Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a75b20740 | ||
|
|
d35d008806 | ||
|
|
f5662d5eb0 | ||
|
|
39010dd255 | ||
|
|
fbaad570c7 | ||
|
|
f974e3b3c1 | ||
|
|
46b49cc176 | ||
|
|
5256e74d0c | ||
|
|
621d6a0b89 | ||
|
|
08be7c8bbe | ||
|
|
980a5472b6 | ||
|
|
51c618e357 | ||
|
|
4dde3786c2 | ||
|
|
d544342602 | ||
|
|
fac91fca2a | ||
|
|
6edf756849 | ||
|
|
4fb1bb4de6 | ||
|
|
6a8eb7daaa | ||
|
|
0544d06c3d | ||
|
|
34c285c9ac | ||
|
|
2f53b27651 | ||
|
|
772677746b | ||
|
|
f0bad87ea6 | ||
|
|
44e71f8c14 | ||
|
|
964b30ca26 | ||
|
|
214a333e2d | ||
|
|
ec6401ab57 | ||
|
|
cbc5e8ce8d | ||
|
|
a1c4cfe8f1 | ||
|
|
3a721e6578 | ||
|
|
e6b716cdde | ||
|
|
02c39998b8 | ||
|
|
0774bc7f14 | ||
|
|
c6a98b3d0b | ||
|
|
981bbf1105 | ||
|
|
2b0c6cfd40 | ||
|
|
59f6bc8306 | ||
|
|
653c4ffb45 | ||
|
|
d947ca258e | ||
|
|
d5ff7f7db9 | ||
|
|
579cef3649 | ||
|
|
cb2f090c60 | ||
|
|
f3d6387bca | ||
|
|
abf9729c61 | ||
|
|
442e9c9f0d | ||
|
|
397fad249d | ||
|
|
9a3c5a3f7c | ||
|
|
950c700274 | ||
|
|
26432c38a9 | ||
|
|
28be50136c | ||
|
|
0c62f2de5d | ||
|
|
5caf654f22 | ||
|
|
205593445e | ||
|
|
f25fb8c63a | ||
|
|
99c78650b6 | ||
|
|
69355886a8 | ||
|
|
08e89e2dbe | ||
|
|
0e013df161 | ||
|
|
9ba4e3ab46 | ||
|
|
5fdcb7602b | ||
|
|
b4db1b741f | ||
|
|
7a8cc21e31 | ||
|
|
0674829d8f | ||
|
|
315aa0474b | ||
|
|
df3451e779 | ||
|
|
3ba42802d1 | ||
|
|
d6342cb8c2 | ||
|
|
065bddbc6c | ||
|
|
067f429dde | ||
|
|
6895c2d70f | ||
|
|
686481982a | ||
|
|
a9e1d19b78 | ||
|
|
f95aa63718 | ||
|
|
855de287b2 | ||
|
|
feeb9f213f | ||
|
|
e7eb8fa805 | ||
|
|
8a747f005a | ||
|
|
16ab4a8b4e | ||
|
|
8d30cff4ef | ||
|
|
59d5b0d1bd | ||
|
|
9ec0745ab8 | ||
|
|
3a3635f7f9 | ||
|
|
6a746a1cbb | ||
|
|
906c130f96 | ||
|
|
4a78458821 | ||
|
|
fddf3ce2f4 | ||
|
|
353b34e695 | ||
|
|
7d63355c3c | ||
|
|
42ff7fc842 | ||
|
|
26470fe16a | ||
|
|
3b9d4b7f0a | ||
|
|
11f53fe9a9 | ||
|
|
123c0c766f | ||
|
|
6a9be2142e | ||
|
|
0bc350f55e | ||
|
|
7a6edf62ba | ||
|
|
07b6f06f11 | ||
|
|
2005f622bb | ||
|
|
cca04fd799 | ||
|
|
75bf8e4ba2 | ||
|
|
daabb5b100 | ||
|
|
035ebea72f | ||
|
|
a499956462 | ||
|
|
74d2a156c4 | ||
|
|
f87fc7b12d | ||
|
|
602f5632cb | ||
|
|
9fbbcf7599 | ||
|
|
9498f01f59 | ||
|
|
2c59aca5a1 | ||
|
|
51301d69c9 | ||
|
|
7e608fd1df | ||
|
|
ecc79315df | ||
|
|
14365d10b8 | ||
|
|
5e5320020f | ||
|
|
103c3e0cd6 | ||
|
|
7a1c89edd9 | ||
|
|
a5ff3d2f42 | ||
|
|
b71d16dd96 | ||
|
|
fd593eb5e9 | ||
|
|
a0b98abb94 | ||
|
|
18353e1e94 | ||
|
|
9adcad84da | ||
|
|
f2714586d8 | ||
|
|
0b6fb62967 | ||
|
|
1db8b0b943 | ||
|
|
f38aebb3d5 | ||
|
|
7162c36d37 | ||
|
|
f4d4ea46c8 | ||
|
|
2fd1a0f178 | ||
|
|
73ed33a086 | ||
|
|
e6095a9949 | ||
|
|
16f05af401 | ||
|
|
1631afc878 | ||
|
|
63d87fc440 | ||
|
|
9489c01259 | ||
|
|
30d92ad83f | ||
|
|
a4987733c4 | ||
|
|
39eee05230 | ||
|
|
5b2f2e6290 | ||
|
|
445617a1a5 | ||
|
|
f6e90a5934 | ||
|
|
43618e6b3f | ||
|
|
e97f89de3b | ||
|
|
11d3e32f1e | ||
|
|
2affa83efe | ||
|
|
c90d5cd84b | ||
|
|
aacaba3d26 | ||
|
|
fec53be841 | ||
|
|
3f7b540f76 | ||
|
|
d217856166 | ||
|
|
e2be457e9b | ||
|
|
4850f486d2 | ||
|
|
729c7febd9 | ||
|
|
6c6aca2f1e | ||
|
|
c69823f496 | ||
|
|
73f8f6aac8 | ||
|
|
d944254e45 | ||
|
|
f7ddffe554 | ||
|
|
8a73ed5d5a | ||
|
|
03669183d7 | ||
|
|
74e101a2fa | ||
|
|
532cf18ad3 | ||
|
|
0b90b697e2 | ||
|
|
6be7c5f7c8 | ||
|
|
db2e5132e6 | ||
|
|
b14f6f778a | ||
|
|
415de77457 | ||
|
|
a9466c4f58 | ||
|
|
d9ae453a63 | ||
|
|
9841e09233 | ||
|
|
0ca314e066 | ||
|
|
d7680cae27 | ||
|
|
491b6bdb1f | ||
|
|
c591f9601a | ||
|
|
8d1e75017e | ||
|
|
94615f7ad4 | ||
|
|
e5df8e1315 | ||
|
|
d739b91aef | ||
|
|
686cfb2539 | ||
|
|
2633716bb7 | ||
|
|
0a07c0a44e | ||
|
|
2ca6e110ca | ||
|
|
334a07c839 | ||
|
|
a57c39358d | ||
|
|
30a0c315fb | ||
|
|
b860f0d94c | ||
|
|
14f4c19f5a | ||
|
|
7ab5c55d46 | ||
|
|
8b6ecd5971 | ||
|
|
7b0871ae4c | ||
|
|
b73af7ce10 | ||
|
|
60645717e2 | ||
|
|
1cbf578538 | ||
|
|
e966c1fceb | ||
|
|
d0133f8641 | ||
|
|
6d30b497dc | ||
|
|
f3b89e66eb | ||
|
|
04154e207c | ||
|
|
9898904be7 | ||
|
|
27d5229842 | ||
|
|
4a9a575ef0 | ||
|
|
52fd9a630d | ||
|
|
a596ccf844 | ||
|
|
e7fa97731f | ||
|
|
290aa28108 | ||
|
|
a95640ed9e | ||
|
|
f69267bb67 | ||
|
|
e36d5a309f | ||
|
|
55566d9830 | ||
|
|
f02ea20678 | ||
|
|
372c22d42b | ||
|
|
949265bbd0 | ||
|
|
916106733c | ||
|
|
44bcafd3aa | ||
|
|
71166f7be8 | ||
|
|
580252a1a0 | ||
|
|
c0b60dae6a | ||
|
|
ae123fd209 | ||
|
|
454ad0acc5 | ||
|
|
0c306ac328 | ||
|
|
52d99732b1 | ||
|
|
5b5827983b | ||
|
|
56f9bc311d | ||
|
|
eb17dc1ecf | ||
|
|
6f8115a052 | ||
|
|
aac913c666 | ||
|
|
b5e73ac4e4 | ||
|
|
9e98c90891 | ||
|
|
ca2592c1d9 | ||
|
|
a31f17bb9d | ||
|
|
1cb46afa94 | ||
|
|
5a759947dd | ||
|
|
db3df13e95 | ||
|
|
2a8bc03167 | ||
|
|
d2297b39d0 | ||
|
|
e4cd081d4d | ||
|
|
d2dbea6cf8 | ||
|
|
46a279a49a | ||
|
|
299f0c4003 | ||
|
|
9ffb45f283 | ||
|
|
cd61c4efd9 | ||
|
|
a06ab2a1c5 | ||
|
|
dfa4ebf1a6 | ||
|
|
58f388c69d | ||
|
|
990b462a94 | ||
|
|
b928dc0808 | ||
|
|
8916955f45 | ||
|
|
82bef40aa6 | ||
|
|
1c45f32941 | ||
|
|
fadc0cf69b | ||
|
|
7ce9d08b2d | ||
|
|
eb3a51e33a | ||
|
|
f3dd733773 | ||
|
|
4dbc5e1dba | ||
|
|
c0637c287e | ||
|
|
6127f7abd6 | ||
|
|
a4059762e6 | ||
|
|
40afcd68a7 | ||
|
|
f238e721ed | ||
|
|
16eb5627a7 | ||
|
|
fbf0674189 | ||
|
|
62c4f65fc3 | ||
|
|
e400112f32 | ||
|
|
7935914f55 | ||
|
|
ad3a1dbbad | ||
|
|
0655f8e7ae | ||
|
|
04a9372584 | ||
|
|
b9646b6f85 | ||
|
|
53c953a561 | ||
|
|
c278fecb34 | ||
|
|
23951c9e38 | ||
|
|
e8ae370ceb | ||
|
|
67be4d1904 | ||
|
|
6f82097d14 | ||
|
|
fc6f959d21 | ||
|
|
e38d569d8f | ||
|
|
0856750ee2 | ||
|
|
05721ba84a | ||
|
|
38c3422e5e | ||
|
|
d153a6f6df | ||
|
|
1a7738a925 | ||
|
|
8985c0dfe9 | ||
|
|
ebfe008432 | ||
|
|
1f16eb6f50 | ||
|
|
cbb0868ae3 | ||
|
|
68bb38d0ad | ||
|
|
0443e87345 | ||
|
|
b3de5833d3 | ||
|
|
95b14ee282 | ||
|
|
07b89e6a19 | ||
|
|
8991d2cb33 | ||
|
|
86a20c4130 | ||
|
|
6827a6efe8 |
@@ -64,6 +64,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
img2pdf \
|
img2pdf \
|
||||||
libsm6 libxext6 libxrender-dev \
|
libsm6 libxext6 libxrender-dev \
|
||||||
pngquant \
|
pngquant \
|
||||||
|
python-is-python3 \
|
||||||
tesseract-ocr \
|
tesseract-ocr \
|
||||||
tesseract-ocr-chi-sim \
|
tesseract-ocr-chi-sim \
|
||||||
tesseract-ocr-deu \
|
tesseract-ocr-deu \
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
FROM alpine:3.18 as base
|
FROM alpine:3.19.1 as base
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: General issues
|
name: Installation, packaging, dependencies
|
||||||
description: Installation, packages, dependencies, "nothing works", test suite failures...
|
description: Installation, packages, dependencies, "nothing works", test suite failures...
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug", "triage"]
|
labels: ["triage"]
|
||||||
assignees:
|
assignees:
|
||||||
- jbarlow83
|
- jbarlow83
|
||||||
body:
|
body:
|
||||||
@@ -9,6 +9,10 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to fill out this bug report!
|
Thanks for taking the time to fill out this bug report!
|
||||||
|
|
||||||
|
If your issue involves using OCRmyPDF on specific file(s) and not getting
|
||||||
|
good results, this is the *wrong* issue template. Please use the recommended
|
||||||
|
template to ensure we have enough information to help.
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
@@ -20,7 +24,7 @@ body:
|
|||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: packaging-system
|
id: packaging-system
|
||||||
attributes:
|
attributes:
|
||||||
label: Where are you installing from?
|
label: Where are you installing/running from?
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- PyPI (pip, poetry, pipx, etc.)
|
- PyPI (pip, poetry, pipx, etc.)
|
||||||
@@ -33,6 +37,11 @@ body:
|
|||||||
- source build
|
- source build
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: OCRmyPDF version
|
||||||
|
description: Paste "ocrmypdf --version" here
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: operating-system
|
id: operating-system
|
||||||
attributes:
|
attributes:
|
||||||
@@ -43,6 +52,18 @@ body:
|
|||||||
- Windows
|
- Windows
|
||||||
- macOS
|
- macOS
|
||||||
- BSD
|
- BSD
|
||||||
|
- type: input
|
||||||
|
id: os_version
|
||||||
|
attributes:
|
||||||
|
label: Operating system details and version
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Simple sanity checks
|
||||||
|
description: Select all that apply
|
||||||
|
options:
|
||||||
|
- label: Operating system is currently supported by its vendor (not end of life)
|
||||||
|
- label: Python version is compatible with OCRmyPDF
|
||||||
|
- label: This issue is not about a specific input file
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Problem with specific file
|
name: Problem with specific file
|
||||||
description: Something went wrong while trying to OCR a specific file
|
description: Something went wrong while trying to OCR a specific file
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug", "triage"]
|
labels: ["triage"]
|
||||||
assignees:
|
assignees:
|
||||||
- jbarlow83
|
- jbarlow83
|
||||||
body:
|
body:
|
||||||
@@ -21,7 +21,7 @@ body:
|
|||||||
id: reproduce
|
id: reproduce
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Steps to reproduce
|
||||||
description: Please include steps to reproduce
|
description: Please include steps to reproduce.
|
||||||
value: |
|
value: |
|
||||||
1. Run ocrmypdf -v1 ...arguments... input.pdf output.pdf
|
1. Run ocrmypdf -v1 ...arguments... input.pdf output.pdf
|
||||||
2. Open output.pdf
|
2. Open output.pdf
|
||||||
@@ -31,8 +31,21 @@ body:
|
|||||||
id: files
|
id: files
|
||||||
attributes:
|
attributes:
|
||||||
label: Files
|
label: Files
|
||||||
description: Please attach the input and output files, or any screenshots that may be helpful.
|
description: |
|
||||||
placeholder: Drag and drop files here
|
Please attach the input and output files, or any screenshots that may be helpful.
|
||||||
|
|
||||||
|
If you cannot provide a test file, we probably won't be able to help with the issue.
|
||||||
|
PDF is a complex file format, and there may be technical details in the PDF that are
|
||||||
|
causing the issue. There's really no substitute for a test file.
|
||||||
|
|
||||||
|
We understand files may contain personal or sensitive information. Here are some options:
|
||||||
|
- Try reproducing the issue with a file from the OCRmyPDF test suite. (See tests/resources)
|
||||||
|
- Try to create another file in the same way as your private file.
|
||||||
|
- Encrypt the file to OCRmyPDF's private GPG key, and then zip the GPG file.
|
||||||
|
- Use ``qpdf --json yourfile.pdf`` to produce a JSON representation of your file that
|
||||||
|
omits personal information.
|
||||||
|
placeholder: |
|
||||||
|
Drag and drop files here.
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: packaging-system
|
id: packaging-system
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Problem with third party app that uses OCRmyPDF
|
||||||
|
description: |
|
||||||
|
For PDF generation issues with third party software such as Paperless-ngx that
|
||||||
|
uses OCRmyPDF to perform OCR or generate PDFs.
|
||||||
|
title: "[3rdparty]: "
|
||||||
|
labels: ["triage"]
|
||||||
|
assignees:
|
||||||
|
- jbarlow83
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to describe this issue with a particular file
|
||||||
|
and third party app.
|
||||||
|
|
||||||
|
If you are comfortable using OCRmyPDF, please trying to install OCRmyPDF,
|
||||||
|
run it on your file, and see if it works. It's easier for everyone
|
||||||
|
if you can confirm that the issue occurs with OCRmyPDF and not with
|
||||||
|
the third party app.
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Simple sanity checks
|
||||||
|
description: Select all that apply
|
||||||
|
options:
|
||||||
|
- label: This is an issue with an app that uses OCRmyPDF for OCR
|
||||||
|
- label: I am using a recent version of the third party app
|
||||||
|
- label: I will include a file that reproduces the issuse
|
||||||
|
- type: input
|
||||||
|
id: thirdparty-app-name-version
|
||||||
|
attributes:
|
||||||
|
label: Third party app name and version
|
||||||
|
description: e.g. Paperless-ngx 2.9.0
|
||||||
|
- 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. Import attached file into Paperless-ngx
|
||||||
|
2. Trigger OCR
|
||||||
|
3. Check log file
|
||||||
|
4. ...
|
||||||
|
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.
|
||||||
|
|
||||||
|
If you cannot provide a test file, we probably won't be able to help with the issue.
|
||||||
|
PDF is a complex file format, and there may be technical details in the PDF that are
|
||||||
|
causing the issue. There's really no substitute for a test file.
|
||||||
|
|
||||||
|
We understand files may contain personal or sensitive information. Here are some options:
|
||||||
|
- Try reproducing the issue with a file from the test suite. (See tests/resources)
|
||||||
|
- Try to create another file in the same way as your private file.
|
||||||
|
- Encrypt the file to OCRmyPDF's private GPG key, and then zip the GPG file.
|
||||||
|
- Use ``qpdf --json yourfile.pdf`` to produce a JSON representation of your file that
|
||||||
|
omits personal information.
|
||||||
|
placeholder: |
|
||||||
|
Drag and drop files here.
|
||||||
|
- 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
|
||||||
+27
-23
@@ -22,20 +22,18 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-22.04
|
|
||||||
python: "3.9"
|
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
python: "3.10"
|
python: "3.10"
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
python: "3.11"
|
python: "3.11"
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
python: "3.9"
|
python: "3.10"
|
||||||
tesseract5: true
|
tesseract5: true
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
python: "3.12"
|
python: "3.12"
|
||||||
tesseract5: true
|
tesseract5: true
|
||||||
#- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
# python: "pypy3.9"
|
python: "pypy3.10"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OS: ${{ matrix.os }}
|
OS: ${{ matrix.os }}
|
||||||
@@ -46,7 +44,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v5
|
||||||
name: Setup Python
|
name: Setup Python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
@@ -55,7 +53,7 @@ jobs:
|
|||||||
- name: Install Tesseract 5
|
- name: Install Tesseract 5
|
||||||
if: matrix.tesseract5
|
if: matrix.tesseract5
|
||||||
run: |
|
run: |
|
||||||
sudo add-apt-repository ppa:alex-p/tesseract-ocr-devel
|
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
|
||||||
|
|
||||||
- name: Install common packages
|
- name: Install common packages
|
||||||
run: |
|
run: |
|
||||||
@@ -63,6 +61,7 @@ jobs:
|
|||||||
sudo apt-get install -y --no-install-recommends \
|
sudo apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
ghostscript \
|
ghostscript \
|
||||||
|
jbig2dec \
|
||||||
img2pdf \
|
img2pdf \
|
||||||
libexempi8 \
|
libexempi8 \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
@@ -102,17 +101,20 @@ jobs:
|
|||||||
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
env_vars: OS,PYTHON
|
env_vars: OS,PYTHON
|
||||||
|
|
||||||
|
|
||||||
test_macos:
|
test_macos:
|
||||||
name: Test macOS
|
name: Test macOS
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-latest]
|
os: [macos-latest, macos-13] # macos-latest is arm64, macos-13 is x86_64
|
||||||
python: ["3.10", "3.11", "3.12"]
|
python: ["3.10", "3.11", "3.12"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -136,7 +138,7 @@ jobs:
|
|||||||
pngquant \
|
pngquant \
|
||||||
tesseract
|
tesseract
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v5
|
||||||
name: Setup Python
|
name: Setup Python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
@@ -159,7 +161,9 @@ jobs:
|
|||||||
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
env_vars: OS,PYTHON
|
env_vars: OS,PYTHON
|
||||||
@@ -181,7 +185,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v5
|
||||||
name: Setup Python
|
name: Setup Python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
@@ -190,7 +194,7 @@ jobs:
|
|||||||
- name: Install system packages
|
- name: Install system packages
|
||||||
run: |
|
run: |
|
||||||
choco install --yes --no-progress --pre tesseract
|
choco install --yes --no-progress --pre tesseract
|
||||||
choco install --yes --no-progress --ignore-checksums ghostscript
|
choco install --yes --no-progress --ignore-checksums ghostscript --version 9.56.1
|
||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
@@ -202,7 +206,9 @@ jobs:
|
|||||||
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
env_vars: OS,PYTHON
|
env_vars: OS,PYTHON
|
||||||
@@ -215,10 +221,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v5
|
||||||
name: Setup Python
|
name: Setup Python
|
||||||
with:
|
with:
|
||||||
python-version: "3.9"
|
python-version: "3.10"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|
||||||
- name: Make wheels and sdist
|
- name: Make wheels and sdist
|
||||||
@@ -226,8 +232,9 @@ jobs:
|
|||||||
python -m pip install --upgrade pip wheel build
|
python -m pip install --upgrade pip wheel build
|
||||||
python -m build --sdist --wheel
|
python -m build --sdist --wheel
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
name: artifact
|
||||||
path: |
|
path: |
|
||||||
./dist/*.whl
|
./dist/*.whl
|
||||||
./dist/*.tar.gz
|
./dist/*.tar.gz
|
||||||
@@ -241,7 +248,7 @@ jobs:
|
|||||||
id-token: write # mandatory for PyPI publishing
|
id-token: write # mandatory for PyPI publishing
|
||||||
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
|
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v3
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: artifact
|
name: artifact
|
||||||
path: dist
|
path: dist
|
||||||
@@ -258,7 +265,7 @@ jobs:
|
|||||||
# Required to create a release
|
# Required to create a release
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v3
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: artifact
|
name: artifact
|
||||||
path: dist
|
path: dist
|
||||||
@@ -352,9 +359,6 @@ jobs:
|
|||||||
username: jbarlow83
|
username: jbarlow83
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -366,6 +370,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--push \
|
--push \
|
||||||
--platform linux/amd64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
--tag "${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}" \
|
--tag "${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}" \
|
||||||
--file .docker/Dockerfile.alpine .
|
--file .docker/Dockerfile.alpine .
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ formats:
|
|||||||
build:
|
build:
|
||||||
os: ubuntu-22.04
|
os: ubuntu-22.04
|
||||||
tools:
|
tools:
|
||||||
python: "3.9"
|
python: "3.10"
|
||||||
|
|
||||||
python:
|
python:
|
||||||
install:
|
install:
|
||||||
|
|||||||
+14
-7
@@ -3,7 +3,6 @@ Upstream-Name: OCRmyPDF
|
|||||||
Upstream-Contact: James R. Barlow <james@purplerock.ca>
|
Upstream-Contact: James R. Barlow <james@purplerock.ca>
|
||||||
Source: https://github.com/ocrmypdf/OCRmyPDF
|
Source: https://github.com/ocrmypdf/OCRmyPDF
|
||||||
|
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
.git_archival.txt
|
.git_archival.txt
|
||||||
docs/images/logo-social.png
|
docs/images/logo-social.png
|
||||||
@@ -11,14 +10,13 @@ Files:
|
|||||||
docs/images/logo-square.png
|
docs/images/logo-square.png
|
||||||
docs/images/logo-square.svg
|
docs/images/logo-square.svg
|
||||||
docs/images/logo.svg
|
docs/images/logo.svg
|
||||||
setup.cfg
|
|
||||||
Copyright: (C) 2022 James R. Barlow
|
Copyright: (C) 2022 James R. Barlow
|
||||||
License: MPL-2.0
|
License: MPL-2.0
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
.github/ISSUE_TEMPLATE/*.md
|
.github/ISSUE_TEMPLATE/*.yml
|
||||||
docs/images/macos-workflow.png
|
docs/images/macos-workflow.png
|
||||||
Copyright: (C) 2022 James R. Barlow
|
Copyright: (C) 2023 James R. Barlow
|
||||||
License: CC-BY-SA-4.0
|
License: CC-BY-SA-4.0
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
@@ -34,15 +32,16 @@ Files:
|
|||||||
tests/resources/invalid.pdf
|
tests/resources/invalid.pdf
|
||||||
tests/resources/kcs.pdf
|
tests/resources/kcs.pdf
|
||||||
tests/resources/livecycle.pdf
|
tests/resources/livecycle.pdf
|
||||||
|
tests/resources/meta.pdf
|
||||||
tests/resources/missing_docinfo.pdf
|
tests/resources/missing_docinfo.pdf
|
||||||
tests/resources/negzero.pdf
|
tests/resources/negzero.pdf
|
||||||
tests/resources/no_contents.pdf
|
tests/resources/no_contents.pdf
|
||||||
|
tests/resources/tagged*
|
||||||
tests/resources/toc.pdf
|
tests/resources/toc.pdf
|
||||||
tests/resources/trivial.pdf
|
tests/resources/trivial.pdf
|
||||||
tests/resources/truetype_font_nomapping.pdf
|
tests/resources/truetype_font_nomapping.pdf
|
||||||
tests/resources/type3_font_nomapping.pdf
|
tests/resources/type3_font_nomapping.pdf
|
||||||
misc/screencast/*
|
Copyright: (C) 2023 James R. Barlow
|
||||||
Copyright: (C) 2022 James R. Barlow
|
|
||||||
License: CC-BY-SA-4.0
|
License: CC-BY-SA-4.0
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
@@ -52,7 +51,6 @@ Copyright: (C) 2012 SmokeyJoe
|
|||||||
License: GFDL-1.2-or-later or CC-BY-SA-3.0
|
License: GFDL-1.2-or-later or CC-BY-SA-3.0
|
||||||
|
|
||||||
Files: tests/resources/c02-22.pdf
|
Files: tests/resources/c02-22.pdf
|
||||||
tests/resources/congress.jpg
|
|
||||||
tests/resources/multipage.pdf
|
tests/resources/multipage.pdf
|
||||||
Copyright: Public domain
|
Copyright: Public domain
|
||||||
License: public-domain
|
License: public-domain
|
||||||
@@ -72,6 +70,7 @@ Files: tests/resources/linn.png
|
|||||||
tests/resources/ccitt.pdf
|
tests/resources/ccitt.pdf
|
||||||
tests/resources/cardinal.pdf
|
tests/resources/cardinal.pdf
|
||||||
tests/resources/jbig2.pdf
|
tests/resources/jbig2.pdf
|
||||||
|
tests/resources/jbig2_baddevicen.pdf
|
||||||
tests/resources/skew.pdf
|
tests/resources/skew.pdf
|
||||||
tests/resources/rotated_skew.pdf
|
tests/resources/rotated_skew.pdf
|
||||||
tests/resources/poster.pdf
|
tests/resources/poster.pdf
|
||||||
@@ -125,6 +124,14 @@ Copyright: Kai-Uwe Behrmann <www.behrmann.name>
|
|||||||
ColorSolutions <www.basICColor.com>
|
ColorSolutions <www.basICColor.com>
|
||||||
License: Zlib
|
License: Zlib
|
||||||
|
|
||||||
|
Files: src/ocrmypdf/data/pdf.ttf
|
||||||
|
Copyright: (C) 2014 Ray Smith
|
||||||
|
(C) 2015 Ken Sharp
|
||||||
|
(C) 2016 James R. Barlow
|
||||||
|
(C) 2016 Jeff Breidenbach
|
||||||
|
(C) 2017 Zdenko Podobný
|
||||||
|
License: Apache-2.0
|
||||||
|
|
||||||
Files: tests/resources/3small.pdf
|
Files: tests/resources/3small.pdf
|
||||||
Copyright: (C) 2014 Euskaldunaa
|
Copyright: (C) 2014 Euskaldunaa
|
||||||
(C) 2017 James R. Barlow
|
(C) 2017 James R. Barlow
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
|
|||||||
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
|
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
|
||||||
| Fedora | ``dnf install ocrmypdf`` |
|
| Fedora | ``dnf install ocrmypdf`` |
|
||||||
| macOS (Homebrew) | ``brew install ocrmypdf`` |
|
| macOS (Homebrew) | ``brew install ocrmypdf`` |
|
||||||
|
| macOS (MacPorts) | ``port install ocrmypdf`` |
|
||||||
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
||||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||||
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
||||||
|
|||||||
+93
-39
@@ -118,15 +118,16 @@ OCR for huge images
|
|||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
Tesseract has internal limits on the size
|
Tesseract has internal limits on the size
|
||||||
of images it will process. If you issue
|
of images it will process. By default,
|
||||||
``--tesseract-downsample-large-images``, OCRmyPDF will downsample images
|
``--tesseract-downsample-large-images`` is enabled, and OCRmyPDF will
|
||||||
to fit Tesseract limits. (The limits are usually entered only for scanned
|
downsample images to fit Tesseract limits. (The limits are usually encountered
|
||||||
images of oversized media, such as large maps or blueprints exceeding
|
only for scanned images of oversized media, such as large maps or blueprints exceeding
|
||||||
110 cm or 43 inches in either dimension, and at high DPI.)
|
110 cm or 43 inches in either dimension, and at high DPI.) This feature can disabled
|
||||||
|
using ``--no-tesseract-downsample-large-images``.
|
||||||
|
|
||||||
``--tesseract-downsample-above Npixels`` adjusts the threshold at which images
|
``--tesseract-downsample-above Npixels`` adjusts the threshold at which images
|
||||||
will be downsampled. By default, only images that exceed any of Tesseract's
|
will be downsampled. By default, only images that exceed any of Tesseract's
|
||||||
internal limits are downsampled.
|
internal limits are downsampled (32767 pixels on either dimension).
|
||||||
|
|
||||||
You will also need to set ``--tesseract-timeout`` high enough to allow
|
You will also need to set ``--tesseract-timeout`` high enough to allow
|
||||||
for processing.
|
for processing.
|
||||||
@@ -227,6 +228,59 @@ then run ocrmypdf as follows (along with any other desired arguments):
|
|||||||
Some combinations of control parameters will break Tesseract or break
|
Some combinations of control parameters will break Tesseract or break
|
||||||
assumptions that OCRmyPDF makes about Tesseract's output.
|
assumptions that OCRmyPDF makes about Tesseract's output.
|
||||||
|
|
||||||
|
Changing page segmentation mode
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
The directive ``--tesseract-pagesegmode Nmode`` forwards the desired page segmentation
|
||||||
|
mode to Tesseract OCR. The default is 3.
|
||||||
|
|
||||||
|
Page segmentation can improve OCR results when you know that a PDF ought to be
|
||||||
|
analyzed a particular way, such as PDFs whose pages contain only a single line of
|
||||||
|
text. For the vast majority of users, changing the page segmentation mode will only
|
||||||
|
make things worse.
|
||||||
|
|
||||||
|
As of June 2024, the Tesseract page segmentation modes are:
|
||||||
|
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| ID | Description |
|
||||||
|
+=====+==================================================================================+
|
||||||
|
| 0 | Orientation and script detection (OSD) only. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 1 | Automatic page segmentation with OSD. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 2 | Automatic page segmentation, but no OSD, or OCR. (not implemented) |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 3 | Fully automatic page segmentation, but no OSD. (Default) |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 4 | Assume a single column of text of variable sizes. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 5 | Assume a single uniform block of vertically aligned text. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 6 | Assume a single uniform block of text. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 7 | Treat the image as a single text line. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 8 | Treat the image as a single word. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 9 | Treat the image as a single word in a circle. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 10 | Treat the image as a single character. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 11 | Sparse text. Find as much text as possible in no particular order. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 12 | Sparse text with OSD. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
| 13 | Raw line. Treat the image as a single text line, bypassing hacks that are |
|
||||||
|
| | Tesseract-specific. |
|
||||||
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
Modes 0, 1, 2, and 12 (all of those that enable orientation and script detection)
|
||||||
|
are not compatible with OCRmyPDF, which performs OSD in a separate step from OCR.
|
||||||
|
Their use may interfere with ``--rotate-pages`` and other features.
|
||||||
|
|
||||||
|
It is currently not possible to use advanced Tesseract OCR features, such as creating
|
||||||
|
OCR information, when using Tesseract through OCRmyPDF.
|
||||||
|
|
||||||
Changing the PDF renderer
|
Changing the PDF renderer
|
||||||
=========================
|
=========================
|
||||||
|
|
||||||
@@ -239,46 +293,46 @@ rendering
|
|||||||
OCRmyPDF has these PDF renderers: ``sandwich`` and ``hocr``. The
|
OCRmyPDF has these PDF renderers: ``sandwich`` and ``hocr``. The
|
||||||
renderer may be selected using ``--pdf-renderer``. The default is
|
renderer may be selected using ``--pdf-renderer``. The default is
|
||||||
``auto`` which lets OCRmyPDF select the renderer to use. Currently,
|
``auto`` which lets OCRmyPDF select the renderer to use. Currently,
|
||||||
``auto`` always selects ``sandwich``.
|
``auto`` always selects ``hocr``.
|
||||||
|
|
||||||
The ``sandwich`` renderer
|
|
||||||
-------------------------
|
|
||||||
|
|
||||||
The ``sandwich`` renderer uses Tesseract's new text-only PDF feature,
|
|
||||||
which produces a PDF page that lays out the OCR in invisible text. This
|
|
||||||
page is then "sandwiched" onto the original PDF page, allowing lossless
|
|
||||||
application of OCR even to PDF pages that contain other vector objects.
|
|
||||||
|
|
||||||
Currently this is the best renderer for most uses, however it is
|
|
||||||
implemented in Tesseract so OCRmyPDF cannot influence it. Currently some
|
|
||||||
problematic PDF viewers like Mozilla PDF.js and macOS Preview have
|
|
||||||
problems with segmenting its text output, and
|
|
||||||
mightrunseveralwordstogether.
|
|
||||||
|
|
||||||
When image preprocessing features like ``--deskew`` are used, the
|
|
||||||
original PDF will be rendered as a full page and the OCR layer will be
|
|
||||||
placed on top.
|
|
||||||
|
|
||||||
The ``hocr`` renderer
|
The ``hocr`` renderer
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
The ``hocr`` renderer works with older versions of Tesseract. The image
|
.. versionchanged:: 16.0.0
|
||||||
layer is copied from the original PDF page if possible, avoiding
|
|
||||||
potentially lossy transcoding or loss of other PDF information. If
|
|
||||||
preprocessing is specified, then the image layer is a new PDF. (You may
|
|
||||||
need to disable PDF/A conversion nad optimization to eliminate all
|
|
||||||
lossy transformations.)
|
|
||||||
|
|
||||||
Unlike ``sandwich`` this renderer is implemented within OCRmyPDF; anyone
|
In both renderers, a text-only layer is rendered and sandwiched (overlaid)
|
||||||
looking to customize how OCR is presented should look here. A major
|
on to either the original PDF page, or newly rasterized version of the
|
||||||
disadvantage of this renderer is it not capable of correctly handling
|
original PDF page (when ``--force-ocr`` is used). In this way, loss
|
||||||
text outside the Latin alphabet (specifically, it supports the ISO 8859-1
|
of PDF information is generally avoided. (You may need to disable PDF/A
|
||||||
character set). Pull requests to improve the situation are welcome.
|
conversion and optimization to eliminate all lossy transformations.)
|
||||||
|
|
||||||
Currently, this renderer has the best compatibility with Mozilla's
|
The current approach used by the new hOCR renderer is a re-implementation
|
||||||
PDF.js viewer.
|
of Tesseract's PDF renderer, using the same Glyphless font and general
|
||||||
|
ideas, but fixing many technical issues that impeded it. The new hocr
|
||||||
|
provides better text placement accuracy, avoids issues with word
|
||||||
|
segmentation, and provides better positioning of skewed text.
|
||||||
|
|
||||||
This works in all versions of Tesseract.
|
Using the experimental API, it is also possible to edit the OCR output
|
||||||
|
from Tesseract, using any tool that is capable of editing hOCR files.
|
||||||
|
|
||||||
|
Older versions of this renderer did not support non-Latin languages, but
|
||||||
|
it is now universal.
|
||||||
|
|
||||||
|
The ``sandwich`` renderer
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
The ``sandwich`` renderer uses Tesseract's text-only PDF feature,
|
||||||
|
which produces a PDF page that lays out the OCR in invisible text.
|
||||||
|
|
||||||
|
Currently some problematic PDF viewers like Mozilla PDF.js and macOS
|
||||||
|
Preview have problems with segmenting its text output, and
|
||||||
|
mightrunseveralwordstogether. It also does not implement right to left
|
||||||
|
fonts (Arabic, Hebrew, Persian). The output of this renderer cannot
|
||||||
|
be edited. The sandwich renderer is retained for testing.
|
||||||
|
|
||||||
|
When image preprocessing features like ``--deskew`` are used, the
|
||||||
|
original PDF will be rendered as a full page and the OCR layer will be
|
||||||
|
placed on top.
|
||||||
|
|
||||||
Rendering and rasterizing options
|
Rendering and rasterizing options
|
||||||
=================================
|
=================================
|
||||||
|
|||||||
+3
-12
@@ -112,7 +112,9 @@ OCRmyPDF is strict about not writing to standard output so that
|
|||||||
users can safely use it in a pipeline and produce a valid output
|
users can safely use it in a pipeline and produce a valid output
|
||||||
file. A caller application will have to ensure it does not write to
|
file. A caller application will have to ensure it does not write to
|
||||||
standard output either, if it wants to be compatible with this
|
standard output either, if it wants to be compatible with this
|
||||||
behavior and support piping to a file.
|
behavior and support piping to a file. Another benefit of running
|
||||||
|
OCRmyPDF in a child process, as recommended above, is that it will
|
||||||
|
not interfere with the parent process's standard output.
|
||||||
|
|
||||||
Exceptions
|
Exceptions
|
||||||
----------
|
----------
|
||||||
@@ -124,14 +126,3 @@ handler. OCRmyPDF will clean up its temporary files and worker processes
|
|||||||
automatically when an exception occurs.
|
automatically when an exception occurs.
|
||||||
|
|
||||||
When OCRmyPDF succeeds conditionally, it returns an integer exit code.
|
When OCRmyPDF succeeds conditionally, it returns an integer exit code.
|
||||||
|
|
||||||
Reference
|
|
||||||
---------
|
|
||||||
|
|
||||||
.. autofunction:: ocrmypdf.ocr
|
|
||||||
|
|
||||||
.. autoclass:: ocrmypdf.Verbosity
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autofunction:: ocrmypdf.configure_logging
|
|
||||||
|
|||||||
+14
-2
@@ -3,11 +3,11 @@
|
|||||||
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
|
|
||||||
=============
|
=============
|
||||||
API Reference
|
API reference
|
||||||
=============
|
=============
|
||||||
|
|
||||||
This page summarizes the rest of the public API. Generally speaking this
|
This page summarizes the rest of the public API. Generally speaking this
|
||||||
should mainly of interest to plugin developers.
|
should be mainly of interest to plugin developers.
|
||||||
|
|
||||||
ocrmypdf
|
ocrmypdf
|
||||||
========
|
========
|
||||||
@@ -18,6 +18,18 @@ ocrmypdf
|
|||||||
.. autoclass:: ocrmypdf.PdfContext
|
.. autoclass:: ocrmypdf.PdfContext
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
.. autoclass:: ocrmypdf.Verbosity
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
.. autofunction:: ocrmypdf.configure_logging
|
||||||
|
|
||||||
|
.. autofunction:: ocrmypdf.ocr
|
||||||
|
|
||||||
|
.. autofunction:: ocrmypdf.pdf_to_hocr
|
||||||
|
|
||||||
|
.. autofunction:: ocrmypdf.hocr_to_ocr_pdf
|
||||||
|
|
||||||
ocrmypdf.exceptions
|
ocrmypdf.exceptions
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
.. SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
..
|
||||||
|
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
|
|
||||||
|
|
||||||
.. _ocr-service:
|
.. _ocr-service:
|
||||||
|
|
||||||
==================
|
==================
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Code style
|
|||||||
==========
|
==========
|
||||||
|
|
||||||
We use PEP8, ``black`` for code formatting and ``ruff`` for everything else. 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
|
settings for these programs are in ``pyproject.toml``. Pull
|
||||||
requests should follow the style guide. One difference we use from "black" style
|
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
|
is that strings shown to the user are always in double quotes (``"``) and strings
|
||||||
for internal uses are in single quotes (``'``).
|
for internal uses are in single quotes (``'``).
|
||||||
|
|||||||
+16
-2
@@ -187,7 +187,7 @@ might remove desirable content, especially from poor quality scans.
|
|||||||
background from grayscale or color images. Monochrome images are
|
background from grayscale or color images. Monochrome images are
|
||||||
ignored. This should not be used on documents that contain color
|
ignored. This should not be used on documents that contain color
|
||||||
photos as it may remove them.
|
photos as it may remove them.
|
||||||
- ``--deskew`` will correct pages were scanned at a skewed angle by
|
- ``--deskew`` will correct pages that were scanned at a skewed angle by
|
||||||
rotating them back into place.
|
rotating them back into place.
|
||||||
- ``--clean`` uses
|
- ``--clean`` uses
|
||||||
`unpaper <https://www.flameeyes.eu/projects/unpaper>`__ to clean up
|
`unpaper <https://www.flameeyes.eu/projects/unpaper>`__ to clean up
|
||||||
@@ -245,6 +245,20 @@ if all you want to is to apply image processing or PDF/A conversion.
|
|||||||
the case. Use ``--tesseract-non-ocr-timeout`` to control the timeout
|
the case. Use ``--tesseract-non-ocr-timeout`` to control the timeout
|
||||||
for non-OCR operations, if needed.
|
for non-OCR operations, if needed.
|
||||||
|
|
||||||
|
Remove all text or OCR from my PDF
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
This is getting ridiculous, but OCRmyPDF can complete strip all textual
|
||||||
|
information from a PDF and reconstruct it as a "bag of images" PDF.
|
||||||
|
|
||||||
|
.. code-block::
|
||||||
|
|
||||||
|
ocrmypdf --tesseract-timeout 0 --force-ocr input.pdf output.pdf
|
||||||
|
|
||||||
|
Why would you want to do this? Perhaps you have a PDF where OCR
|
||||||
|
fails to produce useful results, and just want to get rid of all OCR information.
|
||||||
|
This command also removes OCR generated by third party tools.
|
||||||
|
|
||||||
Optimize images without performing OCR
|
Optimize images without performing OCR
|
||||||
--------------------------------------
|
--------------------------------------
|
||||||
|
|
||||||
@@ -393,4 +407,4 @@ any digital signatures will be invalidated.
|
|||||||
OCRmyPDF cannot open documents that are encrypted with a digital certificate.
|
OCRmyPDF cannot open documents that are encrypted with a digital certificate.
|
||||||
|
|
||||||
Versions of OCRmyPDF prior to 14.4.0 would invalidate existing digital signatures
|
Versions of OCRmyPDF prior to 14.4.0 would invalidate existing digital signatures
|
||||||
without warning.
|
without warning.
|
||||||
|
|||||||
+8
-8
@@ -12,12 +12,12 @@ OCRmyPDF is also available in Docker images that packages recent
|
|||||||
versions of all dependencies.
|
versions of all dependencies.
|
||||||
|
|
||||||
For users who already have Docker installed this may be an easy and
|
For users who already have Docker installed this may be an easy and
|
||||||
convenient option. However, it is less performant than a system
|
convenient option.
|
||||||
installation and may require Docker engine configuration. OCRmyPDF
|
|
||||||
needs a generous amount of RAM, CPU cores, temporary storage
|
On platforms other than Linux, Docker runs in a virtual machine, and so may
|
||||||
space, whether running in a Docker container or on its own. It may be
|
be less performant. You may also want to adjust the Docker virtual machine's
|
||||||
necessary to ensure the container is provisioned with additional
|
memory and CPU allocation. On Linux, the Docker image runs natively and
|
||||||
resources.
|
performance is comparable to a system installation.
|
||||||
|
|
||||||
.. _docker-install:
|
.. _docker-install:
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ execute the image:
|
|||||||
- Architecture
|
- Architecture
|
||||||
- Description
|
- Description
|
||||||
* - ``jbarlow83/ocrmypdf-alpine``
|
* - ``jbarlow83/ocrmypdf-alpine``
|
||||||
- x86_64 only
|
- x86_64 and arm64
|
||||||
- Recommended image, based on Alpine Linux.
|
- Recommended image, based on Alpine Linux.
|
||||||
* - ``jbarlow83/ocrmypdf-ubuntu``
|
* - ``jbarlow83/ocrmypdf-ubuntu``
|
||||||
- x86_64 and arm64
|
- x86_64 and arm64
|
||||||
@@ -81,7 +81,7 @@ To start a Docker container (instance of the image):
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
docker tag jbarlow83/ocrmypdf ocrmypdf
|
docker tag jbarlow83/ocrmypdf-alpine ocrmypdf
|
||||||
docker run --rm -i ocrmypdf (... all other arguments here...) - -
|
docker run --rm -i ocrmypdf (... all other arguments here...) - -
|
||||||
|
|
||||||
For convenience, create a shell alias to hide the Docker command. It is
|
For convenience, create a shell alias to hide the Docker command. It is
|
||||||
|
|||||||
+87
-49
@@ -23,7 +23,9 @@ These platforms have one-liner installs:
|
|||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| Fedora | ``dnf install ocrmypdf tesseract-osd`` |
|
| Fedora | ``dnf install ocrmypdf tesseract-osd`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| macOS | ``brew install ocrmypdf`` |
|
| macOS (Homebrew) | ``brew install ocrmypdf`` |
|
||||||
|
+-------------------------------+-----------------------------------------+
|
||||||
|
| macOS (MacPorts) | ``port install ocrmypdf`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
@@ -99,12 +101,12 @@ For full details on version availability for your platform, check the
|
|||||||
Fedora
|
Fedora
|
||||||
------
|
------
|
||||||
|
|
||||||
.. |fedora-37| image:: https://repology.org/badge/version-for-repo/fedora_37/ocrmypdf.svg
|
|
||||||
:alt: Fedora 37
|
|
||||||
|
|
||||||
.. |fedora-38| image:: https://repology.org/badge/version-for-repo/fedora_38/ocrmypdf.svg
|
.. |fedora-38| image:: https://repology.org/badge/version-for-repo/fedora_38/ocrmypdf.svg
|
||||||
:alt: Fedora 38
|
:alt: Fedora 38
|
||||||
|
|
||||||
|
.. |fedora-39| image:: https://repology.org/badge/version-for-repo/fedora_39/ocrmypdf.svg
|
||||||
|
:alt: Fedora 39
|
||||||
|
|
||||||
.. |fedora-rawhide| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
.. |fedora-rawhide| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
||||||
:alt: Fedore Rawhide
|
:alt: Fedore Rawhide
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ Fedora
|
|||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| |latest| |
|
| |latest| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| |fedora-37| |fedora-38| |fedora-rawhide| |
|
| |fedora-38| |fedora-39| |fedora-rawhide| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
|
|
||||||
Users of Fedora may simply
|
Users of Fedora may simply
|
||||||
@@ -123,7 +125,7 @@ Users of Fedora may simply
|
|||||||
dnf install ocrmypdf tesseract-osd
|
dnf install ocrmypdf tesseract-osd
|
||||||
|
|
||||||
For full details on version availability, check the `Fedora Package
|
For full details on version availability, check the `Fedora Package
|
||||||
Tracker <https://apps.fedoraproject.org/packages/ocrmypdf>`__.
|
Tracker <https://packages.fedoraproject.org/pkgs/ocrmypdf/ocrmypdf/>`__.
|
||||||
|
|
||||||
If the version available for your platform is out of date, you could opt
|
If the version available for your platform is out of date, you could opt
|
||||||
to install the latest version from source. See `Installing HEAD revision
|
to install the latest version from source. See `Installing HEAD revision
|
||||||
@@ -135,10 +137,36 @@ from sources <#installing-head-revision-from-sources>`__.
|
|||||||
issues. OCRmyPDF works fine without it but will produce larger output
|
issues. OCRmyPDF works fine without it but will produce larger output
|
||||||
files. If you build jbig2enc from source, ocrmypdf 7.0.0 and later
|
files. If you build jbig2enc from source, ocrmypdf 7.0.0 and later
|
||||||
will automatically detect it on the ``PATH``. To add JBIG2 encoding,
|
will automatically detect it on the ``PATH``. To add JBIG2 encoding,
|
||||||
see `Installing the JBIG2 encoder <jbig2>`__.
|
see :ref:`Installing the JBIG2 encoder <jbig2>`.
|
||||||
|
|
||||||
.. _ubuntu-lts-latest:
|
.. _ubuntu-lts-latest:
|
||||||
|
|
||||||
|
RHEL 9
|
||||||
|
------
|
||||||
|
|
||||||
|
Prepare the environment by getting Python 3.11:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
dnf install python3.11 python3.11-pip
|
||||||
|
|
||||||
|
Then, follow `Requirements for pip and HEAD install <#requirements-for-pip-and-head-install>`__ to instal dependencies:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
dnf install ghostscript tesseract
|
||||||
|
|
||||||
|
and build ocrmypdf in virtual environment:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
python3.11 -m venv .venv
|
||||||
|
|
||||||
|
To add JBIG2 encoding, see :ref:`Installing the JBIG2 encoder <jbig2>`.
|
||||||
|
|
||||||
|
Note Fedora packages for language data haven't been branched for RHEL/EPEL, but you can get traineddata files directly from `tesseract
|
||||||
|
<https://github.com/tesseract-ocr/tessdata/>`__ and place them in ``/usr/share/tesseract/tessdata``.
|
||||||
|
|
||||||
Installing the latest version on Ubuntu 22.04 LTS
|
Installing the latest version on Ubuntu 22.04 LTS
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
@@ -162,37 +190,13 @@ To add JBIG2 encoding, see :ref:`jbig2`.
|
|||||||
Ubuntu 20.04 LTS
|
Ubuntu 20.04 LTS
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
Ubuntu 20.04 includes ocrmypdf 9.6.0 - you can install that with ``apt``. To
|
Ubuntu 20.04 includes ocrmypdf 9.6.0 - you can install that with ``apt``. The
|
||||||
install a more recent version, uninstall the system-provided version of
|
most convenient way to install recent OCRmyPDF on older Ubuntu is to use
|
||||||
ocrmypdf, and install the following dependencies:
|
Homebrew on Linux (Linuxbrew).
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
sudo apt-get -y remove ocrmypdf # remove system ocrmypdf, if installed
|
brew install ocrmypdf
|
||||||
sudo apt-get -y update
|
|
||||||
sudo apt-get -y install \
|
|
||||||
ghostscript \
|
|
||||||
icc-profiles-free \
|
|
||||||
libxml2 \
|
|
||||||
pngquant \
|
|
||||||
python3-pip \
|
|
||||||
tesseract-ocr \
|
|
||||||
zlib1g
|
|
||||||
|
|
||||||
To install ocrmypdf for the system:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
pip3 install ocrmypdf
|
|
||||||
|
|
||||||
To install for the current user only:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
export PATH=$HOME/.local/bin:$PATH
|
|
||||||
pip3 install --user ocrmypdf
|
|
||||||
|
|
||||||
To add JBIG2 encoding, see :ref:`jbig2`.
|
|
||||||
|
|
||||||
Arch Linux (AUR)
|
Arch Linux (AUR)
|
||||||
----------------
|
----------------
|
||||||
@@ -215,12 +219,12 @@ you are using a VM image, such as `the official Vagrant image
|
|||||||
be completed for you.
|
be completed for you.
|
||||||
|
|
||||||
Next you should install the `base-devel package group
|
Next you should install the `base-devel package group
|
||||||
<https://www.archlinux.org/groups/x86_64/base-devel/>`__. This includes the
|
<https://archlinux.org/packages/core/any/base-devel/>`__. This includes the
|
||||||
standard tooling needed to build packages, such as a compiler and binary tools.
|
standard tooling needed to build packages, such as a compiler and binary tools.
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
sudo pacman -S base-devel
|
sudo pacman -S --needed base-devel
|
||||||
|
|
||||||
Now you are ready to install the OCRmyPDF package.
|
Now you are ready to install the OCRmyPDF package.
|
||||||
|
|
||||||
@@ -258,7 +262,7 @@ page.
|
|||||||
<https://aur.archlinux.org/packages/jbig2enc-git/>`__ and may be installed
|
<https://aur.archlinux.org/packages/jbig2enc-git/>`__ and may be installed
|
||||||
using the same series of steps as for the installation OCRmyPDF AUR
|
using the same series of steps as for the installation OCRmyPDF AUR
|
||||||
package. Alternatively, it may be built manually from source following the
|
package. Alternatively, it may be built manually from source following the
|
||||||
instructions in `Installing the JBIG2 encoder <jbig2>`__. If JBIG2 is
|
instructions in :ref:`Installing the JBIG2 encoder <jbig2>`. If JBIG2 is
|
||||||
installed, OCRmyPDF 7.0.0 and later will automatically detect it.
|
installed, OCRmyPDF 7.0.0 and later will automatically detect it.
|
||||||
|
|
||||||
Alpine Linux
|
Alpine Linux
|
||||||
@@ -323,6 +327,22 @@ languages you can optionally install them all:
|
|||||||
|
|
||||||
brew install tesseract-lang # Optional: Install all language packs
|
brew install tesseract-lang # Optional: Install all language packs
|
||||||
|
|
||||||
|
MacPorts
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fports.macports.org%2Fapi%2Fv1%2Fports%2Focrmypdf%2F%3Fformat%3Djson&query=version&label=MacPorts
|
||||||
|
:alt: Macports Version Information
|
||||||
|
:target: https://ports.macports.org/port/ocrmypdf
|
||||||
|
|
||||||
|
OCRmyPDF is includes in MacPorts:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
sudo port install ocrmypdf
|
||||||
|
|
||||||
|
Note that while this will install tesseract you will need to install
|
||||||
|
the appropriate tesseract `language ports <https://ports.macports.org/search/?selected_facets=categories_exact%3Atextproc&installed_file=&q=tesseract&name=on>`__.
|
||||||
|
|
||||||
Manual installation on macOS
|
Manual installation on macOS
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
@@ -391,18 +411,20 @@ package manager:
|
|||||||
|
|
||||||
* ``winget install -e --id Python.Python.3.11``
|
* ``winget install -e --id Python.Python.3.11``
|
||||||
* ``winget install -e --id UB-Mannheim.TesseractOCR``
|
* ``winget install -e --id UB-Mannheim.TesseractOCR``
|
||||||
* ``winget install -e --id ArtifexSoftware.GhostScript``
|
|
||||||
|
|
||||||
|
You will need to install Ghostscript manually, `since it does not support automated
|
||||||
|
installs anymore <https://artifex.com/news/ghostscript-10.01.0-disabling-silent-install-option>`_.
|
||||||
|
|
||||||
|
* `Ghostscript download page <https://ghostscript.com/releases/gsdnld.html>`_.`
|
||||||
|
|
||||||
(Or alternately, using the `Chocolatey <https://chocolatey.org/>`_ package manager, install
|
(Or alternately, using the `Chocolatey <https://chocolatey.org/>`_ package manager, install
|
||||||
the following when running in an Administrator command prompt):
|
the following when running in an Administrator command prompt):
|
||||||
|
|
||||||
* ``choco install python3``
|
* ``choco install python3``
|
||||||
* ``choco install --pre tesseract``
|
* ``choco install --pre tesseract``
|
||||||
* ``choco install ghostscript``
|
|
||||||
* ``choco install pngquant`` (optional)
|
* ``choco install pngquant`` (optional)
|
||||||
|
|
||||||
Either set of commands will install the required software. At the mmoment there is no
|
Either set of commands will install the required software. At the moment there is no
|
||||||
single command to install Windows.
|
single command to install Windows.
|
||||||
|
|
||||||
You may then use ``pip`` to install ocrmypdf. (This can performed by a user or
|
You may then use ``pip`` to install ocrmypdf. (This can performed by a user or
|
||||||
@@ -452,7 +474,7 @@ Cygwin64
|
|||||||
|
|
||||||
First install the the following prerequisite Cygwin packages using ``setup-x86_64.exe``::
|
First install the the following prerequisite Cygwin packages using ``setup-x86_64.exe``::
|
||||||
|
|
||||||
python39 (or later)
|
python310 (or later)
|
||||||
python3?-devel
|
python3?-devel
|
||||||
python3?-pip
|
python3?-pip
|
||||||
python3?-lxml
|
python3?-lxml
|
||||||
@@ -538,9 +560,25 @@ try:
|
|||||||
|
|
||||||
pip install --user ocrmypdf
|
pip install --user ocrmypdf
|
||||||
|
|
||||||
|
(If the message appears ``Requirement already satisfied: ocrmypdf in...``,
|
||||||
|
you will need to use ``pip install --user --upgrade ocrmypdf``.)
|
||||||
|
|
||||||
You should then be able to run ``ocrmypdf --version`` and see that the
|
You should then be able to run ``ocrmypdf --version`` and see that the
|
||||||
latest version was located.
|
latest version was located.
|
||||||
|
|
||||||
|
Installing with pipx
|
||||||
|
====================
|
||||||
|
|
||||||
|
Some users may prefer pipx. As with the method above, you will need to
|
||||||
|
satisfy all non-Python dependencies. Then if pipx is installed, you
|
||||||
|
can use
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
pipx run ocrmypdf
|
||||||
|
|
||||||
|
(If not installed, pipx will install first.)
|
||||||
|
|
||||||
Requirements for pip and HEAD install
|
Requirements for pip and HEAD install
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
||||||
@@ -550,8 +588,8 @@ manager. ``pip`` cannot provide them.
|
|||||||
|
|
||||||
The following versions are required:
|
The following versions are required:
|
||||||
|
|
||||||
- Python 3.9 or newer
|
- Python 3.10 or newer
|
||||||
- Ghostscript 9.55 or newer
|
- Ghostscript 9.54 or newer
|
||||||
- Tesseract 4.1.1 or newer
|
- Tesseract 4.1.1 or newer
|
||||||
- jbig2enc 0.29 or newer
|
- jbig2enc 0.29 or newer
|
||||||
- pngquant 2.5 or newer
|
- pngquant 2.5 or newer
|
||||||
@@ -586,7 +624,7 @@ unfortunately, the ``pip install`` command cannot satisfy all of them.
|
|||||||
Installing HEAD revision from sources
|
Installing HEAD revision from sources
|
||||||
=====================================
|
=====================================
|
||||||
|
|
||||||
If you have ``git`` and Python 3.9 or newer installed, you can install
|
If you have ``git`` and Python 3.10 or newer installed, you can install
|
||||||
from source. When the ``pip`` installer runs, it will alert you if
|
from source. When the ``pip`` installer runs, it will alert you if
|
||||||
dependencies are missing.
|
dependencies are missing.
|
||||||
|
|
||||||
@@ -603,7 +641,7 @@ environment:
|
|||||||
pip install git+https://github.com/ocrmypdf/OCRmyPDF.git
|
pip install git+https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
|
|
||||||
Or, to install in `development
|
Or, to install in `development
|
||||||
mode <https://pythonhosted.org/setuptools/setuptools.html#development-mode>`__,
|
mode <https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#working-in-development-mode>`__,
|
||||||
allowing customization of OCRmyPDF, use the ``-e`` flag:
|
allowing customization of OCRmyPDF, use the ``-e`` flag:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
@@ -643,7 +681,7 @@ To install all of the development and test requirements:
|
|||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
python -m .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
cd OCRmyPDF
|
cd OCRmyPDF
|
||||||
pip install -e .[test]
|
pip install -e .[test]
|
||||||
@@ -669,7 +707,7 @@ To manually install the ``fish`` completion, copy
|
|||||||
Note on 32-bit support
|
Note on 32-bit support
|
||||||
======================
|
======================
|
||||||
|
|
||||||
Many Python libraries no longer 32-bit binary wheels for Linux. This
|
Many Python libraries no longer provide 32-bit binary wheels for Linux. This
|
||||||
includes many of the libraries that OCRmyPDF depends on, such as
|
includes many of the libraries that OCRmyPDF depends on, such as
|
||||||
Pillow. The easiest way to express this to end users is to say we don't
|
Pillow. The easiest way to express this to end users is to say we don't
|
||||||
support 32-bit Linux.
|
support 32-bit Linux.
|
||||||
@@ -679,4 +717,4 @@ can still install and use OCRmyPDF. A warning message will appear.
|
|||||||
In practice, OCRmyPDF may need more than 32-bit memory space to run when
|
In practice, OCRmyPDF may need more than 32-bit memory space to run when
|
||||||
large documents are processed, so there are practical limitations to what
|
large documents are processed, so there are practical limitations to what
|
||||||
users can accomplish with it. Still, for the common use case of an 32-bit
|
users can accomplish with it. Still, for the common use case of an 32-bit
|
||||||
ARM NAS or Raspberry Pi processing small documents, it should work.
|
ARM NAS or Raspberry Pi processing small documents, it should work.
|
||||||
|
|||||||
+107
-124
@@ -6,23 +6,23 @@
|
|||||||
Introduction
|
Introduction
|
||||||
============
|
============
|
||||||
|
|
||||||
OCRmyPDF is an application and library that adds text "layers" to images
|
OCRmyPDF is a Python application and library that adds text "layers" to images in
|
||||||
in PDFs, making scanned image PDFs searchable. It uses OCR to guess what text
|
PDFs, making scanned image PDFs searchable. It uses OCR to guess the text
|
||||||
is contained in images. It is written in Python. OCRmyPDF supports plugins
|
contained in images. OCRmyPDF also supports plugins
|
||||||
that allow customization of its processing steps, and is very tolerant of
|
that enable customization of its processing steps, and it is highly tolerant
|
||||||
PDFs that contain scanned images and "born digital" content that needs no
|
of PDFs containing scanned images and "born digital" content that doesn't
|
||||||
text recognition.
|
require text recognition.
|
||||||
|
|
||||||
About OCR
|
About OCR
|
||||||
=========
|
=========
|
||||||
|
|
||||||
`Optical character
|
`Optical character
|
||||||
recognition <https://en.wikipedia.org/wiki/Optical_character_recognition>`__
|
recognition <https://en.wikipedia.org/wiki/Optical_character_recognition>`__
|
||||||
is technology that converts images of typed or handwritten text, such as
|
is a technology that converts images of typed or handwritten text, such as
|
||||||
in a scanned document, to computer text that can be selected, searched and copied.
|
in a scanned document, into computer text that can be selected, searched and copied.
|
||||||
|
|
||||||
OCRmyPDF uses
|
OCRmyPDF uses
|
||||||
`Tesseract <https://github.com/tesseract-ocr/tesseract>`__, the best
|
`Tesseract <https://github.com/tesseract-ocr/tesseract>`__, a widely
|
||||||
available open source OCR engine, to perform OCR.
|
available open source OCR engine, to perform OCR.
|
||||||
|
|
||||||
.. _raster-vector:
|
.. _raster-vector:
|
||||||
@@ -30,19 +30,19 @@ available open source OCR engine, to perform OCR.
|
|||||||
About PDFs
|
About PDFs
|
||||||
==========
|
==========
|
||||||
|
|
||||||
PDFs are page description files that attempts to preserve a layout
|
PDFs are page description files that attempt to preserve a layout
|
||||||
exactly. They contain `vector
|
exactly. They contain `vector
|
||||||
graphics <http://vector-conversions.com/vectorizing/raster_vs_vector.html>`__
|
graphics <http://vector-conversions.com/vectorizing/raster_vs_vector.html>`__
|
||||||
that can contain raster objects such as scanned images. Because PDFs can
|
that can contain raster objects, such as scanned images. Because PDFs can
|
||||||
contain multiple pages (unlike many image formats) and can contain fonts
|
contain multiple pages (unlike many image formats) and can contain fonts
|
||||||
and text, it is a good format for exchanging scanned documents.
|
and text, they are a suitable format for exchanging scanned documents.
|
||||||
|
|
||||||
|image|
|
|image|
|
||||||
|
|
||||||
A PDF page might contain multiple images, even if it only appears to
|
A PDF page may contain multiple images, even if it appears to have only
|
||||||
have one image. Some scanners or scanning software will segment pages
|
one image. Some scanners or scanning software may segment pages into
|
||||||
into monochromatic text and color regions for example, to improve the
|
monochromatic text and color regions, for example, to enhance the compression
|
||||||
compression ratio and appearance of the page.
|
ratio and appearance of the page.
|
||||||
|
|
||||||
Rasterizing a PDF is the process of generating corresponding raster images.
|
Rasterizing a PDF is the process of generating corresponding raster images.
|
||||||
OCR engines like Tesseract work with images, not scalable vector graphics
|
OCR engines like Tesseract work with images, not scalable vector graphics
|
||||||
@@ -54,147 +54,131 @@ About PDF/A
|
|||||||
`PDF/A <https://en.wikipedia.org/wiki/PDF/A>`__ is an ISO-standardized
|
`PDF/A <https://en.wikipedia.org/wiki/PDF/A>`__ is an ISO-standardized
|
||||||
subset of the full PDF specification that is designed for archiving (the
|
subset of the full PDF specification that is designed for archiving (the
|
||||||
'A' stands for Archive). PDF/A differs from PDF primarily by omitting
|
'A' stands for Archive). PDF/A differs from PDF primarily by omitting
|
||||||
features that would make it difficult to read the file in the future,
|
features that could complicate future file readability,
|
||||||
such as embedded Javascript, video, audio and references to external
|
such as embedded Javascript, video, audio and references to external
|
||||||
fonts. All fonts and resources needed to interpret the PDF must be
|
fonts. All fonts and resources needed to interpret the PDF must be
|
||||||
contained within it. Because PDF/A disables Javascript and other types
|
contained within it. Because PDF/A disables Javascript and other types
|
||||||
of embedded content, it is probably more secure.
|
of embedded content, it is likely more secure.
|
||||||
|
|
||||||
There are various conformance levels and versions, such as "PDF/A-2b".
|
There are various conformance levels and versions, such as "PDF/A-2b".
|
||||||
|
|
||||||
Generally speaking, the best format for scanned documents is PDF/A. Some
|
In general, the preferred format for scanned documents is PDF/A. Some
|
||||||
governments and jurisdictions, US Courts in particular, `mandate the use
|
governments and jurisdictions, US Courts in particular, `mandate the use
|
||||||
of PDF/A <https://pdfblog.com/2012/02/13/what-is-pdfa/>`__ for scanned
|
of PDF/A <https://pdfblog.com/2012/02/13/what-is-pdfa/>`__ for scanned
|
||||||
documents.
|
documents.
|
||||||
|
|
||||||
Since most people who scan documents are interested in reading them
|
Since most individuals scanning documents aim for long-term readability,
|
||||||
indefinitely into the future, OCRmyPDF generates PDF/A-2b by default.
|
OCRmyPDF defaults to generating PDF/A-2b.
|
||||||
|
|
||||||
PDF/A has a few drawbacks. Some PDF viewers include an alert that the
|
PDF/A does have a few drawbacks. Some PDF viewers display an alert
|
||||||
file is a PDF/A, which may confuse some users. It also tends to produce
|
indicating that the file is in PDF/A format, which may confuse some users.
|
||||||
larger files than PDF, because it embeds certain resources even if they
|
Additionally, it tends to result in larger files than standard PDFs because
|
||||||
are commonly available. PDF/A files can be digitally signed, but may not
|
it embeds certain resources, even if they are widely available. PDF/A
|
||||||
be encrypted, to ensure they can be read in the future. Fortunately,
|
files can be digitally signed but may not be encrypted to ensure future
|
||||||
converting from PDF/A to a regular PDF is trivial, and any PDF viewer
|
readability. Fortunately, converting from PDF/A to a regular PDF is
|
||||||
can view PDF/A.
|
straightforward, and any PDF viewer can handle PDF/A files.
|
||||||
|
|
||||||
What OCRmyPDF does
|
What OCRmyPDF does
|
||||||
==================
|
==================
|
||||||
|
|
||||||
OCRmyPDF analyzes each page of a PDF to determine the colorspace and
|
OCRmyPDF analyzes each page of a PDF to determine the required colorspace
|
||||||
resolution (DPI) needed to capture all of the information on that page
|
and resolution (DPI) for capturing all the information on that page without
|
||||||
without losing content. It uses
|
losing content. It uses
|
||||||
`Ghostscript <http://ghostscript.com/>`__ to rasterize the page, and
|
`Ghostscript <http://ghostscript.com/>`__ to rasterize each page and subsequently
|
||||||
then performs OCR on the rasterized image to create an OCR "layer".
|
performs OCR on the rasterized image to generate an OCR "layer." This layer
|
||||||
The layer is then grafted back onto the original PDF.
|
is then integrated back into the original PDF.
|
||||||
|
|
||||||
While one can use a program like Ghostscript or ImageMagick to get an
|
While it is possible to use a program like Ghostscript or ImageMagick to
|
||||||
image and put the image through Tesseract, that actually creates a new
|
obtain an image and then run that image through Tesseract OCR, this process
|
||||||
PDF and many details may be lost. OCRmyPDF can produce a minimally
|
actually generates a new PDF, potentially resulting in the loss of various
|
||||||
changed PDF as output.
|
details (such as the document's metadata). In contrast, OCRmyPDF can produce
|
||||||
|
a minimally altered PDF as the output.
|
||||||
|
|
||||||
OCRmyPDF also provides some image processing options, like deskew, which
|
OCRmyPDF also offers several image processing options, such as deskew, which
|
||||||
improves the appearance of files and quality of OCR. When these are used,
|
enhances the visual quality of files and the accuracy of OCR. When these
|
||||||
the OCR layer is grafted onto the processed image instead.
|
options are utilized, the OCR layer is integrated into the processed image.
|
||||||
|
|
||||||
By default, OCRmyPDF produces archival PDFs – PDF/A, which are a
|
By default, OCRmyPDF generates archival PDFs in the PDF/A format, which is
|
||||||
stricter subset of PDF features designed for long term archives. If
|
a more rigid subset of PDF features designed for long-term archives. If you
|
||||||
regular PDFs are desired, this can be disabled with
|
prefer regular PDFs, you can disable this feature using the
|
||||||
``--output-type pdf``.
|
``--output-type pdf`` option.
|
||||||
|
|
||||||
Why you shouldn't do this manually
|
Why you shouldn't do this manually
|
||||||
==================================
|
==================================
|
||||||
|
|
||||||
A PDF is similar to an HTML file, in that it contains document structure
|
A PDF is similar to an HTML file, in that it contains document structure
|
||||||
along with images. Sometimes a PDF does nothing more than present a full
|
along with images. While some PDFs may solely display a full-page image,
|
||||||
page image, but often there is additional content that would be lost.
|
they often contain additional content that would be forfeited if not preserved.
|
||||||
|
|
||||||
A manual process could work like either of these:
|
A manual process could take one of these approaches:
|
||||||
|
|
||||||
1. Rasterize each page as an image, OCR the images, and combine the
|
1. Rasterize each page as an image, perform OCR on the images, and then merge the
|
||||||
output into a PDF. This preserves the layout of each page, but
|
output into a PDF. This method preserves the layout of each page, but
|
||||||
resamples all images (possibly losing quality, increasing file size,
|
resamples all images potentially leading to quality loss, increased file size,
|
||||||
introducing compression artifacts, etc.).
|
and the introduction of compression artifacts, among other issues.
|
||||||
2. Extract each image, OCR, and combine the output into a PDF. This
|
2. Extract each image, OCR, and combine the output into a PDF. This approach
|
||||||
loses the context in which images are used in the PDF, meaning that
|
loses the context in which images are used in the PDF, potentially resulting
|
||||||
cropping, rotation and scaling of pages may be lost. Some scanned
|
in loss of information related to scaling and position of images. Some scanned
|
||||||
PDFs use multiple images segmented into black and white, grayscale
|
PDFs contain multiple images segmented into black and white, grayscale
|
||||||
and color regions, with stencil masks to prevent overlap, as this can
|
and color regions, with stencil masks to prevent overlap, as this can
|
||||||
enhance the appearance of a file while reducing file size. Clearly,
|
enhance the appearance of a file while reducing file size.
|
||||||
reassembling these images will be easy. This also loses and text or
|
Reassembling these images can be challenging, and risks losing vector art
|
||||||
vector art on any pages in a PDF with both scanned and pure digital
|
or text that is not part of an image.
|
||||||
content.
|
|
||||||
|
|
||||||
In the case of a PDF that is nothing other than a container of images
|
In cases where a PDF solely serves as a container for images without any
|
||||||
(no rotation, scaling, cropping, one image per page), the second
|
rotation, scaling, or cropping, the second approach can be lossless.
|
||||||
approach can be lossless.
|
|
||||||
|
|
||||||
OCRmyPDF uses several strategies depending on input options and the
|
OCRmyPDF uses various strategies depending on input options and the input PDF
|
||||||
input PDF itself, but generally speaking it rasterizes a page for OCR
|
itself. Generally, it rasterizes a page for OCR and then integrates the OCR
|
||||||
and then grafts the OCR back onto the original. As such it can handle
|
data back into the original PDF. This approach allows it to handle complex
|
||||||
complex PDFs and still preserve their contents as much as possible.
|
PDFs and preserve their content as much as possible.
|
||||||
|
|
||||||
OCRmyPDF also supports a many, many edge cases that have cropped over
|
Furthermore, OCRmyPDF supports a wide range of edge cases that have emerged
|
||||||
several years of development. We support PDF features like images inside
|
during several years of development. It accommodates PDF features like
|
||||||
of Form XObjects, and pages with UserUnit scaling. We support rare image
|
images within Form XObjects and pages with UserUnit scaling. It also
|
||||||
formats like non-monochrome 1-bit images. We warn about files you may
|
supports less common image formats like non-monochrome 1-bit images and
|
||||||
not to OCR. Thanks to pikepdf and QPDF, we auto-repair PDFs that are
|
provides warnings about files you may not want to OCR. Thanks to tools
|
||||||
damaged. (Not that you need to know what any of these are! You should be
|
like pikepdf and QPDF, it can auto-repair damaged PDFs. You don't need to
|
||||||
able to throw any PDF at it.)
|
understand the intricacies of these issues; you should be able to use
|
||||||
|
OCRmyPDF with any PDF file, and expect reasonable results.
|
||||||
|
|
||||||
Limitations
|
Limitations
|
||||||
===========
|
===========
|
||||||
|
|
||||||
OCRmyPDF is limited by the Tesseract OCR engine. As such it experiences
|
OCRmyPDF is subject to limitations imposed by the Tesseract OCR engine.
|
||||||
these limitations, as do any other programs that rely on Tesseract:
|
These limitations are inherent to any software relying on Tesseract:
|
||||||
|
|
||||||
- The OCR is not as accurate as commercial OCR solutions.
|
- The OCR accuracy may not match that of commercial OCR solutions.
|
||||||
- It is not capable of recognizing handwriting.
|
- It is incapable of recognizing handwriting.
|
||||||
- It may find gibberish and report this as OCR output.
|
- It may detect gibberish and report it as OCR output.
|
||||||
- If a document contains languages outside of those given in the
|
- Results may be subpar when a document contains languages not specified
|
||||||
``-l LANG`` arguments, results may be poor.
|
in the ``-l LANG`` argument.
|
||||||
- It is not always good at analyzing the natural reading order of
|
- Tesseract may struggle to analyze the natural reading order of documents.
|
||||||
documents. For example, it may fail to recognize that a document
|
For instance, it might fail to recognize two columns in a document and
|
||||||
contains two columns, and may try to join text across columns.
|
attempt to join text across columns.
|
||||||
- Poor quality scans may produce poor quality OCR. Garbage in, garbage
|
- Poor quality scans can result in subpar OCR quality. In other words, the
|
||||||
out.
|
quality of the OCR output depends on the quality of the input.
|
||||||
- It does not expose information about what font family text belongs
|
- Tesseract does not provide information about the font family to which text
|
||||||
to.
|
belongs.
|
||||||
|
- Tesseract does not divide text into paragraphs or headings. It only provides
|
||||||
OCRmyPDF is also limited by the PDF specification:
|
the text and its bounding box. As such, the generated PDF does not
|
||||||
|
contain any information about the document's structure.
|
||||||
- PDF encodes the position of text glyphs but does not encode document
|
|
||||||
structure. There is no markup that divides a document in sections,
|
|
||||||
paragraphs, sentences, or even words (since blank spaces are not
|
|
||||||
represented). As such all elements of document structure including
|
|
||||||
the spaces between words must be derived heuristically. Some PDF
|
|
||||||
viewers do a better job of this than others.
|
|
||||||
- Because some popular open source PDF viewers have a particularly hard
|
|
||||||
time with spaces between words, OCRmyPDF appends a space to each text
|
|
||||||
element as a workaround (when using ``--pdf-renderer hocr``). While
|
|
||||||
this mixes document structure with graphical information that ideally
|
|
||||||
should be left to the PDF viewer to interpret, it improves
|
|
||||||
compatibility with some viewers and does not cause problems for
|
|
||||||
better ones.
|
|
||||||
|
|
||||||
Ghostscript also imposes some limitations:
|
Ghostscript also imposes some limitations:
|
||||||
|
|
||||||
- PDFs containing JBIG2-encoded content will be converted to CCITT
|
- PDFs containing JPEG 2000-encoded content may be converted to JPEG
|
||||||
Group4 encoding, which has lower compression ratios, if Ghostscript
|
|
||||||
PDF/A is enabled.
|
|
||||||
- PDFs containing JPEG 2000-encoded content will be converted to JPEG
|
|
||||||
encoding, which may introduce compression artifacts, if Ghostscript
|
encoding, which may introduce compression artifacts, if Ghostscript
|
||||||
PDF/A is enabled.
|
PDF/A is enabled.
|
||||||
- Ghostscript may transcode grayscale and color images, either lossy to
|
- Ghostscript may transcode grayscale and color images, potentially
|
||||||
lossless or lossless to lossy, based on an internal algorithm. This
|
lossily, based on an internal algorithm. This
|
||||||
behavior can be suppressed by setting ``--pdfa-image-compression`` to
|
behavior can be suppressed by setting ``--pdfa-image-compression`` to
|
||||||
``jpeg`` or ``lossless`` to set all images to one type or the other.
|
``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 lacks an option to maintain the input image's format.
|
||||||
(Modern Ghostscript can copy JPEG images without transcoding them.)
|
(Modern Ghostscript can copy JPEG images without transcoding them.)
|
||||||
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
|
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
|
||||||
one of the standard XMP metadata namespaces for PDFs. In particular,
|
one of the standard XMP metadata namespaces for PDFs. In particular,
|
||||||
PRISM Metadata is removed.
|
PRISM Metadata is removed.
|
||||||
- Ghostscript's PDF/A conversion seems to remove or deactivate
|
- Ghostscript's PDF/A conversion may remove or deactivate
|
||||||
hyperlinks and other active content.
|
hyperlinks and other active content.
|
||||||
|
|
||||||
You can use ``--output-type pdf`` to disable PDF/A conversion and produce
|
You can use ``--output-type pdf`` to disable PDF/A conversion and produce
|
||||||
@@ -202,7 +186,7 @@ a standard, non-archival PDF.
|
|||||||
|
|
||||||
Regarding OCRmyPDF itself:
|
Regarding OCRmyPDF itself:
|
||||||
|
|
||||||
- PDFs that use transparency are not currently represented in the test
|
- PDFs using transparency are not currently represented in the test
|
||||||
suite
|
suite
|
||||||
|
|
||||||
Similar programs
|
Similar programs
|
||||||
@@ -210,11 +194,7 @@ Similar programs
|
|||||||
|
|
||||||
To the author's knowledge, OCRmyPDF is the most feature-rich and
|
To the author's knowledge, OCRmyPDF is the most feature-rich and
|
||||||
thoroughly tested command line OCR PDF conversion tool. If it does not
|
thoroughly tested command line OCR PDF conversion tool. If it does not
|
||||||
meet your needs, contributions and suggestions are welcome. If not,
|
meet your needs, contributions and suggestions are welcome.
|
||||||
consider one of these similar open source programs:
|
|
||||||
|
|
||||||
- pdf2pdfocr
|
|
||||||
- pdfsandwich
|
|
||||||
|
|
||||||
Ghostscript recently added three "pdfocr" output devices. They work by
|
Ghostscript recently added three "pdfocr" output devices. They work by
|
||||||
rasterizing all content and converting all pages to a single colour space.
|
rasterizing all content and converting all pages to a single colour space.
|
||||||
@@ -222,16 +202,19 @@ rasterizing all content and converting all pages to a single colour space.
|
|||||||
Web front-ends
|
Web front-ends
|
||||||
==============
|
==============
|
||||||
|
|
||||||
The Docker image ``ocrmypdf`` provides a web service front-end
|
The Docker image of OCRmyPDF provides a web service front-end
|
||||||
that allows files to submitted over HTTP and the results "downloaded".
|
that allows files to submitted over HTTP, and the results can be downloaded.
|
||||||
This is an HTTP server intended to simplify web services deployments; it
|
This is an HTTP server intended to demonstrate how OCRmyPDF can be
|
||||||
is not intended to be deployed on the public internet and no real
|
integrated into a web service. It is not intended to be deployed on the
|
||||||
security measures to speak of.
|
public internet and does not provide any security measures.
|
||||||
|
|
||||||
In addition, the following third-party integrations are available:
|
In addition, the following third-party integrations are available:
|
||||||
|
|
||||||
|
- `Paperless-ngx <https://docs.paperless-ngx.com/>`__ is a free software
|
||||||
|
document management system that uses OCRmyPDF to perform OCR on
|
||||||
|
uploaded documents.
|
||||||
- `Nextcloud OCR <https://github.com/janis91/ocr>`__ is a free software
|
- `Nextcloud OCR <https://github.com/janis91/ocr>`__ is a free software
|
||||||
plugin for the Nextcloud private cloud software
|
plugin for the Nextcloud private cloud software.
|
||||||
|
|
||||||
OCRmyPDF is not designed to be secure against malware-bearing PDFs (see
|
OCRmyPDF is not designed to be secure against malware-bearing PDFs (see
|
||||||
`Using OCRmyPDF online <ocr-service>`__). Users should ensure they
|
`Using OCRmyPDF online <ocr-service>`__). Users should ensure they
|
||||||
|
|||||||
+25
-9
@@ -14,17 +14,20 @@ expired as of 2017, but it is possible that unknown patents exist.
|
|||||||
|
|
||||||
JBIG2 encoding is recommended for OCRmyPDF and is used to losslessly
|
JBIG2 encoding is recommended for OCRmyPDF and is used to losslessly
|
||||||
create smaller PDFs. If JBIG2 encoding is not available, lower quality
|
create smaller PDFs. If JBIG2 encoding is not available, lower quality
|
||||||
encodings will be used.
|
CCITT encoding will be used for monochrome images.
|
||||||
|
|
||||||
JBIG2 decoding is not patented and is performed automatically by most
|
JBIG2 decoding is not patented and is performed automatically by most
|
||||||
PDF viewers. It is widely supported and has been part of the PDF
|
PDF viewers. It is widely supported and has been part of the PDF
|
||||||
specification since 2001.
|
specification since 2001.
|
||||||
|
|
||||||
On macOS, Homebrew packages jbig2enc and OCRmyPDF includes it by
|
JBIG encoding is automatically provided by these OCRmyPDF packages:
|
||||||
default. The Docker image for OCRmyPDF also builds its own JBIG2 encoder
|
- Docker image (both Ubuntu and Alpine)
|
||||||
from source.
|
- Snap package
|
||||||
|
- ArchLinux AUR package
|
||||||
|
- Alpine Linux package
|
||||||
|
- Homebrew on macOS
|
||||||
|
|
||||||
For all other Linux, you must build a JBIG2 encoder from source:
|
For all other platforms, you would need to build the JBIG2 encoder from source:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
@@ -38,21 +41,34 @@ For all other Linux, you must build a JBIG2 encoder from source:
|
|||||||
|
|
||||||
Dependencies include libtoolize and libleptonica, which on Ubuntu systems
|
Dependencies include libtoolize and libleptonica, which on Ubuntu systems
|
||||||
are packaged as libtool and libleptonica-dev. On Fedora (35) they are packaged
|
are packaged as libtool and libleptonica-dev. On Fedora (35) they are packaged
|
||||||
as libtool and leptonica-devel.
|
as libtool and leptonica-devel. For this to work, please make sure to install
|
||||||
|
``autotools``, ``automake``, ``libtool`` and ``leptonica`` first if not already
|
||||||
|
installed.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
[sudo] apt install autotools-dev automake libtool libleptonica-dev
|
||||||
|
..
|
||||||
|
|
||||||
|
|
||||||
Lossy mode JBIG2
|
Lossy mode JBIG2
|
||||||
================
|
================
|
||||||
|
|
||||||
OCRmyPDF provides lossy mode JBIG2 as an advanced feature. Users should
|
OCRmyPDF provides lossy mode JBIG2 as an advanced and potentially dangerous
|
||||||
|
feature. Users should
|
||||||
`review the technical concerns with JBIG2 in lossy
|
`review the technical concerns with JBIG2 in lossy
|
||||||
mode <https://en.wikipedia.org/wiki/JBIG2#Disadvantages>`__
|
mode <https://en.wikipedia.org/wiki/JBIG2#Disadvantages>`__
|
||||||
and decide if this feature is acceptable for their use case.
|
and decide if this feature is acceptable for their use case. In general,
|
||||||
|
this mode should not be used for archival purposes, should not be used when
|
||||||
|
the original document is not available or will be destroyed, and should
|
||||||
|
not be used when numbers present in the document are important, because
|
||||||
|
there is a risk of 6/8 and 8/6 substitution errors.
|
||||||
|
|
||||||
JBIG2 lossy mode does achieve higher compression ratios than any other
|
JBIG2 lossy mode does achieve higher compression ratios than any other
|
||||||
monochrome (bitonal) compression technology; for large text documents
|
monochrome (bitonal) compression technology; for large text documents
|
||||||
the savings are considerable. JBIG2 lossless still gives great
|
the savings are considerable. JBIG2 lossless still gives great
|
||||||
compression ratios and is a major improvement over the older CCITT G4
|
compression ratios and is a major improvement over the older CCITT G4
|
||||||
standard. As explained above, there is some risk of substitution errors.
|
standard.
|
||||||
|
|
||||||
To turn on JBIG2 lossy mode, add the argument ``--jbig2-lossy``.
|
To turn on JBIG2 lossy mode, add the argument ``--jbig2-lossy``.
|
||||||
``--optimize {1,2,3}`` are necessary for the argument to take effect
|
``--optimize {1,2,3}`` are necessary for the argument to take effect
|
||||||
|
|||||||
+47
-13
@@ -18,16 +18,26 @@ 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
|
Some are anglicized, e.g. Spanish is ``spa`` rather than ``esp``, while others
|
||||||
are not, e.g. German is ``deu`` and French is ``fra``.
|
are not, e.g. German is ``deu`` and French is ``fra``.
|
||||||
|
|
||||||
|
Language packs (strictly speaking, Tesseract "traineddata" files) generally correspond
|
||||||
|
to the language in question, but different language packs are used in certain
|
||||||
|
situations. For German, the "Fraktur" language pack can assist with reading older
|
||||||
|
materials in the Fraktur typeface family (``deu_frak``). Some communities have changed
|
||||||
|
their script from Cyrillic to Latin; the Cyrillic version of Uzbek is available
|
||||||
|
as ``uzb_cyrl`` and the Latin version is ``uzb``.
|
||||||
|
|
||||||
After you have installed a language pack, you can use it with ``ocrmypdf -l <language>``,
|
After you have installed a language pack, you can use it with ``ocrmypdf -l <language>``,
|
||||||
for example ``ocrmypdf -l spa``. For multilingual documents, you can specify
|
for example ``ocrmypdf -l spa``. For multilingual documents, you can specify
|
||||||
all languages to be expected, e.g. ``ocrmypdf -l eng+fra`` for English and French.
|
all languages to be expected, e.g. ``ocrmypdf -l eng+fra`` for English and French.
|
||||||
English is assumed by default unless other language(s) are specified.
|
English is assumed by default unless other language(s) are specified.
|
||||||
|
|
||||||
For Linux users, you can often find packages that provide language
|
For Linux users, you can often find packages that provide language
|
||||||
packs:
|
packs.
|
||||||
|
|
||||||
Debian and Ubuntu users
|
Platform install steps
|
||||||
=======================
|
======================
|
||||||
|
|
||||||
|
Debian and Ubuntu (apt)
|
||||||
|
-----------------------
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
@@ -42,8 +52,8 @@ to what languages it should search for. Multiple languages can be
|
|||||||
requested using either ``-l eng+fra`` (English and French) or
|
requested using either ``-l eng+fra`` (English and French) or
|
||||||
``-l eng -l fra``.
|
``-l eng -l fra``.
|
||||||
|
|
||||||
Fedora users
|
Fedora
|
||||||
============
|
------
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
@@ -58,8 +68,24 @@ to what languages it should search for. Multiple languages can be
|
|||||||
requested using either ``-l eng+fra`` (English and French) or
|
requested using either ``-l eng+fra`` (English and French) or
|
||||||
``-l eng -l fra``.
|
``-l eng -l fra``.
|
||||||
|
|
||||||
Gentoo users
|
Archlinux
|
||||||
============
|
------
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
# Display a list of all Tesseract language packs
|
||||||
|
pacman -Ss tesseract-data
|
||||||
|
|
||||||
|
# Install German language pack
|
||||||
|
pacman -S tesseract-data-deu
|
||||||
|
|
||||||
|
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 using either ``-l eng+fra`` (English and French) or
|
||||||
|
``-l eng -l fra``.
|
||||||
|
|
||||||
|
Gentoo
|
||||||
|
------
|
||||||
|
|
||||||
On Gentoo the package ``app-text/tessdata_fast``, which ``app-text/tesseract`` depends on, handles Tesseract languages.
|
On Gentoo the package ``app-text/tessdata_fast``, which ``app-text/tesseract`` depends on, handles Tesseract languages.
|
||||||
It accepts USE flags to select what languages should be installed, these can be set in ``/etc/portage/package.use``.
|
It accepts USE flags to select what languages should be installed, these can be set in ``/etc/portage/package.use``.
|
||||||
@@ -85,23 +111,31 @@ to what languages it should search for. Multiple languages can be
|
|||||||
requested using either ``-l eng+fra`` (English and French) or
|
requested using either ``-l eng+fra`` (English and French) or
|
||||||
``-l eng -l fra``.
|
``-l eng -l fra``.
|
||||||
|
|
||||||
macOS users
|
macOS
|
||||||
===========
|
-----
|
||||||
|
|
||||||
You can install additional language packs by
|
You can install additional language packs by
|
||||||
:ref:`installing Tesseract using Homebrew with all language packs <macos-all-languages>`.
|
:ref:`installing Tesseract using Homebrew with all language packs <macos-all-languages>`.
|
||||||
|
|
||||||
Docker users
|
Docker
|
||||||
============
|
------
|
||||||
|
|
||||||
Users of the OCRmyPDF Docker image should install language packs into a
|
Users of the OCRmyPDF Docker image should install language packs into a
|
||||||
derived Docker image as
|
derived Docker image as
|
||||||
:ref:`described in that section <docker-lang-packs>`.
|
:ref:`described in that section <docker-lang-packs>`.
|
||||||
|
|
||||||
Windows users
|
Windows
|
||||||
=============
|
-------
|
||||||
|
|
||||||
The Tesseract installer provided by Chocolatey currently includes only English language.
|
The Tesseract installer provided by Chocolatey currently includes only English language.
|
||||||
To install other languages, download the respective language pack (``.traineddata`` file)
|
To install other languages, download the respective language pack (``.traineddata`` file)
|
||||||
from https://github.com/tesseract-ocr/tessdata/ and place it in
|
from https://github.com/tesseract-ocr/tessdata/ and place it in
|
||||||
``C:\\Program Files\\Tesseract-OCR\\tessdata`` (or wherever Tesseract OCR is installed).
|
``C:\\Program Files\\Tesseract-OCR\\tessdata`` (or wherever Tesseract OCR is installed).
|
||||||
|
|
||||||
|
Custom language packs
|
||||||
|
=====================
|
||||||
|
|
||||||
|
If you have fine-tuned or trained Tesseract and generated custom trained data, you can
|
||||||
|
copy your ``customlang.traineddata`` file into your Tesseract "tessdata" folder, and
|
||||||
|
then use the ``-l customlang`` argument to tell OCRmyPDF to pass that language on to
|
||||||
|
Tesseract.
|
||||||
|
|||||||
+10
-1
@@ -64,4 +64,13 @@ installation documentation.
|
|||||||
|
|
||||||
If you maintain a Linux distribution that supports 32-bit x86 or ARM, OCRmyPDF
|
If you maintain a Linux distribution that supports 32-bit x86 or ARM, OCRmyPDF
|
||||||
should continue to work as long as all of its dependencies continue to be
|
should continue to work as long as all of its dependencies continue to be
|
||||||
available in 32-bit form. Please note we do not test on 32-bit platforms.
|
available in 32-bit form. Please note we do not test on 32-bit platforms.
|
||||||
|
|
||||||
|
HEIF/HEIC
|
||||||
|
---------
|
||||||
|
|
||||||
|
OCRmyPDF defaults to installing the pi-heif PyPI package, which supports converting
|
||||||
|
HEIF (High Efficiency Image File Format) images to PDF from the command line.
|
||||||
|
If your distribution does not have this library available, you can exclude it and
|
||||||
|
OCRmyPDF will gracefully degrade automatically, losing only support for this
|
||||||
|
feature.
|
||||||
@@ -17,7 +17,7 @@ If running OCRmyPDF quickly is your main goal, you can use settings such as:
|
|||||||
|
|
||||||
* ``--optimize 0`` to disable file size optimization
|
* ``--optimize 0`` to disable file size optimization
|
||||||
* ``--output-type pdf`` to disable PDF/A generation
|
* ``--output-type pdf`` to disable PDF/A generation
|
||||||
* ``--fast-web-view 0`` to disable fast web view optimization
|
* ``--fast-web-view 999999`` to disable fast web view optimization
|
||||||
* ``--skip-big`` to skip large images, if some pages have large images
|
* ``--skip-big`` to skip large images, if some pages have large images
|
||||||
|
|
||||||
You can also avoid:
|
You can also avoid:
|
||||||
|
|||||||
@@ -177,8 +177,13 @@ Custom command line arguments
|
|||||||
Execution and progress reporting
|
Execution and progress reporting
|
||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
||||||
|
.. autoclass:: ocrmypdf.pluginspec.ProgressBar
|
||||||
|
:members:
|
||||||
|
:special-members: __init__, __enter__, __exit__
|
||||||
|
|
||||||
.. autoclass:: ocrmypdf.pluginspec.Executor
|
.. autoclass:: ocrmypdf.pluginspec.Executor
|
||||||
:members:
|
:members:
|
||||||
|
:special-members: __call__
|
||||||
|
|
||||||
.. autofunction:: ocrmypdf.pluginspec.get_logging_console
|
.. autofunction:: ocrmypdf.pluginspec.get_logging_console
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ The most recent release of OCRmyPDF is |OCRmyPDF PyPI|. Any newer versions
|
|||||||
referred to in these notes may exist the main branch but have not been
|
referred to in these notes may exist the main branch but have not been
|
||||||
tagged yet.
|
tagged yet.
|
||||||
|
|
||||||
|
OCRmyPDF typically supports the three most recent Python versions.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Attention maintainers: these release notes may be updated with information
|
Attention maintainers: these release notes may be updated with information
|
||||||
@@ -28,6 +30,228 @@ tagged yet.
|
|||||||
|
|
||||||
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||||
|
|
||||||
|
v16.4.3
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Work around pdfminer.six issue where a token on the buffer boundary is incorrectly
|
||||||
|
parsed as two tokens. :issue:`1361`
|
||||||
|
- New rules are applied to stencil masks and explicit masks when calculating the
|
||||||
|
optimal page DPI for rendering. :issue:`1362`
|
||||||
|
- Fixed attempts to use an incompatible jbig2.EXE provided by TeX Live. :issue:`1363`
|
||||||
|
|
||||||
|
v16.4.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed order of filenames passed to Ghostscript for PDF/A generation. :issue:`1359`
|
||||||
|
- Suppressed missing jbig2dec warning message. :issue:`1358`
|
||||||
|
- Fixed calculation of image size when soft mask dimensions don't match image
|
||||||
|
dimension. :issue:`1351`
|
||||||
|
- Several fixes to documentation. Thanks to users Iris and JoKalliauer
|
||||||
|
who contributed these changes.
|
||||||
|
- Fixed error on processing PDFs that are missing certain image metadata. :issue:`1315`
|
||||||
|
|
||||||
|
v16.4.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed calculation of image printed area (used in finding weighted DPI for OCR).
|
||||||
|
:issue:`1334`
|
||||||
|
- Fixed "NotImplementedError: not sure how to get colorspace" error
|
||||||
|
messages in logs which simply records a failure to optimize images with
|
||||||
|
print production colorspaces. :issue:`1315`
|
||||||
|
|
||||||
|
v16.4.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Selecting the ``osd`` and ``equ`` pseudo-languages with ``-l/--language`` now
|
||||||
|
exits with an error when using Tesseract OCR, because these are not
|
||||||
|
regular Tesseract languages but implementation details implemented.
|
||||||
|
Using them can cause Tesseract to crash.
|
||||||
|
- The hOCR renderer is more tolerant of extra whitespace in input files.
|
||||||
|
- watcher.py now changes the output file extension to .pdf when the input is not
|
||||||
|
.pdf.
|
||||||
|
- Improved handling of PDFs that contain circularly referenced Form XObjects.
|
||||||
|
:issue:`1321`
|
||||||
|
- Fixed Alpine Docker image for ARM64, which was not building correctly.
|
||||||
|
- Docker images now use pikepdf 9.0.0.
|
||||||
|
- Prevent use of Tesseract OCR 5.4.0, a version with known regressions.
|
||||||
|
- Disabled progressbar for "Linearizing" when ``--no-progress-bar`` set.
|
||||||
|
- Fixed some tests that warn about missing JBIG2 decoding via pikepdf, by
|
||||||
|
installing the necessary libraries during tests.
|
||||||
|
|
||||||
|
v16.3.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed a test suite failure with Ghostscript 10.03.0+. :issue:`1316`
|
||||||
|
- Fixed an issue with the presentation of the "OCR" progress bar. :issue:`1313`
|
||||||
|
|
||||||
|
v16.3.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed progress bar not displaying for Ghostscript PDF/A conversion. :issue:`1313`
|
||||||
|
- Added progress bar for linearization. :issue:`1313`
|
||||||
|
- If `--rotate-pages-threshold` issued without `--rotate-pages` we now exit with
|
||||||
|
an error since the user likely intended to use `--rotate-pages`. :issue:`1309`
|
||||||
|
- If Tesseract hOCR gives an invalid line box, print an error message instead of
|
||||||
|
exiting with an error. :issue:`1312`
|
||||||
|
|
||||||
|
v16.2.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed issue 'NoneType' object has no attribute 'get' when optimizing certain PDFs.
|
||||||
|
:issue:`1293,1271`
|
||||||
|
- Switched formatting from black to ruff.
|
||||||
|
- Added support for sending sidecar output to io.BytesIO.
|
||||||
|
- Added support for converting HEIF/HEIC images (the native image of iPhones and
|
||||||
|
some other devices) to PDFs, when the appropriate pi-hief library is installed.
|
||||||
|
This library is marked as a dependency, but maintainers may opt out if needed.
|
||||||
|
- We now default to downsampling large images that would exceed Tesseract's internal
|
||||||
|
limits, but only if it cause processing to fail. Previously, this behavior only
|
||||||
|
occurred if specifically requested on command line. It can still be configured
|
||||||
|
and disabled. See the --tesseract command line options.
|
||||||
|
- Added Macports install instructions. Thanks @akierig.
|
||||||
|
- Improved logging output when an unexpected error occurs while trying to obtain
|
||||||
|
the version of a third party program.
|
||||||
|
|
||||||
|
v16.1.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed test suite failure when using Ghostscript 10.3.
|
||||||
|
- Other minor corrections.
|
||||||
|
|
||||||
|
v16.1.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed PyPy 3.10 support.
|
||||||
|
|
||||||
|
v16.1.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Improved hOCR renderer is now default for left to right languages.
|
||||||
|
- Improved handling of rotated pages. Previously, OCR text might be missing for
|
||||||
|
pages that were rotated with a /Rotate tag on the page entry.
|
||||||
|
- Improved handling of cropped pages. Previously, in some cases a page with a
|
||||||
|
crop box would not have its OCR applied correctly and misalignment between
|
||||||
|
OCR text and visible text coudl occur.
|
||||||
|
- Documentation improvements, especially installation instructions for less
|
||||||
|
common platforms.
|
||||||
|
|
||||||
|
v16.0.4
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed some issues for left-to-right text with the new hOCR renderer. It is still
|
||||||
|
not default yet but will be made so soon. Right-to-left text is still in progress.
|
||||||
|
- Added an error to prevent use of several versions of Ghostscript that seem
|
||||||
|
corrupt existing text in input PDFs. Newly generated OCR is not affected.
|
||||||
|
For best results, use Ghostscript 10.02.1 or newer, which contains the fix
|
||||||
|
for the issue.
|
||||||
|
|
||||||
|
v16.0.3
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Changed minimum required Ghostscript to 9.54, to support users of RHEL 9 and its
|
||||||
|
derivatives, since that is the latest version available there.
|
||||||
|
- Removed warning message about CVE-2023-43115, on the assumption that most
|
||||||
|
distributions have backported the patch by now.
|
||||||
|
|
||||||
|
v16.0.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Temporarily changed PDF text renderer back to sandwich by default to address
|
||||||
|
regressions in macOS Preview.
|
||||||
|
|
||||||
|
v16.0.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed text rendering issue with new hOCR text renderer - extraneous byte order
|
||||||
|
marks.
|
||||||
|
- Tightened dependencies.
|
||||||
|
|
||||||
|
v16.0.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Added OCR text renderer, combined the best ideas of Tesseract's PDF
|
||||||
|
generator and the older hOCR transformer renderer. The result is a hopefully
|
||||||
|
permanent fix for wordssmushedtogetherwithoutspaces issues in extracted text,
|
||||||
|
better registration/position of text on skewed baselines :issue:`1009`,
|
||||||
|
fixes to character output when the German Fraktur script is used :issue:`1191`,
|
||||||
|
proper rendering of right to left languages (Arabic, Hebrew, Persian) :issue:`1157`.
|
||||||
|
Asian languages may still have excessive word breaks compared to expectations.
|
||||||
|
The new renderer is the default; the old sandwich renderer is still available
|
||||||
|
using ``--pdf-renderer sandwich``; the old hOCR renderer is no more.
|
||||||
|
- The ``ocrmypdf.hocrtransform`` API has changed substantially.
|
||||||
|
- Support for Python 3.9 has been dropped. Python 3.10+ is now required.
|
||||||
|
- pikepdf >= 8.8.0 is now required.
|
||||||
|
|
||||||
|
|
||||||
|
v15.4.4
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed documentation for installing Ghostscript on Windows. :issue:`1198`
|
||||||
|
- Added warning message about security issue in older versions of Ghostscript.
|
||||||
|
|
||||||
|
v15.4.3
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed deprecation warning in pikepdf older than 8.7.1; pikepdf >= 8.7.1 is
|
||||||
|
now required.
|
||||||
|
|
||||||
|
v15.4.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- We now raise an exception on a certain class of PDFs that likely need an
|
||||||
|
explicit color conversion strategy selected to display correctly
|
||||||
|
for PDF/A conversion.
|
||||||
|
- Fixed an error that occurred while trying to write a log message after the
|
||||||
|
debug log handler was removed.
|
||||||
|
|
||||||
|
v15.4.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed misc/watcher.py regressions: accept ``--ocr-json-settings`` as either
|
||||||
|
filename or JSON string, as previously; and argument count mismatch.
|
||||||
|
:issue:`1183,1185`
|
||||||
|
- We no longer attempt to set /ProcSet in the PDF output, since this is an
|
||||||
|
obsolete PDF feature.
|
||||||
|
- Documentation improvements.
|
||||||
|
|
||||||
|
v15.4.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Added new experimental APIs to support offline editing of the final text.
|
||||||
|
Specifically, one can now generate hOCR files with OCRmyPDF, edit them with
|
||||||
|
some other tool, and then finalize the PDF. They are experimental and
|
||||||
|
subject to change, including details of how the working folder is used.
|
||||||
|
There is no command line interface.
|
||||||
|
- Code reorganization: executors, progress bars, initialization and setup.
|
||||||
|
- Fixed test coverage in cases where the coverage tool did not properly trace
|
||||||
|
into threads or subprocesses. This code was still being tested but appeared
|
||||||
|
as not covered.
|
||||||
|
- In the test suite, reduced use of subprocesses and other techniques that
|
||||||
|
interfere with coverage measurement.
|
||||||
|
- Improved error check for when we appear to be running inside a snap container
|
||||||
|
and files are not available.
|
||||||
|
- Plugin specification now properly defines progress bars as a protocol rather
|
||||||
|
than defining them as "tqdm-like".
|
||||||
|
- We now default to using "forkserver" process creation on POSIX platforms
|
||||||
|
rather than fork, since this is method is more robust and avoids some
|
||||||
|
issues when threads are present.
|
||||||
|
- Fixed an instance where the user's request to ``--no-use-threads`` was ignored.
|
||||||
|
- If a PDF does not have language metadata on its top level object, we add
|
||||||
|
the OCR language.
|
||||||
|
- Replace some cryptic test error messages with more helpful ones.
|
||||||
|
- Debug messages for how OCRmyPDF picks the colorspace for a page are now
|
||||||
|
more descriptive.
|
||||||
|
|
||||||
|
v15.3.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed an issue with logging settings for misc/watcher.py introduced in the
|
||||||
|
previous release. :issue:`1180`
|
||||||
|
- We now attempt to preserve the input's extended attributes when creating
|
||||||
|
the output file.
|
||||||
|
- For some reason, the macOS build now needs OpenSSL explicitly installed.
|
||||||
|
- Updated documentation on Docker performance concerns.
|
||||||
|
|
||||||
v15.3.0
|
v15.3.0
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
|||||||
+48
-10
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# SPDX-FileCopyrightText: 2016 findingorder <https://github.com/findingorder>
|
# SPDX-FileCopyrightText: 2016 findingorder <https://github.com/findingorder>
|
||||||
|
# SPDX-FileCopyrightText: 2024 nilsro <https://github.com/nilsro>
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Example of using ocrmypdf as a library in a script.
|
"""Example of using ocrmypdf as a library in a script.
|
||||||
@@ -13,7 +14,11 @@ You should edit this script to meet your needs.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import filecmp
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -22,32 +27,65 @@ import ocrmypdf
|
|||||||
# pylint: disable=logging-format-interpolation
|
# pylint: disable=logging-format-interpolation
|
||||||
# pylint: disable=logging-not-lazy
|
# pylint: disable=logging-not-lazy
|
||||||
|
|
||||||
|
|
||||||
|
def filecompare(a, b):
|
||||||
|
try:
|
||||||
|
return filecmp.cmp(a, b, shallow=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
script_dir = Path(__file__).parent
|
script_dir = Path(__file__).parent
|
||||||
|
# set archive_dir to a path for backup original documents. Leave empty if not required.
|
||||||
|
archive_dir = "/pdfbak"
|
||||||
|
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
start_dir = Path(sys.argv[1])
|
start_dir = Path(sys.argv[1])
|
||||||
else:
|
else:
|
||||||
start_dir = Path('.')
|
start_dir = Path(".")
|
||||||
|
|
||||||
if len(sys.argv) > 2:
|
if len(sys.argv) > 2:
|
||||||
log_file = Path(sys.argv[2])
|
log_file = Path(sys.argv[2])
|
||||||
else:
|
else:
|
||||||
log_file = script_dir.with_name('ocr-tree.log')
|
log_file = script_dir.with_name("ocr-tree.log")
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s %(message)s',
|
format="%(asctime)s %(message)s",
|
||||||
filename=log_file,
|
filename=log_file,
|
||||||
filemode='a',
|
filemode="a",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logging.info(f"Start directory {start_dir}")
|
||||||
|
|
||||||
ocrmypdf.configure_logging(ocrmypdf.Verbosity.default)
|
ocrmypdf.configure_logging(ocrmypdf.Verbosity.default)
|
||||||
|
|
||||||
for filename in start_dir.glob("**/*.py"):
|
for filename in start_dir.glob("**/*.pdf"):
|
||||||
logging.info(f"Processing {filename}")
|
logging.info(f"Processing {filename}")
|
||||||
result = ocrmypdf.ocr(filename, filename, deskew=True)
|
if ocrmypdf.pdfa.file_claims_pdfa(filename)["pass"]:
|
||||||
if result == ocrmypdf.ExitCode.already_done_ocr:
|
logging.info("Skipped document because it already contained text")
|
||||||
logging.error("Skipped document because it already contained text")
|
else:
|
||||||
elif result == ocrmypdf.ExitCode.ok:
|
archive_filename = archive_dir + str(filename)
|
||||||
|
if len(archive_dir) > 0 and not filecompare(filename, archive_filename):
|
||||||
|
logging.info(f"Archiving document to {archive_filename}")
|
||||||
|
try:
|
||||||
|
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
||||||
|
except OSError:
|
||||||
|
os.makedirs(posixpath.dirname(archive_filename))
|
||||||
|
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
||||||
|
try:
|
||||||
|
result = ocrmypdf.ocr(filename, filename, deskew=True)
|
||||||
|
logging.info(result)
|
||||||
|
except ocrmypdf.exceptions.EncryptedPdfError:
|
||||||
|
logging.info("Skipped document because it is encrypted")
|
||||||
|
except ocrmypdf.exceptions.PriorOcrFoundError:
|
||||||
|
logging.info("Skipped document because it already contained text")
|
||||||
|
except ocrmypdf.exceptions.DigitalSignatureError:
|
||||||
|
logging.info("Skipped document because it has a digital signature")
|
||||||
|
except ocrmypdf.exceptions.TaggedPDFError:
|
||||||
|
logging.info(
|
||||||
|
"Skipped document because it does not need ocr as it is tagged"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.error("Unhandled error occured")
|
||||||
logging.info("OCR complete")
|
logging.info("OCR complete")
|
||||||
logging.info(result)
|
|
||||||
|
|||||||
+4
-3
@@ -53,9 +53,10 @@ for dir_name, _subdirs, file_list in os.walk(start_dir):
|
|||||||
]
|
]
|
||||||
logging.info(cmd)
|
logging.info(cmd)
|
||||||
full_path_ocr = os.path.join(dir_name, filename_ocr)
|
full_path_ocr = os.path.join(dir_name, filename_ocr)
|
||||||
with open(filename, 'rb') as input_file, open(
|
with (
|
||||||
full_path_ocr, 'wb'
|
open(filename, 'rb') as input_file,
|
||||||
) as output_file:
|
open(full_path_ocr, 'wb') as output_file,
|
||||||
|
):
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
stdin=input_file,
|
stdin=input_file,
|
||||||
|
|||||||
+25
-18
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
# Do not enable annotations!
|
# Do not enable annotations!
|
||||||
# https://github.com/tiangolo/typer/discussions/598
|
# https://github.com/tiangolo/typer/discussions/598
|
||||||
# from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -47,15 +46,18 @@ class LoggingLevelEnum(str, Enum):
|
|||||||
CRITICAL = "CRITICAL"
|
CRITICAL = "CRITICAL"
|
||||||
|
|
||||||
|
|
||||||
def get_output_dir(root: Path, basename: str, output_dir_year_month: bool) -> Path:
|
def get_output_path(root: Path, basename: str, output_dir_year_month: bool) -> Path:
|
||||||
|
assert '/' not in basename, "basename must not contain '/'"
|
||||||
if output_dir_year_month:
|
if output_dir_year_month:
|
||||||
today = datetime.today()
|
today = datetime.today()
|
||||||
output_directory_year_month = root / str(today.year) / f'{today.month:02d}'
|
output_directory_year_month = root / str(today.year) / f'{today.month:02d}'
|
||||||
if not output_directory_year_month.exists():
|
if not output_directory_year_month.exists():
|
||||||
output_directory_year_month.mkdir(parents=True, exist_ok=True)
|
output_directory_year_month.mkdir(parents=True, exist_ok=True)
|
||||||
output_path = Path(output_directory_year_month) / basename
|
output_path = Path(output_directory_year_month) / Path(basename).with_suffix(
|
||||||
|
'.pdf'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
output_path = root / basename
|
output_path = root / Path(basename).with_suffix('.pdf')
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +94,6 @@ def execute_ocrmypdf(
|
|||||||
file_path: Path,
|
file_path: Path,
|
||||||
archive_dir: Path,
|
archive_dir: Path,
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
deskew: bool,
|
|
||||||
ocrmypdf_kwargs: dict[str, Any],
|
ocrmypdf_kwargs: dict[str, Any],
|
||||||
on_success_delete: bool,
|
on_success_delete: bool,
|
||||||
on_success_archive: bool,
|
on_success_archive: bool,
|
||||||
@@ -100,7 +101,7 @@ def execute_ocrmypdf(
|
|||||||
retries_loading_file: int,
|
retries_loading_file: int,
|
||||||
output_dir_year_month: bool,
|
output_dir_year_month: bool,
|
||||||
):
|
):
|
||||||
output_path = get_output_dir(output_dir, file_path.name, output_dir_year_month)
|
output_path = get_output_path(output_dir, file_path.name, output_dir_year_month)
|
||||||
|
|
||||||
log.info("-" * 20)
|
log.info("-" * 20)
|
||||||
log.info(f'New file: {file_path}. Waiting until fully written...')
|
log.info(f'New file: {file_path}. Waiting until fully written...')
|
||||||
@@ -108,10 +109,14 @@ def execute_ocrmypdf(
|
|||||||
log.info(f"Gave up waiting for {file_path} to become ready")
|
log.info(f"Gave up waiting for {file_path} to become ready")
|
||||||
return
|
return
|
||||||
log.info(f'Attempting to OCRmyPDF to: {output_path}')
|
log.info(f'Attempting to OCRmyPDF to: {output_path}')
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
f'OCRmyPDF input_file={file_path} output_file={output_path} '
|
||||||
|
f'kwargs: {ocrmypdf_kwargs}'
|
||||||
|
)
|
||||||
exit_code = ocrmypdf.ocr(
|
exit_code = ocrmypdf.ocr(
|
||||||
input_file=file_path,
|
input_file=file_path,
|
||||||
output_file=output_path,
|
output_file=output_path,
|
||||||
deskew=deskew,
|
|
||||||
**ocrmypdf_kwargs,
|
**ocrmypdf_kwargs,
|
||||||
)
|
)
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
@@ -128,7 +133,7 @@ def execute_ocrmypdf(
|
|||||||
|
|
||||||
|
|
||||||
class HandleObserverEvent(PatternMatchingEventHandler):
|
class HandleObserverEvent(PatternMatchingEventHandler):
|
||||||
def __init__(
|
def __init__( # noqa: D107
|
||||||
self,
|
self,
|
||||||
patterns=None,
|
patterns=None,
|
||||||
ignore_patterns=None,
|
ignore_patterns=None,
|
||||||
@@ -146,7 +151,7 @@ class HandleObserverEvent(PatternMatchingEventHandler):
|
|||||||
|
|
||||||
def on_any_event(self, event):
|
def on_any_event(self, event):
|
||||||
if event.event_type in ['created']:
|
if event.event_type in ['created']:
|
||||||
execute_ocrmypdf(event.src_path, **self._settings)
|
execute_ocrmypdf(file_path=Path(event.src_path), **self._settings)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -188,7 +193,7 @@ def main(
|
|||||||
bool,
|
bool,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
envvar='OCR_OUTPUT_DIRECTORY_YEAR_MONTH',
|
envvar='OCR_OUTPUT_DIRECTORY_YEAR_MONTH',
|
||||||
help='Create a subdirectory in the output directory for each year and month',
|
help='Create a subdirectory in the output directory for each year/month',
|
||||||
),
|
),
|
||||||
] = False,
|
] = False,
|
||||||
on_success_delete: Annotated[
|
on_success_delete: Annotated[
|
||||||
@@ -213,10 +218,10 @@ def main(
|
|||||||
),
|
),
|
||||||
] = False,
|
] = False,
|
||||||
ocr_json_settings: Annotated[
|
ocr_json_settings: Annotated[
|
||||||
typer.FileText,
|
str,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
envvar='OCR_JSON_SETTINGS',
|
envvar='OCR_JSON_SETTINGS',
|
||||||
help='JSON settings to pass to OCRmyPDF',
|
help='JSON settings to pass to OCRmyPDF (JSON string or file path)',
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
poll_new_file_seconds: Annotated[
|
poll_new_file_seconds: Annotated[
|
||||||
@@ -260,12 +265,12 @@ def main(
|
|||||||
ocrmypdf.configure_logging(
|
ocrmypdf.configure_logging(
|
||||||
verbosity=(
|
verbosity=(
|
||||||
ocrmypdf.Verbosity.default
|
ocrmypdf.Verbosity.default
|
||||||
if loglevel != 'DEBUG'
|
if loglevel != LoggingLevelEnum.DEBUG
|
||||||
else ocrmypdf.Verbosity.debug
|
else ocrmypdf.Verbosity.debug
|
||||||
),
|
),
|
||||||
manage_root_logger=True,
|
manage_root_logger=True,
|
||||||
)
|
)
|
||||||
log.setLevel(loglevel)
|
log.setLevel(loglevel.value)
|
||||||
log.info(
|
log.info(
|
||||||
f"Starting OCRmyPDF watcher with config:\n"
|
f"Starting OCRmyPDF watcher with config:\n"
|
||||||
f"Input Directory: {input_dir}\n"
|
f"Input Directory: {input_dir}\n"
|
||||||
@@ -285,10 +290,13 @@ def main(
|
|||||||
f"POLL_NEW_FILE_SECONDS: {poll_new_file_seconds}\n"
|
f"POLL_NEW_FILE_SECONDS: {poll_new_file_seconds}\n"
|
||||||
f"RETRIES_LOADING_FILE: {retries_loading_file}\n"
|
f"RETRIES_LOADING_FILE: {retries_loading_file}\n"
|
||||||
f"USE_POLLING: {use_polling}\n"
|
f"USE_POLLING: {use_polling}\n"
|
||||||
f"LOGLEVEL: {loglevel}"
|
f"LOGLEVEL: {loglevel.value}"
|
||||||
)
|
)
|
||||||
|
|
||||||
json_settings = json.loads(ocr_json_settings.read() if ocr_json_settings else '{}')
|
if ocr_json_settings and Path(ocr_json_settings).exists():
|
||||||
|
json_settings = json.loads(Path(ocr_json_settings).read_text())
|
||||||
|
else:
|
||||||
|
json_settings = json.loads(ocr_json_settings or '{}')
|
||||||
|
|
||||||
if 'input_file' in json_settings or 'output_file' in json_settings:
|
if 'input_file' in json_settings or 'output_file' in json_settings:
|
||||||
log.error(
|
log.error(
|
||||||
@@ -301,8 +309,7 @@ def main(
|
|||||||
settings={
|
settings={
|
||||||
'archive_dir': archive_dir,
|
'archive_dir': archive_dir,
|
||||||
'output_dir': output_dir,
|
'output_dir': output_dir,
|
||||||
'deskew': deskew,
|
'ocrmypdf_kwargs': json_settings | {'deskew': deskew},
|
||||||
'ocrmypdf_kwargs': json_settings,
|
|
||||||
'on_success_delete': on_success_delete,
|
'on_success_delete': on_success_delete,
|
||||||
'on_success_archive': on_success_archive,
|
'on_success_archive': on_success_archive,
|
||||||
'poll_new_file_seconds': poll_new_file_seconds,
|
'poll_new_file_seconds': poll_new_file_seconds,
|
||||||
|
|||||||
+27
-41
@@ -10,18 +10,17 @@ dynamic = ["version"]
|
|||||||
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
|
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MPL-2.0" }
|
license = { text = "MPL-2.0" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Pillow>=10.0.1",
|
|
||||||
"deprecation>=2.1.0",
|
"deprecation>=2.1.0",
|
||||||
"img2pdf>=0.4.4",
|
"img2pdf>=0.5",
|
||||||
"packaging>=20",
|
"packaging>=20",
|
||||||
"pdfminer.six>=20220319",
|
"pdfminer.six>=20220319",
|
||||||
"pikepdf>=8",
|
"pi-heif", # Heif image format - maintainers: if this is removed, it will NOT break
|
||||||
"pluggy>=0.13.0",
|
"pikepdf>=8.10.1",
|
||||||
"reportlab>=3.6.8",
|
"Pillow>=10.0.1",
|
||||||
|
"pluggy>=1",
|
||||||
"rich>=13",
|
"rich>=13",
|
||||||
"typing-extensions>=4;python_version<'3.10'",
|
|
||||||
]
|
]
|
||||||
authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }]
|
authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -50,7 +49,7 @@ Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues"
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
docs = ["sphinx", "sphinx-issues", "sphinx-rtd-theme"]
|
docs = ["sphinx", "sphinx-issues", "sphinx-rtd-theme"]
|
||||||
extended_test = ["PyMuPDF==1.19.1"]
|
extended_test = ["PyMuPDF>=1.19.1"]
|
||||||
test = [
|
test = [
|
||||||
"coverage[toml]>=6.2",
|
"coverage[toml]>=6.2",
|
||||||
"hypothesis>=6.36.0",
|
"hypothesis>=6.36.0",
|
||||||
@@ -58,10 +57,11 @@ test = [
|
|||||||
"pytest-cov>=3.0.0",
|
"pytest-cov>=3.0.0",
|
||||||
"pytest-xdist>=2.5.0",
|
"pytest-xdist>=2.5.0",
|
||||||
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
|
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
|
||||||
|
"reportlab>=3.6.8",
|
||||||
"types-Pillow",
|
"types-Pillow",
|
||||||
"types-humanfriendly",
|
"types-humanfriendly",
|
||||||
]
|
]
|
||||||
watcher = ["watchdog>=1.0.2", "typer[all]", "python-dotenv"]
|
watcher = ["watchdog>=1.0.2", "typer-slim[standard]", "python-dotenv"]
|
||||||
webservice = ["Flask>=2.0.1"]
|
webservice = ["Flask>=2.0.1"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -77,35 +77,13 @@ namespaces = false
|
|||||||
[tool.setuptools_scm]
|
[tool.setuptools_scm]
|
||||||
|
|
||||||
[tool.distutils.bdist_wheel]
|
[tool.distutils.bdist_wheel]
|
||||||
python-tag = "py39"
|
python-tag = "py310"
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 88
|
|
||||||
target-version = ["py39", "py310", "py311"]
|
|
||||||
skip-string-normalization = true
|
|
||||||
include = '\.pyi?$'
|
|
||||||
exclude = '''
|
|
||||||
/(
|
|
||||||
\.eggs
|
|
||||||
| \.git
|
|
||||||
| \.hg
|
|
||||||
| \.mypy_cache
|
|
||||||
| \.tox
|
|
||||||
| \.venv
|
|
||||||
| _build
|
|
||||||
| buck-out
|
|
||||||
| build
|
|
||||||
| dist
|
|
||||||
| docs
|
|
||||||
| misc
|
|
||||||
| \.egg-info
|
|
||||||
)/
|
|
||||||
'''
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
branch = true
|
branch = true
|
||||||
parallel = true
|
parallel = true
|
||||||
concurrency = ["multiprocessing"]
|
concurrency = ["multiprocessing", "thread"]
|
||||||
|
sigterm = true
|
||||||
|
|
||||||
[tool.coverage.paths]
|
[tool.coverage.paths]
|
||||||
source = ["src/ocrmypdf"]
|
source = ["src/ocrmypdf"]
|
||||||
@@ -131,7 +109,11 @@ norecursedirs = ["lib", ".pc", ".git", "venv", "output", "cache", "resources"]
|
|||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-n auto"
|
addopts = "-n auto"
|
||||||
markers = ["slow"]
|
markers = ["slow"]
|
||||||
filterwarnings = ["ignore:.*XMLParser.*:DeprecationWarning"]
|
filterwarnings = [
|
||||||
|
"ignore:.*XMLParser.*:DeprecationWarning",
|
||||||
|
"ignore:.*ast.NameConstant.*:DeprecationWarning:reportlab",
|
||||||
|
"ignore:.*distutils.*:DeprecationWarning:libxmp",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
|
|
||||||
@@ -149,7 +131,10 @@ module = [
|
|||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
select = [
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
"select" = [
|
||||||
"D", # pydocstyle
|
"D", # pydocstyle
|
||||||
"E", # pycodestyle
|
"E", # pycodestyle
|
||||||
"W", # pycodestyle
|
"W", # pycodestyle
|
||||||
@@ -157,17 +142,18 @@ select = [
|
|||||||
"I001", # isort
|
"I001", # isort
|
||||||
"UP", # pyupgrade
|
"UP", # pyupgrade
|
||||||
]
|
]
|
||||||
target-version = "py39"
|
|
||||||
|
|
||||||
[tool.ruff.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["ocrmypdf"]
|
known-first-party = ["ocrmypdf"]
|
||||||
required-imports = ["from __future__ import annotations"]
|
|
||||||
|
|
||||||
[tool.ruff.pydocstyle]
|
[tool.ruff.lint.pydocstyle]
|
||||||
convention = "google"
|
convention = "google"
|
||||||
|
|
||||||
[tool.ruff.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"docs/conf.py" = ["D100", "D101", "D105"]
|
"docs/conf.py" = ["D100", "D101", "D105"]
|
||||||
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105"]
|
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105"]
|
||||||
"misc/*.py" = ["D103", "D101", "D102"]
|
"misc/*.py" = ["D103", "D101", "D102"]
|
||||||
"src/ocrmypdf/builtin_plugins/*.py" = ["D103", "D102", "D105"]
|
"src/ocrmypdf/builtin_plugins/*.py" = ["D103", "D102", "D105"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "preserve"
|
||||||
|
|||||||
+2
-2
@@ -18,8 +18,8 @@ architectures: [amd64]
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/4.00/tessdata
|
TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/4.00/tessdata
|
||||||
GS_LIB: $SNAP/usr/share/ghostscript/9.55/Resource/Init
|
GS_LIB: $SNAP/usr/share/ghostscript/9.55.0/Resource/Init
|
||||||
GS_FONTPATH: $SNAP/usr/share/ghostscript/9.55/Resource/Font
|
GS_FONTPATH: $SNAP/usr/share/ghostscript/9.55.0/Resource/Font
|
||||||
LD_LIBRARY_PATH: $SNAP/usr/lib/x86_64-linux-gnu
|
LD_LIBRARY_PATH: $SNAP/usr/lib/x86_64-linux-gnu
|
||||||
|
|
||||||
apps:
|
apps:
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ from pluggy import HookimplMarker as _HookimplMarker
|
|||||||
from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo
|
from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo
|
||||||
from ocrmypdf._concurrent import Executor
|
from ocrmypdf._concurrent import Executor
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
|
from ocrmypdf._pipelines._common import (
|
||||||
|
configure_debug_logging,
|
||||||
|
)
|
||||||
from ocrmypdf._version import PROGRAM_NAME, __version__
|
from ocrmypdf._version import PROGRAM_NAME, __version__
|
||||||
from ocrmypdf.api import Verbosity, configure_logging, ocr
|
from ocrmypdf.api import (
|
||||||
|
Verbosity,
|
||||||
|
configure_logging,
|
||||||
|
ocr,
|
||||||
|
)
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
BadArgsError,
|
BadArgsError,
|
||||||
DpiError,
|
DpiError,
|
||||||
@@ -30,9 +37,11 @@ from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
|
|||||||
|
|
||||||
hookimpl = _HookimplMarker('ocrmypdf')
|
hookimpl = _HookimplMarker('ocrmypdf')
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'__version__',
|
'__version__',
|
||||||
'BadArgsError',
|
'BadArgsError',
|
||||||
|
'configure_debug_logging',
|
||||||
'configure_logging',
|
'configure_logging',
|
||||||
'DpiError',
|
'DpiError',
|
||||||
'EncryptedPdfError',
|
'EncryptedPdfError',
|
||||||
|
|||||||
@@ -7,14 +7,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from ocrmypdf import __version__
|
from ocrmypdf import __version__
|
||||||
|
from ocrmypdf._pipelines.ocr import run_pipeline_cli
|
||||||
from ocrmypdf._plugin_manager import get_parser_options_plugins
|
from ocrmypdf._plugin_manager import get_parser_options_plugins
|
||||||
from ocrmypdf._sync import run_pipeline
|
|
||||||
from ocrmypdf._validation import check_options
|
from ocrmypdf._validation import check_options
|
||||||
from ocrmypdf.api import Verbosity, configure_logging
|
from ocrmypdf.api import Verbosity, configure_logging
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
@@ -71,9 +72,12 @@ def run(args=None):
|
|||||||
with suppress(AttributeError, OSError):
|
with suppress(AttributeError, OSError):
|
||||||
signal.signal(signal.SIGBUS, sigbus)
|
signal.signal(signal.SIGBUS, sigbus)
|
||||||
|
|
||||||
result = run_pipeline(options=options, plugin_manager=plugin_manager)
|
result = run_pipeline_cli(options=options, plugin_manager=plugin_manager)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
if os.name == 'posix':
|
||||||
|
multiprocessing.set_start_method('forkserver')
|
||||||
sys.exit(run())
|
sys.exit(run())
|
||||||
|
|||||||
+18
-26
@@ -7,28 +7,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from typing import Callable
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from ocrmypdf._progressbar import NullProgressBar, ProgressBar
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
def _task_noop(*_args, **_kwargs):
|
def _task_noop(*_args, **_kwargs):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
class NullProgressBar:
|
def _task_finished_noop(_result: Any, pbar: ProgressBar):
|
||||||
"""Progress bar API that takes no actions."""
|
pbar.update()
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def update(self, _arg=None):
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class Executor(ABC):
|
class Executor(ABC):
|
||||||
@@ -46,11 +38,11 @@ class Executor(ABC):
|
|||||||
*,
|
*,
|
||||||
use_threads: bool,
|
use_threads: bool,
|
||||||
max_workers: int,
|
max_workers: int,
|
||||||
tqdm_kwargs: dict,
|
progress_kwargs: dict,
|
||||||
worker_initializer: Callable | None = None,
|
worker_initializer: Callable | None = None,
|
||||||
task: Callable | None = None,
|
task: Callable[..., T] | None = None,
|
||||||
task_arguments: Iterable | None = None,
|
task_arguments: Iterable | None = None,
|
||||||
task_finished: Callable | None = None,
|
task_finished: Callable[[T, ProgressBar], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up parallel execution and progress reporting.
|
"""Set up parallel execution and progress reporting.
|
||||||
|
|
||||||
@@ -60,7 +52,7 @@ class Executor(ABC):
|
|||||||
heavily, and parallelizing it with threads is not expected to be
|
heavily, and parallelizing it with threads is not expected to be
|
||||||
performant).
|
performant).
|
||||||
max_workers: The maximum number of workers that should be run.
|
max_workers: The maximum number of workers that should be run.
|
||||||
tqdm_kwargs: Arguments to set up the progress bar.
|
progress_kwargs: Arguments to set up the progress bar.
|
||||||
worker_initializer: Called when a worker is initialized, in the worker's
|
worker_initializer: Called when a worker is initialized, in the worker's
|
||||||
execution context. If the child workers are processes, it must be
|
execution context. If the child workers are processes, it must be
|
||||||
possible to marshall/pickle the worker initializer.
|
possible to marshall/pickle the worker initializer.
|
||||||
@@ -78,7 +70,7 @@ class Executor(ABC):
|
|||||||
if not worker_initializer:
|
if not worker_initializer:
|
||||||
worker_initializer = _task_noop
|
worker_initializer = _task_noop
|
||||||
if not task_finished:
|
if not task_finished:
|
||||||
task_finished = _task_noop
|
task_finished = _task_finished_noop
|
||||||
if not task:
|
if not task:
|
||||||
task = _task_noop
|
task = _task_noop
|
||||||
|
|
||||||
@@ -86,7 +78,7 @@ class Executor(ABC):
|
|||||||
self._execute(
|
self._execute(
|
||||||
use_threads=use_threads,
|
use_threads=use_threads,
|
||||||
max_workers=max_workers,
|
max_workers=max_workers,
|
||||||
tqdm_kwargs=tqdm_kwargs,
|
progress_kwargs=progress_kwargs,
|
||||||
worker_initializer=worker_initializer,
|
worker_initializer=worker_initializer,
|
||||||
task=task,
|
task=task,
|
||||||
task_arguments=task_arguments,
|
task_arguments=task_arguments,
|
||||||
@@ -99,7 +91,7 @@ class Executor(ABC):
|
|||||||
*,
|
*,
|
||||||
use_threads: bool,
|
use_threads: bool,
|
||||||
max_workers: int,
|
max_workers: int,
|
||||||
tqdm_kwargs: dict,
|
progress_kwargs: dict,
|
||||||
worker_initializer: Callable,
|
worker_initializer: Callable,
|
||||||
task: Callable,
|
task: Callable,
|
||||||
task_arguments: Iterable,
|
task_arguments: Iterable,
|
||||||
@@ -125,13 +117,13 @@ class SerialExecutor(Executor):
|
|||||||
*,
|
*,
|
||||||
use_threads: bool,
|
use_threads: bool,
|
||||||
max_workers: int,
|
max_workers: int,
|
||||||
tqdm_kwargs: dict,
|
progress_kwargs: dict,
|
||||||
worker_initializer: Callable,
|
worker_initializer: Callable,
|
||||||
task: Callable,
|
task: Callable,
|
||||||
task_arguments: Iterable,
|
task_arguments: Iterable,
|
||||||
task_finished: Callable,
|
task_finished: Callable,
|
||||||
): # pylint: disable=unused-argument
|
): # pylint: disable=unused-argument
|
||||||
with self.pbar_class(**tqdm_kwargs) as pbar:
|
with self.pbar_class(**progress_kwargs) as pbar:
|
||||||
for args in task_arguments:
|
for args in task_arguments:
|
||||||
result = task(args)
|
result = task(*args)
|
||||||
task_finished(result, pbar)
|
task_finished(result, pbar)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
# Enforce English hegemony
|
||||||
|
DEFAULT_LANGUAGE = 'eng'
|
||||||
|
|
||||||
|
# Default rotation threshold
|
||||||
|
DEFAULT_ROTATE_PAGES_THRESHOLD = 14.0
|
||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from collections import deque
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,7 +17,7 @@ from subprocess import PIPE, CalledProcessError
|
|||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from ocrmypdf.exceptions import SubprocessOutputError
|
from ocrmypdf.exceptions import ColorConversionNeededError, SubprocessOutputError
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
||||||
|
|
||||||
@@ -29,38 +30,44 @@ COLOR_CONVERSION_STRATEGIES = frozenset(
|
|||||||
'UseDeviceIndependentColor',
|
'UseDeviceIndependentColor',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
# Ghostscript executable - gswin32c is not supported
|
||||||
|
GS = 'gswin64c' if os.name == 'nt' else 'gs'
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DuplicateFilter(logging.Filter):
|
class DuplicateFilter(logging.Filter):
|
||||||
"""Filter out duplicate log messages."""
|
"""Filter out duplicate log messages.
|
||||||
|
|
||||||
def __init__(self, logger: logging.Logger):
|
A context window of default 5 messages is used to determine if a message is a
|
||||||
self.last: logging.LogRecord | None = None
|
duplicate. This is because some Ghostscript messages are word wrapped.
|
||||||
self.count = 0
|
"""
|
||||||
|
|
||||||
|
def __init__(self, logger: logging.Logger, context_window=5):
|
||||||
|
self.window: deque[str] = deque([], maxlen=context_window)
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self.levelno = logging.DEBUG
|
||||||
|
self.count = 0
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record):
|
||||||
if self.last and record.msg == self.last.msg:
|
if record.msg in self.window:
|
||||||
self.count += 1
|
self.count += 1
|
||||||
|
self.levelno = record.levelno
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
if self.count >= 1:
|
if self.count >= 1:
|
||||||
rep_msg = f"(previous message repeated {self.count} times)"
|
rep_msg = f"(suppressed {self.count} repeated lines)"
|
||||||
self.count = 0 # Avoid infinite recursion
|
self.count = 0 # Avoid infinite recursion
|
||||||
self.logger.log(self.last.levelno, rep_msg)
|
self.logger.log(self.levelno, rep_msg)
|
||||||
self.last = record
|
self.window.clear()
|
||||||
|
self.window.append(record.msg)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
log.addFilter(DuplicateFilter(log))
|
log.addFilter(DuplicateFilter(log))
|
||||||
|
|
||||||
|
|
||||||
# Ghostscript executable - gswin32c is not supported
|
|
||||||
GS = 'gswin64c' if os.name == 'nt' else 'gs'
|
|
||||||
|
|
||||||
|
|
||||||
def version() -> Version:
|
def version() -> Version:
|
||||||
return Version(get_version(GS))
|
return Version(get_version(GS))
|
||||||
|
|
||||||
@@ -70,6 +77,20 @@ def _gs_error_reported(stream) -> bool:
|
|||||||
return bool(match)
|
return bool(match)
|
||||||
|
|
||||||
|
|
||||||
|
def _gs_devicen_reported(stream) -> bool:
|
||||||
|
"""Did Ghostscript warn about a DeviceN with inappropriate alternate?
|
||||||
|
|
||||||
|
If so, we need the user to select a color conversion, or the resulting PDF will
|
||||||
|
not present correctly in some PDF viewers.
|
||||||
|
"""
|
||||||
|
match = re.search(
|
||||||
|
r'DeviceN.*inappropriate alternate',
|
||||||
|
stream,
|
||||||
|
flags=re.IGNORECASE | re.MULTILINE,
|
||||||
|
)
|
||||||
|
return bool(match)
|
||||||
|
|
||||||
|
|
||||||
def rasterize_pdf(
|
def rasterize_pdf(
|
||||||
input_file: os.PathLike,
|
input_file: os.PathLike,
|
||||||
output_file: os.PathLike,
|
output_file: os.PathLike,
|
||||||
@@ -156,6 +177,17 @@ class GhostscriptFollower:
|
|||||||
self.progressbar_class = progressbar_class
|
self.progressbar_class = progressbar_class
|
||||||
self.progressbar = None
|
self.progressbar = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
# We can't actually set up the progressbar here, because we don't know
|
||||||
|
# how many pages there are until the first __call__() happens. So we
|
||||||
|
# do it in __call__().
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
if self.progressbar:
|
||||||
|
return self.progressbar.__exit__(exc_type, exc_value, traceback)
|
||||||
|
return False
|
||||||
|
|
||||||
def __call__(self, line):
|
def __call__(self, line):
|
||||||
if not self.progressbar_class:
|
if not self.progressbar_class:
|
||||||
return
|
return
|
||||||
@@ -166,7 +198,8 @@ class GhostscriptFollower:
|
|||||||
self.progressbar = self.progressbar_class(
|
self.progressbar = self.progressbar_class(
|
||||||
total=self.count, desc="PDF/A conversion", unit='page'
|
total=self.count, desc="PDF/A conversion", unit='page'
|
||||||
)
|
)
|
||||||
return
|
# Now that we know the count, we can set up the progressbar.
|
||||||
|
self.progressbar.__enter__()
|
||||||
else:
|
else:
|
||||||
if self.re_page.match(line.strip()):
|
if self.re_page.match(line.strip()):
|
||||||
self.progressbar.update()
|
self.progressbar.update()
|
||||||
@@ -243,9 +276,11 @@ def generate_pdfa(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Path(output_file).open('wb') as output:
|
with (
|
||||||
|
Path(output_file).open('wb') as output,
|
||||||
|
GhostscriptFollower(progressbar_class) as pbar,
|
||||||
|
):
|
||||||
p = run_polling_stderr(
|
p = run_polling_stderr(
|
||||||
args_gs,
|
args_gs,
|
||||||
stdout=output,
|
stdout=output,
|
||||||
@@ -254,7 +289,7 @@ def generate_pdfa(
|
|||||||
text=True,
|
text=True,
|
||||||
encoding='utf-8',
|
encoding='utf-8',
|
||||||
errors='replace',
|
errors='replace',
|
||||||
callback=GhostscriptFollower(progressbar_class),
|
callback=pbar,
|
||||||
)
|
)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
# Ghostscript does not change return code when it fails to create
|
# Ghostscript does not change return code when it fails to create
|
||||||
@@ -272,3 +307,5 @@ def generate_pdfa(
|
|||||||
# the **** pattern to split the stderr into parts.
|
# the **** pattern to split the stderr into parts.
|
||||||
for part in stderr.split('****'):
|
for part in stderr.split('****'):
|
||||||
log.error(part)
|
log.error(part)
|
||||||
|
if _gs_devicen_reported(stderr):
|
||||||
|
raise ColorConversionNeededError()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from subprocess import PIPE
|
from subprocess import PIPE, CalledProcessError
|
||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
|
|
||||||
@@ -14,7 +14,13 @@ from ocrmypdf.subprocess import get_version, run
|
|||||||
|
|
||||||
|
|
||||||
def version() -> Version:
|
def version() -> Version:
|
||||||
return Version(get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*'))
|
try:
|
||||||
|
version = get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*')
|
||||||
|
except CalledProcessError as e:
|
||||||
|
# TeX Live for Windows provides an incompatible jbig2.EXE which may
|
||||||
|
# be on the PATH.
|
||||||
|
raise MissingDependencyError('jbig2enc') from e
|
||||||
|
return Version(version)
|
||||||
|
|
||||||
|
|
||||||
def available():
|
def available():
|
||||||
@@ -25,7 +31,7 @@ def available():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def convert_group(*, cwd, infiles, out_prefix, threshold):
|
def convert_group(cwd, infiles, out_prefix, threshold):
|
||||||
args = [
|
args = [
|
||||||
'jbig2',
|
'jbig2',
|
||||||
'-b',
|
'-b',
|
||||||
@@ -43,21 +49,9 @@ def convert_group(*, cwd, infiles, out_prefix, threshold):
|
|||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
|
||||||
def convert_group_mp(args):
|
def convert_single(cwd, infile, outfile, threshold):
|
||||||
return convert_group(
|
|
||||||
cwd=args[0], infiles=args[1], out_prefix=args[2], threshold=args[3]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_single(*, cwd, infile, outfile, threshold):
|
|
||||||
args = ['jbig2', '--pdf', '-t', str(threshold), infile]
|
args = ['jbig2', '--pdf', '-t', str(threshold), infile]
|
||||||
with open(outfile, 'wb') as fstdout:
|
with open(outfile, 'wb') as fstdout:
|
||||||
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
|
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
|
||||||
proc.check_returncode()
|
proc.check_returncode()
|
||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
|
||||||
def convert_single_mp(args):
|
|
||||||
return convert_single(
|
|
||||||
cwd=args[0], infile=args[1], outfile=args[2], threshold=args[3]
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -5,13 +5,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE
|
from subprocess import PIPE
|
||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
from ocrmypdf.subprocess import get_version, run
|
from ocrmypdf.subprocess import get_version, run
|
||||||
@@ -29,21 +26,16 @@ def available():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def input_as_png(input_file: Path):
|
|
||||||
if not input_file.name.endswith('.png'):
|
|
||||||
with Image.open(input_file) as im:
|
|
||||||
bio = BytesIO()
|
|
||||||
im.save(bio, format='png')
|
|
||||||
bio.seek(0)
|
|
||||||
yield bio
|
|
||||||
else:
|
|
||||||
with open(input_file, 'rb') as f:
|
|
||||||
yield f
|
|
||||||
|
|
||||||
|
|
||||||
def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max: int):
|
def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max: int):
|
||||||
with input_as_png(input_file) as input_stream:
|
"""Quantize a PNG image using pngquant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: Input PNG image
|
||||||
|
output_file: Output PNG image
|
||||||
|
quality_min: Minimum quality to use
|
||||||
|
quality_max: Maximum quality to use
|
||||||
|
"""
|
||||||
|
with open(input_file, 'rb') as input_stream:
|
||||||
args = [
|
args = [
|
||||||
'pngquant',
|
'pngquant',
|
||||||
'--force',
|
'--force',
|
||||||
@@ -58,7 +50,3 @@ def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max:
|
|||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# input_file could be the same as output_file, so we defer the write
|
# input_file could be the same as output_file, so we defer the write
|
||||||
output_file.write_bytes(result.stdout)
|
output_file.write_bytes(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
def quantize_mp(args):
|
|
||||||
return quantize(*args)
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from contextlib import suppress
|
||||||
from math import pi
|
from math import pi
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired
|
from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired
|
||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
MissingDependencyError,
|
MissingDependencyError,
|
||||||
@@ -26,25 +26,6 @@ from ocrmypdf.subprocess import get_version, run
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
HOCR_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
|
||||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
|
||||||
<head>
|
|
||||||
<title></title>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
|
|
||||||
<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>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
|
TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
|
||||||
'auto': 0,
|
'auto': 0,
|
||||||
'otsu': 0,
|
'otsu': 0,
|
||||||
@@ -239,7 +220,8 @@ def get_deskew(
|
|||||||
|
|
||||||
def tesseract_log_output(stream: bytes) -> None:
|
def tesseract_log_output(stream: bytes) -> None:
|
||||||
tlog = TesseractLoggerAdapter(
|
tlog = TesseractLoggerAdapter(
|
||||||
log, extra=log.extra if hasattr(log, 'extra') else None # type: ignore
|
log,
|
||||||
|
extra=log.extra if hasattr(log, 'extra') else None, # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
if not stream:
|
if not stream:
|
||||||
@@ -284,14 +266,11 @@ def page_timedout(timeout: float) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _generate_null_hocr(output_hocr: Path, output_text: Path, image: Path) -> None:
|
def _generate_null_hocr(output_hocr: Path, output_text: Path, image: Path) -> None:
|
||||||
"""Produce a .hocr file that reports no text detected.
|
"""Produce an empty .hocr file.
|
||||||
|
|
||||||
Ensures page is the same size as the input image.
|
Ensures page is the same size as the input image.
|
||||||
"""
|
"""
|
||||||
with Image.open(image) as im:
|
output_hocr.write_text('', encoding='utf-8')
|
||||||
w, h = im.size
|
|
||||||
|
|
||||||
output_hocr.write_text(HOCR_TEMPLATE.format(w, h), encoding='utf-8')
|
|
||||||
output_text.write_text('[skipped page]', encoding='utf-8')
|
output_text.write_text('[skipped page]', encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
@@ -350,7 +329,7 @@ def generate_hocr(
|
|||||||
tesseract_log_output(stdout)
|
tesseract_log_output(stdout)
|
||||||
# The sidecar text file will get the suffix .txt; rename it to
|
# The sidecar text file will get the suffix .txt; rename it to
|
||||||
# whatever caller wants it named
|
# whatever caller wants it named
|
||||||
if prefix.with_suffix('.txt').exists():
|
with suppress(FileNotFoundError):
|
||||||
prefix.with_suffix('.txt').replace(output_text)
|
prefix.with_suffix('.txt').replace(output_text)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,7 +385,7 @@ def generate_pdf(
|
|||||||
try:
|
try:
|
||||||
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
|
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
|
||||||
stdout = p.stdout
|
stdout = p.stdout
|
||||||
if prefix.with_suffix('.txt').exists():
|
with suppress(FileNotFoundError):
|
||||||
prefix.with_suffix('.txt').replace(output_text)
|
prefix.with_suffix('.txt').replace(output_text)
|
||||||
except TimeoutExpired:
|
except TimeoutExpired:
|
||||||
page_timedout(timeout)
|
page_timedout(timeout)
|
||||||
|
|||||||
@@ -8,48 +8,26 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, STDOUT
|
from subprocess import PIPE, STDOUT
|
||||||
from typing import Union
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError
|
from ocrmypdf.exceptions import SubprocessOutputError
|
||||||
from ocrmypdf.subprocess import get_version, run
|
from ocrmypdf.subprocess import get_version, run
|
||||||
|
|
||||||
# unpaper documentation:
|
# unpaper documentation:
|
||||||
# https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md
|
# https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 10):
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
else:
|
|
||||||
from tempfile import TemporaryDirectory as _TemporaryDirectory
|
|
||||||
|
|
||||||
class TemporaryDirectory(_TemporaryDirectory):
|
|
||||||
"""Shim to consume ignore_cleanup_errors kwarg on Python 3.9 and older.
|
|
||||||
|
|
||||||
The argument is consumed without action. If users are getting errors related
|
|
||||||
to temporary file cleanup, they should upgrade to Python 3.10 which properly
|
|
||||||
cleans up temporary directories on Windows.
|
|
||||||
|
|
||||||
See: https://github.com/python/cpython/pull/24793
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ignore_cleanup_errors=False, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
del _TemporaryDirectory
|
|
||||||
|
|
||||||
|
|
||||||
UNPAPER_IMAGE_PIXEL_LIMIT = 256 * 1024 * 1024
|
UNPAPER_IMAGE_PIXEL_LIMIT = 256 * 1024 * 1024
|
||||||
|
|
||||||
DecFloat = Union[Decimal, float]
|
DecFloat = Decimal | float
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,52 +51,20 @@ def version() -> Version:
|
|||||||
return Version(get_version('unpaper'))
|
return Version(get_version('unpaper'))
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_MODES = {'1', 'L', 'RGB'}
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_image(im: Image.Image) -> tuple[Image.Image, bool]:
|
|
||||||
im_modified = False
|
|
||||||
|
|
||||||
if im.mode not in SUPPORTED_MODES:
|
|
||||||
log.info("Converting image to other colorspace")
|
|
||||||
try:
|
|
||||||
if im.mode == 'P' and len(im.getcolors()) == 2:
|
|
||||||
im = im.convert(mode='1')
|
|
||||||
else:
|
|
||||||
im = im.convert(mode='RGB')
|
|
||||||
except OSError as e:
|
|
||||||
raise MissingDependencyError(
|
|
||||||
"Could not convert image with type " + im.mode
|
|
||||||
) from e
|
|
||||||
else:
|
|
||||||
im_modified = True
|
|
||||||
if im.mode not in SUPPORTED_MODES:
|
|
||||||
raise MissingDependencyError(
|
|
||||||
"Failed to convert image to a supported format."
|
|
||||||
) from None
|
|
||||||
return im, im_modified
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _setup_unpaper_io(input_file: Path) -> Iterator[tuple[Path, Path, Path]]:
|
def _setup_unpaper_io(input_file: Path) -> Iterator[tuple[Path, Path, Path]]:
|
||||||
with Image.open(input_file) as im:
|
with Image.open(input_file) as im:
|
||||||
if im.width * im.height >= UNPAPER_IMAGE_PIXEL_LIMIT:
|
if im.width * im.height >= UNPAPER_IMAGE_PIXEL_LIMIT:
|
||||||
raise UnpaperImageTooLargeError(w=im.width, h=im.height)
|
raise UnpaperImageTooLargeError(w=im.width, h=im.height)
|
||||||
im, im_modified = _convert_image(im)
|
|
||||||
|
|
||||||
with TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
with TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
||||||
tmppath = Path(tmpdir)
|
tmppath = Path(tmpdir)
|
||||||
if im_modified or input_file.suffix != '.png':
|
# No changes, PNG input, just use the file we already have
|
||||||
input_png = tmppath / 'input.png'
|
input_png = input_file
|
||||||
im.save(input_png, format='PNG')
|
# unpaper can write .png too, but it seems to write them slowly
|
||||||
else:
|
# adds a few seconds to test suite - so just use pnm
|
||||||
# No changes, PNG input, just use the file we already have
|
output_pnm = tmppath / 'output.pnm'
|
||||||
input_png = input_file
|
yield input_png, output_pnm, tmppath
|
||||||
|
|
||||||
# unpaper can write .png too, but it seems to write them slowly
|
|
||||||
# adds a few seconds to test suite - so just use pnm
|
|
||||||
output_pnm = tmppath / 'output.pnm'
|
|
||||||
yield input_png, output_pnm, tmppath
|
|
||||||
|
|
||||||
|
|
||||||
def run_unpaper(
|
def run_unpaper(
|
||||||
|
|||||||
+66
-46
@@ -7,32 +7,46 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pikepdf import (
|
from pikepdf import (
|
||||||
Dictionary,
|
Dictionary,
|
||||||
|
Matrix,
|
||||||
Name,
|
Name,
|
||||||
Object,
|
|
||||||
Operator,
|
Operator,
|
||||||
|
Page,
|
||||||
Pdf,
|
Pdf,
|
||||||
PdfError,
|
PdfError,
|
||||||
PdfMatrix,
|
|
||||||
Stream,
|
Stream,
|
||||||
parse_content_stream,
|
parse_content_stream,
|
||||||
unparse_content_stream,
|
unparse_content_stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
|
|
||||||
|
|
||||||
|
class RenderMode(Enum):
|
||||||
|
ON_TOP = 0
|
||||||
|
UNDERNEATH = 1
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
MAX_REPLACE_PAGES = 100
|
MAX_REPLACE_PAGES = 100
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dictionary(obj, name):
|
def _ensure_dictionary(obj: Dictionary | Stream, name: Name):
|
||||||
if name not in obj:
|
if name not in obj:
|
||||||
obj[name] = Dictionary({})
|
obj[name] = Dictionary({})
|
||||||
return obj[name]
|
return obj[name]
|
||||||
|
|
||||||
|
|
||||||
def _update_resources(*, obj, font, font_key, procset):
|
def _update_resources(
|
||||||
|
*,
|
||||||
|
obj: Dictionary | Stream,
|
||||||
|
font: Dictionary | None,
|
||||||
|
font_key: Name | None,
|
||||||
|
):
|
||||||
"""Update this obj's fonts with a reference to the Glyphless font.
|
"""Update this obj's fonts with a reference to the Glyphless font.
|
||||||
|
|
||||||
obj can be a page or Form XObject.
|
obj can be a page or Form XObject.
|
||||||
@@ -42,13 +56,8 @@ def _update_resources(*, obj, font, font_key, procset):
|
|||||||
if font_key is not None and font_key not in fonts:
|
if font_key is not None and font_key not in fonts:
|
||||||
fonts[font_key] = font
|
fonts[font_key] = font
|
||||||
|
|
||||||
# Reassign /ProcSet to one that just lists everything - ProcSet is
|
|
||||||
# obsolete and doesn't matter but recommended for old viewer support
|
|
||||||
if procset:
|
|
||||||
resources['/ProcSet'] = procset
|
|
||||||
|
|
||||||
|
def strip_invisible_text(pdf: Pdf, page: Page):
|
||||||
def strip_invisible_text(pdf, page):
|
|
||||||
stream = []
|
stream = []
|
||||||
in_text_obj = False
|
in_text_obj = False
|
||||||
render_mode = 0
|
render_mode = 0
|
||||||
@@ -79,22 +88,20 @@ def strip_invisible_text(pdf, page):
|
|||||||
class OcrGrafter:
|
class OcrGrafter:
|
||||||
"""Manages grafting text-only PDFs onto regular PDFs."""
|
"""Manages grafting text-only PDFs onto regular PDFs."""
|
||||||
|
|
||||||
def __init__(self, context):
|
def __init__(self, context: PdfContext):
|
||||||
self.context = context
|
self.context = context
|
||||||
self.path_base = context.origin
|
self.path_base = context.origin
|
||||||
|
|
||||||
self.pdf_base = Pdf.open(self.path_base)
|
self.pdf_base = Pdf.open(self.path_base)
|
||||||
self.font, self.font_key = None, None
|
self.font: Dictionary | None = None
|
||||||
|
self.font_key: Name | None = None
|
||||||
|
|
||||||
self.pdfinfo = context.pdfinfo
|
self.pdfinfo = context.pdfinfo
|
||||||
self.output_file = context.get_path('graft_layers.pdf')
|
self.output_file = context.get_path('graft_layers.pdf')
|
||||||
|
|
||||||
self.procset = self.pdf_base.make_indirect(
|
|
||||||
Object.parse(b'[ /PDF /Text /ImageB /ImageC /ImageI ]')
|
|
||||||
)
|
|
||||||
|
|
||||||
self.emplacements = 1
|
self.emplacements = 1
|
||||||
self.interim_count = 0
|
self.interim_count = 0
|
||||||
|
self.render_mode = RenderMode.UNDERNEATH
|
||||||
|
|
||||||
def graft_page(
|
def graft_page(
|
||||||
self,
|
self,
|
||||||
@@ -119,7 +126,9 @@ class OcrGrafter:
|
|||||||
foreign_image_page = pdf_image.pages[0]
|
foreign_image_page = pdf_image.pages[0]
|
||||||
self.pdf_base.pages.append(foreign_image_page)
|
self.pdf_base.pages.append(foreign_image_page)
|
||||||
local_image_page = self.pdf_base.pages[-1]
|
local_image_page = self.pdf_base.pages[-1]
|
||||||
self.pdf_base.pages[pageno].emplace(local_image_page)
|
self.pdf_base.pages[pageno].emplace(
|
||||||
|
local_image_page, retain=(Name.Parent,)
|
||||||
|
)
|
||||||
del self.pdf_base.pages[-1]
|
del self.pdf_base.pages[-1]
|
||||||
emplaced_page = True
|
emplaced_page = True
|
||||||
|
|
||||||
@@ -135,6 +144,8 @@ class OcrGrafter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if textpdf and self.font:
|
if textpdf and self.font:
|
||||||
|
if self.font_key is None:
|
||||||
|
raise ValueError("Font key is not set")
|
||||||
# Graft the text layer onto this page, whether new or old, possibly
|
# Graft the text layer onto this page, whether new or old, possibly
|
||||||
# rotating the text layer by the amount is misaligned.
|
# rotating the text layer by the amount is misaligned.
|
||||||
strip_old = self.context.options.redo_ocr
|
strip_old = self.context.options.redo_ocr
|
||||||
@@ -144,7 +155,6 @@ class OcrGrafter:
|
|||||||
font=self.font,
|
font=self.font,
|
||||||
font_key=self.font_key,
|
font_key=self.font_key,
|
||||||
text_rotation=text_misaligned,
|
text_rotation=text_misaligned,
|
||||||
procset=self.procset,
|
|
||||||
strip_old_text=strip_old,
|
strip_old_text=strip_old,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,7 +169,7 @@ class OcrGrafter:
|
|||||||
if self.emplacements % MAX_REPLACE_PAGES == 0:
|
if self.emplacements % MAX_REPLACE_PAGES == 0:
|
||||||
self.save_and_reload()
|
self.save_and_reload()
|
||||||
|
|
||||||
def save_and_reload(self):
|
def save_and_reload(self) -> None:
|
||||||
"""Save and reload the Pdf.
|
"""Save and reload the Pdf.
|
||||||
|
|
||||||
This will keep a lid on our memory usage for very large files. Attach
|
This will keep a lid on our memory usage for very large files. Attach
|
||||||
@@ -167,9 +177,7 @@ class OcrGrafter:
|
|||||||
back.
|
back.
|
||||||
"""
|
"""
|
||||||
page0 = self.pdf_base.pages[0]
|
page0 = self.pdf_base.pages[0]
|
||||||
_update_resources(
|
_update_resources(obj=page0.obj, font=self.font, font_key=self.font_key)
|
||||||
obj=page0, font=self.font, font_key=self.font_key, procset=self.procset
|
|
||||||
)
|
|
||||||
|
|
||||||
# We cannot read and write the same file, that will corrupt it
|
# We cannot read and write the same file, that will corrupt it
|
||||||
# but we don't to keep more copies than we need to. Delete intermediates.
|
# but we don't to keep more copies than we need to. Delete intermediates.
|
||||||
@@ -188,7 +196,6 @@ class OcrGrafter:
|
|||||||
self.pdf_base.close()
|
self.pdf_base.close()
|
||||||
|
|
||||||
self.pdf_base = Pdf.open(next_file)
|
self.pdf_base = Pdf.open(next_file)
|
||||||
self.procset = self.pdf_base.pages[0].Resources.ProcSet
|
|
||||||
self.font, self.font_key = None, None # Ensure we reacquire this information
|
self.font, self.font_key = None, None # Ensure we reacquire this information
|
||||||
self.interim_count += 1
|
self.interim_count += 1
|
||||||
|
|
||||||
@@ -197,24 +204,32 @@ class OcrGrafter:
|
|||||||
self.pdf_base.close()
|
self.pdf_base.close()
|
||||||
return self.output_file
|
return self.output_file
|
||||||
|
|
||||||
def _find_font(self, text):
|
def _find_font(self, text: Path) -> tuple[Dictionary | None, Name | None]:
|
||||||
"""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
|
font, font_key = None, None
|
||||||
possible_font_names = ('/f-0-0', '/F1')
|
possible_font_names = ('/f-0-0', '/F1')
|
||||||
try:
|
try:
|
||||||
with Pdf.open(text) as pdf_text:
|
with Pdf.open(text) as pdf_text:
|
||||||
try:
|
try:
|
||||||
pdf_text_fonts = pdf_text.pages[0].Resources.get('/Font', {})
|
pdf_text_fonts = pdf_text.pages[0].Resources.get(
|
||||||
|
Name.Font, Dictionary()
|
||||||
|
)
|
||||||
except (AttributeError, IndexError, KeyError):
|
except (AttributeError, IndexError, KeyError):
|
||||||
return None, None
|
return None, None
|
||||||
|
if not isinstance(pdf_text_fonts, Dictionary):
|
||||||
|
log.warning("Page fonts are not stored in a dictionary")
|
||||||
|
return None, None
|
||||||
pdf_text_font = None
|
pdf_text_font = None
|
||||||
for f in possible_font_names:
|
for f in possible_font_names:
|
||||||
pdf_text_font = pdf_text_fonts.get(f, None)
|
pdf_text_font = pdf_text_fonts.get(f, None)
|
||||||
if pdf_text_font is not None:
|
if pdf_text_font is not None:
|
||||||
font_key = f
|
font_key = Name(f)
|
||||||
break
|
break
|
||||||
if pdf_text_font:
|
if pdf_text_font:
|
||||||
font = self.pdf_base.copy_foreign(pdf_text_font)
|
font = self.pdf_base.copy_foreign(pdf_text_font)
|
||||||
|
if not isinstance(font, Dictionary):
|
||||||
|
log.warning("Font is not a dictionary")
|
||||||
|
font, font_key = None, None
|
||||||
return font, font_key
|
return font, font_key
|
||||||
except (FileNotFoundError, PdfError):
|
except (FileNotFoundError, PdfError):
|
||||||
# PdfError occurs if a 0-length file is written e.g. due to OCR timeout
|
# PdfError occurs if a 0-length file is written e.g. due to OCR timeout
|
||||||
@@ -225,9 +240,8 @@ class OcrGrafter:
|
|||||||
*,
|
*,
|
||||||
page_num: int,
|
page_num: int,
|
||||||
textpdf: Path,
|
textpdf: Path,
|
||||||
font: Object,
|
font: Dictionary,
|
||||||
font_key: Object,
|
font_key: Name,
|
||||||
procset: Object,
|
|
||||||
text_rotation: int,
|
text_rotation: int,
|
||||||
strip_old_text: bool,
|
strip_old_text: bool,
|
||||||
):
|
):
|
||||||
@@ -248,19 +262,19 @@ class OcrGrafter:
|
|||||||
# content may have a rotation applied. Wrap the text stream with a rotation
|
# content may have a rotation applied. Wrap the text stream with a rotation
|
||||||
# so it will be oriented the same way as the rest of the page content.
|
# so it will be oriented the same way as the rest of the page content.
|
||||||
# (Previous versions OCRmyPDF rotated the content layer to match the text.)
|
# (Previous versions OCRmyPDF rotated the content layer to match the text.)
|
||||||
mediabox = [float(pdf_text.pages[0].MediaBox[v]) for v in range(4)]
|
mediabox = pdf_text.pages[0].mediabox
|
||||||
wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
|
wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
|
||||||
|
|
||||||
mediabox = [float(base_page.MediaBox[v]) for v in range(4)]
|
mediabox = base_page.mediabox
|
||||||
wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
|
wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
|
||||||
|
|
||||||
translate = PdfMatrix().translated(-wt / 2, -ht / 2)
|
translate = Matrix().translated(-wt / 2, -ht / 2)
|
||||||
untranslate = PdfMatrix().translated(wp / 2, hp / 2)
|
untranslate = Matrix().translated(wp / 2, hp / 2)
|
||||||
corner = PdfMatrix().translated(mediabox[0], mediabox[1])
|
corner = Matrix().translated(mediabox[0], mediabox[1])
|
||||||
# -rotation because the input is a clockwise angle and this formula
|
# -rotation because the input is a clockwise angle and this formula
|
||||||
# uses CCW
|
# uses CCW
|
||||||
text_rotation = -text_rotation % 360
|
text_rotation = -text_rotation % 360
|
||||||
rotate = PdfMatrix().rotated(text_rotation)
|
rotate = Matrix().rotated(text_rotation)
|
||||||
|
|
||||||
# Because of rounding of DPI, we might get a text layer that is not
|
# Because of rounding of DPI, we might get a text layer that is not
|
||||||
# identically sized to the target page. Scale to adjust. Normally this
|
# identically sized to the target page. Scale to adjust. Normally this
|
||||||
@@ -271,14 +285,15 @@ class OcrGrafter:
|
|||||||
scale_y = hp / ht
|
scale_y = hp / ht
|
||||||
|
|
||||||
# log.debug('%r', scale_x, scale_y)
|
# log.debug('%r', scale_x, scale_y)
|
||||||
scale = PdfMatrix().scaled(scale_x, scale_y)
|
scale = Matrix().scaled(scale_x, scale_y)
|
||||||
|
|
||||||
# Translate the text so it is centered at (0, 0), rotate it there, adjust
|
# Translate the text so it is centered at (0, 0), rotate it there, adjust
|
||||||
# for a size different between initial and text PDF, then untranslate, and
|
# for a size different between initial and text PDF, then untranslate, and
|
||||||
# finally move the lower left corner to match the mediabox
|
# finally move the lower left corner to match the mediabox.
|
||||||
ctm = translate @ rotate @ scale @ untranslate @ corner
|
ctm = translate @ rotate @ scale @ untranslate @ corner
|
||||||
|
log.debug("Grafting with ctm %r", ctm)
|
||||||
|
|
||||||
base_resources = _ensure_dictionary(base_page, Name.Resources)
|
base_resources = _ensure_dictionary(base_page.obj, Name.Resources)
|
||||||
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
|
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
|
||||||
text_xobj_name = Name.random(prefix="OCR-")
|
text_xobj_name = Name.random(prefix="OCR-")
|
||||||
xobj = self.pdf_base.make_stream(pdf_text_contents)
|
xobj = self.pdf_base.make_stream(pdf_text_contents)
|
||||||
@@ -287,9 +302,7 @@ class OcrGrafter:
|
|||||||
xobj.Subtype = Name.Form
|
xobj.Subtype = Name.Form
|
||||||
xobj.FormType = 1
|
xobj.FormType = 1
|
||||||
xobj.BBox = mediabox
|
xobj.BBox = mediabox
|
||||||
_update_resources(
|
_update_resources(obj=xobj, font=font, font_key=font_key)
|
||||||
obj=xobj, font=font, font_key=font_key, procset=[Name.PDF]
|
|
||||||
)
|
|
||||||
|
|
||||||
pdf_draw_xobj = (
|
pdf_draw_xobj = (
|
||||||
(b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
|
(b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
|
||||||
@@ -298,9 +311,16 @@ class OcrGrafter:
|
|||||||
|
|
||||||
if strip_old_text:
|
if strip_old_text:
|
||||||
strip_invisible_text(self.pdf_base, base_page)
|
strip_invisible_text(self.pdf_base, base_page)
|
||||||
|
base_page.contents_coalesce()
|
||||||
base_page.contents_add(new_text_layer, prepend=True)
|
if self.render_mode == RenderMode.ON_TOP:
|
||||||
|
# Add q/Q to ensure content we append is drawn correctly
|
||||||
_update_resources(
|
# Strictly speaking this needs to trace the whole q/Q stack in case
|
||||||
obj=base_page, font=font, font_key=font_key, procset=procset
|
# stack is not balanced.
|
||||||
|
original = base_page.Contents.read_bytes()
|
||||||
|
base_page.Contents.write(b'q\n' + original + b'\nQ\n')
|
||||||
|
base_page.contents_add(
|
||||||
|
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
|
||||||
)
|
)
|
||||||
|
base_page.contents_coalesce()
|
||||||
|
|
||||||
|
_update_resources(obj=base_page.obj, font=font, font_key=font_key)
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from copy import copy
|
from copy import copy
|
||||||
@@ -55,6 +53,12 @@ class PdfContext:
|
|||||||
for n in range(npages):
|
for n in range(npages):
|
||||||
yield PageContext(self, n)
|
yield PageContext(self, n)
|
||||||
|
|
||||||
|
def get_page_context_args(self) -> Iterator[tuple[PageContext]]:
|
||||||
|
"""Get all ``PageContext`` for this PDF packaged in tuple for args-splatting."""
|
||||||
|
npages = len(self.pdfinfo)
|
||||||
|
for n in range(npages):
|
||||||
|
yield (PageContext(self, n),)
|
||||||
|
|
||||||
|
|
||||||
class PageContext:
|
class PageContext:
|
||||||
"""Holds our context for a page.
|
"""Holds our context for a page.
|
||||||
@@ -89,15 +93,8 @@ class PageContext:
|
|||||||
state = self.__dict__.copy()
|
state = self.__dict__.copy()
|
||||||
|
|
||||||
state['options'] = copy(self.options)
|
state['options'] = copy(self.options)
|
||||||
if not isinstance(state['options'].input_file, (str, bytes, os.PathLike)):
|
if not isinstance(state['options'].input_file, str | bytes | os.PathLike):
|
||||||
state['options'].input_file = 'stream'
|
state['options'].input_file = 'stream'
|
||||||
if not isinstance(state['options'].output_file, (str, bytes, os.PathLike)):
|
if not isinstance(state['options'].output_file, str | bytes | os.PathLike):
|
||||||
state['options'].output_file = 'stream'
|
state['options'].output_file = 'stream'
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
def cleanup_working_files(work_folder: Path, options: Namespace):
|
|
||||||
if options.keep_temporary_files:
|
|
||||||
print(f"Temporary working files retained at:\n{work_folder}", file=sys.stderr)
|
|
||||||
else:
|
|
||||||
shutil.rmtree(work_folder, ignore_errors=True)
|
|
||||||
|
|||||||
@@ -9,15 +9,6 @@ import logging
|
|||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
from rich.progress import (
|
|
||||||
BarColumn,
|
|
||||||
MofNCompleteColumn,
|
|
||||||
Progress,
|
|
||||||
TaskProgressColumn,
|
|
||||||
TextColumn,
|
|
||||||
TimeRemainingColumn,
|
|
||||||
)
|
|
||||||
from rich.table import Column
|
|
||||||
|
|
||||||
|
|
||||||
class PageNumberFilter(logging.Filter):
|
class PageNumberFilter(logging.Filter):
|
||||||
@@ -37,56 +28,3 @@ class RichLoggingHandler(RichHandler):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
console=console, show_level=False, show_time=False, markup=True, **kwargs
|
console=console, show_level=False, show_time=False, markup=True, **kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RichTqdmProgressAdapter:
|
|
||||||
"""Adapt tqdm API to rich progress bar."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
console: Console,
|
|
||||||
desc: str,
|
|
||||||
total: float | None = None,
|
|
||||||
unit: str | None = None,
|
|
||||||
unit_scale: float | None = 1.0,
|
|
||||||
disable: bool = False,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
self.progress = Progress(
|
|
||||||
TextColumn(
|
|
||||||
"[progress.description]{task.description}",
|
|
||||||
table_column=Column(min_width=20),
|
|
||||||
),
|
|
||||||
BarColumn(),
|
|
||||||
TaskProgressColumn(),
|
|
||||||
MofNCompleteColumn(),
|
|
||||||
TimeRemainingColumn(),
|
|
||||||
console=console,
|
|
||||||
auto_refresh=True,
|
|
||||||
redirect_stderr=True,
|
|
||||||
redirect_stdout=False,
|
|
||||||
disable=disable,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
self.unit_scale = unit_scale
|
|
||||||
self.progress_bar = self.progress.add_task(
|
|
||||||
desc,
|
|
||||||
total=total * self.unit_scale
|
|
||||||
if total is not None and self.unit_scale is not None
|
|
||||||
else None,
|
|
||||||
unit=unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.progress.start()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
|
||||||
self.progress.refresh()
|
|
||||||
self.progress.stop()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def update(self, value=None):
|
|
||||||
advance = self.unit_scale if value is None else value
|
|
||||||
self.progress.update(self.progress_bar, advance=advance)
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""OCRmyPDF page processing pipeline functions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pikepdf import Dictionary, Name, Pdf
|
||||||
|
from pikepdf import __version__ as PIKEPDF_VERSION
|
||||||
|
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
|
||||||
|
|
||||||
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
|
from ocrmypdf._version import PROGRAM_NAME
|
||||||
|
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
|
||||||
|
from ocrmypdf.languages import iso_639_2_from_3
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_docinfo(base_pdf: Pdf, context: PdfContext) -> dict[str, str]:
|
||||||
|
"""Read the document info and store it in a dictionary."""
|
||||||
|
options = context.options
|
||||||
|
|
||||||
|
def from_document_info(key):
|
||||||
|
try:
|
||||||
|
s = base_pdf.docinfo[key]
|
||||||
|
return str(s)
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
pdfmark = {
|
||||||
|
k: from_document_info(k)
|
||||||
|
for k in ('/Title', '/Author', '/Keywords', '/Subject', '/CreationDate')
|
||||||
|
}
|
||||||
|
if options.title:
|
||||||
|
pdfmark['/Title'] = options.title
|
||||||
|
if options.author:
|
||||||
|
pdfmark['/Author'] = options.author
|
||||||
|
if options.keywords:
|
||||||
|
pdfmark['/Keywords'] = options.keywords
|
||||||
|
if options.subject:
|
||||||
|
pdfmark['/Subject'] = options.subject
|
||||||
|
|
||||||
|
creator_tag = context.plugin_manager.hook.get_ocr_engine().creator_tag(options)
|
||||||
|
|
||||||
|
pdfmark['/Creator'] = f'{PROGRAM_NAME} {OCRMYPF_VERSION} / {creator_tag}'
|
||||||
|
pdfmark['/Producer'] = f'pikepdf {PIKEPDF_VERSION}'
|
||||||
|
pdfmark['/ModDate'] = encode_pdf_date(datetime.now(timezone.utc))
|
||||||
|
return pdfmark
|
||||||
|
|
||||||
|
|
||||||
|
def report_on_metadata(options, missing):
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
if options.output_type.startswith('pdfa'):
|
||||||
|
log.warning(
|
||||||
|
"Some 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."
|
||||||
|
)
|
||||||
|
log.debug("The following metadata fields were not copied: %r", missing)
|
||||||
|
else:
|
||||||
|
log.error(
|
||||||
|
"Some input metadata could not be copied."
|
||||||
|
"You may wish to examine the output PDF's XMP metadata."
|
||||||
|
)
|
||||||
|
log.info("The following metadata fields were not copied: %r", missing)
|
||||||
|
|
||||||
|
|
||||||
|
def repair_docinfo_nuls(pdf):
|
||||||
|
"""If the DocumentInfo block contains NUL characters, remove them.
|
||||||
|
|
||||||
|
If the DocumentInfo block is malformed, log an error and continue.
|
||||||
|
"""
|
||||||
|
modified = False
|
||||||
|
try:
|
||||||
|
if not isinstance(pdf.docinfo, Dictionary):
|
||||||
|
raise TypeError("DocumentInfo is not a dictionary")
|
||||||
|
for k, v in pdf.docinfo.items():
|
||||||
|
if isinstance(v, str) and b'\x00' in bytes(v):
|
||||||
|
pdf.docinfo[k] = bytes(v).replace(b'\x00', b'')
|
||||||
|
modified = True
|
||||||
|
except TypeError:
|
||||||
|
# TypeError can also be raised if dictionary items are unexpected types
|
||||||
|
log.error("File contains a malformed DocumentInfo block - continuing anyway.")
|
||||||
|
return modified
|
||||||
|
|
||||||
|
|
||||||
|
def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
||||||
|
"""Determine whether the PDF should be linearized.
|
||||||
|
|
||||||
|
For smaller files, linearization is not worth the effort.
|
||||||
|
"""
|
||||||
|
filesize = os.stat(working_file).st_size
|
||||||
|
if filesize > (context.options.fast_web_view * 1_000_000):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_metadata(meta_original: PdfMetadata, meta_pdf: PdfMetadata):
|
||||||
|
# If xmp:CreateDate is missing, set it to the modify date to
|
||||||
|
# ensure consistency with Ghostscript.
|
||||||
|
if 'xmp:CreateDate' not in meta_pdf:
|
||||||
|
meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '')
|
||||||
|
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_pdf['dc:title']
|
||||||
|
|
||||||
|
|
||||||
|
def _unset_empty_metadata(meta: PdfMetadata, options):
|
||||||
|
"""Unset metadata fields that were explicitly set to empty strings.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _set_language(pdf: Pdf, languages: list[str]):
|
||||||
|
"""Set the language of the PDF."""
|
||||||
|
if Name.Lang in pdf.Root or not languages:
|
||||||
|
return # Already set or can't change
|
||||||
|
primary_language_iso639_3 = languages[0]
|
||||||
|
if not primary_language_iso639_3:
|
||||||
|
return
|
||||||
|
iso639_2 = iso_639_2_from_3(primary_language_iso639_3)
|
||||||
|
if not iso639_2:
|
||||||
|
return
|
||||||
|
pdf.Root.Lang = iso639_2
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataProgress:
|
||||||
|
def __init__(self, progressbar_class, enable: bool = True):
|
||||||
|
self.progressbar_class = progressbar_class
|
||||||
|
self.progressbar = self.progressbar_class(
|
||||||
|
total=100, desc="Linearizing", unit='%', disable=not enable
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.progressbar.__enter__()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
return self.progressbar.__exit__(exc_type, exc_value, traceback)
|
||||||
|
|
||||||
|
def __call__(self, percent: int):
|
||||||
|
if not self.progressbar_class:
|
||||||
|
return
|
||||||
|
self.progressbar.update(completed=percent)
|
||||||
|
|
||||||
|
|
||||||
|
def metadata_fixup(
|
||||||
|
working_file: Path, context: PdfContext, pdf_save_settings: dict[str, Any]
|
||||||
|
) -> Path:
|
||||||
|
"""Fix certain metadata fields whether PDF or PDF/A.
|
||||||
|
|
||||||
|
Override some of Ghostscript's metadata choices.
|
||||||
|
|
||||||
|
Also report on metadata in the input file that was not retained during
|
||||||
|
conversion.
|
||||||
|
"""
|
||||||
|
output_file = context.get_path('metafix.pdf')
|
||||||
|
options = context.options
|
||||||
|
|
||||||
|
pbar_class = context.plugin_manager.hook.get_progressbar_class()
|
||||||
|
with (
|
||||||
|
Pdf.open(context.origin) as original,
|
||||||
|
Pdf.open(working_file) as pdf,
|
||||||
|
MetadataProgress(pbar_class, options.progress_bar) as pbar,
|
||||||
|
):
|
||||||
|
docinfo = get_docinfo(original, context)
|
||||||
|
with (
|
||||||
|
original.open_metadata(
|
||||||
|
set_pikepdf_as_editor=False, update_docinfo=False, strict=False
|
||||||
|
) as meta_original,
|
||||||
|
pdf.open_metadata() as meta_pdf,
|
||||||
|
):
|
||||||
|
meta_pdf.load_from_docinfo(
|
||||||
|
docinfo, delete_missing=False, raise_failure=False
|
||||||
|
)
|
||||||
|
_fix_metadata(meta_original, meta_pdf)
|
||||||
|
_unset_empty_metadata(meta_original, options)
|
||||||
|
_unset_empty_metadata(meta_pdf, options)
|
||||||
|
meta_missing = set(meta_original.keys()) - set(meta_pdf.keys())
|
||||||
|
report_on_metadata(options, meta_missing)
|
||||||
|
|
||||||
|
_set_language(pdf, options.languages)
|
||||||
|
pdf.save(output_file, progress=pbar, **pdf_save_settings)
|
||||||
|
|
||||||
|
return output_file
|
||||||
+110
-151
@@ -12,21 +12,19 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from collections.abc import Iterable, Iterator, Sequence
|
from collections.abc import Iterable, Iterator, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime, timezone
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copyfileobj
|
from shutil import copyfileobj
|
||||||
from typing import Any, BinaryIO, TypeVar, cast
|
from typing import Any, BinaryIO, TypeVar, cast
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
import pikepdf
|
import pikepdf
|
||||||
from pikepdf.models.metadata import encode_pdf_date
|
|
||||||
from PIL import Image, ImageColor, ImageDraw
|
from PIL import Image, ImageColor, ImageDraw
|
||||||
|
|
||||||
from ocrmypdf._concurrent import Executor
|
from ocrmypdf._concurrent import Executor
|
||||||
from ocrmypdf._exec import unpaper
|
from ocrmypdf._exec import unpaper
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._version import PROGRAM_NAME
|
from ocrmypdf._metadata import repair_docinfo_nuls
|
||||||
from ocrmypdf._version import __version__ as VERSION
|
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
DigitalSignatureError,
|
DigitalSignatureError,
|
||||||
DpiError,
|
DpiError,
|
||||||
@@ -37,17 +35,29 @@ from ocrmypdf.exceptions import (
|
|||||||
UnsupportedImageFormatError,
|
UnsupportedImageFormatError,
|
||||||
)
|
)
|
||||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
|
||||||
from ocrmypdf.hocrtransform import HocrTransform
|
from ocrmypdf.hocrtransform import DebugRenderOptions, HocrTransform
|
||||||
|
from ocrmypdf.hocrtransform._font import Courier
|
||||||
from ocrmypdf.pdfa import generate_pdfa_ps
|
from ocrmypdf.pdfa import generate_pdfa_ps
|
||||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo
|
from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo
|
||||||
from ocrmypdf.pluginspec import OrientationConfidence
|
from ocrmypdf.pluginspec import OrientationConfidence
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pi_heif import register_heif_opener
|
||||||
|
except ImportError:
|
||||||
|
|
||||||
|
def register_heif_opener():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
VECTOR_PAGE_DPI = 400
|
VECTOR_PAGE_DPI = 400
|
||||||
|
|
||||||
|
|
||||||
|
register_heif_opener()
|
||||||
|
|
||||||
|
|
||||||
def triage_image_file(input_file: Path, output_file: Path, options) -> None:
|
def triage_image_file(input_file: Path, output_file: Path, options) -> None:
|
||||||
"""Triage the input image file.
|
"""Triage the input image file.
|
||||||
|
|
||||||
@@ -132,7 +142,7 @@ def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
|
|||||||
"""
|
"""
|
||||||
with open(input_file, 'rb') as f:
|
with open(input_file, 'rb') as f:
|
||||||
signature = f.read(search_window)
|
signature = f.read(search_window)
|
||||||
m = re.search(br'%PDF-(\d\.\d)', signature)
|
m = re.search(rb'%PDF-(\d\.\d)', signature)
|
||||||
if m:
|
if m:
|
||||||
return m.group(1).decode('ascii')
|
return m.group(1).decode('ascii')
|
||||||
return ''
|
return ''
|
||||||
@@ -168,6 +178,7 @@ def get_pdfinfo(
|
|||||||
detailed_analysis: bool = False,
|
detailed_analysis: bool = False,
|
||||||
progbar: bool = False,
|
progbar: bool = False,
|
||||||
max_workers: int | None = None,
|
max_workers: int | None = None,
|
||||||
|
use_threads: bool = True,
|
||||||
check_pages=None,
|
check_pages=None,
|
||||||
) -> PdfInfo:
|
) -> PdfInfo:
|
||||||
"""Get the PDF info."""
|
"""Get the PDF info."""
|
||||||
@@ -177,6 +188,7 @@ def get_pdfinfo(
|
|||||||
detailed_analysis=detailed_analysis,
|
detailed_analysis=detailed_analysis,
|
||||||
progbar=progbar,
|
progbar=progbar,
|
||||||
max_workers=max_workers,
|
max_workers=max_workers,
|
||||||
|
use_threads=use_threads,
|
||||||
check_pages=check_pages,
|
check_pages=check_pages,
|
||||||
executor=executor,
|
executor=executor,
|
||||||
)
|
)
|
||||||
@@ -463,7 +475,7 @@ def calculate_raster_dpi(page_context: PageContext):
|
|||||||
page_dpi = get_page_square_dpi(page_context, image_dpi)
|
page_dpi = get_page_square_dpi(page_context, image_dpi)
|
||||||
if dpi_profile and dpi_profile.average_to_max_dpi_ratio < 0.8:
|
if dpi_profile and dpi_profile.average_to_max_dpi_ratio < 0.8:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Weight average image DPI is %0.1f, max DPI is %0.1f. "
|
"Weighted average image DPI is %0.1f, max DPI is %0.1f. "
|
||||||
"The discrepancy may indicate a high detail region on this page, "
|
"The discrepancy may indicate a high detail region on this page, "
|
||||||
"but could also indicate a problem with the input PDF file. "
|
"but could also indicate a problem with the input PDF file. "
|
||||||
"Page image will be rendered at %0.1f DPI.",
|
"Page image will be rendered at %0.1f DPI.",
|
||||||
@@ -519,6 +531,7 @@ def rasterize(
|
|||||||
device_idx = at_least('png16m')
|
device_idx = at_least('png16m')
|
||||||
|
|
||||||
if pageinfo.has_vector:
|
if pageinfo.has_vector:
|
||||||
|
log.debug("Page has vector content, using png16m")
|
||||||
device_idx = at_least('png16m')
|
device_idx = at_least('png16m')
|
||||||
|
|
||||||
device = colorspaces[device_idx]
|
device = colorspaces[device_idx]
|
||||||
@@ -655,7 +668,7 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path,
|
|||||||
output_text=hocr_text_out,
|
output_text=hocr_text_out,
|
||||||
options=options,
|
options=options,
|
||||||
)
|
)
|
||||||
return (hocr_out, hocr_text_out)
|
return hocr_out, hocr_text_out
|
||||||
|
|
||||||
|
|
||||||
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
|
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
|
||||||
@@ -712,19 +725,29 @@ def create_pdf_page_from_image(
|
|||||||
pageinfo = page_context.pageinfo
|
pageinfo = page_context.pageinfo
|
||||||
pagesize = 72.0 * float(pageinfo.width_inches), 72.0 * float(pageinfo.height_inches)
|
pagesize = 72.0 * float(pageinfo.width_inches), 72.0 * float(pageinfo.height_inches)
|
||||||
effective_rotation = (pageinfo.rotation - orientation_correction) % 360
|
effective_rotation = (pageinfo.rotation - orientation_correction) % 360
|
||||||
if effective_rotation % 180 == 90:
|
swap_axis = effective_rotation % 180 == 90
|
||||||
|
if swap_axis:
|
||||||
pagesize = pagesize[1], pagesize[0]
|
pagesize = pagesize[1], pagesize[0]
|
||||||
|
|
||||||
# This create a single page PDF
|
# Create a new single page PDF to hold
|
||||||
with open(image, 'rb') as imfile, open(output_file, 'wb') as pdf:
|
bio = BytesIO()
|
||||||
|
with open(image, 'rb') as imfile:
|
||||||
log.debug('convert')
|
log.debug('convert')
|
||||||
|
|
||||||
layout_fun = img2pdf.get_layout_fun(pagesize)
|
layout_fun = img2pdf.get_layout_fun(pagesize)
|
||||||
img2pdf.convert(
|
img2pdf.convert(
|
||||||
imfile, layout_fun=layout_fun, outputstream=pdf, **IMG2PDF_KWARGS
|
imfile,
|
||||||
|
layout_fun=layout_fun,
|
||||||
|
outputstream=bio,
|
||||||
|
engine=img2pdf.Engine.pikepdf,
|
||||||
|
rotation=img2pdf.Rotation.ifvalid,
|
||||||
)
|
)
|
||||||
log.debug('convert done')
|
log.debug('convert done')
|
||||||
|
|
||||||
|
# img2pdf does not generate boxes correctly, so we fix them
|
||||||
|
bio.seek(0)
|
||||||
|
fix_pagepdf_boxes(bio, output_file, page_context, swap_axis=swap_axis)
|
||||||
|
|
||||||
output_file = page_context.plugin_manager.hook.filter_pdf_page(
|
output_file = page_context.plugin_manager.hook.filter_pdf_page(
|
||||||
page=page_context, image_filename=image, output_pdf=output_file
|
page=page_context, image_filename=image, output_pdf=output_file
|
||||||
)
|
)
|
||||||
@@ -735,16 +758,33 @@ def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
|
|||||||
"""Render the hOCR page to a PDF."""
|
"""Render the hOCR page to a PDF."""
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
output_file = page_context.get_path('ocr_hocr.pdf')
|
output_file = page_context.get_path('ocr_hocr.pdf')
|
||||||
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
|
if hocr.stat().st_size == 0:
|
||||||
debug_mode = options.pdf_renderer == 'hocrdebug'
|
# If hOCR file is empty (skipped page marker), create an empty PDF file
|
||||||
|
output_file.touch()
|
||||||
|
return output_file
|
||||||
|
|
||||||
hocrtransform = HocrTransform(hocr_filename=hocr, dpi=dpi.x) # square
|
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
|
||||||
hocrtransform.to_pdf(
|
debug_kwargs = {}
|
||||||
|
if options.pdf_renderer == 'hocrdebug':
|
||||||
|
debug_kwargs = dict(
|
||||||
|
debug_render_options=DebugRenderOptions(
|
||||||
|
render_baseline=True,
|
||||||
|
render_triangle=True,
|
||||||
|
render_line_bbox=False,
|
||||||
|
render_word_bbox=True,
|
||||||
|
render_paragraph_bbox=False,
|
||||||
|
render_space_bbox=False,
|
||||||
|
),
|
||||||
|
font=Courier(),
|
||||||
|
)
|
||||||
|
HocrTransform(
|
||||||
|
hocr_filename=hocr,
|
||||||
|
dpi=dpi.to_scalar(),
|
||||||
|
**debug_kwargs, # square
|
||||||
|
).to_pdf(
|
||||||
out_filename=output_file,
|
out_filename=output_file,
|
||||||
image_filename=None,
|
image_filename=None,
|
||||||
show_bounding_boxes=False if not debug_mode else True,
|
invisible_text=True if not debug_kwargs else False,
|
||||||
invisible_text=True if not debug_mode else False,
|
|
||||||
interword_spaces=True,
|
|
||||||
)
|
)
|
||||||
return output_file
|
return output_file
|
||||||
|
|
||||||
@@ -764,39 +804,57 @@ def ocr_engine_textonly_pdf(
|
|||||||
output_text=output_text,
|
output_text=output_text,
|
||||||
options=options,
|
options=options,
|
||||||
)
|
)
|
||||||
return (output_pdf, output_text)
|
return output_pdf, output_text
|
||||||
|
|
||||||
|
|
||||||
def get_docinfo(base_pdf: pikepdf.Pdf, context: PdfContext) -> dict[str, str]:
|
def _offset_rect(rect: tuple[float, float, float, float], offset: tuple[float, float]):
|
||||||
"""Read the document info and store it in a dictionary."""
|
"""Offset a rectangle by a given amount."""
|
||||||
options = context.options
|
return (
|
||||||
|
rect[0] + offset[0],
|
||||||
|
rect[1] + offset[1],
|
||||||
|
rect[2] + offset[0],
|
||||||
|
rect[3] + offset[1],
|
||||||
|
)
|
||||||
|
|
||||||
def from_document_info(key):
|
|
||||||
try:
|
|
||||||
s = base_pdf.docinfo[key]
|
|
||||||
return str(s)
|
|
||||||
except (KeyError, TypeError):
|
|
||||||
return ''
|
|
||||||
|
|
||||||
pdfmark = {
|
def fix_pagepdf_boxes(
|
||||||
k: from_document_info(k)
|
infile: Path | BinaryIO,
|
||||||
for k in ('/Title', '/Author', '/Keywords', '/Subject', '/CreationDate')
|
out_file: Path,
|
||||||
}
|
page_context: PageContext,
|
||||||
if options.title:
|
swap_axis: bool = False,
|
||||||
pdfmark['/Title'] = options.title
|
) -> Path:
|
||||||
if options.author:
|
"""Fix the bounding boxes in a single page PDF.
|
||||||
pdfmark['/Author'] = options.author
|
|
||||||
if options.keywords:
|
|
||||||
pdfmark['/Keywords'] = options.keywords
|
|
||||||
if options.subject:
|
|
||||||
pdfmark['/Subject'] = options.subject
|
|
||||||
|
|
||||||
creator_tag = context.plugin_manager.hook.get_ocr_engine().creator_tag(options)
|
The single page PDF is created with a normal MediaBox with its lower left corner
|
||||||
|
at (0, 0). infile is the single page PDF. page_context.mediabox has the original
|
||||||
|
file's mediabox, which may have a different origin. We needto adjust the other
|
||||||
|
boxes in the single page PDF to match the effect they had on the original page.
|
||||||
|
|
||||||
pdfmark['/Creator'] = f'{PROGRAM_NAME} {VERSION} / {creator_tag}'
|
When correcting page rotation, we create a single page PDF that is correctly
|
||||||
pdfmark['/Producer'] = f'pikepdf {pikepdf.__version__}'
|
rotated instead of an incorrectly rotated and then setting page.Rotate on it.
|
||||||
pdfmark['/ModDate'] = encode_pdf_date(datetime.now(timezone.utc))
|
If rotation is either 90 or 270 degrees, then this function can be called
|
||||||
return pdfmark
|
with swap_axis to swap the X and Y coordinates of all the boxes.
|
||||||
|
|
||||||
|
We are not concerned with solving degenerate cases where the boxes overlap or
|
||||||
|
or express invalid rectangles. We merely pass the boxes, producing a
|
||||||
|
transformation equivalent to the change made by constructing a new page image.
|
||||||
|
"""
|
||||||
|
with pikepdf.open(infile) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
# page.BleedBox = page_context.pageinfo.bleedbox
|
||||||
|
# page.ArtBox = page_context.pageinfo.artbox
|
||||||
|
mediabox = page_context.pageinfo.mediabox
|
||||||
|
offset = mediabox[0], mediabox[1]
|
||||||
|
cropbox = _offset_rect(page_context.pageinfo.cropbox, offset)
|
||||||
|
trimbox = _offset_rect(page_context.pageinfo.trimbox, offset)
|
||||||
|
|
||||||
|
if swap_axis:
|
||||||
|
cropbox = cropbox[1], cropbox[0], cropbox[3], cropbox[2]
|
||||||
|
trimbox = trimbox[1], trimbox[0], trimbox[3], trimbox[2]
|
||||||
|
page.CropBox = cropbox
|
||||||
|
page.TrimBox = trimbox
|
||||||
|
pdf.save(out_file)
|
||||||
|
return pdf
|
||||||
|
|
||||||
|
|
||||||
def generate_postscript_stub(context: PdfContext) -> Path:
|
def generate_postscript_stub(context: PdfContext) -> Path:
|
||||||
@@ -833,7 +891,7 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
|
|||||||
# pikepdf can deal with this, but we make the world a better place by
|
# pikepdf can deal with this, but we make the world a better place by
|
||||||
# stamping them out as soon as possible.
|
# stamping them out as soon as possible.
|
||||||
with pikepdf.open(input_pdf) as pdf_file:
|
with pikepdf.open(input_pdf) as pdf_file:
|
||||||
if _repair_docinfo_nuls(pdf_file):
|
if repair_docinfo_nuls(pdf_file):
|
||||||
pdf_file.save(fix_docinfo_file)
|
pdf_file.save(fix_docinfo_file)
|
||||||
else:
|
else:
|
||||||
safe_symlink(input_pdf, fix_docinfo_file)
|
safe_symlink(input_pdf, fix_docinfo_file)
|
||||||
@@ -856,25 +914,6 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
|
|||||||
return output_file
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
def _repair_docinfo_nuls(pdf):
|
|
||||||
"""If the DocumentInfo block contains NUL characters, remove them.
|
|
||||||
|
|
||||||
If the DocumentInfo block is malformed, log an error and continue.
|
|
||||||
"""
|
|
||||||
modified = False
|
|
||||||
try:
|
|
||||||
if not isinstance(pdf.docinfo, pikepdf.Dictionary):
|
|
||||||
raise TypeError("DocumentInfo is not a dictionary")
|
|
||||||
for k, v in pdf.docinfo.items():
|
|
||||||
if isinstance(v, str) and b'\x00' in bytes(v):
|
|
||||||
pdf.docinfo[k] = bytes(v).replace(b'\x00', b'')
|
|
||||||
modified = True
|
|
||||||
except TypeError:
|
|
||||||
# TypeError can also be raised if dictionary items are unexpected types
|
|
||||||
log.error("File contains a malformed DocumentInfo block - continuing anyway.")
|
|
||||||
return modified
|
|
||||||
|
|
||||||
|
|
||||||
def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
||||||
"""Determine whether the PDF should be linearized.
|
"""Determine whether the PDF should be linearized.
|
||||||
|
|
||||||
@@ -909,86 +948,6 @@ def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def metadata_fixup(working_file: Path, context: PdfContext) -> Path:
|
|
||||||
"""Fix certain metadata fields after Ghostscript PDF/A conversion.
|
|
||||||
|
|
||||||
Also report on metadata in the input file that was not retained during
|
|
||||||
PDF/A conversion.
|
|
||||||
"""
|
|
||||||
output_file = context.get_path('metafix.pdf')
|
|
||||||
options = context.options
|
|
||||||
|
|
||||||
def report_on_metadata(missing):
|
|
||||||
if not missing:
|
|
||||||
return
|
|
||||||
if options.output_type.startswith('pdfa'):
|
|
||||||
log.warning(
|
|
||||||
"Some 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."
|
|
||||||
)
|
|
||||||
log.debug("The following metadata fields were not copied: %r", missing)
|
|
||||||
else:
|
|
||||||
log.error(
|
|
||||||
"Some input metadata could not be copied."
|
|
||||||
"You may wish to examine the output PDF's XMP metadata."
|
|
||||||
)
|
|
||||||
log.info("The following metadata fields were not copied: %r", missing)
|
|
||||||
|
|
||||||
with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf:
|
|
||||||
docinfo = get_docinfo(original, context)
|
|
||||||
with original.open_metadata(
|
|
||||||
set_pikepdf_as_editor=False, update_docinfo=False, strict=False
|
|
||||||
) as meta_original, pdf.open_metadata() as meta_pdf:
|
|
||||||
meta_pdf.load_from_docinfo(
|
|
||||||
docinfo, delete_missing=False, raise_failure=False
|
|
||||||
)
|
|
||||||
# If xmp:CreateDate is missing, set it to the modify date to
|
|
||||||
# ensure consistency with Ghostscript.
|
|
||||||
if 'xmp:CreateDate' not in meta_pdf:
|
|
||||||
meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '')
|
|
||||||
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_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
|
|
||||||
)
|
|
||||||
pdf.save(
|
|
||||||
output_file,
|
|
||||||
**get_pdf_save_settings(options.output_type),
|
|
||||||
linearize=( # Don't linearize if optimize() will be linearizing too
|
|
||||||
not optimizing and should_linearize(working_file, context)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return output_file
|
|
||||||
|
|
||||||
|
|
||||||
def _file_size_ratio(
|
def _file_size_ratio(
|
||||||
input_file: Path, output_file: Path
|
input_file: Path, output_file: Path
|
||||||
) -> tuple[float | None, float | None]:
|
) -> tuple[float | None, float | None]:
|
||||||
@@ -1036,7 +995,7 @@ def optimize_pdf(
|
|||||||
|
|
||||||
def enumerate_compress_ranges(
|
def enumerate_compress_ranges(
|
||||||
iterable: Iterable[T],
|
iterable: Iterable[T],
|
||||||
) -> Iterator[tuple[tuple[int, int], T]]:
|
) -> Iterator[tuple[tuple[int, int], T | None]]:
|
||||||
"""Enumerate the ranges of non-empty elements in an iterable.
|
"""Enumerate the ranges of non-empty elements in an iterable.
|
||||||
|
|
||||||
Compresses consecutive ranges of length 1 into single elements.
|
Compresses consecutive ranges of length 1 into single elements.
|
||||||
@@ -1090,14 +1049,14 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
|
|||||||
|
|
||||||
|
|
||||||
def copy_final(
|
def copy_final(
|
||||||
input_file: Path, output_file: str | Path | BinaryIO, _context: PdfContext
|
input_file: Path, output_file: str | Path | BinaryIO, original_file: Path | None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Copy the final temporary file to the output destination.
|
"""Copy the final temporary file to the output destination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_file (Path): The input file to copy.
|
input_file (Path): The intermediate input file to copy.
|
||||||
output_file (str | Path | BinaryIO): The output file to copy to.
|
output_file (str | Path | BinaryIO): The output file to copy to.
|
||||||
_context (PdfContext): The PDF context.
|
original_file: The original file to copy attributes from.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None
|
None
|
||||||
@@ -1116,5 +1075,5 @@ def copy_final(
|
|||||||
# At this point we overwrite the output_file specified by the user
|
# At this point we overwrite the output_file specified by the user
|
||||||
# use copyfileobj because then we use open() to create the file and
|
# use copyfileobj because then we use open() to create the file and
|
||||||
# get the appropriate umask, ownership, etc.
|
# get the appropriate umask, ownership, etc.
|
||||||
with open(output_file, 'wb') as output_stream:
|
with open(output_file, 'w+b') as output_stream:
|
||||||
copyfileobj(input_stream, output_stream)
|
copyfileobj(input_stream, output_stream)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
@@ -1,66 +1,53 @@
|
|||||||
# SPDX-FileCopyrightText: 2019-2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
# SPDX-FileCopyrightText: 2019 Martin Wind
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
"""Implements the concurrent and page synchronous parts of the pipeline."""
|
|
||||||
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from concurrent.futures.process import BrokenProcessPool
|
from concurrent.futures.process import BrokenProcessPool
|
||||||
from concurrent.futures.thread import BrokenThreadPool
|
from concurrent.futures.thread import BrokenThreadPool
|
||||||
from functools import partial
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import mkdtemp
|
|
||||||
from typing import NamedTuple, cast
|
from typing import NamedTuple, cast
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
|
|
||||||
from ocrmypdf._concurrent import Executor, setup_executor
|
from ocrmypdf._concurrent import Executor, setup_executor
|
||||||
from ocrmypdf._graft import OcrGrafter
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext, cleanup_working_files
|
|
||||||
from ocrmypdf._logging import PageNumberFilter
|
from ocrmypdf._logging import PageNumberFilter
|
||||||
|
from ocrmypdf._metadata import metadata_fixup
|
||||||
from ocrmypdf._pipeline import (
|
from ocrmypdf._pipeline import (
|
||||||
convert_to_pdfa,
|
convert_to_pdfa,
|
||||||
copy_final,
|
|
||||||
create_ocr_image,
|
create_ocr_image,
|
||||||
create_pdf_page_from_image,
|
create_pdf_page_from_image,
|
||||||
create_visible_page_jpg,
|
create_visible_page_jpg,
|
||||||
generate_postscript_stub,
|
generate_postscript_stub,
|
||||||
get_orientation_correction,
|
get_orientation_correction,
|
||||||
get_pdfinfo,
|
get_pdf_save_settings,
|
||||||
is_ocr_required,
|
|
||||||
merge_sidecars,
|
|
||||||
metadata_fixup,
|
|
||||||
ocr_engine_hocr,
|
|
||||||
ocr_engine_textonly_pdf,
|
|
||||||
optimize_pdf,
|
optimize_pdf,
|
||||||
preprocess_clean,
|
preprocess_clean,
|
||||||
preprocess_deskew,
|
preprocess_deskew,
|
||||||
preprocess_remove_background,
|
preprocess_remove_background,
|
||||||
rasterize,
|
rasterize,
|
||||||
rasterize_preview,
|
rasterize_preview,
|
||||||
render_hocr_page,
|
should_linearize,
|
||||||
should_visible_page_image_use_jpg,
|
should_visible_page_image_use_jpg,
|
||||||
triage,
|
|
||||||
validate_pdfinfo_options,
|
|
||||||
)
|
)
|
||||||
from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager
|
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
||||||
from ocrmypdf._validation import (
|
from ocrmypdf._validation import (
|
||||||
check_requested_output_file,
|
|
||||||
create_input_file,
|
|
||||||
report_output_file_size,
|
report_output_file_size,
|
||||||
)
|
)
|
||||||
from ocrmypdf.exceptions import ExitCode, ExitCodeException
|
from ocrmypdf.exceptions import ExitCode, ExitCodeException
|
||||||
from ocrmypdf.helpers import (
|
from ocrmypdf.helpers import (
|
||||||
NeverRaise,
|
|
||||||
available_cpu_count,
|
available_cpu_count,
|
||||||
check_pdf,
|
check_pdf,
|
||||||
pikepdf_enable_mmap,
|
pikepdf_enable_mmap,
|
||||||
@@ -69,33 +56,248 @@ from ocrmypdf.helpers import (
|
|||||||
from ocrmypdf.pdfa import file_claims_pdfa
|
from ocrmypdf.pdfa import file_claims_pdfa
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
tls = threading.local()
|
||||||
|
tls.pageno = None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_logging_tls(tls):
|
||||||
|
"""Inject current page number (when available) into log records."""
|
||||||
|
old_factory = logging.getLogRecordFactory()
|
||||||
|
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
record = old_factory(*args, **kwargs)
|
||||||
|
if hasattr(tls, 'pageno'):
|
||||||
|
record.pageno = tls.pageno
|
||||||
|
return record
|
||||||
|
|
||||||
|
logging.setLogRecordFactory(wrapper)
|
||||||
|
|
||||||
|
|
||||||
|
_set_logging_tls(tls)
|
||||||
|
|
||||||
|
|
||||||
|
def set_thread_pageno(pageno: int | None):
|
||||||
|
"""Set page number (1-based) that the current thread is processing."""
|
||||||
|
tls.pageno = pageno
|
||||||
|
|
||||||
|
|
||||||
class PageResult(NamedTuple):
|
class PageResult(NamedTuple):
|
||||||
"""Result when a page is finished processing."""
|
"""Result when a page is finished processing."""
|
||||||
|
|
||||||
pageno: int
|
pageno: int
|
||||||
pdf_page_from_image: Path | None
|
"""Page number, 0-based."""
|
||||||
ocr: Path | None
|
|
||||||
text: Path | None
|
pdf_page_from_image: Path | None = None
|
||||||
orientation_correction: int
|
"""Single page PDF from image."""
|
||||||
|
|
||||||
|
ocr: Path | None = None
|
||||||
|
"""Single page OCR PDF."""
|
||||||
|
|
||||||
|
text: Path | None = None
|
||||||
|
"""Single page text file."""
|
||||||
|
|
||||||
|
orientation_correction: int = 0
|
||||||
|
"""Orientation correction in degrees."""
|
||||||
|
|
||||||
|
|
||||||
tls = threading.local()
|
@dataclass
|
||||||
tls.pageno = None
|
class HOCRResult:
|
||||||
|
"""Result when hOCR is finished processing."""
|
||||||
|
|
||||||
|
pageno: int
|
||||||
|
"""Page number, 0-based."""
|
||||||
|
|
||||||
|
pdf_page_from_image: Path | None = None
|
||||||
|
"""Single page PDF from image."""
|
||||||
|
|
||||||
|
hocr: Path | None = None
|
||||||
|
"""Single page hOCR file."""
|
||||||
|
|
||||||
|
textpdf: Path | None = None
|
||||||
|
"""hOCR file after conversion to PDF."""
|
||||||
|
|
||||||
|
orientation_correction: int = 0
|
||||||
|
"""Orientation correction in degrees."""
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
"""Return state values to be pickled."""
|
||||||
|
return {
|
||||||
|
k: (
|
||||||
|
('Path://' + str(v))
|
||||||
|
if k in ('pdf_page_from_image', 'hocr', 'textpdf') and v is not None
|
||||||
|
else v
|
||||||
|
)
|
||||||
|
for k, v in self.__dict__.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
"""Restore state from the unpickled state values."""
|
||||||
|
self.__dict__.update(
|
||||||
|
{
|
||||||
|
k: (
|
||||||
|
Path(v.removeprefix('Path://'))
|
||||||
|
if k in ('pdf_page_from_image', 'hocr', 'textpdf') and v is not None
|
||||||
|
else v
|
||||||
|
)
|
||||||
|
for k, v in state.items()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, json_str: str) -> HOCRResult:
|
||||||
|
"""Create an instance from a dict."""
|
||||||
|
return cls(**json.loads(json_str))
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
"""Serialize to a JSON string."""
|
||||||
|
return json.dumps(self.__getstate__())
|
||||||
|
|
||||||
|
|
||||||
old_factory = logging.getLogRecordFactory()
|
def configure_debug_logging(
|
||||||
|
log_filename: Path, prefix: str = ''
|
||||||
|
) -> tuple[logging.FileHandler, Callable[[], None]]:
|
||||||
|
"""Create a debug log file at a specified location.
|
||||||
|
|
||||||
|
Returns the log handler, and a function to remove the handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_filename: Where to the put the log file.
|
||||||
|
prefix: The logging domain prefix that should be sent to the log.
|
||||||
|
"""
|
||||||
|
log_file_handler = logging.FileHandler(log_filename, delay=True)
|
||||||
|
log_file_handler.setLevel(logging.DEBUG)
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'[%(asctime)s] - %(name)s - %(levelname)7s -%(pageno)s %(message)s'
|
||||||
|
)
|
||||||
|
log_file_handler.setFormatter(formatter)
|
||||||
|
log_file_handler.addFilter(PageNumberFilter())
|
||||||
|
logging.getLogger(prefix).addHandler(log_file_handler)
|
||||||
|
|
||||||
|
def remover():
|
||||||
|
try:
|
||||||
|
logging.getLogger(prefix).removeHandler(log_file_handler)
|
||||||
|
log_file_handler.close()
|
||||||
|
except OSError as e:
|
||||||
|
print(e, file=sys.stderr)
|
||||||
|
|
||||||
|
return log_file_handler, remover
|
||||||
|
|
||||||
|
|
||||||
def record_factory(*args, **kwargs):
|
def worker_init(max_pixels: int) -> None:
|
||||||
record = old_factory(*args, **kwargs)
|
"""Initialize a worker thread or process."""
|
||||||
if hasattr(tls, 'pageno'):
|
# In Windows, child process will not inherit our change to this value in
|
||||||
record.pageno = tls.pageno
|
# the parent process, so ensure workers get it set. Not needed when running
|
||||||
return record
|
# threaded, but harmless to set again.
|
||||||
|
PIL.Image.MAX_IMAGE_PIXELS = max_pixels
|
||||||
|
pikepdf_enable_mmap()
|
||||||
|
|
||||||
|
|
||||||
logging.setLogRecordFactory(record_factory)
|
@contextmanager
|
||||||
|
def manage_debug_log_handler(
|
||||||
|
*,
|
||||||
|
options: argparse.Namespace,
|
||||||
|
work_folder: Path,
|
||||||
|
):
|
||||||
|
remover = None
|
||||||
|
if (options.keep_temporary_files or options.verbose >= 1) and not os.environ.get(
|
||||||
|
'PYTEST_CURRENT_TEST', ''
|
||||||
|
):
|
||||||
|
# Debug log for command line interface only with verbose output
|
||||||
|
# See https://github.com/pytest-dev/pytest/issues/5502 for why we skip this
|
||||||
|
# when pytest is running
|
||||||
|
_debug_log_handler, remover = configure_debug_logging(
|
||||||
|
work_folder / "debug.log", prefix=""
|
||||||
|
) # pragma: no cover
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if remover:
|
||||||
|
remover()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def manage_work_folder(*, work_folder: Path, retain: bool, print_location: bool):
|
||||||
|
try:
|
||||||
|
yield work_folder
|
||||||
|
finally:
|
||||||
|
if retain:
|
||||||
|
if print_location:
|
||||||
|
print(
|
||||||
|
f"Temporary working files retained at:\n{work_folder}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shutil.rmtree(work_folder, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def cli_exception_handler(
|
||||||
|
fn: Callable[[argparse.Namespace, OcrmypdfPluginManager], ExitCode],
|
||||||
|
options: argparse.Namespace,
|
||||||
|
plugin_manager: OcrmypdfPluginManager,
|
||||||
|
) -> ExitCode:
|
||||||
|
"""Convert exceptions into command line error messages and exit codes.
|
||||||
|
|
||||||
|
When known exceptions are raised, the exception message is printed to stderr
|
||||||
|
and the program exits with a non-zero exit code. When unknown exceptions are
|
||||||
|
raised, the exception traceback is printed to stderr and the program exits
|
||||||
|
with a non-zero exit code.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# We cannot use a generator and yield here, as would be the usual pattern
|
||||||
|
# for exception handling context managers, because we need to return an exit
|
||||||
|
# code.
|
||||||
|
return fn(options, plugin_manager)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
if options.verbose >= 1:
|
||||||
|
log.exception("KeyboardInterrupt")
|
||||||
|
else:
|
||||||
|
log.error("KeyboardInterrupt")
|
||||||
|
return ExitCode.ctrl_c
|
||||||
|
except ExitCodeException as e:
|
||||||
|
e = cast(ExitCodeException, e)
|
||||||
|
if options.verbose >= 1:
|
||||||
|
log.exception("ExitCodeException")
|
||||||
|
elif str(e):
|
||||||
|
log.error("%s: %s", type(e).__name__, str(e))
|
||||||
|
else:
|
||||||
|
log.error(type(e).__name__)
|
||||||
|
return e.exit_code
|
||||||
|
except PIL.Image.DecompressionBombError:
|
||||||
|
log.exception(
|
||||||
|
"A decompression bomb error was encountered while executing the "
|
||||||
|
"pipeline. Use the argument --max-image-mpixels to raise the maximum "
|
||||||
|
"image pixel limit."
|
||||||
|
)
|
||||||
|
return ExitCode.other_error
|
||||||
|
except (
|
||||||
|
BrokenProcessPool,
|
||||||
|
BrokenThreadPool,
|
||||||
|
):
|
||||||
|
log.exception(
|
||||||
|
"A worker process was terminated unexpectedly. This is known to occur if "
|
||||||
|
"processing your file takes all available swap space and RAM. It may "
|
||||||
|
"help to try again with a smaller number of jobs, using the --jobs "
|
||||||
|
"argument."
|
||||||
|
)
|
||||||
|
return ExitCode.child_process_error
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
log.exception("An exception occurred while executing the pipeline")
|
||||||
|
return ExitCode.other_error
|
||||||
|
|
||||||
|
|
||||||
|
def setup_pipeline(
|
||||||
|
options: argparse.Namespace,
|
||||||
|
plugin_manager: OcrmypdfPluginManager,
|
||||||
|
) -> Executor:
|
||||||
|
# Any changes to options will not take effect for options that are already
|
||||||
|
# bound to function parameters in the pipeline. (For example
|
||||||
|
# options.input_file, options.pdf_renderer are already bound.)
|
||||||
|
if not options.jobs:
|
||||||
|
options.jobs = available_cpu_count()
|
||||||
|
|
||||||
|
pikepdf_enable_mmap()
|
||||||
|
executor = setup_executor(plugin_manager)
|
||||||
|
return executor
|
||||||
|
|
||||||
|
|
||||||
def preprocess(
|
def preprocess(
|
||||||
@@ -175,20 +377,9 @@ def make_intermediate_images(
|
|||||||
return ocr_image, preprocess_out
|
return ocr_image, preprocess_out
|
||||||
|
|
||||||
|
|
||||||
def exec_page_sync(page_context: PageContext) -> PageResult:
|
def process_page(page_context: PageContext) -> tuple[Path, Path | None, int]:
|
||||||
"""Execute a pipeline for a single page synchronously."""
|
"""Process page to create OCR image, visible page image and orientation."""
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
tls.pageno = page_context.pageno + 1
|
|
||||||
|
|
||||||
if not is_ocr_required(page_context):
|
|
||||||
return PageResult(
|
|
||||||
pageno=page_context.pageno,
|
|
||||||
pdf_page_from_image=None,
|
|
||||||
ocr=None,
|
|
||||||
text=None,
|
|
||||||
orientation_correction=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
orientation_correction = 0
|
orientation_correction = 0
|
||||||
if options.rotate_pages:
|
if options.rotate_pages:
|
||||||
# Rasterize
|
# Rasterize
|
||||||
@@ -216,25 +407,10 @@ def exec_page_sync(page_context: PageContext) -> PageResult:
|
|||||||
pdf_page_from_image_out = create_pdf_page_from_image(
|
pdf_page_from_image_out = create_pdf_page_from_image(
|
||||||
visible_image_out, page_context, orientation_correction
|
visible_image_out, page_context, orientation_correction
|
||||||
)
|
)
|
||||||
|
return ocr_image_out, pdf_page_from_image_out, orientation_correction
|
||||||
if options.pdf_renderer.startswith('hocr'):
|
|
||||||
(hocr_out, text_out) = ocr_engine_hocr(ocr_image_out, page_context)
|
|
||||||
ocr_out = render_hocr_page(hocr_out, page_context)
|
|
||||||
elif options.pdf_renderer == 'sandwich':
|
|
||||||
(ocr_out, text_out) = ocr_engine_textonly_pdf(ocr_image_out, page_context)
|
|
||||||
else:
|
|
||||||
raise NotImplementedError(f"pdf_renderer {options.pdf_renderer}")
|
|
||||||
|
|
||||||
return PageResult(
|
|
||||||
pageno=page_context.pageno,
|
|
||||||
pdf_page_from_image=pdf_page_from_image_out,
|
|
||||||
ocr=ocr_out,
|
|
||||||
text=text_out,
|
|
||||||
orientation_correction=orientation_correction,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def post_process(
|
def postprocess(
|
||||||
pdf_file: Path, context: PdfContext, executor: Executor
|
pdf_file: Path, context: PdfContext, executor: Executor
|
||||||
) -> tuple[Path, Sequence[str]]:
|
) -> tuple[Path, Sequence[str]]:
|
||||||
"""Postprocess the PDF file."""
|
"""Postprocess the PDF file."""
|
||||||
@@ -243,242 +419,36 @@ def post_process(
|
|||||||
ps_stub_out = generate_postscript_stub(context)
|
ps_stub_out = generate_postscript_stub(context)
|
||||||
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
|
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
|
||||||
|
|
||||||
pdf_out = metadata_fixup(pdf_out, context)
|
optimizing = context.plugin_manager.hook.is_optimization_enabled(context=context)
|
||||||
|
save_settings = get_pdf_save_settings(context.options.output_type)
|
||||||
|
save_settings['linearize'] = not optimizing and should_linearize(pdf_out, context)
|
||||||
|
|
||||||
|
pdf_out = metadata_fixup(pdf_out, context, pdf_save_settings=save_settings)
|
||||||
return optimize_pdf(pdf_out, context, executor)
|
return optimize_pdf(pdf_out, context, executor)
|
||||||
|
|
||||||
|
|
||||||
def worker_init(max_pixels: int) -> None:
|
def report_output_pdf(options, start_input_file, optimize_messages) -> ExitCode:
|
||||||
"""Initialize a worker thread or process."""
|
if options.output_file == '-':
|
||||||
# In Windows, child process will not inherit our change to this value in
|
log.info("Output sent to stdout")
|
||||||
# the parent process, so ensure workers get it set. Not needed when running
|
elif hasattr(options.output_file, 'writable') and options.output_file.writable():
|
||||||
# threaded, but harmless to set again.
|
log.info("Output written to stream")
|
||||||
PIL.Image.MAX_IMAGE_PIXELS = max_pixels
|
elif samefile(options.output_file, Path(os.devnull)):
|
||||||
pikepdf_enable_mmap()
|
pass # Say nothing when sending to dev null
|
||||||
|
else:
|
||||||
|
if options.output_type.startswith('pdfa'):
|
||||||
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
pdfa_info = file_claims_pdfa(options.output_file)
|
||||||
"""Execute the OCR pipeline concurrently."""
|
if pdfa_info['pass']:
|
||||||
# Run exec_page_sync on every page
|
log.info("Output file is a %s (as expected)", pdfa_info['conformance'])
|
||||||
options = context.options
|
else:
|
||||||
max_workers = min(len(context.pdfinfo), options.jobs)
|
log.warning(
|
||||||
if max_workers > 1:
|
"Output file is okay but is not PDF/A (seems to be %s)",
|
||||||
log.info("Start processing %d pages concurrently", max_workers)
|
pdfa_info['conformance'],
|
||||||
|
)
|
||||||
sidecars: list[Path | None] = [None] * len(context.pdfinfo)
|
return ExitCode.pdfa_conversion_failed
|
||||||
ocrgraft = OcrGrafter(context)
|
if not check_pdf(options.output_file):
|
||||||
|
log.warning('Output file: The generated PDF is INVALID')
|
||||||
def update_page(result: PageResult, pbar):
|
return ExitCode.invalid_output_pdf
|
||||||
"""After OCR is complete for a page, update the PDF."""
|
report_output_file_size(
|
||||||
try:
|
options, start_input_file, options.output_file, optimize_messages
|
||||||
tls.pageno = result.pageno + 1
|
|
||||||
sidecars[result.pageno] = result.text
|
|
||||||
pbar.update()
|
|
||||||
ocrgraft.graft_page(
|
|
||||||
pageno=result.pageno,
|
|
||||||
image=result.pdf_page_from_image,
|
|
||||||
textpdf=result.ocr,
|
|
||||||
autorotate_correction=result.orientation_correction,
|
|
||||||
)
|
|
||||||
pbar.update()
|
|
||||||
finally:
|
|
||||||
tls.pageno = None
|
|
||||||
|
|
||||||
executor(
|
|
||||||
use_threads=options.use_threads,
|
|
||||||
max_workers=max_workers,
|
|
||||||
tqdm_kwargs=dict(
|
|
||||||
total=(2 * len(context.pdfinfo)),
|
|
||||||
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
|
|
||||||
unit='page',
|
|
||||||
unit_scale=0.5,
|
|
||||||
disable=not options.progress_bar,
|
|
||||||
),
|
|
||||||
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
|
|
||||||
task=exec_page_sync,
|
|
||||||
task_arguments=context.get_page_contexts(),
|
|
||||||
task_finished=update_page,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Output sidecar text
|
|
||||||
if options.sidecar:
|
|
||||||
text = merge_sidecars(sidecars, context)
|
|
||||||
# Copy text file to destination
|
|
||||||
copy_final(text, options.sidecar, context)
|
|
||||||
|
|
||||||
# Merge layers to one single pdf
|
|
||||||
pdf = ocrgraft.finalize()
|
|
||||||
|
|
||||||
messages: Sequence[str] = []
|
|
||||||
if options.output_type != 'none':
|
|
||||||
# PDF/A and metadata
|
|
||||||
log.info("Postprocessing...")
|
|
||||||
pdf, messages = post_process(pdf, context, executor)
|
|
||||||
|
|
||||||
# Copy PDF file to destination
|
|
||||||
copy_final(pdf, options.output_file, context)
|
|
||||||
return messages
|
|
||||||
|
|
||||||
|
|
||||||
def configure_debug_logging(
|
|
||||||
log_filename: Path, prefix: str = ''
|
|
||||||
) -> logging.FileHandler:
|
|
||||||
"""Create a debug log file at a specified location.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
log_filename: Where to the put the log file.
|
|
||||||
prefix: The logging domain prefix that should be sent to the log.
|
|
||||||
"""
|
|
||||||
log_file_handler = logging.FileHandler(log_filename, delay=True)
|
|
||||||
log_file_handler.setLevel(logging.DEBUG)
|
|
||||||
formatter = logging.Formatter(
|
|
||||||
'[%(asctime)s] - %(name)s - %(levelname)7s -%(pageno)s %(message)s'
|
|
||||||
)
|
|
||||||
log_file_handler.setFormatter(formatter)
|
|
||||||
log_file_handler.addFilter(PageNumberFilter())
|
|
||||||
logging.getLogger(prefix).addHandler(log_file_handler)
|
|
||||||
return log_file_handler
|
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(
|
|
||||||
options: argparse.Namespace,
|
|
||||||
*,
|
|
||||||
plugin_manager: OcrmypdfPluginManager | None,
|
|
||||||
api: bool = False,
|
|
||||||
) -> ExitCode:
|
|
||||||
"""Run the OCR pipeline.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
options: The parsed command line options.
|
|
||||||
plugin_manager: The plugin manager to use. If not provided, one will be
|
|
||||||
created.
|
|
||||||
api: If ``True``, the pipeline is being run from the API. This is used
|
|
||||||
to manage exceptions in a way appropriate for API or CLI usage.
|
|
||||||
For CLI (``api=False``), exceptions are printed and described;
|
|
||||||
for API use, they are propagated to the caller.
|
|
||||||
"""
|
|
||||||
# Any changes to options will not take effect for options that are already
|
|
||||||
# bound to function parameters in the pipeline. (For example
|
|
||||||
# options.input_file, options.pdf_renderer are already bound.)
|
|
||||||
if not options.jobs:
|
|
||||||
options.jobs = available_cpu_count()
|
|
||||||
if not plugin_manager:
|
|
||||||
plugin_manager = get_plugin_manager(options.plugins)
|
|
||||||
|
|
||||||
work_folder = Path(mkdtemp(prefix="ocrmypdf.io."))
|
|
||||||
debug_log_handler = None
|
|
||||||
if (
|
|
||||||
(options.keep_temporary_files or options.verbose >= 1)
|
|
||||||
and not os.environ.get('PYTEST_CURRENT_TEST', '')
|
|
||||||
and not api
|
|
||||||
):
|
|
||||||
# Debug log for command line interface only with verbose output
|
|
||||||
# See https://github.com/pytest-dev/pytest/issues/5502 for why we skip this
|
|
||||||
# when pytest is running
|
|
||||||
debug_log_handler = configure_debug_logging(
|
|
||||||
Path(work_folder) / "debug.log"
|
|
||||||
) # pragma: no cover
|
|
||||||
|
|
||||||
pikepdf_enable_mmap()
|
|
||||||
|
|
||||||
executor = setup_executor(plugin_manager)
|
|
||||||
try:
|
|
||||||
check_requested_output_file(options)
|
|
||||||
start_input_file, original_filename = create_input_file(options, work_folder)
|
|
||||||
|
|
||||||
# Triage image or pdf
|
|
||||||
origin_pdf = triage(
|
|
||||||
original_filename, start_input_file, work_folder / 'origin.pdf', options
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Gather pdfinfo and create context
|
|
||||||
pdfinfo = get_pdfinfo(
|
|
||||||
origin_pdf,
|
|
||||||
executor=executor,
|
|
||||||
detailed_analysis=options.redo_ocr,
|
|
||||||
progbar=options.progress_bar,
|
|
||||||
max_workers=options.jobs if not options.use_threads else 1, # To help debug
|
|
||||||
check_pages=options.pages,
|
|
||||||
)
|
|
||||||
|
|
||||||
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
|
|
||||||
|
|
||||||
# Validate options are okay for this pdf
|
|
||||||
validate_pdfinfo_options(context)
|
|
||||||
|
|
||||||
# Execute the pipeline
|
|
||||||
optimize_messages = exec_concurrent(context, executor)
|
|
||||||
|
|
||||||
if options.output_file == '-':
|
|
||||||
log.info("Output sent to stdout")
|
|
||||||
elif (
|
|
||||||
hasattr(options.output_file, 'writable') and options.output_file.writable()
|
|
||||||
):
|
|
||||||
log.info("Output written to stream")
|
|
||||||
elif samefile(options.output_file, Path(os.devnull)):
|
|
||||||
pass # Say nothing when sending to dev null
|
|
||||||
else:
|
|
||||||
if options.output_type.startswith('pdfa'):
|
|
||||||
pdfa_info = file_claims_pdfa(options.output_file)
|
|
||||||
if pdfa_info['pass']:
|
|
||||||
log.info(
|
|
||||||
"Output file is a %s (as expected)", pdfa_info['conformance']
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
"Output file is okay but is not PDF/A (seems to be %s)",
|
|
||||||
pdfa_info['conformance'],
|
|
||||||
)
|
|
||||||
return ExitCode.pdfa_conversion_failed
|
|
||||||
if not check_pdf(options.output_file):
|
|
||||||
log.warning('Output file: The generated PDF is INVALID')
|
|
||||||
return ExitCode.invalid_output_pdf
|
|
||||||
report_output_file_size(
|
|
||||||
options, start_input_file, options.output_file, optimize_messages
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
|
||||||
e = cast(ExitCodeException, e)
|
|
||||||
if options.verbose >= 1:
|
|
||||||
log.exception("ExitCodeException")
|
|
||||||
elif str(e):
|
|
||||||
log.error("%s: %s", type(e).__name__, str(e))
|
|
||||||
else:
|
|
||||||
log.error(type(e).__name__)
|
|
||||||
return e.exit_code
|
|
||||||
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 "
|
|
||||||
"image pixel limit."
|
|
||||||
)
|
|
||||||
return ExitCode.other_error
|
|
||||||
except (
|
|
||||||
BrokenProcessPool if not api else NeverRaise,
|
|
||||||
BrokenThreadPool if not api else NeverRaise,
|
|
||||||
):
|
|
||||||
log.exception(
|
|
||||||
"A worker process was terminated unexpectedly. This is known to occur if "
|
|
||||||
"processing your file takes all available swap space and RAM. It may "
|
|
||||||
"help to try again with a smaller number of jobs, using the --jobs "
|
|
||||||
"argument."
|
|
||||||
)
|
|
||||||
return ExitCode.child_process_error
|
|
||||||
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:
|
|
||||||
if debug_log_handler:
|
|
||||||
try:
|
|
||||||
debug_log_handler.close()
|
|
||||||
log.removeHandler(debug_log_handler)
|
|
||||||
except OSError as e:
|
|
||||||
print(e, file=sys.stderr)
|
|
||||||
cleanup_working_files(work_folder, options)
|
|
||||||
|
|
||||||
return ExitCode.ok
|
return ExitCode.ok
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2019-2023 James R. Barlow
|
||||||
|
# SPDX-FileCopyrightText: 2019 Martin Wind
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Implements the concurrent and page synchronous parts of the pipeline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
import PIL
|
||||||
|
|
||||||
|
from ocrmypdf._concurrent import Executor
|
||||||
|
from ocrmypdf._graft import OcrGrafter
|
||||||
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
|
from ocrmypdf._pipeline import (
|
||||||
|
copy_final,
|
||||||
|
get_pdfinfo,
|
||||||
|
render_hocr_page,
|
||||||
|
)
|
||||||
|
from ocrmypdf._pipelines._common import (
|
||||||
|
HOCRResult,
|
||||||
|
manage_work_folder,
|
||||||
|
postprocess,
|
||||||
|
report_output_pdf,
|
||||||
|
set_thread_pageno,
|
||||||
|
setup_pipeline,
|
||||||
|
worker_init,
|
||||||
|
)
|
||||||
|
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
||||||
|
from ocrmypdf._progressbar import ProgressBar
|
||||||
|
from ocrmypdf.exceptions import ExitCode
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_hocrtransform_sync(page_context: PageContext) -> HOCRResult:
|
||||||
|
"""Process each page."""
|
||||||
|
hocr_json = page_context.get_path('hocr.json')
|
||||||
|
if not hocr_json.exists():
|
||||||
|
# No hOCR file, so no OCR was performed on this page.
|
||||||
|
return HOCRResult(pageno=page_context.pageno)
|
||||||
|
hocr_result = HOCRResult.from_json(hocr_json.read_text())
|
||||||
|
hocr_result.textpdf = render_hocr_page(
|
||||||
|
page_context.get_path('ocr_hocr.hocr'), page_context
|
||||||
|
)
|
||||||
|
return hocr_result
|
||||||
|
|
||||||
|
|
||||||
|
def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[str]:
|
||||||
|
"""Convert hOCR files to OCR PDF."""
|
||||||
|
# Run exec_page_sync on every page
|
||||||
|
options = context.options
|
||||||
|
max_workers = min(len(context.pdfinfo), options.jobs)
|
||||||
|
if max_workers > 1:
|
||||||
|
log.info("Continue processing %d pages concurrently", max_workers)
|
||||||
|
|
||||||
|
ocrgraft = OcrGrafter(context)
|
||||||
|
|
||||||
|
def graft_page(result: HOCRResult, pbar: ProgressBar):
|
||||||
|
"""Graft text only PDF on to main PDF's page."""
|
||||||
|
try:
|
||||||
|
set_thread_pageno(result.pageno + 1)
|
||||||
|
pbar.update()
|
||||||
|
ocrgraft.graft_page(
|
||||||
|
pageno=result.pageno,
|
||||||
|
image=result.pdf_page_from_image,
|
||||||
|
textpdf=result.textpdf,
|
||||||
|
autorotate_correction=result.orientation_correction,
|
||||||
|
)
|
||||||
|
pbar.update()
|
||||||
|
finally:
|
||||||
|
set_thread_pageno(None)
|
||||||
|
|
||||||
|
executor(
|
||||||
|
use_threads=options.use_threads,
|
||||||
|
max_workers=max_workers,
|
||||||
|
progress_kwargs=dict(
|
||||||
|
total=(2 * len(context.pdfinfo)),
|
||||||
|
desc='Grafting hOCR to PDF',
|
||||||
|
unit='page',
|
||||||
|
unit_scale=0.5,
|
||||||
|
disable=not options.progress_bar,
|
||||||
|
),
|
||||||
|
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
|
||||||
|
task=_exec_hocrtransform_sync,
|
||||||
|
task_arguments=context.get_page_context_args(),
|
||||||
|
task_finished=graft_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf = ocrgraft.finalize()
|
||||||
|
messages: Sequence[str] = []
|
||||||
|
if options.output_type != 'none':
|
||||||
|
# PDF/A and metadata
|
||||||
|
log.info("Postprocessing...")
|
||||||
|
pdf, messages = postprocess(pdf, context, executor)
|
||||||
|
|
||||||
|
# Copy PDF file to destination (we don't know the input PDF file name)
|
||||||
|
copy_final(pdf, options.output_file, None)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def run_hocr_to_ocr_pdf_pipeline(
|
||||||
|
options: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
plugin_manager: OcrmypdfPluginManager,
|
||||||
|
) -> ExitCode:
|
||||||
|
"""Run pipeline to convert hOCR to final output PDF."""
|
||||||
|
with manage_work_folder(
|
||||||
|
work_folder=options.work_folder, retain=True, print_location=False
|
||||||
|
) as work_folder:
|
||||||
|
executor = setup_pipeline(options, plugin_manager)
|
||||||
|
origin_pdf = work_folder / 'origin.pdf'
|
||||||
|
|
||||||
|
# Gather pdfinfo and create context
|
||||||
|
pdfinfo = get_pdfinfo(
|
||||||
|
origin_pdf,
|
||||||
|
executor=executor,
|
||||||
|
detailed_analysis=options.redo_ocr,
|
||||||
|
progbar=options.progress_bar,
|
||||||
|
max_workers=options.jobs,
|
||||||
|
use_threads=options.use_threads,
|
||||||
|
check_pages=options.pages,
|
||||||
|
)
|
||||||
|
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
|
||||||
|
plugin_manager.hook.check_options(options=options)
|
||||||
|
optimize_messages = exec_hocr_to_ocr_pdf(context, executor)
|
||||||
|
|
||||||
|
return report_output_pdf(options, origin_pdf, optimize_messages)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2019-2023 James R. Barlow
|
||||||
|
# SPDX-FileCopyrightText: 2019 Martin Wind
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Implements the concurrent and page synchronous parts of the pipeline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import mkdtemp
|
||||||
|
|
||||||
|
import PIL
|
||||||
|
|
||||||
|
from ocrmypdf._concurrent import Executor
|
||||||
|
from ocrmypdf._graft import OcrGrafter
|
||||||
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
|
from ocrmypdf._pipeline import (
|
||||||
|
copy_final,
|
||||||
|
get_pdfinfo,
|
||||||
|
is_ocr_required,
|
||||||
|
merge_sidecars,
|
||||||
|
ocr_engine_hocr,
|
||||||
|
ocr_engine_textonly_pdf,
|
||||||
|
render_hocr_page,
|
||||||
|
triage,
|
||||||
|
validate_pdfinfo_options,
|
||||||
|
)
|
||||||
|
from ocrmypdf._pipelines._common import (
|
||||||
|
PageResult,
|
||||||
|
cli_exception_handler,
|
||||||
|
manage_debug_log_handler,
|
||||||
|
manage_work_folder,
|
||||||
|
postprocess,
|
||||||
|
process_page,
|
||||||
|
report_output_pdf,
|
||||||
|
set_thread_pageno,
|
||||||
|
setup_pipeline,
|
||||||
|
worker_init,
|
||||||
|
)
|
||||||
|
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
||||||
|
from ocrmypdf._progressbar import ProgressBar
|
||||||
|
from ocrmypdf._validation import (
|
||||||
|
check_requested_output_file,
|
||||||
|
create_input_file,
|
||||||
|
)
|
||||||
|
from ocrmypdf.exceptions import ExitCode
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_to_ocr_text(
|
||||||
|
page_context: PageContext, ocr_image_out: Path
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
"""Run OCR engine on image to create OCR PDF and text file."""
|
||||||
|
options = page_context.options
|
||||||
|
if options.pdf_renderer.startswith('hocr'):
|
||||||
|
hocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context)
|
||||||
|
ocr_out = render_hocr_page(hocr_out, page_context)
|
||||||
|
elif options.pdf_renderer == 'sandwich':
|
||||||
|
ocr_out, text_out = ocr_engine_textonly_pdf(ocr_image_out, page_context)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"pdf_renderer {options.pdf_renderer}")
|
||||||
|
return ocr_out, text_out
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_page_sync(page_context: PageContext) -> PageResult:
|
||||||
|
"""Execute a pipeline for a single page synchronously."""
|
||||||
|
set_thread_pageno(page_context.pageno + 1)
|
||||||
|
|
||||||
|
if not is_ocr_required(page_context):
|
||||||
|
return PageResult(pageno=page_context.pageno)
|
||||||
|
|
||||||
|
ocr_image_out, pdf_page_from_image_out, orientation_correction = process_page(
|
||||||
|
page_context
|
||||||
|
)
|
||||||
|
ocr_out, text_out = _image_to_ocr_text(page_context, ocr_image_out)
|
||||||
|
return PageResult(
|
||||||
|
pageno=page_context.pageno,
|
||||||
|
pdf_page_from_image=pdf_page_from_image_out,
|
||||||
|
ocr=ocr_out,
|
||||||
|
text=text_out,
|
||||||
|
orientation_correction=orientation_correction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
||||||
|
"""Execute the OCR pipeline concurrently."""
|
||||||
|
options = context.options
|
||||||
|
max_workers = min(len(context.pdfinfo), options.jobs)
|
||||||
|
if max_workers > 1:
|
||||||
|
log.info("Start processing %d pages concurrently", max_workers)
|
||||||
|
|
||||||
|
sidecars: list[Path | None] = [None] * len(context.pdfinfo)
|
||||||
|
ocrgraft = OcrGrafter(context)
|
||||||
|
|
||||||
|
def update_page(result: PageResult, pbar: ProgressBar):
|
||||||
|
"""After OCR is complete for a page, update the PDF."""
|
||||||
|
try:
|
||||||
|
set_thread_pageno(result.pageno + 1)
|
||||||
|
sidecars[result.pageno] = result.text
|
||||||
|
pbar.update(0.5)
|
||||||
|
ocrgraft.graft_page(
|
||||||
|
pageno=result.pageno,
|
||||||
|
image=result.pdf_page_from_image,
|
||||||
|
textpdf=result.ocr,
|
||||||
|
autorotate_correction=result.orientation_correction,
|
||||||
|
)
|
||||||
|
pbar.update(0.5)
|
||||||
|
finally:
|
||||||
|
set_thread_pageno(None)
|
||||||
|
|
||||||
|
executor(
|
||||||
|
use_threads=options.use_threads,
|
||||||
|
max_workers=max_workers,
|
||||||
|
progress_kwargs=dict(
|
||||||
|
total=len(context.pdfinfo),
|
||||||
|
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
|
||||||
|
unit='page',
|
||||||
|
disable=not options.progress_bar,
|
||||||
|
),
|
||||||
|
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
|
||||||
|
task=_exec_page_sync,
|
||||||
|
task_arguments=context.get_page_context_args(),
|
||||||
|
task_finished=update_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Output sidecar text
|
||||||
|
if options.sidecar:
|
||||||
|
text = merge_sidecars(sidecars, context)
|
||||||
|
# Copy text file to destination
|
||||||
|
copy_final(text, options.sidecar, options.input_file)
|
||||||
|
|
||||||
|
# Merge layers to one single pdf
|
||||||
|
pdf = ocrgraft.finalize()
|
||||||
|
|
||||||
|
messages: Sequence[str] = []
|
||||||
|
if options.output_type != 'none':
|
||||||
|
# PDF/A and metadata
|
||||||
|
log.info("Postprocessing...")
|
||||||
|
pdf, messages = postprocess(pdf, context, executor)
|
||||||
|
|
||||||
|
# Copy PDF file to destination
|
||||||
|
copy_final(pdf, options.output_file, options.input_file)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pipeline(
|
||||||
|
options: argparse.Namespace,
|
||||||
|
plugin_manager: OcrmypdfPluginManager,
|
||||||
|
) -> ExitCode:
|
||||||
|
with (
|
||||||
|
manage_work_folder(
|
||||||
|
work_folder=Path(mkdtemp(prefix="ocrmypdf.io.")),
|
||||||
|
retain=options.keep_temporary_files,
|
||||||
|
print_location=options.keep_temporary_files,
|
||||||
|
) as work_folder,
|
||||||
|
manage_debug_log_handler(options=options, work_folder=work_folder),
|
||||||
|
):
|
||||||
|
executor = setup_pipeline(options, plugin_manager)
|
||||||
|
check_requested_output_file(options)
|
||||||
|
start_input_file, original_filename = create_input_file(options, work_folder)
|
||||||
|
|
||||||
|
# Triage image or pdf
|
||||||
|
origin_pdf = triage(
|
||||||
|
original_filename, start_input_file, work_folder / 'origin.pdf', options
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gather pdfinfo and create context
|
||||||
|
pdfinfo = get_pdfinfo(
|
||||||
|
origin_pdf,
|
||||||
|
executor=executor,
|
||||||
|
detailed_analysis=options.redo_ocr,
|
||||||
|
progbar=options.progress_bar,
|
||||||
|
max_workers=options.jobs,
|
||||||
|
use_threads=options.use_threads,
|
||||||
|
check_pages=options.pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
|
||||||
|
|
||||||
|
# Validate options are okay for this pdf
|
||||||
|
validate_pdfinfo_options(context)
|
||||||
|
|
||||||
|
# Execute the pipeline
|
||||||
|
optimize_messages = exec_concurrent(context, executor)
|
||||||
|
|
||||||
|
exitcode = report_output_pdf(options, start_input_file, optimize_messages)
|
||||||
|
return exitcode
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline_cli(
|
||||||
|
options: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
plugin_manager: OcrmypdfPluginManager,
|
||||||
|
) -> ExitCode:
|
||||||
|
"""Run the OCR pipeline with command line exception handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: The parsed command line options.
|
||||||
|
plugin_manager: The plugin manager to use. If not provided, one will be
|
||||||
|
created.
|
||||||
|
"""
|
||||||
|
return cli_exception_handler(_run_pipeline, options, plugin_manager)
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline(
|
||||||
|
options: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
plugin_manager: OcrmypdfPluginManager,
|
||||||
|
) -> ExitCode:
|
||||||
|
"""Run the OCR pipeline without command line exception handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: The parsed command line options.
|
||||||
|
plugin_manager: The plugin manager to use. If not provided, one will be
|
||||||
|
created.
|
||||||
|
"""
|
||||||
|
return _run_pipeline(options, plugin_manager)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2019-2023 James R. Barlow
|
||||||
|
# SPDX-FileCopyrightText: 2019 Martin Wind
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Implements the concurrent and page synchronous parts of the pipeline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import shutil
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
import PIL
|
||||||
|
|
||||||
|
from ocrmypdf._concurrent import Executor
|
||||||
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
|
from ocrmypdf._pipeline import (
|
||||||
|
get_pdfinfo,
|
||||||
|
is_ocr_required,
|
||||||
|
ocr_engine_hocr,
|
||||||
|
validate_pdfinfo_options,
|
||||||
|
)
|
||||||
|
from ocrmypdf._pipelines._common import (
|
||||||
|
HOCRResult,
|
||||||
|
manage_work_folder,
|
||||||
|
process_page,
|
||||||
|
set_thread_pageno,
|
||||||
|
setup_pipeline,
|
||||||
|
worker_init,
|
||||||
|
)
|
||||||
|
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
||||||
|
from ocrmypdf._validation import (
|
||||||
|
set_lossless_reconstruction,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_page_hocr_sync(page_context: PageContext) -> HOCRResult:
|
||||||
|
"""Execute a pipeline for a single page hOCR."""
|
||||||
|
set_thread_pageno(page_context.pageno + 1)
|
||||||
|
|
||||||
|
if not is_ocr_required(page_context):
|
||||||
|
return HOCRResult(pageno=page_context.pageno)
|
||||||
|
|
||||||
|
ocr_image_out, pdf_page_from_image_out, orientation_correction = process_page(
|
||||||
|
page_context
|
||||||
|
)
|
||||||
|
hocr_out, _ = ocr_engine_hocr(ocr_image_out, page_context)
|
||||||
|
|
||||||
|
result = HOCRResult(
|
||||||
|
pageno=page_context.pageno,
|
||||||
|
pdf_page_from_image=pdf_page_from_image_out,
|
||||||
|
hocr=hocr_out,
|
||||||
|
orientation_correction=orientation_correction,
|
||||||
|
)
|
||||||
|
page_context.get_path('hocr.json').write_text(result.to_json())
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def exec_pdf_to_hocr(context: PdfContext, executor: Executor) -> None:
|
||||||
|
"""Execute the OCR pipeline concurrently and output hOCR."""
|
||||||
|
# Run exec_page_sync on every page
|
||||||
|
options = context.options
|
||||||
|
max_workers = min(len(context.pdfinfo), options.jobs)
|
||||||
|
if max_workers > 1:
|
||||||
|
log.info("Start processing %d pages concurrently", max_workers)
|
||||||
|
|
||||||
|
executor(
|
||||||
|
use_threads=options.use_threads,
|
||||||
|
max_workers=max_workers,
|
||||||
|
progress_kwargs=dict(
|
||||||
|
total=(2 * len(context.pdfinfo)),
|
||||||
|
desc='hOCR',
|
||||||
|
unit='page',
|
||||||
|
unit_scale=0.5,
|
||||||
|
disable=not options.progress_bar,
|
||||||
|
),
|
||||||
|
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
|
||||||
|
task=_exec_page_hocr_sync,
|
||||||
|
task_arguments=context.get_page_context_args(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_hocr_pipeline(
|
||||||
|
options: argparse.Namespace,
|
||||||
|
*,
|
||||||
|
plugin_manager: OcrmypdfPluginManager,
|
||||||
|
) -> None:
|
||||||
|
"""Run pipeline to output hOCR."""
|
||||||
|
with manage_work_folder(
|
||||||
|
work_folder=options.output_folder, retain=True, print_location=False
|
||||||
|
) as work_folder:
|
||||||
|
executor = setup_pipeline(options, plugin_manager)
|
||||||
|
shutil.copy2(options.input_file, work_folder / 'origin.pdf')
|
||||||
|
|
||||||
|
# Gather pdfinfo and create context
|
||||||
|
pdfinfo = get_pdfinfo(
|
||||||
|
options.input_file,
|
||||||
|
executor=executor,
|
||||||
|
detailed_analysis=options.redo_ocr,
|
||||||
|
progbar=options.progress_bar,
|
||||||
|
max_workers=options.jobs,
|
||||||
|
use_threads=options.use_threads,
|
||||||
|
check_pages=options.pages,
|
||||||
|
)
|
||||||
|
context = PdfContext(
|
||||||
|
options, work_folder, options.input_file, pdfinfo, plugin_manager
|
||||||
|
)
|
||||||
|
# Validate options are okay for this pdf
|
||||||
|
set_lossless_reconstruction(options)
|
||||||
|
validate_pdfinfo_options(context)
|
||||||
|
exec_pdf_to_hocr(context, executor)
|
||||||
@@ -33,7 +33,7 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*args,
|
*args,
|
||||||
plugins: list[str | Path],
|
plugins: Sequence[str | Path],
|
||||||
builtins: bool = True,
|
builtins: bool = True,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -101,11 +101,11 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
|
|||||||
|
|
||||||
|
|
||||||
def get_plugin_manager(
|
def get_plugin_manager(
|
||||||
plugins: list[str | Path], builtins=True
|
plugins: Sequence[str | Path] | None = None, builtins=True
|
||||||
) -> OcrmypdfPluginManager:
|
) -> OcrmypdfPluginManager:
|
||||||
return OcrmypdfPluginManager(
|
return OcrmypdfPluginManager(
|
||||||
project_name='ocrmypdf',
|
project_name='ocrmypdf',
|
||||||
plugins=plugins,
|
plugins=plugins if plugins is not None else [],
|
||||||
builtins=builtins,
|
builtins=builtins,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Defines progress bar API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.progress import (
|
||||||
|
BarColumn,
|
||||||
|
MofNCompleteColumn,
|
||||||
|
Progress,
|
||||||
|
TaskProgressColumn,
|
||||||
|
TextColumn,
|
||||||
|
TimeRemainingColumn,
|
||||||
|
)
|
||||||
|
from rich.table import Column
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressBar(Protocol):
|
||||||
|
"""The protocol that OCRmyPDF expects progress bar classes to be compatible with.
|
||||||
|
|
||||||
|
In practice this could be used for any time of monitoring, not just a progress bar.
|
||||||
|
|
||||||
|
Calling the class should return a new progress bar object, which is activated
|
||||||
|
with ``__enter__`` and terminated with ``__exit__``. An update method is called
|
||||||
|
whenever the progress bar is updated. Progress bar objects will not be reused;
|
||||||
|
a new one will be created for each group of tasks.
|
||||||
|
|
||||||
|
The progress bar is held in the main process/thread and not updated by child
|
||||||
|
process/threads. When a child notifies the parent of completed work, the
|
||||||
|
parent updates the progress bar.
|
||||||
|
|
||||||
|
Progress bars should never write to ``sys.stdout``, or they will corrupt the
|
||||||
|
output if OCRmyPDF writes a PDF to standard output.
|
||||||
|
|
||||||
|
The type of events that OCRmyPDF reports to a progress bar may change in
|
||||||
|
minor releases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
total: int | float | None,
|
||||||
|
desc: str | None,
|
||||||
|
unit: str | None,
|
||||||
|
disable: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Initialize a progress bar.
|
||||||
|
|
||||||
|
*total* indicates the total number of work units. If None, the total
|
||||||
|
number of work units is unknown. If *disable* is True, the progress bar
|
||||||
|
should be disabled. *unit* is a description of the work unit.
|
||||||
|
*desc* is a description of the overall task to be performed.
|
||||||
|
|
||||||
|
Unrecognized keyword arguments must be ignored, as the list of keyword
|
||||||
|
arguments may grow with time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Enter a progress bar context."""
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
"""Exit a progress bar context."""
|
||||||
|
|
||||||
|
def update(self, n=1, *, completed=None):
|
||||||
|
"""Update the progress bar by an increment.
|
||||||
|
|
||||||
|
For use within a progress bar context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class NullProgressBar:
|
||||||
|
"""Progress bar API that takes no actions."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update(self, _arg=None, *, completed=None):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class RichProgressBar:
|
||||||
|
"""Display progress bar using rich."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
console: Console,
|
||||||
|
desc: str,
|
||||||
|
total: float | None = None,
|
||||||
|
unit: str | None = None,
|
||||||
|
unit_scale: float | None = 1.0,
|
||||||
|
disable: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self._entered = False
|
||||||
|
self.progress = Progress(
|
||||||
|
TextColumn(
|
||||||
|
"[progress.description]{task.description}",
|
||||||
|
table_column=Column(min_width=20),
|
||||||
|
),
|
||||||
|
BarColumn(),
|
||||||
|
TaskProgressColumn(),
|
||||||
|
MofNCompleteColumn(),
|
||||||
|
TimeRemainingColumn(),
|
||||||
|
console=console,
|
||||||
|
auto_refresh=True,
|
||||||
|
redirect_stderr=True,
|
||||||
|
redirect_stdout=False,
|
||||||
|
disable=disable,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self.unit_scale = unit_scale
|
||||||
|
self.progress_bar = self.progress.add_task(
|
||||||
|
desc,
|
||||||
|
total=total * self.unit_scale
|
||||||
|
if total is not None and self.unit_scale is not None
|
||||||
|
else None,
|
||||||
|
unit=unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.progress.start()
|
||||||
|
self._entered = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
self.progress.refresh()
|
||||||
|
self.progress.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update(self, n=1, *, completed=None):
|
||||||
|
assert self._entered, "Progress bar must be entered before updating"
|
||||||
|
if completed is None:
|
||||||
|
advance = self.unit_scale if n is None else n
|
||||||
|
self.progress.update(self.progress_bar, advance=advance)
|
||||||
|
else:
|
||||||
|
self.progress.update(self.progress_bar, completed=completed)
|
||||||
+11
-19
@@ -20,6 +20,7 @@ import pikepdf
|
|||||||
import PIL
|
import PIL
|
||||||
from pluggy import PluginManager
|
from pluggy import PluginManager
|
||||||
|
|
||||||
|
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||||
from ocrmypdf._exec import unpaper
|
from ocrmypdf._exec import unpaper
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
BadArgsError,
|
BadArgsError,
|
||||||
@@ -28,20 +29,11 @@ from ocrmypdf.exceptions import (
|
|||||||
OutputFileAccessError,
|
OutputFileAccessError,
|
||||||
)
|
)
|
||||||
from ocrmypdf.helpers import is_file_writable, monotonic, safe_symlink
|
from ocrmypdf.helpers import is_file_writable, monotonic, safe_symlink
|
||||||
from ocrmypdf.hocrtransform import HOCR_OK_LANGS
|
|
||||||
from ocrmypdf.subprocess import check_external_program
|
from ocrmypdf.subprocess import check_external_program
|
||||||
|
|
||||||
# -------------
|
|
||||||
# External dependencies
|
|
||||||
|
|
||||||
DEFAULT_LANGUAGE = 'eng' # Enforce English hegemony
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# --------
|
|
||||||
|
|
||||||
|
|
||||||
def check_platform() -> None:
|
def check_platform() -> None:
|
||||||
if sys.maxsize <= 2**32: # pragma: no cover
|
if sys.maxsize <= 2**32: # pragma: no cover
|
||||||
log.warning(
|
log.warning(
|
||||||
@@ -62,6 +54,7 @@ def check_options_languages(
|
|||||||
log.debug("No language specified; assuming --language %s", DEFAULT_LANGUAGE)
|
log.debug("No language specified; assuming --language %s", DEFAULT_LANGUAGE)
|
||||||
if not ocr_engine_languages:
|
if not ocr_engine_languages:
|
||||||
return
|
return
|
||||||
|
|
||||||
missing_languages = set(options.languages) - set(ocr_engine_languages)
|
missing_languages = set(options.languages) - set(ocr_engine_languages)
|
||||||
if missing_languages:
|
if missing_languages:
|
||||||
lang_text = '\n'.join(lang for lang in missing_languages)
|
lang_text = '\n'.join(lang for lang in missing_languages)
|
||||||
@@ -83,15 +76,6 @@ def check_options_languages(
|
|||||||
|
|
||||||
|
|
||||||
def check_options_output(options: Namespace) -> None:
|
def check_options_output(options: Namespace) -> None:
|
||||||
is_latin = set(options.languages).issubset(HOCR_OK_LANGS)
|
|
||||||
|
|
||||||
if options.pdf_renderer.startswith('hocr') and not is_latin:
|
|
||||||
log.warning(
|
|
||||||
"The 'hocr' PDF renderer is known to cause problems with one "
|
|
||||||
"or more of the languages in your document. Use "
|
|
||||||
"`--pdf-renderer auto` (the default) to avoid this issue."
|
|
||||||
)
|
|
||||||
|
|
||||||
if options.output_type == 'none' and options.output_file not in (os.devnull, '-'):
|
if options.output_type == 'none' and options.output_file not in (os.devnull, '-'):
|
||||||
raise BadArgsError(
|
raise BadArgsError(
|
||||||
"Since you specified `--output-type none`, the output file "
|
"Since you specified `--output-type none`, the output file "
|
||||||
@@ -99,6 +83,8 @@ def check_options_output(options: Namespace) -> None:
|
|||||||
f"`-` to suppress this message."
|
f"`-` to suppress this message."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_lossless_reconstruction(options: Namespace) -> None:
|
||||||
lossless_reconstruction = False
|
lossless_reconstruction = False
|
||||||
if not any(
|
if not any(
|
||||||
(
|
(
|
||||||
@@ -138,6 +124,11 @@ def check_options_preprocessing(options: Namespace) -> None:
|
|||||||
options.clean = True
|
options.clean = True
|
||||||
if options.unpaper_args and not options.clean:
|
if options.unpaper_args and not options.clean:
|
||||||
raise BadArgsError("--clean is required for --unpaper-args")
|
raise BadArgsError("--clean is required for --unpaper-args")
|
||||||
|
if (
|
||||||
|
options.rotate_pages_threshold != DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||||
|
and not options.rotate_pages
|
||||||
|
):
|
||||||
|
raise BadArgsError("--rotate-pages is required for --rotate-pages-threshold")
|
||||||
if options.clean:
|
if options.clean:
|
||||||
check_external_program(
|
check_external_program(
|
||||||
program='unpaper',
|
program='unpaper',
|
||||||
@@ -228,6 +219,7 @@ def _check_plugin_invariant_options(options: Namespace) -> None:
|
|||||||
check_platform()
|
check_platform()
|
||||||
check_options_metadata(options)
|
check_options_metadata(options)
|
||||||
check_options_output(options)
|
check_options_output(options)
|
||||||
|
set_lossless_reconstruction(options)
|
||||||
check_options_sidecar(options)
|
check_options_sidecar(options)
|
||||||
check_options_preprocessing(options)
|
check_options_preprocessing(options)
|
||||||
check_options_ocr_behavior(options)
|
check_options_ocr_behavior(options)
|
||||||
@@ -282,7 +274,7 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
|
|||||||
msg = f"File not found - {options.input_file}"
|
msg = f"File not found - {options.input_file}"
|
||||||
if _in_docker(): # pragma: no cover
|
if _in_docker(): # pragma: no cover
|
||||||
msg += (
|
msg += (
|
||||||
"\nDocker cannot your working directory unless you "
|
"\nDocker cannot access your working directory unless you "
|
||||||
"explicitly share it with the Docker container and set up"
|
"explicitly share it with the Docker container and set up"
|
||||||
"permissions correctly.\n"
|
"permissions correctly.\n"
|
||||||
"You may find it easier to use stdin/stdout:"
|
"You may find it easier to use stdin/stdout:"
|
||||||
|
|||||||
+228
-43
@@ -10,25 +10,30 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable, Sequence
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from io import IOBase
|
from io import IOBase
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AnyStr, BinaryIO, Union
|
from typing import AnyStr, BinaryIO
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
import pluggy
|
import pluggy
|
||||||
|
|
||||||
from ocrmypdf._logging import PageNumberFilter
|
from ocrmypdf._logging import PageNumberFilter
|
||||||
|
from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline
|
||||||
|
from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli
|
||||||
|
from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
|
||||||
from ocrmypdf._plugin_manager import get_plugin_manager
|
from ocrmypdf._plugin_manager import get_plugin_manager
|
||||||
from ocrmypdf._sync import run_pipeline
|
|
||||||
from ocrmypdf._validation import check_options
|
from ocrmypdf._validation import check_options
|
||||||
from ocrmypdf.cli import ArgumentParser, get_parser
|
from ocrmypdf.cli import ArgumentParser, get_parser
|
||||||
from ocrmypdf.helpers import is_iterable_notstr
|
from ocrmypdf.helpers import is_iterable_notstr
|
||||||
|
|
||||||
StrPath = Union[Path, AnyStr]
|
StrPath = Path | AnyStr
|
||||||
PathOrIO = Union[BinaryIO, StrPath]
|
PathOrIO = BinaryIO | StrPath
|
||||||
|
|
||||||
|
# Installing plugins affects the global state of the Python interpreter,
|
||||||
|
# so we need to use a lock to prevent multiple threads from installing
|
||||||
|
# plugins at the same time.
|
||||||
_api_lock = threading.Lock()
|
_api_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -133,34 +138,19 @@ def configure_logging(
|
|||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
def create_options(
|
def _kwargs_to_cmdline(
|
||||||
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
|
*, defer_kwargs: set[str], **kwargs
|
||||||
) -> Namespace:
|
) -> tuple[list[str], dict[str, AnyStr]]:
|
||||||
"""Construct an options object from the input/output files and keyword arguments.
|
"""Convert kwargs to command line arguments."""
|
||||||
|
|
||||||
Args:
|
|
||||||
input_file: Input file path or file object.
|
|
||||||
output_file: Output file path or file object.
|
|
||||||
parser: ArgumentParser object.
|
|
||||||
**kwargs: Keyword arguments.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
argparse.Namespace: A Namespace object containing the parsed arguments.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If the type of a keyword argument is not supported.
|
|
||||||
"""
|
|
||||||
cmdline = []
|
cmdline = []
|
||||||
deferred = []
|
deferred = {}
|
||||||
|
|
||||||
for arg, val in kwargs.items():
|
for arg, val in kwargs.items():
|
||||||
if val is None:
|
if val is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# These arguments with special handling for which we bypass
|
# Skip arguments that are handled elsewhere
|
||||||
# argparse
|
if arg in defer_kwargs:
|
||||||
if arg in {'progress_bar', 'plugins'}:
|
deferred[arg] = val
|
||||||
deferred.append((arg, val))
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cmd_style_arg = arg.replace('_', '-')
|
cmd_style_arg = arg.replace('_', '-')
|
||||||
@@ -179,7 +169,7 @@ def create_options(
|
|||||||
|
|
||||||
# We have a parameter
|
# We have a parameter
|
||||||
cmdline.append(f"--{cmd_style_arg}")
|
cmdline.append(f"--{cmd_style_arg}")
|
||||||
if isinstance(val, (int, float)):
|
if isinstance(val, int | float):
|
||||||
cmdline.append(str(val))
|
cmdline.append(str(val))
|
||||||
elif isinstance(val, str):
|
elif isinstance(val, str):
|
||||||
cmdline.append(val)
|
cmdline.append(val)
|
||||||
@@ -187,25 +177,53 @@ def create_options(
|
|||||||
cmdline.append(str(val))
|
cmdline.append(str(val))
|
||||||
else:
|
else:
|
||||||
raise TypeError(f"{arg}: {val} ({type(val)})")
|
raise TypeError(f"{arg}: {val} ({type(val)})")
|
||||||
|
return cmdline, deferred
|
||||||
|
|
||||||
if isinstance(input_file, (BinaryIO, IOBase)):
|
|
||||||
|
def create_options(
|
||||||
|
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
|
||||||
|
) -> Namespace:
|
||||||
|
"""Construct an options object from the input/output files and keyword arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: Input file path or file object.
|
||||||
|
output_file: Output file path or file object.
|
||||||
|
parser: ArgumentParser object.
|
||||||
|
**kwargs: Keyword arguments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
argparse.Namespace: A Namespace object containing the parsed arguments.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the type of a keyword argument is not supported.
|
||||||
|
"""
|
||||||
|
cmdline, deferred = _kwargs_to_cmdline(
|
||||||
|
defer_kwargs={'progress_bar', 'plugins', 'parser', 'input_file', 'output_file'},
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if isinstance(input_file, BinaryIO | IOBase):
|
||||||
cmdline.append('stream://input_file')
|
cmdline.append('stream://input_file')
|
||||||
else:
|
else:
|
||||||
cmdline.append(os.fspath(input_file))
|
cmdline.append(os.fspath(input_file))
|
||||||
if isinstance(output_file, (BinaryIO, IOBase)):
|
if isinstance(output_file, BinaryIO | IOBase):
|
||||||
cmdline.append('stream://output_file')
|
cmdline.append('stream://output_file')
|
||||||
else:
|
else:
|
||||||
cmdline.append(os.fspath(output_file))
|
cmdline.append(os.fspath(output_file))
|
||||||
|
if 'sidecar' in kwargs and isinstance(kwargs['sidecar'], BinaryIO | IOBase):
|
||||||
|
cmdline.append('--sidecar')
|
||||||
|
cmdline.append('stream://sidecar')
|
||||||
|
|
||||||
parser.enable_api_mode()
|
parser.enable_api_mode()
|
||||||
options = parser.parse_args(cmdline)
|
options = parser.parse_args(cmdline)
|
||||||
for keyword, val in deferred:
|
for keyword, val in deferred.items():
|
||||||
setattr(options, keyword, val)
|
setattr(options, keyword, val)
|
||||||
|
|
||||||
if options.input_file == 'stream://input_file':
|
if options.input_file == 'stream://input_file':
|
||||||
options.input_file = input_file
|
options.input_file = input_file
|
||||||
if options.output_file == 'stream://output_file':
|
if options.output_file == 'stream://output_file':
|
||||||
options.output_file = output_file
|
options.output_file = output_file
|
||||||
|
if options.sidecar == 'stream://sidecar':
|
||||||
|
options.sidecar = kwargs['sidecar']
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
||||||
@@ -217,7 +235,7 @@ def ocr( # noqa: D417
|
|||||||
language: Iterable[str] | None = None,
|
language: Iterable[str] | None = None,
|
||||||
image_dpi: int | None = None,
|
image_dpi: int | None = None,
|
||||||
output_type: str | None = None,
|
output_type: str | None = None,
|
||||||
sidecar: StrPath | None = None,
|
sidecar: PathOrIO | None = None,
|
||||||
jobs: int | None = None,
|
jobs: int | None = None,
|
||||||
use_threads: bool | None = None,
|
use_threads: bool | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
@@ -330,23 +348,21 @@ def ocr( # noqa: D417
|
|||||||
|
|
||||||
if not plugins:
|
if not plugins:
|
||||||
plugins = []
|
plugins = []
|
||||||
elif isinstance(plugins, (str, Path)):
|
elif isinstance(plugins, str | Path):
|
||||||
plugins = [plugins]
|
plugins = [plugins]
|
||||||
else:
|
else:
|
||||||
plugins = list(plugins)
|
plugins = list(plugins)
|
||||||
|
|
||||||
# No new variable names should be assigned until these two steps are run
|
# No new variable names should be assigned until these two steps are run
|
||||||
create_options_kwargs = {k: v for k, v in locals().items() if k != 'kwargs'}
|
create_options_kwargs = {
|
||||||
|
k: v
|
||||||
|
for k, v in locals().items()
|
||||||
|
if k not in {'input_file', 'output_file', 'kwargs'}
|
||||||
|
}
|
||||||
create_options_kwargs.update(kwargs)
|
create_options_kwargs.update(kwargs)
|
||||||
|
|
||||||
parser = get_parser()
|
parser = get_parser()
|
||||||
create_options_kwargs['parser'] = parser
|
|
||||||
|
|
||||||
with _api_lock:
|
with _api_lock:
|
||||||
# We can't allow multiple ocrmypdf.ocr() threads to run in parallel, because
|
|
||||||
# they might install different plugins, and generally speaking we have areas
|
|
||||||
# of code that use global state.
|
|
||||||
|
|
||||||
if not plugin_manager:
|
if not plugin_manager:
|
||||||
plugin_manager = get_plugin_manager(plugins)
|
plugin_manager = get_plugin_manager(plugins)
|
||||||
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
|
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
|
||||||
@@ -354,9 +370,177 @@ def ocr( # noqa: D417
|
|||||||
if 'verbose' in kwargs:
|
if 'verbose' in kwargs:
|
||||||
warn("ocrmypdf.ocr(verbose=) is ignored. Use ocrmypdf.configure_logging().")
|
warn("ocrmypdf.ocr(verbose=) is ignored. Use ocrmypdf.configure_logging().")
|
||||||
|
|
||||||
options = create_options(**create_options_kwargs)
|
options = create_options(
|
||||||
|
input_file=input_file,
|
||||||
|
output_file=output_file,
|
||||||
|
parser=parser,
|
||||||
|
**create_options_kwargs,
|
||||||
|
)
|
||||||
check_options(options, plugin_manager)
|
check_options(options, plugin_manager)
|
||||||
return run_pipeline(options=options, plugin_manager=plugin_manager, api=True)
|
return run_pipeline(options=options, plugin_manager=plugin_manager)
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_to_hocr( # noqa: D417
|
||||||
|
input_pdf: Path,
|
||||||
|
output_folder: Path,
|
||||||
|
*,
|
||||||
|
language: Iterable[str] | None = None,
|
||||||
|
image_dpi: int | None = 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,
|
||||||
|
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,
|
||||||
|
tesseract_timeout: float | None = None,
|
||||||
|
tesseract_non_ocr_timeout: float | None = None,
|
||||||
|
tesseract_downsample_above: int | None = None,
|
||||||
|
tesseract_downsample_large_images: bool | None = None,
|
||||||
|
rotate_pages_threshold: float | None = None,
|
||||||
|
user_words: os.PathLike | None = None,
|
||||||
|
user_patterns: os.PathLike | None = None,
|
||||||
|
continue_on_soft_render_error: bool | None = None,
|
||||||
|
invalidate_digital_signatures: bool | None = None,
|
||||||
|
plugin_manager=None,
|
||||||
|
plugins: Sequence[StrPath] | None = None,
|
||||||
|
keep_temporary_files: bool | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Partially run OCRmyPDF and produces an output folder containing hOCR files.
|
||||||
|
|
||||||
|
Given a PDF file, this function will run OCRmyPDF up to the point where
|
||||||
|
the PDF is rasterized to images, OCRed, and the hOCR files are produced,
|
||||||
|
all of which are saved to the output folder. This is useful for applications
|
||||||
|
that want to provide an interface for users to edit the text before
|
||||||
|
rendering the final PDF.
|
||||||
|
|
||||||
|
Use :func:`hocr_to_ocr_pdf` to produce the final PDF.
|
||||||
|
|
||||||
|
For arguments not explicitly documented here, see documentation for the
|
||||||
|
equivalent command line parameter.
|
||||||
|
|
||||||
|
This API is **experimental** and subject to change.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_pdf: Input PDF file path.
|
||||||
|
output_folder: Output folder path.
|
||||||
|
**kwargs: Keyword arguments.
|
||||||
|
"""
|
||||||
|
# No new variable names should be assigned until these two steps are run
|
||||||
|
create_options_kwargs = {
|
||||||
|
k: v
|
||||||
|
for k, v in locals().items()
|
||||||
|
if k not in {'input_pdf', 'output_folder', 'kwargs'}
|
||||||
|
}
|
||||||
|
create_options_kwargs.update(kwargs)
|
||||||
|
|
||||||
|
parser = get_parser()
|
||||||
|
|
||||||
|
with _api_lock:
|
||||||
|
if not plugin_manager:
|
||||||
|
plugin_manager = get_plugin_manager(plugins)
|
||||||
|
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
|
||||||
|
|
||||||
|
cmdline, deferred = _kwargs_to_cmdline(
|
||||||
|
defer_kwargs={'input_pdf', 'output_folder', 'plugins'},
|
||||||
|
**create_options_kwargs,
|
||||||
|
)
|
||||||
|
cmdline.append(str(input_pdf))
|
||||||
|
cmdline.append(str(output_folder))
|
||||||
|
parser.enable_api_mode()
|
||||||
|
options = parser.parse_args(cmdline)
|
||||||
|
for keyword, val in deferred.items():
|
||||||
|
setattr(options, keyword, val)
|
||||||
|
delattr(options, 'output_file')
|
||||||
|
setattr(options, 'output_folder', output_folder)
|
||||||
|
|
||||||
|
return run_hocr_pipeline(options=options, plugin_manager=plugin_manager)
|
||||||
|
|
||||||
|
|
||||||
|
def _hocr_to_ocr_pdf( # noqa: D417
|
||||||
|
work_folder: Path,
|
||||||
|
output_file: Path,
|
||||||
|
*,
|
||||||
|
jobs: int | None = None,
|
||||||
|
use_threads: bool | 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,
|
||||||
|
jbig2_threshold: float | None = None,
|
||||||
|
pdfa_image_compression: str | None = None,
|
||||||
|
color_conversion_strategy: str | None = None,
|
||||||
|
fast_web_view: float | None = None,
|
||||||
|
plugin_manager=None,
|
||||||
|
plugins: Sequence[StrPath] | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Run OCRmyPDF on a work folder and produce an output PDF.
|
||||||
|
|
||||||
|
After running :func:`pdf_to_hocr`, this function will run OCRmyPDF on the work
|
||||||
|
folder to produce an output PDF. This function consolidates any changes made
|
||||||
|
to the hOCR files in the work folder and produces a final PDF.
|
||||||
|
|
||||||
|
For arguments not explicitly documented here, see documentation for the
|
||||||
|
equivalent command line parameter.
|
||||||
|
|
||||||
|
This API is **experimental** and subject to change.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
work_folder: Work folder path, as generated by :func:`pdf_to_hocr`.
|
||||||
|
output_file: Output PDF file path.
|
||||||
|
**kwargs: Keyword arguments.
|
||||||
|
"""
|
||||||
|
# No new variable names should be assigned until these two steps are run
|
||||||
|
create_options_kwargs = {
|
||||||
|
k: v
|
||||||
|
for k, v in locals().items()
|
||||||
|
if k not in {'work_folder', 'output_pdf', 'kwargs'}
|
||||||
|
}
|
||||||
|
create_options_kwargs.update(kwargs)
|
||||||
|
|
||||||
|
parser = get_parser()
|
||||||
|
|
||||||
|
with _api_lock:
|
||||||
|
if not plugin_manager:
|
||||||
|
plugin_manager = get_plugin_manager(plugins)
|
||||||
|
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
|
||||||
|
|
||||||
|
cmdline, deferred = _kwargs_to_cmdline(
|
||||||
|
defer_kwargs={'work_folder', 'output_file', 'plugins'},
|
||||||
|
**create_options_kwargs,
|
||||||
|
)
|
||||||
|
cmdline.append(str(work_folder))
|
||||||
|
cmdline.append(str(output_file))
|
||||||
|
parser.enable_api_mode()
|
||||||
|
options = parser.parse_args(cmdline)
|
||||||
|
for keyword, val in deferred.items():
|
||||||
|
setattr(options, keyword, val)
|
||||||
|
delattr(options, 'input_file')
|
||||||
|
setattr(options, 'work_folder', work_folder)
|
||||||
|
|
||||||
|
return run_hocr_to_ocr_pdf_pipeline(
|
||||||
|
options=options, plugin_manager=plugin_manager
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -369,4 +553,5 @@ __all__ = [
|
|||||||
'get_plugin_manager',
|
'get_plugin_manager',
|
||||||
'ocr',
|
'ocr',
|
||||||
'run_pipeline',
|
'run_pipeline',
|
||||||
|
'run_pipeline_cli',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,20 +12,23 @@ import queue
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Callable, Union
|
from typing import Union
|
||||||
|
|
||||||
from rich.console import Console as RichConsole
|
from rich.console import Console as RichConsole
|
||||||
|
|
||||||
from ocrmypdf import Executor, hookimpl
|
from ocrmypdf import Executor, hookimpl
|
||||||
from ocrmypdf._logging import RichLoggingHandler, RichTqdmProgressAdapter
|
from ocrmypdf._logging import RichLoggingHandler
|
||||||
|
from ocrmypdf._progressbar import RichProgressBar
|
||||||
from ocrmypdf.exceptions import InputFileError
|
from ocrmypdf.exceptions import InputFileError
|
||||||
from ocrmypdf.helpers import remove_all_log_handlers
|
from ocrmypdf.helpers import remove_all_log_handlers
|
||||||
|
|
||||||
FuturesExecutorClass = Union[type[ThreadPoolExecutor], type[ProcessPoolExecutor]]
|
FuturesExecutorClass = Union[ # noqa: UP007
|
||||||
Queue = Union[multiprocessing.Queue, queue.Queue]
|
type[ThreadPoolExecutor], type[ProcessPoolExecutor]
|
||||||
|
]
|
||||||
|
Queue = Union[multiprocessing.Queue, queue.Queue] # noqa: UP007
|
||||||
UserInit = Callable[[], None]
|
UserInit = Callable[[], None]
|
||||||
WorkerInit = Callable[[Queue, UserInit, int], None]
|
WorkerInit = Callable[[Queue, UserInit, int], None]
|
||||||
|
|
||||||
@@ -101,7 +104,7 @@ class StandardExecutor(Executor):
|
|||||||
*,
|
*,
|
||||||
use_threads: bool,
|
use_threads: bool,
|
||||||
max_workers: int,
|
max_workers: int,
|
||||||
tqdm_kwargs: dict,
|
progress_kwargs: dict,
|
||||||
worker_initializer: Callable,
|
worker_initializer: Callable,
|
||||||
task: Callable,
|
task: Callable,
|
||||||
task_arguments: Iterable,
|
task_arguments: Iterable,
|
||||||
@@ -127,12 +130,15 @@ class StandardExecutor(Executor):
|
|||||||
listener = threading.Thread(target=log_listener, args=(log_queue,))
|
listener = threading.Thread(target=log_listener, args=(log_queue,))
|
||||||
listener.start()
|
listener.start()
|
||||||
|
|
||||||
with self.pbar_class(**tqdm_kwargs) as pbar, executor_class(
|
with (
|
||||||
max_workers=max_workers,
|
self.pbar_class(**progress_kwargs) as pbar,
|
||||||
initializer=initializer,
|
executor_class(
|
||||||
initargs=(log_queue, worker_initializer, logging.getLogger("").level),
|
max_workers=max_workers,
|
||||||
) as executor:
|
initializer=initializer,
|
||||||
futures = [executor.submit(task, args) for args in task_arguments]
|
initargs=(log_queue, worker_initializer, logging.getLogger("").level),
|
||||||
|
) as executor,
|
||||||
|
):
|
||||||
|
futures = [executor.submit(task, *args) for args in task_arguments]
|
||||||
try:
|
try:
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
result = future.result()
|
result = future.result()
|
||||||
@@ -172,10 +178,10 @@ RICH_CONSOLE = RichConsole(stderr=True)
|
|||||||
def get_progressbar_class():
|
def get_progressbar_class():
|
||||||
"""Return the default progress bar class."""
|
"""Return the default progress bar class."""
|
||||||
|
|
||||||
def partial_RichTqdmProgressAdapter(*args, **kwargs):
|
def partial_RichProgressBar(*args, **kwargs):
|
||||||
return RichTqdmProgressAdapter(*args, **kwargs, console=RICH_CONSOLE)
|
return RichProgressBar(*args, **kwargs, console=RICH_CONSOLE)
|
||||||
|
|
||||||
return partial_RichTqdmProgressAdapter
|
return partial_RichProgressBar
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
|
|||||||
@@ -8,7 +8,5 @@ from ocrmypdf import hookimpl
|
|||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def filter_pdf_page(
|
def filter_pdf_page(page, image_filename, output_pdf): # pylint: disable=unused-argument
|
||||||
page, image_filename, output_pdf
|
|
||||||
): # pylint: disable=unused-argument
|
|
||||||
return output_pdf
|
return output_pdf
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
from ocrmypdf import hookimpl
|
from ocrmypdf import hookimpl
|
||||||
from ocrmypdf._exec import ghostscript
|
from ocrmypdf._exec import ghostscript
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
@@ -15,7 +17,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Currently all blacklisted versions are lower than 9.55, so none need to
|
# Currently all blacklisted versions are lower than 9.55, so none need to
|
||||||
# be added here. If a future version is blacklisted, add it here.
|
# be added here. If a future version is blacklisted, add it here.
|
||||||
BLACKLISTED_GS_VERSIONS: frozenset[str] = frozenset()
|
BLACKLISTED_GS_VERSIONS: frozenset[Version] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
@@ -52,14 +54,24 @@ def check_options(options):
|
|||||||
program='gs',
|
program='gs',
|
||||||
package='ghostscript',
|
package='ghostscript',
|
||||||
version_checker=ghostscript.version,
|
version_checker=ghostscript.version,
|
||||||
need_version='9.55', # Ubuntu 22.04's version
|
need_version='9.54', # RHEL 9's version; Ubuntu 22.04 has 9.55
|
||||||
)
|
)
|
||||||
gs_version = ghostscript.version()
|
gs_version = ghostscript.version()
|
||||||
if gs_version in BLACKLISTED_GS_VERSIONS:
|
if gs_version in BLACKLISTED_GS_VERSIONS:
|
||||||
raise MissingDependencyError(
|
raise MissingDependencyError(
|
||||||
f"Ghostscript {gs_version} contains serious regressions and is not "
|
f"Ghostscript {gs_version} contains serious regressions and is not "
|
||||||
"supported. Please upgrade to a newer version, or downgrade to the "
|
"supported. Please upgrade to a newer version."
|
||||||
"previous version."
|
)
|
||||||
|
if Version('10.0.0') <= gs_version < Version('10.02.1') and (
|
||||||
|
options.skip_text or options.redo_ocr
|
||||||
|
):
|
||||||
|
raise MissingDependencyError(
|
||||||
|
f"Ghostscript 10.0.0 through 10.02.0 (your version: {gs_version}) "
|
||||||
|
"contain serious regressions that corrupt PDFs with existing text, "
|
||||||
|
"such as those processed using --skip-text or --redo-ocr. "
|
||||||
|
"Please upgrade to a "
|
||||||
|
"newer version, or use --output-type pdf to avoid Ghostscript, or "
|
||||||
|
"use --force-ocr to discard existing text."
|
||||||
)
|
)
|
||||||
|
|
||||||
if options.output_type == 'pdfa':
|
if options.output_type == 'pdfa':
|
||||||
@@ -117,7 +129,7 @@ def generate_pdfa(
|
|||||||
):
|
):
|
||||||
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
|
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
|
||||||
ghostscript.generate_pdfa(
|
ghostscript.generate_pdfa(
|
||||||
pdf_pages=[*pdf_pages, pdfmark],
|
pdf_pages=[pdfmark, *pdf_pages],
|
||||||
output_file=output_file,
|
output_file=output_file,
|
||||||
compression=context.options.pdfa_image_compression,
|
compression=context.options.pdfa_image_compression,
|
||||||
color_conversion_strategy=context.options.color_conversion_strategy,
|
color_conversion_strategy=context.options.color_conversion_strategy,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
"""Built-in plugin to implement OCR using Tesseract."""
|
"""Built-in plugin to implement OCR using Tesseract."""
|
||||||
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ from ocrmypdf import hookimpl
|
|||||||
from ocrmypdf._exec import tesseract
|
from ocrmypdf._exec import tesseract
|
||||||
from ocrmypdf._jobcontext import PageContext
|
from ocrmypdf._jobcontext import PageContext
|
||||||
from ocrmypdf.cli import numeric, str_to_int
|
from ocrmypdf.cli import numeric, str_to_int
|
||||||
|
from ocrmypdf.exceptions import BadArgsError, MissingDependencyError
|
||||||
from ocrmypdf.helpers import clamp
|
from ocrmypdf.helpers import clamp
|
||||||
from ocrmypdf.imageops import calculate_downsample, downsample_image
|
from ocrmypdf.imageops import calculate_downsample, downsample_image
|
||||||
from ocrmypdf.pluginspec import OcrEngine
|
from ocrmypdf.pluginspec import OcrEngine
|
||||||
@@ -94,7 +95,8 @@ def add_options(parser):
|
|||||||
)
|
)
|
||||||
tess.add_argument(
|
tess.add_argument(
|
||||||
'--tesseract-downsample-large-images',
|
'--tesseract-downsample-large-images',
|
||||||
action='store_true',
|
action=argparse.BooleanOptionalAction,
|
||||||
|
default=True,
|
||||||
help=(
|
help=(
|
||||||
"Downsample large images before OCR. Tesseract has an upper limit on the "
|
"Downsample large images before OCR. Tesseract has an upper limit on the "
|
||||||
"size images it will support. If this argument is given, OCRmyPDF will "
|
"size images it will support. If this argument is given, OCRmyPDF will "
|
||||||
@@ -143,10 +145,20 @@ def check_options(options):
|
|||||||
need_version='4.1.1', # Ubuntu 22.04 version (also 20.04)
|
need_version='4.1.1', # Ubuntu 22.04 version (also 20.04)
|
||||||
version_parser=tesseract.TesseractVersion,
|
version_parser=tesseract.TesseractVersion,
|
||||||
)
|
)
|
||||||
|
tess_version = tesseract.version()
|
||||||
|
if tess_version == tesseract.TesseractVersion('5.4.0'):
|
||||||
|
raise MissingDependencyError(
|
||||||
|
"Tesseract 5.4.0 is not supported due to regressions in this version. "
|
||||||
|
"Please upgrade to a newer or supported older version."
|
||||||
|
)
|
||||||
|
|
||||||
# Decide on what renderer to use
|
# Decide on what renderer to use
|
||||||
if options.pdf_renderer == 'auto':
|
if options.pdf_renderer == 'auto':
|
||||||
options.pdf_renderer = 'sandwich'
|
if {'ara', 'heb', 'fas', 'per'} & set(options.languages):
|
||||||
|
log.info("Using sandwich renderer since there is an RTL language")
|
||||||
|
options.pdf_renderer = 'sandwich'
|
||||||
|
else:
|
||||||
|
options.pdf_renderer = 'hocr'
|
||||||
|
|
||||||
if not tesseract.has_thresholding() and options.tesseract_thresholding != 0:
|
if not tesseract.has_thresholding() and options.tesseract_thresholding != 0:
|
||||||
log.warning(
|
log.warning(
|
||||||
@@ -159,6 +171,14 @@ def check_options(options):
|
|||||||
"The --tesseract-pagesegmode argument you select will disable OCR. "
|
"The --tesseract-pagesegmode argument you select will disable OCR. "
|
||||||
"This may cause processing to fail."
|
"This may cause processing to fail."
|
||||||
)
|
)
|
||||||
|
DENIED_LANGUAGES = {'equ', 'osd'}
|
||||||
|
if DENIED_LANGUAGES & set(options.languages):
|
||||||
|
raise BadArgsError(
|
||||||
|
"The following languages for Tesseract's internal use and should not "
|
||||||
|
"be issued explicitly: "
|
||||||
|
f"{', '.join(DENIED_LANGUAGES & set(options.languages))}\n"
|
||||||
|
"Remove them from the -l/--language argument."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
@@ -216,7 +236,7 @@ class TesseractOcrEngine(OcrEngine):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def creator_tag(options):
|
def creator_tag(options):
|
||||||
tag = '-PDF' if options.pdf_renderer == 'sandwich' else ''
|
tag = '-PDF' if options.pdf_renderer == 'sandwich' else '-hOCR'
|
||||||
return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}"
|
return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
+7
-6
@@ -6,9 +6,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import Any, Callable, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||||
from ocrmypdf._version import PROGRAM_NAME as _PROGRAM_NAME
|
from ocrmypdf._version import PROGRAM_NAME as _PROGRAM_NAME
|
||||||
from ocrmypdf._version import __version__ as _VERSION
|
from ocrmypdf._version import __version__ as _VERSION
|
||||||
|
|
||||||
@@ -193,8 +194,8 @@ Online documentation is located at:
|
|||||||
help="Choose output type. 'pdfa' creates a PDF/A-2b compliant file for "
|
help="Choose output type. 'pdfa' creates a PDF/A-2b compliant file for "
|
||||||
"long term archiving (default, recommended) but may not suitable "
|
"long term archiving (default, recommended) but may not suitable "
|
||||||
"for users who want their file altered as little as possible. 'pdfa' "
|
"for users who want their file altered as little as possible. 'pdfa' "
|
||||||
"also has problems with full Unicode text. 'pdf' attempts to "
|
"also has problems with full Unicode text. 'pdf' minimizes changes "
|
||||||
"preserve file contents as much as possible. 'pdf-a1' creates a "
|
"to the input file. 'pdf-a1' creates a "
|
||||||
"PDF/A1-b file. 'pdf-a2' is equivalent to 'pdfa'. 'pdf-a3' creates a "
|
"PDF/A1-b file. 'pdf-a2' is equivalent to 'pdfa'. 'pdf-a3' creates a "
|
||||||
"PDF/A3-b file. 'none' will produce no output, which may be helpful if "
|
"PDF/A3-b file. 'none' will produce no output, which may be helpful if "
|
||||||
"only the --sidecar is desired.",
|
"only the --sidecar is desired.",
|
||||||
@@ -390,7 +391,7 @@ Online documentation is located at:
|
|||||||
action='store',
|
action='store',
|
||||||
type=numeric(float, 0),
|
type=numeric(float, 0),
|
||||||
metavar='MPixels',
|
metavar='MPixels',
|
||||||
help="Set maximum number of pixels to unpack before treating an image as a "
|
help="Set maximum number of megapixels to unpack before treating an image as a "
|
||||||
"decompression bomb",
|
"decompression bomb",
|
||||||
default=250.0,
|
default=250.0,
|
||||||
)
|
)
|
||||||
@@ -403,7 +404,7 @@ Online documentation is located at:
|
|||||||
)
|
)
|
||||||
advanced.add_argument(
|
advanced.add_argument(
|
||||||
'--rotate-pages-threshold',
|
'--rotate-pages-threshold',
|
||||||
default=14.0,
|
default=DEFAULT_ROTATE_PAGES_THRESHOLD,
|
||||||
type=numeric(float, 0, 1000),
|
type=numeric(float, 0, 1000),
|
||||||
metavar='CONFIDENCE',
|
metavar='CONFIDENCE',
|
||||||
help="Only rotate pages when confidence is above this value (arbitrary "
|
help="Only rotate pages when confidence is above this value (arbitrary "
|
||||||
|
|||||||
Binary file not shown.
@@ -137,3 +137,16 @@ class TaggedPDFError(InputFileError):
|
|||||||
override this error.
|
override this error.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ColorConversionNeededError(BadArgsError):
|
||||||
|
"""PDF needs color conversion."""
|
||||||
|
|
||||||
|
message = dedent(
|
||||||
|
"""\
|
||||||
|
The input PDF has an unusual color space. Use
|
||||||
|
--color-conversion-strategy to convert to a common color space
|
||||||
|
such as RGB, or use --output-type pdf to skip PDF/A conversion
|
||||||
|
and retain the original color space.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,13 +20,12 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import signal
|
import signal
|
||||||
from collections.abc import Iterable, Iterator
|
from collections.abc import Callable, Iterable, Iterator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from itertools import islice, repeat, takewhile, zip_longest
|
from itertools import islice, repeat, takewhile, zip_longest
|
||||||
from multiprocessing import Pipe, Process
|
from multiprocessing import Pipe, Process
|
||||||
from multiprocessing.connection import Connection, wait
|
from multiprocessing.connection import Connection, wait
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from ocrmypdf import Executor, hookimpl
|
from ocrmypdf import Executor, hookimpl
|
||||||
from ocrmypdf._concurrent import NullProgressBar
|
from ocrmypdf._concurrent import NullProgressBar
|
||||||
@@ -94,7 +93,7 @@ def process_loop(
|
|||||||
|
|
||||||
for args in task_args:
|
for args in task_args:
|
||||||
try:
|
try:
|
||||||
result = task(args)
|
result = task(*args)
|
||||||
except Exception as e: # pylint: disable=broad-except
|
except Exception as e: # pylint: disable=broad-except
|
||||||
conn.send((MessageType.exception, e))
|
conn.send((MessageType.exception, e))
|
||||||
break
|
break
|
||||||
@@ -114,16 +113,16 @@ class LambdaExecutor(Executor):
|
|||||||
*,
|
*,
|
||||||
use_threads: bool,
|
use_threads: bool,
|
||||||
max_workers: int,
|
max_workers: int,
|
||||||
tqdm_kwargs: dict,
|
progress_kwargs: dict,
|
||||||
worker_initializer: Callable,
|
worker_initializer: Callable,
|
||||||
task: Callable,
|
task: Callable,
|
||||||
task_arguments: Iterable,
|
task_arguments: Iterable,
|
||||||
task_finished: Callable,
|
task_finished: Callable,
|
||||||
):
|
):
|
||||||
if use_threads and max_workers == 1:
|
if use_threads and max_workers == 1:
|
||||||
with self.pbar_class(**tqdm_kwargs) as pbar:
|
with self.pbar_class(**progress_kwargs) as pbar:
|
||||||
for args in task_arguments:
|
for args in task_arguments:
|
||||||
result = task(args)
|
result = task(*args)
|
||||||
task_finished(result, pbar)
|
task_finished(result, pbar)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -157,7 +156,7 @@ class LambdaExecutor(Executor):
|
|||||||
for process in processes:
|
for process in processes:
|
||||||
process.start()
|
process.start()
|
||||||
|
|
||||||
with self.pbar_class(**tqdm_kwargs) as pbar:
|
with self.pbar_class(**progress_kwargs) as pbar:
|
||||||
while connections:
|
while connections:
|
||||||
for result in wait(connections):
|
for result in wait(connections):
|
||||||
if not isinstance(result, Connection):
|
if not isinstance(result, Connection):
|
||||||
|
|||||||
+12
-5
@@ -10,7 +10,7 @@ import multiprocessing
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
@@ -19,13 +19,13 @@ from pathlib import Path
|
|||||||
from statistics import harmonic_mean
|
from statistics import harmonic_mean
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
|
||||||
Generic,
|
Generic,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
)
|
)
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
import pikepdf
|
import pikepdf
|
||||||
|
from deprecation import deprecated
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -136,6 +136,7 @@ class Resolution(Generic[T]):
|
|||||||
return self._isclose(self.x, other.x) and self._isclose(self.y, other.y)
|
return self._isclose(self.x, other.x) and self._isclose(self.y, other.y)
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated(deprecated_in='15.4.0')
|
||||||
class NeverRaise(Exception):
|
class NeverRaise(Exception):
|
||||||
"""An exception that is never raised."""
|
"""An exception that is never raised."""
|
||||||
|
|
||||||
@@ -267,7 +268,9 @@ def check_pdf(input_file: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
with pdf:
|
with pdf:
|
||||||
messages = pdf.check()
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings('ignore', message=r'pikepdf.*JBIG2.*')
|
||||||
|
messages = pdf.check()
|
||||||
success = True
|
success = True
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if 'error' in msg.lower():
|
if 'error' in msg.lower():
|
||||||
@@ -319,12 +322,16 @@ def remove_all_log_handlers(logger: logging.Logger) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def pikepdf_enable_mmap() -> None:
|
def pikepdf_enable_mmap() -> None:
|
||||||
"""Enable pikepdf mmap."""
|
"""Enable pikepdf memory mapping."""
|
||||||
try:
|
try:
|
||||||
pikepdf._core.set_access_default_mmap(True)
|
pikepdf._core.set_access_default_mmap(True)
|
||||||
log.debug(
|
log.debug(
|
||||||
"pikepdf mmap "
|
"pikepdf mmap "
|
||||||
+ ('enabled' if pikepdf._core.get_access_default_mmap() else 'disabled')
|
+ (
|
||||||
|
'enabled'
|
||||||
|
if pikepdf._core.get_access_default_mmap() # type: ignore[attr-defined]
|
||||||
|
else 'disabled'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
log.debug("pikepdf mmap not available")
|
log.debug("pikepdf mmap not available")
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
|
|
||||||
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
|
|
||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
"""Transform .hocr and page image to text PDF."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import warnings
|
|
||||||
from math import atan, cos, sin
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, NamedTuple
|
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
# reportlab uses deprecated load_module
|
|
||||||
# shim can be removed when we require reportlab >= 3.7
|
|
||||||
warnings.filterwarnings(
|
|
||||||
'ignore', category=DeprecationWarning, message=r".*load_module.*"
|
|
||||||
)
|
|
||||||
from reportlab.lib.colors import black, cyan, magenta, red
|
|
||||||
from reportlab.lib.units import inch
|
|
||||||
from reportlab.pdfgen.canvas import Canvas
|
|
||||||
|
|
||||||
# According to Wikipedia these languages are supported in the ISO-8859-1 character
|
|
||||||
# set, meaning reportlab can generate them and they are compatible with hocr,
|
|
||||||
# assuming Tesseract has the necessary languages installed. Note that there may
|
|
||||||
# not be language packs for them.
|
|
||||||
HOCR_OK_LANGS = frozenset(
|
|
||||||
[
|
|
||||||
# Languages fully covered by Latin-1:
|
|
||||||
'afr', # Afrikaans
|
|
||||||
'alb', # Albanian
|
|
||||||
'ast', # Leonese
|
|
||||||
'baq', # Basque
|
|
||||||
'bre', # Breton
|
|
||||||
'cos', # Corsican
|
|
||||||
'eng', # English
|
|
||||||
'eus', # Basque
|
|
||||||
'fao', # Faoese
|
|
||||||
'gla', # Scottish Gaelic
|
|
||||||
'glg', # Galician
|
|
||||||
'glv', # Manx
|
|
||||||
'ice', # Icelandic
|
|
||||||
'ind', # Indonesian
|
|
||||||
'isl', # Icelandic
|
|
||||||
'ita', # Italian
|
|
||||||
'ltz', # Luxembourgish
|
|
||||||
'mal', # Malay Rumi
|
|
||||||
'mga', # Irish
|
|
||||||
'nor', # Norwegian
|
|
||||||
'oci', # Occitan
|
|
||||||
'por', # Portugeuse
|
|
||||||
'roh', # Romansh
|
|
||||||
'sco', # Scots
|
|
||||||
'sma', # Sami
|
|
||||||
'spa', # Spanish
|
|
||||||
'sqi', # Albanian
|
|
||||||
'swa', # Swahili
|
|
||||||
'swe', # Swedish
|
|
||||||
'tgl', # Tagalog
|
|
||||||
'wln', # Walloon
|
|
||||||
# Languages supported by Latin-1 except for a few rare characters that OCR
|
|
||||||
# is probably not trained to recognize anyway:
|
|
||||||
'cat', # Catalan
|
|
||||||
'cym', # Welsh
|
|
||||||
'dan', # Danish
|
|
||||||
'deu', # German
|
|
||||||
'dut', # Dutch
|
|
||||||
'est', # Estonian
|
|
||||||
'fin', # Finnish
|
|
||||||
'fra', # French
|
|
||||||
'hun', # Hungarian
|
|
||||||
'kur', # Kurdish
|
|
||||||
'nld', # Dutch
|
|
||||||
'wel', # Welsh
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
Element = ElementTree.Element
|
|
||||||
|
|
||||||
|
|
||||||
class Rect(NamedTuple):
|
|
||||||
"""A rectangle for managing PDF coordinates."""
|
|
||||||
|
|
||||||
x1: Any
|
|
||||||
y1: Any
|
|
||||||
x2: Any
|
|
||||||
y2: Any
|
|
||||||
|
|
||||||
|
|
||||||
class HocrTransformError(Exception):
|
|
||||||
"""Error while applying hOCR transform."""
|
|
||||||
|
|
||||||
|
|
||||||
class HocrTransform:
|
|
||||||
"""A class for converting documents from the hOCR format.
|
|
||||||
|
|
||||||
For details of the hOCR format, see:
|
|
||||||
http://kba.cloud/hocr-spec/.
|
|
||||||
"""
|
|
||||||
|
|
||||||
box_pattern = re.compile(r'bbox((\s+\d+){4})')
|
|
||||||
baseline_pattern = re.compile(
|
|
||||||
r'''
|
|
||||||
baseline \s+
|
|
||||||
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
|
|
||||||
([\-\+]?\d+) # +/- int''',
|
|
||||||
re.VERBOSE,
|
|
||||||
)
|
|
||||||
ligatures = str.maketrans(
|
|
||||||
{'ff': 'ff', 'ffi': 'ffi', 'ffl': 'ffl', 'fi': 'fi', 'fl': 'fl'}
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *, hocr_filename: str | Path, dpi: float):
|
|
||||||
"""Initialize the HocrTransform object."""
|
|
||||||
self.dpi = dpi
|
|
||||||
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
|
|
||||||
|
|
||||||
# if the hOCR file has a namespace, ElementTree requires its use to
|
|
||||||
# find elements
|
|
||||||
matches = re.match(r'({.*})html', self.hocr.getroot().tag)
|
|
||||||
self.xmlns = ''
|
|
||||||
if matches:
|
|
||||||
self.xmlns = matches.group(1)
|
|
||||||
|
|
||||||
# get dimension in pt (not pixel!!!!) of the OCRed image
|
|
||||||
self.width, self.height = None, None
|
|
||||||
for div in self.hocr.findall(self._child_xpath('div', 'ocr_page')):
|
|
||||||
coords = self.element_coordinates(div)
|
|
||||||
pt_coords = self.pt_from_pixel(coords)
|
|
||||||
self.width = pt_coords.x2 - pt_coords.x1
|
|
||||||
self.height = pt_coords.y2 - pt_coords.y1
|
|
||||||
# there shouldn't be more than one, and if there is, we don't want
|
|
||||||
# it
|
|
||||||
break
|
|
||||||
if self.width is None or self.height is None:
|
|
||||||
raise HocrTransformError("hocr file is missing page dimensions")
|
|
||||||
|
|
||||||
def __str__(self): # pragma: no cover
|
|
||||||
"""Return the textual content of the HTML body."""
|
|
||||||
if self.hocr is None:
|
|
||||||
return ''
|
|
||||||
body = self.hocr.find(self._child_xpath('body'))
|
|
||||||
if body:
|
|
||||||
return self._get_element_text(body)
|
|
||||||
else:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
def _get_element_text(self, element: Element):
|
|
||||||
"""Return the textual content of the element and its children."""
|
|
||||||
text = ''
|
|
||||||
if element.text is not None:
|
|
||||||
text += element.text
|
|
||||||
for child in element:
|
|
||||||
text += self._get_element_text(child)
|
|
||||||
if element.tail is not None:
|
|
||||||
text += element.tail
|
|
||||||
return text
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def element_coordinates(cls, element: Element) -> Rect:
|
|
||||||
"""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'])
|
|
||||||
if matches:
|
|
||||||
coords = matches.group(1).split()
|
|
||||||
out = Rect._make(int(coords[n]) for n in range(4))
|
|
||||||
return out
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def baseline(cls, element: Element) -> tuple[float, float]:
|
|
||||||
"""Get baseline's slope and intercept."""
|
|
||||||
if 'title' in element.attrib:
|
|
||||||
matches = cls.baseline_pattern.search(element.attrib['title'])
|
|
||||||
if matches:
|
|
||||||
return float(matches.group(1)), int(matches.group(2))
|
|
||||||
return (0.0, 0.0)
|
|
||||||
|
|
||||||
def pt_from_pixel(self, pxl) -> Rect:
|
|
||||||
"""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:
|
|
||||||
xpath = f".//{self.xmlns}{html_tag}"
|
|
||||||
if html_class:
|
|
||||||
xpath += f"[@class='{html_class}']"
|
|
||||||
return xpath
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def replace_unsupported_chars(cls, s: str) -> str:
|
|
||||||
"""Replaces characters with those available in the Helvetica typeface."""
|
|
||||||
return s.translate(cls.ligatures)
|
|
||||||
|
|
||||||
def to_pdf(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
out_filename: Path,
|
|
||||||
image_filename: Path | None = None,
|
|
||||||
show_bounding_boxes: bool = False,
|
|
||||||
fontname: str = "Helvetica",
|
|
||||||
invisible_text: bool = False,
|
|
||||||
interword_spaces: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""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
|
|
||||||
file.
|
|
||||||
It can have a lower resolution, different color mode, etc.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
out_filename: Path of PDF to write.
|
|
||||||
image_filename: Image to use for this file. If omitted, the OCR text
|
|
||||||
is shown.
|
|
||||||
show_bounding_boxes: Show bounding boxes around various text regions,
|
|
||||||
for debugging.
|
|
||||||
fontname: Name of font to use.
|
|
||||||
invisible_text: If True, text is rendered invisible so that is
|
|
||||||
selectable but never drawn. If False, text is visible and may
|
|
||||||
be seen if the image is skipped or deleted in Acrobat.
|
|
||||||
interword_spaces: If True, insert spaces between words rather than
|
|
||||||
drawing each word without spaces. Generally this improves text
|
|
||||||
extraction.
|
|
||||||
"""
|
|
||||||
# create the PDF file
|
|
||||||
# page size in points (1/72 in.)
|
|
||||||
pdf = Canvas(
|
|
||||||
os.fspath(out_filename),
|
|
||||||
pagesize=(self.width, self.height),
|
|
||||||
pageCompression=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# draw bounding box for each paragraph
|
|
||||||
# light blue for bounding box of paragraph
|
|
||||||
pdf.setStrokeColor(cyan)
|
|
||||||
# light blue for bounding box of paragraph
|
|
||||||
pdf.setFillColor(cyan)
|
|
||||||
pdf.setLineWidth(0) # no line for bounding box
|
|
||||||
for elem in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
|
|
||||||
elemtxt = self._get_element_text(elem).rstrip()
|
|
||||||
if len(elemtxt) == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
pxl_coords = self.element_coordinates(elem)
|
|
||||||
pt = self.pt_from_pixel(pxl_coords) # pylint: disable=invalid-name
|
|
||||||
|
|
||||||
# draw the bbox border
|
|
||||||
if show_bounding_boxes: # pragma: no cover
|
|
||||||
pdf.rect(
|
|
||||||
pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, fill=1
|
|
||||||
)
|
|
||||||
|
|
||||||
found_lines = False
|
|
||||||
for line in (
|
|
||||||
element
|
|
||||||
for element in self.hocr.iterfind(self._child_xpath('span'))
|
|
||||||
if 'class' in element.attrib
|
|
||||||
and element.attrib['class'] in {'ocr_header', 'ocr_line', 'ocr_textfloat'}
|
|
||||||
):
|
|
||||||
found_lines = True
|
|
||||||
self._do_line(
|
|
||||||
pdf,
|
|
||||||
line,
|
|
||||||
"ocrx_word",
|
|
||||||
fontname,
|
|
||||||
invisible_text,
|
|
||||||
interword_spaces,
|
|
||||||
show_bounding_boxes,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not found_lines:
|
|
||||||
# Tesseract did not report any lines (just words)
|
|
||||||
root = self.hocr.find(self._child_xpath('div', 'ocr_page'))
|
|
||||||
self._do_line(
|
|
||||||
pdf,
|
|
||||||
root,
|
|
||||||
"ocrx_word",
|
|
||||||
fontname,
|
|
||||||
invisible_text,
|
|
||||||
interword_spaces,
|
|
||||||
show_bounding_boxes,
|
|
||||||
)
|
|
||||||
# put the image on the page, scaled to fill the page
|
|
||||||
if image_filename is not None:
|
|
||||||
pdf.drawImage(
|
|
||||||
os.fspath(image_filename), 0, 0, width=self.width, height=self.height
|
|
||||||
)
|
|
||||||
|
|
||||||
# finish up the page and save it
|
|
||||||
pdf.showPage()
|
|
||||||
pdf.save()
|
|
||||||
|
|
||||||
@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(
|
|
||||||
self,
|
|
||||||
pdf: Canvas,
|
|
||||||
line: Element | None,
|
|
||||||
elemclass: str,
|
|
||||||
fontname: str,
|
|
||||||
invisible_text: bool,
|
|
||||||
interword_spaces: bool,
|
|
||||||
show_bounding_boxes: bool,
|
|
||||||
):
|
|
||||||
if line is None:
|
|
||||||
return
|
|
||||||
pxl_line_coords = self.element_coordinates(line)
|
|
||||||
line_box = self.pt_from_pixel(pxl_line_coords)
|
|
||||||
line_height = line_box.y2 - line_box.y1
|
|
||||||
|
|
||||||
slope, pxl_intercept = self.baseline(line)
|
|
||||||
if abs(slope) < 0.005:
|
|
||||||
slope = 0.0
|
|
||||||
angle = atan(slope)
|
|
||||||
cos_a, sin_a = cos(angle), sin(angle)
|
|
||||||
|
|
||||||
text = pdf.beginText()
|
|
||||||
intercept = pxl_intercept / self.dpi * inch
|
|
||||||
|
|
||||||
# Don't allow the font to break out of the bounding box. Division by
|
|
||||||
# cos_a accounts for extra clearance between the glyph's vertical axis
|
|
||||||
# on a sloped baseline and the edge of the bounding box.
|
|
||||||
fontsize = (line_height - abs(intercept)) / cos_a
|
|
||||||
text.setFont(fontname, fontsize)
|
|
||||||
if invisible_text:
|
|
||||||
text.setTextRenderMode(3) # Invisible (indicates OCR text)
|
|
||||||
|
|
||||||
# Intercept is normally negative, so this places it above the bottom
|
|
||||||
# of the line box
|
|
||||||
baseline_y2 = self.height - (line_box.y2 + intercept)
|
|
||||||
|
|
||||||
if show_bounding_boxes: # pragma: no cover
|
|
||||||
# draw the baseline in magenta, dashed
|
|
||||||
pdf.setDash()
|
|
||||||
pdf.setStrokeColor(magenta)
|
|
||||||
pdf.setLineWidth(0.5)
|
|
||||||
# negate slope because it is defined as a rise/run in pixel
|
|
||||||
# coordinates and page coordinates have the y axis flipped
|
|
||||||
pdf.line(
|
|
||||||
line_box.x1,
|
|
||||||
baseline_y2,
|
|
||||||
line_box.x2,
|
|
||||||
self.polyval((-slope, baseline_y2), line_box.x2 - line_box.x1),
|
|
||||||
)
|
|
||||||
# light green for bounding box of word/line
|
|
||||||
pdf.setDash(6, 3)
|
|
||||||
pdf.setStrokeColor(red)
|
|
||||||
|
|
||||||
text.setTextTransform(cos_a, -sin_a, sin_a, cos_a, line_box.x1, baseline_y2)
|
|
||||||
pdf.setFillColor(black) # text in black
|
|
||||||
|
|
||||||
elements = line.findall(self._child_xpath('span', elemclass))
|
|
||||||
for elem in elements:
|
|
||||||
elemtxt = self._get_element_text(elem).strip()
|
|
||||||
elemtxt = self.replace_unsupported_chars(elemtxt)
|
|
||||||
if elemtxt == '':
|
|
||||||
continue
|
|
||||||
|
|
||||||
pxl_coords = self.element_coordinates(elem)
|
|
||||||
box = self.pt_from_pixel(pxl_coords)
|
|
||||||
if interword_spaces:
|
|
||||||
# if `--interword-spaces` is true, append a space
|
|
||||||
# to the end of each text element to allow simpler PDF viewers
|
|
||||||
# such as PDF.js to better recognize words in search and copy
|
|
||||||
# and paste. Do not remove space from last word in line, even
|
|
||||||
# though it would look better, because it will interfere with
|
|
||||||
# naive text extraction. \n does not work either.
|
|
||||||
elemtxt += ' '
|
|
||||||
box = Rect._make(
|
|
||||||
(
|
|
||||||
box.x1,
|
|
||||||
line_box.y1,
|
|
||||||
box.x2 + pdf.stringWidth(' ', fontname, line_height),
|
|
||||||
line_box.y2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
box_width = box.x2 - box.x1
|
|
||||||
font_width = pdf.stringWidth(elemtxt, fontname, fontsize)
|
|
||||||
|
|
||||||
# draw the bbox border
|
|
||||||
if show_bounding_boxes: # pragma: no cover
|
|
||||||
pdf.rect(
|
|
||||||
box.x1, self.height - line_box.y2, box_width, line_height, fill=0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Adjust relative position of cursor
|
|
||||||
# This is equivalent to:
|
|
||||||
# text.setTextOrigin(pt.x1, self.height - line_box.y2)
|
|
||||||
# but the former generates a full text reposition matrix (Tm) in the
|
|
||||||
# content stream while this issues a "offset" (Td) command.
|
|
||||||
# .moveCursor() is relative to start of the text line, where the
|
|
||||||
# "text line" means whatever reportlab defines it as. Do not use
|
|
||||||
# use .getCursor(), since moveCursor() rather unintuitively plans
|
|
||||||
# its moves relative to .getStartOfLine().
|
|
||||||
# For skewed lines, in the text transform we set up a rotated
|
|
||||||
# coordinate system, so we don't have to account for the
|
|
||||||
# incremental offset. Surprisingly most PDF viewers can handle this.
|
|
||||||
cursor = text.getStartOfLine()
|
|
||||||
dx = box.x1 - cursor[0]
|
|
||||||
dy = baseline_y2 - cursor[1]
|
|
||||||
text.moveCursor(dx, dy)
|
|
||||||
|
|
||||||
# If reportlab tells us this word is 0 units wide, our best seems
|
|
||||||
# to be to suppress this text
|
|
||||||
if font_width > 0:
|
|
||||||
text.setHorizScale(100 * box_width / font_width)
|
|
||||||
text.textOut(elemtxt)
|
|
||||||
pdf.drawText(text)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description='Convert hocr file to PDF')
|
|
||||||
parser.add_argument(
|
|
||||||
'-b',
|
|
||||||
'--boundingboxes',
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help='Show bounding boxes borders',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'-r',
|
|
||||||
'--resolution',
|
|
||||||
type=int,
|
|
||||||
default=300,
|
|
||||||
help='Resolution of the image that was OCRed',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'-i',
|
|
||||||
'--image',
|
|
||||||
default=None,
|
|
||||||
help='Path to the image to be placed above the text',
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--interword-spaces',
|
|
||||||
action='store_true',
|
|
||||||
default=False,
|
|
||||||
help='Add spaces between words',
|
|
||||||
)
|
|
||||||
parser.add_argument('hocrfile', help='Path to the hocr file to be parsed')
|
|
||||||
parser.add_argument('outputfile', help='Path to the PDF file to be generated')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
hocr = HocrTransform(hocr_filename=args.hocrfile, dpi=args.resolution)
|
|
||||||
hocr.to_pdf(
|
|
||||||
out_filename=args.outputfile,
|
|
||||||
image_filename=args.image,
|
|
||||||
show_bounding_boxes=args.boundingboxes,
|
|
||||||
interword_spaces=args.interword_spaces,
|
|
||||||
)
|
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Transform .hocr and page image to text PDF."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ocrmypdf.hocrtransform._hocr import (
|
||||||
|
DebugRenderOptions,
|
||||||
|
HocrTransform,
|
||||||
|
HocrTransformError,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'HocrTransform',
|
||||||
|
'HocrTransformError',
|
||||||
|
'DebugRenderOptions',
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Simple CLI for testing HOCR."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from ocrmypdf.hocrtransform import HocrTransform
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description='Convert hocr file to PDF')
|
||||||
|
parser.add_argument(
|
||||||
|
'-b',
|
||||||
|
'--boundingboxes',
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help='Show bounding boxes borders',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-r',
|
||||||
|
'--resolution',
|
||||||
|
type=int,
|
||||||
|
default=300,
|
||||||
|
help='Resolution of the image that was OCRed',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-i',
|
||||||
|
'--image',
|
||||||
|
default=None,
|
||||||
|
help='Path to the image to be placed above the text',
|
||||||
|
)
|
||||||
|
parser.add_argument('hocrfile', help='Path to the hocr file to be parsed')
|
||||||
|
parser.add_argument('outputfile', help='Path to the PDF file to be generated')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
hocr = HocrTransform(hocr_filename=args.hocrfile, dpi=args.resolution)
|
||||||
|
hocr.to_pdf(
|
||||||
|
out_filename=args.outputfile,
|
||||||
|
image_filename=args.image,
|
||||||
|
)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import unicodedata
|
||||||
|
import zlib
|
||||||
|
from importlib.resources import files as package_files
|
||||||
|
|
||||||
|
from pikepdf import (
|
||||||
|
Dictionary,
|
||||||
|
Name,
|
||||||
|
Pdf,
|
||||||
|
)
|
||||||
|
from pikepdf.canvas import Font
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EncodableFont(Font):
|
||||||
|
def text_encode(self, text: str) -> bytes:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class GlyphlessFont(EncodableFont):
|
||||||
|
CID_TO_GID_DATA = zlib.compress(b"\x00\x01" * 65536)
|
||||||
|
GLYPHLESS_FONT_NAME = 'pdf.ttf'
|
||||||
|
GLYPHLESS_FONT = (package_files('ocrmypdf.data') / GLYPHLESS_FONT_NAME).read_bytes()
|
||||||
|
CHAR_ASPECT = 2
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def text_width(self, text: str, fontsize: float) -> float:
|
||||||
|
"""Estimate the width of a text string when rendered with the given font."""
|
||||||
|
# NFKC: split ligatures, combine diacritics
|
||||||
|
return len(unicodedata.normalize("NFKC", text)) * (fontsize / self.CHAR_ASPECT)
|
||||||
|
|
||||||
|
def text_encode(self, text: str) -> bytes:
|
||||||
|
return text.encode('utf-16be')
|
||||||
|
|
||||||
|
def register(self, pdf: Pdf):
|
||||||
|
"""Register the glyphless font.
|
||||||
|
|
||||||
|
Create several data structures in the Pdf to describe the font. While it create
|
||||||
|
the data, a reference should be set in at least one page's /Resources dictionary
|
||||||
|
to retain the font in the output PDF and ensure it is usable on that page.
|
||||||
|
"""
|
||||||
|
PLACEHOLDER = Name.Placeholder
|
||||||
|
|
||||||
|
basefont = pdf.make_indirect(
|
||||||
|
Dictionary(
|
||||||
|
BaseFont=Name.GlyphLessFont,
|
||||||
|
DescendantFonts=[PLACEHOLDER],
|
||||||
|
Encoding=Name("/Identity-H"),
|
||||||
|
Subtype=Name.Type0,
|
||||||
|
ToUnicode=PLACEHOLDER,
|
||||||
|
Type=Name.Font,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cid_font_type2 = pdf.make_indirect(
|
||||||
|
Dictionary(
|
||||||
|
BaseFont=Name.GlyphLessFont,
|
||||||
|
CIDToGIDMap=PLACEHOLDER,
|
||||||
|
CIDSystemInfo=Dictionary(
|
||||||
|
Ordering="Identity",
|
||||||
|
Registry="Adobe",
|
||||||
|
Supplement=0,
|
||||||
|
),
|
||||||
|
FontDescriptor=PLACEHOLDER,
|
||||||
|
Subtype=Name.CIDFontType2,
|
||||||
|
Type=Name.Font,
|
||||||
|
DW=1000 // self.CHAR_ASPECT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
basefont.DescendantFonts = [cid_font_type2]
|
||||||
|
cid_font_type2.CIDToGIDMap = pdf.make_stream(
|
||||||
|
self.CID_TO_GID_DATA, Filter=Name.FlateDecode
|
||||||
|
)
|
||||||
|
basefont.ToUnicode = pdf.make_stream(
|
||||||
|
b"/CIDInit /ProcSet findresource begin\n"
|
||||||
|
b"12 dict begin\n"
|
||||||
|
b"begincmap\n"
|
||||||
|
b"/CIDSystemInfo\n"
|
||||||
|
b"<<\n"
|
||||||
|
b" /Registry (Adobe)\n"
|
||||||
|
b" /Ordering (UCS)\n"
|
||||||
|
b" /Supplement 0\n"
|
||||||
|
b">> def\n"
|
||||||
|
b"/CMapName /Adobe-Identify-UCS def\n"
|
||||||
|
b"/CMapType 2 def\n"
|
||||||
|
b"1 begincodespacerange\n"
|
||||||
|
b"<0000> <FFFF>\n"
|
||||||
|
b"endcodespacerange\n"
|
||||||
|
b"1 beginbfrange\n"
|
||||||
|
b"<0000> <FFFF> <0000>\n"
|
||||||
|
b"endbfrange\n"
|
||||||
|
b"endcmap\n"
|
||||||
|
b"CMapName currentdict /CMap defineresource pop\n"
|
||||||
|
b"end\n"
|
||||||
|
b"end\n"
|
||||||
|
)
|
||||||
|
font_descriptor = pdf.make_indirect(
|
||||||
|
Dictionary(
|
||||||
|
Ascent=1000,
|
||||||
|
CapHeight=1000,
|
||||||
|
Descent=-1,
|
||||||
|
Flags=5, # Fixed pitch and symbolic
|
||||||
|
FontBBox=[0, 0, 1000 // self.CHAR_ASPECT, 1000],
|
||||||
|
FontFile2=PLACEHOLDER,
|
||||||
|
FontName=Name.GlyphLessFont,
|
||||||
|
ItalicAngle=0,
|
||||||
|
StemV=80,
|
||||||
|
Type=Name.FontDescriptor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
font_descriptor.FontFile2 = pdf.make_stream(self.GLYPHLESS_FONT)
|
||||||
|
cid_font_type2.FontDescriptor = font_descriptor
|
||||||
|
return basefont
|
||||||
|
|
||||||
|
|
||||||
|
class Courier(EncodableFont):
|
||||||
|
"""Courier font."""
|
||||||
|
|
||||||
|
def text_width(self, text: str, fontsize: float) -> float:
|
||||||
|
"""Estimate the width of a text string when rendered with the given font."""
|
||||||
|
return len(text) * fontsize
|
||||||
|
|
||||||
|
def text_encode(self, text: str) -> bytes:
|
||||||
|
return text.encode('pdfdoc', errors='ignore')
|
||||||
|
|
||||||
|
def register(self, pdf: Pdf) -> Dictionary:
|
||||||
|
"""Register the font."""
|
||||||
|
return pdf.make_indirect(
|
||||||
|
Dictionary(
|
||||||
|
BaseFont=Name.Courier,
|
||||||
|
Type=Name.Font,
|
||||||
|
Subtype=Name.Type1,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
|
||||||
|
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
|
||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""hOCR transform implementation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from itertools import pairwise
|
||||||
|
from math import atan, cos, pi
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from pikepdf import Matrix, Name, Rectangle
|
||||||
|
from pikepdf.canvas import (
|
||||||
|
BLACK,
|
||||||
|
BLUE,
|
||||||
|
CYAN,
|
||||||
|
DARKGREEN,
|
||||||
|
GREEN,
|
||||||
|
MAGENTA,
|
||||||
|
RED,
|
||||||
|
Canvas,
|
||||||
|
Text,
|
||||||
|
TextDirection,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ocrmypdf.hocrtransform._font import EncodableFont as Font
|
||||||
|
from ocrmypdf.hocrtransform._font import GlyphlessFont
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INCH = 72.0
|
||||||
|
|
||||||
|
Element = ElementTree.Element
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DebugRenderOptions:
|
||||||
|
"""A class for managing rendering options."""
|
||||||
|
|
||||||
|
render_paragraph_bbox: bool = False
|
||||||
|
render_baseline: bool = False
|
||||||
|
render_triangle: bool = False
|
||||||
|
render_line_bbox: bool = False
|
||||||
|
render_word_bbox: bool = False
|
||||||
|
render_space_bbox: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class HocrTransformError(Exception):
|
||||||
|
"""Error while applying hOCR transform."""
|
||||||
|
|
||||||
|
|
||||||
|
class HocrTransform:
|
||||||
|
"""A class for converting documents from the hOCR format.
|
||||||
|
|
||||||
|
For details of the hOCR format, see:
|
||||||
|
http://kba.github.io/hocr-spec/1.2/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
box_pattern = re.compile(
|
||||||
|
r'''
|
||||||
|
bbox \s+
|
||||||
|
(\d+) \s+ # left: uint
|
||||||
|
(\d+) \s+ # top: uint
|
||||||
|
(\d+) \s+ # right: uint
|
||||||
|
(\d+) # bottom: uint
|
||||||
|
''',
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
baseline_pattern = re.compile(
|
||||||
|
r'''
|
||||||
|
baseline \s+
|
||||||
|
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
|
||||||
|
([\-\+]?\d+) # +/- int
|
||||||
|
''',
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
hocr_filename: str | Path,
|
||||||
|
dpi: float,
|
||||||
|
debug: bool = False,
|
||||||
|
fontname: Name = Name("/f-0-0"),
|
||||||
|
font: Font = GlyphlessFont(),
|
||||||
|
debug_render_options: DebugRenderOptions | None = None,
|
||||||
|
):
|
||||||
|
"""Initialize the HocrTransform object."""
|
||||||
|
if debug:
|
||||||
|
log.warning("Use debug_render_options instead", DeprecationWarning)
|
||||||
|
self.render_options = DebugRenderOptions(
|
||||||
|
render_baseline=debug,
|
||||||
|
render_triangle=debug,
|
||||||
|
render_line_bbox=False,
|
||||||
|
render_word_bbox=debug,
|
||||||
|
render_paragraph_bbox=False,
|
||||||
|
render_space_bbox=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.render_options = debug_render_options or DebugRenderOptions()
|
||||||
|
self.dpi = dpi
|
||||||
|
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
|
||||||
|
self._fontname = fontname
|
||||||
|
self._font = font
|
||||||
|
|
||||||
|
# if the hOCR file has a namespace, ElementTree requires its use to
|
||||||
|
# find elements
|
||||||
|
matches = re.match(r'({.*})html', self.hocr.getroot().tag)
|
||||||
|
self.xmlns = ''
|
||||||
|
if matches:
|
||||||
|
self.xmlns = matches.group(1)
|
||||||
|
|
||||||
|
for div in self.hocr.findall(self._child_xpath('div', 'ocr_page')):
|
||||||
|
coords = self.element_coordinates(div)
|
||||||
|
if not coords:
|
||||||
|
raise HocrTransformError("hocr file is missing page dimensions")
|
||||||
|
self.width = (coords.urx - coords.llx) / (self.dpi / INCH)
|
||||||
|
self.height = (coords.ury - coords.lly) / (self.dpi / INCH)
|
||||||
|
# Stop after first div that has page coordinates
|
||||||
|
break
|
||||||
|
|
||||||
|
def _get_element_text(self, element: Element) -> str:
|
||||||
|
"""Return the textual content of the element and its children."""
|
||||||
|
text = element.text if element.text is not None else ''
|
||||||
|
for child in element:
|
||||||
|
text += self._get_element_text(child)
|
||||||
|
text += element.tail if element.tail is not None else ''
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def element_coordinates(cls, element: Element) -> Rectangle | None:
|
||||||
|
"""Get coordinates of the bounding box around an element."""
|
||||||
|
matches = cls.box_pattern.search(element.attrib.get('title', ''))
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
return Rectangle(
|
||||||
|
float(matches.group(1)), # llx = left
|
||||||
|
float(matches.group(2)), # lly = top
|
||||||
|
float(matches.group(3)), # urx = right
|
||||||
|
float(matches.group(4)), # ury = bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def baseline(cls, element: Element) -> tuple[float, float]:
|
||||||
|
"""Get baseline's slope and intercept."""
|
||||||
|
matches = cls.baseline_pattern.search(element.attrib.get('title', ''))
|
||||||
|
if not matches:
|
||||||
|
return (0.0, 0.0)
|
||||||
|
return float(matches.group(1)), int(matches.group(2))
|
||||||
|
|
||||||
|
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
|
||||||
|
xpath = f".//{self.xmlns}{html_tag}"
|
||||||
|
if html_class:
|
||||||
|
xpath += f"[@class='{html_class}']"
|
||||||
|
return xpath
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_text(cls, s: str) -> str:
|
||||||
|
"""Normalize the given text using the NFKC normalization form."""
|
||||||
|
return unicodedata.normalize("NFKC", s)
|
||||||
|
|
||||||
|
def to_pdf(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
out_filename: Path,
|
||||||
|
image_filename: Path | None = None,
|
||||||
|
invisible_text: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""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
|
||||||
|
file.
|
||||||
|
It can have a lower resolution, different color mode, etc.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
out_filename: Path of PDF to write.
|
||||||
|
image_filename: Image to use for this file. If omitted, the OCR text
|
||||||
|
is shown.
|
||||||
|
invisible_text: If True, text is rendered invisible so that is
|
||||||
|
selectable but never drawn. If False, text is visible and may
|
||||||
|
be seen if the image is skipped or deleted in Acrobat.
|
||||||
|
"""
|
||||||
|
# create the PDF file
|
||||||
|
# page size in points (1/72 in.)
|
||||||
|
canvas = Canvas(page_size=(self.width, self.height))
|
||||||
|
canvas.add_font(self._fontname, self._font)
|
||||||
|
page_matrix = (
|
||||||
|
Matrix()
|
||||||
|
.translated(0, self.height)
|
||||||
|
.scaled(1, -1)
|
||||||
|
.scaled(INCH / self.dpi, INCH / self.dpi)
|
||||||
|
)
|
||||||
|
log.debug(page_matrix)
|
||||||
|
with canvas.do.save_state(cm=page_matrix):
|
||||||
|
self._debug_draw_paragraph_boxes(canvas)
|
||||||
|
found_lines = False
|
||||||
|
for par in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
|
||||||
|
for line in (
|
||||||
|
element
|
||||||
|
for element in par.iterfind(self._child_xpath('span'))
|
||||||
|
if 'class' in element.attrib
|
||||||
|
and element.attrib['class']
|
||||||
|
in {'ocr_header', 'ocr_line', 'ocr_textfloat'}
|
||||||
|
):
|
||||||
|
found_lines = True
|
||||||
|
direction = self._get_text_direction(par)
|
||||||
|
inject_word_breaks = self._get_inject_word_breaks(par)
|
||||||
|
self._do_line(
|
||||||
|
canvas,
|
||||||
|
line,
|
||||||
|
"ocrx_word",
|
||||||
|
invisible_text,
|
||||||
|
direction,
|
||||||
|
inject_word_breaks,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not found_lines:
|
||||||
|
# Tesseract did not report any lines (just words)
|
||||||
|
root = self.hocr.find(self._child_xpath('div', 'ocr_page'))
|
||||||
|
direction = self._get_text_direction(root)
|
||||||
|
self._do_line(
|
||||||
|
canvas,
|
||||||
|
root,
|
||||||
|
"ocrx_word",
|
||||||
|
invisible_text,
|
||||||
|
direction,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
# put the image on the page, scaled to fill the page
|
||||||
|
if image_filename is not None:
|
||||||
|
canvas.do.draw_image(
|
||||||
|
image_filename, 0, 0, width=self.width, height=self.height
|
||||||
|
)
|
||||||
|
|
||||||
|
# finish up the page and save it
|
||||||
|
canvas.to_pdf().save(out_filename)
|
||||||
|
|
||||||
|
def _get_text_direction(self, par):
|
||||||
|
"""Get the text direction of the paragraph.
|
||||||
|
|
||||||
|
Arabic, Hebrew, Persian, are right-to-left languages.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
TextDirection.RTL
|
||||||
|
if par.attrib.get('dir', 'ltr') == 'rtl'
|
||||||
|
else TextDirection.LTR
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_inject_word_breaks(self, par):
|
||||||
|
"""Determine whether word breaks should be injected.
|
||||||
|
|
||||||
|
In Chinese, Japanese, and Korean, word breaks are not injected, because
|
||||||
|
words are usually one or two characters and separators are usually explicit.
|
||||||
|
In all other languages, we inject word breaks to help word segmentation.
|
||||||
|
"""
|
||||||
|
lang = par.attrib.get('lang', '')
|
||||||
|
log.debug(lang)
|
||||||
|
if lang in {'chi_sim', 'chi_tra', 'jpn', 'kor'}:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@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(
|
||||||
|
self,
|
||||||
|
canvas: Canvas,
|
||||||
|
line: Element | None,
|
||||||
|
elemclass: str,
|
||||||
|
invisible_text: bool,
|
||||||
|
text_direction: TextDirection,
|
||||||
|
inject_word_breaks: bool,
|
||||||
|
):
|
||||||
|
"""Render the text for a given line.
|
||||||
|
|
||||||
|
The canvas's coordinate system must be configured so that hOCR pixel
|
||||||
|
coordinates are mapped to PDF coordinates.
|
||||||
|
"""
|
||||||
|
if line is None:
|
||||||
|
return
|
||||||
|
line_box = self.element_coordinates(line)
|
||||||
|
if not line_box:
|
||||||
|
return
|
||||||
|
if line_box.ury <= line_box.lly:
|
||||||
|
log.error(
|
||||||
|
"line box is invalid so we cannot render it: box=%s text=%s",
|
||||||
|
line_box,
|
||||||
|
self._get_element_text(line),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._debug_draw_line_bbox(canvas, line_box)
|
||||||
|
|
||||||
|
# Baseline is a polynomial (usually straight line) that describes the
|
||||||
|
# text baseline relative to the bottom left corner of the line bounding
|
||||||
|
# box.
|
||||||
|
bottom_left_corner = line_box.llx, line_box.ury
|
||||||
|
slope, intercept = self.baseline(line)
|
||||||
|
if abs(slope) < 0.005:
|
||||||
|
slope = 0.0
|
||||||
|
angle = atan(slope)
|
||||||
|
|
||||||
|
# Setup a new coordinate system on the line box's intercept and rotated by
|
||||||
|
# its slope.
|
||||||
|
line_matrix = (
|
||||||
|
Matrix()
|
||||||
|
.translated(*bottom_left_corner)
|
||||||
|
.translated(0, intercept)
|
||||||
|
.rotated(angle / pi * 180)
|
||||||
|
)
|
||||||
|
log.debug(line_matrix)
|
||||||
|
with canvas.do.save_state(cm=line_matrix):
|
||||||
|
text = Text(direction=text_direction)
|
||||||
|
|
||||||
|
# Don't allow the font to break out of the bounding box. Division by
|
||||||
|
# cos_a accounts for extra clearance between the glyph's vertical axis
|
||||||
|
# on a sloped baseline and the edge of the bounding box.
|
||||||
|
line_box_height = abs(line_box.height) / cos(angle)
|
||||||
|
fontsize = line_box_height + intercept
|
||||||
|
text.font(self._fontname, fontsize)
|
||||||
|
text.render_mode(3 if invisible_text else 0)
|
||||||
|
|
||||||
|
self._debug_draw_baseline(
|
||||||
|
canvas, line_matrix.inverse().transform(line_box), 0
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.do.fill_color(BLACK) # text in black
|
||||||
|
elements = line.findall(self._child_xpath('span', elemclass))
|
||||||
|
for elem, next_elem in pairwise(elements + [None]):
|
||||||
|
self._do_line_word(
|
||||||
|
canvas,
|
||||||
|
line_matrix,
|
||||||
|
text,
|
||||||
|
fontsize,
|
||||||
|
elem,
|
||||||
|
next_elem,
|
||||||
|
text_direction,
|
||||||
|
inject_word_breaks,
|
||||||
|
)
|
||||||
|
canvas.do.draw_text(text)
|
||||||
|
|
||||||
|
def _do_line_word(
|
||||||
|
self,
|
||||||
|
canvas: Canvas,
|
||||||
|
line_matrix: Matrix,
|
||||||
|
text: Text,
|
||||||
|
fontsize: float,
|
||||||
|
elem: Element,
|
||||||
|
next_elem: Element | None,
|
||||||
|
text_direction: TextDirection,
|
||||||
|
inject_word_breaks: bool,
|
||||||
|
):
|
||||||
|
"""Render the text for a single word."""
|
||||||
|
if elem is None:
|
||||||
|
return
|
||||||
|
elemtxt = self.normalize_text(self._get_element_text(elem).strip())
|
||||||
|
if elemtxt == '':
|
||||||
|
return
|
||||||
|
|
||||||
|
hocr_box = self.element_coordinates(elem)
|
||||||
|
if hocr_box is None:
|
||||||
|
return
|
||||||
|
box = line_matrix.inverse().transform(hocr_box)
|
||||||
|
font_width = self._font.text_width(elemtxt, fontsize)
|
||||||
|
|
||||||
|
# Debug sketches
|
||||||
|
self._debug_draw_word_triangle(canvas, box)
|
||||||
|
self._debug_draw_word_bbox(canvas, box)
|
||||||
|
|
||||||
|
# If this word is 0 units wide, our best bet seems to be to suppress this text
|
||||||
|
if text_direction == TextDirection.RTL:
|
||||||
|
log.info("RTL: %s", elemtxt)
|
||||||
|
if font_width > 0:
|
||||||
|
if text_direction == TextDirection.LTR:
|
||||||
|
text.text_transform(Matrix(1, 0, 0, -1, box.llx, 0))
|
||||||
|
elif text_direction == TextDirection.RTL:
|
||||||
|
text.text_transform(Matrix(-1, 0, 0, -1, box.llx + box.width, 0))
|
||||||
|
text.horiz_scale(100 * box.width / font_width)
|
||||||
|
text.show(self._font.text_encode(elemtxt))
|
||||||
|
|
||||||
|
# Get coordinates of the next word (if there is one)
|
||||||
|
hocr_next_box = (
|
||||||
|
self.element_coordinates(next_elem) if next_elem is not None else None
|
||||||
|
)
|
||||||
|
if hocr_next_box is None:
|
||||||
|
return
|
||||||
|
# Render a space this word and the next word. The explicit space helps
|
||||||
|
# PDF viewers identify the word break, and horizontally scaling it to
|
||||||
|
# occupy the space the between the words helps the PDF viewer
|
||||||
|
# avoid combiningthewordstogether.
|
||||||
|
if not inject_word_breaks:
|
||||||
|
return
|
||||||
|
next_box = line_matrix.inverse().transform(hocr_next_box)
|
||||||
|
if text_direction == TextDirection.LTR:
|
||||||
|
space_box = Rectangle(box.urx, box.lly, next_box.llx, next_box.ury)
|
||||||
|
elif text_direction == TextDirection.RTL:
|
||||||
|
space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury)
|
||||||
|
self._debug_draw_space_bbox(canvas, space_box)
|
||||||
|
space_width = self._font.text_width(' ', fontsize)
|
||||||
|
if space_width > 0:
|
||||||
|
if text_direction == TextDirection.LTR:
|
||||||
|
text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0))
|
||||||
|
elif text_direction == TextDirection.RTL:
|
||||||
|
text.text_transform(
|
||||||
|
Matrix(-1, 0, 0, -1, space_box.llx + space_box.width, 0)
|
||||||
|
)
|
||||||
|
text.horiz_scale(100 * space_box.width / space_width)
|
||||||
|
text.show(self._font.text_encode(' '))
|
||||||
|
|
||||||
|
def _debug_draw_paragraph_boxes(self, canvas: Canvas, color=CYAN):
|
||||||
|
"""Draw boxes around paragraphs in the document."""
|
||||||
|
if not self.render_options.render_paragraph_bbox: # pragma: no cover
|
||||||
|
return
|
||||||
|
with canvas.do.save_state():
|
||||||
|
# draw box around paragraph
|
||||||
|
canvas.do.stroke_color(color).line_width(0.1)
|
||||||
|
for elem in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
|
||||||
|
elemtxt = self._get_element_text(elem).strip()
|
||||||
|
if len(elemtxt) == 0:
|
||||||
|
continue
|
||||||
|
ocr_par = self.element_coordinates(elem)
|
||||||
|
if ocr_par is None:
|
||||||
|
continue
|
||||||
|
canvas.do.rect(
|
||||||
|
ocr_par.llx, ocr_par.lly, ocr_par.width, ocr_par.height, fill=0
|
||||||
|
)
|
||||||
|
|
||||||
|
def _debug_draw_line_bbox(self, canvas: Canvas, line_box: Rectangle, color=BLUE):
|
||||||
|
"""Render the bounding box of a text line."""
|
||||||
|
if not self.render_options.render_line_bbox: # pragma: no cover
|
||||||
|
return
|
||||||
|
with canvas.do.save_state():
|
||||||
|
canvas.do.stroke_color(color).line_width(0.15).rect(
|
||||||
|
line_box.llx, line_box.lly, line_box.width, line_box.height, fill=0
|
||||||
|
)
|
||||||
|
|
||||||
|
def _debug_draw_word_triangle(
|
||||||
|
self, canvas: Canvas, box: Rectangle, color=RED, line_width=0.1
|
||||||
|
):
|
||||||
|
"""Render a triangle that conveys word height and drawing direction."""
|
||||||
|
if not self.render_options.render_triangle: # pragma: no cover
|
||||||
|
return
|
||||||
|
with canvas.do.save_state():
|
||||||
|
canvas.do.stroke_color(color).line_width(line_width).line(
|
||||||
|
box.llx, box.lly, box.urx, box.lly
|
||||||
|
).line(box.urx, box.lly, box.llx, box.ury).line(
|
||||||
|
box.llx, box.lly, box.llx, box.ury
|
||||||
|
)
|
||||||
|
|
||||||
|
def _debug_draw_word_bbox(
|
||||||
|
self, canvas: Canvas, box: Rectangle, color=GREEN, line_width=0.1
|
||||||
|
):
|
||||||
|
"""Render a box depicting the word."""
|
||||||
|
if not self.render_options.render_word_bbox: # pragma: no cover
|
||||||
|
return
|
||||||
|
with canvas.do.save_state():
|
||||||
|
canvas.do.stroke_color(color).line_width(line_width).rect(
|
||||||
|
box.llx, box.lly, box.width, box.height, fill=0
|
||||||
|
)
|
||||||
|
|
||||||
|
def _debug_draw_space_bbox(
|
||||||
|
self, canvas: Canvas, box: Rectangle, color=DARKGREEN, line_width=0.1
|
||||||
|
):
|
||||||
|
"""Render a box depicting the space between two words."""
|
||||||
|
if not self.render_options.render_space_bbox: # pragma: no cover
|
||||||
|
return
|
||||||
|
with canvas.do.save_state():
|
||||||
|
canvas.do.fill_color(color).line_width(line_width).rect(
|
||||||
|
box.llx, box.lly, box.width, box.height, fill=1
|
||||||
|
)
|
||||||
|
|
||||||
|
def _debug_draw_baseline(
|
||||||
|
self,
|
||||||
|
canvas: Canvas,
|
||||||
|
line_box: Rectangle,
|
||||||
|
baseline_lly,
|
||||||
|
color=MAGENTA,
|
||||||
|
line_width=0.25,
|
||||||
|
):
|
||||||
|
"""Render the text baseline."""
|
||||||
|
if not self.render_options.render_baseline:
|
||||||
|
return
|
||||||
|
with canvas.do.save_state():
|
||||||
|
canvas.do.stroke_color(color).line_width(line_width).line(
|
||||||
|
line_box.llx,
|
||||||
|
baseline_lly,
|
||||||
|
line_box.urx,
|
||||||
|
baseline_lly,
|
||||||
|
)
|
||||||
@@ -7,17 +7,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from math import floor, sqrt
|
from math import floor, sqrt
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
# 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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,9 +30,9 @@ def _calculate_downsample(
|
|||||||
image_size: tuple[int, int],
|
image_size: tuple[int, int],
|
||||||
bytes_per_pixel: int,
|
bytes_per_pixel: int,
|
||||||
*,
|
*,
|
||||||
max_size: Optional[tuple[int, int]] = None,
|
max_size: tuple[int, int] | None = None,
|
||||||
max_pixels: Optional[int] = None,
|
max_pixels: int | None = None,
|
||||||
max_bytes: Optional[int] = None,
|
max_bytes: int | None = None,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Calculate image size required to downsample an image to fit limits.
|
"""Calculate image size required to downsample an image to fit limits.
|
||||||
|
|
||||||
@@ -98,9 +90,9 @@ def _calculate_downsample(
|
|||||||
def calculate_downsample(
|
def calculate_downsample(
|
||||||
image: Image.Image,
|
image: Image.Image,
|
||||||
*,
|
*,
|
||||||
max_size: Optional[tuple[int, int]] = None,
|
max_size: tuple[int, int] | None = None,
|
||||||
max_pixels: Optional[int] = None,
|
max_pixels: int | None = None,
|
||||||
max_bytes: Optional[int] = None,
|
max_bytes: int | None = None,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Calculate image size required to downsample an image to fit limits.
|
"""Calculate image size required to downsample an image to fit limits.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,851 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Language codes and names from ISO 639.
|
||||||
|
|
||||||
|
Derived from
|
||||||
|
https://www.loc.gov/standards/iso639-2/ascii_8bits.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
|
||||||
|
class ISOCodeData(NamedTuple):
|
||||||
|
"""Data for a single ISO 639 code."""
|
||||||
|
|
||||||
|
alt: str
|
||||||
|
alpha_2: str
|
||||||
|
english: str
|
||||||
|
french: str
|
||||||
|
|
||||||
|
|
||||||
|
ISO_639_3 = {
|
||||||
|
'aar': ISOCodeData('', 'aa', 'Afar', 'afar'),
|
||||||
|
'abk': ISOCodeData('', 'ab', 'Abkhazian', 'abkhaze'),
|
||||||
|
'ace': ISOCodeData('', '', 'Achinese', 'aceh'),
|
||||||
|
'ach': ISOCodeData('', '', 'Acoli', 'acoli'),
|
||||||
|
'ada': ISOCodeData('', '', 'Adangme', 'adangme'),
|
||||||
|
'ady': ISOCodeData('', '', 'Adyghe; Adygei', 'adyghé'),
|
||||||
|
'afa': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Afro-Asiatic languages',
|
||||||
|
'afro-asiatiques, langues',
|
||||||
|
),
|
||||||
|
'afh': ISOCodeData('', '', 'Afrihili', 'afrihili'),
|
||||||
|
'afr': ISOCodeData('', 'af', 'Afrikaans', 'afrikaans'),
|
||||||
|
'ain': ISOCodeData('', '', 'Ainu', 'aïnou'),
|
||||||
|
'aka': ISOCodeData('', 'ak', 'Akan', 'akan'),
|
||||||
|
'akk': ISOCodeData('', '', 'Akkadian', 'akkadien'),
|
||||||
|
'alb': ISOCodeData('sqi', 'sq', 'Albanian', 'albanais'),
|
||||||
|
'ale': ISOCodeData('', '', 'Aleut', 'aléoute'),
|
||||||
|
'alg': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Algonquian languages',
|
||||||
|
'algonquines, langues',
|
||||||
|
),
|
||||||
|
'alt': ISOCodeData('', '', 'Southern Altai', 'altai du Sud'),
|
||||||
|
'amh': ISOCodeData('', 'am', 'Amharic', 'amharique'),
|
||||||
|
'ang': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'English, Old (ca.450-1100)',
|
||||||
|
'anglo-saxon (ca.450-1100)',
|
||||||
|
),
|
||||||
|
'anp': ISOCodeData('', '', 'Angika', 'angika'),
|
||||||
|
'apa': ISOCodeData('', '', 'Apache languages', 'apaches, langues'),
|
||||||
|
'ara': ISOCodeData('', 'ar', 'Arabic', 'arabe'),
|
||||||
|
'arc': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)',
|
||||||
|
"araméen d'empire (700-300 BCE)",
|
||||||
|
),
|
||||||
|
'arg': ISOCodeData('', 'an', 'Aragonese', 'aragonais'),
|
||||||
|
'arm': ISOCodeData('hye', 'hy', 'Armenian', 'arménien'),
|
||||||
|
'arn': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Mapudungun; Mapuche',
|
||||||
|
'mapudungun; mapuche; mapuce',
|
||||||
|
),
|
||||||
|
'arp': ISOCodeData('', '', 'Arapaho', 'arapaho'),
|
||||||
|
'art': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Artificial languages',
|
||||||
|
'artificielles, langues',
|
||||||
|
),
|
||||||
|
'arw': ISOCodeData('', '', 'Arawak', 'arawak'),
|
||||||
|
'asm': ISOCodeData('', 'as', 'Assamese', 'assamais'),
|
||||||
|
'ast': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Asturian; Bable; Leonese; Asturleonese',
|
||||||
|
'asturien; bable; léonais; asturoléonais',
|
||||||
|
),
|
||||||
|
'ath': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Athapascan languages',
|
||||||
|
'athapascanes, langues',
|
||||||
|
),
|
||||||
|
'aus': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Australian languages',
|
||||||
|
'australiennes, langues',
|
||||||
|
),
|
||||||
|
'ava': ISOCodeData('', 'av', 'Avaric', 'avar'),
|
||||||
|
'ave': ISOCodeData('', 'ae', 'Avestan', 'avestique'),
|
||||||
|
'awa': ISOCodeData('', '', 'Awadhi', 'awadhi'),
|
||||||
|
'aym': ISOCodeData('', 'ay', 'Aymara', 'aymara'),
|
||||||
|
'aze': ISOCodeData('', 'az', 'Azerbaijani', 'azéri'),
|
||||||
|
'bad': ISOCodeData('', '', 'Banda languages', 'banda, langues'),
|
||||||
|
'bai': ISOCodeData('', '', 'Bamileke languages', 'bamiléké, langues'),
|
||||||
|
'bak': ISOCodeData('', 'ba', 'Bashkir', 'bachkir'),
|
||||||
|
'bal': ISOCodeData('', '', 'Baluchi', 'baloutchi'),
|
||||||
|
'bam': ISOCodeData('', 'bm', 'Bambara', 'bambara'),
|
||||||
|
'ban': ISOCodeData('', '', 'Balinese', 'balinais'),
|
||||||
|
'baq': ISOCodeData('eus', 'eu', 'Basque', 'basque'),
|
||||||
|
'bas': ISOCodeData('', '', 'Basa', 'basa'),
|
||||||
|
'bat': ISOCodeData('', '', 'Baltic languages', 'baltes, langues'),
|
||||||
|
'bej': ISOCodeData('', '', 'Beja; Bedawiyet', 'bedja'),
|
||||||
|
'bel': ISOCodeData('', 'be', 'Belarusian', 'biélorusse'),
|
||||||
|
'bem': ISOCodeData('', '', 'Bemba', 'bemba'),
|
||||||
|
'ben': ISOCodeData('', 'bn', 'Bengali', 'bengali'),
|
||||||
|
'ber': ISOCodeData('', '', 'Berber languages', 'berbères, langues'),
|
||||||
|
'bho': ISOCodeData('', '', 'Bhojpuri', 'bhojpuri'),
|
||||||
|
'bih': ISOCodeData('', 'bh', 'Bihari languages', 'langues biharis'),
|
||||||
|
'bik': ISOCodeData('', '', 'Bikol', 'bikol'),
|
||||||
|
'bin': ISOCodeData('', '', 'Bini; Edo', 'bini; edo'),
|
||||||
|
'bis': ISOCodeData('', 'bi', 'Bislama', 'bichlamar'),
|
||||||
|
'bla': ISOCodeData('', '', 'Siksika', 'blackfoot'),
|
||||||
|
'bnt': ISOCodeData('', '', 'Bantu languages', 'bantou, langues'),
|
||||||
|
'bos': ISOCodeData('', 'bs', 'Bosnian', 'bosniaque'),
|
||||||
|
'bra': ISOCodeData('', '', 'Braj', 'braj'),
|
||||||
|
'bre': ISOCodeData('', 'br', 'Breton', 'breton'),
|
||||||
|
'btk': ISOCodeData('', '', 'Batak languages', 'batak, langues'),
|
||||||
|
'bua': ISOCodeData('', '', 'Buriat', 'bouriate'),
|
||||||
|
'bug': ISOCodeData('', '', 'Buginese', 'bugi'),
|
||||||
|
'bul': ISOCodeData('', 'bg', 'Bulgarian', 'bulgare'),
|
||||||
|
'bur': ISOCodeData('mya', 'my', 'Burmese', 'birman'),
|
||||||
|
'byn': ISOCodeData('', '', 'Blin; Bilin', 'blin; bilen'),
|
||||||
|
'cad': ISOCodeData('', '', 'Caddo', 'caddo'),
|
||||||
|
'cai': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Central American Indian languages',
|
||||||
|
"amérindiennes de L'Amérique centrale, langues",
|
||||||
|
),
|
||||||
|
'car': ISOCodeData('', '', 'Galibi Carib', 'karib; galibi; carib'),
|
||||||
|
'cat': ISOCodeData('', 'ca', 'Catalan; Valencian', 'catalan; valencien'),
|
||||||
|
'cau': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Caucasian languages',
|
||||||
|
'caucasiennes, langues',
|
||||||
|
),
|
||||||
|
'ceb': ISOCodeData('', '', 'Cebuano', 'cebuano'),
|
||||||
|
'cel': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Celtic languages',
|
||||||
|
'celtiques, langues; celtes, langues',
|
||||||
|
),
|
||||||
|
'cha': ISOCodeData('', 'ch', 'Chamorro', 'chamorro'),
|
||||||
|
'chb': ISOCodeData('', '', 'Chibcha', 'chibcha'),
|
||||||
|
'che': ISOCodeData('', 'ce', 'Chechen', 'tchétchène'),
|
||||||
|
'chg': ISOCodeData('', '', 'Chagatai', 'djaghataï'),
|
||||||
|
'chi': ISOCodeData('zho', 'zh', 'Chinese', 'chinois'),
|
||||||
|
'chk': ISOCodeData('', '', 'Chuukese', 'chuuk'),
|
||||||
|
'chm': ISOCodeData('', '', 'Mari', 'mari'),
|
||||||
|
'chn': ISOCodeData('', '', 'Chinook jargon', 'chinook, jargon'),
|
||||||
|
'cho': ISOCodeData('', '', 'Choctaw', 'choctaw'),
|
||||||
|
'chp': ISOCodeData('', '', 'Chipewyan; Dene Suline', 'chipewyan'),
|
||||||
|
'chr': ISOCodeData('', '', 'Cherokee', 'cherokee'),
|
||||||
|
'chu': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'cu',
|
||||||
|
(
|
||||||
|
'Church Slavic; Old Slavonic; Church Slavonic;'
|
||||||
|
' Old Bulgarian; Old Church Slavonic'
|
||||||
|
),
|
||||||
|
"slavon d'église; vieux slave; slavon liturgique; vieux bulgare",
|
||||||
|
),
|
||||||
|
'chv': ISOCodeData('', 'cv', 'Chuvash', 'tchouvache'),
|
||||||
|
'chy': ISOCodeData('', '', 'Cheyenne', 'cheyenne'),
|
||||||
|
'cmc': ISOCodeData('', '', 'Chamic languages', 'chames, langues'),
|
||||||
|
'cnr': ISOCodeData('', '', 'Montenegrin', 'monténégrin'),
|
||||||
|
'cop': ISOCodeData('', '', 'Coptic', 'copte'),
|
||||||
|
'cor': ISOCodeData('', 'kw', 'Cornish', 'cornique'),
|
||||||
|
'cos': ISOCodeData('', 'co', 'Corsican', 'corse'),
|
||||||
|
'cpe': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Creoles and pidgins, English based',
|
||||||
|
"créoles et pidgins basés sur l'anglais",
|
||||||
|
),
|
||||||
|
'cpf': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Creoles and pidgins, French-based',
|
||||||
|
'créoles et pidgins basés sur le français',
|
||||||
|
),
|
||||||
|
'cpp': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Creoles and pidgins, Portuguese-based',
|
||||||
|
'créoles et pidgins basés sur le portugais',
|
||||||
|
),
|
||||||
|
'cre': ISOCodeData('', 'cr', 'Cree', 'cree'),
|
||||||
|
'crh': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Crimean Tatar; Crimean Turkish',
|
||||||
|
'tatar de Crimé',
|
||||||
|
),
|
||||||
|
'crp': ISOCodeData('', '', 'Creoles and pidgins', 'créoles et pidgins'),
|
||||||
|
'csb': ISOCodeData('', '', 'Kashubian', 'kachoube'),
|
||||||
|
'cus': ISOCodeData('', '', 'Cushitic languages', 'couchitiques, langues'),
|
||||||
|
'cze': ISOCodeData('ces', 'cs', 'Czech', 'tchèque'),
|
||||||
|
'dak': ISOCodeData('', '', 'Dakota', 'dakota'),
|
||||||
|
'dan': ISOCodeData('', 'da', 'Danish', 'danois'),
|
||||||
|
'dar': ISOCodeData('', '', 'Dargwa', 'dargwa'),
|
||||||
|
'day': ISOCodeData('', '', 'Land Dayak languages', 'dayak, langues'),
|
||||||
|
'del': ISOCodeData('', '', 'Delaware', 'delaware'),
|
||||||
|
'den': ISOCodeData('', '', 'Slave (Athapascan)', 'esclave (athapascan)'),
|
||||||
|
'dgr': ISOCodeData('', '', 'Dogrib', 'dogrib'),
|
||||||
|
'din': ISOCodeData('', '', 'Dinka', 'dinka'),
|
||||||
|
'div': ISOCodeData('', 'dv', 'Divehi; Dhivehi; Maldivian', 'maldivien'),
|
||||||
|
'doi': ISOCodeData('', '', 'Dogri', 'dogri'),
|
||||||
|
'dra': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Dravidian languages',
|
||||||
|
'dravidiennes, langues',
|
||||||
|
),
|
||||||
|
'dsb': ISOCodeData('', '', 'Lower Sorbian', 'bas-sorabe'),
|
||||||
|
'dua': ISOCodeData('', '', 'Duala', 'douala'),
|
||||||
|
'dum': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Dutch, Middle (ca.1050-1350)',
|
||||||
|
'néerlandais moyen (ca. 1050-1350)',
|
||||||
|
),
|
||||||
|
'dut': ISOCodeData('nld', 'nl', 'Dutch; Flemish', 'néerlandais; flamand'),
|
||||||
|
'dyu': ISOCodeData('', '', 'Dyula', 'dioula'),
|
||||||
|
'dzo': ISOCodeData('', 'dz', 'Dzongkha', 'dzongkha'),
|
||||||
|
'efi': ISOCodeData('', '', 'Efik', 'efik'),
|
||||||
|
'egy': ISOCodeData('', '', 'Egyptian (Ancient)', 'égyptien'),
|
||||||
|
'eka': ISOCodeData('', '', 'Ekajuk', 'ekajuk'),
|
||||||
|
'elx': ISOCodeData('', '', 'Elamite', 'élamite'),
|
||||||
|
'eng': ISOCodeData('', 'en', 'English', 'anglais'),
|
||||||
|
'enm': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'English, Middle (1100-1500)',
|
||||||
|
'anglais moyen (1100-1500)',
|
||||||
|
),
|
||||||
|
'epo': ISOCodeData('', 'eo', 'Esperanto', 'espéranto'),
|
||||||
|
'est': ISOCodeData('', 'et', 'Estonian', 'estonien'),
|
||||||
|
'ewe': ISOCodeData('', 'ee', 'Ewe', 'éwé'),
|
||||||
|
'ewo': ISOCodeData('', '', 'Ewondo', 'éwondo'),
|
||||||
|
'fan': ISOCodeData('', '', 'Fang', 'fang'),
|
||||||
|
'fao': ISOCodeData('', 'fo', 'Faroese', 'féroïen'),
|
||||||
|
'fat': ISOCodeData('', '', 'Fanti', 'fanti'),
|
||||||
|
'fij': ISOCodeData('', 'fj', 'Fijian', 'fidjien'),
|
||||||
|
'fil': ISOCodeData('', '', 'Filipino; Pilipino', 'filipino; pilipino'),
|
||||||
|
'fin': ISOCodeData('', 'fi', 'Finnish', 'finnois'),
|
||||||
|
'fiu': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Finno-Ugrian languages',
|
||||||
|
'finno-ougriennes, langues',
|
||||||
|
),
|
||||||
|
'fon': ISOCodeData('', '', 'Fon', 'fon'),
|
||||||
|
'fre': ISOCodeData('fra', 'fr', 'French', 'français'),
|
||||||
|
'frm': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'French, Middle (ca.1400-1600)',
|
||||||
|
'français moyen (1400-1600)',
|
||||||
|
),
|
||||||
|
'fro': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'French, Old (842-ca.1400)',
|
||||||
|
'français ancien (842-ca.1400)',
|
||||||
|
),
|
||||||
|
'frr': ISOCodeData('', '', 'Northern Frisian', 'frison septentrional'),
|
||||||
|
'frs': ISOCodeData('', '', 'Eastern Frisian', 'frison oriental'),
|
||||||
|
'fry': ISOCodeData('', 'fy', 'Western Frisian', 'frison occidental'),
|
||||||
|
'ful': ISOCodeData('', 'ff', 'Fulah', 'peul'),
|
||||||
|
'fur': ISOCodeData('', '', 'Friulian', 'frioulan'),
|
||||||
|
'gaa': ISOCodeData('', '', 'Ga', 'ga'),
|
||||||
|
'gay': ISOCodeData('', '', 'Gayo', 'gayo'),
|
||||||
|
'gba': ISOCodeData('', '', 'Gbaya', 'gbaya'),
|
||||||
|
'gem': ISOCodeData('', '', 'Germanic languages', 'germaniques, langues'),
|
||||||
|
'geo': ISOCodeData('kat', 'ka', 'Georgian', 'géorgien'),
|
||||||
|
'ger': ISOCodeData('deu', 'de', 'German', 'allemand'),
|
||||||
|
'gez': ISOCodeData('', '', 'Geez', 'guèze'),
|
||||||
|
'gil': ISOCodeData('', '', 'Gilbertese', 'kiribati'),
|
||||||
|
'gla': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'gd',
|
||||||
|
'Gaelic; Scottish Gaelic',
|
||||||
|
'gaélique; gaélique écossais',
|
||||||
|
),
|
||||||
|
'gle': ISOCodeData('', 'ga', 'Irish', 'irlandais'),
|
||||||
|
'glg': ISOCodeData('', 'gl', 'Galician', 'galicien'),
|
||||||
|
'glv': ISOCodeData('', 'gv', 'Manx', 'manx; mannois'),
|
||||||
|
'gmh': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'German, Middle High (ca.1050-1500)',
|
||||||
|
'allemand, moyen haut (ca. 1050-1500)',
|
||||||
|
),
|
||||||
|
'goh': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'German, Old High (ca.750-1050)',
|
||||||
|
'allemand, vieux haut (ca. 750-1050)',
|
||||||
|
),
|
||||||
|
'gon': ISOCodeData('', '', 'Gondi', 'gond'),
|
||||||
|
'gor': ISOCodeData('', '', 'Gorontalo', 'gorontalo'),
|
||||||
|
'got': ISOCodeData('', '', 'Gothic', 'gothique'),
|
||||||
|
'grb': ISOCodeData('', '', 'Grebo', 'grebo'),
|
||||||
|
'grc': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Greek, Ancient (to 1453)',
|
||||||
|
"grec ancien (jusqu'à 1453)",
|
||||||
|
),
|
||||||
|
'gre': ISOCodeData(
|
||||||
|
'ell',
|
||||||
|
'el',
|
||||||
|
'Greek, Modern (1453-)',
|
||||||
|
'grec moderne (après 1453)',
|
||||||
|
),
|
||||||
|
'grn': ISOCodeData('', 'gn', 'Guarani', 'guarani'),
|
||||||
|
'gsw': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Swiss German; Alemannic; Alsatian',
|
||||||
|
'suisse alémanique; alémanique; alsacien',
|
||||||
|
),
|
||||||
|
'guj': ISOCodeData('', 'gu', 'Gujarati', 'goudjrati'),
|
||||||
|
'gwi': ISOCodeData('', '', "Gwich'in", "gwich'in"),
|
||||||
|
'hai': ISOCodeData('', '', 'Haida', 'haida'),
|
||||||
|
'hat': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'ht',
|
||||||
|
'Haitian; Haitian Creole',
|
||||||
|
'haïtien; créole haïtien',
|
||||||
|
),
|
||||||
|
'hau': ISOCodeData('', 'ha', 'Hausa', 'haoussa'),
|
||||||
|
'haw': ISOCodeData('', '', 'Hawaiian', 'hawaïen'),
|
||||||
|
'heb': ISOCodeData('', 'he', 'Hebrew', 'hébreu'),
|
||||||
|
'her': ISOCodeData('', 'hz', 'Herero', 'herero'),
|
||||||
|
'hil': ISOCodeData('', '', 'Hiligaynon', 'hiligaynon'),
|
||||||
|
'him': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Himachali languages; Western Pahari languages',
|
||||||
|
'langues himachalis; langues paharis occidentales',
|
||||||
|
),
|
||||||
|
'hin': ISOCodeData('', 'hi', 'Hindi', 'hindi'),
|
||||||
|
'hit': ISOCodeData('', '', 'Hittite', 'hittite'),
|
||||||
|
'hmn': ISOCodeData('', '', 'Hmong; Mong', 'hmong'),
|
||||||
|
'hmo': ISOCodeData('', 'ho', 'Hiri Motu', 'hiri motu'),
|
||||||
|
'hrv': ISOCodeData('', 'hr', 'Croatian', 'croate'),
|
||||||
|
'hsb': ISOCodeData('', '', 'Upper Sorbian', 'haut-sorabe'),
|
||||||
|
'hun': ISOCodeData('', 'hu', 'Hungarian', 'hongrois'),
|
||||||
|
'hup': ISOCodeData('', '', 'Hupa', 'hupa'),
|
||||||
|
'iba': ISOCodeData('', '', 'Iban', 'iban'),
|
||||||
|
'ibo': ISOCodeData('', 'ig', 'Igbo', 'igbo'),
|
||||||
|
'ice': ISOCodeData('isl', 'is', 'Icelandic', 'islandais'),
|
||||||
|
'ido': ISOCodeData('', 'io', 'Ido', 'ido'),
|
||||||
|
'iii': ISOCodeData('', 'ii', 'Sichuan Yi; Nuosu', 'yi de Sichuan'),
|
||||||
|
'ijo': ISOCodeData('', '', 'Ijo languages', 'ijo, langues'),
|
||||||
|
'iku': ISOCodeData('', 'iu', 'Inuktitut', 'inuktitut'),
|
||||||
|
'ile': ISOCodeData('', 'ie', 'Interlingue; Occidental', 'interlingue'),
|
||||||
|
'ilo': ISOCodeData('', '', 'Iloko', 'ilocano'),
|
||||||
|
'ina': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'ia',
|
||||||
|
'Interlingua (International Auxiliary Language Association)',
|
||||||
|
'interlingua (langue auxiliaire internationale)',
|
||||||
|
),
|
||||||
|
'inc': ISOCodeData('', '', 'Indic languages', 'indo-aryennes, langues'),
|
||||||
|
'ind': ISOCodeData('', 'id', 'Indonesian', 'indonésien'),
|
||||||
|
'ine': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Indo-European languages',
|
||||||
|
'indo-européennes, langues',
|
||||||
|
),
|
||||||
|
'inh': ISOCodeData('', '', 'Ingush', 'ingouche'),
|
||||||
|
'ipk': ISOCodeData('', 'ik', 'Inupiaq', 'inupiaq'),
|
||||||
|
'ira': ISOCodeData('', '', 'Iranian languages', 'iraniennes, langues'),
|
||||||
|
'iro': ISOCodeData('', '', 'Iroquoian languages', 'iroquoises, langues'),
|
||||||
|
'ita': ISOCodeData('', 'it', 'Italian', 'italien'),
|
||||||
|
'jav': ISOCodeData('', 'jv', 'Javanese', 'javanais'),
|
||||||
|
'jbo': ISOCodeData('', '', 'Lojban', 'lojban'),
|
||||||
|
'jpn': ISOCodeData('', 'ja', 'Japanese', 'japonais'),
|
||||||
|
'jpr': ISOCodeData('', '', 'Judeo-Persian', 'judéo-persan'),
|
||||||
|
'jrb': ISOCodeData('', '', 'Judeo-Arabic', 'judéo-arabe'),
|
||||||
|
'kaa': ISOCodeData('', '', 'Kara-Kalpak', 'karakalpak'),
|
||||||
|
'kab': ISOCodeData('', '', 'Kabyle', 'kabyle'),
|
||||||
|
'kac': ISOCodeData('', '', 'Kachin; Jingpho', 'kachin; jingpho'),
|
||||||
|
'kal': ISOCodeData('', 'kl', 'Kalaallisut; Greenlandic', 'groenlandais'),
|
||||||
|
'kam': ISOCodeData('', '', 'Kamba', 'kamba'),
|
||||||
|
'kan': ISOCodeData('', 'kn', 'Kannada', 'kannada'),
|
||||||
|
'kar': ISOCodeData('', '', 'Karen languages', 'karen, langues'),
|
||||||
|
'kas': ISOCodeData('', 'ks', 'Kashmiri', 'kashmiri'),
|
||||||
|
'kau': ISOCodeData('', 'kr', 'Kanuri', 'kanouri'),
|
||||||
|
'kaw': ISOCodeData('', '', 'Kawi', 'kawi'),
|
||||||
|
'kaz': ISOCodeData('', 'kk', 'Kazakh', 'kazakh'),
|
||||||
|
'kbd': ISOCodeData('', '', 'Kabardian', 'kabardien'),
|
||||||
|
'kha': ISOCodeData('', '', 'Khasi', 'khasi'),
|
||||||
|
'khi': ISOCodeData('', '', 'Khoisan languages', 'khoïsan, langues'),
|
||||||
|
'khm': ISOCodeData('', 'km', 'Central Khmer', 'khmer central'),
|
||||||
|
'kho': ISOCodeData('', '', 'Khotanese; Sakan', 'khotanais; sakan'),
|
||||||
|
'kik': ISOCodeData('', 'ki', 'Kikuyu; Gikuyu', 'kikuyu'),
|
||||||
|
'kin': ISOCodeData('', 'rw', 'Kinyarwanda', 'rwanda'),
|
||||||
|
'kir': ISOCodeData('', 'ky', 'Kirghiz; Kyrgyz', 'kirghiz'),
|
||||||
|
'kmb': ISOCodeData('', '', 'Kimbundu', 'kimbundu'),
|
||||||
|
'kok': ISOCodeData('', '', 'Konkani', 'konkani'),
|
||||||
|
'kom': ISOCodeData('', 'kv', 'Komi', 'kom'),
|
||||||
|
'kon': ISOCodeData('', 'kg', 'Kongo', 'kongo'),
|
||||||
|
'kor': ISOCodeData('', 'ko', 'Korean', 'coréen'),
|
||||||
|
'kos': ISOCodeData('', '', 'Kosraean', 'kosrae'),
|
||||||
|
'kpe': ISOCodeData('', '', 'Kpelle', 'kpellé'),
|
||||||
|
'krc': ISOCodeData('', '', 'Karachay-Balkar', 'karatchai balkar'),
|
||||||
|
'krl': ISOCodeData('', '', 'Karelian', 'carélien'),
|
||||||
|
'kro': ISOCodeData('', '', 'Kru languages', 'krou, langues'),
|
||||||
|
'kru': ISOCodeData('', '', 'Kurukh', 'kurukh'),
|
||||||
|
'kua': ISOCodeData('', 'kj', 'Kuanyama; Kwanyama', 'kuanyama; kwanyama'),
|
||||||
|
'kum': ISOCodeData('', '', 'Kumyk', 'koumyk'),
|
||||||
|
'kur': ISOCodeData('', 'ku', 'Kurdish', 'kurde'),
|
||||||
|
'kut': ISOCodeData('', '', 'Kutenai', 'kutenai'),
|
||||||
|
'lad': ISOCodeData('', '', 'Ladino', 'judéo-espagnol'),
|
||||||
|
'lah': ISOCodeData('', '', 'Lahnda', 'lahnda'),
|
||||||
|
'lam': ISOCodeData('', '', 'Lamba', 'lamba'),
|
||||||
|
'lao': ISOCodeData('', 'lo', 'Lao', 'lao'),
|
||||||
|
'lat': ISOCodeData('', 'la', 'Latin', 'latin'),
|
||||||
|
'lav': ISOCodeData('', 'lv', 'Latvian', 'letton'),
|
||||||
|
'lez': ISOCodeData('', '', 'Lezghian', 'lezghien'),
|
||||||
|
'lim': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'li',
|
||||||
|
'Limburgan; Limburger; Limburgish',
|
||||||
|
'limbourgeois',
|
||||||
|
),
|
||||||
|
'lin': ISOCodeData('', 'ln', 'Lingala', 'lingala'),
|
||||||
|
'lit': ISOCodeData('', 'lt', 'Lithuanian', 'lituanien'),
|
||||||
|
'lol': ISOCodeData('', '', 'Mongo', 'mongo'),
|
||||||
|
'loz': ISOCodeData('', '', 'Lozi', 'lozi'),
|
||||||
|
'ltz': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'lb',
|
||||||
|
'Luxembourgish; Letzeburgesch',
|
||||||
|
'luxembourgeois',
|
||||||
|
),
|
||||||
|
'lua': ISOCodeData('', '', 'Luba-Lulua', 'luba-lulua'),
|
||||||
|
'lub': ISOCodeData('', 'lu', 'Luba-Katanga', 'luba-katanga'),
|
||||||
|
'lug': ISOCodeData('', 'lg', 'Ganda', 'ganda'),
|
||||||
|
'lui': ISOCodeData('', '', 'Luiseno', 'luiseno'),
|
||||||
|
'lun': ISOCodeData('', '', 'Lunda', 'lunda'),
|
||||||
|
'luo': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Luo (Kenya and Tanzania)',
|
||||||
|
'luo (Kenya et Tanzanie)',
|
||||||
|
),
|
||||||
|
'lus': ISOCodeData('', '', 'Lushai', 'lushai'),
|
||||||
|
'mac': ISOCodeData('mkd', 'mk', 'Macedonian', 'macédonien'),
|
||||||
|
'mad': ISOCodeData('', '', 'Madurese', 'madourais'),
|
||||||
|
'mag': ISOCodeData('', '', 'Magahi', 'magahi'),
|
||||||
|
'mah': ISOCodeData('', 'mh', 'Marshallese', 'marshall'),
|
||||||
|
'mai': ISOCodeData('', '', 'Maithili', 'maithili'),
|
||||||
|
'mak': ISOCodeData('', '', 'Makasar', 'makassar'),
|
||||||
|
'mal': ISOCodeData('', 'ml', 'Malayalam', 'malayalam'),
|
||||||
|
'man': ISOCodeData('', '', 'Mandingo', 'mandingue'),
|
||||||
|
'mao': ISOCodeData('mri', 'mi', 'Maori', 'maori'),
|
||||||
|
'map': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Austronesian languages',
|
||||||
|
'austronésiennes, langues',
|
||||||
|
),
|
||||||
|
'mar': ISOCodeData('', 'mr', 'Marathi', 'marathe'),
|
||||||
|
'mas': ISOCodeData('', '', 'Masai', 'massaï'),
|
||||||
|
'may': ISOCodeData('msa', 'ms', 'Malay', 'malais'),
|
||||||
|
'mdf': ISOCodeData('', '', 'Moksha', 'moksa'),
|
||||||
|
'mdr': ISOCodeData('', '', 'Mandar', 'mandar'),
|
||||||
|
'men': ISOCodeData('', '', 'Mende', 'mendé'),
|
||||||
|
'mga': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Irish, Middle (900-1200)',
|
||||||
|
'irlandais moyen (900-1200)',
|
||||||
|
),
|
||||||
|
'mic': ISOCodeData('', '', "Mi'kmaq; Micmac", "mi'kmaq; micmac"),
|
||||||
|
'min': ISOCodeData('', '', 'Minangkabau', 'minangkabau'),
|
||||||
|
'mis': ISOCodeData('', '', 'Uncoded languages', 'langues non codées'),
|
||||||
|
'mkh': ISOCodeData('', '', 'Mon-Khmer languages', 'môn-khmer, langues'),
|
||||||
|
'mlg': ISOCodeData('', 'mg', 'Malagasy', 'malgache'),
|
||||||
|
'mlt': ISOCodeData('', 'mt', 'Maltese', 'maltais'),
|
||||||
|
'mnc': ISOCodeData('', '', 'Manchu', 'mandchou'),
|
||||||
|
'mni': ISOCodeData('', '', 'Manipuri', 'manipuri'),
|
||||||
|
'mno': ISOCodeData('', '', 'Manobo languages', 'manobo, langues'),
|
||||||
|
'moh': ISOCodeData('', '', 'Mohawk', 'mohawk'),
|
||||||
|
'mon': ISOCodeData('', 'mn', 'Mongolian', 'mongol'),
|
||||||
|
'mos': ISOCodeData('', '', 'Mossi', 'moré'),
|
||||||
|
'mul': ISOCodeData('', '', 'Multiple languages', 'multilingue'),
|
||||||
|
'mun': ISOCodeData('', '', 'Munda languages', 'mounda, langues'),
|
||||||
|
'mus': ISOCodeData('', '', 'Creek', 'muskogee'),
|
||||||
|
'mwl': ISOCodeData('', '', 'Mirandese', 'mirandais'),
|
||||||
|
'mwr': ISOCodeData('', '', 'Marwari', 'marvari'),
|
||||||
|
'myn': ISOCodeData('', '', 'Mayan languages', 'maya, langues'),
|
||||||
|
'myv': ISOCodeData('', '', 'Erzya', 'erza'),
|
||||||
|
'nah': ISOCodeData('', '', 'Nahuatl languages', 'nahuatl, langues'),
|
||||||
|
'nai': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'North American Indian languages',
|
||||||
|
'nord-amérindiennes, langues',
|
||||||
|
),
|
||||||
|
'nap': ISOCodeData('', '', 'Neapolitan', 'napolitain'),
|
||||||
|
'nau': ISOCodeData('', 'na', 'Nauru', 'nauruan'),
|
||||||
|
'nav': ISOCodeData('', 'nv', 'Navajo; Navaho', 'navaho'),
|
||||||
|
'nbl': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'nr',
|
||||||
|
'Ndebele, South; South Ndebele',
|
||||||
|
'ndébélé du Sud',
|
||||||
|
),
|
||||||
|
'nde': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'nd',
|
||||||
|
'Ndebele, North; North Ndebele',
|
||||||
|
'ndébélé du Nord',
|
||||||
|
),
|
||||||
|
'ndo': ISOCodeData('', 'ng', 'Ndonga', 'ndonga'),
|
||||||
|
'nds': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Low German; Low Saxon; German, Low; Saxon, Low',
|
||||||
|
'bas allemand; bas saxon; allemand, bas; saxon, bas',
|
||||||
|
),
|
||||||
|
'nep': ISOCodeData('', 'ne', 'Nepali', 'népalais'),
|
||||||
|
'new': ISOCodeData('', '', 'Nepal Bhasa; Newari', 'nepal bhasa; newari'),
|
||||||
|
'nia': ISOCodeData('', '', 'Nias', 'nias'),
|
||||||
|
'nic': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Niger-Kordofanian languages',
|
||||||
|
'nigéro-kordofaniennes, langues',
|
||||||
|
),
|
||||||
|
'niu': ISOCodeData('', '', 'Niuean', 'niué'),
|
||||||
|
'nno': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'nn',
|
||||||
|
'Norwegian Nynorsk; Nynorsk, Norwegian',
|
||||||
|
'norvégien nynorsk; nynorsk, norvégien',
|
||||||
|
),
|
||||||
|
'nob': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'nb',
|
||||||
|
'Bokmål, Norwegian; Norwegian Bokmål',
|
||||||
|
'norvégien bokmål',
|
||||||
|
),
|
||||||
|
'nog': ISOCodeData('', '', 'Nogai', 'nogaï; nogay'),
|
||||||
|
'non': ISOCodeData('', '', 'Norse, Old', 'norrois, vieux'),
|
||||||
|
'nor': ISOCodeData('', 'no', 'Norwegian', 'norvégien'),
|
||||||
|
'nqo': ISOCodeData('', '', "N'Ko", "n'ko"),
|
||||||
|
'nso': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Pedi; Sepedi; Northern Sotho',
|
||||||
|
'pedi; sepedi; sotho du Nord',
|
||||||
|
),
|
||||||
|
'nub': ISOCodeData('', '', 'Nubian languages', 'nubiennes, langues'),
|
||||||
|
'nwc': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Classical Newari; Old Newari; Classical Nepal Bhasa',
|
||||||
|
'newari classique',
|
||||||
|
),
|
||||||
|
'nya': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'ny',
|
||||||
|
'Chichewa; Chewa; Nyanja',
|
||||||
|
'chichewa; chewa; nyanja',
|
||||||
|
),
|
||||||
|
'nym': ISOCodeData('', '', 'Nyamwezi', 'nyamwezi'),
|
||||||
|
'nyn': ISOCodeData('', '', 'Nyankole', 'nyankolé'),
|
||||||
|
'nyo': ISOCodeData('', '', 'Nyoro', 'nyoro'),
|
||||||
|
'nzi': ISOCodeData('', '', 'Nzima', 'nzema'),
|
||||||
|
'oci': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'oc',
|
||||||
|
'Occitan (post 1500)',
|
||||||
|
'occitan (après 1500)',
|
||||||
|
),
|
||||||
|
'oji': ISOCodeData('', 'oj', 'Ojibwa', 'ojibwa'),
|
||||||
|
'ori': ISOCodeData('', 'or', 'Oriya', 'oriya'),
|
||||||
|
'orm': ISOCodeData('', 'om', 'Oromo', 'galla'),
|
||||||
|
'osa': ISOCodeData('', '', 'Osage', 'osage'),
|
||||||
|
'oss': ISOCodeData('', 'os', 'Ossetian; Ossetic', 'ossète'),
|
||||||
|
'ota': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Turkish, Ottoman (1500-1928)',
|
||||||
|
'turc ottoman (1500-1928)',
|
||||||
|
),
|
||||||
|
'oto': ISOCodeData('', '', 'Otomian languages', 'otomi, langues'),
|
||||||
|
'paa': ISOCodeData('', '', 'Papuan languages', 'papoues, langues'),
|
||||||
|
'pag': ISOCodeData('', '', 'Pangasinan', 'pangasinan'),
|
||||||
|
'pal': ISOCodeData('', '', 'Pahlavi', 'pahlavi'),
|
||||||
|
'pam': ISOCodeData('', '', 'Pampanga; Kapampangan', 'pampangan'),
|
||||||
|
'pan': ISOCodeData('', 'pa', 'Panjabi; Punjabi', 'pendjabi'),
|
||||||
|
'pap': ISOCodeData('', '', 'Papiamento', 'papiamento'),
|
||||||
|
'pau': ISOCodeData('', '', 'Palauan', 'palau'),
|
||||||
|
'peo': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Persian, Old (ca.600-400 B.C.)',
|
||||||
|
'perse, vieux (ca. 600-400 av. J.-C.)',
|
||||||
|
),
|
||||||
|
'per': ISOCodeData('fas', 'fa', 'Persian', 'persan'),
|
||||||
|
'phi': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Philippine languages',
|
||||||
|
'philippines, langues',
|
||||||
|
),
|
||||||
|
'phn': ISOCodeData('', '', 'Phoenician', 'phénicien'),
|
||||||
|
'pli': ISOCodeData('', 'pi', 'Pali', 'pali'),
|
||||||
|
'pol': ISOCodeData('', 'pl', 'Polish', 'polonais'),
|
||||||
|
'pon': ISOCodeData('', '', 'Pohnpeian', 'pohnpei'),
|
||||||
|
'por': ISOCodeData('', 'pt', 'Portuguese', 'portugais'),
|
||||||
|
'pra': ISOCodeData('', '', 'Prakrit languages', 'prâkrit, langues'),
|
||||||
|
'pro': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Provençal, Old (to 1500); Occitan, Old (to 1500)',
|
||||||
|
"provençal ancien (jusqu'à 1500); occitan ancien (jusqu'à 1500)",
|
||||||
|
),
|
||||||
|
'pus': ISOCodeData('', 'ps', 'Pushto; Pashto', 'pachto'),
|
||||||
|
'qaa': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Reserved for local use',
|
||||||
|
"réservée à l'usage local",
|
||||||
|
),
|
||||||
|
'que': ISOCodeData('', 'qu', 'Quechua', 'quechua'),
|
||||||
|
'raj': ISOCodeData('', '', 'Rajasthani', 'rajasthani'),
|
||||||
|
'rap': ISOCodeData('', '', 'Rapanui', 'rapanui'),
|
||||||
|
'rar': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Rarotongan; Cook Islands Maori',
|
||||||
|
'rarotonga; maori des îles Cook',
|
||||||
|
),
|
||||||
|
'roa': ISOCodeData('', '', 'Romance languages', 'romanes, langues'),
|
||||||
|
'roh': ISOCodeData('', 'rm', 'Romansh', 'romanche'),
|
||||||
|
'rom': ISOCodeData('', '', 'Romany', 'tsigane'),
|
||||||
|
'rum': ISOCodeData(
|
||||||
|
'ron',
|
||||||
|
'ro',
|
||||||
|
'Romanian; Moldavian; Moldovan',
|
||||||
|
'roumain; moldave',
|
||||||
|
),
|
||||||
|
'run': ISOCodeData('', 'rn', 'Rundi', 'rundi'),
|
||||||
|
'rup': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Aromanian; Arumanian; Macedo-Romanian',
|
||||||
|
'aroumain; macédo-roumain',
|
||||||
|
),
|
||||||
|
'rus': ISOCodeData('', 'ru', 'Russian', 'russe'),
|
||||||
|
'sad': ISOCodeData('', '', 'Sandawe', 'sandawe'),
|
||||||
|
'sag': ISOCodeData('', 'sg', 'Sango', 'sango'),
|
||||||
|
'sah': ISOCodeData('', '', 'Yakut', 'iakoute'),
|
||||||
|
'sai': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'South American Indian languages',
|
||||||
|
'sud-amérindiennes, langues',
|
||||||
|
),
|
||||||
|
'sal': ISOCodeData('', '', 'Salishan languages', 'salishennes, langues'),
|
||||||
|
'sam': ISOCodeData('', '', 'Samaritan Aramaic', 'samaritain'),
|
||||||
|
'san': ISOCodeData('', 'sa', 'Sanskrit', 'sanskrit'),
|
||||||
|
'sas': ISOCodeData('', '', 'Sasak', 'sasak'),
|
||||||
|
'sat': ISOCodeData('', '', 'Santali', 'santal'),
|
||||||
|
'scn': ISOCodeData('', '', 'Sicilian', 'sicilien'),
|
||||||
|
'sco': ISOCodeData('', '', 'Scots', 'écossais'),
|
||||||
|
'sel': ISOCodeData('', '', 'Selkup', 'selkoupe'),
|
||||||
|
'sem': ISOCodeData('', '', 'Semitic languages', 'sémitiques, langues'),
|
||||||
|
'sga': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Irish, Old (to 900)',
|
||||||
|
"irlandais ancien (jusqu'à 900)",
|
||||||
|
),
|
||||||
|
'sgn': ISOCodeData('', '', 'Sign Languages', 'langues des signes'),
|
||||||
|
'shn': ISOCodeData('', '', 'Shan', 'chan'),
|
||||||
|
'sid': ISOCodeData('', '', 'Sidamo', 'sidamo'),
|
||||||
|
'sin': ISOCodeData('', 'si', 'Sinhala; Sinhalese', 'singhalais'),
|
||||||
|
'sio': ISOCodeData('', '', 'Siouan languages', 'sioux, langues'),
|
||||||
|
'sit': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Sino-Tibetan languages',
|
||||||
|
'sino-tibétaines, langues',
|
||||||
|
),
|
||||||
|
'sla': ISOCodeData('', '', 'Slavic languages', 'slaves, langues'),
|
||||||
|
'slo': ISOCodeData('slk', 'sk', 'Slovak', 'slovaque'),
|
||||||
|
'slv': ISOCodeData('', 'sl', 'Slovenian', 'slovène'),
|
||||||
|
'sma': ISOCodeData('', '', 'Southern Sami', 'sami du Sud'),
|
||||||
|
'sme': ISOCodeData('', 'se', 'Northern Sami', 'sami du Nord'),
|
||||||
|
'smi': ISOCodeData('', '', 'Sami languages', 'sames, langues'),
|
||||||
|
'smj': ISOCodeData('', '', 'Lule Sami', 'sami de Lule'),
|
||||||
|
'smn': ISOCodeData('', '', 'Inari Sami', "sami d'Inari"),
|
||||||
|
'smo': ISOCodeData('', 'sm', 'Samoan', 'samoan'),
|
||||||
|
'sms': ISOCodeData('', '', 'Skolt Sami', 'sami skolt'),
|
||||||
|
'sna': ISOCodeData('', 'sn', 'Shona', 'shona'),
|
||||||
|
'snd': ISOCodeData('', 'sd', 'Sindhi', 'sindhi'),
|
||||||
|
'snk': ISOCodeData('', '', 'Soninke', 'soninké'),
|
||||||
|
'sog': ISOCodeData('', '', 'Sogdian', 'sogdien'),
|
||||||
|
'som': ISOCodeData('', 'so', 'Somali', 'somali'),
|
||||||
|
'son': ISOCodeData('', '', 'Songhai languages', 'songhai, langues'),
|
||||||
|
'sot': ISOCodeData('', 'st', 'Sotho, Southern', 'sotho du Sud'),
|
||||||
|
'spa': ISOCodeData('', 'es', 'Spanish; Castilian', 'espagnol; castillan'),
|
||||||
|
'srd': ISOCodeData('', 'sc', 'Sardinian', 'sarde'),
|
||||||
|
'srn': ISOCodeData('', '', 'Sranan Tongo', 'sranan tongo'),
|
||||||
|
'srp': ISOCodeData('', 'sr', 'Serbian', 'serbe'),
|
||||||
|
'srr': ISOCodeData('', '', 'Serer', 'sérère'),
|
||||||
|
'ssa': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Nilo-Saharan languages',
|
||||||
|
'nilo-sahariennes, langues',
|
||||||
|
),
|
||||||
|
'ssw': ISOCodeData('', 'ss', 'Swati', 'swati'),
|
||||||
|
'suk': ISOCodeData('', '', 'Sukuma', 'sukuma'),
|
||||||
|
'sun': ISOCodeData('', 'su', 'Sundanese', 'soundanais'),
|
||||||
|
'sus': ISOCodeData('', '', 'Susu', 'soussou'),
|
||||||
|
'sux': ISOCodeData('', '', 'Sumerian', 'sumérien'),
|
||||||
|
'swa': ISOCodeData('', 'sw', 'Swahili', 'swahili'),
|
||||||
|
'swe': ISOCodeData('', 'sv', 'Swedish', 'suédois'),
|
||||||
|
'syc': ISOCodeData('', '', 'Classical Syriac', 'syriaque classique'),
|
||||||
|
'syr': ISOCodeData('', '', 'Syriac', 'syriaque'),
|
||||||
|
'tah': ISOCodeData('', 'ty', 'Tahitian', 'tahitien'),
|
||||||
|
'tai': ISOCodeData('', '', 'Tai languages', 'tai, langues'),
|
||||||
|
'tam': ISOCodeData('', 'ta', 'Tamil', 'tamoul'),
|
||||||
|
'tat': ISOCodeData('', 'tt', 'Tatar', 'tatar'),
|
||||||
|
'tel': ISOCodeData('', 'te', 'Telugu', 'télougou'),
|
||||||
|
'tem': ISOCodeData('', '', 'Timne', 'temne'),
|
||||||
|
'ter': ISOCodeData('', '', 'Tereno', 'tereno'),
|
||||||
|
'tet': ISOCodeData('', '', 'Tetum', 'tetum'),
|
||||||
|
'tgk': ISOCodeData('', 'tg', 'Tajik', 'tadjik'),
|
||||||
|
'tgl': ISOCodeData('', 'tl', 'Tagalog', 'tagalog'),
|
||||||
|
'tha': ISOCodeData('', 'th', 'Thai', 'thaï'),
|
||||||
|
'tib': ISOCodeData('bod', 'bo', 'Tibetan', 'tibétain'),
|
||||||
|
'tig': ISOCodeData('', '', 'Tigre', 'tigré'),
|
||||||
|
'tir': ISOCodeData('', 'ti', 'Tigrinya', 'tigrigna'),
|
||||||
|
'tiv': ISOCodeData('', '', 'Tiv', 'tiv'),
|
||||||
|
'tkl': ISOCodeData('', '', 'Tokelau', 'tokelau'),
|
||||||
|
'tlh': ISOCodeData('', '', 'Klingon; tlhIngan-Hol', 'klingon'),
|
||||||
|
'tli': ISOCodeData('', '', 'Tlingit', 'tlingit'),
|
||||||
|
'tmh': ISOCodeData('', '', 'Tamashek', 'tamacheq'),
|
||||||
|
'tog': ISOCodeData('', '', 'Tonga (Nyasa)', 'tonga (Nyasa)'),
|
||||||
|
'ton': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'to',
|
||||||
|
'Tonga (Tonga Islands)',
|
||||||
|
'tongan (Îles Tonga)',
|
||||||
|
),
|
||||||
|
'tpi': ISOCodeData('', '', 'Tok Pisin', 'tok pisin'),
|
||||||
|
'tsi': ISOCodeData('', '', 'Tsimshian', 'tsimshian'),
|
||||||
|
'tsn': ISOCodeData('', 'tn', 'Tswana', 'tswana'),
|
||||||
|
'tso': ISOCodeData('', 'ts', 'Tsonga', 'tsonga'),
|
||||||
|
'tuk': ISOCodeData('', 'tk', 'Turkmen', 'turkmène'),
|
||||||
|
'tum': ISOCodeData('', '', 'Tumbuka', 'tumbuka'),
|
||||||
|
'tup': ISOCodeData('', '', 'Tupi languages', 'tupi, langues'),
|
||||||
|
'tur': ISOCodeData('', 'tr', 'Turkish', 'turc'),
|
||||||
|
'tut': ISOCodeData('', '', 'Altaic languages', 'altaïques, langues'),
|
||||||
|
'tvl': ISOCodeData('', '', 'Tuvalu', 'tuvalu'),
|
||||||
|
'twi': ISOCodeData('', 'tw', 'Twi', 'twi'),
|
||||||
|
'tyv': ISOCodeData('', '', 'Tuvinian', 'touva'),
|
||||||
|
'udm': ISOCodeData('', '', 'Udmurt', 'oudmourte'),
|
||||||
|
'uga': ISOCodeData('', '', 'Ugaritic', 'ougaritique'),
|
||||||
|
'uig': ISOCodeData('', 'ug', 'Uighur; Uyghur', 'ouïgour'),
|
||||||
|
'ukr': ISOCodeData('', 'uk', 'Ukrainian', 'ukrainien'),
|
||||||
|
'umb': ISOCodeData('', '', 'Umbundu', 'umbundu'),
|
||||||
|
'und': ISOCodeData('', '', 'Undetermined', 'indéterminée'),
|
||||||
|
'urd': ISOCodeData('', 'ur', 'Urdu', 'ourdou'),
|
||||||
|
'uzb': ISOCodeData('', 'uz', 'Uzbek', 'ouszbek'),
|
||||||
|
'vai': ISOCodeData('', '', 'Vai', 'vaï'),
|
||||||
|
'ven': ISOCodeData('', 've', 'Venda', 'venda'),
|
||||||
|
'vie': ISOCodeData('', 'vi', 'Vietnamese', 'vietnamien'),
|
||||||
|
'vol': ISOCodeData('', 'vo', 'Volapük', 'volapük'),
|
||||||
|
'vot': ISOCodeData('', '', 'Votic', 'vote'),
|
||||||
|
'wak': ISOCodeData('', '', 'Wakashan languages', 'wakashanes, langues'),
|
||||||
|
'wal': ISOCodeData('', '', 'Wolaitta; Wolaytta', 'wolaitta; wolaytta'),
|
||||||
|
'war': ISOCodeData('', '', 'Waray', 'waray'),
|
||||||
|
'was': ISOCodeData('', '', 'Washo', 'washo'),
|
||||||
|
'wel': ISOCodeData('cym', 'cy', 'Welsh', 'gallois'),
|
||||||
|
'wen': ISOCodeData('', '', 'Sorbian languages', 'sorabes, langues'),
|
||||||
|
'wln': ISOCodeData('', 'wa', 'Walloon', 'wallon'),
|
||||||
|
'wol': ISOCodeData('', 'wo', 'Wolof', 'wolof'),
|
||||||
|
'xal': ISOCodeData('', '', 'Kalmyk; Oirat', 'kalmouk; oïrat'),
|
||||||
|
'xho': ISOCodeData('', 'xh', 'Xhosa', 'xhosa'),
|
||||||
|
'yao': ISOCodeData('', '', 'Yao', 'yao'),
|
||||||
|
'yap': ISOCodeData('', '', 'Yapese', 'yapois'),
|
||||||
|
'yid': ISOCodeData('', 'yi', 'Yiddish', 'yiddish'),
|
||||||
|
'yor': ISOCodeData('', 'yo', 'Yoruba', 'yoruba'),
|
||||||
|
'ypk': ISOCodeData('', '', 'Yupik languages', 'yupik, langues'),
|
||||||
|
'zap': ISOCodeData('', '', 'Zapotec', 'zapotèque'),
|
||||||
|
'zbl': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Blissymbols; Blissymbolics; Bliss',
|
||||||
|
'symboles Bliss; Bliss',
|
||||||
|
),
|
||||||
|
'zen': ISOCodeData('', '', 'Zenaga', 'zenaga'),
|
||||||
|
'zgh': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Standard Moroccan Tamazight',
|
||||||
|
'amazighe standard marocain',
|
||||||
|
),
|
||||||
|
'zha': ISOCodeData('', 'za', 'Zhuang; Chuang', 'zhuang; chuang'),
|
||||||
|
'znd': ISOCodeData('', '', 'Zande languages', 'zandé, langues'),
|
||||||
|
'zul': ISOCodeData('', 'zu', 'Zulu', 'zoulou'),
|
||||||
|
'zun': ISOCodeData('', '', 'Zuni', 'zuni'),
|
||||||
|
'zxx': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'No linguistic content; Not applicable',
|
||||||
|
'pas de contenu linguistique; non applicable',
|
||||||
|
),
|
||||||
|
'zza': ISOCodeData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki',
|
||||||
|
'zaza; dimili; dimli; kirdki; kirmanjki; zazaki',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iso_639_2_from_3(iso3: str) -> str:
|
||||||
|
"""Convert ISO 639-3 code to ISO 639-2 code."""
|
||||||
|
if iso3 in ISO_639_3:
|
||||||
|
return ISO_639_3[iso3].alpha_2
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
+40
-31
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
"""Post-processing image optimization of OCR PDFs."""
|
"""Post-processing image optimization of OCR PDFs."""
|
||||||
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -11,10 +10,10 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Iterator, MutableSet, Sequence
|
from collections.abc import Callable, Iterator, MutableSet, Sequence
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, NamedTuple, NewType
|
from typing import Any, NamedTuple, NewType
|
||||||
from zlib import compress
|
from zlib import compress
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
@@ -29,11 +28,13 @@ from pikepdf import (
|
|||||||
Stream,
|
Stream,
|
||||||
UnsupportedImageTypeError,
|
UnsupportedImageTypeError,
|
||||||
)
|
)
|
||||||
|
from pikepdf.models.image import HifiPrintImageNotTranscodableError
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ocrmypdf._concurrent import Executor, SerialExecutor
|
from ocrmypdf._concurrent import Executor, SerialExecutor
|
||||||
from ocrmypdf._exec import jbig2enc, pngquant
|
from ocrmypdf._exec import jbig2enc, pngquant
|
||||||
from ocrmypdf._jobcontext import PdfContext
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
|
from ocrmypdf._progressbar import ProgressBar
|
||||||
from ocrmypdf.exceptions import OutputFileAccessError
|
from ocrmypdf.exceptions import OutputFileAccessError
|
||||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, safe_symlink
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, safe_symlink
|
||||||
|
|
||||||
@@ -69,12 +70,9 @@ def jpg_name(root: Path, xref: Xref) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def extract_image_filter(
|
def extract_image_filter(
|
||||||
pdf: Pdf, root: Path, image: Stream, xref: Xref
|
image: Stream, xref: Xref
|
||||||
) -> tuple[PdfImage, tuple[Name, Object]] | None:
|
) -> tuple[PdfImage, tuple[Name, Object]] | None:
|
||||||
"""Determine if an image is extractable."""
|
"""Determine if an image is extractable."""
|
||||||
del pdf # unused args
|
|
||||||
del root
|
|
||||||
|
|
||||||
if image.Subtype != Name.Image:
|
if image.Subtype != Name.Image:
|
||||||
return None
|
return None
|
||||||
if image.Length < 100:
|
if image.Length < 100:
|
||||||
@@ -92,6 +90,7 @@ def extract_image_filter(
|
|||||||
if (
|
if (
|
||||||
len(pim.filter_decodeparms) == 2
|
len(pim.filter_decodeparms) == 2
|
||||||
and first_filtdp[0] == Name.FlateDecode
|
and first_filtdp[0] == Name.FlateDecode
|
||||||
|
and first_filtdp[1] is not None
|
||||||
and first_filtdp[1].get(Name.Predictor, 1) == 1
|
and first_filtdp[1].get(Name.Predictor, 1) == 1
|
||||||
and second_filtdp[0] == Name.DCTDecode
|
and second_filtdp[0] == Name.DCTDecode
|
||||||
and not second_filtdp[1]
|
and not second_filtdp[1]
|
||||||
@@ -132,7 +131,7 @@ def extract_image_jbig2(
|
|||||||
"""Extract an image, saving it as a JBIG2 file."""
|
"""Extract an image, saving it as a JBIG2 file."""
|
||||||
del options # unused arg
|
del options # unused arg
|
||||||
|
|
||||||
result = extract_image_filter(pdf, root, image, xref)
|
result = extract_image_filter(image, xref)
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
pim, filtdp = result
|
pim, filtdp = result
|
||||||
@@ -156,6 +155,13 @@ def extract_image_jbig2(
|
|||||||
with imgname.open('wb') as f:
|
with imgname.open('wb') as f:
|
||||||
ext = pim.extract_to(stream=f)
|
ext = pim.extract_to(stream=f)
|
||||||
imgname.rename(imgname.with_suffix(ext))
|
imgname.rename(imgname.with_suffix(ext))
|
||||||
|
except NotImplementedError as e:
|
||||||
|
if '/Decode' in str(e):
|
||||||
|
log.debug(
|
||||||
|
f"xref {xref}: skipping image with unsupported Decode table"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
raise
|
||||||
except UnsupportedImageTypeError:
|
except UnsupportedImageTypeError:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
@@ -172,7 +178,7 @@ def extract_image_generic(
|
|||||||
*, pdf: Pdf, root: Path, image: Stream, xref: Xref, options
|
*, pdf: Pdf, root: Path, image: Stream, xref: Xref, options
|
||||||
) -> XrefExt | None:
|
) -> XrefExt | None:
|
||||||
"""Generic image extraction."""
|
"""Generic image extraction."""
|
||||||
result = extract_image_filter(pdf, root, image, xref)
|
result = extract_image_filter(image, xref)
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
pim, filtdp = result
|
pim, filtdp = result
|
||||||
@@ -195,7 +201,7 @@ def extract_image_generic(
|
|||||||
with imgname.open('wb') as f:
|
with imgname.open('wb') as f:
|
||||||
ext = pim.extract_to(stream=f)
|
ext = pim.extract_to(stream=f)
|
||||||
imgname.rename(imgname.with_suffix(ext))
|
imgname.rename(imgname.with_suffix(ext))
|
||||||
except UnsupportedImageTypeError:
|
except (UnsupportedImageTypeError, HifiPrintImageNotTranscodableError):
|
||||||
return None
|
return None
|
||||||
return XrefExt(xref, ext)
|
return XrefExt(xref, ext)
|
||||||
elif (
|
elif (
|
||||||
@@ -251,6 +257,9 @@ def _find_image_xrefs_container(
|
|||||||
for _imname, image in dict(xobjs).items():
|
for _imname, image in dict(xobjs).items():
|
||||||
if image.objgen[1] != 0:
|
if image.objgen[1] != 0:
|
||||||
continue # Ignore images in an incremental PDF
|
continue # Ignore images in an incremental PDF
|
||||||
|
xref = Xref(image.objgen[0])
|
||||||
|
if xref in include_xrefs or xref in exclude_xrefs:
|
||||||
|
continue # Already processed
|
||||||
if Name.Subtype in image and image.Subtype == Name.Form:
|
if Name.Subtype in image and image.Subtype == Name.Form:
|
||||||
# Recurse into Form XObjects
|
# Recurse into Form XObjects
|
||||||
log.debug(f"Recursing into Form XObject {_imname} in page {pageno}")
|
log.debug(f"Recursing into Form XObject {_imname} in page {pageno}")
|
||||||
@@ -264,7 +273,6 @@ def _find_image_xrefs_container(
|
|||||||
depth + 1,
|
depth + 1,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
xref = Xref(image.objgen[0])
|
|
||||||
if Name.SMask in image:
|
if Name.SMask in image:
|
||||||
# Ignore soft masks
|
# Ignore soft masks
|
||||||
smask_xref = Xref(image.SMask.objgen[0])
|
smask_xref = Xref(image.SMask.objgen[0])
|
||||||
@@ -283,7 +291,7 @@ def _find_image_xrefs(pdf: Pdf):
|
|||||||
|
|
||||||
for pageno, page in enumerate(pdf.pages):
|
for pageno, page in enumerate(pdf.pages):
|
||||||
_find_image_xrefs_container(
|
_find_image_xrefs_container(
|
||||||
pdf, page, pageno, include_xrefs, exclude_xrefs, pageno_for_xref
|
pdf, page.obj, pageno, include_xrefs, exclude_xrefs, pageno_for_xref
|
||||||
)
|
)
|
||||||
|
|
||||||
working_xrefs = include_xrefs - exclude_xrefs
|
working_xrefs = include_xrefs - exclude_xrefs
|
||||||
@@ -371,7 +379,7 @@ def _produce_jbig2_images(
|
|||||||
options.jbig2_threshold,
|
options.jbig2_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
def jbig2_single_args(root, groups: dict[int, list[XrefExt]]):
|
def jbig2_single_args(root: Path, groups: dict[int, list[XrefExt]]):
|
||||||
for group, xref_exts in groups.items():
|
for group, xref_exts in groups.items():
|
||||||
prefix = f'group{group:08d}'
|
prefix = f'group{group:08d}'
|
||||||
# Second loop is to ensure multiple images per page are unpacked
|
# Second loop is to ensure multiple images per page are unpacked
|
||||||
@@ -386,15 +394,15 @@ def _produce_jbig2_images(
|
|||||||
|
|
||||||
if options.jbig2_page_group_size > 1:
|
if options.jbig2_page_group_size > 1:
|
||||||
jbig2_args = jbig2_group_args
|
jbig2_args = jbig2_group_args
|
||||||
jbig2_convert = jbig2enc.convert_group_mp
|
jbig2_convert = jbig2enc.convert_group
|
||||||
else:
|
else:
|
||||||
jbig2_args = jbig2_single_args
|
jbig2_args = jbig2_single_args
|
||||||
jbig2_convert = jbig2enc.convert_single_mp
|
jbig2_convert = jbig2enc.convert_single
|
||||||
|
|
||||||
executor(
|
executor(
|
||||||
use_threads=True,
|
use_threads=True,
|
||||||
max_workers=options.jobs,
|
max_workers=options.jobs,
|
||||||
tqdm_kwargs=dict(
|
progress_kwargs=dict(
|
||||||
total=len(jbig2_groups),
|
total=len(jbig2_groups),
|
||||||
desc="JBIG2",
|
desc="JBIG2",
|
||||||
unit='item',
|
unit='item',
|
||||||
@@ -450,9 +458,9 @@ def convert_to_jbig2(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _optimize_jpeg(args: tuple[Xref, Path, Path, int]) -> tuple[Xref, Path | None]:
|
def _optimize_jpeg(
|
||||||
xref, in_jpg, opt_jpg, jpeg_quality = args
|
xref: Xref, in_jpg: Path, opt_jpg: Path, jpeg_quality: int
|
||||||
|
) -> tuple[Xref, Path | None]:
|
||||||
with Image.open(in_jpg) as im:
|
with Image.open(in_jpg) as im:
|
||||||
im.save(opt_jpg, optimize=True, quality=jpeg_quality)
|
im.save(opt_jpg, optimize=True, quality=jpeg_quality)
|
||||||
|
|
||||||
@@ -474,7 +482,7 @@ def transcode_jpegs(
|
|||||||
opt_jpg = in_jpg.with_suffix('.opt.jpg')
|
opt_jpg = in_jpg.with_suffix('.opt.jpg')
|
||||||
yield xref, in_jpg, opt_jpg, options.jpeg_quality
|
yield xref, in_jpg, opt_jpg, options.jpeg_quality
|
||||||
|
|
||||||
def finish_jpeg(result: tuple[Xref, Path | None], pbar):
|
def finish_jpeg(result: tuple[Xref, Path | None], pbar: ProgressBar):
|
||||||
xref, opt_jpg = result
|
xref, opt_jpg = result
|
||||||
if opt_jpg:
|
if opt_jpg:
|
||||||
compdata = opt_jpg.read_bytes() # JPEG can inserted into PDF as is
|
compdata = opt_jpg.read_bytes() # JPEG can inserted into PDF as is
|
||||||
@@ -485,7 +493,7 @@ def transcode_jpegs(
|
|||||||
executor(
|
executor(
|
||||||
use_threads=True, # Processes are significantly slower at this task
|
use_threads=True, # Processes are significantly slower at this task
|
||||||
max_workers=options.jobs,
|
max_workers=options.jobs,
|
||||||
tqdm_kwargs=dict(
|
progress_kwargs=dict(
|
||||||
desc="Recompressing JPEGs",
|
desc="Recompressing JPEGs",
|
||||||
total=len(jpegs),
|
total=len(jpegs),
|
||||||
unit='image',
|
unit='image',
|
||||||
@@ -500,7 +508,7 @@ def transcode_jpegs(
|
|||||||
def _find_deflatable_jpeg(
|
def _find_deflatable_jpeg(
|
||||||
*, pdf: Pdf, root: Path, image: Stream, xref: Xref, options
|
*, pdf: Pdf, root: Path, image: Stream, xref: Xref, options
|
||||||
) -> XrefExt | None:
|
) -> XrefExt | None:
|
||||||
result = extract_image_filter(pdf, root, image, xref)
|
result = extract_image_filter(image, xref)
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
_pim, filtdp = result
|
_pim, filtdp = result
|
||||||
@@ -511,8 +519,9 @@ def _find_deflatable_jpeg(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _deflate_jpeg(args: tuple[Pdf, threading.Lock, Xref, int]) -> tuple[Xref, bytes]:
|
def _deflate_jpeg(
|
||||||
pdf, lock, xref, complevel = args
|
pdf: Pdf, lock: threading.Lock, xref: Xref, complevel: int
|
||||||
|
) -> tuple[Xref, bytes]:
|
||||||
with lock:
|
with lock:
|
||||||
xobj = pdf.get_object(xref, 0)
|
xobj = pdf.get_object(xref, 0)
|
||||||
try:
|
try:
|
||||||
@@ -547,7 +556,7 @@ def deflate_jpegs(pdf: Pdf, root: Path, options, executor: Executor) -> None:
|
|||||||
for xref in jpegs:
|
for xref in jpegs:
|
||||||
yield pdf, lock, xref, complevel
|
yield pdf, lock, xref, complevel
|
||||||
|
|
||||||
def finish(result, pbar):
|
def finish(result: tuple[Xref, bytes], pbar: ProgressBar):
|
||||||
xref, compdata = result
|
xref, compdata = result
|
||||||
if len(compdata) > 0:
|
if len(compdata) > 0:
|
||||||
with lock:
|
with lock:
|
||||||
@@ -558,7 +567,7 @@ def deflate_jpegs(pdf: Pdf, root: Path, options, executor: Executor) -> None:
|
|||||||
executor(
|
executor(
|
||||||
use_threads=True, # We're sharing the pdf directly, must use threads
|
use_threads=True, # We're sharing the pdf directly, must use threads
|
||||||
max_workers=options.jobs,
|
max_workers=options.jobs,
|
||||||
tqdm_kwargs=dict(
|
progress_kwargs=dict(
|
||||||
desc="Deflating JPEGs",
|
desc="Deflating JPEGs",
|
||||||
total=len(jpegs),
|
total=len(jpegs),
|
||||||
unit='image',
|
unit='image',
|
||||||
@@ -617,7 +626,7 @@ def transcode_pngs(
|
|||||||
image_name_fn: Callable[[Path, Xref], Path],
|
image_name_fn: Callable[[Path, Xref], Path],
|
||||||
root: Path,
|
root: Path,
|
||||||
options,
|
options,
|
||||||
executor,
|
executor: Executor,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply lossy transcoding to PNGs."""
|
"""Apply lossy transcoding to PNGs."""
|
||||||
modified: MutableSet[Xref] = set()
|
modified: MutableSet[Xref] = set()
|
||||||
@@ -641,13 +650,13 @@ def transcode_pngs(
|
|||||||
executor(
|
executor(
|
||||||
use_threads=True,
|
use_threads=True,
|
||||||
max_workers=options.jobs,
|
max_workers=options.jobs,
|
||||||
tqdm_kwargs=dict(
|
progress_kwargs=dict(
|
||||||
desc="PNGs",
|
desc="PNGs",
|
||||||
total=len(images),
|
total=len(images),
|
||||||
unit='image',
|
unit='image',
|
||||||
disable=not options.progress_bar,
|
disable=not options.progress_bar,
|
||||||
),
|
),
|
||||||
task=pngquant.quantize_mp,
|
task=pngquant.quantize,
|
||||||
task_arguments=pngquant_args(),
|
task_arguments=pngquant_args(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -662,8 +671,8 @@ DEFAULT_EXECUTOR = SerialExecutor()
|
|||||||
def optimize(
|
def optimize(
|
||||||
input_file: Path,
|
input_file: Path,
|
||||||
output_file: Path,
|
output_file: Path,
|
||||||
context,
|
context: PdfContext,
|
||||||
save_settings,
|
save_settings: dict[str, Any],
|
||||||
executor: Executor = DEFAULT_EXECUTOR,
|
executor: Executor = DEFAULT_EXECUTOR,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Optimize images in a PDF file."""
|
"""Optimize images in a PDF file."""
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
|
|||||||
icc: ICC identifier such as 'sRGB'
|
icc: ICC identifier such as 'sRGB'
|
||||||
References:
|
References:
|
||||||
Adobe PDFMARK Reference:
|
Adobe PDFMARK Reference:
|
||||||
https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/pdfmark_reference.pdf
|
https://opensource.adobe.com/dc-acrobat-sdk-docs/library/pdfmark/
|
||||||
"""
|
"""
|
||||||
if icc != 'sRGB':
|
if icc != 'sRGB':
|
||||||
raise NotImplementedError("Only supporting sRGB")
|
raise NotImplementedError("Only supporting sRGB")
|
||||||
|
|||||||
+147
-66
@@ -10,33 +10,34 @@ import atexit
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import statistics
|
import statistics
|
||||||
import sys
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Container, Iterable, Iterator, Mapping, Sequence
|
from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence
|
||||||
from contextlib import ExitStack
|
from contextlib import contextmanager
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from math import hypot, inf, isclose
|
from math import hypot, inf, isclose
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, NamedTuple
|
from typing import NamedTuple
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
from pdfminer.layout import LTPage, LTTextBox
|
from pdfminer.layout import LTPage, LTTextBox
|
||||||
from pikepdf import (
|
from pikepdf import (
|
||||||
|
Matrix,
|
||||||
Name,
|
Name,
|
||||||
Object,
|
Object,
|
||||||
Page,
|
Page,
|
||||||
Pdf,
|
Pdf,
|
||||||
PdfImage,
|
PdfImage,
|
||||||
PdfInlineImage,
|
PdfInlineImage,
|
||||||
PdfMatrix,
|
Stream,
|
||||||
UnsupportedImageTypeError,
|
UnsupportedImageTypeError,
|
||||||
parse_content_stream,
|
parse_content_stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ocrmypdf._concurrent import Executor, SerialExecutor
|
from ocrmypdf._concurrent import Executor, SerialExecutor
|
||||||
|
from ocrmypdf._progressbar import ProgressBar
|
||||||
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
||||||
from ocrmypdf.helpers import Resolution, available_cpu_count, pikepdf_enable_mmap
|
from ocrmypdf.helpers import Resolution, available_cpu_count, pikepdf_enable_mmap
|
||||||
from ocrmypdf.pdfinfo.layout import LTStateAwareChar, get_page_analysis, get_text_boxes
|
from ocrmypdf.pdfinfo.layout import LTStateAwareChar, get_page_analysis, get_text_boxes
|
||||||
@@ -207,7 +208,7 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
|||||||
CTM unchanged.
|
CTM unchanged.
|
||||||
"""
|
"""
|
||||||
stack = []
|
stack = []
|
||||||
ctm = PdfMatrix(initial_shorthand)
|
ctm = Matrix(initial_shorthand)
|
||||||
xobject_settings: list[XobjectSettings] = []
|
xobject_settings: list[XobjectSettings] = []
|
||||||
inline_images: list[InlineSettings] = []
|
inline_images: list[InlineSettings] = []
|
||||||
name_index = defaultdict(lambda: [])
|
name_index = defaultdict(lambda: [])
|
||||||
@@ -238,7 +239,13 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
|||||||
# to do. Just pretend nothing happened, keep calm and carry on.
|
# to do. Just pretend nothing happened, keep calm and carry on.
|
||||||
warn("PDF graphics stack underflowed - PDF may be malformed")
|
warn("PDF graphics stack underflowed - PDF may be malformed")
|
||||||
elif operator == 'cm':
|
elif operator == 'cm':
|
||||||
ctm = PdfMatrix(operands) @ ctm
|
try:
|
||||||
|
ctm = Matrix(operands) @ ctm
|
||||||
|
except ValueError:
|
||||||
|
raise InputFileError(
|
||||||
|
"PDF content stream is corrupt - this PDF is malformed. "
|
||||||
|
"Use a PDF editor that is capable of visually inspecting the PDF."
|
||||||
|
)
|
||||||
elif operator == 'Do':
|
elif operator == 'Do':
|
||||||
image_name = operands[0]
|
image_name = operands[0]
|
||||||
settings = XobjectSettings(
|
settings = XobjectSettings(
|
||||||
@@ -357,13 +364,26 @@ class ImageInfo:
|
|||||||
if inline is not None:
|
if inline is not None:
|
||||||
self._origin = 'inline'
|
self._origin = 'inline'
|
||||||
pim = inline
|
pim = inline
|
||||||
elif pdfimage is not None:
|
elif pdfimage is not None and isinstance(pdfimage, Stream):
|
||||||
self._origin = 'xobject'
|
self._origin = 'xobject'
|
||||||
pim = PdfImage(pdfimage)
|
pim = PdfImage(pdfimage)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Either pdfimage or inline must be set")
|
raise ValueError("Either pdfimage or inline must be set")
|
||||||
|
|
||||||
self._width = pim.width
|
self._width = pim.width
|
||||||
self._height = pim.height
|
self._height = pim.height
|
||||||
|
if (smask := pim.obj.get(Name.SMask, None)) is not None:
|
||||||
|
# SMask is pretty much an alpha channel, but in PDF it's possible
|
||||||
|
# for channel to have different dimensions than the image
|
||||||
|
# itself. Some PDF writers use this to create a grayscale stencil
|
||||||
|
# mask. For our purposes, the effective size is the size of the
|
||||||
|
# larger component (image or smask).
|
||||||
|
self._width = max(smask.get(Name.Width, 0), self._width)
|
||||||
|
self._height = max(smask.get(Name.Height, 0), self._height)
|
||||||
|
if (mask := pim.obj.get(Name.Mask, None)) is not None:
|
||||||
|
# If the image has a /Mask entry, it has an explicit mask.
|
||||||
|
self._width = max(mask.get(Name.Width, 0), self._width)
|
||||||
|
self._height = max(mask.get(Name.Height, 0), self._height)
|
||||||
|
|
||||||
# If /ImageMask is true, then this image is a stencil mask
|
# If /ImageMask is true, then this image is a stencil mask
|
||||||
# (Images that draw with this stencil mask will have a reference to
|
# (Images that draw with this stencil mask will have a reference to
|
||||||
@@ -386,33 +406,43 @@ class ImageInfo:
|
|||||||
if self._enc == Encoding.jpeg2000:
|
if self._enc == Encoding.jpeg2000:
|
||||||
self._color = Colorspace.jpeg2000
|
self._color = Colorspace.jpeg2000
|
||||||
|
|
||||||
if self._color == Colorspace.icc:
|
self._comp = None
|
||||||
# Check the ICC profile to determine actual colorspace
|
if self._color == Colorspace.icc and isinstance(pim, PdfImage):
|
||||||
try:
|
self._comp = self._init_icc(pim)
|
||||||
pim_icc = pim.icc
|
|
||||||
if pim_icc.profile.xcolor_space == 'GRAY':
|
|
||||||
self._comp = 1
|
|
||||||
elif pim_icc.profile.xcolor_space == 'CMYK':
|
|
||||||
self._comp = 4
|
|
||||||
else:
|
|
||||||
self._comp = 3
|
|
||||||
except (AttributeError, UnsupportedImageTypeError) as ex:
|
|
||||||
self._comp = None
|
|
||||||
logger.warning(
|
|
||||||
f"An image with a corrupt or unreadable ICC profile was found. "
|
|
||||||
f"The output PDF may not match the input PDF visually: {ex}. {self}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
if isinstance(self._color, Colorspace):
|
if isinstance(self._color, Colorspace):
|
||||||
self._comp = FRIENDLY_COMP.get(self._color)
|
self._comp = FRIENDLY_COMP.get(self._color)
|
||||||
else:
|
|
||||||
self._comp = None
|
|
||||||
|
|
||||||
# Bit of a hack... infer grayscale if component count is uncertain
|
# Bit of a hack... infer grayscale if component count is uncertain
|
||||||
# but encoding only supports monochrome.
|
# but encoding only supports monochrome.
|
||||||
if self._comp is None and self._enc in (Encoding.ccitt, Encoding.jbig2):
|
if self._comp is None and self._enc in (Encoding.ccitt, Encoding.jbig2):
|
||||||
self._comp = FRIENDLY_COMP[Colorspace.gray]
|
self._comp = FRIENDLY_COMP[Colorspace.gray]
|
||||||
|
|
||||||
|
def _init_icc(self, pim: PdfImage):
|
||||||
|
try:
|
||||||
|
icc = pim.icc
|
||||||
|
except UnsupportedImageTypeError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"An image with a corrupt or unreadable ICC profile was found. "
|
||||||
|
f"Output PDF may not match the input PDF visually: {e}. {self}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# Check the ICC profile to determine actual colorspace
|
||||||
|
if icc is None or not hasattr(icc, 'profile'):
|
||||||
|
logger.warning(
|
||||||
|
f"An image with an ICC profile but no ICC profile data was found. "
|
||||||
|
f"The output PDF may not match the input PDF visually. {self}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if icc.profile.xcolor_space == 'GRAY':
|
||||||
|
return 1
|
||||||
|
elif icc.profile.xcolor_space == 'CMYK':
|
||||||
|
return 4
|
||||||
|
else:
|
||||||
|
return 3
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Name of the image as it appears in the PDF."""
|
"""Name of the image as it appears in the PDF."""
|
||||||
@@ -457,9 +487,18 @@ class ImageInfo:
|
|||||||
def renderable(self) -> bool:
|
def renderable(self) -> bool:
|
||||||
"""Whether the image is renderable.
|
"""Whether the image is renderable.
|
||||||
|
|
||||||
Some PDFs in the wild have invalid images that are not renderable.
|
Some PDFs in the wild have invalid images that are not renderable,
|
||||||
|
due to unusual dimensions.
|
||||||
|
|
||||||
|
Stencil masks are not also not renderable, since they are not
|
||||||
|
drawn, but rather they control how rendering happens.
|
||||||
"""
|
"""
|
||||||
return self.dpi.is_finite and self.width >= 0 and self.height >= 0
|
return (
|
||||||
|
self.dpi.is_finite
|
||||||
|
and self.width >= 0
|
||||||
|
and self.height >= 0
|
||||||
|
and self.type_ != 'stencil'
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dpi(self) -> Resolution:
|
def dpi(self) -> Resolution:
|
||||||
@@ -474,7 +513,7 @@ class ImageInfo:
|
|||||||
"""Physical area of the image in square inches."""
|
"""Physical area of the image in square inches."""
|
||||||
if not self.renderable:
|
if not self.renderable:
|
||||||
return 0.0
|
return 0.0
|
||||||
return float(self.width * self.dpi.x * self.height * self.dpi.y)
|
return float((self.width / self.dpi.x) * (self.height / self.dpi.y))
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Return a string representation of the image."""
|
"""Return a string representation of the image."""
|
||||||
@@ -556,7 +595,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
|
|||||||
xobjs = resources[Name.XObject].as_dict()
|
xobjs = resources[Name.XObject].as_dict()
|
||||||
for xobj in xobjs:
|
for xobj in xobjs:
|
||||||
candidate = xobjs[xobj]
|
candidate = xobjs[xobj]
|
||||||
if candidate is None or candidate[Name.Subtype] != Name.Form:
|
if candidate is None or candidate.get(Name.Subtype) != Name.Form:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
form_xobject = candidate
|
form_xobject = candidate
|
||||||
@@ -602,12 +641,12 @@ def _process_content_streams(
|
|||||||
):
|
):
|
||||||
# Set the CTM to the state it was when the "Do" operator was
|
# Set the CTM to the state it was when the "Do" operator was
|
||||||
# encountered that is drawing this instance of the Form XObject
|
# encountered that is drawing this instance of the Form XObject
|
||||||
ctm = PdfMatrix(shorthand) if shorthand else PdfMatrix.identity()
|
ctm = Matrix(shorthand) if shorthand else Matrix()
|
||||||
|
|
||||||
# A Form XObject may provide its own matrix to map form space into
|
# A Form XObject may provide its own matrix to map form space into
|
||||||
# user space. Get this if one exists
|
# user space. Get this if one exists
|
||||||
form_shorthand = container.get(Name.Matrix, PdfMatrix.identity())
|
form_shorthand = container.get(Name.Matrix, Matrix())
|
||||||
form_matrix = PdfMatrix(form_shorthand)
|
form_matrix = Matrix(form_shorthand)
|
||||||
|
|
||||||
# Concatenate form matrix with CTM to ensure CTM is correct for
|
# Concatenate form matrix with CTM to ensure CTM is correct for
|
||||||
# drawing this instance of the XObject
|
# drawing this instance of the XObject
|
||||||
@@ -693,29 +732,41 @@ def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel):
|
|||||||
atexit.register(on_process_close)
|
atexit.register(on_process_close)
|
||||||
|
|
||||||
|
|
||||||
def _pdf_pageinfo_sync(args):
|
@contextmanager
|
||||||
pageno, thread_pdf, infile, check_pages, detailed_analysis = args
|
def _pdf_pageinfo_sync_pdf(thread_pdf: Pdf | None, infile: Path):
|
||||||
pdf = thread_pdf if thread_pdf is not None else worker_pdf
|
if thread_pdf is not None:
|
||||||
with ExitStack() as stack:
|
yield thread_pdf
|
||||||
if not pdf: # When called with SerialExecutor
|
elif worker_pdf is not None:
|
||||||
pdf = stack.enter_context(Pdf.open(infile))
|
yield worker_pdf
|
||||||
page = PageInfo(pdf, pageno, infile, check_pages, detailed_analysis)
|
else:
|
||||||
return page
|
with Pdf.open(infile) as pdf:
|
||||||
|
yield pdf
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_pageinfo_sync(
|
||||||
|
pageno: int,
|
||||||
|
thread_pdf: Pdf | None,
|
||||||
|
infile: Path,
|
||||||
|
check_pages: Container[int],
|
||||||
|
detailed_analysis: bool,
|
||||||
|
) -> PageInfo:
|
||||||
|
with _pdf_pageinfo_sync_pdf(thread_pdf, infile) as pdf:
|
||||||
|
return PageInfo(pdf, pageno, infile, check_pages, detailed_analysis)
|
||||||
|
|
||||||
|
|
||||||
def _pdf_pageinfo_concurrent(
|
def _pdf_pageinfo_concurrent(
|
||||||
pdf,
|
pdf,
|
||||||
executor: Executor,
|
executor: Executor,
|
||||||
|
max_workers: int,
|
||||||
|
use_threads: bool,
|
||||||
infile,
|
infile,
|
||||||
progbar,
|
progbar,
|
||||||
max_workers,
|
|
||||||
check_pages,
|
check_pages,
|
||||||
detailed_analysis=False,
|
detailed_analysis: bool = False,
|
||||||
) -> Sequence[PageInfo | None]:
|
) -> Sequence[PageInfo | None]:
|
||||||
pages: Sequence[PageInfo | None] = [None] * len(pdf.pages)
|
pages: list[PageInfo | None] = [None] * len(pdf.pages)
|
||||||
|
|
||||||
def update_pageinfo(result, pbar):
|
def update_pageinfo(page: PageInfo, pbar: ProgressBar):
|
||||||
page = result
|
|
||||||
if not page:
|
if not page:
|
||||||
raise InputFileError("Could read a page in the PDF")
|
raise InputFileError("Could read a page in the PDF")
|
||||||
pages[page.pageno] = page
|
pages[page.pageno] = page
|
||||||
@@ -726,13 +777,17 @@ def _pdf_pageinfo_concurrent(
|
|||||||
|
|
||||||
total = len(pdf.pages)
|
total = len(pdf.pages)
|
||||||
|
|
||||||
use_threads = False # No performance gain if threaded due to GIL
|
|
||||||
n_workers = min(1 + len(pages) // 4, max_workers)
|
n_workers = min(1 + len(pages) // 4, max_workers)
|
||||||
if n_workers == 1:
|
if n_workers == 1:
|
||||||
# But if we decided on only one worker, there is no point in using
|
# If we decided on only one worker, there is no point in using
|
||||||
# a separate process.
|
# a separate process.
|
||||||
use_threads = True
|
use_threads = True
|
||||||
|
|
||||||
|
if use_threads and n_workers > 1:
|
||||||
|
# If we are using threads, there is no point in using more than one
|
||||||
|
# worker thread - they will just fight over the GIL.
|
||||||
|
n_workers = 1
|
||||||
|
|
||||||
# If we use a thread, we can pass the already-open Pdf for them to use
|
# If we use a thread, we can pass the already-open Pdf for them to use
|
||||||
# If we use processes, we pass a None which tells the init function to open its
|
# If we use processes, we pass a None which tells the init function to open its
|
||||||
# own
|
# own
|
||||||
@@ -742,10 +797,15 @@ def _pdf_pageinfo_concurrent(
|
|||||||
(n, initial_pdf, infile, check_pages, detailed_analysis) for n in range(total)
|
(n, initial_pdf, infile, check_pages, detailed_analysis) for n in range(total)
|
||||||
)
|
)
|
||||||
assert n_workers == 1 if use_threads else n_workers >= 1, "Not multithreadable"
|
assert n_workers == 1 if use_threads else n_workers >= 1, "Not multithreadable"
|
||||||
|
logger.debug(
|
||||||
|
f"Gathering info with {n_workers} "
|
||||||
|
+ ('thread' if use_threads else 'process')
|
||||||
|
+ " workers"
|
||||||
|
)
|
||||||
executor(
|
executor(
|
||||||
use_threads=use_threads,
|
use_threads=use_threads,
|
||||||
max_workers=n_workers,
|
max_workers=n_workers,
|
||||||
tqdm_kwargs=dict(
|
progress_kwargs=dict(
|
||||||
total=total, desc="Scanning contents", unit='page', disable=not progbar
|
total=total, desc="Scanning contents", unit='page', disable=not progbar
|
||||||
),
|
),
|
||||||
worker_initializer=partial(
|
worker_initializer=partial(
|
||||||
@@ -817,10 +877,16 @@ class PageInfo:
|
|||||||
detailed_analysis: bool,
|
detailed_analysis: bool,
|
||||||
):
|
):
|
||||||
page: Page = pdf.pages[pageno]
|
page: Page = pdf.pages[pageno]
|
||||||
mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
|
mediabox = [Decimal(d) for d in page.mediabox.as_list()]
|
||||||
width_pt = mediabox[2] - mediabox[0]
|
width_pt = mediabox[2] - mediabox[0]
|
||||||
height_pt = mediabox[3] - mediabox[1]
|
height_pt = mediabox[3] - mediabox[1]
|
||||||
|
|
||||||
|
# self._artbox = [float(d) for d in page.artbox.as_list()]
|
||||||
|
# self._bleedbox = [float(d) for d in page.bleedbox.as_list()]
|
||||||
|
self._cropbox = [float(d) for d in page.cropbox.as_list()]
|
||||||
|
self._mediabox = [float(d) for d in page.mediabox.as_list()]
|
||||||
|
self._trimbox = [float(d) for d in page.trimbox.as_list()]
|
||||||
|
|
||||||
check_this_page = pageno in check_pages
|
check_this_page = pageno in check_pages
|
||||||
|
|
||||||
if check_this_page and detailed_analysis:
|
if check_this_page and detailed_analysis:
|
||||||
@@ -937,6 +1003,21 @@ class PageInfo:
|
|||||||
else:
|
else:
|
||||||
raise ValueError("rotation must be a cardinal angle")
|
raise ValueError("rotation must be a cardinal angle")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cropbox(self) -> FloatRect:
|
||||||
|
"""Return cropbox of page in PDF coordinates."""
|
||||||
|
return self._cropbox
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mediabox(self) -> FloatRect:
|
||||||
|
"""Return mediabox of page in PDF coordinates."""
|
||||||
|
return self._mediabox
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trimbox(self) -> FloatRect:
|
||||||
|
"""Return trimbox of page in PDF coordinates."""
|
||||||
|
return self._trimbox
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def images(self) -> list[ImageInfo]:
|
def images(self) -> list[ImageInfo]:
|
||||||
"""Return images."""
|
"""Return images."""
|
||||||
@@ -996,28 +1077,26 @@ class PageInfo:
|
|||||||
|
|
||||||
Returns None if there is no meaningful DPI for the page.
|
Returns None if there is no meaningful DPI for the page.
|
||||||
"""
|
"""
|
||||||
image_dpis = [
|
image_dpis = []
|
||||||
image.dpi.to_scalar() for image in self._images if image.renderable
|
image_areas = []
|
||||||
]
|
for image in self._images:
|
||||||
image_areas = [image.printed_area for image in self._images if image.renderable]
|
if not image.renderable:
|
||||||
|
continue
|
||||||
|
image_dpis.append(image.dpi.to_scalar())
|
||||||
|
image_areas.append(image.printed_area)
|
||||||
|
|
||||||
total_drawn_area = sum(image_areas)
|
total_drawn_area = sum(image_areas)
|
||||||
if total_drawn_area == 0:
|
if total_drawn_area == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
weights = [area / total_drawn_area for area in image_areas]
|
weights = [area / total_drawn_area for area in image_areas]
|
||||||
# Calculate harmonic mean of DPIs weighted by area
|
# Calculate harmonic mean of DPIs weighted by area
|
||||||
if sys.version_info >= (3, 10):
|
weighted_dpi = statistics.harmonic_mean(image_dpis, weights)
|
||||||
weighted_dpi = statistics.harmonic_mean(image_dpis, weights)
|
|
||||||
else:
|
|
||||||
weighted_dpi = sum(weights) / sum(
|
|
||||||
weight / dpi for weight, dpi in zip(weights, image_dpis)
|
|
||||||
)
|
|
||||||
max_dpi = max(image_dpis)
|
max_dpi = max(image_dpis)
|
||||||
dpi_average_max_ratio = weighted_dpi / max_dpi
|
dpi_average_max_ratio = weighted_dpi / max_dpi
|
||||||
|
|
||||||
arg_max_dpi = image_dpis.index(max_dpi)
|
arg_max_dpi = image_dpis.index(max_dpi)
|
||||||
max_area_ratio = image_areas[arg_max_dpi] / total_drawn_area
|
max_area_ratio = image_areas[arg_max_dpi] / total_drawn_area
|
||||||
|
|
||||||
return PageResolutionProfile(
|
return PageResolutionProfile(
|
||||||
weighted_dpi,
|
weighted_dpi,
|
||||||
max_dpi,
|
max_dpi,
|
||||||
@@ -1050,11 +1129,12 @@ class PdfInfo:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
infile,
|
infile: Path,
|
||||||
*,
|
*,
|
||||||
detailed_analysis: bool = False,
|
detailed_analysis: bool = False,
|
||||||
progbar: bool = False,
|
progbar: bool = False,
|
||||||
max_workers: int | None = None,
|
max_workers: int | None = None,
|
||||||
|
use_threads: bool = True,
|
||||||
check_pages=None,
|
check_pages=None,
|
||||||
executor: Executor = DEFAULT_EXECUTOR,
|
executor: Executor = DEFAULT_EXECUTOR,
|
||||||
):
|
):
|
||||||
@@ -1069,9 +1149,10 @@ class PdfInfo:
|
|||||||
self._pages = _pdf_pageinfo_concurrent(
|
self._pages = _pdf_pageinfo_concurrent(
|
||||||
pdf,
|
pdf,
|
||||||
executor,
|
executor,
|
||||||
|
max_workers,
|
||||||
|
use_threads,
|
||||||
infile,
|
infile,
|
||||||
progbar,
|
progbar,
|
||||||
max_workers,
|
|
||||||
check_pages=check_pages,
|
check_pages=check_pages,
|
||||||
detailed_analysis=detailed_analysis,
|
detailed_analysis=detailed_analysis,
|
||||||
)
|
)
|
||||||
@@ -1120,7 +1201,7 @@ class PdfInfo:
|
|||||||
@property
|
@property
|
||||||
def filename(self) -> str | Path:
|
def filename(self) -> str | Path:
|
||||||
"""Return filename of PDF."""
|
"""Return filename of PDF."""
|
||||||
if not isinstance(self._infile, (str, Path)):
|
if not isinstance(self._infile, str | Path):
|
||||||
raise NotImplementedError("can't get filename from stream")
|
raise NotImplementedError("can't get filename from stream")
|
||||||
return self._infile
|
return self._infile
|
||||||
|
|
||||||
@@ -1146,7 +1227,7 @@ class PdfInfo:
|
|||||||
return f"<PdfInfo('...'), page count={len(self)}>"
|
return f"<PdfInfo('...'), page count={len(self)}>"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(): # pragma: no cover
|
||||||
"""Run as a script."""
|
"""Run as a script."""
|
||||||
import argparse # pylint: disable=import-outside-toplevel
|
import argparse # pylint: disable=import-outside-toplevel
|
||||||
from pprint import pprint # pylint: disable=import-outside-toplevel
|
from pprint import pprint # pylint: disable=import-outside-toplevel
|
||||||
|
|||||||
@@ -5,18 +5,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Iterator, Mapping
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from math import copysign
|
from math import copysign
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterator
|
from typing import Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pdfminer
|
import pdfminer
|
||||||
import pdfminer.encodingdb
|
import pdfminer.encodingdb
|
||||||
import pdfminer.pdfdevice
|
import pdfminer.pdfdevice
|
||||||
import pdfminer.pdfinterp
|
import pdfminer.pdfinterp
|
||||||
|
import pdfminer.psparser
|
||||||
from pdfminer.converter import PDFLayoutAnalyzer
|
from pdfminer.converter import PDFLayoutAnalyzer
|
||||||
from pdfminer.layout import LAParams, LTChar, LTPage, LTTextBox
|
from pdfminer.layout import LAParams, LTChar, LTPage, LTTextBox
|
||||||
from pdfminer.pdfcolor import PDFColorSpace
|
from pdfminer.pdfcolor import PDFColorSpace
|
||||||
@@ -58,9 +59,10 @@ def pdfsimplefont__init__(
|
|||||||
|
|
||||||
setattr(PDFSimpleFont, '__init__', pdfsimplefont__init__)
|
setattr(PDFSimpleFont, '__init__', pdfsimplefont__init__)
|
||||||
|
|
||||||
#
|
# Patch pdfminer.six buffer size
|
||||||
# pdfminer patches when creator is PScript5.dll
|
# The parser doesn't properly handle keyword tokens are split across the end of the
|
||||||
#
|
# buffer, so increase the buffer size something far larger than will ever be seen.
|
||||||
|
pdfminer.psparser.PSBaseParser.BUFSIZ = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def pdftype3font__pscript5_get_height(self):
|
def pdftype3font__pscript5_get_height(self):
|
||||||
|
|||||||
+38
-45
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, NamedTuple
|
|||||||
import pluggy
|
import pluggy
|
||||||
|
|
||||||
from ocrmypdf import Executor, PdfContext
|
from ocrmypdf import Executor, PdfContext
|
||||||
|
from ocrmypdf._progressbar import ProgressBar
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -102,7 +103,6 @@ def check_options(options: Namespace) -> None:
|
|||||||
and the application should terminate gracefully with an informative
|
and the application should terminate gracefully with an informative
|
||||||
message and error code.
|
message and error code.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This hook will be called from the main process, and may modify global state
|
This hook will be called from the main process, and may modify global state
|
||||||
before child worker processes are forked.
|
before child worker processes are forked.
|
||||||
@@ -110,7 +110,7 @@ def check_options(options: Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@hookspec(firstresult=True)
|
@hookspec(firstresult=True)
|
||||||
def get_executor(progressbar_class) -> Executor:
|
def get_executor(progressbar_class: type[ProgressBar]) -> Executor:
|
||||||
"""Called to obtain an object that manages parallel execution.
|
"""Called to obtain an object that manages parallel execution.
|
||||||
|
|
||||||
This may be used to replace OCRmyPDF's default parallel execution system
|
This may be used to replace OCRmyPDF's default parallel execution system
|
||||||
@@ -132,41 +132,24 @@ def get_executor(progressbar_class) -> Executor:
|
|||||||
This hook will be called from the main process, and may modify global state
|
This hook will be called from the main process, and may modify global state
|
||||||
before child worker processes are forked.
|
before child worker processes are forked.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec(firstresult=True)
|
@hookspec(firstresult=True)
|
||||||
def get_progressbar_class():
|
def get_progressbar_class() -> type[ProgressBar]:
|
||||||
"""Called to obtain a class that can be used to monitor progress.
|
"""Called to obtain a class that can be used to monitor progress.
|
||||||
|
|
||||||
A progress bar is assumed, but this could be used for any type of monitoring.
|
OCRmyPDF will call this function when it wants to display a progress bar.
|
||||||
|
The class returned by this function must be compatible with the
|
||||||
The class should follow a tqdm-like protocol. Calling the class should return
|
:class:`ProgressBar` protocol.
|
||||||
a new progress bar object, which is activated with ``__enter__`` and terminated
|
|
||||||
``__exit__``. An update method is called whenever the progress bar is updated.
|
|
||||||
Progress bar objects will not be reused; a new one will be created for each
|
|
||||||
group of tasks.
|
|
||||||
|
|
||||||
The progress bar is held in the main process/thread and not updated by child
|
|
||||||
process/threads. When a child notifies the parent of completed work, the
|
|
||||||
parent updates the progress bar.
|
|
||||||
|
|
||||||
The arguments are the same as `tqdm <https://github.com/tqdm/tqdm>`_ accepts.
|
|
||||||
|
|
||||||
Progress bars should never write to ``sys.stdout``, or they will corrupt the
|
|
||||||
output if OCRmyPDF writes a PDF to standard output.
|
|
||||||
|
|
||||||
The type of events that OCRmyPDF reports to a progress bar may change in
|
|
||||||
minor releases.
|
|
||||||
|
|
||||||
Here is how OCRmyPDF will use the progress bar:
|
Here is how OCRmyPDF will use the progress bar:
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
pbar_class = pm.hook.get_progressbar_class()
|
pbar_class = pm.hook.get_progressbar_class()
|
||||||
with pbar_class(**tqdm_kwargs) as pbar:
|
with pbar_class(**progress_kwargs) as pbar:
|
||||||
...
|
...
|
||||||
pbar.update(1)
|
pbar.update(1)
|
||||||
"""
|
"""
|
||||||
@@ -187,7 +170,6 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None:
|
|||||||
and the application should terminate gracefully with an informative
|
and the application should terminate gracefully with an informative
|
||||||
message and error code.
|
message and error code.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This hook will be called from the main process, and may modify global state
|
This hook will be called from the main process, and may modify global state
|
||||||
before child worker processes are forked.
|
before child worker processes are forked.
|
||||||
@@ -231,6 +213,7 @@ def rasterize_pdf_page(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path: output_file if successful
|
Path: output_file if successful
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This hook will be called from child processes. Modifying global state
|
This hook will be called from child processes. Modifying global state
|
||||||
will not affect the main process or other child processes.
|
will not affect the main process or other child processes.
|
||||||
@@ -270,7 +253,6 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
|
|||||||
This hook will be called from child processes. Modifying global state
|
This hook will be called from child processes. Modifying global state
|
||||||
will not affect the main process or other child processes.
|
will not affect the main process or other child processes.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
"""
|
"""
|
||||||
@@ -308,7 +290,6 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
|
|||||||
This hook will be called from child processes. Modifying global state
|
This hook will be called from child processes. Modifying global state
|
||||||
will not affect the main process or other child processes.
|
will not affect the main process or other child processes.
|
||||||
|
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This is a :ref:`firstresult hook<firstresult>`.
|
This is a :ref:`firstresult hook<firstresult>`.
|
||||||
"""
|
"""
|
||||||
@@ -428,21 +409,42 @@ class OcrEngine(ABC):
|
|||||||
def generate_hocr(
|
def generate_hocr(
|
||||||
input_file: Path, output_hocr: Path, output_text: Path, options: Namespace
|
input_file: Path, output_hocr: Path, output_text: Path, options: Namespace
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Called to produce a hOCR file and sidecar text file."""
|
"""Called to produce a hOCR file from a page image and sidecar text file.
|
||||||
|
|
||||||
|
A hOCR file is an HTML-like file that describes the position of text on a
|
||||||
|
page. OCRmyPDF can create a text only PDF from the hOCR file and graft it
|
||||||
|
onto the output PDF.
|
||||||
|
|
||||||
|
This function executes in a worker thread or worker process. OCRmyPDF
|
||||||
|
automatically parallelizes OCR over pages. The OCR engine should not
|
||||||
|
introduce more parallelism.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: A page image on which to perform OCR.
|
||||||
|
output_hocr: The expected name of the output hOCR file.
|
||||||
|
output_text: The expected name of a text file containing the
|
||||||
|
recognized text.
|
||||||
|
options: The command line options.
|
||||||
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def generate_pdf(
|
def generate_pdf(
|
||||||
input_file: Path, output_pdf: Path, output_text: Path, options: Namespace
|
input_file: Path, output_pdf: Path, output_text: Path, options: Namespace
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Called to produce a text only PDF.
|
"""Called to produce a text only PDF from a page image.
|
||||||
|
|
||||||
|
A text only PDF should contain no visible material of any kind, as it
|
||||||
|
will be grafted onto the input PDF page. It must be sized to the
|
||||||
|
exact dimensions of the input image.
|
||||||
|
|
||||||
|
This function executes in a worker thread or worker process. OCRmyPDF
|
||||||
|
automatically parallelizes OCR over pages. The OCR engine should not
|
||||||
|
introduce more parallelism.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_file: A page image on which to perform OCR.
|
input_file: A page image on which to perform OCR.
|
||||||
output_pdf: The expected name of the output PDF, which must be
|
output_pdf: The expected name of the output PDF.
|
||||||
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
|
output_text: The expected name of a text file containing the
|
||||||
recognized text.
|
recognized text.
|
||||||
options: The command line options.
|
options: The command line options.
|
||||||
@@ -469,7 +471,7 @@ def generate_pdfa(
|
|||||||
context: PdfContext,
|
context: PdfContext,
|
||||||
pdf_version: str,
|
pdf_version: str,
|
||||||
pdfa_part: str,
|
pdfa_part: str,
|
||||||
progressbar_class,
|
progressbar_class: type[ProgressBar] | None,
|
||||||
stop_on_soft_error: bool,
|
stop_on_soft_error: bool,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Generate a PDF/A.
|
"""Generate a PDF/A.
|
||||||
@@ -489,14 +491,8 @@ def generate_pdfa(
|
|||||||
At its own discretion, the PDF/A generator may raise the version,
|
At its own discretion, the PDF/A generator may raise the version,
|
||||||
but should not lower it.
|
but should not lower it.
|
||||||
pdfa_part: The desired PDF/A compliance level, such as ``'2B'``.
|
pdfa_part: The desired PDF/A compliance level, such as ``'2B'``.
|
||||||
progressbar_class: The class of a progress bar with a tqdm-like API. An
|
progressbar_class: The class of a progress bar, which must implement
|
||||||
instance of this class will be initialized when PDF/A conversion
|
the ProgressBar protocol. If None, no progress is reported.
|
||||||
begins, using
|
|
||||||
``instance = progressbar_class(total: int, desc: str, unit:str)``,
|
|
||||||
defining the number of work units, a user-visible description,
|
|
||||||
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
|
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
|
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
|
may not visually resemble the original, the implementer of this hook
|
||||||
@@ -514,9 +510,6 @@ def generate_pdfa(
|
|||||||
Before version 15.0.0, the ``context`` was not provided and ``compression``
|
Before version 15.0.0, the ``context`` was not provided and ``compression``
|
||||||
was provided instead. Plugins should now read the context object to determine
|
was provided instead. Plugins should now read the context object to determine
|
||||||
if compression is requested.
|
if compression is requested.
|
||||||
|
|
||||||
See Also:
|
|
||||||
https://github.com/tqdm/tqdm
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
"""Utilities to measure OCR quality."""
|
"""Utilities to measure OCR quality."""
|
||||||
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
|
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
|
||||||
from subprocess import run as subprocess_run
|
from subprocess import run as subprocess_run
|
||||||
from typing import Callable, Union
|
|
||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ from ocrmypdf.exceptions import MissingDependencyError
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
Args = Sequence[Union[Path, str]]
|
Args = Sequence[Path | str]
|
||||||
OsEnviron = os._Environ # pylint: disable=protected-access
|
OsEnviron = os._Environ # pylint: disable=protected-access
|
||||||
|
|
||||||
|
|
||||||
@@ -172,6 +171,7 @@ def get_version(
|
|||||||
) from e
|
) from e
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
if e.returncode != 0:
|
if e.returncode != 0:
|
||||||
|
log.exception(e)
|
||||||
raise MissingDependencyError(
|
raise MissingDependencyError(
|
||||||
f"Ran program '{program}' but it exited with an error:\n{e.output}"
|
f"Ran program '{program}' but it exited with an error:\n{e.output}"
|
||||||
) from e
|
) from e
|
||||||
|
|||||||
@@ -9,18 +9,13 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Iterable, Iterator
|
from collections.abc import Callable, Iterable, Iterator
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, TypeVar
|
from typing import Any, TypeAlias, TypeVar
|
||||||
|
|
||||||
from packaging.version import InvalidVersion, Version
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
if sys.version_info >= (3, 10):
|
|
||||||
from typing import TypeAlias
|
|
||||||
else:
|
|
||||||
from typing_extensions import TypeAlias # pragma: no cover
|
|
||||||
|
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
# mypy understands 'if sys.platform' better than try/except ModuleNotFoundError
|
# mypy understands 'if sys.platform' better than try/except ModuleNotFoundError
|
||||||
import winreg # pylint: disable=import-error
|
import winreg # pylint: disable=import-error
|
||||||
@@ -84,7 +79,7 @@ def registry_path_ghostscript(env=None) -> Iterator[Path]:
|
|||||||
registry_subkeys(k), key=ghostscript_version_key, default=(0, 0, 0)
|
registry_subkeys(k), key=ghostscript_version_key, default=(0, 0, 0)
|
||||||
)
|
)
|
||||||
with winreg.OpenKey(
|
with winreg.OpenKey(
|
||||||
winreg.HKEY_LOCAL_MACHINE, fr"SOFTWARE\Artifex\GPL Ghostscript\{latest_gs}"
|
winreg.HKEY_LOCAL_MACHINE, rf"SOFTWARE\Artifex\GPL Ghostscript\{latest_gs}"
|
||||||
) as k:
|
) as k:
|
||||||
for _, gs_path, _ in registry_values(k):
|
for _, gs_path, _ in registry_values(k):
|
||||||
yield Path(gs_path) / 'bin'
|
yield Path(gs_path) / 'bin'
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
|
||||||
|
<meta name='ocr-system' content='tesseract 5.3.2' />
|
||||||
|
<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 "/tmp/ocrmypdf.io.jcdzf4up/000001_ocr.png"; bbox 0 0 4000 2864; ppageno 0; scan_res 2400 2400'>
|
||||||
|
<div class='ocr_carea' id='block_1_1' title="bbox 251 146 2173 237">
|
||||||
|
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 251 146 2173 237">
|
||||||
|
<span class='ocr_line' id='line_1_1' title="bbox 251 146 2173 237; baseline -0.006 5; x_size 99.418808; x_descenders 9.4188042; x_ascenders 32">
|
||||||
|
<span class='ocrx_word' id='word_1_1' title='bbox 251 154 274 176; x_wconf 89'>i</span>
|
||||||
|
<span class='ocrx_word' id='word_1_2' title='bbox 1080 146 1152 237; x_wconf 89'>a</span>
|
||||||
|
<span class='ocrx_word' id='word_1_3' title='bbox 1254 153 1406 235; x_wconf 92'>la</span>
|
||||||
|
<span class='ocrx_word' id='word_1_4' title='bbox 1500 153 2173 235; x_wconf 95'>Waterman</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_separator' id='block_1_2' title="bbox 135 202 2180 295"></div>
|
||||||
|
<div class='ocr_carea' id='block_1_3' title="bbox 145 414 2929 1221">
|
||||||
|
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 145 414 1154 516">
|
||||||
|
<span class='ocr_line' id='line_1_2' title="bbox 145 414 1154 516; baseline -0.005 -4; x_size 122.38636; x_descenders 24.386362; x_ascenders 40">
|
||||||
|
<span class='ocrx_word' id='word_1_5' title='bbox 145 414 211 512; x_wconf 60'>4</span>
|
||||||
|
<span class='ocrx_word' id='word_1_6' title='bbox 318 453 551 516; x_wconf 93'>ons</span>
|
||||||
|
<span class='ocrx_word' id='word_1_7' title='bbox 660 430 1154 512; x_wconf 91'>linzen</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 146 568 1239 667">
|
||||||
|
<span class='ocr_line' id='line_1_3' title="bbox 146 568 1239 667; baseline -0.004 -16; x_size 99; x_descenders 17; x_ascenders 24">
|
||||||
|
<span class='ocrx_word' id='word_1_8' title='bbox 146 569 209 667; x_wconf 94'>3</span>
|
||||||
|
<span class='ocrx_word' id='word_1_9' title='bbox 323 568 729 652; x_wconf 83'>liter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_10' title='bbox 821 569 1239 650; x_wconf 96'>water</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 148 705 645 804">
|
||||||
|
<span class='ocr_line' id='line_1_4' title="bbox 148 705 645 804; baseline -0.016 -14; x_size 103; x_descenders 19; x_ascenders 27">
|
||||||
|
<span class='ocrx_word' id='word_1_11' title='bbox 148 706 211 804; x_wconf 88'>3</span>
|
||||||
|
<span class='ocrx_word' id='word_1_12' title='bbox 311 705 645 789; x_wconf 52'>uien</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_5' lang='eng' title="bbox 147 832 1154 950">
|
||||||
|
<span class='ocr_line' id='line_1_5' title="bbox 147 832 1154 950; baseline -0.004 -27; x_size 118; x_descenders 28; x_ascenders 30">
|
||||||
|
<span class='ocrx_word' id='word_1_13' title='bbox 147 832 623 950; x_wconf 91'>bloem,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_14' title='bbox 737 843 1154 924; x_wconf 91'>boter</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_6' lang='eng' title="bbox 148 979 1241 1087">
|
||||||
|
<span class='ocr_line' id='line_1_6' title="bbox 148 979 1241 1087; baseline -0.005 -21; x_size 107; x_descenders 24; x_ascenders 24">
|
||||||
|
<span class='ocrx_word' id='word_1_15' title='bbox 148 983 215 1066; x_wconf 88'>2</span>
|
||||||
|
<span class='ocrx_word' id='word_1_16' title='bbox 312 983 807 1087; x_wconf 88'>kopjes</span>
|
||||||
|
<span class='ocrx_word' id='word_1_17' title='bbox 905 979 1241 1062; x_wconf 92'>melk</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_7' lang='eng' title="bbox 154 1109 2929 1221">
|
||||||
|
<span class='ocr_line' id='line_1_7' title="bbox 154 1109 2929 1221; baseline -0.003 -21; x_size 112; x_descenders 28; x_ascenders 26">
|
||||||
|
<span class='ocrx_word' id='word_1_18' title='bbox 154 1117 791 1221; x_wconf 92'>laurier,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_19' title='bbox 906 1111 1810 1219; x_wconf 90'>kruidnagel,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_20' title='bbox 1927 1109 2490 1219; x_wconf 90'>kerrie,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_21' title='bbox 2615 1110 2929 1195; x_wconf 91'>zout</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_carea' id='block_1_4' title="bbox 147 1383 3706 2731">
|
||||||
|
<p class='ocr_par' id='par_1_8' lang='eng' title="bbox 147 1383 3706 2731">
|
||||||
|
<span class='ocr_line' id='line_1_8' title="bbox 149 1383 3612 1474; baseline -0.003 -2; x_size 107.01524; x_descenders 25.01524; x_ascenders 21">
|
||||||
|
<span class='ocrx_word' id='word_1_22' title='bbox 149 1395 303 1474; x_wconf 93'>De</span>
|
||||||
|
<span class='ocrx_word' id='word_1_23' title='bbox 411 1390 902 1473; x_wconf 80'>linzgen</span>
|
||||||
|
<span class='ocrx_word' id='word_1_24' title='bbox 996 1409 1497 1470; x_wconf 89'>wassen</span>
|
||||||
|
<span class='ocrx_word' id='word_1_25' title='bbox 1595 1409 1752 1470; x_wconf 88'>en</span>
|
||||||
|
<span class='ocrx_word' id='word_1_26' title='bbox 1853 1385 2166 1467; x_wconf 75'>in-l</span>
|
||||||
|
<span class='ocrx_word' id='word_1_27' title='bbox 2275 1383 2684 1466; x_wconf 93'>liter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_28' title='bbox 2775 1383 3278 1464; x_wconf 91'>kokend</span>
|
||||||
|
<span class='ocrx_word' id='word_1_29' title='bbox 3368 1401 3612 1462; x_wconf 90'>wa-</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_9' title="bbox 157 1516 3520 1632; baseline -0.004 -21; x_size 110; x_descenders 25; x_ascenders 25">
|
||||||
|
<span class='ocrx_word' id='word_1_30' title='bbox 157 1531 394 1611; x_wconf 94'>ter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_31' title='bbox 495 1527 558 1609; x_wconf 93'>1</span>
|
||||||
|
<span class='ocrx_word' id='word_1_32' title='bbox 658 1530 903 1632; x_wconf 84'>dag</span>
|
||||||
|
<span class='ocrx_word' id='word_1_33' title='bbox 1002 1527 1415 1609; x_wconf 90'>laten</span>
|
||||||
|
<span class='ocrx_word' id='word_1_34' title='bbox 1505 1525 1979 1611; x_wconf 58'>weken,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_35' title='bbox 2103 1521 2167 1603; x_wconf 96'>2</span>
|
||||||
|
<span class='ocrx_word' id='word_1_36' title='bbox 2275 1518 2683 1603; x_wconf 83'>liter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_37' title='bbox 2777 1519 3194 1601; x_wconf 96'>water</span>
|
||||||
|
<span class='ocrx_word' id='word_1_38' title='bbox 3286 1516 3520 1599; x_wconf 89'>bij</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_10' title="bbox 152 1651 3616 1767; baseline -0.004 -21; x_size 110; x_descenders 27; x_ascenders 23">
|
||||||
|
<span class='ocrx_word' id='word_1_39' title='bbox 152 1668 302 1747; x_wconf 92'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_40' title='bbox 407 1662 905 1747; x_wconf 91'>linzen</span>
|
||||||
|
<span class='ocrx_word' id='word_1_41' title='bbox 996 1682 1559 1767; x_wconf 92'>voegen,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_42' title='bbox 1680 1659 2176 1741; x_wconf 96'>zonder</span>
|
||||||
|
<span class='ocrx_word' id='word_1_43' title='bbox 2267 1655 2506 1737; x_wconf 96'>het</span>
|
||||||
|
<span class='ocrx_word' id='word_1_44' title='bbox 2606 1655 3023 1737; x_wconf 92'>water</span>
|
||||||
|
<span class='ocrx_word' id='word_1_45' title='bbox 3116 1651 3616 1735; x_wconf 91'>waarin</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_11' title="bbox 153 1782 3704 1905; baseline -0.004 -20; x_size 117; x_descenders 23; x_ascenders 34">
|
||||||
|
<span class='ocrx_word' id='word_1_46' title='bbox 153 1824 305 1885; x_wconf 91'>ze</span>
|
||||||
|
<span class='ocrx_word' id='word_1_47' title='bbox 407 1800 979 1905; x_wconf 85'>geweekt</span>
|
||||||
|
<span class='ocrx_word' id='word_1_48' title='bbox 1089 1797 1412 1900; x_wconf 96'>zijn</span>
|
||||||
|
<span class='ocrx_word' id='word_1_49' title='bbox 1510 1796 1672 1878; x_wconf 96'>af</span>
|
||||||
|
<span class='ocrx_word' id='word_1_50' title='bbox 1770 1782 1914 1876; x_wconf 93'>te</span>
|
||||||
|
<span class='ocrx_word' id='word_1_51' title='bbox 2019 1792 2576 1899; x_wconf 54'>gieten,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_52' title='bbox 2690 1790 2850 1874; x_wconf 93'>De</span>
|
||||||
|
<span class='ocrx_word' id='word_1_53' title='bbox 2948 1791 3357 1872; x_wconf 89'>helft</span>
|
||||||
|
<span class='ocrx_word' id='word_1_54' title='bbox 3452 1811 3704 1873; x_wconf 96'>van</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_12' title="bbox 151 1928 3593 2035; baseline -0.003 -13; x_size 109; x_descenders 25; x_ascenders 25">
|
||||||
|
<span class='ocrx_word' id='word_1_55' title='bbox 151 1942 305 2024; x_wconf 92'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_56' title='bbox 403 1940 735 2021; x_wconf 89'>uien</span>
|
||||||
|
<span class='ocrx_word' id='word_1_57' title='bbox 829 1938 1330 2022; x_wconf 91'>bakken</span>
|
||||||
|
<span class='ocrx_word' id='word_1_58' title='bbox 1419 1939 1659 2018; x_wconf 92'>met</span>
|
||||||
|
<span class='ocrx_word' id='word_1_59' title='bbox 1770 1933 2346 2016; x_wconf 91'>laurier</span>
|
||||||
|
<span class='ocrx_word' id='word_1_60' title='bbox 2447 1953 2603 2014; x_wconf 91'>en</span>
|
||||||
|
<span class='ocrx_word' id='word_1_61' title='bbox 2691 1928 3593 2035; x_wconf 63'>Kruidnagel.</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_13' title="bbox 151 2067 3451 2180; baseline -0.004 -21; x_size 108; x_descenders 25; x_ascenders 24">
|
||||||
|
<span class='ocrx_word' id='word_1_62' title='bbox 151 2076 473 2159; x_wconf 92'>Alle</span>
|
||||||
|
<span class='ocrx_word' id='word_1_63' title='bbox 569 2076 965 2180; x_wconf 88'>uien,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_64' title='bbox 1079 2072 1578 2156; x_wconf 90'>kerrie</span>
|
||||||
|
<span class='ocrx_word' id='word_1_65' title='bbox 1685 2092 1837 2153; x_wconf 93'>en</span>
|
||||||
|
<span class='ocrx_word' id='word_1_66' title='bbox 1938 2072 2254 2153; x_wconf 81'>zgout</span>
|
||||||
|
<span class='ocrx_word' id='word_1_67' title='bbox 2355 2068 2593 2172; x_wconf 43'>bij</span>
|
||||||
|
<span class='ocrx_word' id='word_1_68' title='bbox 2696 2071 2850 2150; x_wconf 91'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_69' title='bbox 2957 2067 3451 2150; x_wconf 85'>linzen</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_14' title="bbox 147 2205 3614 2318; baseline -0.003 -23; x_size 106; x_descenders 22; x_ascenders 25">
|
||||||
|
<span class='ocrx_word' id='word_1_70' title='bbox 147 2234 711 2318; x_wconf 88'>voegen,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_71' title='bbox 826 2210 1234 2295; x_wconf 95'>Alles</span>
|
||||||
|
<span class='ocrx_word' id='word_1_72' title='bbox 1342 2232 1588 2295; x_wconf 95'>aan</span>
|
||||||
|
<span class='ocrx_word' id='word_1_73' title='bbox 1679 2212 1831 2291; x_wconf 96'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_74' title='bbox 1928 2208 2263 2290; x_wconf 93'>kook</span>
|
||||||
|
<span class='ocrx_word' id='word_1_75' title='bbox 2355 2206 3000 2308; x_wconf 54'>brengen,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_76' title='bbox 3117 2205 3367 2285; x_wconf 95'>Van</span>
|
||||||
|
<span class='ocrx_word' id='word_1_77' title='bbox 3462 2206 3614 2287; x_wconf 95'>de</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_15' title="bbox 152 2341 3706 2447; baseline -0.003 -18; x_size 107; x_descenders 24; x_ascenders 24">
|
||||||
|
<span class='ocrx_word' id='word_1_78' title='bbox 152 2352 565 2434; x_wconf 91'>bloem</span>
|
||||||
|
<span class='ocrx_word' id='word_1_79' title='bbox 655 2351 896 2431; x_wconf 92'>met</span>
|
||||||
|
<span class='ocrx_word' id='word_1_80' title='bbox 997 2349 1669 2431; x_wconf 90'>boter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_81' title='bbox 1514 2337 1683 2455; x_wconf 91'>en</span>
|
||||||
|
<span class='ocrx_word' id='word_1_82' title='bbox 1757 2343 2095 2426; x_wconf 88'>melk</span>
|
||||||
|
<span class='ocrx_word' id='word_1_83' title='bbox 2193 2364 2432 2427; x_wconf 93'>een</span>
|
||||||
|
<span class='ocrx_word' id='word_1_84' title='bbox 2527 2341 2935 2447; x_wconf 90'>papje</span>
|
||||||
|
<span class='ocrx_word' id='word_1_85' title='bbox 3029 2341 3453 2422; x_wconf 96'>maken</span>
|
||||||
|
<span class='ocrx_word' id='word_1_86' title='bbox 3549 2362 3706 2422; x_wconf 95'>en</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_16' title="bbox 149 2477 3619 2586; baseline -0.003 -16; x_size 107; x_descenders 24; x_ascenders 23">
|
||||||
|
<span class='ocrx_word' id='word_1_87' title='bbox 149 2489 650 2571; x_wconf 86'>verder</span>
|
||||||
|
<span class='ocrx_word' id='word_1_88' title='bbox 750 2486 1330 2570; x_wconf 90'>afmaken</span>
|
||||||
|
<span class='ocrx_word' id='word_1_89' title='bbox 1420 2485 1660 2567; x_wconf 96'>met</span>
|
||||||
|
<span class='ocrx_word' id='word_1_90' title='bbox 1765 2485 1917 2566; x_wconf 93'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_91' title='bbox 2025 2505 2409 2586; x_wconf 86'>soep,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_92' title='bbox 2521 2479 2764 2564; x_wconf 96'>Als</span>
|
||||||
|
<span class='ocrx_word' id='word_1_93' title='bbox 2868 2480 3021 2561; x_wconf 92'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_94' title='bbox 3127 2477 3619 2562; x_wconf 91'>linzen</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_17' title="bbox 155 2619 2412 2731; baseline -0.005 -20; x_size 98; x_descenders 15; x_ascenders 23">
|
||||||
|
<span class='ocrx_word' id='word_1_95' title='bbox 155 2647 481 2731; x_wconf 35'>gfgaar</span>
|
||||||
|
<span class='ocrx_word' id='word_1_96' title='bbox 581 2624 909 2728; x_wconf 87'>Zijn</span>
|
||||||
|
<span class='ocrx_word' id='word_1_97' title='bbox 1005 2623 1153 2707; x_wconf 95'>is</span>
|
||||||
|
<span class='ocrx_word' id='word_1_98' title='bbox 1255 2624 1409 2706; x_wconf 93'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_99' title='bbox 1522 2642 1840 2726; x_wconf 91'>soep</span>
|
||||||
|
<span class='ocrx_word' id='word_1_100' title='bbox 1929 2619 2412 2709; x_wconf 89'>klaar.</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
i a la Waterman
|
||||||
|
|
||||||
|
4 ons linzen
|
||||||
|
|
||||||
|
3 liter water
|
||||||
|
|
||||||
|
3 uien
|
||||||
|
|
||||||
|
bloem, boter
|
||||||
|
|
||||||
|
2 kopjes melk
|
||||||
|
|
||||||
|
laurier, kruidnagel, kerrie, zout
|
||||||
|
|
||||||
|
De linzgen wassen en in-l liter kokend wa-
|
||||||
|
ter 1 dag laten weken, 2 liter water bij
|
||||||
|
de linzen voegen, zonder het water waarin
|
||||||
|
ze geweekt zijn af te gieten, De helft van
|
||||||
|
de uien bakken met laurier en Kruidnagel.
|
||||||
|
Alle uien, kerrie en zgout bij de linzen
|
||||||
|
voegen, Alles aan de kook brengen, Van de
|
||||||
|
bloem met boter en melk een papje maken en
|
||||||
|
verder afmaken met de soep, Als de linzen
|
||||||
|
gfgaar Zijn is de soep klaar.
|
||||||
BIN
Binary file not shown.
+89
@@ -0,0 +1,89 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
|
||||||
|
<meta name='ocr-system' content='tesseract 5.3.2' />
|
||||||
|
<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 "/tmp/ocrmypdf.io.vh2to5iv/000001_ocr.png"; bbox 0 0 640 682; ppageno 0; scan_res 230 230'>
|
||||||
|
<div class='ocr_carea' id='block_1_1' title="bbox 365 15 429 29">
|
||||||
|
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 365 15 429 29">
|
||||||
|
<span class='ocr_line' id='line_1_1' title="bbox 365 15 429 29; baseline 0 0; x_size 19.238094; x_descenders 5.2380953; x_ascenders 4">
|
||||||
|
<span class='ocrx_word' id='word_1_1' title='bbox 365 15 429 29; x_wconf 92'>Tarnose</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_photo' id='block_1_2' title="bbox 255 38 616 72"></div>
|
||||||
|
<div class='ocr_photo' id='block_1_3' title="bbox 273 38 349 57"></div>
|
||||||
|
<div class='ocr_carea' id='block_1_4' title="bbox 186 20 244 49">
|
||||||
|
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 186 20 345 49">
|
||||||
|
<span class='ocr_line' id='line_1_2' title="bbox 186 20 244 49; baseline -0.006 -10; x_size 20.454546; x_descenders 5.4545455; x_ascenders 5">
|
||||||
|
<span class='ocrx_word' id='word_1_2' title='bbox 186 20 244 49; x_wconf 92'>Bokale</span>
|
||||||
|
<span class='ocrx_word' id='word_1_3' title='bbox 299 28 345 46; x_wconf 42'>oa</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_photo' id='block_1_5' title="bbox 64 38 186 72"></div>
|
||||||
|
<div class='ocr_carea' id='block_1_6' title="bbox 537 276 612 291">
|
||||||
|
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 537 276 612 291">
|
||||||
|
<span class='ocr_line' id='line_1_3' title="bbox 537 276 612 291; baseline 0 0; x_size 20.238094; x_descenders 5.2380953; x_ascenders 5">
|
||||||
|
<span class='ocrx_word' id='word_1_4' title='bbox 537 276 612 291; x_wconf 92'>Lehuntze</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_photo' id='block_1_7' title="bbox 20 269 616 314"></div>
|
||||||
|
<div class='ocr_carea' id='block_1_8' title="bbox 480 323 550 341">
|
||||||
|
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 480 323 550 341">
|
||||||
|
<span class='ocr_line' id='line_1_4' title="bbox 480 323 550 341; baseline 0 -4; x_size 18; x_descenders 4; x_ascenders 4">
|
||||||
|
<span class='ocrx_word' id='word_1_5' title='bbox 480 323 550 341; x_wconf 91'>Mugerre</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_photo' id='block_1_9' title="bbox 44 574 197 623"></div>
|
||||||
|
<div class='ocr_carea' id='block_1_10' title="bbox 204 542 603 574">
|
||||||
|
<p class='ocr_par' id='par_1_5' lang='eng' title="bbox 204 542 603 574">
|
||||||
|
<span class='ocr_line' id='line_1_5' title="bbox 204 542 603 574; baseline 0.018 -11; x_size 23.26087; x_descenders 5.2608695; x_ascenders 7">
|
||||||
|
<span class='ocrx_word' id='word_1_6' title='bbox 204 542 295 574; x_wconf 91'>Milafranga</span>
|
||||||
|
<span class='ocrx_word' id='word_1_7' title='bbox 439 552 603 569; x_wconf 90'>Komunikabideak</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_carea' id='block_1_11' title="bbox 220 584 616 619">
|
||||||
|
<p class='ocr_par' id='par_1_6' lang='eng' title="bbox 220 584 616 625">
|
||||||
|
<span class='ocr_line' id='line_1_6' title="bbox 220 584 616 619; baseline -0.005 -1; x_size 43.666668; x_descenders 10.916667; x_ascenders 10.916667">
|
||||||
|
<span class='ocrx_word' id='word_1_8' title='bbox 220 585 404 619; x_wconf 2'>BAIONA</span>
|
||||||
|
<span class='ocrx_word' id='word_1_9' title='bbox 468 584 576 625; x_wconf 0'> zeiteninsiie</span>
|
||||||
|
<span class='ocrx_word' id='word_1_10' title='bbox 585 588 616 610; x_wconf 86'>—</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_carea' id='block_1_12' title="bbox 393 623 598 634">
|
||||||
|
<p class='ocr_par' id='par_1_7' lang='eng' title="bbox 393 623 598 634">
|
||||||
|
<span class='ocr_line' id='line_1_7' title="bbox 393 623 598 634; baseline 0 0; x_size 22.75; x_descenders 5.5; x_ascenders 5.75">
|
||||||
|
<span class='ocrx_word' id='word_1_11' title='bbox 393 629 396 631; x_wconf 54'>7</span>
|
||||||
|
<span class='ocrx_word' id='word_1_12' title='bbox 470 623 539 634; x_wconf 24'>Trenbideak</span>
|
||||||
|
<span class='ocrx_word' id='word_1_13' title='bbox 550 628 598 630; x_wconf 24'>-----</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_carea' id='block_1_13' title="bbox 81 633 613 667">
|
||||||
|
<p class='ocr_par' id='par_1_8' lang='eng' title="bbox 81 633 613 667">
|
||||||
|
<span class='ocr_line' id='line_1_8' title="bbox 81 633 613 667; baseline -0.002 -13; x_size 21; x_descenders 4; x_ascenders 7">
|
||||||
|
<span class='ocrx_word' id='word_1_14' title='bbox 81 637 111 653; x_wconf 0'>t\</span>
|
||||||
|
<span class='ocrx_word' id='word_1_15' title='bbox 123 633 201 663; x_wconf 22'>Basusarri</span>
|
||||||
|
<span class='ocrx_word' id='word_1_16' title='bbox 214 633 222 663; x_wconf 0'>—</span>
|
||||||
|
<span class='ocrx_word' id='word_1_17' title='bbox 230 647 325 656; x_wconf 0'>spmeans:20141004</span>
|
||||||
|
<span class='ocrx_word' id='word_1_18' title='bbox 373 633 415 667; x_wconf 42'>ae:</span>
|
||||||
|
<span class='ocrx_word' id='word_1_19' title='bbox 441 650 444 653; x_wconf 25'>.</span>
|
||||||
|
<span class='ocrx_word' id='word_1_20' title='bbox 521 649 544 657; x_wconf 17'>_</span>
|
||||||
|
<span class='ocrx_word' id='word_1_21' title='bbox 595 650 613 659; x_wconf 7'>~</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
Tarnose
|
||||||
|
|
||||||
|
Bokale oa
|
||||||
|
|
||||||
|
Lehuntze
|
||||||
|
|
||||||
|
Mugerre
|
||||||
|
|
||||||
|
Milafranga Komunikabideak
|
||||||
|
|
||||||
|
BAIONA zeiteninsiie —
|
||||||
|
|
||||||
|
7 Trenbideak -----
|
||||||
|
|
||||||
|
t\ Basusarri — spmeans:20141004 ae: . _ ~
|
||||||
BIN
Binary file not shown.
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
|
||||||
|
<meta name='ocr-system' content='tesseract 5.3.2' />
|
||||||
|
<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 "/tmp/ocrmypdf.io.vh2to5iv/000002_ocr.png"; bbox 0 0 400 50; ppageno 0; scan_res 200 200'>
|
||||||
|
<div class='ocr_carea' id='block_1_1' title="bbox 54 16 344 33">
|
||||||
|
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 54 16 344 33">
|
||||||
|
<span class='ocr_line' id='line_1_1' title="bbox 54 16 344 33; baseline 0 -4; x_size 25.75; x_descenders 5.5; x_ascenders 6.75">
|
||||||
|
<span class='ocrx_word' id='word_1_1' title='bbox 54 16 110 29; x_wconf 84'>Covfefe</span>
|
||||||
|
<span class='ocrx_word' id='word_1_2' title='bbox 115 17 127 29; x_wconf 95'>is</span>
|
||||||
|
<span class='ocrx_word' id='word_1_3' title='bbox 132 20 140 29; x_wconf 95'>a</span>
|
||||||
|
<span class='ocrx_word' id='word_1_4' title='bbox 145 16 212 33; x_wconf 85'>perfectly</span>
|
||||||
|
<span class='ocrx_word' id='word_1_5' title='bbox 217 16 296 29; x_wconf 85'>cromulent</span>
|
||||||
|
<span class='ocrx_word' id='word_1_6' title='bbox 300 16 344 29; x_wconf 95'>word.</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Covfefe is a perfectly cromulent word.
|
||||||
BIN
Binary file not shown.
+177
@@ -0,0 +1,177 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
|
||||||
|
<meta name='ocr-system' content='tesseract 5.3.2' />
|
||||||
|
<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 "/tmp/ocrmypdf.io.vh2to5iv/000003_ocr.png"; bbox 0 0 4000 2864; ppageno 0; scan_res 1440 1440'>
|
||||||
|
<div class='ocr_carea' id='block_1_1' title="bbox 142 146 2173 258">
|
||||||
|
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 142 146 2173 258">
|
||||||
|
<span class='ocr_line' id='line_1_1' title="bbox 142 146 2173 258; baseline -0.004 -19; x_size 113; x_descenders 23; x_ascenders 32">
|
||||||
|
<span class='ocrx_word' id='word_1_1' title='bbox 142 154 982 258; x_wconf 90'>Linzensoep</span>
|
||||||
|
<span class='ocrx_word' id='word_1_2' title='bbox 1080 146 1152 237; x_wconf 96'>a</span>
|
||||||
|
<span class='ocrx_word' id='word_1_3' title='bbox 1254 153 1406 235; x_wconf 96'>la</span>
|
||||||
|
<span class='ocrx_word' id='word_1_4' title='bbox 1500 153 2173 235; x_wconf 96'>Waterman</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_separator' id='block_1_2' title="bbox 136 253 2180 275"></div>
|
||||||
|
<div class='ocr_carea' id='block_1_3' title="bbox 145 414 2929 1221">
|
||||||
|
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 145 414 1154 516">
|
||||||
|
<span class='ocr_line' id='line_1_2' title="bbox 145 414 1154 516; baseline -0.005 -4; x_size 122.38636; x_descenders 24.386362; x_ascenders 40">
|
||||||
|
<span class='ocrx_word' id='word_1_5' title='bbox 145 414 211 512; x_wconf 60'>4</span>
|
||||||
|
<span class='ocrx_word' id='word_1_6' title='bbox 318 453 551 516; x_wconf 93'>ons</span>
|
||||||
|
<span class='ocrx_word' id='word_1_7' title='bbox 660 430 1154 512; x_wconf 91'>linzen</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 146 568 1239 667">
|
||||||
|
<span class='ocr_line' id='line_1_3' title="bbox 146 568 1239 667; baseline -0.004 -16; x_size 99; x_descenders 17; x_ascenders 24">
|
||||||
|
<span class='ocrx_word' id='word_1_8' title='bbox 146 569 209 667; x_wconf 94'>3</span>
|
||||||
|
<span class='ocrx_word' id='word_1_9' title='bbox 323 568 729 652; x_wconf 83'>liter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_10' title='bbox 821 569 1239 650; x_wconf 96'>water</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 148 705 645 804">
|
||||||
|
<span class='ocr_line' id='line_1_4' title="bbox 148 705 645 804; baseline -0.016 -14; x_size 103; x_descenders 19; x_ascenders 27">
|
||||||
|
<span class='ocrx_word' id='word_1_11' title='bbox 148 706 211 804; x_wconf 88'>3</span>
|
||||||
|
<span class='ocrx_word' id='word_1_12' title='bbox 311 705 645 789; x_wconf 52'>uien</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_5' lang='eng' title="bbox 147 832 1154 950">
|
||||||
|
<span class='ocr_line' id='line_1_5' title="bbox 147 832 1154 950; baseline -0.004 -27; x_size 118; x_descenders 28; x_ascenders 30">
|
||||||
|
<span class='ocrx_word' id='word_1_13' title='bbox 147 832 623 950; x_wconf 91'>bloem,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_14' title='bbox 737 843 1154 924; x_wconf 91'>boter</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_6' lang='eng' title="bbox 148 979 1241 1087">
|
||||||
|
<span class='ocr_line' id='line_1_6' title="bbox 148 979 1241 1087; baseline -0.005 -21; x_size 107; x_descenders 24; x_ascenders 24">
|
||||||
|
<span class='ocrx_word' id='word_1_15' title='bbox 148 983 215 1066; x_wconf 88'>2</span>
|
||||||
|
<span class='ocrx_word' id='word_1_16' title='bbox 312 983 807 1087; x_wconf 88'>kopjes</span>
|
||||||
|
<span class='ocrx_word' id='word_1_17' title='bbox 905 979 1241 1062; x_wconf 92'>melk</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class='ocr_par' id='par_1_7' lang='eng' title="bbox 154 1109 2929 1221">
|
||||||
|
<span class='ocr_line' id='line_1_7' title="bbox 154 1109 2929 1221; baseline -0.003 -21; x_size 112; x_descenders 28; x_ascenders 26">
|
||||||
|
<span class='ocrx_word' id='word_1_18' title='bbox 154 1117 791 1221; x_wconf 92'>laurier,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_19' title='bbox 906 1111 1810 1219; x_wconf 90'>kruidnagel,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_20' title='bbox 1927 1109 2490 1219; x_wconf 90'>kerrie,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_21' title='bbox 2615 1110 2929 1195; x_wconf 91'>zout</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class='ocr_carea' id='block_1_4' title="bbox 147 1383 3706 2731">
|
||||||
|
<p class='ocr_par' id='par_1_8' lang='eng' title="bbox 147 1383 3706 2731">
|
||||||
|
<span class='ocr_line' id='line_1_8' title="bbox 149 1383 3612 1474; baseline -0.003 -2; x_size 107.01524; x_descenders 25.01524; x_ascenders 21">
|
||||||
|
<span class='ocrx_word' id='word_1_22' title='bbox 149 1395 303 1474; x_wconf 93'>De</span>
|
||||||
|
<span class='ocrx_word' id='word_1_23' title='bbox 411 1390 902 1473; x_wconf 80'>linzgen</span>
|
||||||
|
<span class='ocrx_word' id='word_1_24' title='bbox 996 1409 1497 1470; x_wconf 89'>wassen</span>
|
||||||
|
<span class='ocrx_word' id='word_1_25' title='bbox 1595 1409 1752 1470; x_wconf 88'>en</span>
|
||||||
|
<span class='ocrx_word' id='word_1_26' title='bbox 1853 1385 2166 1467; x_wconf 75'>in-l</span>
|
||||||
|
<span class='ocrx_word' id='word_1_27' title='bbox 2275 1383 2684 1466; x_wconf 93'>liter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_28' title='bbox 2775 1383 3278 1464; x_wconf 91'>kokend</span>
|
||||||
|
<span class='ocrx_word' id='word_1_29' title='bbox 3368 1401 3612 1462; x_wconf 90'>wa-</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_9' title="bbox 157 1516 3521 1632; baseline -0.004 -21; x_size 110; x_descenders 25; x_ascenders 25">
|
||||||
|
<span class='ocrx_word' id='word_1_30' title='bbox 157 1531 394 1611; x_wconf 93'>ter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_31' title='bbox 495 1527 558 1609; x_wconf 94'>1</span>
|
||||||
|
<span class='ocrx_word' id='word_1_32' title='bbox 658 1530 903 1632; x_wconf 75'>dag</span>
|
||||||
|
<span class='ocrx_word' id='word_1_33' title='bbox 1002 1527 1415 1609; x_wconf 85'>laten</span>
|
||||||
|
<span class='ocrx_word' id='word_1_34' title='bbox 1505 1525 1979 1611; x_wconf 86'>weken,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_35' title='bbox 2103 1521 2167 1603; x_wconf 96'>2</span>
|
||||||
|
<span class='ocrx_word' id='word_1_36' title='bbox 2275 1518 2683 1603; x_wconf 93'>liter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_37' title='bbox 2777 1519 3194 1601; x_wconf 96'>water</span>
|
||||||
|
<span class='ocrx_word' id='word_1_38' title='bbox 3286 1516 3521 1620; x_wconf 93'>bij</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_10' title="bbox 152 1651 3616 1767; baseline -0.004 -21; x_size 110; x_descenders 27; x_ascenders 23">
|
||||||
|
<span class='ocrx_word' id='word_1_39' title='bbox 152 1668 302 1747; x_wconf 92'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_40' title='bbox 407 1662 905 1747; x_wconf 91'>linzen</span>
|
||||||
|
<span class='ocrx_word' id='word_1_41' title='bbox 996 1682 1559 1767; x_wconf 92'>voegen,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_42' title='bbox 1680 1659 2176 1741; x_wconf 96'>zonder</span>
|
||||||
|
<span class='ocrx_word' id='word_1_43' title='bbox 2267 1655 2506 1737; x_wconf 96'>het</span>
|
||||||
|
<span class='ocrx_word' id='word_1_44' title='bbox 2606 1655 3023 1737; x_wconf 92'>water</span>
|
||||||
|
<span class='ocrx_word' id='word_1_45' title='bbox 3116 1651 3616 1735; x_wconf 91'>waarin</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_11' title="bbox 153 1782 3704 1905; baseline -0.004 -20; x_size 117; x_descenders 23; x_ascenders 34">
|
||||||
|
<span class='ocrx_word' id='word_1_46' title='bbox 153 1824 305 1885; x_wconf 91'>ze</span>
|
||||||
|
<span class='ocrx_word' id='word_1_47' title='bbox 407 1800 979 1905; x_wconf 85'>geweekt</span>
|
||||||
|
<span class='ocrx_word' id='word_1_48' title='bbox 1089 1797 1412 1900; x_wconf 96'>zijn</span>
|
||||||
|
<span class='ocrx_word' id='word_1_49' title='bbox 1510 1796 1672 1878; x_wconf 96'>af</span>
|
||||||
|
<span class='ocrx_word' id='word_1_50' title='bbox 1770 1782 1914 1876; x_wconf 93'>te</span>
|
||||||
|
<span class='ocrx_word' id='word_1_51' title='bbox 2019 1792 2576 1899; x_wconf 54'>gieten,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_52' title='bbox 2690 1790 2850 1874; x_wconf 93'>De</span>
|
||||||
|
<span class='ocrx_word' id='word_1_53' title='bbox 2948 1791 3357 1872; x_wconf 89'>helft</span>
|
||||||
|
<span class='ocrx_word' id='word_1_54' title='bbox 3452 1811 3704 1873; x_wconf 96'>van</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_12' title="bbox 151 1928 3593 2035; baseline -0.003 -13; x_size 109; x_descenders 25; x_ascenders 25">
|
||||||
|
<span class='ocrx_word' id='word_1_55' title='bbox 151 1942 305 2024; x_wconf 92'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_56' title='bbox 403 1940 735 2021; x_wconf 89'>uien</span>
|
||||||
|
<span class='ocrx_word' id='word_1_57' title='bbox 829 1938 1330 2022; x_wconf 91'>bakken</span>
|
||||||
|
<span class='ocrx_word' id='word_1_58' title='bbox 1419 1939 1659 2018; x_wconf 92'>met</span>
|
||||||
|
<span class='ocrx_word' id='word_1_59' title='bbox 1770 1933 2346 2016; x_wconf 91'>laurier</span>
|
||||||
|
<span class='ocrx_word' id='word_1_60' title='bbox 2447 1953 2603 2014; x_wconf 91'>en</span>
|
||||||
|
<span class='ocrx_word' id='word_1_61' title='bbox 2691 1928 3593 2035; x_wconf 63'>Kruidnagel.</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_13' title="bbox 151 2067 3451 2180; baseline -0.004 -21; x_size 108; x_descenders 25; x_ascenders 24">
|
||||||
|
<span class='ocrx_word' id='word_1_62' title='bbox 151 2076 473 2159; x_wconf 92'>Alle</span>
|
||||||
|
<span class='ocrx_word' id='word_1_63' title='bbox 569 2076 965 2180; x_wconf 88'>uien,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_64' title='bbox 1079 2072 1578 2156; x_wconf 90'>kerrie</span>
|
||||||
|
<span class='ocrx_word' id='word_1_65' title='bbox 1685 2092 1837 2153; x_wconf 93'>en</span>
|
||||||
|
<span class='ocrx_word' id='word_1_66' title='bbox 1938 2072 2254 2153; x_wconf 81'>zgout</span>
|
||||||
|
<span class='ocrx_word' id='word_1_67' title='bbox 2355 2068 2593 2172; x_wconf 43'>bij</span>
|
||||||
|
<span class='ocrx_word' id='word_1_68' title='bbox 2696 2071 2850 2150; x_wconf 91'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_69' title='bbox 2957 2067 3451 2150; x_wconf 85'>linzen</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_14' title="bbox 147 2205 3614 2318; baseline -0.003 -23; x_size 106; x_descenders 22; x_ascenders 25">
|
||||||
|
<span class='ocrx_word' id='word_1_70' title='bbox 147 2234 711 2318; x_wconf 88'>voegen,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_71' title='bbox 826 2210 1234 2295; x_wconf 95'>Alles</span>
|
||||||
|
<span class='ocrx_word' id='word_1_72' title='bbox 1342 2232 1588 2295; x_wconf 95'>aan</span>
|
||||||
|
<span class='ocrx_word' id='word_1_73' title='bbox 1679 2212 1831 2291; x_wconf 96'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_74' title='bbox 1928 2208 2263 2290; x_wconf 93'>kook</span>
|
||||||
|
<span class='ocrx_word' id='word_1_75' title='bbox 2355 2206 3000 2308; x_wconf 54'>brengen,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_76' title='bbox 3117 2205 3367 2285; x_wconf 95'>Van</span>
|
||||||
|
<span class='ocrx_word' id='word_1_77' title='bbox 3462 2206 3614 2287; x_wconf 95'>de</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_15' title="bbox 152 2341 3706 2447; baseline -0.003 -18; x_size 107; x_descenders 24; x_ascenders 24">
|
||||||
|
<span class='ocrx_word' id='word_1_78' title='bbox 152 2352 565 2434; x_wconf 91'>bloem</span>
|
||||||
|
<span class='ocrx_word' id='word_1_79' title='bbox 655 2351 896 2431; x_wconf 92'>met</span>
|
||||||
|
<span class='ocrx_word' id='word_1_80' title='bbox 997 2349 1669 2431; x_wconf 90'>boter</span>
|
||||||
|
<span class='ocrx_word' id='word_1_81' title='bbox 1514 2337 1683 2455; x_wconf 91'>en</span>
|
||||||
|
<span class='ocrx_word' id='word_1_82' title='bbox 1757 2343 2095 2426; x_wconf 88'>melk</span>
|
||||||
|
<span class='ocrx_word' id='word_1_83' title='bbox 2193 2364 2432 2427; x_wconf 93'>een</span>
|
||||||
|
<span class='ocrx_word' id='word_1_84' title='bbox 2527 2341 2935 2447; x_wconf 90'>papje</span>
|
||||||
|
<span class='ocrx_word' id='word_1_85' title='bbox 3029 2341 3453 2422; x_wconf 96'>maken</span>
|
||||||
|
<span class='ocrx_word' id='word_1_86' title='bbox 3549 2362 3706 2422; x_wconf 95'>en</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_16' title="bbox 149 2477 3619 2586; baseline -0.003 -16; x_size 107; x_descenders 24; x_ascenders 23">
|
||||||
|
<span class='ocrx_word' id='word_1_87' title='bbox 149 2489 650 2571; x_wconf 86'>verder</span>
|
||||||
|
<span class='ocrx_word' id='word_1_88' title='bbox 750 2486 1330 2570; x_wconf 90'>afmaken</span>
|
||||||
|
<span class='ocrx_word' id='word_1_89' title='bbox 1420 2485 1660 2567; x_wconf 96'>met</span>
|
||||||
|
<span class='ocrx_word' id='word_1_90' title='bbox 1765 2485 1917 2566; x_wconf 93'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_91' title='bbox 2025 2505 2409 2586; x_wconf 86'>soep,</span>
|
||||||
|
<span class='ocrx_word' id='word_1_92' title='bbox 2521 2479 2764 2564; x_wconf 96'>Als</span>
|
||||||
|
<span class='ocrx_word' id='word_1_93' title='bbox 2868 2480 3021 2561; x_wconf 92'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_94' title='bbox 3127 2477 3619 2562; x_wconf 91'>linzen</span>
|
||||||
|
</span>
|
||||||
|
<span class='ocr_line' id='line_1_17' title="bbox 155 2619 2412 2731; baseline -0.005 -20; x_size 98; x_descenders 15; x_ascenders 23">
|
||||||
|
<span class='ocrx_word' id='word_1_95' title='bbox 155 2647 481 2731; x_wconf 35'>gfgaar</span>
|
||||||
|
<span class='ocrx_word' id='word_1_96' title='bbox 581 2624 909 2728; x_wconf 87'>Zijn</span>
|
||||||
|
<span class='ocrx_word' id='word_1_97' title='bbox 1005 2623 1153 2707; x_wconf 95'>is</span>
|
||||||
|
<span class='ocrx_word' id='word_1_98' title='bbox 1255 2624 1409 2706; x_wconf 93'>de</span>
|
||||||
|
<span class='ocrx_word' id='word_1_99' title='bbox 1522 2642 1840 2726; x_wconf 91'>soep</span>
|
||||||
|
<span class='ocrx_word' id='word_1_100' title='bbox 1929 2619 2412 2709; x_wconf 89'>klaar.</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
Linzensoep a la Waterman
|
||||||
|
|
||||||
|
4 ons linzen
|
||||||
|
|
||||||
|
3 liter water
|
||||||
|
|
||||||
|
3 uien
|
||||||
|
|
||||||
|
bloem, boter
|
||||||
|
|
||||||
|
2 kopjes melk
|
||||||
|
|
||||||
|
laurier, kruidnagel, kerrie, zout
|
||||||
|
|
||||||
|
De linzgen wassen en in-l liter kokend wa-
|
||||||
|
ter 1 dag laten weken, 2 liter water bij
|
||||||
|
de linzen voegen, zonder het water waarin
|
||||||
|
ze geweekt zijn af te gieten, De helft van
|
||||||
|
de uien bakken met laurier en Kruidnagel.
|
||||||
|
Alle uien, kerrie en zgout bij de linzen
|
||||||
|
voegen, Alles aan de kook brengen, Van de
|
||||||
|
bloem met boter en melk een papje maken en
|
||||||
|
verder afmaken met de soep, Als de linzen
|
||||||
|
gfgaar Zijn is de soep klaar.
|
||||||
BIN
Binary file not shown.
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
|
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.9_hn68nz/000001_ocr.png"; bbox 0 0 1000 800; ppageno 0; scan_res 200 200'>
|
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.su6l_waz/000001_ocr.png"; bbox 0 0 1000 800; ppageno 0; scan_res 200 200'>
|
||||||
<div class='ocr_photo' id='block_1_1' title="bbox 296 96 704 504"></div>
|
<div class='ocr_photo' id='block_1_1' title="bbox 296 96 704 504"></div>
|
||||||
<div class='ocr_carea' id='block_1_2' title="bbox 150 592 841 622">
|
<div class='ocr_carea' id='block_1_2' title="bbox 150 592 841 622">
|
||||||
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 150 592 841 622">
|
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 150 592 841 622">
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
|
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.q0nk4qy2/000001_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
|
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.g296cpyi/000001_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
|
||||||
<div class='ocr_carea' id='block_1_1' title="bbox 582 131 1968 303">
|
<div class='ocr_carea' id='block_1_1' title="bbox 582 131 1968 303">
|
||||||
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 582 131 1968 303">
|
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 582 131 1968 303">
|
||||||
<span class='ocr_header' id='line_1_1' title="bbox 882 131 1657 217; baseline 0.001 -17; x_size 85; x_descenders 16; x_ascenders 19">
|
<span class='ocr_header' id='line_1_1' title="bbox 882 131 1657 217; baseline 0.001 -17; x_size 85; x_descenders 16; x_ascenders 19">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user