Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1fc77e1b6 | ||
|
|
17eed0529a | ||
|
|
f02353686d | ||
|
|
32813a3c3d | ||
|
|
073a434ab3 | ||
|
|
f390e7f9d1 | ||
|
|
bfbe571f12 | ||
|
|
368568b8ea | ||
|
|
55e7177dbe | ||
|
|
b486df7e2d | ||
|
|
74a84b6ae9 | ||
|
|
cfebf1dc8b | ||
|
|
1aaff4af6f | ||
|
|
36c82e0659 | ||
|
|
522f9d5f56 | ||
|
|
796e424ee5 | ||
|
|
d87db6cad0 | ||
|
|
dd6ed4c5f8 | ||
|
|
206bab74bc | ||
|
|
b333480749 | ||
|
|
f71a5ffd61 | ||
|
|
b7c3ea70ed | ||
|
|
636623ab49 | ||
|
|
74253e5fc8 | ||
|
|
02d85ff070 | ||
|
|
179c36151b | ||
|
|
3c4b099cb1 | ||
|
|
15df9c370c | ||
|
|
86d92ef490 | ||
|
|
8f44b29ca3 | ||
|
|
5a08a6cfeb | ||
|
|
cc058be4b2 | ||
|
|
7565d20c0a | ||
|
|
9a075039b5 | ||
|
|
5a1c043331 | ||
|
|
fe89be5dc0 | ||
|
|
d70296b97a | ||
|
|
7d7658018d | ||
|
|
8fb8e9f72c | ||
|
|
85d6fb8ce9 | ||
|
|
828e741c24 | ||
|
|
36837f8353 | ||
|
|
12fd4f70f1 | ||
|
|
250615561d | ||
|
|
a659f83d67 | ||
|
|
08f95c0b13 | ||
|
|
dbd3c93757 | ||
|
|
5d128a91d2 | ||
|
|
a1b8113d56 | ||
|
|
f052e910c9 | ||
|
|
116e2692d0 | ||
|
|
b2669c7d71 | ||
|
|
c8c53d38a3 | ||
|
|
d303b42c86 | ||
|
|
f77f701a50 | ||
|
|
1c3b7d1507 | ||
|
|
bf62562787 | ||
|
|
6c6cbfd4d6 | ||
|
|
ee5acbe94e | ||
|
|
5e478a7774 | ||
|
|
92c5200ad2 | ||
|
|
86a102f8e6 | ||
|
|
2463b91051 | ||
|
|
07f7c6b812 | ||
|
|
8138664287 | ||
|
|
120ca72393 | ||
|
|
f9b3e9a97b | ||
|
|
1e87930bbb | ||
|
|
fe4725658e | ||
|
|
9d042767cc | ||
|
|
23bc247b9c | ||
|
|
e44bf46d77 | ||
|
|
f50620c244 | ||
|
|
6f755321b8 | ||
|
|
706681deb8 | ||
|
|
c283cf0a0d | ||
|
|
0f82d7223e | ||
|
|
9a6150ae53 | ||
|
|
fec0948a13 | ||
|
|
18b59c57b4 | ||
|
|
a67a11e61c | ||
|
|
6ca4940a32 | ||
|
|
0e4cce2642 | ||
|
|
8fca0c71dc | ||
|
|
944d99bdc1 | ||
|
|
5bb6e1c5d7 | ||
|
|
8d7a8f0f98 | ||
|
|
b9dd0a5e3c | ||
|
|
6949ad2c5d | ||
|
|
b3324c3b4e | ||
|
|
b38cac6931 | ||
|
|
bb4c47e707 | ||
|
|
5e1e2497ab | ||
|
|
cd910fbf21 | ||
|
|
1225269a4b | ||
|
|
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 |
+35
-28
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
FROM ubuntu:22.04 as base
|
FROM ubuntu:24.04 AS base
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
@@ -9,19 +9,15 @@ RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selectio
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 \
|
python3 \
|
||||||
libqpdf-dev \
|
python-is-python3
|
||||||
zlib1g \
|
|
||||||
liblept5
|
|
||||||
|
|
||||||
FROM base as builder
|
FROM base AS builder
|
||||||
|
|
||||||
# Note we need leptonica here to build jbig2
|
# Note we need leptonica here to build jbig2
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential autoconf automake libtool \
|
build-essential autoconf automake libtool \
|
||||||
libleptonica-dev \
|
libleptonica-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
python3-dev \
|
|
||||||
python3-distutils \
|
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
@@ -29,42 +25,50 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libcairo2-dev \
|
libcairo2-dev \
|
||||||
pkg-config
|
pkg-config
|
||||||
|
|
||||||
# Get the latest pip (Ubuntu version doesn't support manylinux2010)
|
|
||||||
RUN \
|
|
||||||
curl https://bootstrap.pypa.io/get-pip.py | python3
|
|
||||||
|
|
||||||
# Compile and install jbig2
|
# Compile and install jbig2
|
||||||
# Needs libleptonica-dev, zlib1g-dev
|
# Needs libleptonica-dev, zlib1g-dev
|
||||||
RUN \
|
RUN \
|
||||||
mkdir jbig2 \
|
mkdir jbig2 \
|
||||||
&& curl -L https://github.com/agl/jbig2enc/archive/ea6a40a.tar.gz | \
|
&& curl -L https://github.com/agl/jbig2enc/archive/c0141bf.tar.gz | \
|
||||||
tar xz -C jbig2 --strip-components=1 \
|
tar xz -C jbig2 --strip-components=1 \
|
||||||
&& cd jbig2 \
|
&& cd jbig2 \
|
||||||
&& ./autogen.sh && ./configure && make && make install \
|
&& ./autogen.sh && ./configure && make && make install \
|
||||||
&& cd .. \
|
&& cd .. \
|
||||||
&& rm -rf jbig2
|
&& rm -rf jbig2
|
||||||
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN pip3 install --no-cache-dir .[test,webservice,watcher]
|
# Copy uv from ghcr
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
# Install the project's dependencies using the lockfile and settings
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
# Then, add the rest of the project source code and install it
|
||||||
|
# Installing separately from its dependencies allows optimal layer caching
|
||||||
|
COPY . /app
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen \
|
||||||
|
--extra test --extra webservice --extra watcher --no-dev \
|
||||||
|
--no-install-package pyarrow
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
|
|
||||||
# For Tesseract 5
|
RUN apt-get update && apt-get install -y software-properties-common
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
software-properties-common gpg-agent
|
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5
|
||||||
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ghostscript \
|
ghostscript \
|
||||||
fonts-droid-fallback \
|
fonts-droid-fallback \
|
||||||
jbig2dec \
|
jbig2dec \
|
||||||
img2pdf \
|
|
||||||
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 \
|
||||||
@@ -72,6 +76,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
tesseract-ocr-fra \
|
tesseract-ocr-fra \
|
||||||
tesseract-ocr-por \
|
tesseract-ocr-por \
|
||||||
tesseract-ocr-spa \
|
tesseract-ocr-spa \
|
||||||
|
ttyd \
|
||||||
unpaper \
|
unpaper \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
@@ -80,11 +85,13 @@ WORKDIR /app
|
|||||||
COPY --from=builder /usr/local/lib/ /usr/local/lib/
|
COPY --from=builder /usr/local/lib/ /usr/local/lib/
|
||||||
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
||||||
|
|
||||||
COPY --from=builder /app/misc/webservice.py /app/
|
COPY --from=builder --chown=app:app /app /app
|
||||||
COPY --from=builder /app/misc/watcher.py /app/
|
|
||||||
|
|
||||||
# Copy minimal project files to get the test suite.
|
RUN rm -rf /app/.git && \
|
||||||
COPY --from=builder /app/pyproject.toml /app/README.md /app/
|
ln -s /app/misc/webservice.py /app/webservice.py && \
|
||||||
COPY --from=builder /app/tests /app/tests
|
ln -s /app/misc/watcher.py /app/watcher.py
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/ocrmypdf"]
|
|
||||||
|
|||||||
+35
-36
@@ -1,7 +1,14 @@
|
|||||||
# 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.19 as base
|
# Note: Alpine 3.20 builds tesseract with --enable-opencl, which is not
|
||||||
|
# supported by anyone. OCRmyPDF is not compatible with Alpine 3.20.0
|
||||||
|
# through 3.20.3. The Alpine issue should be fixed in 3.21.0. It is
|
||||||
|
# not clear if 3.20.4+ will have the fix.
|
||||||
|
# Details
|
||||||
|
# https://gitlab.alpinelinux.org/alpine/aports/-/issues/16143
|
||||||
|
# https://github.com/ocrmypdf/OCRmyPDF/issues/1395
|
||||||
|
FROM alpine:3.19 AS base
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
@@ -10,40 +17,37 @@ RUN apk add --no-cache \
|
|||||||
python3 \
|
python3 \
|
||||||
zlib
|
zlib
|
||||||
|
|
||||||
FROM base as builder
|
FROM base AS builder
|
||||||
|
|
||||||
|
# Yes it really is python3-dev, and py3-package
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
git \
|
git \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
py3-pip
|
py3-pyarrow \
|
||||||
|
curl
|
||||||
# On arm64, we need to build cffi from source.
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
|
|
||||||
RUN if [ "${TARGETPLATFORM}" == "linux/arm64" ]; then \
|
|
||||||
apk add --no-cache \
|
|
||||||
build-base \
|
|
||||||
autoconf \
|
|
||||||
automake \
|
|
||||||
libtool \
|
|
||||||
zlib-dev \
|
|
||||||
libffi-dev \
|
|
||||||
cairo-dev \
|
|
||||||
pkgconfig \
|
|
||||||
; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN python3 -m venv .venv
|
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
|
||||||
|
|
||||||
RUN source .venv/bin/activate \
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade pip \
|
|
||||||
&& python3 -m pip install --no-cache-dir wheel \
|
RUN uv venv --system-site-packages .venv
|
||||||
&& python3 -m pip install --no-cache-dir .[test,webservice,watcher]
|
|
||||||
|
# Install the project's dependencies using the lockfile and settings
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
# Then, add the rest of the project source code and install it
|
||||||
|
# Installing separately from its dependencies allows optimal layer caching
|
||||||
|
COPY . /app
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen \
|
||||||
|
--extra test --extra webservice --extra watcher --no-dev \
|
||||||
|
--no-install-package pyarrow
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
|
|
||||||
@@ -61,22 +65,17 @@ RUN apk add --no-cache \
|
|||||||
tesseract-ocr-data-por \
|
tesseract-ocr-data-por \
|
||||||
tesseract-ocr-data-spa \
|
tesseract-ocr-data-spa \
|
||||||
ttf-droid \
|
ttf-droid \
|
||||||
|
ttyd \
|
||||||
unpaper \
|
unpaper \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /usr/local/lib/ /usr/local/lib/
|
COPY --from=builder --chown=app:app /app /app
|
||||||
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
|
||||||
|
|
||||||
COPY --from=builder /app/.venv/ /app/.venv/
|
RUN rm -rf /app/.git && \
|
||||||
|
ln -s /app/misc/webservice.py /app/webservice.py && \
|
||||||
COPY --from=builder /app/misc/webservice.py /app/
|
ln -s /app/misc/watcher.py /app/watcher.py
|
||||||
COPY --from=builder /app/misc/watcher.py /app/
|
|
||||||
|
|
||||||
# Copy minimal project files to get the test suite.
|
|
||||||
COPY --from=builder /app/pyproject.toml /app/README.md /app/
|
|
||||||
COPY --from=builder /app/tests /app/tests
|
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Installation, packaging, dependencies
|
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:
|
||||||
@@ -24,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.)
|
||||||
@@ -37,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:
|
||||||
@@ -47,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:
|
||||||
@@ -39,7 +39,7 @@ body:
|
|||||||
causing the issue. There's really no substitute for a test file.
|
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:
|
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 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.
|
- 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.
|
- 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
|
- Use ``qpdf --json yourfile.pdf`` to produce a JSON representation of your file that
|
||||||
|
|||||||
@@ -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
|
||||||
+80
-59
@@ -21,18 +21,13 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
os: [ubuntu-22.04, ubuntu-24.04]
|
||||||
|
python: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
|
tesseract_ppa: "ppa"
|
||||||
python: "3.10"
|
python: "3.10"
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-24.04
|
||||||
python: "3.11"
|
|
||||||
- os: ubuntu-22.04
|
|
||||||
python: "3.10"
|
|
||||||
tesseract5: true
|
|
||||||
- os: ubuntu-latest
|
|
||||||
python: "3.12"
|
|
||||||
tesseract5: true
|
|
||||||
- os: ubuntu-latest
|
|
||||||
python: "pypy3.10"
|
python: "pypy3.10"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -44,16 +39,20 @@ 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@v5
|
- name: Install uv
|
||||||
name: Setup Python
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.5.x"
|
||||||
|
|
||||||
|
- name: "Set up Python"
|
||||||
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
cache: "pip"
|
|
||||||
|
|
||||||
- name: Install Tesseract 5
|
- name: Install Tesseract from PPA
|
||||||
if: matrix.tesseract5
|
if: matrix.tesseract_ppa == 'ppa'
|
||||||
run: |
|
run: |
|
||||||
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
|
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr5.3
|
||||||
|
|
||||||
- name: Install common packages
|
- name: Install common packages
|
||||||
run: |
|
run: |
|
||||||
@@ -61,6 +60,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 \
|
||||||
@@ -84,8 +84,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip wheel
|
uv sync --extra test --no-dev
|
||||||
python -m pip install --prefer-binary .[test]
|
|
||||||
|
|
||||||
- name: Report versions
|
- name: Report versions
|
||||||
run: |
|
run: |
|
||||||
@@ -93,14 +92,16 @@ jobs:
|
|||||||
gs --version
|
gs --version
|
||||||
pngquant --version
|
pngquant --version
|
||||||
unpaper --version
|
unpaper --version
|
||||||
img2pdf --version
|
uv run --no-dev img2pdf --version
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
env_vars: OS,PYTHON
|
env_vars: OS,PYTHON
|
||||||
@@ -110,8 +111,8 @@ jobs:
|
|||||||
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", "3.13"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OS: ${{ matrix.os }}
|
OS: ${{ matrix.os }}
|
||||||
@@ -131,34 +132,38 @@ jobs:
|
|||||||
ghostscript \
|
ghostscript \
|
||||||
jbig2enc \
|
jbig2enc \
|
||||||
openjpeg \
|
openjpeg \
|
||||||
openssl \
|
|
||||||
pngquant \
|
pngquant \
|
||||||
tesseract
|
tesseract
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- name: Install uv
|
||||||
name: Setup Python
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.5.x"
|
||||||
|
|
||||||
|
- name: "Set up Python"
|
||||||
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
cache: "pip"
|
|
||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip wheel
|
uv sync --extra test --no-dev
|
||||||
python -m pip install --prefer-binary .[test]
|
|
||||||
|
|
||||||
- name: Report versions
|
- name: Report versions
|
||||||
run: |
|
run: |
|
||||||
tesseract --version
|
tesseract --version
|
||||||
gs --version
|
gs --version
|
||||||
pngquant --version
|
pngquant --version
|
||||||
img2pdf --version
|
uv run --no-dev img2pdf --version
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
env_vars: OS,PYTHON
|
env_vars: OS,PYTHON
|
||||||
@@ -169,7 +174,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [windows-latest]
|
os: [windows-latest]
|
||||||
python: ["3.10", "3.11", "3.12"]
|
python: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OS: ${{ matrix.os }}
|
OS: ${{ matrix.os }}
|
||||||
@@ -180,11 +185,15 @@ 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@v5
|
- name: Install uv
|
||||||
name: Setup Python
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.5.x"
|
||||||
|
|
||||||
|
- name: "Set up Python"
|
||||||
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
cache: "pip"
|
|
||||||
|
|
||||||
- name: Install system packages
|
- name: Install system packages
|
||||||
run: |
|
run: |
|
||||||
@@ -193,15 +202,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip wheel
|
uv sync --extra test --no-dev
|
||||||
python -m pip install --prefer-binary .[test]
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
python -m pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
env_vars: OS,PYTHON
|
env_vars: OS,PYTHON
|
||||||
@@ -214,16 +224,14 @@ 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@v5
|
- name: Install uv
|
||||||
name: Setup Python
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
version: "0.5.x"
|
||||||
cache: "pip"
|
|
||||||
|
|
||||||
- name: Make wheels and sdist
|
- name: Make wheels and sdist
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip wheel build
|
uv build --sdist --wheel
|
||||||
python -m build --sdist --wheel
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -251,29 +259,45 @@ jobs:
|
|||||||
|
|
||||||
create_release:
|
create_release:
|
||||||
name: Create GitHub release
|
name: Create GitHub release
|
||||||
needs: [wheel_sdist_linux, test_linux, test_macos, test_windows]
|
needs: [upload_pypi]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
|
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
|
||||||
permissions:
|
permissions:
|
||||||
# Required to create a release
|
# Required to create a release
|
||||||
contents: write
|
contents: write
|
||||||
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: artifact
|
name: artifact
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- name: Create Release
|
- name: Sign the dists with Sigstore
|
||||||
id: create-release
|
uses: sigstore/gh-action-sigstore-python@v3.0.0
|
||||||
uses: shogo82148/actions-create-release@v1
|
|
||||||
|
|
||||||
- name: Upload Assets
|
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
inputs: |
|
||||||
asset_path: |
|
|
||||||
./dist/*.whl
|
|
||||||
./dist/*.tar.gz
|
./dist/*.tar.gz
|
||||||
|
./dist/*.whl
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release create
|
||||||
|
'${{ github.ref_name }}'
|
||||||
|
--repo '${{ github.repository }}'
|
||||||
|
--notes ""
|
||||||
|
|
||||||
|
- name: Upload artifact signatures to GitHub Release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
# Upload to GitHub Release using the `gh` CLI.
|
||||||
|
# `dist/` contains the built packages, and the
|
||||||
|
# sigstore-produced signatures and certificates.
|
||||||
|
run: |
|
||||||
|
gh release upload
|
||||||
|
'${{ github.ref_name }}' dist/**
|
||||||
|
--repo '${{ github.repository }}'
|
||||||
|
|
||||||
docker_ubuntu:
|
docker_ubuntu:
|
||||||
name: Build Ubuntu-based Docker image
|
name: Build Ubuntu-based Docker image
|
||||||
@@ -352,9 +376,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 +387,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 .
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
name: Remove Triage Label on Reply
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types:
|
||||||
|
- created
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
remove-triage-label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check if comment is by the repository owner
|
||||||
|
id: check_comment
|
||||||
|
run: |
|
||||||
|
echo "::set-output name=is_owner::$(
|
||||||
|
if [[ '${{ github.event.comment.user.login }}' == 'jbarlow83' ]]; then
|
||||||
|
echo 'true';
|
||||||
|
else
|
||||||
|
echo 'false';
|
||||||
|
fi
|
||||||
|
)"
|
||||||
|
|
||||||
|
- name: Remove 'triage' label
|
||||||
|
if: ${{ steps.check_comment.outputs.is_owner == 'true' }}
|
||||||
|
uses: actions-ecosystem/action-remove-labels@v1
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
labels: triage
|
||||||
@@ -44,3 +44,4 @@ docs/_build/
|
|||||||
docs/_static/
|
docs/_static/
|
||||||
docs/_templates/
|
docs/_templates/
|
||||||
docs/Makefile
|
docs/Makefile
|
||||||
|
src/ocrmypdf/_version.py
|
||||||
@@ -74,7 +74,6 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
|
|||||||
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
||||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||||
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
||||||
| Conda | ``conda install ocrmypdf`` |
|
|
||||||
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
||||||
|
|
||||||
For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for installation steps.
|
For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for installation steps.
|
||||||
@@ -113,9 +112,33 @@ Our [documentation is served on Read the Docs](https://ocrmypdf.readthedocs.io/e
|
|||||||
|
|
||||||
Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) page, and follow the issue template for quick response.
|
Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) page, and follow the issue template for quick response.
|
||||||
|
|
||||||
|
## Feature demo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add an OCR layer and convert to PDF/A
|
||||||
|
ocrmypdf input.pdf output.pdf
|
||||||
|
|
||||||
|
# Convert an image to single page PDF
|
||||||
|
ocrmypdf input.jpg output.pdf
|
||||||
|
|
||||||
|
# Add OCR to a file in place (only modifies file on success)
|
||||||
|
ocrmypdf myfile.pdf myfile.pdf
|
||||||
|
|
||||||
|
# OCR with non-English languages (look up your language's ISO 639-3 code)
|
||||||
|
ocrmypdf -l fra LeParisien.pdf LeParisien.pdf
|
||||||
|
|
||||||
|
# OCR multilingual documents
|
||||||
|
ocrmypdf -l eng+fra Bilingual-English-French.pdf Bilingual-English-French.pdf
|
||||||
|
|
||||||
|
# Deskew (straighten crooked pages)
|
||||||
|
ocrmypdf --deskew input.pdf output.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
For more features, see the [documentation](https://ocrmypdf.readthedocs.io/en/latest/index.html).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
In addition to the required Python version (3.8+), OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
|
In addition to the required Python version, OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
|
||||||
|
|
||||||
## Press & Media
|
## Press & Media
|
||||||
|
|
||||||
|
|||||||
+60
-3
@@ -228,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
|
||||||
=========================
|
=========================
|
||||||
|
|
||||||
@@ -392,7 +445,7 @@ Debugging the intermediate files
|
|||||||
OCRmyPDF normally saves its intermediate results to a temporary folder
|
OCRmyPDF normally saves its intermediate results to a temporary folder
|
||||||
and deletes this folder when it exits, whether it succeeded or failed.
|
and deletes this folder when it exits, whether it succeeded or failed.
|
||||||
|
|
||||||
If the ``--keep-temporary-files`` (``-k```) argument is issued on the
|
If the ``--keep-temporary-files`` (``-k``) argument is issued on the
|
||||||
command line, OCRmyPDF will keep the temporary folder and print the location,
|
command line, OCRmyPDF will keep the temporary folder and print the location,
|
||||||
whether it succeeded or failed. An example message is:
|
whether it succeeded or failed. An example message is:
|
||||||
|
|
||||||
@@ -401,6 +454,10 @@ whether it succeeded or failed. An example message is:
|
|||||||
Temporary working files retained at:
|
Temporary working files retained at:
|
||||||
/tmp/ocrmypdf.io.u20wpz07
|
/tmp/ocrmypdf.io.u20wpz07
|
||||||
|
|
||||||
|
When OCRmyPDF is launched as a snap, this corresponds to the snap filesystem, for instance:
|
||||||
|
|
||||||
|
/tmp/snap-private-tmp/snap.ocrmypdf/tmp/ocrmypdf.io.u20wpz07
|
||||||
|
|
||||||
The organization of this folder is an implementation detail and subject
|
The organization of this folder is an implementation detail and subject
|
||||||
to change between releases. However the general organization is that
|
to change between releases. However the general organization is that
|
||||||
working files on a per page basis have the page number as a prefix
|
working files on a per page basis have the page number as a prefix
|
||||||
@@ -412,9 +469,9 @@ suffix indicates the file type. Some important files include:
|
|||||||
on arguments this may differ from the presentation image
|
on arguments this may differ from the presentation image
|
||||||
- ``_pp_deskew.png`` - the image, after deskewing
|
- ``_pp_deskew.png`` - the image, after deskewing
|
||||||
- ``_pp_clean.png`` - the image, after cleaning with unpaper
|
- ``_pp_clean.png`` - the image, after cleaning with unpaper
|
||||||
- ``_ocr_tess.pdf`` - the OCR file; appears as a blank page with invisible
|
- ``_ocr_hocr.pdf`` - the OCR file; appears as a blank page with invisible
|
||||||
text embedded
|
text embedded
|
||||||
- ``_ocr_tess.txt`` - the OCR text (not necessarily all text on the page,
|
- ``_ocr_hocr.txt`` - the OCR text (not necessarily all text on the page,
|
||||||
if the page is mixed format)
|
if the page is mixed format)
|
||||||
- ``fix_docinfo.pdf`` - a temporary file created to fix the PDF DocumentInfo
|
- ``fix_docinfo.pdf`` - a temporary file created to fix the PDF DocumentInfo
|
||||||
data structure
|
data structure
|
||||||
|
|||||||
+3
-18
@@ -9,27 +9,12 @@ 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 be mainly of interest to plugin developers.
|
should be mainly of interest to plugin developers.
|
||||||
|
|
||||||
ocrmypdf
|
ocrmypdf.api
|
||||||
========
|
============
|
||||||
|
|
||||||
.. autoclass:: ocrmypdf.PageContext
|
.. automodule:: ocrmypdf.api
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
.. autoclass:: ocrmypdf.PdfContext
|
|
||||||
: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
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -44,7 +44,7 @@ place, and printing each filename in between runs:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
find . -printf '%p\n' -name '*.pdf' -exec ocrmypdf '{}' '{}' \;
|
find . -name '*.pdf' -printf '%p\n' -exec ocrmypdf '{}' '{}' \;
|
||||||
|
|
||||||
This only runs one ``ocrmypdf`` process at a time. This variation uses
|
This only runs one ``ocrmypdf`` process at a time. This variation uses
|
||||||
``find`` to create a directory list and ``parallel`` to parallelize runs
|
``find`` to create a directory list and ``parallel`` to parallelize runs
|
||||||
@@ -135,7 +135,7 @@ Users may need to customize the script to meet their requirements.
|
|||||||
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed original file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
|
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed original file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
|
||||||
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
|
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
|
||||||
"OCR_DESKEW", "Apply deskew to crooked input PDFs"
|
"OCR_DESKEW", "Apply deskew to crooked input PDFs"
|
||||||
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true}'``."
|
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true, ""optimize"": ""3""}'``."
|
||||||
"OCR_POLL_NEW_FILE_SECONDS", "Polling interval"
|
"OCR_POLL_NEW_FILE_SECONDS", "Polling interval"
|
||||||
"OCR_LOGLEVEL", "Level of log messages to report"
|
"OCR_LOGLEVEL", "Level of log messages to report"
|
||||||
|
|
||||||
|
|||||||
+14
-11
@@ -30,6 +30,8 @@
|
|||||||
#
|
#
|
||||||
# needs_sphinx = '1.0'
|
# needs_sphinx = '1.0'
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings. They can be
|
# Add any Sphinx extension module names here, as strings. They can be
|
||||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||||
# ones.
|
# ones.
|
||||||
@@ -38,11 +40,12 @@ extensions = [
|
|||||||
'sphinx.ext.intersphinx',
|
'sphinx.ext.intersphinx',
|
||||||
'sphinx.ext.autosummary',
|
'sphinx.ext.autosummary',
|
||||||
'sphinx.ext.napoleon',
|
'sphinx.ext.napoleon',
|
||||||
|
'sphinx.ext.imgconverter', # PDF docs needs this for SVG to PNG conversion
|
||||||
'sphinx_issues',
|
'sphinx_issues',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Extension settings
|
# Extension settings
|
||||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
|
||||||
napoleon_use_rtype = False
|
napoleon_use_rtype = False
|
||||||
issues_github_path = "ocrmypdf/OCRmyPDF"
|
issues_github_path = "ocrmypdf/OCRmyPDF"
|
||||||
|
|
||||||
@@ -50,10 +53,7 @@ issues_github_path = "ocrmypdf/OCRmyPDF"
|
|||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
|
|
||||||
# The suffix(es) of source filenames.
|
# The suffix(es) of source filenames.
|
||||||
# You can specify multiple suffix as a list of string:
|
source_suffix = {'.rst': 'restructuredtext', '.md': 'markdown'}
|
||||||
#
|
|
||||||
# source_suffix = ['.rst', '.md']
|
|
||||||
source_suffix = '.rst'
|
|
||||||
|
|
||||||
# The encoding of source files.
|
# The encoding of source files.
|
||||||
#
|
#
|
||||||
@@ -64,8 +64,11 @@ master_doc = 'index'
|
|||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = 'ocrmypdf'
|
project = 'ocrmypdf'
|
||||||
|
|
||||||
|
year = str(datetime.date.today().year)
|
||||||
copyright = (
|
copyright = (
|
||||||
'2023, James R. Barlow. Licensed under Creative Commons Attribution-ShareAlike 4.0.'
|
f'{year}, James R. Barlow. ',
|
||||||
|
'Licensed under Creative Commons Attribution-ShareAlike 4.0.',
|
||||||
)
|
)
|
||||||
author = 'James R. Barlow'
|
author = 'James R. Barlow'
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ if on_rtd:
|
|||||||
|
|
||||||
MOCK_MODULES = [
|
MOCK_MODULES = [
|
||||||
'pikepdf',
|
'pikepdf',
|
||||||
|
'pikepdf.canvas',
|
||||||
'pikepdf.models',
|
'pikepdf.models',
|
||||||
'pikepdf.models.metadata',
|
'pikepdf.models.metadata',
|
||||||
]
|
]
|
||||||
@@ -108,7 +112,7 @@ version = '.'.join(release.split('.')[:2])
|
|||||||
#
|
#
|
||||||
# This is also used if you do content translation via gettext catalogs.
|
# This is also used if you do content translation via gettext catalogs.
|
||||||
# Usually you set "language" from the command line for these cases.
|
# Usually you set "language" from the command line for these cases.
|
||||||
language = None
|
language = 'en'
|
||||||
|
|
||||||
# There are two options for replacing |today|: either, you set today to some
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
# non-false value, then it is used:
|
# non-false value, then it is used:
|
||||||
@@ -158,19 +162,18 @@ todo_include_todos = False
|
|||||||
|
|
||||||
# -- Options for HTML output ----------------------------------------------
|
# -- Options for HTML output ----------------------------------------------
|
||||||
|
|
||||||
import sphinx_rtd_theme
|
import sphinx_rtd_theme # noqa: F401
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
# a list of builtin themes.
|
# a list of builtin themes.
|
||||||
#
|
#
|
||||||
html_theme = 'sphinx_rtd_theme'
|
html_theme = 'sphinx_rtd_theme'
|
||||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
|
||||||
|
|
||||||
# Theme options are theme-specific and customize the look and feel of a theme
|
# Theme options are theme-specific and customize the look and feel of a theme
|
||||||
# further. For a list of options available for each theme, see the
|
# further. For a list of options available for each theme, see the
|
||||||
# documentation.
|
# documentation.
|
||||||
#
|
#
|
||||||
html_theme_options = {'display_version': False}
|
html_theme_options = {}
|
||||||
|
|
||||||
# Add any paths that contain custom themes here, relative to this directory.
|
# Add any paths that contain custom themes here, relative to this directory.
|
||||||
# html_theme_path = []
|
# html_theme_path = []
|
||||||
@@ -198,7 +201,7 @@ html_theme_options = {'display_version': False}
|
|||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||||
html_static_path = ['_static']
|
# html_static_path = ['_static']
|
||||||
|
|
||||||
# Add any extra paths that contain custom files (such as robots.txt or
|
# Add any extra paths that contain custom files (such as robots.txt or
|
||||||
# .htaccess) here, relative to this directory. These files are copied
|
# .htaccess) here, relative to this directory. These files are copied
|
||||||
|
|||||||
+1
-1
@@ -399,7 +399,7 @@ Some users may consider enabling lossy JBIG2. See: :ref:`jbig2-lossy`.
|
|||||||
Digitally signed PDFs
|
Digitally signed PDFs
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
OCRmyPDF cannot preserve digital signatures in PDFs and also add to OCR to them.
|
OCRmyPDF cannot preserve digital signatures in PDFs and also add OCR to them.
|
||||||
By default, it will refuse to modify a signed PDF regardless of other settings. You can
|
By default, it will refuse to modify a signed PDF regardless of other settings. You can
|
||||||
override this behavior with ``--invalidate-digital-signatures``; as the name suggests,
|
override this behavior with ``--invalidate-digital-signatures``; as the name suggests,
|
||||||
any digital signatures will be invalidated.
|
any digital signatures will be invalidated.
|
||||||
|
|||||||
+10
-4
@@ -35,14 +35,14 @@ execute the image:
|
|||||||
docker run hello-world
|
docker run hello-world
|
||||||
|
|
||||||
.. list-table:: Docker images
|
.. list-table:: Docker images
|
||||||
:width: 30 20 50
|
:widths: 30 20 50
|
||||||
:header-rows: 1
|
:header-rows: 1
|
||||||
|
|
||||||
* - Image
|
* - 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
|
||||||
@@ -65,7 +65,13 @@ The ``ocrmypdf`` image is also available, but is deprecated and will be removed
|
|||||||
in the future.
|
in the future.
|
||||||
|
|
||||||
OCRmyPDF will use all available CPU cores. See the Docker documentation for
|
OCRmyPDF will use all available CPU cores. See the Docker documentation for
|
||||||
`adjusting memory and CPU on other platforms <https://docs.docker.com/config/containers/resource_constraints/>`__.
|
`adjusting memory and CPU on other platforms <https://docs.docker.com/config/containers/resource_constraints/>`__
|
||||||
|
if you are using Docker on macOS or Windows, where you may need to manually assign
|
||||||
|
more resources. On Linux, all resources will be available automatically.
|
||||||
|
|
||||||
|
The underlying operating system and other details in Docker images are considered
|
||||||
|
implementation details and **subject to change at minor releases**. If you are
|
||||||
|
modifying the image, you should pin the version you intend to use.
|
||||||
|
|
||||||
Using the Docker image on the command line
|
Using the Docker image on the command line
|
||||||
==========================================
|
==========================================
|
||||||
@@ -81,7 +87,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
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ These platforms have one-liner installs:
|
|||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| FreeBSD | ``pkg install textproc/py-ocrmypdf`` |
|
| FreeBSD | ``pkg install textproc/py-ocrmypdf`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| Conda (WSL, macOS, Linux) | ``conda install ocrmypdf`` |
|
|
||||||
+-------------------------------+-----------------------------------------+
|
|
||||||
| Snap (snapcraft packaging) | ``snap install ocrmypdf`` |
|
| Snap (snapcraft packaging) | ``snap install ocrmypdf`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
|
|
||||||
@@ -224,7 +222,7 @@ 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.
|
||||||
|
|
||||||
@@ -341,7 +339,7 @@ OCRmyPDF is includes in MacPorts:
|
|||||||
sudo port install ocrmypdf
|
sudo port install ocrmypdf
|
||||||
|
|
||||||
Note that while this will install tesseract you will need to install
|
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>`__.
|
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
|
||||||
----------------------------
|
----------------------------
|
||||||
@@ -640,8 +638,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 editable 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
|
||||||
@@ -681,7 +678,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]
|
||||||
|
|||||||
+1
-3
@@ -37,8 +37,6 @@ For all other platforms, you would need to build the JBIG2 encoder from source:
|
|||||||
./configure && make
|
./configure && make
|
||||||
[sudo] make install
|
[sudo] make install
|
||||||
|
|
||||||
.. _jbig2-lossy:
|
|
||||||
|
|
||||||
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. For this to work, please make sure to install
|
as libtool and leptonica-devel. For this to work, please make sure to install
|
||||||
@@ -48,8 +46,8 @@ installed.
|
|||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
[sudo] apt install autotools-dev automake libtool libleptonica-dev
|
[sudo] apt install autotools-dev automake libtool libleptonica-dev
|
||||||
..
|
|
||||||
|
|
||||||
|
.. _jbig2-lossy:
|
||||||
|
|
||||||
Lossy mode JBIG2
|
Lossy mode JBIG2
|
||||||
================
|
================
|
||||||
|
|||||||
+17
-1
@@ -68,6 +68,22 @@ 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``.
|
||||||
|
|
||||||
|
Arch Linux
|
||||||
|
----------
|
||||||
|
|
||||||
|
.. 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
|
Gentoo
|
||||||
------
|
------
|
||||||
|
|
||||||
@@ -122,4 +138,4 @@ Custom language packs
|
|||||||
If you have fine-tuned or trained Tesseract and generated custom trained data, you can
|
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
|
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
|
then use the ``-l customlang`` argument to tell OCRmyPDF to pass that language on to
|
||||||
Tesseract.
|
Tesseract.
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ on ARM and x86_64. Performance may be poor on other processor architectures.
|
|||||||
Versioning scheme
|
Versioning scheme
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
OCRmyPDF uses setuptools-scm for versioning, which derives the version from
|
OCRmyPDF uses hatch-vcs for versioning, which derives the version from
|
||||||
Git as a single source of truth. This may be unsuitable for some distributions, e.g.
|
Git as a single source of truth. This may be unsuitable for some distributions, e.g.
|
||||||
to indicate that your distribution modifies OCRmyPDF in some way.
|
to indicate that your distribution modifies OCRmyPDF in some way.
|
||||||
|
|
||||||
You can patch the ``__version__`` variable in ``src/ocrmypdf/_version.py`` if
|
You can patch the ``__version__`` variable in ``src/ocrmypdf/_version.py`` if
|
||||||
necessary.
|
necessary, or set the environment variable ``SETUPTOOLS_SCM_PRETEND_VERSION``
|
||||||
|
to the required version, if you need to override versioning for some reason.
|
||||||
|
|
||||||
jbig2enc
|
jbig2enc
|
||||||
--------
|
--------
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@ perform other possible optimizations such as deduplicating resources,
|
|||||||
consolidating fonts, simplifying vector drawings, or anything of that nature.
|
consolidating fonts, simplifying vector drawings, or anything of that nature.
|
||||||
|
|
||||||
.. list-table:: Title
|
.. list-table:: Title
|
||||||
:widths: 33 6 60
|
:widths: 33 6 60
|
||||||
:header-rows: 1
|
:header-rows: 1
|
||||||
|
|
||||||
* - Optimization level
|
* - Optimization level
|
||||||
- Shorthand
|
- Shorthand
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
..
|
..
|
||||||
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
|
|
||||||
|
.. _security:
|
||||||
|
|
||||||
===================
|
===================
|
||||||
PDF security issues
|
PDF security issues
|
||||||
===================
|
===================
|
||||||
|
|||||||
+9
-12
@@ -29,6 +29,9 @@ conventions. Note that: plugins installed with as setuptools entrypoints are
|
|||||||
not checked currently, because OCRmyPDF assumes you may not want to enable
|
not checked currently, because OCRmyPDF assumes you may not want to enable
|
||||||
plugins for all files.
|
plugins for all files.
|
||||||
|
|
||||||
|
See [OCRmyPDF-EasyOCR](https://github.com/ocrmypdf/OCRmyPDF-EasyOCR) for an
|
||||||
|
example of a straightforward, fully working plugin.
|
||||||
|
|
||||||
Script plugins
|
Script plugins
|
||||||
==============
|
==============
|
||||||
|
|
||||||
@@ -70,14 +73,15 @@ similar to ``pytest`` packages such as ``pytest-cov`` (the package) and
|
|||||||
module), just like pytest plugins. At the same time, please make it clear
|
module), just like pytest plugins. At the same time, please make it clear
|
||||||
that your package is not official.
|
that your package is not official.
|
||||||
|
|
||||||
Setuptools plugins
|
Plugins
|
||||||
==================
|
=======
|
||||||
|
|
||||||
You can also create a plugin that OCRmyPDF will always automatically load if both are
|
You can also create a plugin that OCRmyPDF will always automatically load if both are
|
||||||
installed in the same virtual environment, using a setuptools entrypoint.
|
installed in the same virtual environment, using a project entrypoint.
|
||||||
|
OCRmyPDF uses the entrypoint namespace "ocrmypdf".
|
||||||
|
|
||||||
Your package's ``pyproject.toml`` would need to contain the following, for a plugin
|
For example, ``pyproject.toml`` would need to contain the following, for a plugin named
|
||||||
named ``ocrmypdf-exampleplugin``:
|
``ocrmypdf-exampleplugin``:
|
||||||
|
|
||||||
.. code-block:: toml
|
.. code-block:: toml
|
||||||
|
|
||||||
@@ -87,13 +91,6 @@ named ``ocrmypdf-exampleplugin``:
|
|||||||
[project.entry-points."ocrmypdf"]
|
[project.entry-points."ocrmypdf"]
|
||||||
exampleplugin = "exampleplugin.pluginmodule"
|
exampleplugin = "exampleplugin.pluginmodule"
|
||||||
|
|
||||||
.. code-block:: ini
|
|
||||||
|
|
||||||
# equivalent setup.cfg
|
|
||||||
[options.entry_points]
|
|
||||||
ocrmypdf =
|
|
||||||
exampleplugin = exampleplugin.pluginmodule
|
|
||||||
|
|
||||||
Plugin requirements
|
Plugin requirements
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,137 @@ OCRmyPDF typically supports the three most recent Python versions.
|
|||||||
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||||
|
|
||||||
|
|
||||||
|
v16.8.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Upgraded webservice.py demonstration using streamlit. It's now possible to
|
||||||
|
exercise most of OCRmyPDF's functionality in a simple web UI.
|
||||||
|
- Added cache to Dockerfiles to improve build speed.
|
||||||
|
- Fixed numerous formatting errors in the documentation that prevented some
|
||||||
|
parts of documentation from generating correctly.
|
||||||
|
- Improved OCR text rendering by suppressing negative-width spaces. Thanks
|
||||||
|
@pajowu. :issue:`1446`
|
||||||
|
- Improved detecting of invisible text when using `--redo-ocr`. Thanks
|
||||||
|
@pajowu. :issue:`1448``
|
||||||
|
|
||||||
|
v16.7.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed further issues with Docker build and updated some versions.
|
||||||
|
- Main Docker image returned to Ubuntu 24.04 since the fix in v16.6.2 resolved
|
||||||
|
that concern.
|
||||||
|
- Code that previously sent Ghostscript output to stdout has been changed to
|
||||||
|
output to temporary files, since Ghostscript was doing that anyway internally.
|
||||||
|
This is a modest efficiency improvement.
|
||||||
|
- Fixed an issue with debug log output being parsed as rich markup. :issue:`1444`
|
||||||
|
|
||||||
|
v16.6.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Remove invalid hyperlink annotations to satisfy Ghostscript 10.x during PDF/A
|
||||||
|
conversion. :issue:`1425`
|
||||||
|
|
||||||
|
v16.6.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed some issues with Docker build, such as removing unnecessary content and using
|
||||||
|
a stable Tesseract version.
|
||||||
|
- Reverted Docker image to Ubuntu 22.04 to access older/more stable Ghostscript
|
||||||
|
for now.
|
||||||
|
- Clarified batch commands in documentation.
|
||||||
|
- Fixed an issue with JSON serialization and pickling of HOCRResult. :issue:`1427`
|
||||||
|
|
||||||
|
v16.6.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed an issue where damaged PDFs would fail with ``--redo-ocr``. :issue:`1403`
|
||||||
|
- Fixed an error that prevented JBIG2 optimization on Windows if the image
|
||||||
|
was optimized in an earlier step. :issue:`1396`
|
||||||
|
- Fixed an error detecting the version of unpaper 7.0.0. :issue:`1409`
|
||||||
|
- Fixed a performance regression when scanning pages. :issue:`1378`. Thanks @aliemjay.
|
||||||
|
- Fixed Alpine Docker image by enforcing Alpine 3.19. Alpine 3.20 includes a
|
||||||
|
defective version of Tesseract OCR and so is not usable.
|
||||||
|
- Upgraded Ubuntu Docker image to use Ubuntu 24.04.
|
||||||
|
- Build and test scripts/actions switched to uv.
|
||||||
|
- When running in a container, we now remind the user that temporary folders
|
||||||
|
are inside the container and may not be accessible.
|
||||||
|
- Fixed Linux test coverage matrix, which was missing some key versions.
|
||||||
|
|
||||||
|
v16.5.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed issue with interpreting PDFs that have images with array masks.
|
||||||
|
:issue:`1377`
|
||||||
|
- Enabled testing on Python 3.13.
|
||||||
|
- Fixed a test that did not work correctly but still passed. :issue:`1382`
|
||||||
|
- Improved "PDF/A conversion failed" warning message to better describe implications.
|
||||||
|
- Updated documentation to better explain OCR_JSON_SETTINGS in batch processing.
|
||||||
|
- Build backend changed from setuptools to hatchling.
|
||||||
|
|
||||||
|
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
|
v16.2.0
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
"""This is a simple web service/HTTP wrapper for OCRmyPDF.
|
||||||
|
|
||||||
|
This may be more convenient than the command line tool for some Docker users.
|
||||||
|
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
|
||||||
|
OCRmyPDF is under GPLv3, this file is distributed under the Affero GPLv3+ license,
|
||||||
|
to emphasize that SaaS deployments should make sure they comply with
|
||||||
|
Ghostscript's license as well as OCRmyPDF's.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib
|
||||||
|
from functools import partial
|
||||||
|
from operator import getitem
|
||||||
|
from pathlib import Path
|
||||||
|
from shutil import which
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
import streamlit as st
|
||||||
|
from port_for import get_port
|
||||||
|
from streamlit.components.v1 import iframe
|
||||||
|
|
||||||
|
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def get_host_url_with_port(port: int) -> str:
|
||||||
|
"""Get the host URL for the web service. Hacky."""
|
||||||
|
host_url = st.context.headers["host"]
|
||||||
|
try:
|
||||||
|
host, _streamlit_port = host_url.split(":", maxsplit=1)
|
||||||
|
except ValueError:
|
||||||
|
host = host_url
|
||||||
|
return f"//{host}:{port}" # Use the same protocol
|
||||||
|
|
||||||
|
|
||||||
|
st.title("OCRmyPDF Web Service")
|
||||||
|
|
||||||
|
if not which("ttyd"):
|
||||||
|
st.error("Missing dependency: ttyd. Please install ttyd to the local environment.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
uploaded = st.file_uploader("Upload input PDF or image", type=["pdf"], key="file")
|
||||||
|
|
||||||
|
mode = st.selectbox("Mode", options=["normal", "skip-text", "force-ocr", "redo-ocr"])
|
||||||
|
|
||||||
|
with st.expander("Input options"):
|
||||||
|
invalidate_digital_signatures = st.checkbox(
|
||||||
|
"Invalidate digital signatures", value=False
|
||||||
|
)
|
||||||
|
language = st.selectbox("Language", options=["eng", "deu", "fra", "spa"])
|
||||||
|
|
||||||
|
image_dpi = st.slider(
|
||||||
|
"Image DPI", value=300, key="image_dpi", min_value=1, max_value=5000, step=50
|
||||||
|
)
|
||||||
|
with st.expander("Preprocessing"):
|
||||||
|
skip_big = st.checkbox("Skip OCR on big pages", value=False, key="skip_big")
|
||||||
|
oversample = st.slider("Oversample", min_value=0, max_value=5000, value=0, step=50)
|
||||||
|
rotate_pages = st.checkbox("Rotate pages", value=False, key="rotate")
|
||||||
|
deskew = st.checkbox("Deskew pages", value=False, key="deskew")
|
||||||
|
clean = st.checkbox("Clean pages before OCR", value=False, key="clean")
|
||||||
|
clean_final = st.checkbox("Clean final", value=False, key="clean_final")
|
||||||
|
remove_vectors = st.checkbox("Remove vectors", value=False, key="remove_vectors")
|
||||||
|
|
||||||
|
|
||||||
|
with st.expander("Output options"):
|
||||||
|
output_type = st.selectbox(
|
||||||
|
"Output type", options=["pdfa", "pdfa", "pdfa-1", "pdfa-2", "pdfa-3", "none"]
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_renderer = st.selectbox(
|
||||||
|
"PDF rendereer", options=["auto", "hocr", "hocrdebug", "sandwich"]
|
||||||
|
)
|
||||||
|
|
||||||
|
optimize = st.selectbox("Optimize", options=["0", "1", "2", "3"])
|
||||||
|
|
||||||
|
st.selectbox("PDF/A compression", options=["auto", "jpeg", "lossless"])
|
||||||
|
|
||||||
|
with st.expander("Metadata"):
|
||||||
|
title = author = keywords = subject = None
|
||||||
|
if uploaded:
|
||||||
|
with pikepdf.open(uploaded) as pdf, pdf.open_metadata() as meta:
|
||||||
|
st.code(str(meta), language="xml")
|
||||||
|
title = st.text_input("Title", value=meta.get('dc:title', ''))
|
||||||
|
author = st.text_input("Author", value=meta.get('dc:creator', ''))
|
||||||
|
keywords = st.text_input("Keywords", value=meta.get('dc:subject', ''))
|
||||||
|
subject = st.text_input("Subject", value=meta.get('dc:description', ''))
|
||||||
|
|
||||||
|
|
||||||
|
with st.expander("Optimization after OCR"):
|
||||||
|
jpeg_quality = st.slider(
|
||||||
|
"JPEG quality", min_value=0, max_value=100, value=75, key="jpeg_quality"
|
||||||
|
)
|
||||||
|
png_quality = st.slider(
|
||||||
|
"PNG quality", min_value=0, max_value=100, value=75, key="png_quality"
|
||||||
|
)
|
||||||
|
jbig2_lossy = st.checkbox("JBIG2 lossy (dangerous)", value=False, key="jbig2_lossy")
|
||||||
|
jbig2_threshold = st.number_input("JBIG2 threshold", value=0, key="jbig2_threshold")
|
||||||
|
|
||||||
|
with st.expander("Advanced options"):
|
||||||
|
jobs = st.slider(
|
||||||
|
"Threads",
|
||||||
|
min_value=1,
|
||||||
|
max_value=os.cpu_count(),
|
||||||
|
value=os.cpu_count(),
|
||||||
|
key="threads",
|
||||||
|
)
|
||||||
|
pages = st.text_input(
|
||||||
|
"Pages", value="", help="Comma-separated list of pages to process"
|
||||||
|
)
|
||||||
|
max_image_mpixels = st.number_input(
|
||||||
|
"Max image size",
|
||||||
|
value=250.0,
|
||||||
|
min_value=0.0,
|
||||||
|
help="Maximum image size in megapixels",
|
||||||
|
)
|
||||||
|
rotate_pages_threshold = st.number_input(
|
||||||
|
"Rotate pages threshold",
|
||||||
|
value=DEFAULT_ROTATE_PAGES_THRESHOLD,
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=1000.0,
|
||||||
|
help="Threshold for automatic page rotation",
|
||||||
|
)
|
||||||
|
fast_web_view = st.number_input(
|
||||||
|
"Fast web view",
|
||||||
|
value=1.0,
|
||||||
|
min_value=0.0,
|
||||||
|
help="Linearize files above this size in MB",
|
||||||
|
)
|
||||||
|
continue_on_soft_render_error = st.checkbox(
|
||||||
|
"Continue on soft render error", value=True
|
||||||
|
)
|
||||||
|
verbose_labels = ["quiet", "default", "debug", "debug_all"]
|
||||||
|
verbose = st.selectbox(
|
||||||
|
"Verbosity level",
|
||||||
|
options=[-1, 0, 1, 2],
|
||||||
|
index=1,
|
||||||
|
format_func=partial(getitem, verbose_labels),
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded:
|
||||||
|
args = []
|
||||||
|
if mode and mode != 'normal':
|
||||||
|
args.append(f"--{mode}")
|
||||||
|
if language:
|
||||||
|
args.append(f"--language={language}")
|
||||||
|
if not uploaded.name.lower().endswith(".pdf") and image_dpi:
|
||||||
|
args.append(f"--image-dpi={image_dpi}")
|
||||||
|
if skip_big:
|
||||||
|
args.append("--skip-big")
|
||||||
|
if oversample:
|
||||||
|
args.append(f"--oversample={oversample}")
|
||||||
|
if rotate_pages:
|
||||||
|
args.append("--rotate-pages")
|
||||||
|
if deskew:
|
||||||
|
args.append("--deskew")
|
||||||
|
if clean:
|
||||||
|
args.append("--clean")
|
||||||
|
if clean_final:
|
||||||
|
args.append("--clean-final")
|
||||||
|
if remove_vectors:
|
||||||
|
args.append("--remove-vectors")
|
||||||
|
if output_type:
|
||||||
|
args.append(f"--output-type={output_type}")
|
||||||
|
if pdf_renderer:
|
||||||
|
args.append(f"--pdf-renderer={pdf_renderer}")
|
||||||
|
if optimize:
|
||||||
|
args.append(f"--optimize={optimize}")
|
||||||
|
if title:
|
||||||
|
args.append(f"--title={title}")
|
||||||
|
if author:
|
||||||
|
args.append(f"--author={author}")
|
||||||
|
if keywords:
|
||||||
|
args.append(f"--keywords={keywords}")
|
||||||
|
if subject:
|
||||||
|
args.append(f"--subject={subject}")
|
||||||
|
if pages:
|
||||||
|
args.append(f"--pages={pages}")
|
||||||
|
if max_image_mpixels:
|
||||||
|
args.append(f"--max-image-mpixels={max_image_mpixels}")
|
||||||
|
if rotate_pages_threshold:
|
||||||
|
args.append(f"--rotate-pages-threshold={rotate_pages_threshold}")
|
||||||
|
if fast_web_view:
|
||||||
|
args.append(f"--fast-web-view={fast_web_view}")
|
||||||
|
if continue_on_soft_render_error:
|
||||||
|
args.append("--continue-on-soft-render-error")
|
||||||
|
if verbose:
|
||||||
|
args.append(f"--verbose={verbose}")
|
||||||
|
if optimize > '0' and jpeg_quality:
|
||||||
|
args.append(f"--jpeg-quality={jpeg_quality}")
|
||||||
|
if optimize > '0' and png_quality:
|
||||||
|
args.append(f"--png-quality={png_quality}")
|
||||||
|
if jbig2_lossy:
|
||||||
|
args.append("--jbig2-lossy")
|
||||||
|
if jbig2_threshold:
|
||||||
|
args.append(f"--jbig2-threshold={jbig2_threshold}")
|
||||||
|
if jobs:
|
||||||
|
args.append(f"--jobs={jobs}")
|
||||||
|
input_file = NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}")
|
||||||
|
input_file.write(uploaded.getvalue())
|
||||||
|
input_file.flush()
|
||||||
|
input_file.seek(0)
|
||||||
|
args.append(str(input_file.name))
|
||||||
|
output_file = NamedTemporaryFile(delete=True, suffix=".pdf")
|
||||||
|
args.append(str(output_file.name))
|
||||||
|
|
||||||
|
st.session_state['running'] = (
|
||||||
|
'run_button' in st.session_state and st.session_state.run_button
|
||||||
|
)
|
||||||
|
if st.button(
|
||||||
|
"Run OCRmyPDF",
|
||||||
|
disabled=st.session_state.get("running", False),
|
||||||
|
key='run_button',
|
||||||
|
):
|
||||||
|
st.session_state['running'] = True
|
||||||
|
args = [sys.executable, '-m', "ocrmypdf"] + args
|
||||||
|
cmdline = " ".join(args)
|
||||||
|
st.code(cmdline, language="bash")
|
||||||
|
|
||||||
|
port = get_port((5000, 7000))
|
||||||
|
ttyd_args = ['ttyd', '--port', str(port), '--once', '--readonly']
|
||||||
|
|
||||||
|
ttyd_proc = subprocess.Popen(
|
||||||
|
ttyd_args + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
iframe(src=get_host_url_with_port(port), height=400)
|
||||||
|
|
||||||
|
while ttyd_proc.poll() is None:
|
||||||
|
ttyd_proc.poll()
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
if ttyd_proc.returncode == 0:
|
||||||
|
if Path(output_file.name).stat().st_size == 0:
|
||||||
|
st.error("No output PDF file was generated")
|
||||||
|
else:
|
||||||
|
st.download_button(
|
||||||
|
label="Download output PDF",
|
||||||
|
data=input_file.read(),
|
||||||
|
file_name=uploaded.name,
|
||||||
|
mime="application/pdf",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.error(f"ttyd failed with exit code {ttyd_proc.returncode}")
|
||||||
|
st.session_state['running'] = False
|
||||||
+4
-4
@@ -14,12 +14,12 @@ You should edit this script to meet your needs.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import filecmp
|
||||||
import logging
|
import logging
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import shutil
|
import shutil
|
||||||
import filecmp
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import ocrmypdf
|
import ocrmypdf
|
||||||
@@ -70,7 +70,7 @@ for filename in start_dir.glob("**/*.pdf"):
|
|||||||
logging.info(f"Archiving document to {archive_filename}")
|
logging.info(f"Archiving document to {archive_filename}")
|
||||||
try:
|
try:
|
||||||
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
||||||
except IOError as io_err:
|
except OSError:
|
||||||
os.makedirs(posixpath.dirname(archive_filename))
|
os.makedirs(posixpath.dirname(archive_filename))
|
||||||
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
||||||
try:
|
try:
|
||||||
@@ -86,6 +86,6 @@ for filename in start_dir.glob("**/*.pdf"):
|
|||||||
logging.info(
|
logging.info(
|
||||||
"Skipped document because it does not need ocr as it is tagged"
|
"Skipped document because it does not need ocr as it is tagged"
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
logging.error("Unhandled error occured")
|
logging.error("Unhandled error occured")
|
||||||
logging.info("OCR complete")
|
logging.info("OCR complete")
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Helper script for bisecting PDFs to find a page with an issue."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <input.pdf>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with pikepdf.open(sys.argv[1]) as pdf:
|
||||||
|
num_pages = len(pdf.pages)
|
||||||
|
low = 0
|
||||||
|
high = num_pages - 1
|
||||||
|
while low <= high:
|
||||||
|
mid = (low + high) // 2
|
||||||
|
with pikepdf.new() as new_pdf:
|
||||||
|
new_pdf.pages.extend(pdf.pages[low : mid + 1])
|
||||||
|
new_pdf.save(f"bisect-issue-{low + 1}-{mid + 1}.pdf")
|
||||||
|
print(f"Is bisect-issue-{low + 1}-{mid + 1}.pdf good or bad?", end=" ")
|
||||||
|
while True:
|
||||||
|
response = input().lower()
|
||||||
|
if response == "good":
|
||||||
|
low = mid + 1
|
||||||
|
break
|
||||||
|
elif response == "bad":
|
||||||
|
high = mid - 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("Please respond with 'good' or 'bad'.")
|
||||||
|
print(f"The issue is on page {low + 1} of the original PDF.")
|
||||||
|
with pikepdf.new() as new_pdf:
|
||||||
|
new_pdf.pages.extend(pdf.pages[low])
|
||||||
|
new_pdf.save(f"bisect-issue-bad-{low + 1}.pdf")
|
||||||
|
with pikepdf.new() as new_pdf:
|
||||||
|
new_pdf.pages.extend(pdf.pages[:low])
|
||||||
|
new_pdf.pages.extend(pdf.pages[low + 1 :])
|
||||||
|
new_pdf.save(f"bisect-issue-good-{low + 1}.pdf")
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Run OCRmyPDF on the same PDF with different options."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import check_output, run
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
import pymupdf
|
||||||
|
import streamlit as st
|
||||||
|
from lxml import etree
|
||||||
|
from streamlit_pdf_viewer import pdf_viewer
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
st.set_page_config(layout="wide")
|
||||||
|
|
||||||
|
st.title("OCRmyPDF Compare")
|
||||||
|
st.write("Run OCRmyPDF on the same PDF with different options.")
|
||||||
|
|
||||||
|
uploaded_pdf = st.file_uploader("Upload a PDF", type=["pdf"])
|
||||||
|
if uploaded_pdf is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
pdf_bytes = uploaded_pdf.read()
|
||||||
|
|
||||||
|
with pikepdf.open(BytesIO(pdf_bytes)) as p, TemporaryDirectory() as d:
|
||||||
|
with st.expander("PDF Metadata"):
|
||||||
|
with p.open_metadata() as meta:
|
||||||
|
xml_txt = str(meta)
|
||||||
|
parser = etree.XMLParser(remove_blank_text=True)
|
||||||
|
tree = etree.fromstring(xml_txt, parser=parser)
|
||||||
|
st.code(
|
||||||
|
etree.tostring(tree, pretty_print=True).decode("utf-8"),
|
||||||
|
language="xml",
|
||||||
|
)
|
||||||
|
st.write(p.docinfo)
|
||||||
|
st.write("Number of pages:", len(p.pages))
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
cli1 = st.text_area(
|
||||||
|
"Command line arguments for A",
|
||||||
|
key="args1",
|
||||||
|
value="ocrmypdf {in_} {out}",
|
||||||
|
)
|
||||||
|
env1 = st.text_area("Environment variables for A", key="env1")
|
||||||
|
args1 = shlex.split(
|
||||||
|
cli1.format(
|
||||||
|
in_=os.path.join(d, "input.pdf"),
|
||||||
|
out=os.path.join(d, "output1.pdf"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
st.code(shlex.join(args1))
|
||||||
|
with col2:
|
||||||
|
cli2 = st.text_area(
|
||||||
|
"Command line arguments for B",
|
||||||
|
key="args2",
|
||||||
|
value="ocrmypdf {in_} {out}",
|
||||||
|
)
|
||||||
|
env2 = st.text_area("Environment variables for B", key="env2")
|
||||||
|
args2 = shlex.split(
|
||||||
|
cli2.format(
|
||||||
|
in_=os.path.join(d, "input.pdf"),
|
||||||
|
out=os.path.join(d, "output2.pdf"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
st.code(shlex.join(args2))
|
||||||
|
|
||||||
|
if not st.button("Execute and Compare"):
|
||||||
|
return
|
||||||
|
with st.spinner("Executing..."):
|
||||||
|
Path(d, "input.pdf").write_bytes(pdf_bytes)
|
||||||
|
run(args1, env=dict(os.environ, **eval(env1 or "{}")))
|
||||||
|
run(args2, env=dict(os.environ, **eval(env2 or "{}")))
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
st.text(
|
||||||
|
"Ghostscript version A: "
|
||||||
|
+ check_output(
|
||||||
|
["gs", "--version"],
|
||||||
|
env=dict(os.environ, **eval(env1 or "{}")),
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with col2:
|
||||||
|
st.text(
|
||||||
|
"Ghostscript version B: "
|
||||||
|
+ check_output(
|
||||||
|
["gs", "--version"],
|
||||||
|
env=dict(os.environ, **eval(env2 or "{}")),
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
doc1 = pymupdf.open(os.path.join(d, "output1.pdf"))
|
||||||
|
doc2 = pymupdf.open(os.path.join(d, "output2.pdf"))
|
||||||
|
for i, page1_2 in enumerate(zip(doc1, doc2)):
|
||||||
|
st.write(f"Page {i+1}")
|
||||||
|
page1, page2 = page1_2
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1, st.container(border=True):
|
||||||
|
st.write(page1.get_text())
|
||||||
|
with col2, st.container(border=True):
|
||||||
|
st.write(page2.get_text())
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1, st.expander("PDF Viewer"):
|
||||||
|
pdf_viewer(Path(d, "output1.pdf"))
|
||||||
|
with col2, st.expander("PDF Viewer"):
|
||||||
|
pdf_viewer(Path(d, "output2.pdf"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Compare two PDFs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
import pymupdf
|
||||||
|
import streamlit as st
|
||||||
|
from lxml import etree
|
||||||
|
from streamlit_pdf_viewer import pdf_viewer
|
||||||
|
|
||||||
|
|
||||||
|
def do_metadata(pdf):
|
||||||
|
with pikepdf.open(pdf) as pdf:
|
||||||
|
with pdf.open_metadata() as meta:
|
||||||
|
xml_txt = str(meta)
|
||||||
|
parser = etree.XMLParser(remove_blank_text=True)
|
||||||
|
tree = etree.fromstring(xml_txt, parser=parser)
|
||||||
|
st.code(
|
||||||
|
etree.tostring(tree, pretty_print=True).decode("utf-8"),
|
||||||
|
language="xml",
|
||||||
|
)
|
||||||
|
st.write(pdf.docinfo)
|
||||||
|
st.write("Number of pages:", len(pdf.pages))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
st.set_page_config(layout="wide")
|
||||||
|
|
||||||
|
st.title("PDF Compare")
|
||||||
|
st.write("Compare two PDFs.")
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
uploaded_pdf1 = st.file_uploader("Upload a PDF", type=["pdf"], key='pdf1')
|
||||||
|
with col2:
|
||||||
|
uploaded_pdf2 = st.file_uploader("Upload a PDF", type=["pdf"], key='pdf2')
|
||||||
|
if uploaded_pdf1 is None or uploaded_pdf2 is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
pdf_bytes1 = uploaded_pdf1.getvalue()
|
||||||
|
pdf_bytes2 = uploaded_pdf2.getvalue()
|
||||||
|
|
||||||
|
with st.expander("PDF Metadata"):
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
do_metadata(BytesIO(pdf_bytes1))
|
||||||
|
with col2:
|
||||||
|
do_metadata(BytesIO(pdf_bytes2))
|
||||||
|
|
||||||
|
with TemporaryDirectory() as d:
|
||||||
|
Path(d, "1.pdf").write_bytes(pdf_bytes1)
|
||||||
|
Path(d, "2.pdf").write_bytes(pdf_bytes2)
|
||||||
|
|
||||||
|
with st.expander("Text"):
|
||||||
|
doc1 = pymupdf.open(os.path.join(d, "1.pdf"))
|
||||||
|
doc2 = pymupdf.open(os.path.join(d, "2.pdf"))
|
||||||
|
for i, page1_2 in enumerate(zip(doc1, doc2)):
|
||||||
|
st.write(f"Page {i+1}")
|
||||||
|
page1, page2 = page1_2
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1, st.container(border=True):
|
||||||
|
st.write(page1.get_text())
|
||||||
|
with col2, st.container(border=True):
|
||||||
|
st.write(page2.get_text())
|
||||||
|
|
||||||
|
with st.expander("PDF Viewer"):
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
pdf_viewer(Path(d, "1.pdf"), key='pdf_viewer1', render_text=True)
|
||||||
|
with col2:
|
||||||
|
pdf_viewer(Path(d, "2.pdf"), key='pdf_viewer2', render_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Compare text in PDFs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from subprocess import run
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
pdf1: Annotated[typer.FileBinaryRead, typer.Argument()],
|
||||||
|
pdf2: Annotated[typer.FileBinaryRead, typer.Argument()],
|
||||||
|
engine: Annotated[str, typer.Option()] = 'pdftotext',
|
||||||
|
):
|
||||||
|
"""Compare text in PDFs."""
|
||||||
|
|
||||||
|
text1 = run(
|
||||||
|
['pdftotext', '-layout', '-', '-'], stdin=pdf1, capture_output=True, check=True
|
||||||
|
)
|
||||||
|
text2 = run(
|
||||||
|
['pdftotext', '-layout', '-', '-'], stdin=pdf2, capture_output=True, check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with NamedTemporaryFile() as f1, NamedTemporaryFile() as f2:
|
||||||
|
f1.write(text1.stdout)
|
||||||
|
f1.flush()
|
||||||
|
f2.write(text2.stdout)
|
||||||
|
f2.flush()
|
||||||
|
diff = run(
|
||||||
|
['diff', '--color=always', '--side-by-side', f1.name, f2.name],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
run(['less', '-R'], input=diff.stdout, check=True)
|
||||||
|
if text1.stdout.strip() != text2.stdout.strip():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
typer.run(main)
|
||||||
+7
-4
@@ -46,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
|
||||||
|
|
||||||
|
|
||||||
@@ -98,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...')
|
||||||
|
|||||||
Regular → Executable
+23
-101
@@ -1,107 +1,29 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python
|
||||||
# SPDX-FileCopyrightText: 2019 James R. Barlow
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
"""This is a simple web service/HTTP wrapper for OCRmyPDF.
|
"""Run the OCRmyPDF web service."""
|
||||||
|
|
||||||
This may be more convenient than the command line tool for some Docker users.
|
|
||||||
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
|
|
||||||
OCRmyPDF is under GPLv3, this file is distributed under the Affero GPLv3+ license,
|
|
||||||
to emphasize that SaaS deployments should make sure they comply with
|
|
||||||
Ghostscript's license as well as OCRmyPDF's.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shlex
|
import sys
|
||||||
from subprocess import run
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
|
|
||||||
from flask import Flask, Response, request, send_from_directory
|
try:
|
||||||
from werkzeug.utils import secure_filename
|
import streamlit # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
'You need to install streamlit in the Python environment '
|
||||||
|
'to run the web service.\n'
|
||||||
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
if __name__ == '__main__':
|
||||||
app.secret_key = "secret"
|
os.execvp(
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 50_000_000
|
sys.executable,
|
||||||
app.config.from_envvar("OCRMYPDF_WEBSERVICE_SETTINGS", silent=True)
|
[
|
||||||
|
sys.executable,
|
||||||
ALLOWED_EXTENSIONS = {"pdf"}
|
'-m',
|
||||||
|
'streamlit',
|
||||||
|
'run',
|
||||||
def allowed_file(filename):
|
'misc/_webservice.py',
|
||||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
*sys.argv[1:],
|
||||||
|
],
|
||||||
|
)
|
||||||
def do_ocrmypdf(file):
|
|
||||||
uploaddir = TemporaryDirectory(prefix="ocrmypdf-upload")
|
|
||||||
downloaddir = TemporaryDirectory(prefix="ocrmypdf-download")
|
|
||||||
|
|
||||||
filename = secure_filename(file.filename)
|
|
||||||
up_file = os.path.join(uploaddir.name, filename)
|
|
||||||
file.save(up_file)
|
|
||||||
|
|
||||||
down_file = os.path.join(downloaddir.name, filename)
|
|
||||||
|
|
||||||
cmd_args = [arg for arg in shlex.split(request.form["params"])]
|
|
||||||
if "--sidecar" in cmd_args:
|
|
||||||
return Response("--sidecar not supported", 501, mimetype='text/plain')
|
|
||||||
|
|
||||||
ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file]
|
|
||||||
proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8", check=False)
|
|
||||||
if proc.returncode != 0:
|
|
||||||
stderr = proc.stderr
|
|
||||||
return Response(stderr, 400, mimetype='text/plain')
|
|
||||||
|
|
||||||
return send_from_directory(downloaddir.name, filename)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
|
||||||
def upload_file():
|
|
||||||
if request.method == "POST":
|
|
||||||
if "file" not in request.files:
|
|
||||||
return Response("No file in POST", 400, mimetype='text/plain')
|
|
||||||
file = request.files["file"]
|
|
||||||
if file.filename == "":
|
|
||||||
return Response("Empty filename", 400, mimetype='text/plain')
|
|
||||||
if not allowed_file(file.filename):
|
|
||||||
return Response("Invalid filename", 400, mimetype='text/plain')
|
|
||||||
if file and allowed_file(file.filename):
|
|
||||||
return do_ocrmypdf(file)
|
|
||||||
return Response("Some other problem", 400, mimetype='text/plain')
|
|
||||||
|
|
||||||
return """
|
|
||||||
<!doctype html>
|
|
||||||
<title>OCRmyPDF webservice</title>
|
|
||||||
<h1>Upload a PDF (debug UI)</h1>
|
|
||||||
<form method=post enctype=multipart/form-data>
|
|
||||||
<label for="args">Command line parameters</label>
|
|
||||||
<input type=textbox name=params>
|
|
||||||
<label for="file">File to upload</label>
|
|
||||||
<input type=file name=file>
|
|
||||||
<input type=submit value=Upload>
|
|
||||||
</form>
|
|
||||||
<h4>Notice</h2>
|
|
||||||
<div style="font-size: 70%; max-width: 34em;">
|
|
||||||
<p>This is a webservice wrapper for OCRmyPDF.</p>
|
|
||||||
<p>Copyright 2019 James R. Barlow</p>
|
|
||||||
<p>This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
</p>
|
|
||||||
<p>This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(host='0.0.0.0', port=5000)
|
|
||||||
|
|||||||
+20
-12
@@ -1,8 +1,8 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools >= 61", "setuptools_scm[toml] >= 7.0.5", "wheel"]
|
requires = ["hatchling", "hatch-vcs"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ocrmypdf"
|
name = "ocrmypdf"
|
||||||
@@ -46,6 +46,7 @@ keywords = ["PDF", "OCR", "optical character recognition", "PDF/A", "scanning"]
|
|||||||
Documentation = "https://ocrmypdf.readthedocs.io/"
|
Documentation = "https://ocrmypdf.readthedocs.io/"
|
||||||
Source = "https://github.com/ocrmypdf/OCRmyPDF"
|
Source = "https://github.com/ocrmypdf/OCRmyPDF"
|
||||||
Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues"
|
Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues"
|
||||||
|
Changelog = "https://github.com/ocrmypdf/OCRmyPDF/docs/release_notes.rst"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
docs = ["sphinx", "sphinx-issues", "sphinx-rtd-theme"]
|
docs = ["sphinx", "sphinx-issues", "sphinx-rtd-theme"]
|
||||||
@@ -62,19 +63,19 @@ test = [
|
|||||||
"types-humanfriendly",
|
"types-humanfriendly",
|
||||||
]
|
]
|
||||||
watcher = ["watchdog>=1.0.2", "typer-slim[standard]", "python-dotenv"]
|
watcher = ["watchdog>=1.0.2", "typer-slim[standard]", "python-dotenv"]
|
||||||
webservice = ["Flask>=2.0.1"]
|
webservice = [
|
||||||
|
"port-for>=0.7.4",
|
||||||
|
"streamlit>=1.41.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ocrmypdf = "ocrmypdf.__main__:run"
|
ocrmypdf = "ocrmypdf.__main__:run"
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.hatch.version]
|
||||||
ocrmypdf = ["data/sRGB.icc", "py.typed"]
|
source = "vcs"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.hatch.build.hooks.vcs]
|
||||||
where = ["src"]
|
version-file = "src/ocrmypdf/_version.py"
|
||||||
namespaces = false
|
|
||||||
|
|
||||||
[tool.setuptools_scm]
|
|
||||||
|
|
||||||
[tool.distutils.bdist_wheel]
|
[tool.distutils.bdist_wheel]
|
||||||
python-tag = "py310"
|
python-tag = "py310"
|
||||||
@@ -120,8 +121,6 @@ filterwarnings = [
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
'pluggy',
|
'pluggy',
|
||||||
'tqdm',
|
|
||||||
'coloredlogs',
|
|
||||||
'img2pdf',
|
'img2pdf',
|
||||||
'pdfminer.*',
|
'pdfminer.*',
|
||||||
'reportlab.*',
|
'reportlab.*',
|
||||||
@@ -157,3 +156,12 @@ convention = "google"
|
|||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
quote-style = "preserve"
|
quote-style = "preserve"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.13.0",
|
||||||
|
"pymupdf>=1.24.14",
|
||||||
|
"streamlit-pdf-viewer>=0.0.19",
|
||||||
|
"streamlit>=1.40.2",
|
||||||
|
"ipykernel>=6.29.5",
|
||||||
|
]
|
||||||
|
|||||||
+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:
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ 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._defaults import PROGRAM_NAME
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._pipelines._common import (
|
from ocrmypdf._pipelines._common import (
|
||||||
configure_debug_logging,
|
configure_debug_logging,
|
||||||
)
|
)
|
||||||
from ocrmypdf._version import PROGRAM_NAME, __version__
|
from ocrmypdf._version import __version__
|
||||||
from ocrmypdf.api import (
|
from ocrmypdf.api import (
|
||||||
Verbosity,
|
Verbosity,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
@@ -37,7 +38,6 @@ from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
|
|||||||
|
|
||||||
hookimpl = _HookimplMarker('ocrmypdf')
|
hookimpl = _HookimplMarker('ocrmypdf')
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'__version__',
|
'__version__',
|
||||||
'BadArgsError',
|
'BadArgsError',
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""OCRmyPDF PDF annotation cleanup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pikepdf import Dictionary, Name, NameTree, Pdf
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_broken_goto_annotations(pdf: Pdf) -> bool:
|
||||||
|
"""Remove broken goto annotations from a PDF.
|
||||||
|
|
||||||
|
If a PDF contains a GoTo Action that points to a named destination that does not
|
||||||
|
exist, Ghostscript PDF/A conversion will fail. In any event, a named destination
|
||||||
|
that is not defined is not useful.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf: Opened PDF file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the file was modified, False if not.
|
||||||
|
"""
|
||||||
|
modified = False
|
||||||
|
|
||||||
|
# Check if there are any named destinations
|
||||||
|
if Name.Names not in pdf.Root:
|
||||||
|
return modified
|
||||||
|
if Name.Dests not in pdf.Root[Name.Names]:
|
||||||
|
return modified
|
||||||
|
|
||||||
|
dests = pdf.Root[Name.Names][Name.Dests]
|
||||||
|
if not isinstance(dests, Dictionary):
|
||||||
|
return modified
|
||||||
|
nametree = NameTree(dests)
|
||||||
|
|
||||||
|
# Create a set of all named destinations
|
||||||
|
names = set(k for k in nametree.keys())
|
||||||
|
|
||||||
|
for n, page in enumerate(pdf.pages):
|
||||||
|
if Name.Annots not in page:
|
||||||
|
continue
|
||||||
|
for annot in page[Name.Annots]:
|
||||||
|
if not isinstance(annot, Dictionary):
|
||||||
|
continue
|
||||||
|
if Name.A not in annot or Name.D not in annot[Name.A]:
|
||||||
|
continue
|
||||||
|
# We found an annotation that points to a named destination
|
||||||
|
named_destination = str(annot[Name.A][Name.D])
|
||||||
|
if named_destination not in names:
|
||||||
|
# If there is no corresponding named destination, remove the
|
||||||
|
# annotation. Having no destination set is still valid and just
|
||||||
|
# makes the link non-functional.
|
||||||
|
log.warning(
|
||||||
|
f"Disabling a hyperlink annotation on page {n + 1} to a "
|
||||||
|
"non-existent named destination "
|
||||||
|
f"{named_destination}."
|
||||||
|
)
|
||||||
|
del annot[Name.A][Name.D]
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
return modified
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
PROGRAM_NAME = 'OCRmyPDF'
|
||||||
@@ -125,7 +125,7 @@ def rasterize_pdf(
|
|||||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||||
+ [
|
+ [
|
||||||
'-o',
|
'-o',
|
||||||
'-',
|
fspath(output_file),
|
||||||
'-sstdout=%stderr', # Literal %s, not string interpolation
|
'-sstdout=%stderr', # Literal %s, not string interpolation
|
||||||
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
||||||
'-f',
|
'-f',
|
||||||
@@ -137,14 +137,15 @@ def rasterize_pdf(
|
|||||||
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
log.error(e.stderr.decode(errors='replace'))
|
log.error(e.stderr.decode(errors='replace'))
|
||||||
raise SubprocessOutputError('Ghostscript rasterizing failed') from e
|
Path(output_file).unlink(missing_ok=True)
|
||||||
else:
|
raise SubprocessOutputError("Ghostscript rasterizing failed") from e
|
||||||
stderr = p.stderr.decode(errors='replace')
|
|
||||||
if _gs_error_reported(stderr):
|
stderr = p.stderr.decode(errors='replace')
|
||||||
log.error(stderr)
|
if _gs_error_reported(stderr):
|
||||||
|
log.error(stderr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Image.open(BytesIO(p.stdout)) as im:
|
with Image.open(output_file) as im:
|
||||||
if rotation is not None:
|
if rotation is not None:
|
||||||
log.debug("Rotating output by %i", rotation)
|
log.debug("Rotating output by %i", rotation)
|
||||||
# rotation is a clockwise angle and Image.ROTATE_* is
|
# rotation is a clockwise angle and Image.ROTATE_* is
|
||||||
@@ -157,13 +158,19 @@ def rasterize_pdf(
|
|||||||
im = im.transpose(Image.Transpose.ROTATE_270)
|
im = im.transpose(Image.Transpose.ROTATE_270)
|
||||||
if rotation % 180 == 90:
|
if rotation % 180 == 90:
|
||||||
page_dpi = page_dpi.flip_axis()
|
page_dpi = page_dpi.flip_axis()
|
||||||
im.save(fspath(output_file), dpi=page_dpi)
|
im.save(output_file, dpi=page_dpi)
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
log.error(
|
log.error(
|
||||||
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
||||||
"an invalid page image file."
|
"an invalid page image file."
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
log.error(
|
||||||
|
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
||||||
|
"an invalid page image file."
|
||||||
|
)
|
||||||
|
raise UnidentifiedImageError() from e
|
||||||
|
|
||||||
|
|
||||||
class GhostscriptFollower:
|
class GhostscriptFollower:
|
||||||
@@ -177,6 +184,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
|
||||||
@@ -187,7 +205,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()
|
||||||
@@ -259,22 +278,21 @@ def generate_pdfa(
|
|||||||
f"-dPDFA={pdfa_part}",
|
f"-dPDFA={pdfa_part}",
|
||||||
"-dPDFACompatibilityPolicy=1",
|
"-dPDFACompatibilityPolicy=1",
|
||||||
"-o",
|
"-o",
|
||||||
"-",
|
fspath(output_file),
|
||||||
"-sstdout=%stderr", # Literal %s, not string interpolation
|
"-sstdout=%stderr", # Literal %s, not string interpolation
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
||||||
try:
|
try:
|
||||||
with Path(output_file).open('wb') as output:
|
with GhostscriptFollower(progressbar_class) as pbar:
|
||||||
p = run_polling_stderr(
|
p = run_polling_stderr(
|
||||||
args_gs,
|
args_gs,
|
||||||
stdout=output,
|
|
||||||
stderr=PIPE,
|
stderr=PIPE,
|
||||||
check=True,
|
check=True,
|
||||||
text=True,
|
text=True,
|
||||||
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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class UnpaperImageTooLargeError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def version() -> Version:
|
def version() -> Version:
|
||||||
return Version(get_version('unpaper'))
|
return Version(get_version('unpaper', regex=r'(?m).*?(\d+(\.\d+)(\.\d+)?)'))
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|||||||
+15
-3
@@ -61,19 +61,31 @@ def strip_invisible_text(pdf: Pdf, page: Page):
|
|||||||
stream = []
|
stream = []
|
||||||
in_text_obj = False
|
in_text_obj = False
|
||||||
render_mode = 0
|
render_mode = 0
|
||||||
|
render_mode_stack = []
|
||||||
text_objects = []
|
text_objects = []
|
||||||
|
|
||||||
for operands, operator in parse_content_stream(page, ''):
|
for operands, operator in parse_content_stream(page, ''):
|
||||||
|
if operator == Operator('Tr'):
|
||||||
|
render_mode = operands[0]
|
||||||
|
|
||||||
|
if operator == Operator('q'):
|
||||||
|
render_mode_stack.append(render_mode)
|
||||||
|
|
||||||
|
if operator == Operator('Q'):
|
||||||
|
try:
|
||||||
|
render_mode = render_mode_stack.pop()
|
||||||
|
except IndexError:
|
||||||
|
# Stack underflow: content stream is malformed
|
||||||
|
# but try to carry on
|
||||||
|
pass
|
||||||
|
|
||||||
if not in_text_obj:
|
if not in_text_obj:
|
||||||
if operator == Operator('BT'):
|
if operator == Operator('BT'):
|
||||||
in_text_obj = True
|
in_text_obj = True
|
||||||
render_mode = 0
|
|
||||||
text_objects.append((operands, operator))
|
text_objects.append((operands, operator))
|
||||||
else:
|
else:
|
||||||
stream.append((operands, operator))
|
stream.append((operands, operator))
|
||||||
else:
|
else:
|
||||||
if operator == Operator('Tr'):
|
|
||||||
render_mode = operands[0]
|
|
||||||
text_objects.append((operands, operator))
|
text_objects.append((operands, operator))
|
||||||
if operator == Operator('ET'):
|
if operator == Operator('ET'):
|
||||||
in_text_obj = False
|
in_text_obj = False
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ class PageNumberFilter(logging.Filter):
|
|||||||
class RichLoggingHandler(RichHandler):
|
class RichLoggingHandler(RichHandler):
|
||||||
def __init__(self, console: Console, **kwargs):
|
def __init__(self, console: Console, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
console=console, show_level=False, show_time=False, markup=True, **kwargs
|
console=console, show_level=False, show_time=False, markup=False, **kwargs
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ from pikepdf import Dictionary, Name, Pdf
|
|||||||
from pikepdf import __version__ as PIKEPDF_VERSION
|
from pikepdf import __version__ as PIKEPDF_VERSION
|
||||||
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
|
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
|
from ocrmypdf._defaults import PROGRAM_NAME
|
||||||
from ocrmypdf._jobcontext import PdfContext
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
from ocrmypdf._version import PROGRAM_NAME
|
|
||||||
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
|
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
|
||||||
from ocrmypdf.languages import iso_639_2_from_3
|
from ocrmypdf.languages import iso_639_2_from_3
|
||||||
|
|
||||||
@@ -153,18 +154,45 @@ def _set_language(pdf: Pdf, languages: list[str]):
|
|||||||
pdf.Root.Lang = iso639_2
|
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(
|
def metadata_fixup(
|
||||||
working_file: Path, context: PdfContext, pdf_save_settings: dict[str, Any]
|
working_file: Path, context: PdfContext, pdf_save_settings: dict[str, Any]
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Fix certain metadata fields after Ghostscript PDF/A conversion.
|
"""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
|
Also report on metadata in the input file that was not retained during
|
||||||
PDF/A conversion.
|
conversion.
|
||||||
"""
|
"""
|
||||||
output_file = context.get_path('metafix.pdf')
|
output_file = context.get_path('metafix.pdf')
|
||||||
options = context.options
|
options = context.options
|
||||||
|
|
||||||
with Pdf.open(context.origin) as original, Pdf.open(working_file) as pdf:
|
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)
|
docinfo = get_docinfo(original, context)
|
||||||
with (
|
with (
|
||||||
original.open_metadata(
|
original.open_metadata(
|
||||||
@@ -182,6 +210,6 @@ def metadata_fixup(
|
|||||||
report_on_metadata(options, meta_missing)
|
report_on_metadata(options, meta_missing)
|
||||||
|
|
||||||
_set_language(pdf, options.languages)
|
_set_language(pdf, options.languages)
|
||||||
pdf.save(output_file, **pdf_save_settings)
|
pdf.save(output_file, progress=pbar, **pdf_save_settings)
|
||||||
|
|
||||||
return output_file
|
return output_file
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ def triage_image_file(input_file: Path, output_file: Path, options) -> None:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
# Recover the original filename
|
# Recover the original filename
|
||||||
log.error(str(e).replace(str(input_file), str(options.input_file)))
|
log.error(str(e).replace(str(input_file), str(options.input_file)))
|
||||||
|
if not input_file.exists():
|
||||||
|
log.error("Input file does not exist: %s", input_file)
|
||||||
|
if input_file.is_dir():
|
||||||
|
log.error("Input file is a directory: %s", input_file)
|
||||||
|
if input_file.is_file():
|
||||||
|
log.error("Input file is a file: %s", input_file)
|
||||||
|
if input_file.stat().st_size == 0:
|
||||||
|
log.error("Input file is empty: %s", input_file)
|
||||||
raise UnsupportedImageFormatError() from e
|
raise UnsupportedImageFormatError() from e
|
||||||
|
|
||||||
with im:
|
with im:
|
||||||
@@ -159,8 +167,13 @@ def triage(
|
|||||||
"Argument --image-dpi is being ignored because the "
|
"Argument --image-dpi is being ignored because the "
|
||||||
"input file is a PDF, not an image."
|
"input file is a PDF, not an image."
|
||||||
)
|
)
|
||||||
# Origin file is a pdf create a symlink with pdf extension
|
try:
|
||||||
safe_symlink(input_file, output_file)
|
with pikepdf.open(input_file) as pdf:
|
||||||
|
pdf.save(output_file)
|
||||||
|
except pikepdf.PdfError as e:
|
||||||
|
raise InputFileError() from e
|
||||||
|
except pikepdf.PasswordError as e:
|
||||||
|
raise EncryptedPdfError() from e
|
||||||
return output_file
|
return output_file
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
log.debug(f"Temporary file was at: {input_file}")
|
log.debug(f"Temporary file was at: {input_file}")
|
||||||
@@ -475,7 +488,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.",
|
||||||
@@ -854,7 +867,7 @@ def fix_pagepdf_boxes(
|
|||||||
page.CropBox = cropbox
|
page.CropBox = cropbox
|
||||||
page.TrimBox = trimbox
|
page.TrimBox = trimbox
|
||||||
pdf.save(out_file)
|
pdf.save(out_file)
|
||||||
return pdf
|
return out_file
|
||||||
|
|
||||||
|
|
||||||
def generate_postscript_stub(context: PdfContext) -> Path:
|
def generate_postscript_stub(context: PdfContext) -> Path:
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ from pathlib import Path
|
|||||||
from typing import NamedTuple, cast
|
from typing import NamedTuple, cast
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
|
from pikepdf import Pdf
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
from ocrmypdf._concurrent import Executor, setup_executor
|
from ocrmypdf._concurrent import Executor, setup_executor
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._logging import PageNumberFilter
|
from ocrmypdf._logging import PageNumberFilter
|
||||||
@@ -33,6 +35,7 @@ from ocrmypdf._pipeline import (
|
|||||||
generate_postscript_stub,
|
generate_postscript_stub,
|
||||||
get_orientation_correction,
|
get_orientation_correction,
|
||||||
get_pdf_save_settings,
|
get_pdf_save_settings,
|
||||||
|
get_pdfinfo,
|
||||||
optimize_pdf,
|
optimize_pdf,
|
||||||
preprocess_clean,
|
preprocess_clean,
|
||||||
preprocess_deskew,
|
preprocess_deskew,
|
||||||
@@ -51,9 +54,12 @@ from ocrmypdf.helpers import (
|
|||||||
available_cpu_count,
|
available_cpu_count,
|
||||||
check_pdf,
|
check_pdf,
|
||||||
pikepdf_enable_mmap,
|
pikepdf_enable_mmap,
|
||||||
|
running_in_docker,
|
||||||
|
running_in_snap,
|
||||||
samefile,
|
samefile,
|
||||||
)
|
)
|
||||||
from ocrmypdf.pdfa import file_claims_pdfa
|
from ocrmypdf.pdfa import file_claims_pdfa
|
||||||
|
from ocrmypdf.pdfinfo import PdfInfo
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
tls = threading.local()
|
tls = threading.local()
|
||||||
@@ -100,6 +106,23 @@ class PageResult(NamedTuple):
|
|||||||
"""Orientation correction in degrees."""
|
"""Orientation correction in degrees."""
|
||||||
|
|
||||||
|
|
||||||
|
class HOCRResultEncoder(json.JSONEncoder):
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, Path):
|
||||||
|
return {'Path': str(obj)}
|
||||||
|
return super().default(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class HOCRResultDecoder(json.JSONDecoder):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(object_hook=self.dict_to_object, *args, **kwargs)
|
||||||
|
|
||||||
|
def dict_to_object(self, d):
|
||||||
|
if 'Path' in d:
|
||||||
|
return Path(d['Path'])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HOCRResult:
|
class HOCRResult:
|
||||||
"""Result when hOCR is finished processing."""
|
"""Result when hOCR is finished processing."""
|
||||||
@@ -119,38 +142,14 @@ class HOCRResult:
|
|||||||
orientation_correction: int = 0
|
orientation_correction: int = 0
|
||||||
"""Orientation correction in degrees."""
|
"""Orientation correction in degrees."""
|
||||||
|
|
||||||
def __getstate__(self):
|
|
||||||
"""Return state values to be pickled."""
|
|
||||||
return {
|
|
||||||
k: (
|
|
||||||
('Path://' + str(v))
|
|
||||||
if k in ('pdf_page_from_image', 'hocr', 'textpdf') and v is not None
|
|
||||||
else v
|
|
||||||
)
|
|
||||||
for k, v in self.__dict__.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def __setstate__(self, state):
|
|
||||||
"""Restore state from the unpickled state values."""
|
|
||||||
self.__dict__.update(
|
|
||||||
{
|
|
||||||
k: (
|
|
||||||
Path(v.removeprefix('Path://'))
|
|
||||||
if k in ('pdf_page_from_image', 'hocr', 'textpdf') and v is not None
|
|
||||||
else v
|
|
||||||
)
|
|
||||||
for k, v in state.items()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, json_str: str) -> HOCRResult:
|
def from_json(cls, json_str: str) -> HOCRResult:
|
||||||
"""Create an instance from a dict."""
|
"""Create an instance from a dict."""
|
||||||
return cls(**json.loads(json_str))
|
return cls(**json.loads(json_str, cls=HOCRResultDecoder))
|
||||||
|
|
||||||
def to_json(self) -> str:
|
def to_json(self) -> str:
|
||||||
"""Serialize to a JSON string."""
|
"""Serialize to a JSON string."""
|
||||||
return json.dumps(self.__getstate__())
|
return json.dumps(self.__dict__, cls=HOCRResultEncoder)
|
||||||
|
|
||||||
|
|
||||||
def configure_debug_logging(
|
def configure_debug_logging(
|
||||||
@@ -183,7 +182,7 @@ def configure_debug_logging(
|
|||||||
return log_file_handler, remover
|
return log_file_handler, remover
|
||||||
|
|
||||||
|
|
||||||
def worker_init(max_pixels: int) -> None:
|
def worker_init(max_pixels: int | None) -> None:
|
||||||
"""Initialize a worker thread or process."""
|
"""Initialize a worker thread or process."""
|
||||||
# In Windows, child process will not inherit our change to this value in
|
# In Windows, child process will not inherit our change to this value in
|
||||||
# the parent process, so ensure workers get it set. Not needed when running
|
# the parent process, so ensure workers get it set. Not needed when running
|
||||||
@@ -215,6 +214,22 @@ def manage_debug_log_handler(
|
|||||||
remover()
|
remover()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_temp_folder_location(work_folder: Path):
|
||||||
|
"""Print the location of the temporary work folder."""
|
||||||
|
msgs = [f"Temporary working files retained at:\n{work_folder}"]
|
||||||
|
if running_in_docker(): # pragma: no cover
|
||||||
|
msgs.append(
|
||||||
|
"OCRmyPDF is running in a Docker container, "
|
||||||
|
"so the files will be inside the container."
|
||||||
|
)
|
||||||
|
elif running_in_snap(): # pragma: no cover
|
||||||
|
msgs.append(
|
||||||
|
"OCRmyPDF is running in a Snap container, "
|
||||||
|
"so the files will be inside the container."
|
||||||
|
)
|
||||||
|
print('\n'.join(msgs), file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def manage_work_folder(*, work_folder: Path, retain: bool, print_location: bool):
|
def manage_work_folder(*, work_folder: Path, retain: bool, print_location: bool):
|
||||||
try:
|
try:
|
||||||
@@ -222,10 +237,7 @@ def manage_work_folder(*, work_folder: Path, retain: bool, print_location: bool)
|
|||||||
finally:
|
finally:
|
||||||
if retain:
|
if retain:
|
||||||
if print_location:
|
if print_location:
|
||||||
print(
|
_print_temp_folder_location(work_folder)
|
||||||
f"Temporary working files retained at:\n{work_folder}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
shutil.rmtree(work_folder, ignore_errors=True)
|
shutil.rmtree(work_folder, ignore_errors=True)
|
||||||
|
|
||||||
@@ -300,6 +312,20 @@ def setup_pipeline(
|
|||||||
return executor
|
return executor
|
||||||
|
|
||||||
|
|
||||||
|
def do_get_pdfinfo(
|
||||||
|
pdf_path: Path, executor: Executor, options: argparse.Namespace
|
||||||
|
) -> PdfInfo:
|
||||||
|
return get_pdfinfo(
|
||||||
|
pdf_path,
|
||||||
|
executor=executor,
|
||||||
|
detailed_analysis=options.redo_ocr,
|
||||||
|
progbar=options.progress_bar,
|
||||||
|
max_workers=options.jobs,
|
||||||
|
use_threads=options.use_threads,
|
||||||
|
check_pages=options.pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def preprocess(
|
def preprocess(
|
||||||
page_context: PageContext,
|
page_context: PageContext,
|
||||||
image: Path,
|
image: Path,
|
||||||
@@ -414,7 +440,14 @@ def postprocess(
|
|||||||
pdf_file: Path, context: PdfContext, executor: Executor
|
pdf_file: Path, context: PdfContext, executor: Executor
|
||||||
) -> tuple[Path, Sequence[str]]:
|
) -> tuple[Path, Sequence[str]]:
|
||||||
"""Postprocess the PDF file."""
|
"""Postprocess the PDF file."""
|
||||||
pdf_out = pdf_file
|
# pdf_out = pdf_file
|
||||||
|
with Pdf.open(pdf_file) as pdf:
|
||||||
|
fix_annots = context.get_path('fix_annots.pdf')
|
||||||
|
if remove_broken_goto_annotations(pdf):
|
||||||
|
pdf.save(fix_annots)
|
||||||
|
pdf_out = fix_annots
|
||||||
|
else:
|
||||||
|
pdf_out = pdf_file
|
||||||
if context.options.output_type.startswith('pdfa'):
|
if context.options.output_type.startswith('pdfa'):
|
||||||
ps_stub_out = generate_postscript_stub(context)
|
ps_stub_out = generate_postscript_stub(context)
|
||||||
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
|
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
|
||||||
@@ -441,7 +474,8 @@ def report_output_pdf(options, start_input_file, optimize_messages) -> ExitCode:
|
|||||||
log.info("Output file is a %s (as expected)", pdfa_info['conformance'])
|
log.info("Output file is a %s (as expected)", pdfa_info['conformance'])
|
||||||
else:
|
else:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Output file is okay but is not PDF/A (seems to be %s)",
|
"Output file is a valid PDF, but conversion to PDF/A did not "
|
||||||
|
"succeed (issue: %s)",
|
||||||
pdfa_info['conformance'],
|
pdfa_info['conformance'],
|
||||||
)
|
)
|
||||||
return ExitCode.pdfa_conversion_failed
|
return ExitCode.pdfa_conversion_failed
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ from ocrmypdf._graft import OcrGrafter
|
|||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._pipeline import (
|
from ocrmypdf._pipeline import (
|
||||||
copy_final,
|
copy_final,
|
||||||
get_pdfinfo,
|
|
||||||
render_hocr_page,
|
render_hocr_page,
|
||||||
)
|
)
|
||||||
from ocrmypdf._pipelines._common import (
|
from ocrmypdf._pipelines._common import (
|
||||||
HOCRResult,
|
HOCRResult,
|
||||||
|
do_get_pdfinfo,
|
||||||
manage_work_folder,
|
manage_work_folder,
|
||||||
postprocess,
|
postprocess,
|
||||||
report_output_pdf,
|
report_output_pdf,
|
||||||
@@ -117,15 +117,7 @@ def run_hocr_to_ocr_pdf_pipeline(
|
|||||||
origin_pdf = work_folder / 'origin.pdf'
|
origin_pdf = work_folder / 'origin.pdf'
|
||||||
|
|
||||||
# Gather pdfinfo and create context
|
# Gather pdfinfo and create context
|
||||||
pdfinfo = get_pdfinfo(
|
pdfinfo = do_get_pdfinfo(origin_pdf, executor, options)
|
||||||
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)
|
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
|
||||||
plugin_manager.hook.check_options(options=options)
|
plugin_manager.hook.check_options(options=options)
|
||||||
optimize_messages = exec_hocr_to_ocr_pdf(context, executor)
|
optimize_messages = exec_hocr_to_ocr_pdf(context, executor)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from ocrmypdf._graft import OcrGrafter
|
|||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._pipeline import (
|
from ocrmypdf._pipeline import (
|
||||||
copy_final,
|
copy_final,
|
||||||
get_pdfinfo,
|
|
||||||
is_ocr_required,
|
is_ocr_required,
|
||||||
merge_sidecars,
|
merge_sidecars,
|
||||||
ocr_engine_hocr,
|
ocr_engine_hocr,
|
||||||
@@ -33,6 +32,7 @@ from ocrmypdf._pipeline import (
|
|||||||
from ocrmypdf._pipelines._common import (
|
from ocrmypdf._pipelines._common import (
|
||||||
PageResult,
|
PageResult,
|
||||||
cli_exception_handler,
|
cli_exception_handler,
|
||||||
|
do_get_pdfinfo,
|
||||||
manage_debug_log_handler,
|
manage_debug_log_handler,
|
||||||
manage_work_folder,
|
manage_work_folder,
|
||||||
postprocess,
|
postprocess,
|
||||||
@@ -103,14 +103,14 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
|||||||
try:
|
try:
|
||||||
set_thread_pageno(result.pageno + 1)
|
set_thread_pageno(result.pageno + 1)
|
||||||
sidecars[result.pageno] = result.text
|
sidecars[result.pageno] = result.text
|
||||||
pbar.update()
|
pbar.update(0.5)
|
||||||
ocrgraft.graft_page(
|
ocrgraft.graft_page(
|
||||||
pageno=result.pageno,
|
pageno=result.pageno,
|
||||||
image=result.pdf_page_from_image,
|
image=result.pdf_page_from_image,
|
||||||
textpdf=result.ocr,
|
textpdf=result.ocr,
|
||||||
autorotate_correction=result.orientation_correction,
|
autorotate_correction=result.orientation_correction,
|
||||||
)
|
)
|
||||||
pbar.update()
|
pbar.update(0.5)
|
||||||
finally:
|
finally:
|
||||||
set_thread_pageno(None)
|
set_thread_pageno(None)
|
||||||
|
|
||||||
@@ -118,10 +118,9 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
|||||||
use_threads=options.use_threads,
|
use_threads=options.use_threads,
|
||||||
max_workers=max_workers,
|
max_workers=max_workers,
|
||||||
progress_kwargs=dict(
|
progress_kwargs=dict(
|
||||||
total=(2 * len(context.pdfinfo)),
|
total=len(context.pdfinfo),
|
||||||
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
|
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
|
||||||
unit='page',
|
unit='page',
|
||||||
unit_scale=0.5,
|
|
||||||
disable=not options.progress_bar,
|
disable=not options.progress_bar,
|
||||||
),
|
),
|
||||||
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
|
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
|
||||||
@@ -172,16 +171,7 @@ def _run_pipeline(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Gather pdfinfo and create context
|
# Gather pdfinfo and create context
|
||||||
pdfinfo = get_pdfinfo(
|
pdfinfo = do_get_pdfinfo(origin_pdf, executor, options)
|
||||||
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)
|
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
|
||||||
|
|
||||||
# Validate options are okay for this pdf
|
# Validate options are okay for this pdf
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import PIL
|
|||||||
from ocrmypdf._concurrent import Executor
|
from ocrmypdf._concurrent import Executor
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._pipeline import (
|
from ocrmypdf._pipeline import (
|
||||||
get_pdfinfo,
|
|
||||||
is_ocr_required,
|
is_ocr_required,
|
||||||
ocr_engine_hocr,
|
ocr_engine_hocr,
|
||||||
validate_pdfinfo_options,
|
validate_pdfinfo_options,
|
||||||
)
|
)
|
||||||
from ocrmypdf._pipelines._common import (
|
from ocrmypdf._pipelines._common import (
|
||||||
HOCRResult,
|
HOCRResult,
|
||||||
|
do_get_pdfinfo,
|
||||||
manage_work_folder,
|
manage_work_folder,
|
||||||
process_page,
|
process_page,
|
||||||
set_thread_pageno,
|
set_thread_pageno,
|
||||||
@@ -94,18 +94,11 @@ def run_hocr_pipeline(
|
|||||||
work_folder=options.output_folder, retain=True, print_location=False
|
work_folder=options.output_folder, retain=True, print_location=False
|
||||||
) as work_folder:
|
) as work_folder:
|
||||||
executor = setup_pipeline(options, plugin_manager)
|
executor = setup_pipeline(options, plugin_manager)
|
||||||
shutil.copy2(options.input_file, work_folder / 'origin.pdf')
|
origin_pdf = work_folder / 'origin.pdf'
|
||||||
|
shutil.copy2(options.input_file, origin_pdf)
|
||||||
|
|
||||||
# Gather pdfinfo and create context
|
# Gather pdfinfo and create context
|
||||||
pdfinfo = get_pdfinfo(
|
pdfinfo = do_get_pdfinfo(origin_pdf, executor, options)
|
||||||
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(
|
context = PdfContext(
|
||||||
options, work_folder, options.input_file, pdfinfo, plugin_manager
|
options, work_folder, options.input_file, pdfinfo, plugin_manager
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class ProgressBar(Protocol):
|
|||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
"""Exit a progress bar context."""
|
"""Exit a progress bar context."""
|
||||||
|
|
||||||
def update(self, n=1):
|
def update(self, n=1, *, completed=None):
|
||||||
"""Update the progress bar by an increment.
|
"""Update the progress bar by an increment.
|
||||||
|
|
||||||
For use within a progress bar context.
|
For use within a progress bar context.
|
||||||
@@ -85,7 +85,7 @@ class NullProgressBar:
|
|||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update(self, _arg=None):
|
def update(self, _arg=None, *, completed=None):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ class RichProgressBar:
|
|||||||
disable: bool = False,
|
disable: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
|
self._entered = False
|
||||||
self.progress = Progress(
|
self.progress = Progress(
|
||||||
TextColumn(
|
TextColumn(
|
||||||
"[progress.description]{task.description}",
|
"[progress.description]{task.description}",
|
||||||
@@ -130,6 +131,7 @@ class RichProgressBar:
|
|||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self.progress.start()
|
self.progress.start()
|
||||||
|
self._entered = True
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
@@ -137,6 +139,10 @@ class RichProgressBar:
|
|||||||
self.progress.stop()
|
self.progress.stop()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update(self, value=None):
|
def update(self, n=1, *, completed=None):
|
||||||
advance = self.unit_scale if value is None else value
|
assert self._entered, "Progress bar must be entered before updating"
|
||||||
self.progress.update(self.progress_bar, advance=advance)
|
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)
|
||||||
|
|||||||
+16
-23
@@ -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,
|
||||||
@@ -27,20 +28,18 @@ from ocrmypdf.exceptions import (
|
|||||||
MissingDependencyError,
|
MissingDependencyError,
|
||||||
OutputFileAccessError,
|
OutputFileAccessError,
|
||||||
)
|
)
|
||||||
from ocrmypdf.helpers import is_file_writable, monotonic, safe_symlink
|
from ocrmypdf.helpers import (
|
||||||
|
is_file_writable,
|
||||||
|
monotonic,
|
||||||
|
running_in_docker,
|
||||||
|
running_in_snap,
|
||||||
|
safe_symlink,
|
||||||
|
)
|
||||||
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(
|
||||||
@@ -61,6 +60,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)
|
||||||
@@ -130,6 +130,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',
|
||||||
@@ -238,18 +243,6 @@ def check_options(options: Namespace, plugin_manager: PluginManager) -> None:
|
|||||||
_check_plugin_options(options, plugin_manager)
|
_check_plugin_options(options, plugin_manager)
|
||||||
|
|
||||||
|
|
||||||
def _in_docker():
|
|
||||||
return Path('/.dockerenv').exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _in_snap():
|
|
||||||
try:
|
|
||||||
cgroup_text = Path('/proc/self/cgroup').read_text()
|
|
||||||
return 'snap.ocrmypdf' in cgroup_text
|
|
||||||
except FileNotFoundError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]:
|
def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]:
|
||||||
if options.input_file == '-':
|
if options.input_file == '-':
|
||||||
# stdin
|
# stdin
|
||||||
@@ -273,7 +266,7 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
|
|||||||
return target, os.fspath(options.input_file)
|
return target, os.fspath(options.input_file)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
msg = f"File not found - {options.input_file}"
|
msg = f"File not found - {options.input_file}"
|
||||||
if _in_docker(): # pragma: no cover
|
if running_in_docker(): # pragma: no cover
|
||||||
msg += (
|
msg += (
|
||||||
"\nDocker cannot access 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"
|
||||||
@@ -283,7 +276,7 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
|
|||||||
"\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf"
|
"\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf"
|
||||||
"\n"
|
"\n"
|
||||||
)
|
)
|
||||||
elif _in_snap(): # pragma: no cover
|
elif running_in_snap(): # pragma: no cover
|
||||||
msg += (
|
msg += (
|
||||||
"\nSnap applications cannot access files outside of "
|
"\nSnap applications cannot access files outside of "
|
||||||
"your home directory unless you explicitly allow it. "
|
"your home directory unless you explicitly allow it. "
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
"""Get version by introspecting package information.
|
|
||||||
|
|
||||||
OCRmyPDF uses setuptools_scm to derive version from git tags.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from importlib.metadata import version as _package_version
|
|
||||||
|
|
||||||
PROGRAM_NAME = 'ocrmypdf'
|
|
||||||
|
|
||||||
# Official PEP 396
|
|
||||||
__version__ = _package_version('ocrmypdf')
|
|
||||||
+7
-7
@@ -14,7 +14,7 @@ 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
|
from typing import BinaryIO
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
import pluggy
|
import pluggy
|
||||||
@@ -28,7 +28,7 @@ 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 = Path | AnyStr
|
StrPath = Path | str | bytes
|
||||||
PathOrIO = BinaryIO | StrPath
|
PathOrIO = BinaryIO | StrPath
|
||||||
|
|
||||||
# Installing plugins affects the global state of the Python interpreter,
|
# Installing plugins affects the global state of the Python interpreter,
|
||||||
@@ -140,9 +140,9 @@ def configure_logging(
|
|||||||
|
|
||||||
def _kwargs_to_cmdline(
|
def _kwargs_to_cmdline(
|
||||||
*, defer_kwargs: set[str], **kwargs
|
*, defer_kwargs: set[str], **kwargs
|
||||||
) -> tuple[list[str], dict[str, AnyStr]]:
|
) -> tuple[list[str | bytes], dict[str, str | bytes]]:
|
||||||
"""Convert kwargs to command line arguments."""
|
"""Convert kwargs to command line arguments."""
|
||||||
cmdline = []
|
cmdline: list[str | bytes] = []
|
||||||
deferred = {}
|
deferred = {}
|
||||||
for arg, val in kwargs.items():
|
for arg, val in kwargs.items():
|
||||||
if val is None:
|
if val is None:
|
||||||
@@ -279,7 +279,7 @@ def ocr( # noqa: D417
|
|||||||
fast_web_view: float | None = None,
|
fast_web_view: float | None = None,
|
||||||
continue_on_soft_render_error: bool | None = None,
|
continue_on_soft_render_error: bool | None = None,
|
||||||
invalidate_digital_signatures: bool | None = None,
|
invalidate_digital_signatures: bool | None = None,
|
||||||
plugins: Iterable[StrPath] | None = None,
|
plugins: Iterable[Path | str] | None = None,
|
||||||
plugin_manager=None,
|
plugin_manager=None,
|
||||||
keep_temporary_files: bool | None = None,
|
keep_temporary_files: bool | None = None,
|
||||||
progress_bar: bool | None = None,
|
progress_bar: bool | None = None,
|
||||||
@@ -420,7 +420,7 @@ def _pdf_to_hocr( # noqa: D417
|
|||||||
continue_on_soft_render_error: bool | None = None,
|
continue_on_soft_render_error: bool | None = None,
|
||||||
invalidate_digital_signatures: bool | None = None,
|
invalidate_digital_signatures: bool | None = None,
|
||||||
plugin_manager=None,
|
plugin_manager=None,
|
||||||
plugins: Sequence[StrPath] | None = None,
|
plugins: Sequence[Path | str] | None = None,
|
||||||
keep_temporary_files: bool | None = None,
|
keep_temporary_files: bool | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -491,7 +491,7 @@ def _hocr_to_ocr_pdf( # noqa: D417
|
|||||||
color_conversion_strategy: str | None = None,
|
color_conversion_strategy: str | None = None,
|
||||||
fast_web_view: float | None = None,
|
fast_web_view: float | None = None,
|
||||||
plugin_manager=None,
|
plugin_manager=None,
|
||||||
plugins: Sequence[StrPath] | None = None,
|
plugins: Sequence[Path | str] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Run OCRmyPDF on a work folder and produce an output PDF.
|
"""Run OCRmyPDF on a work folder and produce an output PDF.
|
||||||
|
|||||||
@@ -129,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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -144,6 +145,12 @@ 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':
|
||||||
@@ -164,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
|
||||||
|
|||||||
+3
-2
@@ -9,7 +9,8 @@ import argparse
|
|||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from ocrmypdf._version import PROGRAM_NAME as _PROGRAM_NAME
|
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||||
|
from ocrmypdf._defaults import PROGRAM_NAME as _PROGRAM_NAME
|
||||||
from ocrmypdf._version import __version__ as _VERSION
|
from ocrmypdf._version import __version__ as _VERSION
|
||||||
|
|
||||||
T = TypeVar('T', int, float)
|
T = TypeVar('T', int, float)
|
||||||
@@ -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 "
|
||||||
|
|||||||
+17
-1
@@ -268,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():
|
||||||
@@ -333,3 +335,17 @@ def pikepdf_enable_mmap() -> None:
|
|||||||
)
|
)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
log.debug("pikepdf mmap not available")
|
log.debug("pikepdf mmap not available")
|
||||||
|
|
||||||
|
|
||||||
|
def running_in_docker() -> bool:
|
||||||
|
"""Returns True if we seem to be running in a Docker container."""
|
||||||
|
return Path('/.dockerenv').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def running_in_snap() -> bool:
|
||||||
|
"""Returns True if we seem to be running in a Snap container."""
|
||||||
|
try:
|
||||||
|
cgroup_text = Path('/proc/self/cgroup').read_text()
|
||||||
|
return 'snap.ocrmypdf' in cgroup_text
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
|||||||
@@ -61,15 +61,25 @@ class HocrTransform:
|
|||||||
"""A class for converting documents from the hOCR format.
|
"""A class for converting documents from the hOCR format.
|
||||||
|
|
||||||
For details of the hOCR format, see:
|
For details of the hOCR format, see:
|
||||||
http://kba.cloud/hocr-spec/.
|
http://kba.github.io/hocr-spec/1.2/.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
box_pattern = re.compile(r'bbox (\d+) (\d+) (\d+) (\d+)')
|
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(
|
baseline_pattern = re.compile(
|
||||||
r'''
|
r'''
|
||||||
baseline \s+
|
baseline \s+
|
||||||
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
|
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
|
||||||
([\-\+]?\d+) # +/- int''',
|
([\-\+]?\d+) # +/- int
|
||||||
|
''',
|
||||||
re.VERBOSE,
|
re.VERBOSE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,15 +127,12 @@ class HocrTransform:
|
|||||||
# Stop after first div that has page coordinates
|
# Stop after first div that has page coordinates
|
||||||
break
|
break
|
||||||
|
|
||||||
def _get_element_text(self, element: Element):
|
def _get_element_text(self, element: Element) -> str:
|
||||||
"""Return the textual content of the element and its children."""
|
"""Return the textual content of the element and its children."""
|
||||||
text = ''
|
text = element.text if element.text is not None else ''
|
||||||
if element.text is not None:
|
|
||||||
text += element.text
|
|
||||||
for child in element:
|
for child in element:
|
||||||
text += self._get_element_text(child)
|
text += self._get_element_text(child)
|
||||||
if element.tail is not None:
|
text += element.tail if element.tail is not None else ''
|
||||||
text += element.tail
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -286,7 +293,13 @@ class HocrTransform:
|
|||||||
line_box = self.element_coordinates(line)
|
line_box = self.element_coordinates(line)
|
||||||
if not line_box:
|
if not line_box:
|
||||||
return
|
return
|
||||||
assert line_box.ury > line_box.lly # lly is top, ury is bottom
|
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)
|
self._debug_draw_line_bbox(canvas, line_box)
|
||||||
|
|
||||||
@@ -344,7 +357,7 @@ class HocrTransform:
|
|||||||
line_matrix: Matrix,
|
line_matrix: Matrix,
|
||||||
text: Text,
|
text: Text,
|
||||||
fontsize: float,
|
fontsize: float,
|
||||||
elem: Element,
|
elem: Element | None,
|
||||||
next_elem: Element | None,
|
next_elem: Element | None,
|
||||||
text_direction: TextDirection,
|
text_direction: TextDirection,
|
||||||
inject_word_breaks: bool,
|
inject_word_breaks: bool,
|
||||||
@@ -383,7 +396,7 @@ class HocrTransform:
|
|||||||
)
|
)
|
||||||
if hocr_next_box is None:
|
if hocr_next_box is None:
|
||||||
return
|
return
|
||||||
# Render a space this word and the next word. The explicit space helps
|
# Render a space between this word and the next word. The explicit space helps
|
||||||
# PDF viewers identify the word break, and horizontally scaling it to
|
# PDF viewers identify the word break, and horizontally scaling it to
|
||||||
# occupy the space the between the words helps the PDF viewer
|
# occupy the space the between the words helps the PDF viewer
|
||||||
# avoid combiningthewordstogether.
|
# avoid combiningthewordstogether.
|
||||||
@@ -396,7 +409,7 @@ class HocrTransform:
|
|||||||
space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury)
|
space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury)
|
||||||
self._debug_draw_space_bbox(canvas, space_box)
|
self._debug_draw_space_bbox(canvas, space_box)
|
||||||
space_width = self._font.text_width(' ', fontsize)
|
space_width = self._font.text_width(' ', fontsize)
|
||||||
if space_width > 0:
|
if space_width > 0 and space_box.width > 0:
|
||||||
if text_direction == TextDirection.LTR:
|
if text_direction == TextDirection.LTR:
|
||||||
text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0))
|
text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0))
|
||||||
elif text_direction == TextDirection.RTL:
|
elif text_direction == TextDirection.RTL:
|
||||||
@@ -421,7 +434,7 @@ class HocrTransform:
|
|||||||
if ocr_par is None:
|
if ocr_par is None:
|
||||||
continue
|
continue
|
||||||
canvas.do.rect(
|
canvas.do.rect(
|
||||||
ocr_par.llx, ocr_par.lly, ocr_par.width, ocr_par.height, fill=0
|
ocr_par.llx, ocr_par.lly, ocr_par.width, ocr_par.height, fill=False
|
||||||
)
|
)
|
||||||
|
|
||||||
def _debug_draw_line_bbox(self, canvas: Canvas, line_box: Rectangle, color=BLUE):
|
def _debug_draw_line_bbox(self, canvas: Canvas, line_box: Rectangle, color=BLUE):
|
||||||
@@ -430,7 +443,7 @@ class HocrTransform:
|
|||||||
return
|
return
|
||||||
with canvas.do.save_state():
|
with canvas.do.save_state():
|
||||||
canvas.do.stroke_color(color).line_width(0.15).rect(
|
canvas.do.stroke_color(color).line_width(0.15).rect(
|
||||||
line_box.llx, line_box.lly, line_box.width, line_box.height, fill=0
|
line_box.llx, line_box.lly, line_box.width, line_box.height, fill=False
|
||||||
)
|
)
|
||||||
|
|
||||||
def _debug_draw_word_triangle(
|
def _debug_draw_word_triangle(
|
||||||
@@ -454,7 +467,7 @@ class HocrTransform:
|
|||||||
return
|
return
|
||||||
with canvas.do.save_state():
|
with canvas.do.save_state():
|
||||||
canvas.do.stroke_color(color).line_width(line_width).rect(
|
canvas.do.stroke_color(color).line_width(line_width).rect(
|
||||||
box.llx, box.lly, box.width, box.height, fill=0
|
box.llx, box.lly, box.width, box.height, fill=False
|
||||||
)
|
)
|
||||||
|
|
||||||
def _debug_draw_space_bbox(
|
def _debug_draw_space_bbox(
|
||||||
@@ -465,7 +478,7 @@ class HocrTransform:
|
|||||||
return
|
return
|
||||||
with canvas.do.save_state():
|
with canvas.do.save_state():
|
||||||
canvas.do.fill_color(color).line_width(line_width).rect(
|
canvas.do.fill_color(color).line_width(line_width).rect(
|
||||||
box.llx, box.lly, box.width, box.height, fill=1
|
box.llx, box.lly, box.width, box.height, fill=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def _debug_draw_baseline(
|
def _debug_draw_baseline(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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
|
||||||
@@ -74,10 +75,15 @@ def extract_image_filter(
|
|||||||
"""Determine if an image is extractable."""
|
"""Determine if an image is extractable."""
|
||||||
if image.Subtype != Name.Image:
|
if image.Subtype != Name.Image:
|
||||||
return None
|
return None
|
||||||
if image.Length < 100:
|
if not isinstance(image.Length, int) or image.Length < 100:
|
||||||
log.debug(f"xref {xref}: skipping image with small stream size")
|
log.debug(f"xref {xref}: skipping image with small stream size")
|
||||||
return None
|
return None
|
||||||
if image.Width < 8 or image.Height < 8: # Issue 732
|
if (
|
||||||
|
not isinstance(image.Width, int)
|
||||||
|
or not isinstance(image.Height, int)
|
||||||
|
or image.Width < 8
|
||||||
|
or image.Height < 8
|
||||||
|
): # Issue 732
|
||||||
log.debug(f"xref {xref}: skipping image with unusually small dimensions")
|
log.debug(f"xref {xref}: skipping image with unusually small dimensions")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -153,7 +159,10 @@ def extract_image_jbig2(
|
|||||||
imgname = root / f'{xref:08d}'
|
imgname = root / f'{xref:08d}'
|
||||||
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))
|
# Rename the file so it has .prejbig2.ext extension
|
||||||
|
# Making it unique avoids problems with Windows if the
|
||||||
|
# same image is extracted multiple times
|
||||||
|
imgname.rename(imgname.with_suffix(".prejbig2" + ext))
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
if '/Decode' in str(e):
|
if '/Decode' in str(e):
|
||||||
log.debug(
|
log.debug(
|
||||||
@@ -169,7 +178,7 @@ def extract_image_jbig2(
|
|||||||
pim.obj.ColorSpace = colorspace
|
pim.obj.ColorSpace = colorspace
|
||||||
else:
|
else:
|
||||||
del pim.obj.ColorSpace
|
del pim.obj.ColorSpace
|
||||||
return XrefExt(xref, ext)
|
return XrefExt(xref, ".prejbig2" + ext)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -200,7 +209,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 (
|
||||||
@@ -256,6 +265,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}")
|
||||||
@@ -269,7 +281,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])
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import re
|
|||||||
import statistics
|
import statistics
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence
|
from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager, nullcontext
|
||||||
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
|
||||||
@@ -24,6 +24,7 @@ from warnings import warn
|
|||||||
|
|
||||||
from pdfminer.layout import LTPage, LTTextBox
|
from pdfminer.layout import LTPage, LTTextBox
|
||||||
from pikepdf import (
|
from pikepdf import (
|
||||||
|
Dictionary,
|
||||||
Matrix,
|
Matrix,
|
||||||
Name,
|
Name,
|
||||||
Object,
|
Object,
|
||||||
@@ -40,7 +41,12 @@ from ocrmypdf._concurrent import Executor, SerialExecutor
|
|||||||
from ocrmypdf._progressbar import ProgressBar
|
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,
|
||||||
|
PdfMinerState,
|
||||||
|
get_page_analysis,
|
||||||
|
get_text_boxes,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
@@ -239,7 +245,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 = Matrix(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(
|
||||||
@@ -363,8 +375,26 @@ class ImageInfo:
|
|||||||
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).
|
||||||
|
if isinstance(smask, Stream | Dictionary):
|
||||||
|
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.
|
||||||
|
# /Mask can be a Stream or an Array. If it's a Stream,
|
||||||
|
# use its /Width and /Height if they are larger than the main
|
||||||
|
# image's.
|
||||||
|
if isinstance(mask, Stream | Dictionary):
|
||||||
|
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
|
||||||
@@ -468,9 +498,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:
|
||||||
@@ -485,7 +524,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."""
|
||||||
@@ -567,7 +606,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
|
||||||
@@ -668,13 +707,13 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
|
|||||||
|
|
||||||
|
|
||||||
def simplify_textboxes(
|
def simplify_textboxes(
|
||||||
miner: LTPage, textbox_getter: Callable[[LTPage], Iterator[LTTextBox]]
|
miner_page: LTPage, textbox_getter: Callable[[LTPage], Iterator[LTTextBox]]
|
||||||
) -> Iterator[TextboxInfo]:
|
) -> Iterator[TextboxInfo]:
|
||||||
"""Extract only limited content from text boxes.
|
"""Extract only limited content from text boxes.
|
||||||
|
|
||||||
We do this to save memory and ensure that our objects are pickleable.
|
We do this to save memory and ensure that our objects are pickleable.
|
||||||
"""
|
"""
|
||||||
for box in textbox_getter(miner):
|
for box in textbox_getter(miner_page):
|
||||||
first_line = box._objs[0] # pylint: disable=protected-access
|
first_line = box._objs[0] # pylint: disable=protected-access
|
||||||
first_char = first_line._objs[0] # pylint: disable=protected-access
|
first_char = first_line._objs[0] # pylint: disable=protected-access
|
||||||
if not isinstance(first_char, LTStateAwareChar):
|
if not isinstance(first_char, LTStateAwareChar):
|
||||||
@@ -721,9 +760,12 @@ def _pdf_pageinfo_sync(
|
|||||||
infile: Path,
|
infile: Path,
|
||||||
check_pages: Container[int],
|
check_pages: Container[int],
|
||||||
detailed_analysis: bool,
|
detailed_analysis: bool,
|
||||||
|
miner_state: PdfMinerState | None,
|
||||||
) -> PageInfo:
|
) -> PageInfo:
|
||||||
with _pdf_pageinfo_sync_pdf(thread_pdf, infile) as pdf:
|
with _pdf_pageinfo_sync_pdf(thread_pdf, infile) as pdf:
|
||||||
return PageInfo(pdf, pageno, infile, check_pages, detailed_analysis)
|
return PageInfo(
|
||||||
|
pdf, pageno, infile, check_pages, detailed_analysis, miner_state
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _pdf_pageinfo_concurrent(
|
def _pdf_pageinfo_concurrent(
|
||||||
@@ -735,6 +777,7 @@ def _pdf_pageinfo_concurrent(
|
|||||||
progbar,
|
progbar,
|
||||||
check_pages,
|
check_pages,
|
||||||
detailed_analysis: bool = False,
|
detailed_analysis: bool = False,
|
||||||
|
miner_state: PdfMinerState | None = None,
|
||||||
) -> Sequence[PageInfo | None]:
|
) -> Sequence[PageInfo | None]:
|
||||||
pages: list[PageInfo | None] = [None] * len(pdf.pages)
|
pages: list[PageInfo | None] = [None] * len(pdf.pages)
|
||||||
|
|
||||||
@@ -766,7 +809,8 @@ def _pdf_pageinfo_concurrent(
|
|||||||
initial_pdf = pdf if use_threads else None
|
initial_pdf = pdf if use_threads else None
|
||||||
|
|
||||||
contexts = (
|
contexts = (
|
||||||
(n, initial_pdf, infile, check_pages, detailed_analysis) for n in range(total)
|
(n, initial_pdf, infile, check_pages, detailed_analysis, miner_state)
|
||||||
|
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(
|
logger.debug(
|
||||||
@@ -833,12 +877,15 @@ class PageInfo:
|
|||||||
infile: PathLike,
|
infile: PathLike,
|
||||||
check_pages: Container[int],
|
check_pages: Container[int],
|
||||||
detailed_analysis: bool = False,
|
detailed_analysis: bool = False,
|
||||||
|
miner_state: PdfMinerState | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize a PageInfo object."""
|
"""Initialize a PageInfo object."""
|
||||||
self._pageno = pageno
|
self._pageno = pageno
|
||||||
self._infile = infile
|
self._infile = infile
|
||||||
self._detailed_analysis = detailed_analysis
|
self._detailed_analysis = detailed_analysis
|
||||||
self._gather_pageinfo(pdf, pageno, infile, check_pages, detailed_analysis)
|
self._gather_pageinfo(
|
||||||
|
pdf, pageno, infile, check_pages, detailed_analysis, miner_state
|
||||||
|
)
|
||||||
|
|
||||||
def _gather_pageinfo(
|
def _gather_pageinfo(
|
||||||
self,
|
self,
|
||||||
@@ -847,6 +894,7 @@ class PageInfo:
|
|||||||
infile: PathLike,
|
infile: PathLike,
|
||||||
check_pages: Container[int],
|
check_pages: Container[int],
|
||||||
detailed_analysis: bool,
|
detailed_analysis: bool,
|
||||||
|
miner_state: PdfMinerState | None,
|
||||||
):
|
):
|
||||||
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()]
|
||||||
@@ -862,10 +910,11 @@ class PageInfo:
|
|||||||
check_this_page = pageno in check_pages
|
check_this_page = pageno in check_pages
|
||||||
|
|
||||||
if check_this_page and detailed_analysis:
|
if check_this_page and detailed_analysis:
|
||||||
pscript5_mode = str(pdf.docinfo.get(Name.Creator)).startswith('PScript5')
|
page_analysis = miner_state.get_page_analysis(pageno)
|
||||||
miner = get_page_analysis(infile, pageno, pscript5_mode)
|
if page_analysis is not None:
|
||||||
if miner is not None:
|
self._textboxes = list(
|
||||||
self._textboxes = list(simplify_textboxes(miner, get_text_boxes))
|
simplify_textboxes(page_analysis, get_text_boxes)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._textboxes = []
|
self._textboxes = []
|
||||||
bboxes = (box.bbox for box in self._textboxes)
|
bboxes = (box.bbox for box in self._textboxes)
|
||||||
@@ -1049,10 +1098,14 @@ 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
|
||||||
@@ -1065,7 +1118,6 @@ class PageInfo:
|
|||||||
|
|
||||||
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,
|
||||||
@@ -1115,16 +1167,26 @@ class PdfInfo:
|
|||||||
with Pdf.open(infile) as pdf:
|
with Pdf.open(infile) as pdf:
|
||||||
if pdf.is_encrypted:
|
if pdf.is_encrypted:
|
||||||
raise EncryptedPdfError() # Triggered by encryption with empty passwd
|
raise EncryptedPdfError() # Triggered by encryption with empty passwd
|
||||||
self._pages = _pdf_pageinfo_concurrent(
|
pscript5_mode = str(pdf.docinfo.get(Name.Creator, "")).startswith(
|
||||||
pdf,
|
'PScript5'
|
||||||
executor,
|
|
||||||
max_workers,
|
|
||||||
use_threads,
|
|
||||||
infile,
|
|
||||||
progbar,
|
|
||||||
check_pages=check_pages,
|
|
||||||
detailed_analysis=detailed_analysis,
|
|
||||||
)
|
)
|
||||||
|
self._miner_state = (
|
||||||
|
PdfMinerState(infile, pscript5_mode)
|
||||||
|
if detailed_analysis
|
||||||
|
else nullcontext()
|
||||||
|
)
|
||||||
|
with self._miner_state as miner_state:
|
||||||
|
self._pages = _pdf_pageinfo_concurrent(
|
||||||
|
pdf,
|
||||||
|
executor,
|
||||||
|
max_workers,
|
||||||
|
use_threads,
|
||||||
|
infile,
|
||||||
|
progbar,
|
||||||
|
check_pages=check_pages,
|
||||||
|
detailed_analysis=detailed_analysis,
|
||||||
|
miner_state=miner_state,
|
||||||
|
)
|
||||||
self._needs_rendering = pdf.Root.get(Name.NeedsRendering, False)
|
self._needs_rendering = pdf.Root.get(Name.NeedsRendering, False)
|
||||||
if Name.AcroForm in pdf.Root:
|
if Name.AcroForm in pdf.Root:
|
||||||
if len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0:
|
if len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import pdfminer
|
|||||||
import pdfminer.encodingdb
|
import pdfminer.encodingdb
|
||||||
import pdfminer.pdfdevice
|
import pdfminer.pdfdevice
|
||||||
import pdfminer.pdfinterp
|
import pdfminer.pdfinterp
|
||||||
|
import pdfminer.psparser
|
||||||
|
from deprecation import deprecated
|
||||||
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 +60,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):
|
||||||
@@ -287,6 +290,7 @@ def patch_pdfminer(pscript5_mode: bool):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated(deprecated_in='16.6.0', details='Use PdfMinerState instead.')
|
||||||
def get_page_analysis(
|
def get_page_analysis(
|
||||||
infile: PathLike, pageno: int, pscript5_mode: bool
|
infile: PathLike, pageno: int, pscript5_mode: bool
|
||||||
) -> LTPage | None:
|
) -> LTPage | None:
|
||||||
@@ -317,6 +321,73 @@ def get_page_analysis(
|
|||||||
return dev.get_result()
|
return dev.get_result()
|
||||||
|
|
||||||
|
|
||||||
|
class PdfMinerState:
|
||||||
|
"""Provide a context manager for using pdfminer.six.
|
||||||
|
|
||||||
|
This ensures that the file is closed. It also provides a cache of pages
|
||||||
|
from the PDF so that they can be reused if needed, to improve performance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, infile: Path, pscript5_mode: bool) -> None:
|
||||||
|
"""Initialize the context manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
infile: The path to the PDF file to be analyzed.
|
||||||
|
pscript5_mode: Whether the PDF was generated by PScript5.dll.
|
||||||
|
"""
|
||||||
|
self.infile = infile
|
||||||
|
self.rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
||||||
|
self.disable_boxes_flow = None
|
||||||
|
self.page_cache: list[PDFPage] = []
|
||||||
|
self.pscript5_mode = pscript5_mode
|
||||||
|
self.file = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Enter the context manager."""
|
||||||
|
self.file = Path(self.infile).open('rb')
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
"""Exit the context manager."""
|
||||||
|
if self.file:
|
||||||
|
self.file.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _load_page_cache(self):
|
||||||
|
"""Load the page cache."""
|
||||||
|
try:
|
||||||
|
self.page_cache = list(PDFPage.get_pages(self.file))
|
||||||
|
if not self.page_cache:
|
||||||
|
raise InputFileError(
|
||||||
|
"pdfminer did not find any pages in the input file."
|
||||||
|
)
|
||||||
|
for n, page in enumerate(self.page_cache):
|
||||||
|
if page is None:
|
||||||
|
raise InputFileError(
|
||||||
|
f"pdfminer could not process page {n} (counting from 0)."
|
||||||
|
)
|
||||||
|
except PDFTextExtractionNotAllowed as e:
|
||||||
|
raise EncryptedPdfError() from e
|
||||||
|
|
||||||
|
def get_page_analysis(self, pageno: int):
|
||||||
|
"""Get the page analysis for a given page."""
|
||||||
|
if not self.page_cache:
|
||||||
|
self._load_page_cache()
|
||||||
|
page = self.page_cache[pageno]
|
||||||
|
dev = TextPositionTracker(
|
||||||
|
self.rman,
|
||||||
|
laparams=LAParams(
|
||||||
|
all_texts=True, detect_vertical=True, boxes_flow=self.disable_boxes_flow
|
||||||
|
),
|
||||||
|
)
|
||||||
|
interp = pdfminer.pdfinterp.PDFPageInterpreter(self.rman, dev)
|
||||||
|
|
||||||
|
with patch_pdfminer(self.pscript5_mode):
|
||||||
|
interp.process_page(page)
|
||||||
|
|
||||||
|
return dev.get_result()
|
||||||
|
|
||||||
|
|
||||||
def get_text_boxes(obj) -> Iterator[LTTextBox]:
|
def get_text_boxes(obj) -> Iterator[LTTextBox]:
|
||||||
"""Get the text boxes attached to the current node."""
|
"""Get the text boxes attached to the current node."""
|
||||||
for child in obj:
|
for child in obj:
|
||||||
|
|||||||
@@ -145,13 +145,15 @@ def get_progressbar_class() -> type[ProgressBar]:
|
|||||||
The class returned by this function must be compatible with the
|
The class returned by this function must be compatible with the
|
||||||
:class:`ProgressBar` protocol.
|
:class:`ProgressBar` protocol.
|
||||||
|
|
||||||
Here is how OCRmyPDF will use the progress bar:
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
pbar_class = pm.hook.get_progressbar_class()
|
Here is how OCRmyPDF will use the progress bar:
|
||||||
with pbar_class(**progress_kwargs) as pbar:
|
|
||||||
...
|
.. code-block:: python
|
||||||
pbar.update(1)
|
|
||||||
|
pbar_class = pm.hook.get_progressbar_class()
|
||||||
|
with pbar_class(**progress_kwargs) as pbar:
|
||||||
|
... # do some work
|
||||||
|
pbar.update(1)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -215,8 +215,10 @@ to have {found_version}. Please update this program.
|
|||||||
|
|
||||||
OLD_VERSION_REQUIRED_FOR = '''
|
OLD_VERSION_REQUIRED_FOR = '''
|
||||||
OCRmyPDF requires '{program}' {need_version} or higher when run with the
|
OCRmyPDF requires '{program}' {need_version} or higher when run with the
|
||||||
{required_for} arguments. If you omit these arguments, OCRmyPDF may be able to
|
{required_for} arguments. {program} {found_version} is installed.
|
||||||
proceed. For best results, install the program.
|
|
||||||
|
If you omit these arguments, OCRmyPDF may be able to
|
||||||
|
proceed. For best results, update the program.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
OSX_INSTALL_ADVICE = '''
|
OSX_INSTALL_ADVICE = '''
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ def is_macos():
|
|||||||
return platform.system() == 'Darwin'
|
return platform.system() == 'Darwin'
|
||||||
|
|
||||||
|
|
||||||
def running_in_docker():
|
|
||||||
# Docker creates a file named /.dockerenv in all supported versions
|
|
||||||
return Path('/.dockerenv').exists()
|
|
||||||
|
|
||||||
|
|
||||||
def have_unpaper():
|
def have_unpaper():
|
||||||
try:
|
try:
|
||||||
unpaper.version()
|
unpaper.version()
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pikepdf import Array, Dictionary, Name, NameTree, Pdf
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_broken_goto_annotations(resources):
|
||||||
|
with Pdf.open(resources / 'link.pdf') as pdf:
|
||||||
|
assert not remove_broken_goto_annotations(pdf), "File should not be modified"
|
||||||
|
|
||||||
|
# Construct Dests nametree
|
||||||
|
nt = NameTree.new(pdf)
|
||||||
|
names = pdf.Root[Name.Names] = pdf.make_indirect(Dictionary())
|
||||||
|
names[Name.Dests] = nt.obj
|
||||||
|
# Create a broken named destination
|
||||||
|
nt['Invalid'] = pdf.make_indirect(Dictionary())
|
||||||
|
# Create a valid named destination
|
||||||
|
nt['Valid'] = Array([pdf.pages[0].obj, Name.XYZ, 0, 0, 0])
|
||||||
|
|
||||||
|
pdf.pages[0].Annots[0].A.D = 'Missing'
|
||||||
|
pdf.pages[1].Annots[0].A.D = 'Valid'
|
||||||
|
|
||||||
|
assert remove_broken_goto_annotations(pdf), "File should be modified"
|
||||||
|
|
||||||
|
assert Name.D not in pdf.pages[0].Annots[0].A
|
||||||
|
assert Name.D in pdf.pages[1].Annots[0].A
|
||||||
+29
-1
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pickle
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ import pytest
|
|||||||
from pdfminer.high_level import extract_text
|
from pdfminer.high_level import extract_text
|
||||||
|
|
||||||
import ocrmypdf
|
import ocrmypdf
|
||||||
|
import ocrmypdf._pipelines
|
||||||
import ocrmypdf.api
|
import ocrmypdf.api
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ def test_sidecar_stringio(resources: Path, outdir: Path, outpdf: Path):
|
|||||||
resources / 'ccitt.pdf',
|
resources / 'ccitt.pdf',
|
||||||
outpdf,
|
outpdf,
|
||||||
plugins=['tests/plugins/tesseract_cache.py'],
|
plugins=['tests/plugins/tesseract_cache.py'],
|
||||||
sidecar=s
|
sidecar=s,
|
||||||
)
|
)
|
||||||
s.seek(0)
|
s.seek(0)
|
||||||
assert b'the' in s.getvalue()
|
assert b'the' in s.getvalue()
|
||||||
@@ -75,3 +77,29 @@ def test_hocr_to_pdf_api(resources: Path, outdir: Path, outpdf: Path):
|
|||||||
text = extract_text(outpdf)
|
text = extract_text(outpdf)
|
||||||
assert 'hocr' in text and 'the' not in text
|
assert 'hocr' in text and 'the' not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_hocr_result_json():
|
||||||
|
result = ocrmypdf._pipelines._common.HOCRResult(
|
||||||
|
pageno=1,
|
||||||
|
pdf_page_from_image=Path('a'),
|
||||||
|
hocr=Path('b'),
|
||||||
|
textpdf=Path('c'),
|
||||||
|
orientation_correction=180,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
result.to_json()
|
||||||
|
== '{"pageno": 1, "pdf_page_from_image": {"Path": "a"}, "hocr": {"Path": "b"}, '
|
||||||
|
'"textpdf": {"Path": "c"}, "orientation_correction": 180}'
|
||||||
|
)
|
||||||
|
assert ocrmypdf._pipelines._common.HOCRResult.from_json(result.to_json()) == result
|
||||||
|
|
||||||
|
|
||||||
|
def test_hocr_result_pickle():
|
||||||
|
result = ocrmypdf._pipelines._common.HOCRResult(
|
||||||
|
pageno=1,
|
||||||
|
pdf_page_from_image=Path('a'),
|
||||||
|
hocr=Path('b'),
|
||||||
|
textpdf=Path('c'),
|
||||||
|
orientation_correction=180,
|
||||||
|
)
|
||||||
|
assert result == pickle.loads(pickle.dumps(result))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from subprocess import run
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .conftest import running_in_docker
|
from ocrmypdf.helpers import running_in_docker
|
||||||
|
|
||||||
pytestmark = pytest.mark.skipif(
|
pytestmark = pytest.mark.skipif(
|
||||||
running_in_docker(),
|
running_in_docker(),
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def test_ghostscript_mandatory_color_conversion(resources, outpdf):
|
|||||||
|
|
||||||
def test_rasterize_pdf_errors(resources, no_outpdf, caplog):
|
def test_rasterize_pdf_errors(resources, no_outpdf, caplog):
|
||||||
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
||||||
# ghostscript can produce
|
# ghostscript can produce empty files with return code 0
|
||||||
mock.return_value = subprocess.CompletedProcess(
|
mock.return_value = subprocess.CompletedProcess(
|
||||||
['fakegs'], returncode=0, stdout=b'', stderr=b'error this is an error'
|
['fakegs'], returncode=0, stdout=b'', stderr=b'error this is an error'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,3 +40,72 @@ def test_links(resources, outpdf):
|
|||||||
p2 = pdf.pages[1]
|
p2 = pdf.pages[1]
|
||||||
assert p1.Annots[0].A.D[0].objgen == p2.objgen
|
assert p1.Annots[0].A.D[0].objgen == p2.objgen
|
||||||
assert p2.Annots[0].A.D[0].objgen == p1.objgen
|
assert p2.Annots[0].A.D[0].objgen == p1.objgen
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_invisble_text():
|
||||||
|
pdf = pikepdf.Pdf.new()
|
||||||
|
print(pikepdf.parse_content_stream(pikepdf.Stream(pdf, b'3 Tr')))
|
||||||
|
page = pdf.add_blank_page()
|
||||||
|
visible_text = [
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('BT')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.Name('/F0'), 12), pikepdf.Operator('Tf')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((288, 720), pikepdf.Operator('Td')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.String('visible'),), pikepdf.Operator('Tj')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('ET')),
|
||||||
|
]
|
||||||
|
invisible_text = [
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('BT')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.Name('/F0'), 12), pikepdf.Operator('Tf')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((288, 720), pikepdf.Operator('Td')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.String('invisible'),), pikepdf.Operator('Tj')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('ET')),
|
||||||
|
]
|
||||||
|
invisible_text_setting_tr = [
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('BT')),
|
||||||
|
pikepdf.ContentStreamInstruction([3], pikepdf.Operator('Tr')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.Name('/F0'), 12), pikepdf.Operator('Tf')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((288, 720), pikepdf.Operator('Td')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.String('invisible'),), pikepdf.Operator('Tj')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('ET')),
|
||||||
|
]
|
||||||
|
stream = [
|
||||||
|
pikepdf.ContentStreamInstruction([], pikepdf.Operator('q')),
|
||||||
|
pikepdf.ContentStreamInstruction([3], pikepdf.Operator('Tr')),
|
||||||
|
*invisible_text,
|
||||||
|
pikepdf.ContentStreamInstruction([], pikepdf.Operator('Q')),
|
||||||
|
*visible_text,
|
||||||
|
*invisible_text_setting_tr,
|
||||||
|
*invisible_text,
|
||||||
|
]
|
||||||
|
content_stream = pikepdf.unparse_content_stream(stream)
|
||||||
|
page.Contents = pikepdf.Stream(pdf, content_stream)
|
||||||
|
|
||||||
|
def count(string, page):
|
||||||
|
return len(
|
||||||
|
[
|
||||||
|
True
|
||||||
|
for operands, operator in pikepdf.parse_content_stream(page)
|
||||||
|
if operator == pikepdf.Operator('Tj')
|
||||||
|
and operands[0] == pikepdf.String(string)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
nr_visible_pre = count('visible', page)
|
||||||
|
ocrmypdf._graft.strip_invisible_text(pdf, page)
|
||||||
|
nr_visible_post = count('visible', page)
|
||||||
|
assert (
|
||||||
|
nr_visible_pre == nr_visible_post
|
||||||
|
), 'Number of visible text elements did not change'
|
||||||
|
assert count('invisible', page) == 0, 'No invisible elems left'
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import pytest
|
|||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
|
|
||||||
from ocrmypdf import helpers
|
from ocrmypdf import helpers
|
||||||
|
from ocrmypdf.helpers import running_in_docker
|
||||||
from .conftest import running_in_docker
|
|
||||||
|
|
||||||
needs_symlink = pytest.mark.skipif(os.name == 'nt', reason='needs posix symlink')
|
needs_symlink = pytest.mark.skipif(os.name == 'nt', reason='needs posix symlink')
|
||||||
windows_only = pytest.mark.skipif(os.name != 'nt', reason="Windows test")
|
windows_only = pytest.mark.skipif(os.name != 'nt', reason="Windows test")
|
||||||
|
|||||||
+1
-1
@@ -18,6 +18,7 @@ from PIL import Image
|
|||||||
import ocrmypdf
|
import ocrmypdf
|
||||||
from ocrmypdf._exec import tesseract
|
from ocrmypdf._exec import tesseract
|
||||||
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
|
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
|
||||||
|
from ocrmypdf.helpers import running_in_docker
|
||||||
from ocrmypdf.pdfa import file_claims_pdfa
|
from ocrmypdf.pdfa import file_claims_pdfa
|
||||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo
|
from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo
|
||||||
from ocrmypdf.subprocess import get_version
|
from ocrmypdf.subprocess import get_version
|
||||||
@@ -29,7 +30,6 @@ from .conftest import (
|
|||||||
is_macos,
|
is_macos,
|
||||||
run_ocrmypdf,
|
run_ocrmypdf,
|
||||||
run_ocrmypdf_api,
|
run_ocrmypdf_api,
|
||||||
running_in_docker,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name
|
# pylint: disable=redefined-outer-name
|
||||||
|
|||||||
@@ -208,12 +208,13 @@ def test_pages_issue700(monkeypatch, resources):
|
|||||||
monkeypatch.setattr(PDFPage, 'get_pages', get_no_pages)
|
monkeypatch.setattr(PDFPage, 'get_pages', get_no_pages)
|
||||||
|
|
||||||
with pytest.raises(InputFileError, match="pdfminer"):
|
with pytest.raises(InputFileError, match="pdfminer"):
|
||||||
pdfinfo.PdfInfo(
|
pi = pdfinfo.PdfInfo(
|
||||||
resources / 'cardinal.pdf',
|
resources / 'cardinal.pdf',
|
||||||
detailed_analysis=True,
|
detailed_analysis=True,
|
||||||
progbar=False,
|
progbar=False,
|
||||||
max_workers=1,
|
max_workers=1,
|
||||||
)
|
)
|
||||||
|
pi._miner_state.get_page_analysis(0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -239,7 +239,9 @@ def make_rotate_test(imagefile, outdir, prefix, image_angle, page_angle):
|
|||||||
@pytest.mark.parametrize('image_angle', (0, 90, 180, 270))
|
@pytest.mark.parametrize('image_angle', (0, 90, 180, 270))
|
||||||
def test_rotate_page_level(image_angle, page_angle, resources, outdir, caplog):
|
def test_rotate_page_level(image_angle, page_angle, resources, outdir, caplog):
|
||||||
reference = make_rotate_test(resources / 'typewriter.png', outdir, 'ref', 0, 0)
|
reference = make_rotate_test(resources / 'typewriter.png', outdir, 'ref', 0, 0)
|
||||||
test = make_rotate_test(resources, outdir, 'test', image_angle, page_angle)
|
test = make_rotate_test(
|
||||||
|
resources / 'typewriter.png', outdir, 'test', image_angle, page_angle
|
||||||
|
)
|
||||||
out = test.with_suffix('.out.pdf')
|
out = test.with_suffix('.out.pdf')
|
||||||
|
|
||||||
exitcode = run_ocrmypdf_api(
|
exitcode = run_ocrmypdf_api(
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ def test_semfree(resources, outpdf):
|
|||||||
'--plugin',
|
'--plugin',
|
||||||
'tests/plugins/tesseract_noop.py',
|
'tests/plugins/tesseract_noop.py',
|
||||||
)
|
)
|
||||||
assert exitcode == ExitCode.ok
|
assert exitcode in (ExitCode.ok, ExitCode.pdfa_conversion_failed)
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import pytest
|
|||||||
|
|
||||||
from ocrmypdf import pdfinfo
|
from ocrmypdf import pdfinfo
|
||||||
from ocrmypdf._exec import tesseract
|
from ocrmypdf._exec import tesseract
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
from ocrmypdf.exceptions import BadArgsError, ExitCode, MissingDependencyError
|
||||||
|
|
||||||
from .conftest import check_ocrmypdf
|
from .conftest import check_ocrmypdf, run_ocrmypdf_api
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
@@ -144,3 +144,10 @@ def test_tesseract_log_output_raises(caplog):
|
|||||||
with pytest.raises(tesseract.TesseractConfigError):
|
with pytest.raises(tesseract.TesseractConfigError):
|
||||||
tesseract.tesseract_log_output(b'parameter not found: moo')
|
tesseract.tesseract_log_output(b'parameter not found: moo')
|
||||||
assert 'not found' in caplog.text
|
assert 'not found' in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_language(resources, no_outpdf):
|
||||||
|
infile = resources / 'masks.pdf'
|
||||||
|
for bad_lang in ['osd', 'equ']:
|
||||||
|
with pytest.raises(BadArgsError):
|
||||||
|
run_ocrmypdf_api(infile, no_outpdf, '-l', bad_lang)
|
||||||
|
|||||||
+14
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
@@ -48,6 +48,19 @@ def test_old_unpaper(resources, no_outpdf):
|
|||||||
mock.assert_called()
|
mock.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unpaper_version_chatter(resources, no_outpdf):
|
||||||
|
input_ = fspath(resources / "c02-22.pdf")
|
||||||
|
output = fspath(no_outpdf)
|
||||||
|
|
||||||
|
_parser, options, pm = get_parser_options_plugins(["--clean", input_, output])
|
||||||
|
with patch("ocrmypdf.subprocess.run") as mock:
|
||||||
|
mock.return_value = Mock(stdout='Warning: using insecure memory!\n7.0.0\n')
|
||||||
|
|
||||||
|
with pytest.raises(MissingDependencyError):
|
||||||
|
check_options(options, pm)
|
||||||
|
mock.assert_called()
|
||||||
|
|
||||||
|
|
||||||
@needs_unpaper
|
@needs_unpaper
|
||||||
def test_clean(resources, outpdf):
|
def test_clean(resources, outpdf):
|
||||||
check_ocrmypdf(
|
check_ocrmypdf(
|
||||||
|
|||||||
Reference in New Issue
Block a user