Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cda02317b | ||
|
|
089f46690a | ||
|
|
e45c40b063 | ||
|
|
bbac5307f2 | ||
|
|
6167783696 | ||
|
|
3d291e72c0 | ||
|
|
efebe9ca2e | ||
|
|
12ec97f732 | ||
|
|
3f1aceade2 | ||
|
|
212b28e602 | ||
|
|
dfdb32995e | ||
|
|
273826377e | ||
|
|
5569d4db07 | ||
|
|
8de7b05fb9 | ||
|
|
72ce05768e | ||
|
|
3dc68778fc | ||
|
|
1aec92b919 | ||
|
|
43d3448709 | ||
|
|
7512b1042a | ||
|
|
efe83e8c54 | ||
|
|
a13d27bfb5 | ||
|
|
ea7ad7d683 | ||
|
|
8b20bb3c5b | ||
|
|
320876a6d1 | ||
|
|
dfbb4c9275 | ||
|
|
d4f5c2d160 | ||
|
|
263d6034be | ||
|
|
de403f6d5e | ||
|
|
86b6f2c907 | ||
|
|
e6fab76918 | ||
|
|
334918d0f7 | ||
|
|
d6329489ce | ||
|
|
e6d240ee93 | ||
|
|
ff45e54c07 | ||
|
|
e0ee0882ef | ||
|
|
3d17419a6c | ||
|
|
476ec12383 | ||
|
|
e99177ada7 | ||
|
|
e95ec9c497 | ||
|
|
82f30bfbec | ||
|
|
d1437e6bbc | ||
|
|
c669d30642 | ||
|
|
3613b30ca8 | ||
|
|
0d4c3bcdcf | ||
|
|
8a8d515933 | ||
|
|
11de13ecfe | ||
|
|
58642d8411 | ||
|
|
7e42d3c771 | ||
|
|
5cb5d7a682 | ||
|
|
37e71dece6 | ||
|
|
df84945773 | ||
|
|
b5a6a9f9f1 | ||
|
|
ed36aefe48 | ||
|
|
32013f4294 | ||
|
|
8f2bcc2c64 | ||
|
|
015b53ae30 | ||
|
|
164cf2dc8a | ||
|
|
98d6d02704 | ||
|
|
5efb98931d | ||
|
|
2f4e47213f | ||
|
|
94c8123bd7 | ||
|
|
0db130e1c3 | ||
|
|
91b6a818f5 | ||
|
|
6bc9499e68 | ||
|
|
09f2d6c386 | ||
|
|
87f918f58c | ||
|
|
80e77fb021 | ||
|
|
fa9c5b3fae | ||
|
|
3d17a60a54 | ||
|
|
c33f073d4f | ||
|
|
5d7b5742e4 | ||
|
|
c391b2b7d0 | ||
|
|
0250929150 | ||
|
|
9748208e68 | ||
|
|
e4b0c04be4 | ||
|
|
efb83ad64f | ||
|
|
08e40f96e8 | ||
|
|
3f6feb1dcc | ||
|
|
ab6553f4ff | ||
|
|
cedca9fa1f | ||
|
|
3f40118022 | ||
|
|
b18b1da6d0 | ||
|
|
14fb9f56e8 | ||
|
|
8709cf506b | ||
|
|
9a92eb40df | ||
|
|
0a59c210f9 | ||
|
|
0b370fdd15 | ||
|
|
1c16dd26f7 | ||
|
|
c355d927ba | ||
|
|
c993857752 | ||
|
|
84f5fe9ee0 | ||
|
|
3336d67e77 | ||
|
|
73e16e7821 | ||
|
|
6f1d37d78f | ||
|
|
2ed82de2e0 | ||
|
|
c43903fa14 | ||
|
|
1c89cacfef | ||
|
|
75714fe43e | ||
|
|
e371ce95ca | ||
|
|
716a2e22c3 | ||
|
|
10e6019ada | ||
|
|
89c76b5145 | ||
|
|
83c04e6399 | ||
|
|
7fdeeb3635 | ||
|
|
5be368fe75 | ||
|
|
91c5b1e480 | ||
|
|
73154b97ba | ||
|
|
76a40759ae | ||
|
|
12ce565e98 | ||
|
|
9f46126859 | ||
|
|
11849e5a70 | ||
|
|
e30c00cc26 | ||
|
|
001b403657 | ||
|
|
851c61ee85 | ||
|
|
f5ebd23b8f | ||
|
|
81118c6195 | ||
|
|
834b60a02a | ||
|
|
47e3b5b4d2 | ||
|
|
d9346cc3d8 | ||
|
|
4e974ebd46 | ||
|
|
6f2b8408c1 | ||
|
|
1dba941261 | ||
|
|
ef76625abb | ||
|
|
57bb554a70 | ||
|
|
5b9d6f979e | ||
|
|
b588e3bfd7 | ||
|
|
a35dd1f9ee | ||
|
|
bf46f4fe35 | ||
|
|
55b76338a8 | ||
|
|
2af7b1c179 | ||
|
|
69f4cca9b6 | ||
|
|
4babdfcfbf |
+29
-7
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
FROM ubuntu:25.04 AS base
|
FROM ubuntu:26.04 AS base
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
@@ -40,7 +40,7 @@ RUN \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy uv from ghcr
|
# Copy uv from ghcr
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.8 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /uvx /bin/
|
||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
@@ -60,10 +60,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y software-properties-common
|
# Tesseract 5 ships in the Ubuntu archive as of 24.04, so no third-party PPA is
|
||||||
|
# needed. (Previously this used ppa:alex-p/tesseract-ocr5.)
|
||||||
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ghostscript \
|
ghostscript \
|
||||||
fonts-droid-fallback \
|
fonts-droid-fallback \
|
||||||
@@ -81,6 +79,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
unpaper \
|
unpaper \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create a non-root user to run the application (defense in depth). The build
|
||||||
|
# stages above need root to install packages, but the entrypoint should not.
|
||||||
|
# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable
|
||||||
|
# and matches the --chown below. See docs/docker.md for the volume/permissions
|
||||||
|
# implications under rootless vs rootful Docker.
|
||||||
|
# The Ubuntu base ships a default "ubuntu" user at uid/gid 1000; remove it so
|
||||||
|
# "app" can claim that uid for parity with the Alpine image.
|
||||||
|
RUN userdel -r ubuntu 2>/dev/null; groupdel ubuntu 2>/dev/null; \
|
||||||
|
groupadd -g 1000 app \
|
||||||
|
&& useradd -u 1000 -g app -m -d /home/app app
|
||||||
|
ENV HOME=/home/app
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /usr/local/lib/ /usr/local/lib/
|
COPY --from=builder /usr/local/lib/ /usr/local/lib/
|
||||||
@@ -90,9 +100,21 @@ COPY --from=builder --chown=app:app /app /app
|
|||||||
|
|
||||||
RUN rm -rf /app/.git && \
|
RUN rm -rf /app/.git && \
|
||||||
ln -s /app/misc/webservice.py /app/webservice.py && \
|
ln -s /app/misc/webservice.py /app/webservice.py && \
|
||||||
ln -s /app/misc/watcher.py /app/watcher.py
|
ln -s /app/misc/watcher.py /app/watcher.py && \
|
||||||
|
chown app:app /app
|
||||||
|
|
||||||
|
# Default working directory for bind-mounted data, so relative input/output
|
||||||
|
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
|
||||||
|
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
|
||||||
|
RUN mkdir -p /data && chown app:app /data
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
|
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
|
||||||
|
# overridden) as the unprivileged app user. Override with `--user root` if you
|
||||||
|
# need root inside a running container (e.g. to apt install extra packages).
|
||||||
|
USER app
|
||||||
|
|
||||||
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
FROM alpine:3.23 AS base
|
FROM alpine:3.24 AS base
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
@@ -22,7 +22,7 @@ RUN apk add --no-cache \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.8 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /uvx /bin/
|
||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
@@ -62,14 +62,35 @@ RUN apk add --no-cache \
|
|||||||
unpaper \
|
unpaper \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
# Create a non-root user to run the application (defense in depth). The build
|
||||||
|
# stages above need root to install packages, but the entrypoint should not.
|
||||||
|
# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable
|
||||||
|
# and matches the --chown below. See docs/docker.md for the volume/permissions
|
||||||
|
# implications under rootless vs rootful Docker.
|
||||||
|
RUN addgroup -g 1000 app \
|
||||||
|
&& adduser -u 1000 -G app -D -h /home/app app
|
||||||
|
ENV HOME=/home/app
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder --chown=app:app /app /app
|
COPY --from=builder --chown=app:app /app /app
|
||||||
|
|
||||||
RUN rm -rf /app/.git && \
|
RUN rm -rf /app/.git && \
|
||||||
ln -s /app/misc/webservice.py /app/webservice.py && \
|
ln -s /app/misc/webservice.py /app/webservice.py && \
|
||||||
ln -s /app/misc/watcher.py /app/watcher.py
|
ln -s /app/misc/watcher.py /app/watcher.py && \
|
||||||
|
chown app:app /app
|
||||||
|
|
||||||
|
# Default working directory for bind-mounted data, so relative input/output
|
||||||
|
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
|
||||||
|
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
|
||||||
|
RUN mkdir -p /data && chown app:app /data
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
|
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
|
||||||
|
# overridden) as the unprivileged app user. Override with `--user root` if you
|
||||||
|
# need root inside a running container (e.g. to apk add extra packages).
|
||||||
|
USER app
|
||||||
|
|
||||||
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
||||||
|
|||||||
+40
-17
@@ -14,8 +14,29 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint (prek)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
with:
|
||||||
|
version: "0.9.x"
|
||||||
|
|
||||||
|
- name: "Set up Python"
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Run prek
|
||||||
|
run: |
|
||||||
|
uv run prek run --all-files
|
||||||
|
|
||||||
test_linux:
|
test_linux:
|
||||||
name: Test ${{ matrix.os }} with Python ${{ matrix.python }}
|
name: Test ${{ matrix.os }} with Python ${{ matrix.python }}
|
||||||
|
needs: lint
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -31,7 +52,7 @@ jobs:
|
|||||||
PYTHON: ${{ matrix.python }}
|
PYTHON: ${{ matrix.python }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -87,7 +108,7 @@ jobs:
|
|||||||
uv run --no-dev 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@v5
|
uses: codecov/codecov-action@v7
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -96,6 +117,7 @@ jobs:
|
|||||||
|
|
||||||
test_macos:
|
test_macos:
|
||||||
name: Test macOS
|
name: Test macOS
|
||||||
|
needs: lint
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -107,7 +129,7 @@ jobs:
|
|||||||
PYTHON: ${{ matrix.python }}
|
PYTHON: ${{ matrix.python }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install Homebrew deps
|
- name: Install Homebrew deps
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -149,7 +171,7 @@ jobs:
|
|||||||
uv run --no-dev 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@v5
|
uses: codecov/codecov-action@v7
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -158,6 +180,7 @@ jobs:
|
|||||||
|
|
||||||
test_windows:
|
test_windows:
|
||||||
name: Test Windows
|
name: Test Windows
|
||||||
|
needs: lint
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -169,7 +192,7 @@ jobs:
|
|||||||
PYTHON: ${{ matrix.python }}
|
PYTHON: ${{ matrix.python }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -196,7 +219,7 @@ jobs:
|
|||||||
uv run --no-dev 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@v5
|
uses: codecov/codecov-action@v7
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -207,7 +230,7 @@ jobs:
|
|||||||
name: Build sdist and wheels
|
name: Build sdist and wheels
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -218,7 +241,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
uv build --sdist --wheel
|
uv build --sdist --wheel
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v6
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: artifact
|
name: artifact
|
||||||
path: |
|
path: |
|
||||||
@@ -233,9 +256,9 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/download-artifact@v7
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: artifact
|
name: artifact
|
||||||
path: dist
|
path: dist
|
||||||
@@ -281,20 +304,20 @@ jobs:
|
|||||||
- name: Set image name
|
- name: Set image name
|
||||||
run: echo "DOCKER_IMAGE_NAME=ocrmypdf" >> $GITHUB_ENV
|
run: echo "DOCKER_IMAGE_NAME=ocrmypdf" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: jbarlow83
|
username: jbarlow83
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- 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@v4
|
||||||
|
|
||||||
- name: Print image tag
|
- name: Print image tag
|
||||||
run: echo "Building image ${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}"
|
run: echo "Building image ${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}"
|
||||||
@@ -327,17 +350,17 @@ jobs:
|
|||||||
- name: Set image name
|
- name: Set image name
|
||||||
run: echo "DOCKER_IMAGE_NAME=ocrmypdf-alpine" >> $GITHUB_ENV
|
run: echo "DOCKER_IMAGE_NAME=ocrmypdf-alpine" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: jbarlow83
|
username: jbarlow83
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- 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@v4
|
||||||
|
|
||||||
- name: Print image tag
|
- name: Print image tag
|
||||||
run: echo "Building image ${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}"
|
run: echo "Building image ${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Download artifacts from draft release
|
- name: Download artifacts from draft release
|
||||||
env:
|
env:
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
|
|
||||||
# PyPI doesn't support sigstore publishing, so generate after publishing to PyPI
|
# PyPI doesn't support sigstore publishing, so generate after publishing to PyPI
|
||||||
- name: Sign the dists with Sigstore
|
- name: Sign the dists with Sigstore
|
||||||
uses: sigstore/gh-action-sigstore-python@v3.2.0
|
uses: sigstore/gh-action-sigstore-python@v3.4.0
|
||||||
with:
|
with:
|
||||||
inputs: |
|
inputs: |
|
||||||
./dist/*.tar.gz
|
./dist/*.tar.gz
|
||||||
@@ -87,13 +87,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: jbarlow83
|
username: jbarlow83
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Tag ocrmypdf (Ubuntu) image
|
- name: Tag ocrmypdf (Ubuntu) image
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.4.0
|
|
||||||
hooks:
|
|
||||||
- id: check-case-conflict
|
|
||||||
- id: check-merge-conflict
|
|
||||||
- id: check-toml
|
|
||||||
- id: check-yaml
|
|
||||||
- id: debug-statements
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
||||||
rev: "v0.14.11"
|
|
||||||
hooks:
|
|
||||||
- id: ruff-check
|
|
||||||
args: [--fix]
|
|
||||||
- id: ruff-format
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
||||||
rev: v1.2.0
|
|
||||||
hooks:
|
|
||||||
- id: mypy
|
|
||||||
additional_dependencies:
|
|
||||||
- types-toml
|
|
||||||
- types-setuptools
|
|
||||||
- types-requests
|
|
||||||
- types-Pillow
|
|
||||||
+90
-76
@@ -1,8 +1,3 @@
|
|||||||
# OCRmyPDF 中文说明
|
|
||||||
|
|
||||||
我将为您创建一个中文版的 README.md 文件。以下是基于原始英文版本的中文翻译:
|
|
||||||
|
|
||||||
```markdown:/Users/huapai/PycharmProjects/OCRmyPDF/README.md
|
|
||||||
<!-- SPDX-FileCopyrightText: 2014 Julien Pfefferkorn -->
|
<!-- SPDX-FileCopyrightText: 2014 Julien Pfefferkorn -->
|
||||||
<!-- SPDX-FileCopyrightText: 2015 James R. Barlow -->
|
<!-- SPDX-FileCopyrightText: 2015 James R. Barlow -->
|
||||||
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
|
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
|
||||||
@@ -16,98 +11,109 @@
|
|||||||
[docs]: https://readthedocs.org/projects/ocrmypdf/badge/?version=latest "RTD"
|
[docs]: https://readthedocs.org/projects/ocrmypdf/badge/?version=latest "RTD"
|
||||||
[pyversions]: https://img.shields.io/pypi/pyversions/ocrmypdf "支持的 Python 版本"
|
[pyversions]: https://img.shields.io/pypi/pyversions/ocrmypdf "支持的 Python 版本"
|
||||||
|
|
||||||
OCRmyPDF 为扫描的 PDF 文件添加 OCR 文本层,使其可以被搜索或复制粘贴。
|
OCRmyPDF 会为扫描版 PDF 文件添加 OCR 文本层,使其可以搜索或复制粘贴。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ocrmypdf # 这是一个可脚本化的命令行程序
|
ocrmypdf # 它是一个可脚本化的命令行程序
|
||||||
-l eng+fra # 支持多种语言
|
-l eng+fra # 它支持多种语言
|
||||||
--rotate-pages # 可以修正旋转错误的页面
|
--rotate-pages # 它可以修正旋转方向错误的页面
|
||||||
--deskew # 可以校正倾斜的 PDF!
|
--deskew # 它可以校正歪斜的 PDF!
|
||||||
--title "My PDF" # 可以更改输出元数据
|
--title "My PDF" # 它可以更改输出元数据
|
||||||
--jobs 4 # 默认使用多核心处理
|
--jobs 4 # 它默认使用多个 CPU 核心
|
||||||
--output-type pdfa # 默认生成 PDF/A 格式
|
--output-type pdfa # 它默认生成 PDF/A
|
||||||
input_scanned.pdf # 接受 PDF 输入(或图像)
|
input_scanned.pdf # 接受 PDF 输入(或图像)
|
||||||
output_searchable.pdf # 生成经过验证的 PDF 输出
|
output_searchable.pdf # 生成经过验证的 PDF 输出
|
||||||
```
|
```
|
||||||
|
|
||||||
[查看发布说明了解最新变更的详情](https://ocrmypdf.readthedocs.io/en/latest/release_notes.html)。
|
[查看发布说明,了解最新变更详情](https://ocrmypdf.readthedocs.io/en/latest/release_notes.html)。
|
||||||
|
|
||||||
## 主要特点
|
## 主要功能
|
||||||
|
|
||||||
- 从普通 PDF 生成可搜索的 [PDF/A](https://en.wikipedia.org/?title=PDF/A) 文件
|
- 从普通 PDF 生成可搜索的 [PDF/A](https://en.wikipedia.org/?title=PDF/A) 文件
|
||||||
- 准确地将 OCR 文本放置在图像下方,便于复制/粘贴
|
- 将 OCR 文本准确放置在图像下方,便于复制/粘贴
|
||||||
- 保持原始嵌入图像的精确分辨率
|
- 保持原始嵌入图像的精确分辨率
|
||||||
- 在可能的情况下,以"无损"操作方式插入 OCR 信息,不破坏任何其他内容
|
- 在可能时,以“无损”操作插入 OCR 信息,不干扰任何其他内容
|
||||||
- 优化 PDF 图像,通常生成比输入文件更小的文件
|
- 优化 PDF 图像,通常生成比输入文件更小的文件
|
||||||
- 如果需要,在执行 OCR 前对图像进行校正和/或清理
|
- 按需在执行 OCR 前校正和/或清理图像
|
||||||
- 验证输入和输出文件
|
- 验证输入和输出文件
|
||||||
- 在所有可用的 CPU 核心上分配工作
|
- 在所有可用 CPU 核心间分配工作
|
||||||
- 使用 [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) 引擎识别超过 [100 种语言](https://github.com/tesseract-ocr/tessdata)
|
- 使用 [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) 引擎识别超过 [100 种语言](https://github.com/tesseract-ocr/tessdata)
|
||||||
- 保护您的私人数据安全
|
- 保护你的私有数据。
|
||||||
- 适当扩展以处理包含数千页的文件
|
- 可以妥善扩展,处理包含数千页的文件。
|
||||||
- 在数百万 PDF 上经过实战测试
|
- 已在数百万份 PDF 上经过实战检验。
|
||||||
|
|
||||||
<img src="misc/screencast/demo.svg" alt="终端会话中的 OCRmyPDF 演示">
|
<img src="misc/screencast/demo.svg" alt="OCRmyPDF 在终端会话中的演示">
|
||||||
|
|
||||||
详情请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/)。
|
详情请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/)。
|
||||||
|
|
||||||
## 开发动机
|
## 动机
|
||||||
|
|
||||||
我在网上搜索免费的命令行工具来对 PDF 文件进行 OCR:我找到了很多,但没有一个真正令人满意:
|
我曾在网上寻找一款免费的命令行工具来对 PDF 文件执行 OCR:我找到了很多,但没有一个真正令人满意:
|
||||||
|
|
||||||
- 要么它们生成的 PDF 文件中文本位置错误(使复制/粘贴变得不可能)
|
- 要么生成的 PDF 文件中文本位于图像下方的错误位置(导致无法复制/粘贴)
|
||||||
- 要么它们不处理重音和多语言字符
|
- 要么无法处理重音字符和多语言字符
|
||||||
- 要么它们改变了嵌入图像的分辨率
|
- 要么会改变嵌入图像的分辨率
|
||||||
- 要么它们生成了体积巨大的 PDF 文件
|
- 要么生成的 PDF 文件大得离谱
|
||||||
- 要么它们在尝试 OCR 时崩溃
|
- 要么在尝试 OCR 时崩溃
|
||||||
- 要么它们不生成有效的 PDF 文件
|
- 要么无法生成有效的 PDF 文件
|
||||||
- 最重要的是,它们都不生成 PDF/A 文件(专为长期存储设计的格式)
|
- 除此之外,它们都不能生成 PDF/A 文件(专为长期存储设计的格式)
|
||||||
|
|
||||||
...所以我决定开发自己的工具。
|
……所以我决定开发自己的工具。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
支持 Linux、Windows、macOS 和 FreeBSD。Docker 镜像也可用,同时支持 x64 和 ARM。
|
支持 Linux、Windows、macOS 和 FreeBSD。也提供 Docker 镜像,同时支持 x64 和 ARM。
|
||||||
|
|
||||||
| 操作系统 | 安装命令 |
|
| 操作系统 | 安装命令 |
|
||||||
| --------------------------- | ----------------------------- |
|
| ----------------------------- | ------------------------------ |
|
||||||
| Debian, Ubuntu | ``apt install ocrmypdf`` |
|
| Debian, Ubuntu | ``apt install ocrmypdf`` |
|
||||||
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
|
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
|
||||||
| Fedora | ``dnf install ocrmypdf`` |
|
| Fedora | ``dnf install ocrmypdf`` |
|
||||||
| macOS (Homebrew) | ``brew install ocrmypdf`` |
|
| macOS (Homebrew) | ``brew install ocrmypdf`` |
|
||||||
| macOS (MacPorts) | ``port install ocrmypdf`` |
|
| macOS (MacPorts) | ``port install ocrmypdf`` |
|
||||||
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
||||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||||
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
||||||
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
| OpenBSD | ``pkg_add ocrmypdf`` |
|
||||||
|
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
||||||
|
|
||||||
对于其他用户,[请参阅我们的文档](https://ocrmypdf.readthedocs.io/en/latest/installation.html)了解安装步骤。
|
其他用户请[参阅我们的文档](https://ocrmypdf.readthedocs.io/en/latest/installation.html)了解安装步骤。
|
||||||
|
|
||||||
## 语言
|
## 语言
|
||||||
|
|
||||||
OCRmyPDF 使用 Tesseract 进行 OCR,并依赖其语言包。对于 Linux 用户,您通常可以找到提供语言包的软件包:
|
OCRmyPDF 使用 Tesseract 执行 OCR,并依赖其语言包。对于 Linux 用户,通常可以找到提供语言包的软件包:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 显示所有 Tesseract 语言包的列表
|
|
||||||
apt-cache search tesseract-ocr
|
|
||||||
|
|
||||||
# Debian/Ubuntu 用户
|
# Debian/Ubuntu 用户
|
||||||
apt-get install tesseract-ocr-chi-sim # 示例:安装中文简体语言包
|
apt-cache search tesseract-ocr # 显示所有 Tesseract 语言包列表
|
||||||
|
apt-get install tesseract-ocr-chi-sim # 示例:安装简体中文语言包
|
||||||
|
|
||||||
|
|
||||||
# Arch Linux 用户
|
# Arch Linux 用户
|
||||||
pacman -S tesseract-data-eng tesseract-data-deu # 示例:安装英语和德语语言包
|
pacman -S tesseract-data-eng tesseract-data-deu # 示例:安装英语和德语语言包
|
||||||
|
|
||||||
|
# OpenBSD 用户
|
||||||
|
pkg_info -aQ tesseract # 显示所有 Tesseract 语言包列表
|
||||||
|
pkg_add tesseract-cym # 示例:安装威尔士语语言包
|
||||||
|
|
||||||
# brew macOS 用户
|
# brew macOS 用户
|
||||||
brew install tesseract-lang
|
brew install tesseract-lang
|
||||||
|
|
||||||
|
# Fedora 用户
|
||||||
|
dnf search tesseract-langpack # 显示所有 Tesseract 语言包列表
|
||||||
|
dnf install tesseract-langpack-ita # 示例:安装意大利语语言包
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
然后,您可以向 OCRmyPDF 传递 `-l LANG` 参数,提示它应该搜索哪些语言。可以请求多种语言。
|
随后可以向 OCRmyPDF 传递 `-l LANG` 参数,提示它应搜索哪些语言。可以同时请求多种语言。
|
||||||
|
|
||||||
OCRmyPDF 支持 Tesseract 4.1.1+。它会自动使用在 `PATH` 环境变量中首先找到的版本。在 Windows 上,如果 `PATH` 不提供 Tesseract 二进制文件,我们会根据 Windows 注册表使用已安装的最高版本号。
|
OCRmyPDF 支持 Tesseract 4.1.1+。它会自动使用 `PATH` 环境变量中首先找到的版本。在 Windows 上,如果 `PATH` 中没有 Tesseract 二进制文件,我们会根据 Windows 注册表使用已安装的最高版本号。
|
||||||
|
|
||||||
## 文档和支持
|
## 文档和支持
|
||||||
|
|
||||||
安装 OCRmyPDF 后,可以通过以下方式访问内置帮助,解释命令语法和选项:
|
安装 OCRmyPDF 后,可以通过以下命令访问内置帮助,了解命令语法和选项:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ocrmypdf --help
|
ocrmypdf --help
|
||||||
@@ -115,13 +121,13 @@ ocrmypdf --help
|
|||||||
|
|
||||||
我们的[文档托管在 Read the Docs 上](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
|
我们的[文档托管在 Read the Docs 上](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
|
||||||
|
|
||||||
请在我们的 [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) 页面上报告问题,并遵循问题模板以获得快速响应。
|
请在我们的 [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) 页面报告问题,并遵循 issue 模板以便快速获得响应。
|
||||||
|
|
||||||
## 功能演示
|
## 功能演示
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 添加 OCR 层并转换为 PDF/A
|
# 添加 OCR 层并要求输出 PDF/A
|
||||||
ocrmypdf input.pdf output.pdf
|
ocrmypdf --output-type pdfa input.pdf output.pdf
|
||||||
|
|
||||||
# 将图像转换为单页 PDF
|
# 将图像转换为单页 PDF
|
||||||
ocrmypdf input.jpg output.pdf
|
ocrmypdf input.jpg output.pdf
|
||||||
@@ -129,45 +135,53 @@ ocrmypdf input.jpg output.pdf
|
|||||||
# 就地为文件添加 OCR(仅在成功时修改文件)
|
# 就地为文件添加 OCR(仅在成功时修改文件)
|
||||||
ocrmypdf myfile.pdf myfile.pdf
|
ocrmypdf myfile.pdf myfile.pdf
|
||||||
|
|
||||||
# 使用非英语语言进行 OCR(查找您语言的 ISO 639-3 代码)
|
# 使用非英语语言执行 OCR(请查找对应语言的 ISO 639-3 代码)
|
||||||
ocrmypdf -l fra LeParisien.pdf LeParisien.pdf
|
ocrmypdf -l fra LeParisien.pdf LeParisien.pdf
|
||||||
|
|
||||||
# OCR 多语言文档
|
# OCR 多语言文档
|
||||||
ocrmypdf -l eng+fra Bilingual-English-French.pdf Bilingual-English-French.pdf
|
ocrmypdf -l eng+fra Bilingual-English-French.pdf Bilingual-English-French.pdf
|
||||||
|
|
||||||
# 校正(矫正倾斜的页面)
|
# 校正歪斜页面
|
||||||
ocrmypdf --deskew input.pdf output.pdf
|
ocrmypdf --deskew input.pdf output.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
更多功能,请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
|
更多功能请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
|
||||||
|
|
||||||
## 要求
|
## 要求
|
||||||
|
|
||||||
除了所需的 Python 版本外,OCRmyPDF 还需要外部程序安装 Ghostscript 和 Tesseract OCR。OCRmyPDF 是纯 Python 编写的,几乎可以在所有平台上运行:Linux、macOS、Windows 和 FreeBSD。
|
除所需的 Python 版本外,OCRmyPDF 还需要安装 Ghostscript 和 Tesseract OCR 这两个外部程序。OCRmyPDF 是纯 Python 项目,几乎可以在所有平台上运行:Linux、macOS、Windows 和 FreeBSD。
|
||||||
|
|
||||||
## 媒体报道
|
## 插件
|
||||||
|
|
||||||
- [使用 OCRmyPDF 实现无纸化](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
|
OCRmyPDF 提供插件接口,允许扩展或替换其能力。以下是我们知道的一些插件:
|
||||||
- [将扫描文档转换为带有编辑的压缩可搜索 PDF](https://medium.com/@treyharris/converting-a-scanned-document-into-a-compressed-searchable-pdf-with-redactions-63f61c34fe4c)
|
|
||||||
- [c't 1-2014, 第 59 页](https://heise.de/-2279695):在德国领先的 IT 杂志 c't 中详细介绍 OCRmyPDF v1.0
|
|
||||||
- [heise Open Source, 09/2014: 使用 OCRmyPDF 进行文本识别](https://heise.de/-2356670)
|
|
||||||
- [heise 使用 OCRmyPDF 创建可搜索的 PDF 文档](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html)
|
|
||||||
- [优秀实用工具:OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
|
|
||||||
- [LinuxUser 使用 OCRmyPDF 和 Scanbd 自动化文本识别](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
|
|
||||||
- [Y Combinator 讨论](https://news.ycombinator.com/item?id=32028752)
|
|
||||||
|
|
||||||
## 商业咨询
|
- [OCRmyPDF-AppleOCR](https://github.com/mkyt/ocrmypdf-AppleOCR):用 Apple Vision Framework 替换标准 Tesseract OCR 引擎。需要 macOS。
|
||||||
|
- [OCRmyPDF-EasyOCR](https://github.com/ocrmypdf/OCRmyPDF-EasyOCR):用 EasyOCR 替换标准 Tesseract OCR 引擎;EasyOCR 是基于 PyTorch 的较新 OCR 引擎。强烈建议使用 GPU。
|
||||||
|
- [OCRmyPDF-PaddleOCR](https://github.com/clefru/ocrmypdf-paddleocr):用 PaddleOCR 替换标准 Tesseract OCR 引擎;PaddleOCR 是功能强大的 GPU 加速 OCR 引擎。
|
||||||
|
|
||||||
如果没有公司和用户选择为功能开发和咨询提供支持,OCRmyPDF 就不会成为今天的软件。我们很乐意讨论所有咨询,无论是扩展现有功能集,还是将 OCRmyPDF 集成到更大的系统中。
|
[paperless-ngx](https://docs.paperless-ngx.com/) 将 OCRmyPDF 集成到可搜索的文档管理系统中。
|
||||||
|
|
||||||
|
## 新闻与媒体
|
||||||
|
|
||||||
|
- [Going paperless with OCRmyPDF](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
|
||||||
|
- [Converting a scanned document into a compressed searchable PDF with redactions](https://medium.com/@treyharris/converting-a-scanned-document-into-a-compressed-searchable-pdf-with-redactions-63f61c34fe4c)
|
||||||
|
- [c't 1-2014,第 59 页](https://heise.de/-2279695):德国领先 IT 杂志 c't 对 OCRmyPDF v1.0 的详细介绍
|
||||||
|
- [heise Open Source, 09/2014: Texterkennung mit OCRmyPDF](https://heise.de/-2356670)
|
||||||
|
- [heise Durchsuchbare PDF-Dokumente mit OCRmyPDF erstellen](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html)
|
||||||
|
- [Excellent Utilities: OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
|
||||||
|
- [LinuxUser Texterkennung mit OCRmyPDF und Scanbd automatisieren](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
|
||||||
|
- [Y Combinator discussion](https://news.ycombinator.com/item?id=32028752)
|
||||||
|
|
||||||
|
## 商务咨询
|
||||||
|
|
||||||
|
如果没有公司和用户选择支持功能开发与咨询服务,OCRmyPDF 不会成为今天的软件。无论是扩展现有功能集,还是将 OCRmyPDF 集成到更大的系统中,我们都很乐意讨论各类咨询需求。
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
OCRmyPDF 软件根据 Mozilla 公共许可证 2.0 (MPL-2.0) 授权。此许可证允许将 OCRmyPDF 与其他代码集成,包括商业和闭源代码,但要求您发布对 OCRmyPDF 所做的源代码级修改。
|
OCRmyPDF 软件采用 Mozilla Public License 2.0 (MPL-2.0) 授权。该许可证允许将 OCRmyPDF 与其他代码集成,包括商业代码和闭源代码,但要求你发布对 OCRmyPDF 所做的源代码级修改。
|
||||||
|
|
||||||
OCRmyPDF 的某些组件有其他许可证,如标准 SPDX 许可证标识符或 DEP5 版权和许可信息文件所示。一般来说,非核心代码根据 MIT 许可,文档和测试文件根据 Creative Commons ShareAlike 4.0 (CC-BY-SA 4.0) 许可。
|
OCRmyPDF 的某些组件采用其他许可证,具体由标准 SPDX 许可证标识符或 DEP5 版权与许可信息文件标明。一般来说,非核心代码采用 MIT 许可证,文档和测试文件采用 Creative Commons ShareAlike 4.0 (CC-BY-SA 4.0) 许可证。
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
本软件按"原样"分发,不提供任何明示或暗示的保证或条件。
|
本软件按“原样”分发,不提供任何明示或暗示的保证或条件。
|
||||||
|
|
||||||
这份中文版 README.md 保留了原始文档的所有重要信息,包括功能介绍、安装说明、语言支持、使用示例等内容,同时保持了原始格式和结构。
|
|
||||||
|
|||||||
+7
-6
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import glob
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -18,8 +17,9 @@ import cyclopts
|
|||||||
from packaging.version import InvalidVersion, Version
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from github import Github, GithubException
|
from github import Auth, Github, GithubException
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
Auth = None # type: ignore
|
||||||
Github = None # type: ignore
|
Github = None # type: ignore
|
||||||
GithubException = Exception # type: ignore
|
GithubException = Exception # type: ignore
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ def validate_release_notes(new_version: str) -> bool:
|
|||||||
|
|
||||||
def get_github_client():
|
def get_github_client():
|
||||||
"""Get an authenticated GitHub client."""
|
"""Get an authenticated GitHub client."""
|
||||||
if Github is None:
|
if Github is None or Auth is None:
|
||||||
print(f"{RED}error:{OFF} PyGithub is not installed")
|
print(f"{RED}error:{OFF} PyGithub is not installed")
|
||||||
print(" Install with: pip install PyGithub")
|
print(" Install with: pip install PyGithub")
|
||||||
return None
|
return None
|
||||||
@@ -92,7 +92,7 @@ def get_github_client():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return Github(token)
|
return Github(auth=Auth.Token(token))
|
||||||
except GithubException as e:
|
except GithubException as e:
|
||||||
print(f"{RED}error:{OFF} Failed to authenticate with GitHub: {e}")
|
print(f"{RED}error:{OFF} Failed to authenticate with GitHub: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -281,7 +281,7 @@ def bump_version() -> None:
|
|||||||
actions = []
|
actions = []
|
||||||
|
|
||||||
for path_pattern, version_pattern in config:
|
for path_pattern, version_pattern in config:
|
||||||
paths = [Path(p) for p in glob.glob(path_pattern)]
|
paths = list(Path().glob(path_pattern))
|
||||||
|
|
||||||
if not paths:
|
if not paths:
|
||||||
print(f"error: Pattern {path_pattern} didn't match any files")
|
print(f"error: Pattern {path_pattern} didn't match any files")
|
||||||
@@ -305,7 +305,8 @@ def bump_version() -> None:
|
|||||||
|
|
||||||
if not found_at_least_one_file_needing_update:
|
if not found_at_least_one_file_needing_update:
|
||||||
print(
|
print(
|
||||||
f'''error: Didn't find any occurrences of "{find_pattern}" in "{path_pattern}"'''
|
f'''error: Didn't find any occurrences of "{find_pattern}" '''
|
||||||
|
f'''in "{path_pattern}"'''
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
+131
-3
@@ -121,6 +121,30 @@ representation. This is useful for redoing OCR, for fixing OCR text
|
|||||||
with a damaged character map (text is selectable but not searchable),
|
with a damaged character map (text is selectable but not searchable),
|
||||||
and destroying redacted information.
|
and destroying redacted information.
|
||||||
|
|
||||||
|
### Tagged PDFs and structural markup
|
||||||
|
|
||||||
|
Some PDFs carry a logical structure tree (`/StructTreeRoot`), the markup that
|
||||||
|
makes a "Tagged PDF" — typically the result of layout analysis or a born-digital
|
||||||
|
export. By default OCRmyPDF treats this as a signal that the document may not need
|
||||||
|
OCR and exits, in the same way it stops on PDFs that already contain text. Use
|
||||||
|
`--tagged-pdf-mode ignore`, or one of `--mode skip`/`redo`/`force`, to process
|
||||||
|
such a file anyway.
|
||||||
|
|
||||||
|
OCRmyPDF cannot rebuild a structure tree to match newly recognized text. When
|
||||||
|
`--force-ocr` rasterizes pages, or `--redo-ocr` strips and rewrites the text layer,
|
||||||
|
the structure tree no longer corresponds to the page content, so it is discarded.
|
||||||
|
`--mode skip` leaves text pages untouched, so their structural markup is preserved.
|
||||||
|
|
||||||
|
:::{note}
|
||||||
|
Preservation under `--mode skip` only holds when the output is not converted to
|
||||||
|
PDF/A. PDF/A conversion is performed by Ghostscript, and Ghostscript 10.x discards
|
||||||
|
the structure tree during conversion (Ghostscript 9.x preserved it). Because the
|
||||||
|
default `--output-type auto` may fall back to Ghostscript, use
|
||||||
|
`--output-type pdf` if you need to guarantee that a Tagged PDF's structural markup
|
||||||
|
survives. For best results, install veraPDF so that speculative PDF/A
|
||||||
|
conversion can sidestep this issue entirely in most real cases.
|
||||||
|
:::
|
||||||
|
|
||||||
### Time and image size limits
|
### Time and image size limits
|
||||||
|
|
||||||
By default, OCRmyPDF permits tesseract to run for three minutes (180
|
By default, OCRmyPDF permits tesseract to run for three minutes (180
|
||||||
@@ -187,6 +211,13 @@ include:
|
|||||||
Overrides the path to Tesseract's data files. This can allow
|
Overrides the path to Tesseract's data files. This can allow
|
||||||
simultaneous installation of the "best" and "fast" training data
|
simultaneous installation of the "best" and "fast" training data
|
||||||
sets. OCRmyPDF does not manage this environment variable.
|
sets. OCRmyPDF does not manage this environment variable.
|
||||||
|
|
||||||
|
If you point ``TESSDATA_PREFIX`` at a hand-assembled ``tessdata``
|
||||||
|
folder (for example, individual ``.traineddata`` files downloaded
|
||||||
|
from tessdata_best), make sure it also contains the ``configs/``
|
||||||
|
subdirectory with the ``hocr`` and ``txt`` files. OCRmyPDF requires
|
||||||
|
these; without them Tesseract produces no output. See
|
||||||
|
:ref:`Tesseract cannot open its config file <tesseract-config-missing>`.
|
||||||
```
|
```
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
@@ -419,6 +450,70 @@ curves. In this case, you may want to use a different color conversion
|
|||||||
strategy. The `--color-conversion-strategy` option allows you to select a
|
strategy. The `--color-conversion-strategy` option allows you to select a
|
||||||
different strategy, such as `RGB`.
|
different strategy, such as `RGB`.
|
||||||
|
|
||||||
|
## Advanced Ghostscript tuning
|
||||||
|
|
||||||
|
:::{versionadded} 17.5.0
|
||||||
|
:::
|
||||||
|
|
||||||
|
OCRmyPDF intentionally hides most Ghostscript controls because Ghostscript
|
||||||
|
is a legacy code path. The preferred PDF/A pipeline in v17+ uses pypdfium2
|
||||||
|
as the rasterizer and verapdf to validate speculative PDF/A output, with
|
||||||
|
Ghostscript reserved as a fallback for PDFs that cannot be made compliant
|
||||||
|
without it. OCRmyPDF's separate optimizer (controlled by `--optimize`,
|
||||||
|
`--jpeg-quality`, `--png-quality`, etc.) is the supported way to shrink
|
||||||
|
output PDFs: it gives consistent results across input files, and isolates
|
||||||
|
Ghostscript so it can focus on producing a PDF/A with as few image
|
||||||
|
transformations as possible.
|
||||||
|
|
||||||
|
The two options below are exposed for advanced users who want to tune
|
||||||
|
Ghostscript's intermediate PDF/A output directly. Most users will get
|
||||||
|
more predictable results from the optimizer.
|
||||||
|
|
||||||
|
### `--ghostscript-jpeg-quality Q`
|
||||||
|
|
||||||
|
Sets Ghostscript's `-dJPEGQ` switch for images that Ghostscript chooses
|
||||||
|
to recompress to JPEG while building a PDF/A. `Q=0` requests maximum
|
||||||
|
compression and `Q=100` requests best quality; if the flag is omitted,
|
||||||
|
OCRmyPDF passes `95` (the historical default). This only affects images
|
||||||
|
Ghostscript transcodes — existing JPEGs pass through unchanged on modern
|
||||||
|
Ghostscript releases. For end-to-end JPEG quality tuning, prefer
|
||||||
|
`--jpeg-quality`, which is implemented by the OCRmyPDF optimizer and is
|
||||||
|
applied independently of whatever Ghostscript decides to do.
|
||||||
|
|
||||||
|
Note: setting both `--ghostscript-jpeg-quality` and `--jpeg-quality` can
|
||||||
|
result in double JPEG recompression, since the optimizer may re-encode
|
||||||
|
images that Ghostscript already recompressed. This can degrade quality
|
||||||
|
in subtle ways.
|
||||||
|
|
||||||
|
### `--ghostscript-jpeg-maxdpi DPI`
|
||||||
|
|
||||||
|
Enables Ghostscript's image downsampling and caps color, grayscale, and
|
||||||
|
monochrome image resolution to `DPI`. The downsample threshold is set to
|
||||||
|
`1.0`, so any image whose effective DPI exceeds the cap will be
|
||||||
|
downsampled.
|
||||||
|
|
||||||
|
Reducing JPEG quality is almost always a better trade than downsampling
|
||||||
|
at the same compression budget: a 400 DPI JPEG at modest quality usually
|
||||||
|
looks much better than a 200 DPI JPEG, because the JPEG codec can spend
|
||||||
|
bits where they count. Downsampling is also dangerous for PDFs that
|
||||||
|
combine a low-resolution color image with a high-resolution monochrome
|
||||||
|
mask — capping the mask resolution can produce visible quality loss.
|
||||||
|
For these reasons, prefer `--jpeg-quality` over `--ghostscript-jpeg-maxdpi`
|
||||||
|
unless you specifically want to force a hard DPI cap.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocrmypdf --output-type pdfa \
|
||||||
|
--ghostscript-jpeg-quality 80 \
|
||||||
|
--ghostscript-jpeg-maxdpi 150 \
|
||||||
|
in.pdf out.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
These options only take effect when Ghostscript is invoked for PDF/A
|
||||||
|
conversion (`--output-type pdfa`, `pdfa-1`, `pdfa-2`, or `pdfa-3`, or
|
||||||
|
when `--output-type auto` falls back to Ghostscript).
|
||||||
|
|
||||||
## PDF/A output modes
|
## PDF/A output modes
|
||||||
|
|
||||||
:::{versionchanged} 17.0.0
|
:::{versionchanged} 17.0.0
|
||||||
@@ -438,6 +533,36 @@ OCRmyPDF can produce PDF/A compliant output for long-term archival. The
|
|||||||
| `pdf` | Standard PDF, no PDF/A conversion |
|
| `pdf` | Standard PDF, no PDF/A conversion |
|
||||||
| `none` | No output file (useful with `--sidecar`) |
|
| `none` | No output file (useful with `--sidecar`) |
|
||||||
|
|
||||||
|
### Non-embedded fonts and PDF/A
|
||||||
|
|
||||||
|
:::{versionadded} 17.8.0
|
||||||
|
OCRmyPDF now refuses to corrupt non-embedded CID text layers during PDF/A
|
||||||
|
conversion.
|
||||||
|
:::
|
||||||
|
|
||||||
|
PDF/A requires every font to be embedded. If your input already has a text
|
||||||
|
layer that uses *non-embedded* CID fonts — most commonly a CJK
|
||||||
|
(Chinese-Japanese-Korean) OCR layer
|
||||||
|
produced by Adobe Acrobat, which relies on the reader's system fonts —
|
||||||
|
Ghostscript would have to substitute and re-embed a replacement font to make
|
||||||
|
the file PDF/A. For CID-keyed (CJK) fonts this routinely corrupts the
|
||||||
|
character-to-Unicode mapping, so the text silently becomes garbage or stops
|
||||||
|
being searchable even though the page still *looks* correct.
|
||||||
|
|
||||||
|
Rather than emit corrupted output, OCRmyPDF detects this situation and:
|
||||||
|
|
||||||
|
- with `--output-type auto` (the default), produces a regular PDF instead of
|
||||||
|
PDF/A, preserving the existing text layer exactly;
|
||||||
|
- with an explicit `--output-type pdfa` (or `pdfa-1`/`pdfa-2`/`pdfa-3`), stops
|
||||||
|
with an error.
|
||||||
|
|
||||||
|
This is a Ghostscript limitation that OCRmyPDF cannot repair, because a
|
||||||
|
non-embedded font cannot be made PDF/A-compliant without re-embedding it. To
|
||||||
|
keep the existing text layer, use `--output-type pdf`. To produce PDF/A anyway,
|
||||||
|
re-run OCR with `--force-ocr`, which discards the original text layer and
|
||||||
|
rebuilds it with embedded fonts. Text layers whose fonts are *already embedded*
|
||||||
|
are converted to PDF/A normally.
|
||||||
|
|
||||||
### Speculative PDF/A conversion
|
### Speculative PDF/A conversion
|
||||||
|
|
||||||
:::{versionadded} 17.0.0
|
:::{versionadded} 17.0.0
|
||||||
@@ -451,9 +576,12 @@ fast "speculative" PDF/A conversion that avoids Ghostscript when possible:
|
|||||||
3. If validation passes, Ghostscript is skipped entirely
|
3. If validation passes, Ghostscript is skipped entirely
|
||||||
4. If validation fails or verapdf is unavailable, falls back to Ghostscript
|
4. If validation fails or verapdf is unavailable, falls back to Ghostscript
|
||||||
|
|
||||||
This approach is faster and avoids some Ghostscript limitations (such as
|
This fast path avoids some Ghostscript limitations (such as image
|
||||||
image transcoding), but only works for PDFs that are already "mostly"
|
transcoding) and is used whenever it can produce valid PDF/A. When it
|
||||||
PDF/A compliant.
|
cannot — for example when veraPDF is not installed, or the input needs real
|
||||||
|
conversion — `auto` falls back to Ghostscript so that it still produces
|
||||||
|
PDF/A by default, matching OCRmyPDF 16 and earlier. If even Ghostscript
|
||||||
|
cannot safely produce PDF/A, `auto` outputs a regular PDF instead of failing.
|
||||||
|
|
||||||
### PDF/A conversion flow
|
### PDF/A conversion flow
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -174,7 +174,15 @@ docker run \
|
|||||||
--env PYTHONUNBUFFERED=1 \
|
--env PYTHONUNBUFFERED=1 \
|
||||||
--interactive --tty --entrypoint python3 \
|
--interactive --tty --entrypoint python3 \
|
||||||
jbarlow83/ocrmypdf \
|
jbarlow83/ocrmypdf \
|
||||||
watcher.py
|
/app/watcher.py
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::{note}
|
||||||
|
The image runs as the non-root `app` user (uid 1000) by default, so it
|
||||||
|
may not be able to write to the `/output` and `/processed` volumes unless
|
||||||
|
you add a `--user` argument. The correct value depends on whether you use
|
||||||
|
rootful Docker, rootless Docker, or Podman -- see
|
||||||
|
{ref}`Bind-mounted volumes <docker-volumes>` for details.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
This service will watch for a file that matches `/input/\*.pdf`, convert
|
This service will watch for a file that matches `/input/\*.pdf`, convert
|
||||||
|
|||||||
@@ -178,6 +178,20 @@ html_theme = 'sphinx_rtd_theme'
|
|||||||
#
|
#
|
||||||
html_theme_options = {}
|
html_theme_options = {}
|
||||||
|
|
||||||
|
# ReadTheDocs used to inject the "Edit on GitHub" context automatically, but
|
||||||
|
# dropped it when it switched to Addons, so set it explicitly here. This makes
|
||||||
|
# sphinx_rtd_theme add an "Edit on GitHub" link to each page that points at the
|
||||||
|
# corresponding source file in the repository, replacing the static
|
||||||
|
# "View page source" (_sources/*.txt) link. See
|
||||||
|
# https://github.com/ocrmypdf/OCRmyPDF/issues/1490
|
||||||
|
html_context = {
|
||||||
|
'display_github': True,
|
||||||
|
'github_user': 'ocrmypdf',
|
||||||
|
'github_repo': 'OCRmyPDF',
|
||||||
|
'github_version': 'main',
|
||||||
|
'conf_py_path': '/docs/',
|
||||||
|
}
|
||||||
|
|
||||||
# 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 = []
|
||||||
|
|
||||||
|
|||||||
+47
-12
@@ -31,6 +31,16 @@ ocrmypdf --output-type pdf input.pdf output.pdf
|
|||||||
ocrmypdf --output-type pdfa --pdfa-image-compression jpeg input.pdf output.pdf
|
ocrmypdf --output-type pdfa --pdfa-image-compression jpeg input.pdf output.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Reduce JPEG quality with the optimizer
|
||||||
|
|
||||||
|
This is the recommended way to shrink JPEG content in the output. The
|
||||||
|
optimizer applies regardless of `--output-type`, so it works on both
|
||||||
|
plain PDFs and Ghostscript-produced PDF/A files.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocrmypdf --optimize 2 --jpeg-quality 60 input.pdf output.pdf
|
||||||
|
```
|
||||||
|
|
||||||
### Modify a file in place
|
### Modify a file in place
|
||||||
|
|
||||||
The file will only be overwritten if OCRmyPDF is successful.
|
The file will only be overwritten if OCRmyPDF is successful.
|
||||||
@@ -239,19 +249,34 @@ case. Use `--tesseract-non-ocr-timeout` to control the timeout for
|
|||||||
non-OCR operations, if needed.
|
non-OCR operations, if needed.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Remove all text or OCR from my PDF
|
### Remove the OCR text layer from my PDF
|
||||||
|
|
||||||
This is getting ridiculous, but OCRmyPDF can complete strip all textual
|
To remove the invisible OCR text layer while keeping the original pages
|
||||||
information from a PDF and reconstruct it as a \"bag of images\" PDF.
|
exactly as they are -- no rasterizing, no change to images or visible
|
||||||
|
content, and a smaller output file -- use `--mode strip`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocrmypdf --mode strip input.pdf output.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
Why would you want to do this? Perhaps you have a PDF where OCR failed to
|
||||||
|
produce useful results and you simply want to get rid of it.
|
||||||
|
|
||||||
|
`--mode strip` removes only text drawn as *invisible* (PDF text render
|
||||||
|
mode 3), which is how OCRmyPDF and most OCR tools add a searchable layer
|
||||||
|
over a scanned page. Some OCR products -- and OCRmyPDF v2.2 and earlier --
|
||||||
|
instead draw *visible* text and paint an opaque image on top of it. That
|
||||||
|
text is part of the visible page, so `--mode strip` cannot remove it
|
||||||
|
without altering the page's appearance.
|
||||||
|
|
||||||
|
To strip *all* text, including such visible text, rasterize the whole page
|
||||||
|
into a \"bag of images\" PDF instead (this rebuilds every page as an image,
|
||||||
|
so the file usually grows and vector content is lost):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ocrmypdf --ocr-engine none --force-ocr input.pdf output.pdf
|
ocrmypdf --ocr-engine none --force-ocr input.pdf output.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
Why would you want to do this? Perhaps you have a PDF where OCR fails to
|
|
||||||
produce useful results, and just want to get rid of all OCR information.
|
|
||||||
This command also removes OCR generated by third party tools.
|
|
||||||
|
|
||||||
### Optimize images without performing OCR
|
### Optimize images without performing OCR
|
||||||
|
|
||||||
You can also optimize all images without performing any OCR:
|
You can also optimize all images without performing any OCR:
|
||||||
@@ -333,12 +358,22 @@ Hyphens denote a range of pages and commas separate page numbers. If you
|
|||||||
prefer to use spaces, quote all of the page numbers:
|
prefer to use spaces, quote all of the page numbers:
|
||||||
`--pages '2, 3, 5, 7'`.
|
`--pages '2, 3, 5, 7'`.
|
||||||
|
|
||||||
|
The token `end` (case-insensitive) is an alias for the last page in the
|
||||||
|
document. For example, `--pages 3-end` OCRs from page 3 through the
|
||||||
|
final page, and `--pages end` OCRs only the last page:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocrmypdf --pages 3-end input.pdf output.pdf
|
||||||
|
ocrmypdf --pages end input.pdf output.pdf
|
||||||
|
```
|
||||||
|
|
||||||
OCRmyPDF will warn if your list of page numbers contains duplicates or
|
OCRmyPDF will warn if your list of page numbers contains duplicates or
|
||||||
overlapping pages. OCRmyPDF does not currently account for document page
|
overlapping pages. (Repeated page numbers are de-duplicated automatically,
|
||||||
numbers, such as an introduction section of a book that uses Roman
|
since the underlying set of pages is what matters.) OCRmyPDF does not
|
||||||
numerals. It simply counts the number of virtual pieces of paper since
|
currently account for document page numbers, such as an introduction
|
||||||
the start. If your list of pages is out of numerical order, OCRmyPDF
|
section of a book that uses Roman numerals. It simply counts the number
|
||||||
will sort it for you.
|
of virtual pieces of paper since the start. If your list of pages is out
|
||||||
|
of numerical order, OCRmyPDF will sort it for you.
|
||||||
|
|
||||||
Regardless of the argument to `--pages`, OCRmyPDF will optimize all
|
Regardless of the argument to `--pages`, OCRmyPDF will optimize all
|
||||||
pages/images in the file and convert it to PDF/A, unless you disable
|
pages/images in the file and convert it to PDF/A, unless you disable
|
||||||
|
|||||||
+84
-23
@@ -71,15 +71,29 @@ application (as opposed to the more conventional case, where a Docker
|
|||||||
container runs as a server). For that reason we usually use the `--rm`
|
container runs as a server). For that reason we usually use the `--rm`
|
||||||
argument to delete the container when it exits.
|
argument to delete the container when it exits.
|
||||||
|
|
||||||
|
:::{note}
|
||||||
|
The image runs as a non-root user (`app`, uid/gid 1000) by default,
|
||||||
|
rather than as root. This is a defense-in-depth measure: a flaw in
|
||||||
|
OCRmyPDF or one of its dependencies cannot trivially act as root inside
|
||||||
|
the container. The examples below assume **rootless Docker** or
|
||||||
|
**Podman**; the differences for traditional *rootful* Docker are
|
||||||
|
described separately under *Special case: rootful Docker* below.
|
||||||
|
:::
|
||||||
|
|
||||||
To start a Docker container (instance of the image):
|
To start a Docker container (instance of the image):
|
||||||
|
|
||||||
:::{code} bash
|
:::{code} bash
|
||||||
docker run --rm -i jbarlow83/ocrmypdf-alpine (... all other arguments here...) - -
|
docker run --rm -i jbarlow83/ocrmypdf-alpine (... all other arguments here...) - -
|
||||||
:::
|
:::
|
||||||
|
|
||||||
For convenience, create a shell alias to hide the Docker command. It is
|
### Recommended: pipe through stdin and stdout
|
||||||
easier to send the input file as stdin and read the output from stdout
|
|
||||||
-- **this avoids the messy permission issues with Docker entirely**.
|
The easiest and most portable way to use the image is to send the input
|
||||||
|
file on stdin and read the output from stdout. This **avoids file
|
||||||
|
permission issues entirely** -- nothing is written to a mounted
|
||||||
|
directory, so it does not matter which user the container runs as, nor
|
||||||
|
whether you use rootless or rootful Docker. For convenience, create a
|
||||||
|
shell alias to hide the Docker command:
|
||||||
|
|
||||||
:::{code} bash
|
:::{code} bash
|
||||||
alias docker_ocrmypdf='docker run --rm -i jbarlow83/ocrmypdf-alpine'
|
alias docker_ocrmypdf='docker run --rm -i jbarlow83/ocrmypdf-alpine'
|
||||||
@@ -90,28 +104,42 @@ docker_ocrmypdf - - <input.pdf >output.pdf
|
|||||||
Or in the wonderful [fish shell](https://fishshell.com/):
|
Or in the wonderful [fish shell](https://fishshell.com/):
|
||||||
|
|
||||||
:::{code} fish
|
:::{code} fish
|
||||||
alias docker_ocrmypdf 'docker run --rm jbarlow83/ocrmypdf-alpine'
|
alias docker_ocrmypdf 'docker run --rm -i jbarlow83/ocrmypdf-alpine'
|
||||||
funcsave docker_ocrmypdf
|
funcsave docker_ocrmypdf
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Alternately, you could mount the local current working directory as a
|
{#docker-volumes}
|
||||||
Docker volume:
|
### Bind-mounted volumes
|
||||||
|
|
||||||
|
If you would rather mount a directory and pass file paths, you need to
|
||||||
|
consider which user owns the files OCRmyPDF writes back into that
|
||||||
|
directory. The image's default working directory is `/data`, so mounting
|
||||||
|
your files there lets you pass plain relative paths without an explicit
|
||||||
|
`--workdir`. Because the container runs as the non-root `app` user, the
|
||||||
|
right invocation otherwise depends on your container runtime.
|
||||||
|
|
||||||
|
**Rootless Docker (the assumed default).** Your own account runs the
|
||||||
|
daemon, so the container's `root` maps back to *your* unprivileged host
|
||||||
|
user, while every other container uid -- including the image's default
|
||||||
|
`app`/1000 -- maps to a *subordinate* uid. A directory you own on the
|
||||||
|
host therefore appears owned by `root` inside the container, so the
|
||||||
|
default `app` user usually **cannot write to it at all**. Run the job as
|
||||||
|
container-`root`, which under rootless Docker is still your ordinary host
|
||||||
|
user, so the write succeeds and the output is owned by you:
|
||||||
|
|
||||||
:::{code} bash
|
:::{code} bash
|
||||||
alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
alias docker_ocrmypdf='docker run --rm -i --user 0:0 -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
||||||
docker_ocrmypdf /data/input.pdf /data/output.pdf
|
docker_ocrmypdf input.pdf output.pdf
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Podman
|
**Podman.** Podman provides `--userns keep-id`, which maps your host uid
|
||||||
|
straight through into the container. Combined with `--user`, you run as
|
||||||
Especially if you use [Podman](https://podman.io/) (or use Docker in
|
your own uid and own the output directly, otherwise you may get access
|
||||||
rootless mode), you may need to add `--userns keep-id` there,
|
errors because the user ID is not mapped to the same UID as on the host:
|
||||||
otherwise you may get access errors, because the user ID is otherwise not
|
|
||||||
mapped to the same UID as on the host:
|
|
||||||
|
|
||||||
:::{code} bash
|
:::{code} bash
|
||||||
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
||||||
podman_ocrmypdf /data/input.pdf /data/output.pdf
|
podman_ocrmypdf input.pdf output.pdf
|
||||||
:::
|
:::
|
||||||
|
|
||||||
If you have SELinux enabled, you may additionally need to add the `:Z` [suffix to
|
If you have SELinux enabled, you may additionally need to add the `:Z` [suffix to
|
||||||
@@ -124,10 +152,27 @@ the end of the linked podman documentation for details. This results in
|
|||||||
the following full command:
|
the following full command:
|
||||||
|
|
||||||
:::{code} bash
|
:::{code} bash
|
||||||
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine'
|
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine'
|
||||||
podman_ocrmypdf /data/input.pdf /data/output.pdf
|
podman_ocrmypdf input.pdf output.pdf
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
{#docker-rootful}
|
||||||
|
### Special case: rootful Docker
|
||||||
|
|
||||||
|
With a traditional root daemon, container uid *N* is the *same* uid *N*
|
||||||
|
on the host. Running the container as root would therefore fill your
|
||||||
|
mounted directory with root-owned files and -- more importantly -- a
|
||||||
|
container escape would run as real host root. Drop to your own uid so the
|
||||||
|
output is owned by you and the process stays unprivileged:
|
||||||
|
|
||||||
|
:::{code} bash
|
||||||
|
alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
|
||||||
|
docker_ocrmypdf input.pdf output.pdf
|
||||||
|
:::
|
||||||
|
|
||||||
|
The non-root default and the `--user` override both reduce the risk here,
|
||||||
|
but rootless Docker or Podman remain the safer choice when available.
|
||||||
|
|
||||||
{#docker-lang-packs}
|
{#docker-lang-packs}
|
||||||
## Adding languages to the Docker image
|
## Adding languages to the Docker image
|
||||||
|
|
||||||
@@ -139,8 +184,12 @@ creating a new Dockerfile based on the public one.
|
|||||||
:::{code} dockerfile
|
:::{code} dockerfile
|
||||||
FROM jbarlow83/ocrmypdf
|
FROM jbarlow83/ocrmypdf
|
||||||
|
|
||||||
|
# The image runs as the non-root "app" user, so switch back to root for
|
||||||
|
# build steps that install packages, then drop back to "app".
|
||||||
|
USER root
|
||||||
# Example: add Italian
|
# Example: add Italian
|
||||||
RUN apt install tesseract-ocr-ita
|
RUN apt-get update && apt-get install -y tesseract-ocr-ita
|
||||||
|
USER app
|
||||||
:::
|
:::
|
||||||
|
|
||||||
To install language packs (training data) such as the
|
To install language packs (training data) such as the
|
||||||
@@ -179,7 +228,11 @@ Extending the Docker image
|
|||||||
--------------------------
|
--------------------------
|
||||||
|
|
||||||
You can extend the Docker image with your own customizations, similar to
|
You can extend the Docker image with your own customizations, similar to
|
||||||
the way it is extended to add language packs.
|
the way it is extended to add language packs. Because the image runs as
|
||||||
|
the non-root `app` user, switch to `USER root` for any build steps that
|
||||||
|
require root (installing packages, writing to system directories) and
|
||||||
|
back to `USER app` afterwards, as shown in the language pack example
|
||||||
|
above.
|
||||||
|
|
||||||
Note that the Docker image is subject to change at any time. For
|
Note that the Docker image is subject to change at any time. For
|
||||||
example, the base image may be updated to a newer version of Ubuntu or
|
example, the base image may be updated to a newer version of Ubuntu or
|
||||||
@@ -196,7 +249,7 @@ Executing the test suite
|
|||||||
The OCRmyPDF test suite is installed with image. To run it:
|
The OCRmyPDF test suite is installed with image. To run it:
|
||||||
|
|
||||||
:::{code} bash
|
:::{code} bash
|
||||||
docker run --rm --entrypoint python jbarlow83/ocrmypdf -m pytest
|
docker run --rm --workdir /app --entrypoint python jbarlow83/ocrmypdf -m pytest
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Accessing the shell
|
Accessing the shell
|
||||||
@@ -205,7 +258,15 @@ Accessing the shell
|
|||||||
To use the shell in the Docker image:
|
To use the shell in the Docker image:
|
||||||
|
|
||||||
:::{code} bash
|
:::{code} bash
|
||||||
docker run -it --entrypoint sh jbarlow83/ocrmypdf
|
docker run -it --entrypoint sh jbarlow83/ocrmypdf-alpine
|
||||||
|
:::
|
||||||
|
|
||||||
|
This shell runs as the non-root `app` user. If you need root inside the
|
||||||
|
container -- for example to install extra packages with `apk` or `apt` --
|
||||||
|
add `--user root`:
|
||||||
|
|
||||||
|
:::{code} bash
|
||||||
|
docker run -it --user root --entrypoint sh jbarlow83/ocrmypdf-alpine
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Using the OCRmyPDF web service wrapper
|
Using the OCRmyPDF web service wrapper
|
||||||
@@ -215,7 +276,7 @@ The OCRmyPDF Docker image includes an example, barebones HTTP web
|
|||||||
service. The webservice may be launched as follows:
|
service. The webservice may be launched as follows:
|
||||||
|
|
||||||
:::{code} bash
|
:::{code} bash
|
||||||
docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf webservice.py
|
docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf /app/webservice.py
|
||||||
:::
|
:::
|
||||||
|
|
||||||
We omit the `--rm` parameter so that the container will not be
|
We omit the `--rm` parameter so that the container will not be
|
||||||
|
|||||||
@@ -49,3 +49,31 @@ pdftk input.pdf cat output output.pdf
|
|||||||
|
|
||||||
Sometimes Acrobat can repair PDFs with its [Preflight
|
Sometimes Acrobat can repair PDFs with its [Preflight
|
||||||
tool](https://helpx.adobe.com/acrobat/using/correcting-problem-areas-preflight-tool.html).
|
tool](https://helpx.adobe.com/acrobat/using/correcting-problem-areas-preflight-tool.html).
|
||||||
|
|
||||||
|
(tesseract-config-missing)=
|
||||||
|
|
||||||
|
## Tesseract cannot open its config file \'hocr\' or \'txt\'
|
||||||
|
|
||||||
|
:::{code}
|
||||||
|
ERROR - Tesseract cannot open its config file 'hocr'.
|
||||||
|
:::
|
||||||
|
|
||||||
|
OCRmyPDF asks Tesseract to produce `hocr` and `txt` output. Tesseract
|
||||||
|
reads the instructions for these output formats from configuration files
|
||||||
|
named `hocr` and `txt` that live in the `configs/` subdirectory of its
|
||||||
|
`tessdata` folder. If those files are missing, Tesseract prints
|
||||||
|
`read_params_file: Can't open hocr`, exits without error, and produces no
|
||||||
|
output.
|
||||||
|
|
||||||
|
This usually happens when a `tessdata` directory was assembled by hand --
|
||||||
|
for example, by downloading individual `.traineddata` files from
|
||||||
|
[tessdata_best](https://github.com/tesseract-ocr/tessdata_best) and
|
||||||
|
pointing `TESSDATA_PREFIX` at them -- because those repositories do not
|
||||||
|
include the `configs/` directory. A complete Tesseract installation from
|
||||||
|
your operating system\'s package manager includes it.
|
||||||
|
|
||||||
|
To fix this, ensure the `configs/hocr` and `configs/txt` files exist in
|
||||||
|
the `tessdata` directory that Tesseract is using. Copying the `configs/`
|
||||||
|
directory from a full Tesseract installation is sufficient. See
|
||||||
|
{envvar}`TESSDATA_PREFIX` for more on selecting an alternate `tessdata`
|
||||||
|
folder.
|
||||||
|
|||||||
+16
-4
@@ -178,11 +178,23 @@ v17 addresses through alternative codepaths. When Ghostscript is used:
|
|||||||
encoding, which may introduce compression artifacts, if Ghostscript
|
encoding, which may introduce compression artifacts, if Ghostscript
|
||||||
PDF/A is enabled.
|
PDF/A is enabled.
|
||||||
- Ghostscript may transcode grayscale and color images, potentially
|
- Ghostscript may transcode grayscale and color images, potentially
|
||||||
lossily, based on an internal algorithm. This
|
lossily, based on an internal algorithm. By default
|
||||||
behavior can be suppressed by setting `--pdfa-image-compression` to
|
(`--pdfa-image-compression=auto`) OCRmyPDF selects lossless image
|
||||||
`jpeg` or `lossless` to set all images to one type or the other.
|
compression at `-O0` so Ghostscript will not transcode lossless images
|
||||||
Ghostscript lacks an option to maintain the input image's format.
|
to JPEG. At `-O1` (the default optimization level) and above, `auto`
|
||||||
|
defers to Ghostscript's heuristic instead; `-O1` is a historical
|
||||||
|
exception, kept for backwards compatibility because coercing it to
|
||||||
|
lossless can substantially bloat output. You can override this by
|
||||||
|
setting `--pdfa-image-compression` to `jpeg` or `lossless` to force all
|
||||||
|
images to one type or the other. `lossless` passes existing JPEGs
|
||||||
|
through untouched (re-encoding them losslessly would only inflate them)
|
||||||
|
while encoding non-JPEG images losslessly.
|
||||||
(Modern Ghostscript can copy JPEG images without transcoding them.)
|
(Modern Ghostscript can copy JPEG images without transcoding them.)
|
||||||
|
Advanced users can also tune Ghostscript's image recompression with
|
||||||
|
`--ghostscript-jpeg-quality` and `--ghostscript-jpeg-maxdpi`; see
|
||||||
|
[Advanced Ghostscript tuning](advanced.md#advanced-ghostscript-tuning).
|
||||||
|
Most users should prefer `--jpeg-quality` (applied by the OCRmyPDF
|
||||||
|
optimizer) over those Ghostscript-scoped controls.
|
||||||
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
|
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
|
||||||
one of the standard XMP metadata namespaces for PDFs. In particular,
|
one of the standard XMP metadata namespaces for PDFs. In particular,
|
||||||
PRISM Metadata is removed.
|
PRISM Metadata is removed.
|
||||||
|
|||||||
+10
-1
@@ -98,7 +98,16 @@ If `pngquant` is installed, OCRmyPDF will use it to perform quantize
|
|||||||
paletted images to reduce their size.
|
paletted images to reduce their size.
|
||||||
|
|
||||||
The quality of JPEGs may be lowered, on the assumption that a lower
|
The quality of JPEGs may be lowered, on the assumption that a lower
|
||||||
quality image may be suitable for storage after OCR.
|
quality image may be suitable for storage after OCR. Use `--jpeg-quality`
|
||||||
|
to control the optimizer's JPEG quality target. The optimizer is the
|
||||||
|
recommended way to reduce JPEG image sizes: it applies consistently
|
||||||
|
regardless of whether Ghostscript was used to produce a PDF/A.
|
||||||
|
|
||||||
|
If you specifically need to tune Ghostscript's own PDF/A image handling
|
||||||
|
(for example, to force a hard DPI cap), see
|
||||||
|
[Advanced Ghostscript tuning](advanced.md#advanced-ghostscript-tuning)
|
||||||
|
for the separate `--ghostscript-jpeg-quality` and
|
||||||
|
`--ghostscript-jpeg-maxdpi` options.
|
||||||
|
|
||||||
It is not possible to optimize all image types. Uncommon image types may
|
It is not possible to optimize all image types. Uncommon image types may
|
||||||
be skipped by the optimizer.
|
be skipped by the optimizer.
|
||||||
|
|||||||
@@ -3,6 +3,256 @@
|
|||||||
|
|
||||||
# v17
|
# v17
|
||||||
|
|
||||||
|
## v17.8.1
|
||||||
|
|
||||||
|
- Improved the `--tesseract-pagesegmode` help text to point to
|
||||||
|
`tesseract --help-extra`, since Tesseract 5.5.2 moved the page segmentation
|
||||||
|
mode documentation there from `tesseract --help`. Thanks @sokai.
|
||||||
|
- Internal refactoring: completed a project-wide mypy type-checking pass
|
||||||
|
(`--check-untyped-defs` is now enabled, and the mypy pre-commit hook is now
|
||||||
|
blocking rather than advisory), fixing several latent edge-case bugs
|
||||||
|
surfaced along the way.
|
||||||
|
- Release process improvements: migrated from pre-commit to prek for local
|
||||||
|
git hooks, and added a dedicated lint job to CI.
|
||||||
|
- Improved typing strictness for `Path`.
|
||||||
|
|
||||||
|
## v17.8.0
|
||||||
|
|
||||||
|
- `--output-type auto` (the default) again produces PDF/A whenever it can,
|
||||||
|
matching OCRmyPDF 16's "PDF/A by default" behavior. It first tries the fast
|
||||||
|
Ghostscript-free conversion (validated by veraPDF when available) and now
|
||||||
|
falls back to Ghostscript when that cannot produce PDF/A, only emitting a
|
||||||
|
regular PDF when even Ghostscript cannot safely convert (for example, an
|
||||||
|
input with non-embedded CID/CJK fonts, per {issue}`1561`). A consequence is
|
||||||
|
that the default path may once again invoke Ghostscript, which is slower and
|
||||||
|
may transcode images; use `--output-type pdf` to skip PDF/A conversion
|
||||||
|
entirely.
|
||||||
|
- Fixed detection of veraPDF 1.30.0 and newer: recent builds print JVM
|
||||||
|
warnings before their version string, which caused OCRmyPDF to report
|
||||||
|
veraPDF as unavailable and skip the fast PDF/A path.
|
||||||
|
- OCRmyPDF no longer silently corrupts a non-embedded CID (CJK) text layer when
|
||||||
|
producing PDF/A ({issue}`1561`). PDF/A requires all fonts to be embedded, so
|
||||||
|
Ghostscript substitutes and re-embeds non-embedded CID fonts — such as the OCR
|
||||||
|
text layer Adobe Acrobat adds to scanned CJK documents — which mangles the
|
||||||
|
text and destroys searchability. OCRmyPDF now detects non-embedded CID fonts
|
||||||
|
before conversion: with `--output-type auto` (the default) it produces a
|
||||||
|
regular PDF and preserves the existing text layer, and with an explicit
|
||||||
|
`--output-type pdfa*` it stops with an error rather than emit corrupted
|
||||||
|
output. Use `--output-type pdf` to keep the text layer, or `--force-ocr` to
|
||||||
|
rebuild it with embedded fonts.
|
||||||
|
- Writing the output PDF to standard output (`ocrmypdf input.pdf -`) is now
|
||||||
|
protected against corruption at the operating system level. Previously
|
||||||
|
OCRmyPDF relied on no in-process code — third-party libraries, plugins, or
|
||||||
|
stray `print()` calls — ever writing to stdout; a single accidental write
|
||||||
|
would silently corrupt the PDF. The command line program now saves the real
|
||||||
|
stdout at startup, before plugins are loaded or any worker process/thread is
|
||||||
|
started, and redirects file descriptor 1 to stderr, so that only OCRmyPDF's
|
||||||
|
final PDF output can reach stdout. A consequence is that a plugin which
|
||||||
|
intentionally prints to stdout will have that output redirected to stderr.
|
||||||
|
- Added the public API function {func}`ocrmypdf.configure_stdout_protection`,
|
||||||
|
which installs this same protection. Like {func}`ocrmypdf.configure_logging`,
|
||||||
|
it is optional and intended for callers that want command-line-like behavior;
|
||||||
|
applications that manage their own standard output should not call it.
|
||||||
|
- Fixed an uncaught `UnicodeDecodeError` when processing a PDF whose
|
||||||
|
`/DocumentInfo` dictionary contains a `/Name` key encoded in Latin-1 (or
|
||||||
|
another non-UTF-8 encoding), such as `/Saks#e5r`. `repair_docinfo_nuls` now
|
||||||
|
treats such a block as malformed, logs a message, and continues instead of
|
||||||
|
crashing the pipeline ({issue}`1540`). Current pikepdf releases tolerate these
|
||||||
|
keys by surrogate-escaping them, but older versions raised while iterating the
|
||||||
|
dictionary.
|
||||||
|
|
||||||
|
## v17.7.1
|
||||||
|
|
||||||
|
- Fixed a severe, Windows-specific performance regression in the "Scanning
|
||||||
|
contents" phase, most visible with `--redo-ocr` ({issue}`1662`). Since
|
||||||
|
v16.4.3, OCRmyPDF forced pdfminer's read buffer to 256 MiB to work around a
|
||||||
|
pdfminer bug that mishandled tokens split across the buffer boundary
|
||||||
|
({issue}`1361`). On Windows, CPython's `BufferedReader.read()` eagerly
|
||||||
|
allocates a buffer of the requested size on every read, so the oversized
|
||||||
|
buffer made each of pdfminer's thousands of reads cost tens of milliseconds
|
||||||
|
(this allocation is lazy, and effectively free, on Linux). The underlying
|
||||||
|
pdfminer bug was fixed upstream in pdfminer.six 20250327
|
||||||
|
([#1030](https://github.com/pdfminer/pdfminer.six/pull/1030)), with a
|
||||||
|
follow-up for tokens split across streams in 20260107
|
||||||
|
([#1158](https://github.com/pdfminer/pdfminer.six/pull/1158)), so the
|
||||||
|
workaround has been removed and the minimum pdfminer.six version raised to
|
||||||
|
20260107.
|
||||||
|
- The font discovery used to build the OCR text layer now finds variable fonts
|
||||||
|
such as `NotoSansArabic[wdth,wght].ttf`, the form shipped by Homebrew casks
|
||||||
|
and current Google Fonts releases. Previously only static `-Regular.ttf`/`.otf`
|
||||||
|
files were matched, so users who had installed the correct Noto font still got
|
||||||
|
the glyphless fallback and a "No font found" warning ({issue}`1652`).
|
||||||
|
- Font discovery is now language-aware for CJK: each Chinese, Japanese, and
|
||||||
|
Korean language maps to its own per-language Noto family (NotoSansSC, TC, HK,
|
||||||
|
JP, KR), with the pan-CJK super font kept as a shared fallback, since the
|
||||||
|
per-language fonts are region subsets that may lack glyphs from other scripts.
|
||||||
|
- The warning shown when no installed font has glyphs for some text was reworded
|
||||||
|
to explain the consequence — the text is still added as a searchable, copyable
|
||||||
|
layer but appears blank when highlighted in a viewer — and to name the specific
|
||||||
|
font family to install.
|
||||||
|
|
||||||
|
## v17.7.0
|
||||||
|
|
||||||
|
- The Docker images now run as a non-root user (`app`, uid/gid 1000) by default
|
||||||
|
rather than as root, as a defense-in-depth measure. If you bind-mount a
|
||||||
|
directory for input and output, you may now need to add a `--user` argument so
|
||||||
|
the container can write to it; the correct value differs for rootless Docker,
|
||||||
|
Podman, and rootful Docker, and is described in the Docker documentation.
|
||||||
|
Piping the input and output through stdin/stdout still works with no
|
||||||
|
permission setup.
|
||||||
|
- The Docker images now default their working directory to `/data`, so files in
|
||||||
|
a directory mounted there can be given as relative paths without an explicit
|
||||||
|
`--workdir`.
|
||||||
|
- The Ubuntu Docker image now installs Tesseract 5 from the Ubuntu archive
|
||||||
|
instead of the third-party `alex-p/tesseract-ocr5` PPA, and the base images
|
||||||
|
were updated to Ubuntu 26.04 and Alpine 3.24.
|
||||||
|
- Fixed a missing space in the error message shown when OCRmyPDF cannot access
|
||||||
|
its working directory inside a Docker container.
|
||||||
|
- Updated packaged dependencies, including the optional web service stack
|
||||||
|
(starlette, tornado, python-multipart) and cryptography.
|
||||||
|
|
||||||
|
## v17.6.0
|
||||||
|
|
||||||
|
- When the optimizer encounters an image it cannot process (for example, an
|
||||||
|
exotic colorspace that cannot be transcoded), it now logs a concise warning
|
||||||
|
that the image was left unchanged rather than printing an alarming
|
||||||
|
traceback. The output file was already valid in these cases; only the
|
||||||
|
reporting was misleading. The full traceback is still available at debug
|
||||||
|
verbosity (`-v 1`) ({issue}`846`).
|
||||||
|
- `--pdfa-image-compression=auto` (the default) now selects lossless image
|
||||||
|
compression at `-O0` so Ghostscript no longer transcodes lossless images to
|
||||||
|
JPEG during PDF/A generation. At `-O1` and above, `auto` continues to defer
|
||||||
|
to Ghostscript's heuristic, which may recompress images lossily. `-O1` (the
|
||||||
|
default level) is kept as a historical exception because coercing it to
|
||||||
|
lossless can substantially bloat output; users who want guaranteed lossless
|
||||||
|
image handling should pass `--pdfa-image-compression=lossless` or use `-O0`
|
||||||
|
({issue}`1124`).
|
||||||
|
- `--pdfa-image-compression=lossless` now passes existing JPEG images through
|
||||||
|
unchanged rather than re-encoding them with a lossless codec. Re-encoding an
|
||||||
|
already-lossy JPEG losslessly cannot recover quality and only inflates the
|
||||||
|
file, so JPEGs are preserved while non-JPEG images are encoded losslessly.
|
||||||
|
- OCRmyPDF now validates and repairs malformed page-boundary boxes
|
||||||
|
(``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``, ``/BleedBox``) in its
|
||||||
|
input, following the PDF 2.0 specification. Coordinates written in invalid
|
||||||
|
exponential notation are reinterpreted ({issue}`1398`); rectangles whose
|
||||||
|
corners are given in reversed order are normalized, which previously crashed
|
||||||
|
with ``NegativeDimensionError`` ({issue}`1526`); and a crop/trim/art/bleed box
|
||||||
|
that falls outside the MediaBox is clamped to their intersection, or discarded
|
||||||
|
when that intersection is empty, which previously produced an output with a
|
||||||
|
zero-height effective page that some viewers refused to open ({issue}`1400`).
|
||||||
|
When a box is discarded, clamped, or reinterpreted, OCRmyPDF logs a warning
|
||||||
|
recommending visual inspection of the output. Thanks @ajdlinux for the initial
|
||||||
|
fix in PR #1691.
|
||||||
|
- OCRmyPDF now discards an embedded Adobe full-text search index
|
||||||
|
(``/Root/PieceInfo/SearchIndex``) from its output. This proprietary index,
|
||||||
|
produced by Acrobat's "Embed Index" feature, is read only by Adobe Acrobat;
|
||||||
|
other viewers ignore it and search the text on the fly. Because any change to
|
||||||
|
a PDF invalidates the index, retaining it after OCRmyPDF rewrites the document
|
||||||
|
would leave a stale index that returns incorrect search results in Acrobat.
|
||||||
|
Modern viewers rebuild a search index on demand, so there is no loss of
|
||||||
|
search capability.
|
||||||
|
- OCRmyPDF now discards embedded per-page thumbnail images (the optional
|
||||||
|
``/Thumb`` image XObject on a page) from its output. OCRmyPDF alters page
|
||||||
|
appearance (deskew, clean, rasterize, re-render) and plugins may edit pages
|
||||||
|
arbitrarily, so a retained thumbnail would be stale and no longer match its
|
||||||
|
page. Embedded thumbnails are a navigation aid that modern viewers generate
|
||||||
|
on demand, so there is no loss of functionality.
|
||||||
|
- Fixed a regression in OCR quality for PDFs that paint a 1-bit image mask
|
||||||
|
(stencil) with a gray or colored fill color. Previously such pages were
|
||||||
|
rasterized as 1-bit black-and-white before OCR, so Ghostscript dithered
|
||||||
|
mid-tone text into an unreadable stipple and Tesseract failed to recognize
|
||||||
|
it. The rasterizer now inspects the fill color used to paint a mask and
|
||||||
|
promotes the page to grayscale or full color as needed, so the distinction
|
||||||
|
is preserved for the OCR engine. This applies to both the Ghostscript and
|
||||||
|
pypdfium rasterizers. {issue}`1688`
|
||||||
|
- The default 1-bit raster device for Ghostscript is now ``pngmonod``
|
||||||
|
(error-diffusion) instead of ``pngmono`` (ordered dithering). It produces
|
||||||
|
better input for OCR on faint or anti-aliased scans at negligible cost and
|
||||||
|
no change to output file size, since the rasterized image is an
|
||||||
|
intermediate that is discarded after OCR.
|
||||||
|
- When rasterizing pages with Ghostscript, OCRmyPDF now enables text and
|
||||||
|
graphics anti-aliasing (``-dTextAlphaBits=4 -dGraphicsAlphaBits=4``) for the
|
||||||
|
grayscale and color raster devices. Ghostscript 10.x renders aliased glyphs
|
||||||
|
that OCR frequently misreads as extra word breaks or substituted characters;
|
||||||
|
anti-aliasing materially improves OCR accuracy on the Ghostscript
|
||||||
|
rasterization path, especially for small fonts at moderate resolution. The
|
||||||
|
1-bit monochrome devices are unaffected, since they perform their own
|
||||||
|
anti-aliased downscaling and older Ghostscript versions reject alpha-bit
|
||||||
|
options on them. Note that the default rasterizer (``--rasterizer auto``)
|
||||||
|
prefers pypdfium2, which already anti-aliases; this change benefits users who
|
||||||
|
select ``--rasterizer ghostscript`` or do not have pypdfium2 installed.
|
||||||
|
OCRmyPDF now also logs which rasterizer rendered each page at debug verbosity
|
||||||
|
(``-v 1``), and the ``--rasterizer`` help text explains the OCR-quality
|
||||||
|
trade-off, to make such reports easier to diagnose. {issue}`1439`
|
||||||
|
- When Tesseract reports a page with many diacritics, OCRmyPDF still logs its
|
||||||
|
interpreted "lots of diacritics - possibly poor OCR" hint, but now also emits
|
||||||
|
Tesseract's raw message at debug verbosity (``-v 1``) so the original wording
|
||||||
|
is available for diagnosis. {issue}`1566`
|
||||||
|
- Added ``--mode strip``, which removes the invisible OCR text layer from a PDF
|
||||||
|
in place. Unlike ``--ocr-engine none --force-ocr``, it does not rasterize the
|
||||||
|
page, so images and visible content are preserved unchanged and the output is
|
||||||
|
smaller rather than larger. Only text drawn as invisible (PDF text render mode
|
||||||
|
3) is removed; some OCR engines -- and OCRmyPDF v2.2 and earlier -- express
|
||||||
|
text as visible glyphs covered by an opaque image, and that text cannot be
|
||||||
|
removed this way. {issue}`1435`
|
||||||
|
|
||||||
|
## v17.5.0
|
||||||
|
|
||||||
|
- Added support for the ``end`` alias in ``--pages``, denoting the last page
|
||||||
|
of the document. For example, ``--pages 3-end`` OCRs from page 3 through
|
||||||
|
the final page. {issue}`1615`
|
||||||
|
- Added ``--ghostscript-jpeg-quality`` and ``--ghostscript-jpeg-maxdpi``
|
||||||
|
advanced options for tuning Ghostscript's PDF/A output. The optimizer's
|
||||||
|
``--jpeg-quality`` remains the recommended file-size control.
|
||||||
|
- Fixed pypdfium2 rasterizer clipping content when the CropBox was smaller
|
||||||
|
than the MediaBox (e.g. JSTOR or cropped PDFs). {issue}`1685`
|
||||||
|
- Fixed Form XObject cycle detection in the optimizer's image xref scan.
|
||||||
|
Self-referential or DAG-shaped Form graphs (notably from PowerPoint
|
||||||
|
exports) previously produced floods of recursion warnings and could hang
|
||||||
|
for minutes. {issue}`1321`
|
||||||
|
- Tesseract config errors are now surfaced as ``TesseractConfigError`` with
|
||||||
|
actionable guidance, instead of crashing later with a confusing
|
||||||
|
``FileNotFoundError`` on the missing hOCR output. {issue}`1687`
|
||||||
|
- Refreshed the Chinese README translation. Thanks @cislunarspace.
|
||||||
|
- Internal refactoring of the ``_exec`` and ``subprocess`` modules to
|
||||||
|
separate probing from execution.
|
||||||
|
- CI dependency updates.
|
||||||
|
|
||||||
|
## v17.4.2
|
||||||
|
|
||||||
|
- Fixed Python API unconditionally overriding ``PIL.Image.MAX_IMAGE_PIXELS``
|
||||||
|
when the caller did not explicitly set ``max_image_mpixels``. Host
|
||||||
|
applications (e.g. Paperless-NGX) that configure the PIL limit before
|
||||||
|
invoking ``ocrmypdf.ocr()`` now have their setting respected. The CLI
|
||||||
|
default of 250 megapixels is unchanged. {issue}`1665`
|
||||||
|
- Updated uv.lock to avoid pinning a vulnerable version of Pillow. {issue}`1666`
|
||||||
|
|
||||||
|
## v17.4.1
|
||||||
|
|
||||||
|
- Fixed RTL text extraction order in the fpdf2 renderer. Arabic lam-alef
|
||||||
|
ligatures and other multi-character CMap entries were garbled by the bidi
|
||||||
|
algorithm during text extraction. {issue}`1655`
|
||||||
|
- Fixed ``work_folder`` not being set in ``PdfContext`` options when using
|
||||||
|
the Python API. Thanks @bluebox-steven. {issue}`1613`
|
||||||
|
- Updated Ghostscript JPEG corruption warning to include the detected version
|
||||||
|
number, confirming the bug persists in Ghostscript 10.7.0.
|
||||||
|
- Internal refactoring.
|
||||||
|
- CI dependency updates.
|
||||||
|
|
||||||
|
## v17.4.0
|
||||||
|
|
||||||
|
- Added ``--no-overwrite`` / ``-n`` option to prevent overwriting output files.
|
||||||
|
If the destination file already exists, OCRmyPDF exits with code 5
|
||||||
|
(``OutputFileAccessError``). {issue}`1642`
|
||||||
|
- Fixed text layer stretching in the fpdf2 renderer for widely-spaced words.
|
||||||
|
The horizontal scaling (Tz) was incorrectly stretched to fill inter-word gaps
|
||||||
|
instead of relying on Td positioning, causing text selection to highlight far
|
||||||
|
beyond the actual word boundaries. {issue}`1635`
|
||||||
|
- Fixed ``optimize=2`` or ``optimize=3`` crash when using the Python API without
|
||||||
|
explicitly setting ``jpg_quality`` or ``png_quality``. {issue}`1641`
|
||||||
|
- Fixed ``verapdf`` availability check crashing with ``NotADirectoryError`` on
|
||||||
|
some platforms. {issue}`1638`
|
||||||
|
|
||||||
## v17.3.0
|
## v17.3.0
|
||||||
|
|
||||||
- Fixed Python API ignoring the ``language`` parameter, always defaulting to
|
- Fixed Python API ignoring the ``language`` parameter, always defaulting to
|
||||||
|
|||||||
+2
-3
@@ -16,7 +16,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import filecmp
|
import filecmp
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import posixpath
|
import posixpath
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
@@ -39,7 +38,7 @@ script_dir = Path(__file__).parent
|
|||||||
# set archive_dir to a path for backup original documents. Leave empty if not required.
|
# set archive_dir to a path for backup original documents. Leave empty if not required.
|
||||||
archive_dir = "/pdfbak"
|
archive_dir = "/pdfbak"
|
||||||
|
|
||||||
start_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
|
start_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path()
|
||||||
|
|
||||||
if len(sys.argv) > 2:
|
if len(sys.argv) > 2:
|
||||||
log_file = Path(sys.argv[2])
|
log_file = Path(sys.argv[2])
|
||||||
@@ -68,7 +67,7 @@ for filename in start_dir.glob("**/*.pdf"):
|
|||||||
try:
|
try:
|
||||||
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
||||||
except OSError:
|
except OSError:
|
||||||
os.makedirs(posixpath.dirname(archive_filename))
|
Path(posixpath.dirname(archive_filename)).mkdir(parents=True)
|
||||||
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
shutil.copy2(filename, posixpath.dirname(archive_filename))
|
||||||
try:
|
try:
|
||||||
result = ocrmypdf.ocr(filename, filename, deskew=True)
|
result = ocrmypdf.ocr(filename, filename, deskew=True)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Helper script for bisecting PDFs to find a page with an issue."""
|
"""Helper script for bisecting PDFs to find a page with an issue."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ __ocrmypdf_arguments()
|
|||||||
--rasterizer (PDF page rasterizer)
|
--rasterizer (PDF page rasterizer)
|
||||||
--rotate-pages-threshold (page rotation confidence)
|
--rotate-pages-threshold (page rotation confidence)
|
||||||
--pdfa-image-compression (set PDF/A image compression options)
|
--pdfa-image-compression (set PDF/A image compression options)
|
||||||
|
--ghostscript-jpeg-quality (Ghostscript JPEG quality during PDF/A [0..100])
|
||||||
|
--ghostscript-jpeg-maxdpi (cap Ghostscript image DPI during PDF/A)
|
||||||
--fast-web-view (if file size if above this amount in MB linearize PDF)
|
--fast-web-view (if file size if above this amount in MB linearize PDF)
|
||||||
--continue-on-soft-render-error (continue after recoverable render errors)
|
--continue-on-soft-render-error (continue after recoverable render errors)
|
||||||
--plugin (name of plugin to import)
|
--plugin (name of plugin to import)
|
||||||
@@ -337,6 +339,7 @@ __ocrmypdf_check_previous()
|
|||||||
|
|
||||||
--title|--author|--subject|--keywords|--unpaper-args|--pages|--plugin|\
|
--title|--author|--subject|--keywords|--unpaper-args|--pages|--plugin|\
|
||||||
--jpeg-quality|--png-quality|--image-dpi|--oversample|--skip-big|--max-image-mpixels|\
|
--jpeg-quality|--png-quality|--image-dpi|--oversample|--skip-big|--max-image-mpixels|\
|
||||||
|
--ghostscript-jpeg-quality|--ghostscript-jpeg-maxdpi|\
|
||||||
--tesseract-timeout|--tesseract-non-ocr-timeout|--tesseract-downsample-above|\
|
--tesseract-timeout|--tesseract-non-ocr-timeout|--tesseract-downsample-above|\
|
||||||
--rotate-pages-threshold|--fast-web-view)
|
--rotate-pages-threshold|--fast-web-view)
|
||||||
# argument required but no completions available
|
# argument required but no completions available
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ function __fish_ocrmypdf_pdfa_compression
|
|||||||
echo -e "lossless\t"(_ "convert color and grayscale images to lossless (PNG)")
|
echo -e "lossless\t"(_ "convert color and grayscale images to lossless (PNG)")
|
||||||
end
|
end
|
||||||
complete -c ocrmypdf -x -l pdfa-image-compression -a '(__fish_ocrmypdf_pdfa_compression)' -d "set PDF/A image compression options"
|
complete -c ocrmypdf -x -l pdfa-image-compression -a '(__fish_ocrmypdf_pdfa_compression)' -d "set PDF/A image compression options"
|
||||||
|
complete -c ocrmypdf -x -l ghostscript-jpeg-quality -d "Ghostscript JPEG quality during PDF/A [0..100]"
|
||||||
|
complete -c ocrmypdf -x -l ghostscript-jpeg-maxdpi -d "cap Ghostscript image DPI during PDF/A"
|
||||||
|
|
||||||
complete -c ocrmypdf -x -s j -l jobs -d "how many worker processes to use"
|
complete -c ocrmypdf -x -s j -l jobs -d "how many worker processes to use"
|
||||||
complete -c ocrmypdf -x -l title -d "set metadata"
|
complete -c ocrmypdf -x -l title -d "set metadata"
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ services:
|
|||||||
ocrmypdf:
|
ocrmypdf:
|
||||||
restart: always
|
restart: always
|
||||||
container_name: ocrmypdf
|
container_name: ocrmypdf
|
||||||
image: jbarlow83/ocrmypdf
|
image: jbarlow83/ocrmypdf-alpine
|
||||||
volumes:
|
volumes:
|
||||||
- "/media/scan:/input"
|
- "/media/scan:/input"
|
||||||
- "/mnt/scan:/output"
|
- "/mnt/scan:/output"
|
||||||
environment:
|
environment:
|
||||||
- OCR_OUTPUT_DIRECTORY_YEAR_MONTH=0
|
- OCR_OUTPUT_DIRECTORY_YEAR_MONTH=0
|
||||||
|
# The image runs as the non-root "app" user (uid 1000) by default. The
|
||||||
|
# correct value here depends on your runtime, so that the watcher can write
|
||||||
|
# to the /output bind mount and the files end up owned by you:
|
||||||
|
# rootful Docker -> your host uid:gid
|
||||||
|
# rootless Docker -> "0:0" (container root maps to your host user)
|
||||||
|
# Podman -> your host uid:gid, plus `userns_mode: "keep-id"`
|
||||||
|
# See docs/docker.md ("Bind-mounted volumes") for the reasoning.
|
||||||
user: "<SET TO YOUR USER ID>:<SET TO YOUR GROUP ID>"
|
user: "<SET TO YOUR USER ID>:<SET TO YOUR GROUP ID>"
|
||||||
entrypoint: python3
|
entrypoint: python3
|
||||||
command: watcher.py
|
command: /app/watcher.py
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ def do_column(label, suffix, d):
|
|||||||
env[k] = v
|
env[k] = v
|
||||||
args = shlex.split(
|
args = shlex.split(
|
||||||
cli.format(
|
cli.format(
|
||||||
in_=os.path.join(d, "input.pdf"),
|
in_=Path(d) / "input.pdf",
|
||||||
out=os.path.join(d, f"output{suffix}.pdf"),
|
out=Path(d) / f"output{suffix}.pdf",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
with st.expander("Environment variables", expanded=bool(env_text.strip())):
|
with st.expander("Environment variables", expanded=bool(env_text.strip())):
|
||||||
@@ -106,10 +106,10 @@ def main():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
doc1 = pymupdf.open(os.path.join(d, "output1.pdf"))
|
doc1 = pymupdf.open(Path(d, "output1.pdf"))
|
||||||
doc2 = pymupdf.open(os.path.join(d, "output2.pdf"))
|
doc2 = pymupdf.open(Path(d, "output2.pdf"))
|
||||||
for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)):
|
for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)):
|
||||||
st.write(f"Page {i+1}")
|
st.write(f"Page {i + 1}")
|
||||||
page1, page2 = page1_2
|
page1, page2 = page1_2
|
||||||
col1, col2 = st.columns(2)
|
col1, col2 = st.columns(2)
|
||||||
with col1, st.container(border=True):
|
with col1, st.container(border=True):
|
||||||
|
|||||||
+3
-4
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
@@ -60,10 +59,10 @@ def main():
|
|||||||
Path(d, "2.pdf").write_bytes(pdf_bytes2)
|
Path(d, "2.pdf").write_bytes(pdf_bytes2)
|
||||||
|
|
||||||
with st.expander("Text"):
|
with st.expander("Text"):
|
||||||
doc1 = pymupdf.open(os.path.join(d, "1.pdf"))
|
doc1 = pymupdf.open(Path(d, "1.pdf"))
|
||||||
doc2 = pymupdf.open(os.path.join(d, "2.pdf"))
|
doc2 = pymupdf.open(Path(d, "2.pdf"))
|
||||||
for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)):
|
for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)):
|
||||||
st.write(f"Page {i+1}")
|
st.write(f"Page {i + 1}")
|
||||||
page1, page2 = page1_2
|
page1, page2 = page1_2
|
||||||
col1, col2 = st.columns(2)
|
col1, col2 = st.columns(2)
|
||||||
with col1, st.container(border=True):
|
with col1, st.container(border=True):
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def main(
|
|||||||
engine: Annotated[str, cyclopts.Parameter()] = 'pdftotext',
|
engine: Annotated[str, cyclopts.Parameter()] = 'pdftotext',
|
||||||
):
|
):
|
||||||
"""Compare text in PDFs."""
|
"""Compare text in PDFs."""
|
||||||
with open(pdf1, 'rb') as f1, open(pdf2, 'rb') as f2:
|
with pdf1.open('rb') as f1, pdf2.open('rb') as f2:
|
||||||
text1 = run(
|
text1 = run(
|
||||||
['pdftotext', '-layout', '-', '-'],
|
['pdftotext', '-layout', '-', '-'],
|
||||||
stdin=f1,
|
stdin=f1,
|
||||||
|
|||||||
+10
-9
@@ -13,13 +13,14 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# pylint: disable=logging-format-interpolation
|
# pylint: disable=logging-format-interpolation
|
||||||
# pylint: disable=logging-not-lazy
|
# pylint: disable=logging-not-lazy
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
script_dir = Path(os.path.realpath(__file__)).parent
|
||||||
timestamp = time.strftime("%Y-%m-%d-%H%M_")
|
timestamp = time.strftime("%Y-%m-%d-%H%M_")
|
||||||
log_file = script_dir + '/' + timestamp + 'ocrmypdf.log'
|
log_file = script_dir / (timestamp + 'ocrmypdf.log')
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s %(message)s',
|
format='%(asctime)s %(message)s',
|
||||||
@@ -33,10 +34,10 @@ for dir_name, _subdirs, file_list in os.walk(start_dir):
|
|||||||
logging.info(dir_name)
|
logging.info(dir_name)
|
||||||
os.chdir(dir_name)
|
os.chdir(dir_name)
|
||||||
for filename in file_list:
|
for filename in file_list:
|
||||||
file_stem, file_ext = os.path.splitext(filename)
|
file_stem, file_ext = Path(filename).stem, Path(filename).suffix
|
||||||
if file_ext != '.pdf':
|
if file_ext != '.pdf':
|
||||||
continue
|
continue
|
||||||
full_path = os.path.join(dir_name, filename)
|
full_path = Path(dir_name, filename)
|
||||||
timestamp_ocr = time.strftime("%Y-%m-%d-%H%M_OCR_")
|
timestamp_ocr = time.strftime("%Y-%m-%d-%H%M_OCR_")
|
||||||
filename_ocr = timestamp_ocr + file_stem + '.pdf'
|
filename_ocr = timestamp_ocr + file_stem + '.pdf'
|
||||||
# create string for pdf processing
|
# create string for pdf processing
|
||||||
@@ -52,10 +53,10 @@ for dir_name, _subdirs, file_list in os.walk(start_dir):
|
|||||||
'-',
|
'-',
|
||||||
]
|
]
|
||||||
logging.info(cmd)
|
logging.info(cmd)
|
||||||
full_path_ocr = os.path.join(dir_name, filename_ocr)
|
full_path_ocr = Path(dir_name, filename_ocr)
|
||||||
with (
|
with (
|
||||||
open(filename, 'rb') as input_file,
|
Path(filename).open('rb') as input_file,
|
||||||
open(full_path_ocr, 'wb') as output_file,
|
full_path_ocr.open('wb') as output_file,
|
||||||
):
|
):
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -67,8 +68,8 @@ for dir_name, _subdirs, file_list in os.walk(start_dir):
|
|||||||
errors='ignore',
|
errors='ignore',
|
||||||
)
|
)
|
||||||
logging.info(proc.stderr)
|
logging.info(proc.stderr)
|
||||||
os.chmod(full_path_ocr, 0o664)
|
full_path_ocr.chmod(0o664)
|
||||||
os.chmod(full_path, 0o664)
|
full_path.chmod(0o664)
|
||||||
full_path_ocr_archive = sys.argv[2]
|
full_path_ocr_archive = sys.argv[2]
|
||||||
full_path_archive = sys.argv[2] + '/no_ocr'
|
full_path_archive = sys.argv[2] + '/no_ocr'
|
||||||
shutil.move(full_path_ocr, full_path_ocr_archive)
|
shutil.move(full_path_ocr, full_path_ocr_archive)
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from enum import Enum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ app = cyclopts.App(name="ocrmypdf-watcher")
|
|||||||
log = logging.getLogger('ocrmypdf-watcher')
|
log = logging.getLogger('ocrmypdf-watcher')
|
||||||
|
|
||||||
|
|
||||||
class LoggingLevelEnum(str, Enum):
|
class LoggingLevelEnum(StrEnum):
|
||||||
"""Enum for logging levels."""
|
"""Enum for logging levels."""
|
||||||
|
|
||||||
DEBUG = "DEBUG"
|
DEBUG = "DEBUG"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# prek pre-commit configuration — https://prek.j178.dev
|
||||||
|
#
|
||||||
|
# The local/system hooks below invoke the project's OWN pinned tools (ruff/mypy
|
||||||
|
# from uv.lock) and mirror .github/workflows/build.yml's lint job exactly, so
|
||||||
|
# they can never drift from CI's versions or rules. prek installs nothing of
|
||||||
|
# its own for them — "system" language just execs whatever `uv run` resolves.
|
||||||
|
#
|
||||||
|
# The pre-commit/pre-commit-hooks repo hooks below are generic file checks with
|
||||||
|
# no project-local tool equivalent, so they're kept as a normal (non-local) repo.
|
||||||
|
#
|
||||||
|
# Run all checks manually: `uv run prek run --all-files`
|
||||||
|
# Install the git hooks: `uv run prek install`
|
||||||
|
|
||||||
|
default_install_hook_types = ["pre-commit", "pre-push"]
|
||||||
|
default_stages = ["pre-commit"]
|
||||||
|
|
||||||
|
[[repos]]
|
||||||
|
repo = "https://github.com/pre-commit/pre-commit-hooks"
|
||||||
|
rev = "v4.4.0"
|
||||||
|
|
||||||
|
[[repos.hooks]]
|
||||||
|
id = "check-case-conflict"
|
||||||
|
|
||||||
|
[[repos.hooks]]
|
||||||
|
id = "check-merge-conflict"
|
||||||
|
|
||||||
|
[[repos.hooks]]
|
||||||
|
id = "check-toml"
|
||||||
|
|
||||||
|
[[repos.hooks]]
|
||||||
|
id = "check-yaml"
|
||||||
|
|
||||||
|
[[repos.hooks]]
|
||||||
|
id = "debug-statements"
|
||||||
|
|
||||||
|
[[repos]]
|
||||||
|
repo = "local"
|
||||||
|
|
||||||
|
[[repos.hooks]]
|
||||||
|
id = "ruff-format"
|
||||||
|
name = "ruff format (check)"
|
||||||
|
language = "system"
|
||||||
|
entry = "uv run ruff format --check ."
|
||||||
|
types = ["python"]
|
||||||
|
pass_filenames = false
|
||||||
|
require_serial = true
|
||||||
|
|
||||||
|
[[repos.hooks]]
|
||||||
|
id = "ruff-check"
|
||||||
|
name = "ruff check"
|
||||||
|
language = "system"
|
||||||
|
entry = "uv run ruff check ."
|
||||||
|
types = ["python"]
|
||||||
|
pass_filenames = false
|
||||||
|
require_serial = true
|
||||||
|
|
||||||
|
[[repos.hooks]]
|
||||||
|
id = "mypy"
|
||||||
|
name = "mypy"
|
||||||
|
language = "system"
|
||||||
|
entry = "uv run mypy src/ocrmypdf"
|
||||||
|
types = ["python"]
|
||||||
|
pass_filenames = false
|
||||||
|
require_serial = true
|
||||||
+24
-6
@@ -6,17 +6,16 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ocrmypdf"
|
name = "ocrmypdf"
|
||||||
version = "17.3.0"
|
version = "17.8.1"
|
||||||
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
|
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MPL-2.0"
|
license = "MPL-2.0"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deprecation>=2.1.0",
|
|
||||||
"fpdf2>=2.8.0",
|
"fpdf2>=2.8.0",
|
||||||
"img2pdf>=0.5",
|
"img2pdf>=0.5",
|
||||||
"packaging>=20",
|
"packaging>=20",
|
||||||
"pdfminer.six>=20220319",
|
"pdfminer.six>=20260107", # fixes parsing of tokens split across the read buffer/streams (gh #1361)
|
||||||
"pi-heif", # Heif image format - maintainers: if this is removed, it will NOT break
|
"pi-heif", # Heif image format - maintainers: if this is removed, it will NOT break
|
||||||
"pikepdf>=10",
|
"pikepdf>=10",
|
||||||
"Pillow>=10.0.1",
|
"Pillow>=10.0.1",
|
||||||
@@ -24,6 +23,7 @@ dependencies = [
|
|||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pypdfium2>=5.0.0",
|
"pypdfium2>=5.0.0",
|
||||||
"rich>=13",
|
"rich>=13",
|
||||||
|
"typing-extensions>=4.12; python_version < '3.13'",
|
||||||
"uharfbuzz>=0.53.2",
|
"uharfbuzz>=0.53.2",
|
||||||
]
|
]
|
||||||
authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }]
|
authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }]
|
||||||
@@ -98,18 +98,28 @@ filterwarnings = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
|
check_untyped_defs = true
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
'pluggy',
|
|
||||||
'img2pdf',
|
'img2pdf',
|
||||||
'pdfminer.*',
|
'pdfminer.*',
|
||||||
'reportlab.*',
|
'reportlab.*',
|
||||||
'fitz',
|
'fitz',
|
||||||
'libxmp.utils',
|
'libxmp.utils',
|
||||||
|
'pypdfium2',
|
||||||
|
'uharfbuzz',
|
||||||
|
'pi_heif',
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
# Test functions are not required to annotate their return type (almost
|
||||||
|
# always None); it's a low-value hint that would just be noise here.
|
||||||
|
module = 'tests.*'
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
disallow_incomplete_defs = false
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
exclude = ["src/ocrmypdf/_version.py"] # Autogenerated
|
exclude = ["src/ocrmypdf/_version.py"] # Autogenerated
|
||||||
@@ -125,6 +135,7 @@ exclude = ["src/ocrmypdf/_version.py"] # Autogenerated
|
|||||||
"SIM", # simplify
|
"SIM", # simplify
|
||||||
"B", # flake8-bugbear
|
"B", # flake8-bugbear
|
||||||
"ICN", # flake8-import-conventions
|
"ICN", # flake8-import-conventions
|
||||||
|
"PTH", # flake8-use-pathlib
|
||||||
]
|
]
|
||||||
ignore = [
|
ignore = [
|
||||||
"B028", # warning with no explicit stacklevel
|
"B028", # warning with no explicit stacklevel
|
||||||
@@ -156,7 +167,15 @@ quote-style = "preserve"
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
# Developer-only tools - use `uv sync --group <name>`
|
# Developer-only tools - use `uv sync --group <name>`
|
||||||
dev = ["mypy>=1.13.0", "ipykernel>=6.29.5", "reportlab>=4.4.4"]
|
dev = [
|
||||||
|
"mypy>=1.13.0",
|
||||||
|
"ruff>=0.14.11",
|
||||||
|
"prek>=0.4.8",
|
||||||
|
"ipykernel>=6.29.5",
|
||||||
|
"reportlab>=4.4.4",
|
||||||
|
"cyclopts>=4.5.1",
|
||||||
|
"pygithub>=2.9.1",
|
||||||
|
]
|
||||||
test = [
|
test = [
|
||||||
# Core testing framework
|
# Core testing framework
|
||||||
"coverage[toml]>=6.2",
|
"coverage[toml]>=6.2",
|
||||||
@@ -168,7 +187,6 @@ test = [
|
|||||||
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
|
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
|
||||||
"reportlab>=3.6.8",
|
"reportlab>=3.6.8",
|
||||||
# Type stubs for testing
|
# Type stubs for testing
|
||||||
"types-Pillow",
|
|
||||||
"types-humanfriendly",
|
"types-humanfriendly",
|
||||||
# Extended test capabilities (merged from extended_test)
|
# Extended test capabilities (merged from extended_test)
|
||||||
"pymupdf>=1.24.14",
|
"pymupdf>=1.24.14",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ocrmypdf._version import __version__
|
|||||||
from ocrmypdf.api import (
|
from ocrmypdf.api import (
|
||||||
Verbosity,
|
Verbosity,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
|
configure_stdout_protection,
|
||||||
ocr,
|
ocr,
|
||||||
)
|
)
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
@@ -53,6 +54,7 @@ __all__ = [
|
|||||||
'BoundingBox',
|
'BoundingBox',
|
||||||
'configure_debug_logging',
|
'configure_debug_logging',
|
||||||
'configure_logging',
|
'configure_logging',
|
||||||
|
'configure_stdout_protection',
|
||||||
'DpiError',
|
'DpiError',
|
||||||
'EncryptedPdfError',
|
'EncryptedPdfError',
|
||||||
'Executor',
|
'Executor',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from contextlib import suppress
|
|||||||
from ocrmypdf import __version__
|
from ocrmypdf import __version__
|
||||||
from ocrmypdf._pipelines.ocr import run_pipeline_cli
|
from ocrmypdf._pipelines.ocr import run_pipeline_cli
|
||||||
from ocrmypdf._validation import check_options
|
from ocrmypdf._validation import check_options
|
||||||
from ocrmypdf.api import Verbosity, configure_logging
|
from ocrmypdf.api import Verbosity, configure_logging, configure_stdout_protection
|
||||||
from ocrmypdf.cli import get_options_and_plugins
|
from ocrmypdf.cli import get_options_and_plugins
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
BadArgsError,
|
BadArgsError,
|
||||||
@@ -39,12 +39,17 @@ def sigbus(*args):
|
|||||||
|
|
||||||
def run(args=None):
|
def run(args=None):
|
||||||
"""Run the ocrmypdf command line interface."""
|
"""Run the ocrmypdf command line interface."""
|
||||||
|
# Protect the real stdout before loading plugins or starting any worker
|
||||||
|
# processes/threads, so that only our final PDF output can reach it and
|
||||||
|
# stray writes from plugins or libraries are diverted to stderr.
|
||||||
|
configure_stdout_protection()
|
||||||
|
|
||||||
options, plugin_manager = get_options_and_plugins(args=args)
|
options, plugin_manager = get_options_and_plugins(args=args)
|
||||||
|
|
||||||
with suppress(AttributeError, PermissionError):
|
with suppress(AttributeError, PermissionError):
|
||||||
os.nice(5)
|
os.nice(5)
|
||||||
|
|
||||||
verbosity = options.verbose
|
verbosity = Verbosity(options.verbose)
|
||||||
if not os.isatty(sys.stderr.fileno()):
|
if not os.isatty(sys.stderr.fileno()):
|
||||||
options.progress_bar = False
|
options.progress_bar = False
|
||||||
if options.quiet:
|
if options.quiet:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
import threading
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar, cast
|
||||||
|
|
||||||
from ocrmypdf._progressbar import NullProgressBar, ProgressBar
|
from ocrmypdf._progressbar import NullProgressBar, ProgressBar
|
||||||
|
|
||||||
@@ -72,7 +72,10 @@ class Executor(ABC):
|
|||||||
if not task_finished:
|
if not task_finished:
|
||||||
task_finished = _task_finished_noop
|
task_finished = _task_finished_noop
|
||||||
if not task:
|
if not task:
|
||||||
task = _task_noop
|
# _task_noop always returns None, but T is unbound here (it's
|
||||||
|
# only meaningful when a real task is supplied); task_finished's
|
||||||
|
# own no-op default accepts Any, so this is safe.
|
||||||
|
task = cast('Callable[..., T]', _task_noop)
|
||||||
|
|
||||||
with self.pool_lock:
|
with self.pool_lock:
|
||||||
self._execute(
|
self._execute(
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
"""Probe helper for external executables.
|
||||||
|
|
||||||
|
Each ``ocrmypdf._exec.<tool>`` module describes its external program with a
|
||||||
|
module-level :class:`ToolProbe` and delegates ``version()`` / ``available()``
|
||||||
|
to it. This separates the "is the tool installed and suitable?" question
|
||||||
|
(probing) from the "run the tool" question (execution). Work functions stay
|
||||||
|
as pure module-level functions so they are trivially picklable for use in
|
||||||
|
subprocess workers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
|
from ocrmypdf.subprocess import get_version
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolProbe:
|
||||||
|
"""Describes how to detect an external executable and its version.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
program: The program name as it appears on PATH (or a full path).
|
||||||
|
version_arg: The argument that elicits a version string.
|
||||||
|
version_regex: A regex with a capturing group that extracts the
|
||||||
|
version from the program's output.
|
||||||
|
version_cls: A :class:`packaging.version.Version` subclass, used for
|
||||||
|
tools with non-standard version strings (e.g. Tesseract).
|
||||||
|
env: Optional environment overrides applied when probing the version.
|
||||||
|
also_catch: Additional exception types that should be treated as
|
||||||
|
"not available" by :meth:`available`. :class:`OSError` is useful
|
||||||
|
for tools like verapdf whose launcher may fail with non-standard
|
||||||
|
errors when the JVM is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
program: str
|
||||||
|
version_arg: str = '--version'
|
||||||
|
version_regex: str = r'(\d+(\.\d+)*)'
|
||||||
|
version_cls: type[Version] = Version
|
||||||
|
env: Mapping[str, str] | None = None
|
||||||
|
also_catch: tuple[type[BaseException], ...] = ()
|
||||||
|
|
||||||
|
def version(self) -> Version:
|
||||||
|
"""Return the installed version of the program.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MissingDependencyError: if the program cannot be found or its
|
||||||
|
version string cannot be parsed.
|
||||||
|
"""
|
||||||
|
raw = get_version(
|
||||||
|
self.program,
|
||||||
|
version_arg=self.version_arg,
|
||||||
|
regex=self.version_regex,
|
||||||
|
env=self.env,
|
||||||
|
)
|
||||||
|
return self.version_cls(raw)
|
||||||
|
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return whether a usable version of the program is installed."""
|
||||||
|
try:
|
||||||
|
self.version()
|
||||||
|
except MissingDependencyError:
|
||||||
|
return False
|
||||||
|
except self.also_catch:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -16,6 +16,7 @@ from subprocess import PIPE, CalledProcessError
|
|||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
from ocrmypdf._exec._probe import ToolProbe
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
ColorConversionNeededError,
|
ColorConversionNeededError,
|
||||||
InputFileError,
|
InputFileError,
|
||||||
@@ -23,7 +24,7 @@ from ocrmypdf.exceptions import (
|
|||||||
)
|
)
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
||||||
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
from ocrmypdf.subprocess import run, run_polling_stderr
|
||||||
|
|
||||||
COLOR_CONVERSION_STRATEGIES = frozenset(
|
COLOR_CONVERSION_STRATEGIES = frozenset(
|
||||||
[
|
[
|
||||||
@@ -69,11 +70,19 @@ class DuplicateFilter(logging.Filter):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
log.addFilter(DuplicateFilter(log))
|
PROBE = ToolProbe(program=GS)
|
||||||
|
version = PROBE.version
|
||||||
|
available = PROBE.available
|
||||||
|
|
||||||
|
|
||||||
def version() -> Version:
|
def _ensure_log_filter_installed() -> None:
|
||||||
return Version(get_version(GS))
|
"""Idempotently attach the duplicate-suppressing filter to the GS logger.
|
||||||
|
|
||||||
|
Called at the top of each work function so the filter is present in the
|
||||||
|
main process *and* in any subprocess worker that calls Ghostscript.
|
||||||
|
"""
|
||||||
|
if not any(isinstance(f, DuplicateFilter) for f in log.filters):
|
||||||
|
log.addFilter(DuplicateFilter(log))
|
||||||
|
|
||||||
|
|
||||||
def _gs_error_reported(stream) -> bool:
|
def _gs_error_reported(stream) -> bool:
|
||||||
@@ -96,8 +105,8 @@ def _gs_devicen_reported(stream) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def rasterize_pdf(
|
def rasterize_pdf(
|
||||||
input_file: os.PathLike,
|
input_file: Path,
|
||||||
output_file: os.PathLike,
|
output_file: Path,
|
||||||
*,
|
*,
|
||||||
raster_device: GhostscriptRasterDevice,
|
raster_device: GhostscriptRasterDevice,
|
||||||
raster_dpi: Resolution,
|
raster_dpi: Resolution,
|
||||||
@@ -123,6 +132,7 @@ def rasterize_pdf(
|
|||||||
use_cropbox: If True, rasterize the CropBox instead of MediaBox.
|
use_cropbox: If True, rasterize the CropBox instead of MediaBox.
|
||||||
Default is False (use MediaBox).
|
Default is False (use MediaBox).
|
||||||
"""
|
"""
|
||||||
|
_ensure_log_filter_installed()
|
||||||
raster_dpi = raster_dpi.round(6)
|
raster_dpi = raster_dpi.round(6)
|
||||||
if not page_dpi:
|
if not page_dpi:
|
||||||
page_dpi = raster_dpi
|
page_dpi = raster_dpi
|
||||||
@@ -140,6 +150,19 @@ def rasterize_pdf(
|
|||||||
else:
|
else:
|
||||||
effective_dpi = raster_dpi
|
effective_dpi = raster_dpi
|
||||||
|
|
||||||
|
# Anti-alias text and vector graphics when rendering to a contone device.
|
||||||
|
# Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as
|
||||||
|
# extra word breaks; anti-aliasing empirically improves OCR accuracy on the
|
||||||
|
# Ghostscript path, especially for small fonts at moderate DPI (#1439).
|
||||||
|
# The 1-bit mono devices do not accept alpha bits (older Ghostscript
|
||||||
|
# rejects them) and pngmonod performs its own anti-aliased downscaling.
|
||||||
|
mono_devices = (GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD)
|
||||||
|
antialias_args = (
|
||||||
|
[]
|
||||||
|
if raster_device in mono_devices
|
||||||
|
else ['-dTextAlphaBits=4', '-dGraphicsAlphaBits=4']
|
||||||
|
)
|
||||||
|
|
||||||
args_gs = (
|
args_gs = (
|
||||||
[
|
[
|
||||||
GS,
|
GS,
|
||||||
@@ -152,6 +175,7 @@ def rasterize_pdf(
|
|||||||
f'-dLastPage={pageno}',
|
f'-dLastPage={pageno}',
|
||||||
f'-r{effective_dpi.x:f}x{effective_dpi.y:f}',
|
f'-r{effective_dpi.x:f}x{effective_dpi.y:f}',
|
||||||
]
|
]
|
||||||
|
+ antialias_args
|
||||||
+ (['-dUseCropBox'] if use_cropbox else [])
|
+ (['-dUseCropBox'] if use_cropbox else [])
|
||||||
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
||||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||||
@@ -185,6 +209,7 @@ def rasterize_pdf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
im: Image.Image
|
||||||
with Image.open(output_file) as im:
|
with Image.open(output_file) as im:
|
||||||
if needs_low_dpi_resize:
|
if needs_low_dpi_resize:
|
||||||
# Resize to the dimensions that would have resulted from the
|
# Resize to the dimensions that would have resulted from the
|
||||||
@@ -264,15 +289,18 @@ class GhostscriptFollower:
|
|||||||
|
|
||||||
def generate_pdfa(
|
def generate_pdfa(
|
||||||
pdf_pages,
|
pdf_pages,
|
||||||
output_file: os.PathLike,
|
output_file: Path,
|
||||||
*,
|
*,
|
||||||
compression: str,
|
compression: str,
|
||||||
color_conversion_strategy: str,
|
color_conversion_strategy: str,
|
||||||
|
jpeg_quality: int | None = None,
|
||||||
|
jpeg_maxdpi: int | None = None,
|
||||||
pdf_version: str = '1.5',
|
pdf_version: str = '1.5',
|
||||||
pdfa_part: str = '2',
|
pdfa_part: str = '2',
|
||||||
progressbar_class=None,
|
progressbar_class=None,
|
||||||
stop_on_error: bool = False,
|
stop_on_error: bool = False,
|
||||||
):
|
):
|
||||||
|
_ensure_log_filter_installed()
|
||||||
# Ghostscript's compression is all or nothing. We can either force all images
|
# Ghostscript's compression is all or nothing. We can either force all images
|
||||||
# to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
|
# to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
|
||||||
# In most case it's best to let it decide.
|
# In most case it's best to let it decide.
|
||||||
@@ -286,6 +314,11 @@ def generate_pdfa(
|
|||||||
]
|
]
|
||||||
elif compression == 'lossless':
|
elif compression == 'lossless':
|
||||||
compression_args = [
|
compression_args = [
|
||||||
|
# Re-encoding an existing JPEG with a lossless codec only inflates
|
||||||
|
# its size: the lossy data is already baked in, so there is nothing
|
||||||
|
# to gain. Pass JPEGs through untouched and apply lossless (Flate)
|
||||||
|
# encoding only to images that are not already JPEG.
|
||||||
|
"-dPassThroughJPEGImages=true",
|
||||||
"-dAutoFilterColorImages=false",
|
"-dAutoFilterColorImages=false",
|
||||||
"-dColorImageFilter=/FlateEncode",
|
"-dColorImageFilter=/FlateEncode",
|
||||||
"-dAutoFilterGrayImages=false",
|
"-dAutoFilterGrayImages=false",
|
||||||
@@ -307,6 +340,35 @@ def generate_pdfa(
|
|||||||
# Windows has lots of fatal "permission denied" errors
|
# Windows has lots of fatal "permission denied" errors
|
||||||
stop_on_error = False
|
stop_on_error = False
|
||||||
|
|
||||||
|
# `-dJPEGQ=N` tells Ghostscript to use a JPEG quality of N, IF it decides
|
||||||
|
# to transcode an image to JPEG. When there are existing JPEG images,
|
||||||
|
# Ghostscript uses passthrough mode, so the quality level is not changed.
|
||||||
|
# OCRmyPDF's optimizer separately uses the `--jpeg-quality` command line
|
||||||
|
# option to potentially re-encode JPEG images, regardless of whether
|
||||||
|
# Ghostscript decided to transcode them to JPEG or not.
|
||||||
|
# `jpeg_quality=0` is meaningful to Ghostscript (maximum compression), so
|
||||||
|
# only fall back to the default when the value is None.
|
||||||
|
effective_jpeg_quality = jpeg_quality if jpeg_quality is not None else 95
|
||||||
|
|
||||||
|
# Downsampling images is a blunt-force way to reduce file size and almost
|
||||||
|
# always degrades quality more than lowering JPEG quality at the original
|
||||||
|
# resolution. We expose this for users with very specific needs (e.g.
|
||||||
|
# producing very small files for screen-only viewing); the optimizer is
|
||||||
|
# usually a better choice.
|
||||||
|
downsample_args: list[str] = []
|
||||||
|
if jpeg_maxdpi is not None:
|
||||||
|
downsample_args = [
|
||||||
|
"-dDownsampleColorImages=true",
|
||||||
|
"-dColorImageDownsampleThreshold=1.0",
|
||||||
|
"-dDownsampleGrayImages=true",
|
||||||
|
"-dGrayImageDownsampleThreshold=1.0",
|
||||||
|
"-dDownsampleMonoImages=true",
|
||||||
|
"-dMonoImageDownsampleThreshold=1.0",
|
||||||
|
f"-dColorImageResolution={jpeg_maxdpi}",
|
||||||
|
f"-dGrayImageResolution={jpeg_maxdpi}",
|
||||||
|
f"-dMonoImageResolution={jpeg_maxdpi}",
|
||||||
|
]
|
||||||
|
|
||||||
# nb no need to specify ProcessColorModel when ColorConversionStrategy
|
# nb no need to specify ProcessColorModel when ColorConversionStrategy
|
||||||
# is set; see:
|
# is set; see:
|
||||||
# https://bugs.ghostscript.com/show_bug.cgi?id=699392
|
# https://bugs.ghostscript.com/show_bug.cgi?id=699392
|
||||||
@@ -323,8 +385,9 @@ def generate_pdfa(
|
|||||||
]
|
]
|
||||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||||
+ compression_args
|
+ compression_args
|
||||||
|
+ downsample_args
|
||||||
+ [
|
+ [
|
||||||
"-dJPEGQ=95",
|
f"-dJPEGQ={effective_jpeg_quality}", # See note above on JPEG quality
|
||||||
"-dSubsetFonts=false", # Prevents GS from messing up some encodings
|
"-dSubsetFonts=false", # Prevents GS from messing up some encodings
|
||||||
f"-dPDFA={pdfa_part}",
|
f"-dPDFA={pdfa_part}",
|
||||||
"-dPDFACompatibilityPolicy=1",
|
"-dPDFACompatibilityPolicy=1",
|
||||||
@@ -362,4 +425,8 @@ def generate_pdfa(
|
|||||||
for part in stderr.split('****'):
|
for part in stderr.split('****'):
|
||||||
log.error(part)
|
log.error(part)
|
||||||
if _gs_devicen_reported(stderr):
|
if _gs_devicen_reported(stderr):
|
||||||
raise ColorConversionNeededError()
|
# Ghostscript could not normalize the DeviceN colorspace for PDF/A,
|
||||||
|
# even if the user requested a conversion strategy. The output is
|
||||||
|
# liable to render blank in some viewers, so raise regardless of the
|
||||||
|
# strategy and tailor the guidance to what was attempted.
|
||||||
|
raise ColorConversionNeededError(color_conversion_strategy)
|
||||||
|
|||||||
@@ -9,21 +9,23 @@ from subprocess import PIPE, CalledProcessError
|
|||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
|
|
||||||
|
from ocrmypdf._exec._probe import ToolProbe
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
from ocrmypdf.subprocess import get_version, run
|
from ocrmypdf.subprocess import run
|
||||||
|
|
||||||
|
_PROBE = ToolProbe(program='jbig2', version_regex=r'jbig2enc (\d+(\.\d+)*).*')
|
||||||
|
|
||||||
|
|
||||||
def version() -> Version:
|
def version() -> Version:
|
||||||
try:
|
try:
|
||||||
version = get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*')
|
return _PROBE.version()
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
# TeX Live for Windows provides an incompatible jbig2.EXE which may
|
# TeX Live for Windows provides an incompatible jbig2.EXE which may
|
||||||
# be on the PATH.
|
# be on the PATH.
|
||||||
raise MissingDependencyError('jbig2enc') from e
|
raise MissingDependencyError('jbig2enc') from e
|
||||||
return Version(version)
|
|
||||||
|
|
||||||
|
|
||||||
def available():
|
def available() -> bool:
|
||||||
try:
|
try:
|
||||||
version()
|
version()
|
||||||
except MissingDependencyError:
|
except MissingDependencyError:
|
||||||
@@ -33,7 +35,7 @@ def available():
|
|||||||
|
|
||||||
def convert_single(cwd, infile, outfile, threshold):
|
def convert_single(cwd, infile, outfile, threshold):
|
||||||
args = ['jbig2', '--pdf', '-t', str(threshold), infile]
|
args = ['jbig2', '--pdf', '-t', str(threshold), infile]
|
||||||
with open(outfile, 'wb') as fstdout:
|
with outfile.open('wb') as fstdout:
|
||||||
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
|
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
|
||||||
proc.check_returncode()
|
proc.check_returncode()
|
||||||
return proc
|
return proc
|
||||||
|
|||||||
@@ -8,22 +8,12 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE
|
from subprocess import PIPE
|
||||||
|
|
||||||
from packaging.version import Version
|
from ocrmypdf._exec._probe import ToolProbe
|
||||||
|
from ocrmypdf.subprocess import run
|
||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
PROBE = ToolProbe(program='pngquant', version_regex=r'(\d+(\.\d+)*).*')
|
||||||
from ocrmypdf.subprocess import get_version, run
|
version = PROBE.version
|
||||||
|
available = PROBE.available
|
||||||
|
|
||||||
def version() -> Version:
|
|
||||||
return Version(get_version('pngquant', regex=r'(\d+(\.\d+)*).*'))
|
|
||||||
|
|
||||||
|
|
||||||
def available():
|
|
||||||
try:
|
|
||||||
version()
|
|
||||||
except MissingDependencyError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max: int):
|
def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max: int):
|
||||||
@@ -35,7 +25,7 @@ def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max:
|
|||||||
quality_min: Minimum quality to use
|
quality_min: Minimum quality to use
|
||||||
quality_max: Maximum quality to use
|
quality_max: Maximum quality to use
|
||||||
"""
|
"""
|
||||||
with open(input_file, 'rb') as input_stream:
|
with input_file.open('rb') as input_stream:
|
||||||
args = [
|
args = [
|
||||||
'pngquant',
|
'pngquant',
|
||||||
'--force',
|
'--force',
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired
|
|||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
|
|
||||||
|
from ocrmypdf._exec._probe import ToolProbe
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
MissingDependencyError,
|
MissingDependencyError,
|
||||||
SubprocessOutputError,
|
SubprocessOutputError,
|
||||||
TesseractConfigError,
|
TesseractConfigError,
|
||||||
)
|
)
|
||||||
from ocrmypdf.pluginspec import OrientationConfidence
|
from ocrmypdf.pluginspec import OrientationConfidence
|
||||||
from ocrmypdf.subprocess import get_version, run
|
from ocrmypdf.subprocess import run
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -115,8 +116,13 @@ class TesseractVersion(Version):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def version() -> Version:
|
PROBE = ToolProbe(
|
||||||
return TesseractVersion(get_version('tesseract', regex=r'tesseract\s(.+)'))
|
program='tesseract',
|
||||||
|
version_regex=r'tesseract\s(.+)',
|
||||||
|
version_cls=TesseractVersion,
|
||||||
|
)
|
||||||
|
version = PROBE.version
|
||||||
|
available = PROBE.available
|
||||||
|
|
||||||
|
|
||||||
def has_thresholding() -> bool:
|
def has_thresholding() -> bool:
|
||||||
@@ -287,12 +293,14 @@ def tesseract_log_output(stream: bytes) -> None:
|
|||||||
|
|
||||||
lines = text.splitlines()
|
lines = text.splitlines()
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if line.startswith(
|
if line.startswith(("Tesseract Open Source", "Warning in pixReadMem")):
|
||||||
("Tesseract Open Source", "Warning in pixReadMem")
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
elif 'diacritics' in line:
|
elif 'diacritics' in line:
|
||||||
tlog.warning("lots of diacritics - possibly poor OCR")
|
tlog.warning("lots of diacritics - possibly poor OCR")
|
||||||
|
# Surface the raw Tesseract message at debug level so users can see
|
||||||
|
# exactly what Tesseract reported (e.g. the affected count) without
|
||||||
|
# losing the interpreted hint above (#1566).
|
||||||
|
tlog.debug(line.strip())
|
||||||
elif line.startswith('OSD: Weak margin'):
|
elif line.startswith('OSD: Weak margin'):
|
||||||
tlog.warning("unsure about page orientation")
|
tlog.warning("unsure about page orientation")
|
||||||
elif 'Error in pixScanForForeground' in line:
|
elif 'Error in pixScanForForeground' in line:
|
||||||
@@ -309,6 +317,23 @@ def tesseract_log_output(stream: bytes) -> None:
|
|||||||
tlog.warning(line.strip())
|
tlog.warning(line.strip())
|
||||||
elif 'read_params_file' in line.lower():
|
elif 'read_params_file' in line.lower():
|
||||||
tlog.error(line.strip())
|
tlog.error(line.strip())
|
||||||
|
# Tesseract emits "read_params_file: Can't open <name>" when it
|
||||||
|
# cannot locate a config file (e.g. 'hocr', 'txt') in its
|
||||||
|
# tessdata configs/ directory, then exits 0 without producing
|
||||||
|
# the requested output. Promote to a hard error so the user
|
||||||
|
# sees the root cause instead of a downstream FileNotFoundError.
|
||||||
|
if "Can't open" in line:
|
||||||
|
missing = line.split("Can't open", 1)[1].strip()
|
||||||
|
else:
|
||||||
|
missing = line.strip()
|
||||||
|
raise TesseractConfigError(
|
||||||
|
f"Tesseract cannot open its config file '{missing}'. "
|
||||||
|
"This usually means Tesseract is installed but its config "
|
||||||
|
"files are missing from the tessdata configs/ directory. "
|
||||||
|
"On Debian/Ubuntu, ensure the 'tesseract-ocr' package is "
|
||||||
|
"fully installed. If you set TESSDATA_PREFIX, verify its "
|
||||||
|
"configs/ subdirectory contains the required files."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
tlog.info(line.strip())
|
tlog.info(line.strip())
|
||||||
|
|
||||||
@@ -389,6 +414,12 @@ def generate_hocr(
|
|||||||
raise SubprocessOutputError() from e
|
raise SubprocessOutputError() from e
|
||||||
else:
|
else:
|
||||||
tesseract_log_output(stdout)
|
tesseract_log_output(stdout)
|
||||||
|
if not output_hocr.exists():
|
||||||
|
raise SubprocessOutputError(
|
||||||
|
"Tesseract exited successfully but did not produce the "
|
||||||
|
f"expected hOCR output at {output_hocr}. Tesseract output:\n"
|
||||||
|
+ (stdout.decode(errors='replace') if stdout else '(empty)')
|
||||||
|
)
|
||||||
# The sidecar text file will get the suffix .txt; rename it to
|
# The sidecar text file will get the suffix .txt; rename it to
|
||||||
# whatever caller wants it named
|
# whatever caller wants it named
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
@@ -457,6 +488,12 @@ def generate_pdf(
|
|||||||
stdout = p.stdout
|
stdout = p.stdout
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
prefix.with_suffix('.txt').replace(output_text)
|
prefix.with_suffix('.txt').replace(output_text)
|
||||||
|
if not output_pdf.exists():
|
||||||
|
raise SubprocessOutputError(
|
||||||
|
"Tesseract exited successfully but did not produce the "
|
||||||
|
f"expected PDF output at {output_pdf}. Tesseract output:\n"
|
||||||
|
+ (stdout.decode(errors='replace') if stdout else '(empty)')
|
||||||
|
)
|
||||||
except TimeoutExpired:
|
except TimeoutExpired:
|
||||||
page_timedout(timeout)
|
page_timedout(timeout)
|
||||||
use_skip_page(output_pdf, output_text)
|
use_skip_page(output_pdf, output_text)
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ from pathlib import Path
|
|||||||
from subprocess import PIPE, STDOUT
|
from subprocess import PIPE, STDOUT
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from packaging.version import Version
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from ocrmypdf._exec._probe import ToolProbe
|
||||||
from ocrmypdf.exceptions import SubprocessOutputError
|
from ocrmypdf.exceptions import SubprocessOutputError
|
||||||
from ocrmypdf.subprocess import get_version, run
|
from ocrmypdf.subprocess import run
|
||||||
|
|
||||||
# unpaper documentation:
|
# unpaper documentation:
|
||||||
# https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md
|
# https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md
|
||||||
@@ -46,8 +46,9 @@ class UnpaperImageTooLargeError(Exception):
|
|||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
def version() -> Version:
|
PROBE = ToolProbe(program='unpaper', version_regex=r'(?m).*?(\d+(\.\d+)(\.\d+)?)')
|
||||||
return Version(get_version('unpaper', regex=r'(?m).*?(\d+(\.\d+)(\.\d+)?)'))
|
version = PROBE.version
|
||||||
|
available = PROBE.available
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ from pathlib import Path
|
|||||||
from subprocess import PIPE
|
from subprocess import PIPE
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from packaging.version import Version
|
from ocrmypdf._exec._probe import ToolProbe
|
||||||
|
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
from ocrmypdf.subprocess import get_version, run
|
from ocrmypdf.subprocess import run
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,18 +26,13 @@ class ValidationResult(NamedTuple):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
def version() -> Version:
|
PROBE = ToolProbe(
|
||||||
"""Get verapdf version."""
|
program='verapdf',
|
||||||
return Version(get_version('verapdf', regex=r'veraPDF (\d+(\.\d+)*)'))
|
version_regex=r'veraPDF (\d+(\.\d+)*)',
|
||||||
|
also_catch=(OSError,),
|
||||||
|
)
|
||||||
def available() -> bool:
|
version = PROBE.version
|
||||||
"""Check if verapdf is available."""
|
available = PROBE.available
|
||||||
try:
|
|
||||||
version()
|
|
||||||
except MissingDependencyError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def output_type_to_flavour(output_type: str) -> str:
|
def output_type_to_flavour(output_type: str) -> str:
|
||||||
|
|||||||
+119
-8
@@ -6,11 +6,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Collection
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ocrmypdf.hocrtransform import OcrElement
|
from ocrmypdf.hocrtransform import OcrElement
|
||||||
@@ -18,6 +19,7 @@ if TYPE_CHECKING:
|
|||||||
from pikepdf import (
|
from pikepdf import (
|
||||||
Dictionary,
|
Dictionary,
|
||||||
Name,
|
Name,
|
||||||
|
Object,
|
||||||
Operator,
|
Operator,
|
||||||
Page,
|
Page,
|
||||||
Pdf,
|
Pdf,
|
||||||
@@ -181,9 +183,13 @@ def strip_invisible_text(pdf: Pdf, page: Page):
|
|||||||
render_mode_stack = []
|
render_mode_stack = []
|
||||||
text_objects = []
|
text_objects = []
|
||||||
|
|
||||||
for operands, operator in parse_content_stream(page, ''):
|
for instruction in parse_content_stream(page, ''):
|
||||||
|
operands, operator = instruction.operands, instruction.operator
|
||||||
if operator == Operator('Tr'):
|
if operator == Operator('Tr'):
|
||||||
render_mode = operands[0]
|
# operands[0] is already a plain int under pikepdf's default
|
||||||
|
# (implicit) conversion mode, or a pikepdf.Object under explicit
|
||||||
|
# conversion mode; int() handles both.
|
||||||
|
render_mode = int(operands[0])
|
||||||
|
|
||||||
if operator == Operator('q'):
|
if operator == Operator('q'):
|
||||||
render_mode_stack.append(render_mode)
|
render_mode_stack.append(render_mode)
|
||||||
@@ -207,10 +213,103 @@ def strip_invisible_text(pdf: Pdf, page: Page):
|
|||||||
stream.extend(text_objects)
|
stream.extend(text_objects)
|
||||||
text_objects.clear()
|
text_objects.clear()
|
||||||
|
|
||||||
content_stream = unparse_content_stream(stream)
|
# pikepdf's Collection[...] parameter doesn't structurally match our
|
||||||
|
# _ObjectList-based tuples even though it works fine at runtime.
|
||||||
|
content_stream = unparse_content_stream(
|
||||||
|
cast('list[tuple[Collection[Object], Operator]]', stream)
|
||||||
|
)
|
||||||
page.Contents = Stream(pdf, content_stream)
|
page.Contents = Stream(pdf, content_stream)
|
||||||
|
|
||||||
|
|
||||||
|
def discard_text_search_index(pdf: Pdf) -> bool:
|
||||||
|
"""Discard an embedded Adobe full-text search index from the catalog.
|
||||||
|
|
||||||
|
Adobe Acrobat can embed a full-text search index in the document catalog at
|
||||||
|
``/Root/PieceInfo/SearchIndex``. It is built from the page text, and only
|
||||||
|
Acrobat reads it; other viewers ignore it and search the text on the fly.
|
||||||
|
Any change to the PDF invalidates the index, so once OCRmyPDF rewrites the
|
||||||
|
document (editing the text layer, rasterizing, optimizing) a retained index
|
||||||
|
would be stale and return incorrect search results in Acrobat. We cannot
|
||||||
|
update this vendor-private data, so we discard it; modern viewers rebuild a
|
||||||
|
search index on demand. Returns True if the catalog was modified.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pieceinfo = pdf.Root.get(Name.PieceInfo)
|
||||||
|
if not isinstance(pieceinfo, Dictionary) or Name.SearchIndex not in pieceinfo:
|
||||||
|
return False
|
||||||
|
del pieceinfo[Name.SearchIndex]
|
||||||
|
log.debug(
|
||||||
|
"Discarded embedded text search index "
|
||||||
|
"(/Root/PieceInfo/SearchIndex) because the PDF was rewritten; "
|
||||||
|
"it would otherwise be stale."
|
||||||
|
)
|
||||||
|
# Drop an empty PieceInfo rather than leave a husk behind.
|
||||||
|
if len(pieceinfo) == 0:
|
||||||
|
del pdf.Root.PieceInfo
|
||||||
|
return True
|
||||||
|
except (KeyError, TypeError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def discard_page_thumbnails(pdf: Pdf) -> int:
|
||||||
|
"""Discard embedded per-page thumbnail images.
|
||||||
|
|
||||||
|
A page object may carry an optional ``/Thumb`` image XObject — a miniature
|
||||||
|
rendering of the page (ISO 32000-2, 12.3.4). It is only a navigation aid and
|
||||||
|
modern viewers generate page thumbnails on demand. OCRmyPDF alters page
|
||||||
|
appearance (deskew, clean, rasterize, re-render) and plugins may edit pages
|
||||||
|
arbitrarily, so any retained thumbnail would be stale and misrepresent its
|
||||||
|
page. We discard them; viewers rebuild thumbnails as needed. Returns the
|
||||||
|
number of thumbnails removed.
|
||||||
|
"""
|
||||||
|
removed = 0
|
||||||
|
for page in pdf.pages:
|
||||||
|
pageobj = page.obj
|
||||||
|
if Name.Thumb in pageobj:
|
||||||
|
del pageobj[Name.Thumb]
|
||||||
|
removed += 1
|
||||||
|
if removed:
|
||||||
|
log.debug(
|
||||||
|
"Discarded %d embedded page thumbnail(s) (/Thumb) because the PDF "
|
||||||
|
"was rewritten; they would otherwise be stale.",
|
||||||
|
removed,
|
||||||
|
)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def discard_structure_tree(pdf: Pdf) -> bool:
|
||||||
|
"""Discard the logical structure (tagged-PDF) tree from the document.
|
||||||
|
|
||||||
|
The structure tree (``/Root/StructTreeRoot``, ``/Root/MarkInfo``) maps
|
||||||
|
marked content in the page content streams to semantic elements via MCIDs.
|
||||||
|
When OCRmyPDF rasterizes pages (force) or strips and rewrites the text layer
|
||||||
|
(redo), those MCIDs are destroyed or renumbered, leaving the tree dangling
|
||||||
|
and inconsistent with the new content. We cannot rebuild it to match, so we
|
||||||
|
discard it; the page-level ``/StructParents`` keys go too. Returns True if
|
||||||
|
the catalog was modified.
|
||||||
|
"""
|
||||||
|
modified = False
|
||||||
|
try:
|
||||||
|
if Name.StructTreeRoot in pdf.Root:
|
||||||
|
del pdf.Root.StructTreeRoot
|
||||||
|
modified = True
|
||||||
|
if Name.MarkInfo in pdf.Root:
|
||||||
|
del pdf.Root.MarkInfo
|
||||||
|
modified = True
|
||||||
|
for page in pdf.pages:
|
||||||
|
if Name.StructParents in page.obj:
|
||||||
|
del page.obj[Name.StructParents]
|
||||||
|
modified = True
|
||||||
|
except (KeyError, TypeError, AttributeError):
|
||||||
|
return modified
|
||||||
|
if modified:
|
||||||
|
log.debug(
|
||||||
|
"Discarded the logical structure tree (/Root/StructTreeRoot) "
|
||||||
|
"because the PDF was re-OCR'd; it would otherwise be stale."
|
||||||
|
)
|
||||||
|
return modified
|
||||||
|
|
||||||
|
|
||||||
class OcrGrafter:
|
class OcrGrafter:
|
||||||
"""Manages grafting text-only PDFs onto regular PDFs."""
|
"""Manages grafting text-only PDFs onto regular PDFs."""
|
||||||
|
|
||||||
@@ -253,6 +352,14 @@ class OcrGrafter:
|
|||||||
ocr_tree: OCR tree for fpdf2 renderer.
|
ocr_tree: OCR tree for fpdf2 renderer.
|
||||||
autorotate_correction: Orientation correction in degrees (0, 90, 180, 270).
|
autorotate_correction: Orientation correction in degrees (0, 90, 180, 270).
|
||||||
"""
|
"""
|
||||||
|
if self.context.options.mode == ProcessingMode.strip_text:
|
||||||
|
# Strip mode: remove the invisible OCR text layer in place without
|
||||||
|
# rasterizing or grafting anything. Honor --pages if specified.
|
||||||
|
options = self.context.options
|
||||||
|
if not options.pages or pageno in options.pages:
|
||||||
|
strip_invisible_text(self.pdf_base, self.pdf_base.pages[pageno])
|
||||||
|
return
|
||||||
|
|
||||||
if ocr_output and ocr_tree:
|
if ocr_output and ocr_tree:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'Cannot specify both ocr_output and ocr_tree for fpdf2 renderer'
|
'Cannot specify both ocr_output and ocr_tree for fpdf2 renderer'
|
||||||
@@ -319,9 +426,9 @@ class OcrGrafter:
|
|||||||
|
|
||||||
def finalize(self):
|
def finalize(self):
|
||||||
# Can have hocr OR parsed pages OR neither (no OCR), but not both
|
# Can have hocr OR parsed pages OR neither (no OCR), but not both
|
||||||
assert not (
|
assert not (self.fpdf2_hocr_pages and self.fpdf2_parsed_pages), (
|
||||||
self.fpdf2_hocr_pages and self.fpdf2_parsed_pages
|
"Can't have both hocr and ocrtree pages"
|
||||||
), "Can't have both hocr and ocrtree pages"
|
)
|
||||||
|
|
||||||
if self.fpdf2_hocr_pages:
|
if self.fpdf2_hocr_pages:
|
||||||
# Render all pages with fpdf2, then graft
|
# Render all pages with fpdf2, then graft
|
||||||
@@ -331,11 +438,15 @@ class OcrGrafter:
|
|||||||
if self.fpdf2_parsed_pages:
|
if self.fpdf2_parsed_pages:
|
||||||
self._render_and_graft_fpdf2_pages()
|
self._render_and_graft_fpdf2_pages()
|
||||||
|
|
||||||
|
discard_text_search_index(self.pdf_base)
|
||||||
|
discard_page_thumbnails(self.pdf_base)
|
||||||
|
if self.context.options.mode in (ProcessingMode.force, ProcessingMode.redo):
|
||||||
|
discard_structure_tree(self.pdf_base)
|
||||||
self.pdf_base.save(self.output_file)
|
self.pdf_base.save(self.output_file)
|
||||||
self.pdf_base.close()
|
self.pdf_base.close()
|
||||||
return self.output_file
|
return self.output_file
|
||||||
|
|
||||||
def _parse_hocr_pages(self):
|
def _parse_hocr_pages(self) -> list[Fpdf2ParsedPage]:
|
||||||
"""Render all pages to multi-page PDF with shared fonts, then graft."""
|
"""Render all pages to multi-page PDF with shared fonts, then graft."""
|
||||||
from ocrmypdf.hocrtransform.hocr_parser import HocrParser
|
from ocrmypdf.hocrtransform.hocr_parser import HocrParser
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class PdfContext:
|
|||||||
plugin_manager,
|
plugin_manager,
|
||||||
):
|
):
|
||||||
self.options = options
|
self.options = options
|
||||||
|
self.options.work_folder = work_folder
|
||||||
self.work_folder = work_folder
|
self.work_folder = work_folder
|
||||||
self.origin = origin
|
self.origin = origin
|
||||||
self.pdfinfo = pdfinfo
|
self.pdfinfo = pdfinfo
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -88,8 +87,12 @@ def repair_docinfo_nuls(pdf):
|
|||||||
if isinstance(v, str) and b'\x00' in bytes(v):
|
if isinstance(v, str) and b'\x00' in bytes(v):
|
||||||
pdf.docinfo[k] = bytes(v).replace(b'\x00', b'')
|
pdf.docinfo[k] = bytes(v).replace(b'\x00', b'')
|
||||||
modified = True
|
modified = True
|
||||||
except TypeError:
|
except (TypeError, UnicodeDecodeError):
|
||||||
# TypeError can also be raised if dictionary items are unexpected types
|
# TypeError: DocumentInfo is not a dictionary, or its items are
|
||||||
|
# unexpected types.
|
||||||
|
# UnicodeDecodeError: a DocumentInfo key or value contains bytes that
|
||||||
|
# are not valid PDFDocEncoding/UTF-16, e.g. a Latin-1 /Name key such as
|
||||||
|
# /Saks#e5r. Older pikepdf raised while iterating such a block (#1540).
|
||||||
log.error("File contains a malformed DocumentInfo block - continuing anyway.")
|
log.error("File contains a malformed DocumentInfo block - continuing anyway.")
|
||||||
return modified
|
return modified
|
||||||
|
|
||||||
@@ -99,7 +102,7 @@ def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
|||||||
|
|
||||||
For smaller files, linearization is not worth the effort.
|
For smaller files, linearization is not worth the effort.
|
||||||
"""
|
"""
|
||||||
filesize = os.stat(working_file).st_size
|
filesize = working_file.stat().st_size
|
||||||
return filesize > (context.options.fast_web_view * 1_000_000)
|
return filesize > (context.options.fast_web_view * 1_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+62
-11
@@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Module-level registry for plugin option models
|
# Module-level registry for plugin option models
|
||||||
# This is populated by setup_plugin_infrastructure() after plugins are loaded
|
# This is populated by setup_plugin_infrastructure() after plugins are loaded
|
||||||
_plugin_option_models: dict[str, type] = {}
|
_plugin_option_models: dict[str, type[BaseModel]] = {}
|
||||||
|
|
||||||
PathOrIO = BinaryIO | IOBase | Path | str | bytes
|
PathOrIO = BinaryIO | IOBase | Path | str | bytes
|
||||||
|
|
||||||
@@ -43,12 +43,16 @@ class ProcessingMode(StrEnum):
|
|||||||
- ``force``: Rasterize all content and run OCR regardless of existing text
|
- ``force``: Rasterize all content and run OCR regardless of existing text
|
||||||
- ``skip``: Skip OCR on pages that already have text
|
- ``skip``: Skip OCR on pages that already have text
|
||||||
- ``redo``: Re-OCR pages, stripping old invisible text layer
|
- ``redo``: Re-OCR pages, stripping old invisible text layer
|
||||||
|
- ``strip``: Remove the invisible OCR text layer in place; do not OCR
|
||||||
"""
|
"""
|
||||||
|
|
||||||
default = 'default'
|
default = 'default'
|
||||||
force = 'force'
|
force = 'force'
|
||||||
skip = 'skip'
|
skip = 'skip'
|
||||||
redo = 'redo'
|
redo = 'redo'
|
||||||
|
# User-facing value is '--mode strip'; the member is named strip_text to
|
||||||
|
# avoid shadowing str.strip on this str-based enum.
|
||||||
|
strip_text = 'strip'
|
||||||
|
|
||||||
|
|
||||||
class TaggedPdfMode(StrEnum):
|
class TaggedPdfMode(StrEnum):
|
||||||
@@ -65,8 +69,33 @@ class TaggedPdfMode(StrEnum):
|
|||||||
ignore = 'ignore'
|
ignore = 'ignore'
|
||||||
|
|
||||||
|
|
||||||
def _pages_from_ranges(ranges: str) -> set[int]:
|
def _has_end_alias(ranges: str) -> bool:
|
||||||
"""Convert page range string to set of page numbers."""
|
"""Return True if the page range string uses the ``end`` alias."""
|
||||||
|
return 'end' in ranges.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_page_token(token: str, total_pages: int | None) -> int:
|
||||||
|
"""Convert a single page-number token to a 1-based integer.
|
||||||
|
|
||||||
|
The literal ``end`` (case-insensitive) is resolved to ``total_pages``. If
|
||||||
|
``total_pages`` is None, an error is raised.
|
||||||
|
"""
|
||||||
|
if token.lower() == 'end':
|
||||||
|
if total_pages is None:
|
||||||
|
raise BadArgsError(
|
||||||
|
"'end' was used in --pages but the total page count is not yet known"
|
||||||
|
)
|
||||||
|
return total_pages
|
||||||
|
return int(token)
|
||||||
|
|
||||||
|
|
||||||
|
def _pages_from_ranges(ranges: str, total_pages: int | None = None) -> set[int]:
|
||||||
|
"""Convert page range string to set of 0-based page numbers.
|
||||||
|
|
||||||
|
The token ``end`` (case-insensitive) is an alias for the last page of the
|
||||||
|
document. It is resolved using ``total_pages``; if ``end`` appears in the
|
||||||
|
string and ``total_pages`` is None, a :class:`BadArgsError` is raised.
|
||||||
|
"""
|
||||||
pages: list[int] = []
|
pages: list[int] = []
|
||||||
page_groups = ranges.replace(' ', '').split(',')
|
page_groups = ranges.replace(' ', '').split(',')
|
||||||
for group in page_groups:
|
for group in page_groups:
|
||||||
@@ -75,10 +104,15 @@ def _pages_from_ranges(ranges: str) -> set[int]:
|
|||||||
try:
|
try:
|
||||||
start, end = group.split('-')
|
start, end = group.split('-')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pages.append(int(group) - 1)
|
try:
|
||||||
|
pages.append(_resolve_page_token(group, total_pages) - 1)
|
||||||
|
except ValueError:
|
||||||
|
raise BadArgsError(f"invalid page number '{group}'") from None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
new_pages = list(range(int(start) - 1, int(end)))
|
start_n = _resolve_page_token(start, total_pages)
|
||||||
|
end_n = _resolve_page_token(end, total_pages)
|
||||||
|
new_pages = list(range(start_n - 1, end_n))
|
||||||
if not new_pages:
|
if not new_pages:
|
||||||
raise BadArgsError(
|
raise BadArgsError(
|
||||||
f"invalid page subrange '{start}-{end}'"
|
f"invalid page subrange '{start}-{end}'"
|
||||||
@@ -175,7 +209,6 @@ class OcrOptions(BaseModel):
|
|||||||
optimize: int = 1
|
optimize: int = 1
|
||||||
jpg_quality: int | None = None
|
jpg_quality: int | None = None
|
||||||
png_quality: int | None = None
|
png_quality: int | None = None
|
||||||
jbig2_threshold: float = 0.85
|
|
||||||
|
|
||||||
# Compatibility alias for plugins that expect jpeg_quality
|
# Compatibility alias for plugins that expect jpeg_quality
|
||||||
@property
|
@property
|
||||||
@@ -188,8 +221,11 @@ class OcrOptions(BaseModel):
|
|||||||
"""Compatibility alias for jpg_quality."""
|
"""Compatibility alias for jpg_quality."""
|
||||||
self.jpg_quality = value
|
self.jpg_quality = value
|
||||||
|
|
||||||
|
# Output behavior
|
||||||
|
no_overwrite: bool = False
|
||||||
|
|
||||||
# Advanced options
|
# Advanced options
|
||||||
max_image_mpixels: float = 250.0
|
max_image_mpixels: float | None = None
|
||||||
pdf_renderer: str = 'auto'
|
pdf_renderer: str = 'auto'
|
||||||
ocr_engine: str = 'auto'
|
ocr_engine: str = 'auto'
|
||||||
rasterizer: str = 'auto'
|
rasterizer: str = 'auto'
|
||||||
@@ -298,7 +334,7 @@ class OcrOptions(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def validate_max_image_mpixels(cls, v):
|
def validate_max_image_mpixels(cls, v):
|
||||||
"""Validate max image megapixels."""
|
"""Validate max image megapixels."""
|
||||||
if v < 0:
|
if v is not None and v < 0:
|
||||||
raise ValueError("max_image_mpixels must be non-negative")
|
raise ValueError("max_image_mpixels must be non-negative")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -329,11 +365,19 @@ class OcrOptions(BaseModel):
|
|||||||
@field_validator('pages')
|
@field_validator('pages')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_pages_format(cls, v):
|
def validate_pages_format(cls, v):
|
||||||
"""Convert page ranges string to set of page numbers."""
|
"""Convert page ranges string to set of page numbers.
|
||||||
|
|
||||||
|
If the string uses the ``end`` alias, the original string is preserved
|
||||||
|
so that resolution can happen later, once the document's page count is
|
||||||
|
known.
|
||||||
|
"""
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
if isinstance(v, set):
|
if isinstance(v, set):
|
||||||
return v # Already processed
|
return v # Already processed
|
||||||
|
if _has_end_alias(v):
|
||||||
|
# Defer resolution until total page count is known
|
||||||
|
return v
|
||||||
|
|
||||||
# Convert string ranges to set of page numbers
|
# Convert string ranges to set of page numbers
|
||||||
return _pages_from_ranges(v)
|
return _pages_from_ranges(v)
|
||||||
@@ -420,7 +464,7 @@ class OcrOptions(BaseModel):
|
|||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Since you specified `--output-type none`, the output file "
|
"Since you specified `--output-type none`, the output file "
|
||||||
f"{self.output_file} cannot be produced. Set the output file to "
|
f"{str(self.output_file)} cannot be produced. Set the output file to "
|
||||||
f"`-` to suppress this message."
|
f"`-` to suppress this message."
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
@@ -531,7 +575,7 @@ class OcrOptions(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def register_plugin_models(cls, models: dict[str, type]) -> None:
|
def register_plugin_models(cls, models: dict[str, type[BaseModel]]) -> None:
|
||||||
"""Register plugin option model classes for nested access.
|
"""Register plugin option model classes for nested access.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -579,6 +623,13 @@ class OcrOptions(BaseModel):
|
|||||||
value = getattr(self, flat_name)
|
value = getattr(self, flat_name)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
kwargs[field_name] = _convert_value(value)
|
kwargs[field_name] = _convert_value(value)
|
||||||
|
# Plugin-scoped fields that aren't in the central OcrOptions
|
||||||
|
# registry: argparse stores them in extra_attrs under the
|
||||||
|
# namespace_field name.
|
||||||
|
elif flat_name in self.extra_attrs:
|
||||||
|
value = self.extra_attrs[flat_name]
|
||||||
|
if value is not None:
|
||||||
|
kwargs[field_name] = _convert_value(value)
|
||||||
# Also check direct field name (for fields like jbig2_lossy)
|
# Also check direct field name (for fields like jbig2_lossy)
|
||||||
elif field_name in OcrOptions.model_fields:
|
elif field_name in OcrOptions.model_fields:
|
||||||
value = getattr(self, field_name)
|
value = getattr(self, field_name)
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 James R. Barlow
|
||||||
|
# SPDX-FileCopyrightText: 2025 ajdlinux
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Validate and repair malformed page-boundary boxes.
|
||||||
|
|
||||||
|
A page's boundary boxes (``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``,
|
||||||
|
``/BleedBox``) are sometimes malformed in ways that PDF readers tolerate but
|
||||||
|
that crash or corrupt downstream processing. This module normalizes them in
|
||||||
|
place following the PDF 2.0 specification (ISO 32000-2:2020):
|
||||||
|
|
||||||
|
- **Non-decimal coordinates** (§7.3.3): a coordinate written in exponential
|
||||||
|
notation is invalid PDF number syntax and is stored by qpdf/pikepdf as a
|
||||||
|
string. We coerce it back to a number (issue #1398).
|
||||||
|
- **Reversed corners** (§7.9.5): a rectangle is "a pair of diagonally opposite
|
||||||
|
corners"; ``[llx lly urx ury]`` is only the typical order. We normalize to
|
||||||
|
``[min_x, min_y, max_x, max_y]`` (issue #1526).
|
||||||
|
- **Sub-box outside the MediaBox** (§14.11.2): "If the bounds of the crop,
|
||||||
|
trim, bleed or art box extends outside of the bounds of the media box, a
|
||||||
|
processor shall treat the box as its intersection with the media box." We
|
||||||
|
clamp to that intersection, or discard the sub-box (so it inherits the
|
||||||
|
MediaBox) when the intersection is empty (issue #1400).
|
||||||
|
|
||||||
|
A rectangle is treated as empty when its width or height is ``<= 0``; PDF 2.0
|
||||||
|
permits zero-dimension rectangles and defines no minimum page size, so no other
|
||||||
|
size floor is imposed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
from pikepdf import Name
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SUBBOXES = ('CropBox', 'TrimBox', 'ArtBox', 'BleedBox')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BoxRepair:
|
||||||
|
"""A single change made to a page box.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
box: The box name, e.g. ``"CropBox"``.
|
||||||
|
kind: One of ``"reordered"`` (reversed corners normalized; lossless),
|
||||||
|
``"recoded"`` (non-numeric/exponential coordinate coerced),
|
||||||
|
``"clamped"`` (sub-box clamped to the MediaBox), ``"discarded"``
|
||||||
|
(sub-box removed because its MediaBox intersection was empty), or
|
||||||
|
``"degenerate_mediabox"`` (MediaBox has zero width or height).
|
||||||
|
"""
|
||||||
|
|
||||||
|
box: str
|
||||||
|
kind: str
|
||||||
|
|
||||||
|
|
||||||
|
def _read_box(values: Sequence) -> tuple[list[float], bool, bool] | None:
|
||||||
|
"""Coerce a box array to floats and normalize corner order.
|
||||||
|
|
||||||
|
Returns ``(normalized_values, recoded, reordered)`` where ``recoded`` is
|
||||||
|
True if any element needed string/exponential coercion and ``reordered`` is
|
||||||
|
True if the corners were given in non-standard order. Returns None if the
|
||||||
|
array is not four finite numbers.
|
||||||
|
"""
|
||||||
|
if len(values) != 4:
|
||||||
|
return None
|
||||||
|
nums: list[float] = []
|
||||||
|
recoded = False
|
||||||
|
for v in values:
|
||||||
|
try:
|
||||||
|
n = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
try:
|
||||||
|
n = float(str(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
recoded = True
|
||||||
|
if not math.isfinite(n):
|
||||||
|
return None
|
||||||
|
nums.append(n)
|
||||||
|
x0, y0, x1, y1 = nums
|
||||||
|
normalized = [min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)]
|
||||||
|
reordered = normalized != nums
|
||||||
|
return normalized, recoded, reordered
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_box(values: Iterable) -> list[float]:
|
||||||
|
"""Return box values coerced to floats with corner order normalized.
|
||||||
|
|
||||||
|
Robust against exponential/string coordinates and reversed corners, so
|
||||||
|
callers that only need to read a box (e.g. dimension calculations) do not
|
||||||
|
crash on malformed input. Falls back to best-effort per-element coercion if
|
||||||
|
the array is not four numbers.
|
||||||
|
"""
|
||||||
|
values = list(values)
|
||||||
|
result = _read_box(values)
|
||||||
|
if result is not None:
|
||||||
|
return result[0]
|
||||||
|
coerced = []
|
||||||
|
for v in values:
|
||||||
|
try:
|
||||||
|
coerced.append(float(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
coerced.append(float(str(v)))
|
||||||
|
return coerced
|
||||||
|
|
||||||
|
|
||||||
|
def _is_empty(box: Sequence[float]) -> bool:
|
||||||
|
"""A rectangle is empty when its width or height is non-positive."""
|
||||||
|
return (box[2] - box[0]) <= 0 or (box[3] - box[1]) <= 0
|
||||||
|
|
||||||
|
|
||||||
|
def repair_page_boxes(page: pikepdf.Page) -> list[BoxRepair]:
|
||||||
|
"""Validate and repair the boundary boxes of a single page, in place.
|
||||||
|
|
||||||
|
Returns the list of changes made (empty if the page was already valid).
|
||||||
|
Only boxes that actually change are written back, so valid pages are left
|
||||||
|
untouched. Performs no logging or I/O.
|
||||||
|
"""
|
||||||
|
repairs: list[BoxRepair] = []
|
||||||
|
|
||||||
|
# MediaBox is the reference rectangle; read it inheritance-aware.
|
||||||
|
mediabox: list[float] | None = None
|
||||||
|
try:
|
||||||
|
mb_result = _read_box(list(page.mediabox.as_list()))
|
||||||
|
except (AttributeError, KeyError, RuntimeError):
|
||||||
|
mb_result = None
|
||||||
|
if mb_result is not None:
|
||||||
|
mediabox, recoded, reordered = mb_result
|
||||||
|
if reordered:
|
||||||
|
repairs.append(BoxRepair('MediaBox', 'reordered'))
|
||||||
|
if recoded:
|
||||||
|
repairs.append(BoxRepair('MediaBox', 'recoded'))
|
||||||
|
if recoded or reordered:
|
||||||
|
page.obj.MediaBox = pikepdf.Array(mediabox)
|
||||||
|
if _is_empty(mediabox):
|
||||||
|
repairs.append(BoxRepair('MediaBox', 'degenerate_mediabox'))
|
||||||
|
mediabox = None # don't clamp against a degenerate reference
|
||||||
|
|
||||||
|
for box in _SUBBOXES:
|
||||||
|
name = Name('/' + box)
|
||||||
|
if name not in page.obj:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sub_result = _read_box(list(page.obj[name]))
|
||||||
|
except (TypeError, RuntimeError):
|
||||||
|
continue
|
||||||
|
if sub_result is None:
|
||||||
|
continue
|
||||||
|
values, recoded, reordered = sub_result
|
||||||
|
if reordered:
|
||||||
|
repairs.append(BoxRepair(box, 'reordered'))
|
||||||
|
if recoded:
|
||||||
|
repairs.append(BoxRepair(box, 'recoded'))
|
||||||
|
if recoded or reordered:
|
||||||
|
page.obj[name] = pikepdf.Array(values)
|
||||||
|
|
||||||
|
if mediabox is None:
|
||||||
|
continue
|
||||||
|
intersection = [
|
||||||
|
max(values[0], mediabox[0]),
|
||||||
|
max(values[1], mediabox[1]),
|
||||||
|
min(values[2], mediabox[2]),
|
||||||
|
min(values[3], mediabox[3]),
|
||||||
|
]
|
||||||
|
if _is_empty(intersection):
|
||||||
|
del page.obj[name]
|
||||||
|
repairs.append(BoxRepair(box, 'discarded'))
|
||||||
|
elif intersection != values:
|
||||||
|
page.obj[name] = pikepdf.Array(intersection)
|
||||||
|
repairs.append(BoxRepair(box, 'clamped'))
|
||||||
|
|
||||||
|
return repairs
|
||||||
|
|
||||||
|
|
||||||
|
# Per-kind log severity and message template ({box} is substituted).
|
||||||
|
_KIND_MESSAGES: dict[str, tuple[int, str]] = {
|
||||||
|
'discarded': (
|
||||||
|
logging.WARNING,
|
||||||
|
'{box} lies outside the MediaBox and was discarded; '
|
||||||
|
'the full page will be shown',
|
||||||
|
),
|
||||||
|
'clamped': (
|
||||||
|
logging.WARNING,
|
||||||
|
'{box} extended beyond the MediaBox and was clamped to it',
|
||||||
|
),
|
||||||
|
'recoded': (
|
||||||
|
logging.WARNING,
|
||||||
|
'{box} used invalid (e.g. exponential) coordinates, which were reinterpreted',
|
||||||
|
),
|
||||||
|
'degenerate_mediabox': (
|
||||||
|
logging.WARNING,
|
||||||
|
'MediaBox has zero width or height and could not be repaired; '
|
||||||
|
'output may be invalid',
|
||||||
|
),
|
||||||
|
'reordered': (
|
||||||
|
logging.DEBUG,
|
||||||
|
'{box} corners were reversed and have been normalized',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kinds that change page appearance and warrant manual review of the output.
|
||||||
|
_INSPECT_KINDS = frozenset({'discarded', 'clamped', 'recoded'})
|
||||||
|
_INSPECT = ' Please visually inspect the output PDF.'
|
||||||
|
|
||||||
|
|
||||||
|
def _format_pages(pagenos: Iterable[int]) -> str:
|
||||||
|
"""Format 0-based page numbers as a compact 1-based range string."""
|
||||||
|
nums = sorted(p + 1 for p in pagenos)
|
||||||
|
ranges: list[tuple[int, int]] = []
|
||||||
|
start = prev = nums[0]
|
||||||
|
for n in nums[1:]:
|
||||||
|
if n == prev + 1:
|
||||||
|
prev = n
|
||||||
|
continue
|
||||||
|
ranges.append((start, prev))
|
||||||
|
start = prev = n
|
||||||
|
ranges.append((start, prev))
|
||||||
|
return ', '.join(f'{a}' if a == b else f'{a}-{b}' for a, b in ranges)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_box_repairs(
|
||||||
|
repairs_by_page: Mapping[int, Sequence[BoxRepair]],
|
||||||
|
) -> list[tuple[int, str]]:
|
||||||
|
"""Aggregate per-page repairs into ``(log_level, message)`` pairs.
|
||||||
|
|
||||||
|
Repairs are grouped by ``(kind, box)`` so a defect shared across many pages
|
||||||
|
yields a single message listing the affected pages, rather than one message
|
||||||
|
per page.
|
||||||
|
"""
|
||||||
|
groups: dict[tuple[str, str], set[int]] = {}
|
||||||
|
for pageno, repairs in repairs_by_page.items():
|
||||||
|
for repair in repairs:
|
||||||
|
groups.setdefault((repair.kind, repair.box), set()).add(pageno)
|
||||||
|
|
||||||
|
messages: list[tuple[int, str]] = []
|
||||||
|
for (kind, box), pages in sorted(groups.items()):
|
||||||
|
level, template = _KIND_MESSAGES[kind]
|
||||||
|
text = f'Page(s) {_format_pages(pages)}: {template.format(box=box)}.'
|
||||||
|
if kind in _INSPECT_KINDS:
|
||||||
|
text += _INSPECT
|
||||||
|
messages.append((level, text))
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def log_box_repairs(repairs_by_page: Mapping[int, Sequence[BoxRepair]]) -> None:
|
||||||
|
"""Emit aggregated log messages for the repairs made across all pages."""
|
||||||
|
for level, message in summarize_box_repairs(repairs_by_page):
|
||||||
|
log.log(level, message)
|
||||||
+173
-71
@@ -28,23 +28,29 @@ from ocrmypdf._concurrent import Executor
|
|||||||
from ocrmypdf._exec import unpaper
|
from ocrmypdf._exec import unpaper
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._metadata import repair_docinfo_nuls
|
from ocrmypdf._metadata import repair_docinfo_nuls
|
||||||
from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode
|
from ocrmypdf._options import OcrOptions, PathOrIO, ProcessingMode, TaggedPdfMode
|
||||||
|
from ocrmypdf._pageboxes import log_box_repairs, repair_page_boxes
|
||||||
|
from ocrmypdf._stdoutprotect import get_protected_stdout_fd
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
|
ColorConversionNeededError,
|
||||||
DigitalSignatureError,
|
DigitalSignatureError,
|
||||||
DpiError,
|
DpiError,
|
||||||
EncryptedPdfError,
|
EncryptedPdfError,
|
||||||
InputFileError,
|
InputFileError,
|
||||||
|
NonEmbeddedFontsError,
|
||||||
PriorOcrFoundError,
|
PriorOcrFoundError,
|
||||||
|
SubprocessOutputError,
|
||||||
TaggedPDFError,
|
TaggedPDFError,
|
||||||
UnsupportedImageFormatError,
|
UnsupportedImageFormatError,
|
||||||
)
|
)
|
||||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
|
||||||
from ocrmypdf.pdfa import (
|
from ocrmypdf.pdfa import (
|
||||||
file_claims_pdfa,
|
file_claims_pdfa,
|
||||||
|
find_nonembedded_cid_fonts,
|
||||||
generate_pdfa_ps,
|
generate_pdfa_ps,
|
||||||
speculative_pdfa_conversion,
|
speculative_pdfa_conversion,
|
||||||
)
|
)
|
||||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, PageInfo, PdfInfo
|
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, Ink, PageInfo, PdfInfo
|
||||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence
|
from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -116,8 +122,7 @@ def triage_image_file(input_file: Path, output_file: Path, options: OcrOptions)
|
|||||||
|
|
||||||
if im.mode in ('RGBA', 'LA'):
|
if im.mode in ('RGBA', 'LA'):
|
||||||
raise UnsupportedImageFormatError(
|
raise UnsupportedImageFormatError(
|
||||||
"The input image has an alpha channel. Remove the alpha "
|
"The input image has an alpha channel. Remove the alpha channel first."
|
||||||
"channel first."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'iccprofile' not in im.info:
|
if 'iccprofile' not in im.info:
|
||||||
@@ -135,7 +140,7 @@ def triage_image_file(input_file: Path, output_file: Path, options: OcrOptions)
|
|||||||
layout_fun = img2pdf.get_fixed_dpi_layout_fun(
|
layout_fun = img2pdf.get_fixed_dpi_layout_fun(
|
||||||
Resolution(options.image_dpi, options.image_dpi)
|
Resolution(options.image_dpi, options.image_dpi)
|
||||||
)
|
)
|
||||||
with open(output_file, 'wb') as outf:
|
with output_file.open('wb') as outf:
|
||||||
img2pdf.convert(
|
img2pdf.convert(
|
||||||
os.fspath(input_file),
|
os.fspath(input_file),
|
||||||
layout_fun=layout_fun,
|
layout_fun=layout_fun,
|
||||||
@@ -154,7 +159,7 @@ def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
|
|||||||
|
|
||||||
Returns empty string if not found, indicating file is probably not PDF.
|
Returns empty string if not found, indicating file is probably not PDF.
|
||||||
"""
|
"""
|
||||||
with open(input_file, 'rb') as f:
|
with input_file.open('rb') as f:
|
||||||
signature = f.read(search_window)
|
signature = f.read(search_window)
|
||||||
m = re.search(rb'%PDF-(\d\.\d)', signature)
|
m = re.search(rb'%PDF-(\d\.\d)', signature)
|
||||||
if m:
|
if m:
|
||||||
@@ -175,6 +180,12 @@ def triage(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with pikepdf.open(input_file) as pdf:
|
with pikepdf.open(input_file) as pdf:
|
||||||
|
repairs_by_page = {
|
||||||
|
n: repairs
|
||||||
|
for n, page in enumerate(pdf.pages)
|
||||||
|
if (repairs := repair_page_boxes(page))
|
||||||
|
}
|
||||||
|
log_box_repairs(repairs_by_page)
|
||||||
pdf.save(output_file)
|
pdf.save(output_file)
|
||||||
except pikepdf.PdfError as e:
|
except pikepdf.PdfError as e:
|
||||||
raise InputFileError() from e
|
raise InputFileError() from e
|
||||||
@@ -250,12 +261,15 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
|
|||||||
"image of the form and all filled form fields. The output PDF "
|
"image of the form and all filled form fields. The output PDF "
|
||||||
"will be 'flattened' and will no longer be fillable."
|
"will be 'flattened' and will no longer be fillable."
|
||||||
)
|
)
|
||||||
if pdfinfo.is_tagged:
|
if pdfinfo.is_tagged or pdfinfo.has_structure_tree:
|
||||||
log.warning(
|
log.warning(
|
||||||
"This PDF is marked as a Tagged PDF. This often indicates "
|
"This PDF contains structural markup (it is a Tagged PDF or "
|
||||||
"that the PDF was generated from an office document and does "
|
"carries a logical structure tree). This often indicates that the "
|
||||||
"not need OCR. PDF pages processed by OCRmyPDF may not be "
|
"PDF was generated from an office document or is otherwise born "
|
||||||
"tagged correctly."
|
"digital, and does not need OCR. OCRmyPDF cannot rebuild this "
|
||||||
|
"structure to match new text, so any page it re-OCRs with "
|
||||||
|
"--force-ocr or --redo-ocr will have its structural markup "
|
||||||
|
"discarded."
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
options.tagged_pdf_mode == TaggedPdfMode.default
|
options.tagged_pdf_mode == TaggedPdfMode.default
|
||||||
@@ -325,6 +339,11 @@ def is_ocr_required(page_context: PageContext) -> bool:
|
|||||||
pageinfo = page_context.pageinfo
|
pageinfo = page_context.pageinfo
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
|
|
||||||
|
if options.mode == ProcessingMode.strip_text:
|
||||||
|
# Strip mode removes the OCR text layer in place; it never rasterizes
|
||||||
|
# or runs OCR. The stripping happens in OcrGrafter.graft_page.
|
||||||
|
return False
|
||||||
|
|
||||||
ocr_required = True
|
ocr_required = True
|
||||||
|
|
||||||
if options.pages and pageinfo.pageno not in options.pages:
|
if options.pages and pageinfo.pageno not in options.pages:
|
||||||
@@ -508,6 +527,49 @@ def calculate_raster_dpi(page_context: PageContext):
|
|||||||
return canvas_dpi, page_dpi
|
return canvas_dpi, page_dpi
|
||||||
|
|
||||||
|
|
||||||
|
def _select_raster_device(pageinfo: PageInfo) -> GhostscriptRasterDevice:
|
||||||
|
"""Choose the minimum raster device that preserves the page's color depth.
|
||||||
|
|
||||||
|
The device escalates from 1-bit mono through grayscale, indexed, and full
|
||||||
|
color as required by the page's images, image masks, and vector content.
|
||||||
|
Image masks are painted with the current fill color, so a mask painted in
|
||||||
|
gray or color escalates the device even though the mask itself is 1-bit.
|
||||||
|
"""
|
||||||
|
colorspaces = [
|
||||||
|
GhostscriptRasterDevice.PNGMONOD,
|
||||||
|
GhostscriptRasterDevice.PNGGRAY,
|
||||||
|
GhostscriptRasterDevice.PNG256,
|
||||||
|
GhostscriptRasterDevice.PNG16M,
|
||||||
|
]
|
||||||
|
device_idx = 0
|
||||||
|
|
||||||
|
def at_least(colorspace):
|
||||||
|
return max(device_idx, colorspaces.index(colorspace))
|
||||||
|
|
||||||
|
for image in pageinfo.images:
|
||||||
|
if image.type_ == 'stencil':
|
||||||
|
# The fill color used to paint the mask, not the 1-bit mask data,
|
||||||
|
# determines the color depth OCR needs.
|
||||||
|
if image.ink == Ink.color:
|
||||||
|
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||||
|
elif image.ink == Ink.gray:
|
||||||
|
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
||||||
|
continue
|
||||||
|
if image.bpc > 1:
|
||||||
|
if image.color == Colorspace.index:
|
||||||
|
device_idx = at_least(GhostscriptRasterDevice.PNG256)
|
||||||
|
elif image.color == Colorspace.gray:
|
||||||
|
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
||||||
|
else:
|
||||||
|
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||||
|
|
||||||
|
if pageinfo.has_vector:
|
||||||
|
log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}")
|
||||||
|
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||||
|
|
||||||
|
return colorspaces[device_idx]
|
||||||
|
|
||||||
|
|
||||||
def rasterize(
|
def rasterize(
|
||||||
input_file: Path,
|
input_file: Path,
|
||||||
page_context: PageContext,
|
page_context: PageContext,
|
||||||
@@ -529,39 +591,13 @@ def rasterize(
|
|||||||
Returns:
|
Returns:
|
||||||
Path: The output PNG file path.
|
Path: The output PNG file path.
|
||||||
"""
|
"""
|
||||||
colorspaces = [
|
|
||||||
GhostscriptRasterDevice.PNGMONO,
|
|
||||||
GhostscriptRasterDevice.PNGGRAY,
|
|
||||||
GhostscriptRasterDevice.PNG256,
|
|
||||||
GhostscriptRasterDevice.PNG16M,
|
|
||||||
]
|
|
||||||
device_idx = 0
|
|
||||||
|
|
||||||
if remove_vectors is None:
|
if remove_vectors is None:
|
||||||
remove_vectors = page_context.options.remove_vectors
|
remove_vectors = page_context.options.remove_vectors
|
||||||
|
|
||||||
output_file = page_context.get_path(f'rasterize{output_tag}.png')
|
output_file = page_context.get_path(f'rasterize{output_tag}.png')
|
||||||
pageinfo = page_context.pageinfo
|
pageinfo = page_context.pageinfo
|
||||||
|
|
||||||
def at_least(colorspace):
|
device = _select_raster_device(pageinfo)
|
||||||
return max(device_idx, colorspaces.index(colorspace))
|
|
||||||
|
|
||||||
for image in pageinfo.images:
|
|
||||||
if image.type_ != 'image':
|
|
||||||
continue # ignore masks
|
|
||||||
if image.bpc > 1:
|
|
||||||
if image.color == Colorspace.index:
|
|
||||||
device_idx = at_least(GhostscriptRasterDevice.PNG256)
|
|
||||||
elif image.color == Colorspace.gray:
|
|
||||||
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
|
||||||
else:
|
|
||||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
|
||||||
|
|
||||||
if pageinfo.has_vector:
|
|
||||||
log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}")
|
|
||||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
|
||||||
|
|
||||||
device = colorspaces[device_idx]
|
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Rasterize with {device}, rotation {correction}, mediabox {pageinfo.mediabox}"
|
f"Rasterize with {device}, rotation {correction}, mediabox {pageinfo.mediabox}"
|
||||||
@@ -647,6 +683,7 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path:
|
|||||||
"""
|
"""
|
||||||
output_file = page_context.get_path('ocr.png')
|
output_file = page_context.get_path('ocr.png')
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
|
im: Image.Image
|
||||||
with Image.open(image) as im:
|
with Image.open(image) as im:
|
||||||
log.debug('resolution %r', im.info['dpi'])
|
log.debug('resolution %r', im.info['dpi'])
|
||||||
|
|
||||||
@@ -796,7 +833,7 @@ def create_pdf_page_from_image(
|
|||||||
|
|
||||||
# Create a new single page PDF to hold
|
# Create a new single page PDF to hold
|
||||||
bio = BytesIO()
|
bio = BytesIO()
|
||||||
with open(image, 'rb') as imfile:
|
with image.open('rb') as imfile:
|
||||||
log.debug('convert')
|
log.debug('convert')
|
||||||
|
|
||||||
layout_fun = img2pdf.get_layout_fun(pagesize)
|
layout_fun = img2pdf.get_layout_fun(pagesize)
|
||||||
@@ -945,6 +982,12 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
|
|||||||
# pikepdf can deal with this, but we make the world a better place by
|
# pikepdf can deal with this, but we make the world a better place by
|
||||||
# stamping them out as soon as possible.
|
# stamping them out as soon as possible.
|
||||||
with pikepdf.open(input_pdf) as pdf_file:
|
with pikepdf.open(input_pdf) as pdf_file:
|
||||||
|
# Ghostscript would substitute and re-embed any non-embedded CID font to
|
||||||
|
# satisfy PDF/A, corrupting CJK text (e.g. an Acrobat OCR layer) in the
|
||||||
|
# process. Refuse rather than silently damage the user's text layer.
|
||||||
|
nonembedded = find_nonembedded_cid_fonts(pdf_file)
|
||||||
|
if nonembedded:
|
||||||
|
raise NonEmbeddedFontsError(nonembedded)
|
||||||
if repair_docinfo_nuls(pdf_file):
|
if repair_docinfo_nuls(pdf_file):
|
||||||
pdf_file.save(fix_docinfo_file)
|
pdf_file.save(fix_docinfo_file)
|
||||||
else:
|
else:
|
||||||
@@ -1038,14 +1081,46 @@ def try_speculative_pdfa(input_pdf: Path, context: PdfContext) -> Path | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ghostscript_pdfa_fallback(input_pdf: Path, context: PdfContext) -> Path | None:
|
||||||
|
"""Best-effort PDF/A conversion via Ghostscript for 'auto' output type.
|
||||||
|
|
||||||
|
Returns the converted PDF/A path, or None if Ghostscript is unavailable,
|
||||||
|
fails, or cannot produce valid PDF/A. Never raises: 'auto' mode degrades to
|
||||||
|
a regular PDF instead of erroring or emitting corrupted output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_pdf: Path to the PDF to convert.
|
||||||
|
context: The PDF context.
|
||||||
|
"""
|
||||||
|
from ocrmypdf._exec import ghostscript
|
||||||
|
|
||||||
|
if not ghostscript.available():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ps_stub = generate_postscript_stub(context)
|
||||||
|
gs_out = convert_to_pdfa(input_pdf, ps_stub, context)
|
||||||
|
except (
|
||||||
|
SubprocessOutputError,
|
||||||
|
ColorConversionNeededError,
|
||||||
|
NonEmbeddedFontsError,
|
||||||
|
) as e:
|
||||||
|
log.info('Auto mode: Ghostscript could not produce PDF/A (%s)', e)
|
||||||
|
return None
|
||||||
|
if not file_claims_pdfa(gs_out)['pass']:
|
||||||
|
log.info('Auto mode: Ghostscript output is not valid PDF/A')
|
||||||
|
return None
|
||||||
|
return gs_out
|
||||||
|
|
||||||
|
|
||||||
def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]:
|
def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]:
|
||||||
"""Best-effort PDF/A for 'auto' output type.
|
"""Best-effort PDF/A for 'auto' output type.
|
||||||
|
|
||||||
This function attempts to produce PDF/A without requiring Ghostscript:
|
Order of attempts, first success wins:
|
||||||
1. If verapdf is available, tries speculative conversion with validation
|
1. Non-embedded CID fonts -> regular PDF (Ghostscript would corrupt them).
|
||||||
2. Without verapdf, passes through as PDF/A if safe (input already PDF/A
|
2. Speculative conversion validated by verapdf (no Ghostscript).
|
||||||
or force-ocr was used)
|
3. Without verapdf, pass through if already PDF/A or rebuilt with force-ocr.
|
||||||
3. Falls back to regular PDF if neither condition is met
|
4. Ghostscript conversion (best-effort; failures fall through).
|
||||||
|
5. Regular PDF if none of the above produced PDF/A.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_pdf: Path to the PDF to convert
|
input_pdf: Path to the PDF to convert
|
||||||
@@ -1057,25 +1132,42 @@ def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]:
|
|||||||
"""
|
"""
|
||||||
from ocrmypdf._exec import verapdf
|
from ocrmypdf._exec import verapdf
|
||||||
|
|
||||||
# If verapdf available, try speculative conversion with validation
|
# Non-embedded CID fonts cannot be made PDF/A without Ghostscript font
|
||||||
|
# substitution that corrupts CID/CJK text. Rather than risk an existing
|
||||||
|
# text layer, downgrade to a regular PDF (the same outcome as any other
|
||||||
|
# case where best-effort PDF/A is not achievable).
|
||||||
|
with pikepdf.open(input_pdf) as pdf_file:
|
||||||
|
nonembedded = find_nonembedded_cid_fonts(pdf_file)
|
||||||
|
if nonembedded:
|
||||||
|
log.info(
|
||||||
|
"Auto mode: input has non-embedded CID fonts (%s) that cannot be "
|
||||||
|
"converted to PDF/A without corrupting the text; outputting a "
|
||||||
|
"regular PDF. Use --output-type pdf to select this explicitly.",
|
||||||
|
', '.join(sorted(nonembedded)),
|
||||||
|
)
|
||||||
|
return (input_pdf, 'pdf')
|
||||||
|
|
||||||
|
# Cheap path: speculative conversion validated by verapdf (no Ghostscript).
|
||||||
if verapdf.available():
|
if verapdf.available():
|
||||||
result = try_speculative_pdfa(input_pdf, context)
|
result = try_speculative_pdfa(input_pdf, context)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return (result, 'pdfa')
|
return (result, 'pdfa')
|
||||||
# verapdf validation failed - fall through to regular PDF
|
log.info('Auto mode: speculative PDF/A validation failed')
|
||||||
log.info(
|
elif _is_safe_pdfa(input_pdf, context.options):
|
||||||
'Auto mode: speculative PDF/A validation failed, outputting regular PDF'
|
# No verapdf, but the input is already PDF/A or was rebuilt with
|
||||||
)
|
# --force-ocr, so we can pass it through without Ghostscript.
|
||||||
return (input_pdf, 'pdf')
|
|
||||||
|
|
||||||
# Without verapdf, check if we can pass through as PDF/A
|
|
||||||
if _is_safe_pdfa(input_pdf, context.options):
|
|
||||||
# Pass through as-is (no modifications needed)
|
|
||||||
log.info('Auto mode: passing through as PDF/A (input already compliant)')
|
log.info('Auto mode: passing through as PDF/A (input already compliant)')
|
||||||
return (input_pdf, 'pdfa')
|
return (input_pdf, 'pdfa')
|
||||||
|
|
||||||
# Fall through to regular PDF
|
# Fall back to Ghostscript to produce real PDF/A (v16 behavior). Best-effort:
|
||||||
log.info('Auto mode: no verapdf available and input is not PDF/A, outputting PDF')
|
# if Ghostscript is unavailable or cannot safely produce PDF/A, keep a
|
||||||
|
# regular PDF rather than error.
|
||||||
|
gs_out = _ghostscript_pdfa_fallback(input_pdf, context)
|
||||||
|
if gs_out is not None:
|
||||||
|
log.info('Auto mode: produced PDF/A via Ghostscript')
|
||||||
|
return (gs_out, 'pdfa')
|
||||||
|
|
||||||
|
log.info('Auto mode: could not produce PDF/A, outputting regular PDF')
|
||||||
return (input_pdf, 'pdf')
|
return (input_pdf, 'pdf')
|
||||||
|
|
||||||
|
|
||||||
@@ -1107,7 +1199,7 @@ def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
|||||||
|
|
||||||
For smaller files, linearization is not worth the effort.
|
For smaller files, linearization is not worth the effort.
|
||||||
"""
|
"""
|
||||||
filesize = os.stat(working_file).st_size
|
filesize = working_file.stat().st_size
|
||||||
return filesize > (context.options.fast_web_view * 1_000_000)
|
return filesize > (context.options.fast_web_view * 1_000_000)
|
||||||
|
|
||||||
|
|
||||||
@@ -1193,7 +1285,8 @@ def enumerate_compress_ranges(
|
|||||||
A tuple containing a range of indices and the corresponding element.
|
A tuple containing a range of indices and the corresponding element.
|
||||||
If the element is None, the range represents a skipped range of indices.
|
If the element is None, the range represents a skipped range of indices.
|
||||||
"""
|
"""
|
||||||
skipped_from, index = None, None
|
skipped_from: int | None = None
|
||||||
|
index: int | None = None
|
||||||
for index, txt_file in enumerate(iterable):
|
for index, txt_file in enumerate(iterable):
|
||||||
index += 1
|
index += 1
|
||||||
if txt_file:
|
if txt_file:
|
||||||
@@ -1205,6 +1298,9 @@ def enumerate_compress_ranges(
|
|||||||
if skipped_from is None:
|
if skipped_from is None:
|
||||||
skipped_from = index
|
skipped_from = index
|
||||||
if skipped_from is not None:
|
if skipped_from is not None:
|
||||||
|
# skipped_from can only be set inside the loop above, so the loop
|
||||||
|
# must have run at least once and index is guaranteed to be an int.
|
||||||
|
assert index is not None
|
||||||
yield (skipped_from, index), None
|
yield (skipped_from, index), None
|
||||||
|
|
||||||
|
|
||||||
@@ -1216,7 +1312,7 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
|
|||||||
and returns the path to the merged file.
|
and returns the path to the merged file.
|
||||||
"""
|
"""
|
||||||
output_file = context.get_path('sidecar.txt')
|
output_file = context.get_path('sidecar.txt')
|
||||||
with open(output_file, 'w', encoding="utf-8") as stream:
|
with output_file.open('w', encoding="utf-8") as stream:
|
||||||
for (from_, to_), txt_file in enumerate_compress_ranges(txt_files):
|
for (from_, to_), txt_file in enumerate_compress_ranges(txt_files):
|
||||||
if from_ != 1:
|
if from_ != 1:
|
||||||
stream.write('\f') # Form feed between pages for all pages after first
|
stream.write('\f') # Form feed between pages for all pages after first
|
||||||
@@ -1231,24 +1327,28 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
|
|||||||
return output_file
|
return output_file
|
||||||
|
|
||||||
|
|
||||||
def copy_final(
|
def copy_final(input_file: Path, output_file: PathOrIO) -> None:
|
||||||
input_file: Path, output_file: str | Path | BinaryIO, original_file: Path | None
|
|
||||||
) -> None:
|
|
||||||
"""Copy the final temporary file to the output destination.
|
"""Copy the final temporary file to the output destination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_file (Path): The intermediate input file to copy.
|
input_file: The intermediate input file to copy.
|
||||||
output_file (str | Path | BinaryIO): The output file to copy to.
|
output_file: The output file to copy to.
|
||||||
original_file: The original file to copy attributes from.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
"""
|
||||||
log.debug('%s -> %s', input_file, output_file)
|
log.debug('%s -> %s', input_file, output_file)
|
||||||
with input_file.open('rb') as input_stream:
|
with input_file.open('rb') as input_stream:
|
||||||
if output_file == '-':
|
if output_file == '-':
|
||||||
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
|
fd = get_protected_stdout_fd()
|
||||||
sys.stdout.flush()
|
if fd is not None:
|
||||||
|
# Stdout protection is active: write to the preserved real
|
||||||
|
# stdout. dup the saved fd so the with-block's close() does not
|
||||||
|
# close our long-lived descriptor.
|
||||||
|
with os.fdopen(os.dup(fd), 'wb') as stdout_stream:
|
||||||
|
copyfileobj(input_stream, stdout_stream)
|
||||||
|
stdout_stream.flush()
|
||||||
|
else:
|
||||||
|
# No protection installed (e.g. plain API use): legacy behavior.
|
||||||
|
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
|
||||||
|
sys.stdout.flush()
|
||||||
elif hasattr(output_file, 'writable'):
|
elif hasattr(output_file, 'writable'):
|
||||||
output_stream = cast(BinaryIO, output_file)
|
output_stream = cast(BinaryIO, output_file)
|
||||||
copyfileobj(input_stream, output_stream) # type: ignore[misc]
|
copyfileobj(input_stream, output_stream) # type: ignore[misc]
|
||||||
@@ -1258,5 +1358,7 @@ def copy_final(
|
|||||||
# At this point we overwrite the output_file specified by the user
|
# At this point we overwrite the output_file specified by the user
|
||||||
# use copyfileobj because then we use open() to create the file and
|
# use copyfileobj because then we use open() to create the file and
|
||||||
# get the appropriate umask, ownership, etc.
|
# get the appropriate umask, ownership, etc.
|
||||||
with open(output_file, 'w+b') as output_stream:
|
# The `hasattr` check above already ruled out stream-like objects.
|
||||||
|
assert isinstance(output_file, str | bytes | os.PathLike)
|
||||||
|
with Path(os.fsdecode(output_file)).open('w+b') as output_stream:
|
||||||
copyfileobj(input_stream, output_stream)
|
copyfileobj(input_stream, output_stream)
|
||||||
|
|||||||
@@ -329,10 +329,13 @@ def setup_pipeline(
|
|||||||
# Note: OcrOptions is immutable, so we can't modify options.jobs directly
|
# Note: OcrOptions is immutable, so we can't modify options.jobs directly
|
||||||
# The jobs field should already be set correctly during OcrOptions creation
|
# The jobs field should already be set correctly during OcrOptions creation
|
||||||
|
|
||||||
# Apply PIL max image pixels side effect
|
# Apply PIL max image pixels side effect only when explicitly requested.
|
||||||
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
|
# When None, leave PIL.Image.MAX_IMAGE_PIXELS as the host application
|
||||||
if PIL.Image.MAX_IMAGE_PIXELS == 0:
|
# configured it. The CLI passes its own default (250.0) via argparse.
|
||||||
PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
|
if options.max_image_mpixels is not None:
|
||||||
|
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
|
||||||
|
if PIL.Image.MAX_IMAGE_PIXELS == 0:
|
||||||
|
PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
|
||||||
|
|
||||||
pikepdf_enable_mmap()
|
pikepdf_enable_mmap()
|
||||||
executor = setup_executor(plugin_manager)
|
executor = setup_executor(plugin_manager)
|
||||||
@@ -340,12 +343,17 @@ def setup_pipeline(
|
|||||||
|
|
||||||
|
|
||||||
def do_get_pdfinfo(pdf_path: Path, executor: Executor, options) -> PdfInfo:
|
def do_get_pdfinfo(pdf_path: Path, executor: Executor, options) -> PdfInfo:
|
||||||
# Handle pages field - it might be a string that needs conversion
|
# Handle pages field - it might be a string that needs conversion.
|
||||||
|
# A string indicates the ``end`` alias was used and resolution was
|
||||||
|
# deferred; we resolve it now using the document's actual page count.
|
||||||
check_pages = options.pages
|
check_pages = options.pages
|
||||||
if isinstance(check_pages, str):
|
if isinstance(check_pages, str):
|
||||||
from ocrmypdf._options import _pages_from_ranges
|
from ocrmypdf._options import _pages_from_ranges
|
||||||
|
|
||||||
check_pages = _pages_from_ranges(check_pages)
|
with Pdf.open(pdf_path) as pdf:
|
||||||
|
total_pages = len(pdf.pages)
|
||||||
|
check_pages = _pages_from_ranges(check_pages, total_pages=total_pages)
|
||||||
|
options.pages = check_pages
|
||||||
|
|
||||||
return get_pdfinfo(
|
return get_pdfinfo(
|
||||||
pdf_path,
|
pdf_path,
|
||||||
@@ -481,7 +489,7 @@ def postprocess(
|
|||||||
else:
|
else:
|
||||||
pdf_out = pdf_file
|
pdf_out = pdf_file
|
||||||
if context.options.output_type == 'auto':
|
if context.options.output_type == 'auto':
|
||||||
# Best effort PDF/A - never uses Ghostscript
|
# Best effort PDF/A - may use Ghostscript as a last resort
|
||||||
pdf_out, actual_type = try_auto_pdfa(pdf_out, context)
|
pdf_out, actual_type = try_auto_pdfa(pdf_out, context)
|
||||||
# Store actual output type for reporting
|
# Store actual output type for reporting
|
||||||
context.options.extra_attrs['_actual_output_type'] = actual_type
|
context.options.extra_attrs['_actual_output_type'] = actual_type
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st
|
|||||||
log.info("Postprocessing...")
|
log.info("Postprocessing...")
|
||||||
pdf, messages = postprocess(pdf, context, executor)
|
pdf, messages = postprocess(pdf, context, executor)
|
||||||
|
|
||||||
# Copy PDF file to destination (we don't know the input PDF file name)
|
# Copy PDF file to destination
|
||||||
copy_final(pdf, options.output_file, None)
|
copy_final(pdf, options.output_file)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +109,9 @@ def run_hocr_to_ocr_pdf_pipeline(
|
|||||||
plugin_manager: OcrmypdfPluginManager,
|
plugin_manager: OcrmypdfPluginManager,
|
||||||
) -> ExitCode:
|
) -> ExitCode:
|
||||||
"""Run pipeline to convert hOCR to final output PDF."""
|
"""Run pipeline to convert hOCR to final output PDF."""
|
||||||
|
# The _hocr_to_ocr_pdf() API requires work_folder: Path and stores it on
|
||||||
|
# options before this pipeline runs, so it is always set at this point.
|
||||||
|
assert options.work_folder is not None
|
||||||
with manage_work_folder(
|
with manage_work_folder(
|
||||||
work_folder=options.work_folder, retain=True, print_location=False
|
work_folder=options.work_folder, retain=True, print_location=False
|
||||||
) as work_folder:
|
) as work_folder:
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
|||||||
if options.sidecar:
|
if options.sidecar:
|
||||||
text = merge_sidecars(sidecars, context)
|
text = merge_sidecars(sidecars, context)
|
||||||
# Copy text file to destination
|
# Copy text file to destination
|
||||||
copy_final(text, options.sidecar, options.input_file)
|
copy_final(text, options.sidecar)
|
||||||
|
|
||||||
# Merge layers to one single pdf
|
# Merge layers to one single pdf
|
||||||
pdf = ocrgraft.finalize()
|
pdf = ocrgraft.finalize()
|
||||||
@@ -157,7 +157,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
|||||||
pdf, messages = postprocess(pdf, context, executor)
|
pdf, messages = postprocess(pdf, context, executor)
|
||||||
|
|
||||||
# Copy PDF file to destination
|
# Copy PDF file to destination
|
||||||
copy_final(pdf, options.output_file, options.input_file)
|
copy_final(pdf, options.output_file)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
@@ -91,6 +92,9 @@ def run_hocr_pipeline(
|
|||||||
"""Run pipeline to output hOCR."""
|
"""Run pipeline to output hOCR."""
|
||||||
if options.output_folder is None:
|
if options.output_folder is None:
|
||||||
raise ValueError("output_folder must be specified for hOCR pipeline")
|
raise ValueError("output_folder must be specified for hOCR pipeline")
|
||||||
|
# This pipeline is only reachable via the _pdf_to_hocr() API, which
|
||||||
|
# declares input_pdf: Path - streams and raw bytes paths are not supported.
|
||||||
|
assert isinstance(options.input_file, str | os.PathLike)
|
||||||
with manage_work_folder(
|
with manage_work_folder(
|
||||||
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:
|
||||||
@@ -100,9 +104,7 @@ def run_hocr_pipeline(
|
|||||||
|
|
||||||
# Gather pdfinfo and create context
|
# Gather pdfinfo and create context
|
||||||
pdfinfo = do_get_pdfinfo(origin_pdf, executor, options)
|
pdfinfo = do_get_pdfinfo(origin_pdf, executor, options)
|
||||||
context = PdfContext(
|
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
|
||||||
options, work_folder, options.input_file, pdfinfo, plugin_manager
|
|
||||||
)
|
|
||||||
# Validate options are okay for this pdf
|
# Validate options are okay for this pdf
|
||||||
validate_pdfinfo_options(context)
|
validate_pdfinfo_options(context)
|
||||||
exec_pdf_to_hocr(context, executor)
|
exec_pdf_to_hocr(context, executor)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from pydantic import BaseModel
|
|||||||
import ocrmypdf.builtin_plugins
|
import ocrmypdf.builtin_plugins
|
||||||
from ocrmypdf import Executor, PdfContext, pluginspec
|
from ocrmypdf import Executor, PdfContext, pluginspec
|
||||||
from ocrmypdf._options import OcrOptions
|
from ocrmypdf._options import OcrOptions
|
||||||
|
from ocrmypdf._plugin_registry import PluginOptionRegistry
|
||||||
from ocrmypdf._progressbar import ProgressBar
|
from ocrmypdf._progressbar import ProgressBar
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
from ocrmypdf.pluginspec import OcrEngine
|
from ocrmypdf.pluginspec import OcrEngine
|
||||||
@@ -53,10 +54,11 @@ class OcrmypdfPluginManager:
|
|||||||
self._plugins = plugins
|
self._plugins = plugins
|
||||||
self._builtins = builtins
|
self._builtins = builtins
|
||||||
self._pm = pluggy.PluginManager(*args, **kwargs)
|
self._pm = pluggy.PluginManager(*args, **kwargs)
|
||||||
|
self._option_registry: PluginOptionRegistry | None = None
|
||||||
self._setup_plugins()
|
self._setup_plugins()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pluggy(self) -> pluggy.PluginManager:
|
def pluggy_manager(self) -> pluggy.PluginManager:
|
||||||
"""Access the underlying pluggy.PluginManager for advanced use cases.
|
"""Access the underlying pluggy.PluginManager for advanced use cases.
|
||||||
|
|
||||||
This is useful for plugins that need to call methods like set_blocked()
|
This is useful for plugins that need to call methods like set_blocked()
|
||||||
@@ -74,7 +76,8 @@ class OcrmypdfPluginManager:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state):
|
||||||
self.__init__(
|
OcrmypdfPluginManager.__init__(
|
||||||
|
self,
|
||||||
*state['init_args'],
|
*state['init_args'],
|
||||||
plugins=state['plugins'],
|
plugins=state['plugins'],
|
||||||
builtins=state['builtins'],
|
builtins=state['builtins'],
|
||||||
@@ -86,10 +89,10 @@ class OcrmypdfPluginManager:
|
|||||||
|
|
||||||
# 1. Register builtins
|
# 1. Register builtins
|
||||||
if self._builtins:
|
if self._builtins:
|
||||||
for module in sorted(
|
for module_info in sorted(
|
||||||
pkgutil.iter_modules(ocrmypdf.builtin_plugins.__path__)
|
pkgutil.iter_modules(ocrmypdf.builtin_plugins.__path__)
|
||||||
):
|
):
|
||||||
name = f'ocrmypdf.builtin_plugins.{module.name}'
|
name = f'ocrmypdf.builtin_plugins.{module_info.name}'
|
||||||
module = importlib.import_module(name)
|
module = importlib.import_module(name)
|
||||||
self._pm.register(module)
|
self._pm.register(module)
|
||||||
|
|
||||||
@@ -97,17 +100,20 @@ class OcrmypdfPluginManager:
|
|||||||
self._pm.load_setuptools_entrypoints('ocrmypdf')
|
self._pm.load_setuptools_entrypoints('ocrmypdf')
|
||||||
|
|
||||||
# 3. Register plugins specified on command line
|
# 3. Register plugins specified on command line
|
||||||
for name in self._plugins:
|
for plugin in self._plugins:
|
||||||
if isinstance(name, Path) or name.endswith('.py'):
|
if isinstance(plugin, Path) or plugin.endswith('.py'):
|
||||||
# Import by filename
|
# Import by filename
|
||||||
module_name = Path(name).stem
|
plugin_path = Path(plugin)
|
||||||
spec = importlib.util.spec_from_file_location(module_name, name)
|
module_name = plugin_path.stem
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, plugin_path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f'Could not load plugin from {plugin_path}')
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
sys.modules[module_name] = module
|
sys.modules[module_name] = module
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
else:
|
else:
|
||||||
# Import by dotted module name
|
# Import by dotted module name
|
||||||
module = importlib.import_module(name)
|
module = importlib.import_module(plugin)
|
||||||
self._pm.register(module)
|
self._pm.register(module)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class PluginOptionRegistry:
|
|||||||
compatibility (e.g., options.tesseract_timeout).
|
compatibility (e.g., options.tesseract_timeout).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self._option_models: dict[str, type[BaseModel]] = {}
|
self._option_models: dict[str, type[BaseModel]] = {}
|
||||||
|
|
||||||
def register_option_model(
|
def register_option_model(
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Protect the real standard output from corruption by stray writes.
|
||||||
|
|
||||||
|
When OCRmyPDF writes its final PDF to standard output (``ocrmypdf in.pdf -``),
|
||||||
|
the bytes on stdout must be exactly the PDF and nothing else. Any accidental
|
||||||
|
write to file descriptor 1 anywhere in the process -- from a third-party
|
||||||
|
library, a plugin, or a stray ``print()`` -- would silently corrupt the output.
|
||||||
|
|
||||||
|
This module enforces that guarantee at the operating system level. It saves a
|
||||||
|
private duplicate of the real stdout and points file descriptor 1 at standard
|
||||||
|
error, so that anything that writes to stdout lands harmlessly on stderr. Only
|
||||||
|
OCRmyPDF's final "produce the PDF" step writes to the preserved real stdout, via
|
||||||
|
:func:`get_protected_stdout_fd`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_saved_fd: int | None = None
|
||||||
|
_active = False
|
||||||
|
|
||||||
|
|
||||||
|
def protect_stdout() -> bool:
|
||||||
|
"""Redirect file descriptor 1 to stderr and preserve the real stdout.
|
||||||
|
|
||||||
|
After this call, any write to file descriptor 1 -- including ``print()`` and
|
||||||
|
writes from third-party C libraries -- is redirected to standard error and
|
||||||
|
cannot corrupt the real standard output. The real stdout is preserved on a
|
||||||
|
private file descriptor available from :func:`get_protected_stdout_fd`.
|
||||||
|
|
||||||
|
This mutates process-global state and affects the whole process. It must be
|
||||||
|
called once, early, before any plugins are loaded or any worker
|
||||||
|
process/thread is started, so that all of them inherit the redirected
|
||||||
|
descriptor.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if protection was installed (or was already active). False if
|
||||||
|
stdout is not backed by a real OS file descriptor -- for example under
|
||||||
|
a test harness that captures stdout -- in which case nothing is changed.
|
||||||
|
"""
|
||||||
|
global _saved_fd, _active
|
||||||
|
with _lock:
|
||||||
|
if _active:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
fd1 = sys.stdout.fileno()
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
# stdout is not backed by a real file descriptor (e.g. captured by
|
||||||
|
# a test harness or replaced with an in-memory stream).
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
sys.stdout.flush()
|
||||||
|
saved = os.dup(fd1)
|
||||||
|
os.dup2(2, fd1) # point stdout at stderr
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
_saved_fd = saved
|
||||||
|
_active = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_protected_stdout_fd() -> int | None:
|
||||||
|
"""Return the preserved real stdout file descriptor, or None if inactive."""
|
||||||
|
return _saved_fd if _active else None
|
||||||
|
|
||||||
|
|
||||||
|
def protected_stdout_isatty() -> bool | None:
|
||||||
|
"""Whether the preserved real stdout is a terminal.
|
||||||
|
|
||||||
|
Returns None if protection is not active, in which case the caller should
|
||||||
|
fall back to ``sys.stdout.isatty()``. When protection is active,
|
||||||
|
``sys.stdout`` reports the terminal status of stderr (its descriptor was
|
||||||
|
redirected), so this consults the saved real-stdout descriptor instead.
|
||||||
|
"""
|
||||||
|
if not _active or _saved_fd is None:
|
||||||
|
return None
|
||||||
|
return os.isatty(_saved_fd)
|
||||||
+73
-12
@@ -10,15 +10,18 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from collections.abc import Set as AbstractSet
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copyfileobj
|
from shutil import copyfileobj
|
||||||
|
from typing import BinaryIO, cast
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
|
|
||||||
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||||
from ocrmypdf._exec import unpaper
|
from ocrmypdf._exec import unpaper
|
||||||
from ocrmypdf._options import OcrOptions
|
from ocrmypdf._options import OcrOptions, ProcessingMode
|
||||||
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
||||||
|
from ocrmypdf._stdoutprotect import protected_stdout_isatty
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
BadArgsError,
|
BadArgsError,
|
||||||
InputFileError,
|
InputFileError,
|
||||||
@@ -47,7 +50,7 @@ def check_platform() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def check_options_languages(
|
def check_options_languages(
|
||||||
options: OcrOptions, ocr_engine_languages: list[str]
|
options: OcrOptions, ocr_engine_languages: AbstractSet[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
# Check for blocked languages first, before checking if they're installed
|
# Check for blocked languages first, before checking if they're installed
|
||||||
DENIED_LANGUAGES = {'equ', 'osd'}
|
DENIED_LANGUAGES = {'equ', 'osd'}
|
||||||
@@ -91,7 +94,15 @@ def check_options_sidecar(options: OcrOptions) -> None:
|
|||||||
raise BadArgsError(
|
raise BadArgsError(
|
||||||
"--sidecar filename needed when output file is /dev/null or NUL."
|
"--sidecar filename needed when output file is /dev/null or NUL."
|
||||||
)
|
)
|
||||||
options.sidecar = options.output_file + '.txt'
|
elif not isinstance(options.output_file, str | Path):
|
||||||
|
# The '\0' sentinel is only ever set by the CLI, which always
|
||||||
|
# supplies output_file as a plain path - not a stream. If this
|
||||||
|
# somehow fires, the caller mixed a CLI-only sentinel with the
|
||||||
|
# stream-based API.
|
||||||
|
raise BadArgsError(
|
||||||
|
"--sidecar filename needed when output file is not a path."
|
||||||
|
)
|
||||||
|
options.sidecar = os.fspath(options.output_file) + '.txt'
|
||||||
if options.sidecar == options.input_file or options.sidecar == options.output_file:
|
if options.sidecar == options.input_file or options.sidecar == options.output_file:
|
||||||
raise BadArgsError(
|
raise BadArgsError(
|
||||||
"--sidecar file must be different from the input and output files"
|
"--sidecar file must be different from the input and output files"
|
||||||
@@ -118,8 +129,36 @@ def check_options_preprocessing(options: OcrOptions) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_options_strip(options: OcrOptions) -> None:
|
||||||
|
"""Reject options that cannot apply in strip mode.
|
||||||
|
|
||||||
|
``--mode strip`` removes the OCR text layer in place without rasterizing or
|
||||||
|
running OCR, so image-processing and OCR-output options have no effect.
|
||||||
|
"""
|
||||||
|
if options.mode != ProcessingMode.strip_text:
|
||||||
|
return
|
||||||
|
incompatible = {
|
||||||
|
'--deskew': options.deskew,
|
||||||
|
'--clean': options.clean,
|
||||||
|
'--clean-final': options.clean_final,
|
||||||
|
'--remove-background': options.remove_background,
|
||||||
|
'--rotate-pages': options.rotate_pages,
|
||||||
|
'--oversample': options.oversample,
|
||||||
|
'--remove-vectors': options.remove_vectors,
|
||||||
|
'--sidecar': options.sidecar,
|
||||||
|
}
|
||||||
|
used = sorted(name for name, value in incompatible.items() if value)
|
||||||
|
if used:
|
||||||
|
raise BadArgsError(
|
||||||
|
"--mode strip removes the OCR text layer without rasterizing or "
|
||||||
|
"running OCR, so these options have no effect and are not allowed: "
|
||||||
|
f"{', '.join(used)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _check_plugin_invariant_options(options: OcrOptions) -> None:
|
def _check_plugin_invariant_options(options: OcrOptions) -> None:
|
||||||
check_platform()
|
check_platform()
|
||||||
|
check_options_strip(options)
|
||||||
check_options_sidecar(options)
|
check_options_sidecar(options)
|
||||||
check_options_preprocessing(options)
|
check_options_preprocessing(options)
|
||||||
|
|
||||||
@@ -161,28 +200,32 @@ def create_input_file(options: OcrOptions, work_folder: Path) -> tuple[Path, str
|
|||||||
# stdin
|
# stdin
|
||||||
log.info('reading file from standard input')
|
log.info('reading file from standard input')
|
||||||
target = work_folder / 'stdin'
|
target = work_folder / 'stdin'
|
||||||
with open(target, 'wb') as stream_buffer:
|
with target.open('wb') as stream_buffer:
|
||||||
copyfileobj(sys.stdin.buffer, stream_buffer)
|
copyfileobj(sys.stdin.buffer, stream_buffer)
|
||||||
return target, "stdin"
|
return target, "stdin"
|
||||||
elif hasattr(options.input_file, 'readable'):
|
elif hasattr(options.input_file, 'readable'):
|
||||||
if not options.input_file.readable():
|
input_stream = cast(BinaryIO, options.input_file)
|
||||||
|
if not input_stream.readable():
|
||||||
raise InputFileError("Input file stream is not readable")
|
raise InputFileError("Input file stream is not readable")
|
||||||
log.info('reading file from input stream')
|
log.info('reading file from input stream')
|
||||||
target = work_folder / 'stream'
|
target = work_folder / 'stream'
|
||||||
with open(target, 'wb') as stream_buffer:
|
with target.open('wb') as stream_buffer:
|
||||||
copyfileobj(options.input_file, stream_buffer)
|
copyfileobj(input_stream, stream_buffer)
|
||||||
return target, "stream"
|
return target, "stream"
|
||||||
else:
|
else:
|
||||||
|
# The branches above already ruled out the stdin sentinel and
|
||||||
|
# stream-like objects, so this must be a filesystem path.
|
||||||
|
assert isinstance(options.input_file, str | bytes | os.PathLike)
|
||||||
try:
|
try:
|
||||||
target = work_folder / 'origin'
|
target = work_folder / 'origin'
|
||||||
safe_symlink(options.input_file, target)
|
safe_symlink(options.input_file, target)
|
||||||
return target, os.fspath(options.input_file)
|
return target, os.fsdecode(options.input_file)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
msg = f"File not found - {options.input_file}"
|
msg = f"File not found - {os.fsdecode(options.input_file)}"
|
||||||
if running_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 "
|
||||||
"permissions correctly.\n"
|
"permissions correctly.\n"
|
||||||
"You may find it easier to use stdin/stdout:"
|
"You may find it easier to use stdin/stdout:"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -203,7 +246,13 @@ def create_input_file(options: OcrOptions, work_folder: Path) -> tuple[Path, str
|
|||||||
|
|
||||||
def check_requested_output_file(options: OcrOptions) -> None:
|
def check_requested_output_file(options: OcrOptions) -> None:
|
||||||
if options.output_file == '-':
|
if options.output_file == '-':
|
||||||
if sys.stdout.isatty():
|
# When stdout protection is active, fd 1 has been redirected to stderr,
|
||||||
|
# so sys.stdout.isatty() would report stderr's status. Consult the
|
||||||
|
# preserved real stdout instead, falling back when protection is off.
|
||||||
|
is_tty = protected_stdout_isatty()
|
||||||
|
if is_tty is None:
|
||||||
|
is_tty = sys.stdout.isatty()
|
||||||
|
if is_tty:
|
||||||
raise BadArgsError(
|
raise BadArgsError(
|
||||||
"Output was set to stdout '-' but it looks like stdout "
|
"Output was set to stdout '-' but it looks like stdout "
|
||||||
"is connected to a terminal. Please redirect stdout to a "
|
"is connected to a terminal. Please redirect stdout to a "
|
||||||
@@ -214,7 +263,19 @@ def check_requested_output_file(options: OcrOptions) -> None:
|
|||||||
raise OutputFileAccessError("Output stream is not writable")
|
raise OutputFileAccessError("Output stream is not writable")
|
||||||
elif not is_file_writable(options.output_file):
|
elif not is_file_writable(options.output_file):
|
||||||
raise OutputFileAccessError(
|
raise OutputFileAccessError(
|
||||||
f"Output file location ({options.output_file}) is not a writable file."
|
f"Output file location ({os.fsdecode(options.output_file)}) is not a "
|
||||||
|
"writable file."
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.no_overwrite
|
||||||
|
and not hasattr(options.output_file, 'writable')
|
||||||
|
and options.output_file != '-'
|
||||||
|
and Path(str(options.output_file)).exists()
|
||||||
|
):
|
||||||
|
raise OutputFileAccessError(
|
||||||
|
f"Output file already exists: {os.fsdecode(options.output_file)}\n"
|
||||||
|
"To overwrite it, omit the --no-overwrite / -n option."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import os
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import pluggy
|
|
||||||
|
|
||||||
from ocrmypdf._options import OcrOptions
|
from ocrmypdf._options import OcrOptions
|
||||||
|
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,9 +19,8 @@ log = logging.getLogger(__name__)
|
|||||||
class ValidationCoordinator:
|
class ValidationCoordinator:
|
||||||
"""Coordinates validation across plugin models and core options."""
|
"""Coordinates validation across plugin models and core options."""
|
||||||
|
|
||||||
def __init__(self, plugin_manager: pluggy.PluginManager):
|
def __init__(self, plugin_manager: OcrmypdfPluginManager):
|
||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
self.registry = getattr(plugin_manager, '_option_registry', None)
|
|
||||||
|
|
||||||
def validate_all_options(self, options: OcrOptions) -> None:
|
def validate_all_options(self, options: OcrOptions) -> None:
|
||||||
"""Run comprehensive validation on all options.
|
"""Run comprehensive validation on all options.
|
||||||
@@ -110,13 +108,18 @@ class ValidationCoordinator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate output type compatibility
|
# Validate output type compatibility
|
||||||
if options.output_type == 'none' and str(options.output_file) not in (
|
output_file_display = (
|
||||||
|
os.fsdecode(options.output_file)
|
||||||
|
if isinstance(options.output_file, bytes)
|
||||||
|
else str(options.output_file)
|
||||||
|
)
|
||||||
|
if options.output_type == 'none' and output_file_display not in (
|
||||||
os.devnull,
|
os.devnull,
|
||||||
'-',
|
'-',
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Since you specified `--output-type none`, the output file "
|
"Since you specified `--output-type none`, the output file "
|
||||||
f"{options.output_file} cannot be produced. Set the output file to "
|
f"{output_file_display} cannot be produced. Set the output file to "
|
||||||
"`-` to suppress this message."
|
"`-` to suppress this message."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
__version__ = "17.3.0"
|
__version__ = "17.8.1"
|
||||||
|
|||||||
+39
-2
@@ -50,12 +50,15 @@ from pathlib import Path
|
|||||||
from typing import BinaryIO, overload
|
from typing import BinaryIO, overload
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ocrmypdf._logging import PageNumberFilter
|
from ocrmypdf._logging import PageNumberFilter
|
||||||
from ocrmypdf._options import OcrOptions
|
from ocrmypdf._options import OcrOptions
|
||||||
from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline
|
from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline
|
||||||
from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli
|
from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli
|
||||||
from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
|
from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
|
||||||
from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager
|
from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager
|
||||||
|
from ocrmypdf._stdoutprotect import protect_stdout
|
||||||
from ocrmypdf._validation import check_options
|
from ocrmypdf._validation import check_options
|
||||||
from ocrmypdf.cli import ArgumentParser, get_parser
|
from ocrmypdf.cli import ArgumentParser, get_parser
|
||||||
from ocrmypdf.exceptions import ExitCode
|
from ocrmypdf.exceptions import ExitCode
|
||||||
@@ -105,7 +108,7 @@ def setup_plugin_infrastructure(
|
|||||||
plugin_manager = get_plugin_manager(plugins)
|
plugin_manager = get_plugin_manager(plugins)
|
||||||
|
|
||||||
# Initialize plugins (pass the underlying pluggy manager)
|
# Initialize plugins (pass the underlying pluggy manager)
|
||||||
plugin_manager.initialize(plugin_manager=plugin_manager.pluggy)
|
plugin_manager.initialize(plugin_manager=plugin_manager.pluggy_manager)
|
||||||
|
|
||||||
# Initialize plugin option registry
|
# Initialize plugin option registry
|
||||||
from ocrmypdf._plugin_registry import PluginOptionRegistry
|
from ocrmypdf._plugin_registry import PluginOptionRegistry
|
||||||
@@ -114,7 +117,7 @@ def setup_plugin_infrastructure(
|
|||||||
|
|
||||||
# Let plugins register their option models
|
# Let plugins register their option models
|
||||||
option_models = plugin_manager.register_options()
|
option_models = plugin_manager.register_options()
|
||||||
all_plugin_models: dict[str, type] = {}
|
all_plugin_models: dict[str, type[BaseModel]] = {}
|
||||||
for plugin_options in option_models:
|
for plugin_options in option_models:
|
||||||
if plugin_options: # Skip None returns
|
if plugin_options: # Skip None returns
|
||||||
for namespace, model_class in plugin_options.items():
|
for namespace, model_class in plugin_options.items():
|
||||||
@@ -233,6 +236,37 @@ def configure_logging(
|
|||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
def configure_stdout_protection() -> bool:
|
||||||
|
"""Protect the process's real standard output from corruption.
|
||||||
|
|
||||||
|
When OCRmyPDF writes its final PDF to standard output (``output_file='-'``),
|
||||||
|
the bytes on stdout must be exactly the PDF and nothing else. By default
|
||||||
|
OCRmyPDF relies on no in-process code -- third party libraries, plugins, or
|
||||||
|
stray ``print()`` calls -- ever writing to stdout. This function makes that
|
||||||
|
guarantee real: it redirects file descriptor 1 to standard error and
|
||||||
|
preserves a private copy of the real stdout, so that any accidental write to
|
||||||
|
stdout lands harmlessly on stderr while OCRmyPDF still emits its final PDF to
|
||||||
|
the preserved descriptor.
|
||||||
|
|
||||||
|
This is the same protection the ``ocrmypdf`` command line program installs.
|
||||||
|
It is optional for API users and works like :func:`configure_logging`: call
|
||||||
|
it before :func:`ocr` if you want command-line-like behavior. It must be
|
||||||
|
called once, early -- before any plugins are loaded or any worker
|
||||||
|
process/thread is started -- so that they inherit the redirected descriptor.
|
||||||
|
|
||||||
|
Because it mutates process-global file descriptors and affects the entire
|
||||||
|
process, applications that manage their own standard output (for example,
|
||||||
|
a long-lived service that calls :func:`ocr` in-process) should **not** call
|
||||||
|
this function.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if protection was installed (or was already active). False if
|
||||||
|
stdout is not backed by a real operating system file descriptor, in
|
||||||
|
which case nothing is changed.
|
||||||
|
"""
|
||||||
|
return protect_stdout()
|
||||||
|
|
||||||
|
|
||||||
def _check_no_conflicting_ocr_params(
|
def _check_no_conflicting_ocr_params(
|
||||||
locals_dict: dict,
|
locals_dict: dict,
|
||||||
kwargs: dict,
|
kwargs: dict,
|
||||||
@@ -440,6 +474,7 @@ def ocr(
|
|||||||
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,
|
||||||
tagged_pdf_mode: str | None = None,
|
tagged_pdf_mode: str | None = None,
|
||||||
|
no_overwrite: bool | None = None,
|
||||||
plugins: Iterable[Path | str] | None = None,
|
plugins: Iterable[Path | str] | None = None,
|
||||||
plugin_manager: OcrmypdfPluginManager | None = None,
|
plugin_manager: OcrmypdfPluginManager | None = None,
|
||||||
keep_temporary_files: bool | None = None,
|
keep_temporary_files: bool | None = None,
|
||||||
@@ -502,6 +537,7 @@ def ocr( # 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,
|
||||||
tagged_pdf_mode: str | None = None,
|
tagged_pdf_mode: str | None = None,
|
||||||
|
no_overwrite: bool | None = None,
|
||||||
plugins: Iterable[Path | str] | None = None,
|
plugins: Iterable[Path | str] | None = None,
|
||||||
plugin_manager: OcrmypdfPluginManager | None = None,
|
plugin_manager: OcrmypdfPluginManager | None = None,
|
||||||
keep_temporary_files: bool | None = None,
|
keep_temporary_files: bool | None = None,
|
||||||
@@ -963,6 +999,7 @@ __all__ = [
|
|||||||
'Verbosity',
|
'Verbosity',
|
||||||
'check_options',
|
'check_options',
|
||||||
'configure_logging',
|
'configure_logging',
|
||||||
|
'configure_stdout_protection',
|
||||||
'create_options',
|
'create_options',
|
||||||
'get_parser',
|
'get_parser',
|
||||||
'get_plugin_manager',
|
'get_plugin_manager',
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ from ocrmypdf.exceptions import InputFileError
|
|||||||
from ocrmypdf.helpers import remove_all_log_handlers
|
from ocrmypdf.helpers import remove_all_log_handlers
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from logging import LogRecord
|
||||||
from typing import TypeAlias
|
from typing import TypeAlias
|
||||||
|
|
||||||
Queue: TypeAlias = multiprocessing.queues.Queue | queue.Queue
|
Queue: TypeAlias = (
|
||||||
|
multiprocessing.queues.Queue[LogRecord | None] | queue.Queue[LogRecord | None]
|
||||||
|
)
|
||||||
UserInit: TypeAlias = Callable[[], None]
|
UserInit: TypeAlias = Callable[[], None]
|
||||||
WorkerInit: TypeAlias = Callable[[Queue, UserInit, int], None]
|
WorkerInit: TypeAlias = Callable[[Queue, UserInit, int], None]
|
||||||
|
|
||||||
@@ -99,7 +102,9 @@ def thread_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def setup_executor(use_threads: bool) -> tuple[Queue, Executor, WorkerInit]:
|
def setup_executor(
|
||||||
|
use_threads: bool,
|
||||||
|
) -> tuple[Queue, FuturesExecutorClass, WorkerInit]:
|
||||||
if not use_threads:
|
if not use_threads:
|
||||||
# Some execution environments like AWS Lambda and Termux do not support
|
# Some execution environments like AWS Lambda and Termux do not support
|
||||||
# semaphores. Check if semaphore support is available, and if not, fall back
|
# semaphores. Check if semaphore support is available, and if not, fall back
|
||||||
@@ -112,6 +117,8 @@ def setup_executor(use_threads: bool) -> tuple[Queue, Executor, WorkerInit]:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
use_threads = True
|
use_threads = True
|
||||||
|
|
||||||
|
loq_queue: Queue
|
||||||
|
executor_class: FuturesExecutorClass
|
||||||
if use_threads:
|
if use_threads:
|
||||||
loq_queue = queue.Queue(-1)
|
loq_queue = queue.Queue(-1)
|
||||||
executor_class = ThreadPoolExecutor
|
executor_class = ThreadPoolExecutor
|
||||||
|
|||||||
@@ -44,6 +44,30 @@ class PdfaImageCompression(StrEnum):
|
|||||||
LOSSLESS = 'lossless'
|
LOSSLESS = 'lossless'
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_auto_compression(
|
||||||
|
compression: PdfaImageCompression, optimize_level: int
|
||||||
|
) -> PdfaImageCompression:
|
||||||
|
"""Resolve 'auto' image compression based on the optimization level.
|
||||||
|
|
||||||
|
At ``-O0`` (no optimization) ``auto`` maps to ``lossless`` so Ghostscript
|
||||||
|
will not transcode lossless images to JPEG during PDF/A generation. At all
|
||||||
|
other levels ``auto`` defers to Ghostscript's heuristic, which may
|
||||||
|
recompress images lossily.
|
||||||
|
|
||||||
|
``-O1`` is a historical exception: although it is otherwise a
|
||||||
|
lossless-only optimization level, coercing ``auto`` to ``lossless`` there
|
||||||
|
can bloat output substantially (Ghostscript's heuristic often picks JPEG
|
||||||
|
for photographic content), so the default is left alone for backwards
|
||||||
|
compatibility. Users who want guaranteed lossless image handling at any
|
||||||
|
level can pass ``--pdfa-image-compression=lossless`` explicitly.
|
||||||
|
|
||||||
|
Explicit ``jpeg`` and ``lossless`` choices are always respected.
|
||||||
|
"""
|
||||||
|
if compression == PdfaImageCompression.AUTO and optimize_level == 0:
|
||||||
|
return PdfaImageCompression.LOSSLESS
|
||||||
|
return compression
|
||||||
|
|
||||||
|
|
||||||
class GhostscriptOptions(BaseModel):
|
class GhostscriptOptions(BaseModel):
|
||||||
"""Options specific to Ghostscript operations."""
|
"""Options specific to Ghostscript operations."""
|
||||||
|
|
||||||
@@ -54,6 +78,27 @@ class GhostscriptOptions(BaseModel):
|
|||||||
pdfa_image_compression: Annotated[
|
pdfa_image_compression: Annotated[
|
||||||
PdfaImageCompression, Field(description="PDF/A image compression method")
|
PdfaImageCompression, Field(description="PDF/A image compression method")
|
||||||
] = PdfaImageCompression.AUTO
|
] = PdfaImageCompression.AUTO
|
||||||
|
jpeg_quality: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
ge=0,
|
||||||
|
le=100,
|
||||||
|
description=(
|
||||||
|
"JPEG quality (0-100) for Ghostscript image recompression during "
|
||||||
|
"PDF/A generation; None uses Ghostscript's default."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
jpeg_maxdpi: Annotated[
|
||||||
|
int | None,
|
||||||
|
Field(
|
||||||
|
ge=1,
|
||||||
|
description=(
|
||||||
|
"Maximum DPI for Ghostscript image downsampling during PDF/A "
|
||||||
|
"generation."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add_arguments_to_parser(cls, parser, namespace: str = 'ghostscript'):
|
def add_arguments_to_parser(cls, parser, namespace: str = 'ghostscript'):
|
||||||
@@ -78,14 +123,48 @@ class GhostscriptOptions(BaseModel):
|
|||||||
choices=[pc.value for pc in PdfaImageCompression],
|
choices=[pc.value for pc in PdfaImageCompression],
|
||||||
default=PdfaImageCompression.AUTO.value,
|
default=PdfaImageCompression.AUTO.value,
|
||||||
help="Specify how to compress images in the output PDF/A. 'auto' lets "
|
help="Specify how to compress images in the output PDF/A. 'auto' lets "
|
||||||
"OCRmyPDF decide. 'jpeg' changes all grayscale and color images to "
|
"OCRmyPDF decide: at -O0 it uses lossless image compression so "
|
||||||
|
"Ghostscript does not transcode lossless images to JPEG; at -O1 and "
|
||||||
|
"above it defers to Ghostscript's heuristic, which may recompress "
|
||||||
|
"images lossily. 'jpeg' changes all grayscale and color images to "
|
||||||
"JPEG compression. 'lossless' uses PNG-style lossless compression "
|
"JPEG compression. 'lossless' uses PNG-style lossless compression "
|
||||||
"for all images. Monochrome images are always compressed using a "
|
"for non-JPEG images and passes existing JPEGs through unchanged "
|
||||||
|
"(re-encoding them losslessly would only inflate them). Monochrome "
|
||||||
|
"images are always compressed using a "
|
||||||
"lossless codec. Compression settings "
|
"lossless codec. Compression settings "
|
||||||
"are applied to all pages, including those for which OCR was "
|
"are applied to all pages, including those for which OCR was "
|
||||||
"skipped. Not supported for --output-type=pdf ; that setting "
|
"skipped. Not supported for --output-type=pdf ; that setting "
|
||||||
"preserves the original compression of all images.",
|
"preserves the original compression of all images.",
|
||||||
)
|
)
|
||||||
|
gs.add_argument(
|
||||||
|
'--ghostscript-jpeg-quality',
|
||||||
|
type=int,
|
||||||
|
metavar='Q',
|
||||||
|
default=None,
|
||||||
|
dest=f'{namespace}_jpeg_quality',
|
||||||
|
help=(
|
||||||
|
"Advanced: Set Ghostscript's -dJPEGQ for images that Ghostscript "
|
||||||
|
"transcodes to JPEG during PDF/A generation. 0 is maximum "
|
||||||
|
"compression; 100 is best quality. If omitted, Ghostscript's "
|
||||||
|
"default is used. This only affects images Ghostscript chooses "
|
||||||
|
"to recompress; for general JPEG quality tuning prefer "
|
||||||
|
"--jpeg-quality, which is applied by the OCRmyPDF optimizer."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
gs.add_argument(
|
||||||
|
'--ghostscript-jpeg-maxdpi',
|
||||||
|
type=int,
|
||||||
|
metavar='DPI',
|
||||||
|
default=None,
|
||||||
|
dest=f'{namespace}_jpeg_maxdpi',
|
||||||
|
help=(
|
||||||
|
"Advanced: Force Ghostscript to downsample color, grayscale, "
|
||||||
|
"and monochrome images in PDF/A output to the given maximum DPI. "
|
||||||
|
"Reducing JPEG quality usually gives better results than "
|
||||||
|
"downsampling at the same file size, and can degrade quality "
|
||||||
|
"of high-resolution monochrome masks."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
@@ -131,10 +210,10 @@ def check_options(options):
|
|||||||
)
|
)
|
||||||
if gs_version >= Version('10.6.0'):
|
if gs_version >= Version('10.6.0'):
|
||||||
log.warning(
|
log.warning(
|
||||||
"Ghostscript 10.6.x contains JPEG encoding errors that may corrupt "
|
"Ghostscript %s contains JPEG encoding errors that may corrupt "
|
||||||
"images. OCRmyPDF will attempt to mitigate, but this version is "
|
"images. OCRmyPDF will attempt to mitigate, but versions 10.6.0+ "
|
||||||
"strongly not recommended. Please upgrade to a newer version. "
|
"are strongly not recommended until this is fixed upstream.",
|
||||||
"As of 2025-12, 10.6.0 is the latest version of Ghostscript."
|
gs_version,
|
||||||
)
|
)
|
||||||
if options.output_type == 'pdfa':
|
if options.output_type == 'pdfa':
|
||||||
options.output_type = 'pdfa-2'
|
options.output_type = 'pdfa-2'
|
||||||
@@ -177,6 +256,8 @@ def rasterize_pdf_page(
|
|||||||
# Let pypdfium handle it (it will error in check_options if unavailable)
|
# Let pypdfium handle it (it will error in check_options if unavailable)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
log.debug("Rasterizing page %d with the Ghostscript rasterizer", pageno)
|
||||||
|
|
||||||
ghostscript.rasterize_pdf(
|
ghostscript.rasterize_pdf(
|
||||||
input_file,
|
input_file,
|
||||||
output_file,
|
output_file,
|
||||||
@@ -347,11 +428,18 @@ def generate_pdfa(
|
|||||||
if output_type == 'pdfa':
|
if output_type == 'pdfa':
|
||||||
output_type = 'pdfa-2'
|
output_type = 'pdfa-2'
|
||||||
|
|
||||||
|
compression = _resolve_auto_compression(
|
||||||
|
context.options.ghostscript.pdfa_image_compression,
|
||||||
|
context.options.optimize,
|
||||||
|
)
|
||||||
|
|
||||||
ghostscript.generate_pdfa(
|
ghostscript.generate_pdfa(
|
||||||
pdf_pages=[pdfmark, *pdf_pages],
|
pdf_pages=[pdfmark, *pdf_pages],
|
||||||
output_file=output_file,
|
output_file=output_file,
|
||||||
compression=context.options.ghostscript.pdfa_image_compression,
|
compression=compression,
|
||||||
color_conversion_strategy=context.options.ghostscript.color_conversion_strategy,
|
color_conversion_strategy=context.options.ghostscript.color_conversion_strategy,
|
||||||
|
jpeg_quality=context.options.ghostscript.jpeg_quality,
|
||||||
|
jpeg_maxdpi=context.options.ghostscript.jpeg_maxdpi,
|
||||||
pdf_version=pdf_version,
|
pdf_version=pdf_version,
|
||||||
pdfa_part=pdfa_part,
|
pdfa_part=pdfa_part,
|
||||||
progressbar_class=progressbar_class,
|
progressbar_class=progressbar_class,
|
||||||
|
|||||||
@@ -48,27 +48,18 @@ def _open_pdf_document(input_file: Path):
|
|||||||
return pdfium.PdfDocument(input_file)
|
return pdfium.PdfDocument(input_file)
|
||||||
|
|
||||||
|
|
||||||
def _calculate_mediabox_crop(page) -> tuple[float, float, float, float]:
|
def _expand_cropbox_to_mediabox(page) -> None:
|
||||||
"""Calculate crop values to expand rendering from CropBox to MediaBox.
|
"""Set the page's CropBox to its MediaBox so PDFium renders the full page.
|
||||||
|
|
||||||
By default pypdfium2 renders to the CropBox. To render the full MediaBox,
|
PDFium renders to the CropBox by default. Negative ``crop`` values to
|
||||||
we need negative crop values to expand the rendering area.
|
``render()`` are not supported and only pad the output canvas without
|
||||||
|
expanding the rendered area — content outside the CropBox is clipped.
|
||||||
Returns:
|
The supported approach is to widen the CropBox in memory before rendering.
|
||||||
Tuple of (left, bottom, right, top) crop values. Negative values
|
The document is never saved back to disk, so this mutation is local.
|
||||||
expand the rendering area beyond the CropBox to the MediaBox.
|
See https://github.com/ocrmypdf/OCRmyPDF/issues/1685.
|
||||||
"""
|
"""
|
||||||
mediabox = page.get_mediabox() # (left, bottom, right, top)
|
mediabox = page.get_mediabox() # (left, bottom, right, top)
|
||||||
cropbox = page.get_cropbox() # (left, bottom, right, top), defaults to mediabox
|
page.set_cropbox(*mediabox)
|
||||||
|
|
||||||
# Calculate how much to expand from cropbox to mediabox
|
|
||||||
# Negative values = expand, positive = shrink
|
|
||||||
return (
|
|
||||||
mediabox[0] - cropbox[0], # Expand left
|
|
||||||
mediabox[1] - cropbox[1], # Expand bottom
|
|
||||||
cropbox[2] - mediabox[2], # Expand right
|
|
||||||
cropbox[3] - mediabox[3], # Expand top
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_page_to_bitmap(
|
def _render_page_to_bitmap(
|
||||||
@@ -105,16 +96,20 @@ def _render_page_to_bitmap(
|
|||||||
# Render the page to a bitmap
|
# Render the page to a bitmap
|
||||||
# The scale parameter controls the resolution
|
# The scale parameter controls the resolution
|
||||||
# Render in grayscale for mono and gray devices (better input for 1-bit conversion)
|
# Render in grayscale for mono and gray devices (better input for 1-bit conversion)
|
||||||
grayscale = raster_device.lower() in ('pngmono', 'pnggray', 'jpeggray')
|
grayscale = raster_device.lower() in (
|
||||||
|
'pngmono',
|
||||||
|
'pngmonod',
|
||||||
|
'pnggray',
|
||||||
|
'jpeggray',
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate crop to render the appropriate box
|
|
||||||
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
|
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
|
||||||
crop = (0, 0, 0, 0) if use_cropbox else _calculate_mediabox_crop(page)
|
if not use_cropbox:
|
||||||
|
_expand_cropbox_to_mediabox(page)
|
||||||
|
|
||||||
bitmap = page.render(
|
bitmap = page.render(
|
||||||
scale=scale,
|
scale=scale,
|
||||||
rotation=0, # We already set rotation on the page
|
rotation=0, # We already set rotation on the page
|
||||||
crop=crop,
|
|
||||||
may_draw_forms=True,
|
may_draw_forms=True,
|
||||||
draw_annots=True,
|
draw_annots=True,
|
||||||
grayscale=grayscale,
|
grayscale=grayscale,
|
||||||
@@ -167,8 +162,8 @@ def _process_image_for_output(
|
|||||||
# This ensures pypdfium output matches Ghostscript's native device output
|
# This ensures pypdfium output matches Ghostscript's native device output
|
||||||
raster_device_lower = raster_device.lower()
|
raster_device_lower = raster_device.lower()
|
||||||
|
|
||||||
if raster_device_lower == 'pngmono':
|
if raster_device_lower in ('pngmono', 'pngmonod'):
|
||||||
# Convert to 1-bit black and white (matches Ghostscript pngmono device)
|
# Convert to 1-bit black and white (matches Ghostscript pngmono/pngmonod)
|
||||||
if pil_image.mode != '1':
|
if pil_image.mode != '1':
|
||||||
if pil_image.mode not in ('L', '1'):
|
if pil_image.mode not in ('L', '1'):
|
||||||
pil_image = pil_image.convert('L')
|
pil_image = pil_image.convert('L')
|
||||||
@@ -194,7 +189,16 @@ def _process_image_for_output(
|
|||||||
# pngalpha: keep RGBA as-is
|
# pngalpha: keep RGBA as-is
|
||||||
|
|
||||||
# Determine output format based on raster_device
|
# Determine output format based on raster_device
|
||||||
png_devices = ('png', 'pngmono', 'pnggray', 'png256', 'png16m', 'pngalpha')
|
png_devices = (
|
||||||
|
'png',
|
||||||
|
'pngmono',
|
||||||
|
'pngmonod',
|
||||||
|
'pnggray',
|
||||||
|
'png256',
|
||||||
|
'png16m',
|
||||||
|
'pngalpha',
|
||||||
|
)
|
||||||
|
format_name: Literal['PNG', 'TIFF', 'JPEG']
|
||||||
if raster_device_lower in png_devices:
|
if raster_device_lower in png_devices:
|
||||||
format_name = 'PNG'
|
format_name = 'PNG'
|
||||||
elif raster_device_lower in ('jpeg', 'jpeggray', 'jpg'):
|
elif raster_device_lower in ('jpeg', 'jpeggray', 'jpg'):
|
||||||
@@ -252,6 +256,8 @@ def rasterize_pdf_page(
|
|||||||
if pdfium is None:
|
if pdfium is None:
|
||||||
return None # Fall back to Ghostscript
|
return None # Fall back to Ghostscript
|
||||||
|
|
||||||
|
log.debug("Rasterizing page %d with the pypdfium2 rasterizer", pageno)
|
||||||
|
|
||||||
# Acquire lock to ensure thread-safe access to pypdfium2
|
# Acquire lock to ensure thread-safe access to pypdfium2
|
||||||
with (
|
with (
|
||||||
_pdfium_lock,
|
_pdfium_lock,
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class TesseractOptions(BaseModel):
|
|||||||
metavar='PSM',
|
metavar='PSM',
|
||||||
choices=range(0, 14),
|
choices=range(0, 14),
|
||||||
dest=f'{namespace}_pagesegmode',
|
dest=f'{namespace}_pagesegmode',
|
||||||
help="Set Tesseract page segmentation mode (see tesseract --help).",
|
help="Set Tesseract page segmentation mode (see tesseract --help-extra).",
|
||||||
)
|
)
|
||||||
|
|
||||||
tess.add_argument(
|
tess.add_argument(
|
||||||
@@ -168,7 +168,7 @@ class TesseractOptions(BaseModel):
|
|||||||
tess.add_argument(
|
tess.add_argument(
|
||||||
f'--{namespace}-timeout',
|
f'--{namespace}-timeout',
|
||||||
default=180.0,
|
default=180.0,
|
||||||
type=numeric(float, 0),
|
type=numeric(float, 0.0),
|
||||||
metavar='SECONDS',
|
metavar='SECONDS',
|
||||||
dest=f'{namespace}_timeout',
|
dest=f'{namespace}_timeout',
|
||||||
help=(
|
help=(
|
||||||
@@ -183,7 +183,7 @@ class TesseractOptions(BaseModel):
|
|||||||
tess.add_argument(
|
tess.add_argument(
|
||||||
f'--{namespace}-non-ocr-timeout',
|
f'--{namespace}-non-ocr-timeout',
|
||||||
default=180.0,
|
default=180.0,
|
||||||
type=numeric(float, 0),
|
type=numeric(float, 0.0),
|
||||||
metavar='SECONDS',
|
metavar='SECONDS',
|
||||||
dest=f'{namespace}_non_ocr_timeout',
|
dest=f'{namespace}_non_ocr_timeout',
|
||||||
help=(
|
help=(
|
||||||
|
|||||||
+30
-11
@@ -137,8 +137,9 @@ Online documentation is located at:
|
|||||||
'output_file',
|
'output_file',
|
||||||
metavar="output_pdf",
|
metavar="output_pdf",
|
||||||
help="Output searchable PDF file (or '-' to write to standard output). "
|
help="Output searchable PDF file (or '-' to write to standard output). "
|
||||||
"Existing files will be overwritten. If same as input file, the "
|
"Existing files will be overwritten (use --no-overwrite to prevent this). "
|
||||||
"input file will be updated only if processing is successful.",
|
"If same as input file, the input file will be updated only if "
|
||||||
|
"processing is successful.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'-l',
|
'-l',
|
||||||
@@ -190,6 +191,15 @@ Online documentation is located at:
|
|||||||
"may not both use stdout at the same time.",
|
"may not both use stdout at the same time.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-n',
|
||||||
|
'--no-overwrite',
|
||||||
|
action='store_true',
|
||||||
|
default=False,
|
||||||
|
help="If the output file already exists, exit with an error instead of "
|
||||||
|
"overwriting it.",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--version',
|
'--version',
|
||||||
action='version',
|
action='version',
|
||||||
@@ -317,7 +327,11 @@ Online documentation is located at:
|
|||||||
"'default' errors if text is found. "
|
"'default' errors if text is found. "
|
||||||
"'force' rasterizes all content and runs OCR (same as --force-ocr). "
|
"'force' rasterizes all content and runs OCR (same as --force-ocr). "
|
||||||
"'skip' skips pages with existing text (same as --skip-text). "
|
"'skip' skips pages with existing text (same as --skip-text). "
|
||||||
"'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr).",
|
"'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr). "
|
||||||
|
"'strip' removes the invisible OCR text layer without rasterizing or "
|
||||||
|
"running OCR, producing a smaller file; only text drawn as invisible "
|
||||||
|
"(render mode 3) is removed, so text from some OCR engines cannot be "
|
||||||
|
"removed this way.",
|
||||||
)
|
)
|
||||||
# Legacy flags for backward compatibility - these set the mode internally
|
# Legacy flags for backward compatibility - these set the mode internally
|
||||||
ocrsettings.add_argument(
|
ocrsettings.add_argument(
|
||||||
@@ -348,7 +362,7 @@ Online documentation is located at:
|
|||||||
)
|
)
|
||||||
ocrsettings.add_argument(
|
ocrsettings.add_argument(
|
||||||
'--skip-big',
|
'--skip-big',
|
||||||
type=numeric(float, 0, 5000),
|
type=numeric(float, 0.0, 5000.0),
|
||||||
metavar='MPixels',
|
metavar='MPixels',
|
||||||
help="Skip OCR on pages larger than the specified amount of megapixels, "
|
help="Skip OCR on pages larger than the specified amount of megapixels, "
|
||||||
"but include skipped pages in final output",
|
"but include skipped pages in final output",
|
||||||
@@ -377,13 +391,14 @@ Online documentation is located at:
|
|||||||
type=str,
|
type=str,
|
||||||
help=(
|
help=(
|
||||||
"Limit OCR to the specified pages (ranges or comma separated), "
|
"Limit OCR to the specified pages (ranges or comma separated), "
|
||||||
"skipping others"
|
"skipping others. The token 'end' is an alias for the last page, "
|
||||||
|
"so e.g. '3-end' OCRs from page 3 to the last page."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
advanced.add_argument(
|
advanced.add_argument(
|
||||||
'--max-image-mpixels',
|
'--max-image-mpixels',
|
||||||
action='store',
|
action='store',
|
||||||
type=numeric(float, 0),
|
type=numeric(float, 0.0),
|
||||||
metavar='MPixels',
|
metavar='MPixels',
|
||||||
help="Set maximum number of megapixels to unpack before treating an image as a "
|
help="Set maximum number of megapixels to unpack before treating an image as a "
|
||||||
"decompression bomb",
|
"decompression bomb",
|
||||||
@@ -412,21 +427,25 @@ Online documentation is located at:
|
|||||||
'--rasterizer',
|
'--rasterizer',
|
||||||
choices=['auto', 'ghostscript', 'pypdfium'],
|
choices=['auto', 'ghostscript', 'pypdfium'],
|
||||||
default='auto',
|
default='auto',
|
||||||
help="Choose PDF page rasterizer. 'auto' prefers pypdfium when available, "
|
help="Choose PDF page rasterizer. 'auto' (the default) prefers pypdfium2 "
|
||||||
"falling back to Ghostscript. 'pypdfium' is faster but requires the "
|
"when the pypdfium2 package is installed, falling back to Ghostscript "
|
||||||
"pypdfium2 package. 'ghostscript' uses the traditional Ghostscript rasterizer.",
|
"otherwise. pypdfium2 anti-aliases page content and generally produces "
|
||||||
|
"better input for OCR than Ghostscript 10.x, which can render aliased "
|
||||||
|
"glyphs that OCR misreads as extra word breaks. 'pypdfium' forces the "
|
||||||
|
"pypdfium2 rasterizer (requires the pypdfium2 package); 'ghostscript' "
|
||||||
|
"forces the traditional Ghostscript rasterizer.",
|
||||||
)
|
)
|
||||||
advanced.add_argument(
|
advanced.add_argument(
|
||||||
'--rotate-pages-threshold',
|
'--rotate-pages-threshold',
|
||||||
default=DEFAULT_ROTATE_PAGES_THRESHOLD,
|
default=DEFAULT_ROTATE_PAGES_THRESHOLD,
|
||||||
type=numeric(float, 0, 1000),
|
type=numeric(float, 0.0, 1000.0),
|
||||||
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 "
|
||||||
"units reported by tesseract)",
|
"units reported by tesseract)",
|
||||||
)
|
)
|
||||||
advanced.add_argument(
|
advanced.add_argument(
|
||||||
'--fast-web-view',
|
'--fast-web-view',
|
||||||
type=numeric(float, 0),
|
type=numeric(float, 0.0),
|
||||||
default=1.0,
|
default=1.0,
|
||||||
metavar="MEGABYTES",
|
metavar="MEGABYTES",
|
||||||
help="If the size of file is more than this threshold (in MB), then "
|
help="If the size of file is more than this threshold (in MB), then "
|
||||||
|
|||||||
+70
-10
@@ -139,14 +139,74 @@ class TaggedPDFError(InputFileError):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ColorConversionNeededError(BadArgsError):
|
class NonEmbeddedFontsError(InputFileError):
|
||||||
"""PDF needs color conversion."""
|
"""Input has non-embedded CID fonts that PDF/A conversion would corrupt.
|
||||||
|
|
||||||
message = dedent(
|
PDF/A requires all fonts to be embedded. Ghostscript substitutes and embeds
|
||||||
"""\
|
a replacement for non-embedded CID (CJK) fonts, which corrupts the
|
||||||
The input PDF has an unusual color space. Use
|
character-to-Unicode mapping and silently destroys an existing text layer
|
||||||
--color-conversion-strategy to convert to a common color space
|
(commonly an Adobe Acrobat CJK OCR layer). OCRmyPDF refuses to produce such
|
||||||
such as RGB, or use --output-type pdf to skip PDF/A conversion
|
output rather than damage the user's data
|
||||||
and retain the original color space.
|
(see https://github.com/ocrmypdf/OCRmyPDF/issues/1561).
|
||||||
"""
|
"""
|
||||||
)
|
|
||||||
|
def __init__(self, fonts: set[str]):
|
||||||
|
"""Build guidance naming the offending fonts."""
|
||||||
|
super().__init__()
|
||||||
|
font_list = ', '.join(sorted(fonts))
|
||||||
|
self.message = dedent(
|
||||||
|
f"""\
|
||||||
|
The input PDF contains non-embedded CID (character ID) fonts: {font_list}.
|
||||||
|
|
||||||
|
PDF/A requires all fonts to be embedded. Converting to PDF/A would
|
||||||
|
make Ghostscript substitute and embed replacement fonts, which
|
||||||
|
corrupts CID (e.g. CJK/Chinese-Japanese-Korean) text and silently
|
||||||
|
destroys an existing text layer such as one produced by Adobe Acrobat.
|
||||||
|
|
||||||
|
Use --output-type pdf to keep the existing text layer intact without
|
||||||
|
PDF/A conversion, or --force-ocr to discard the existing layer and
|
||||||
|
rebuild it with embedded fonts.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ColorConversionNeededError(BadArgsError):
|
||||||
|
"""PDF needs color conversion to a standard color space.
|
||||||
|
|
||||||
|
Ghostscript reported a DeviceN colorspace with an inappropriate alternate.
|
||||||
|
The resulting PDF/A is liable to render incorrectly (often blank) in some
|
||||||
|
viewers such as Adobe Reader, so the colorspace must be normalized to a
|
||||||
|
common one. RGB, CMYK, and Gray are known to work; LeaveColorUnchanged
|
||||||
|
performs no conversion and UseDeviceIndependentColor does not resolve the
|
||||||
|
problem (see https://github.com/ocrmypdf/OCRmyPDF/issues/1187).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Strategies that can normalize an unusual DeviceN colorspace into one that
|
||||||
|
# PDF/A viewers render correctly.
|
||||||
|
_effective_strategies = "RGB, CMYK, or Gray"
|
||||||
|
|
||||||
|
def __init__(self, color_conversion_strategy: str = "LeaveColorUnchanged"):
|
||||||
|
"""Build guidance tailored to the conversion strategy that was used."""
|
||||||
|
super().__init__()
|
||||||
|
if color_conversion_strategy == "LeaveColorUnchanged":
|
||||||
|
self.message = dedent(
|
||||||
|
f"""\
|
||||||
|
The input PDF has an unusual DeviceN color space that cannot be
|
||||||
|
represented in PDF/A; the output may appear blank in some viewers
|
||||||
|
such as Adobe Reader. Convert it to a common color space with
|
||||||
|
--color-conversion-strategy ({self._effective_strategies}), or use
|
||||||
|
--output-type pdf to skip PDF/A conversion and retain the original
|
||||||
|
color space.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.message = dedent(
|
||||||
|
f"""\
|
||||||
|
Color conversion with --color-conversion-strategy
|
||||||
|
{color_conversion_strategy} did not resolve the input PDF's unusual
|
||||||
|
DeviceN color space; the output may appear blank in some viewers
|
||||||
|
such as Adobe Reader. Try a different --color-conversion-strategy
|
||||||
|
({self._effective_strategies}), or use --output-type pdf to skip
|
||||||
|
PDF/A conversion and retain the original color space.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ This module provides font infrastructure for the fpdf2 PDF renderer. It includes
|
|||||||
- MultiFontManager: Automatic font selection for multilingual documents
|
- MultiFontManager: Automatic font selection for multilingual documents
|
||||||
- SystemFontProvider: System font discovery
|
- SystemFontProvider: System font discovery
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ocrmypdf.font.font_manager import FontManager
|
from ocrmypdf.font.font_manager import FontManager
|
||||||
|
|||||||
@@ -54,13 +54,15 @@ class MultiFontManager:
|
|||||||
'kok': 'NotoSansDevanagari-Regular', # Konkani
|
'kok': 'NotoSansDevanagari-Regular', # Konkani
|
||||||
'bho': 'NotoSansDevanagari-Regular', # Bhojpuri
|
'bho': 'NotoSansDevanagari-Regular', # Bhojpuri
|
||||||
'mai': 'NotoSansDevanagari-Regular', # Maithili
|
'mai': 'NotoSansDevanagari-Regular', # Maithili
|
||||||
# CJK
|
# CJK — prefer the family matching the document language, because the
|
||||||
'chi': 'NotoSansCJK-Regular', # Chinese (generic)
|
# modern per-language Noto fonts are region subsets (e.g. NotoSansSC
|
||||||
'zho': 'NotoSansCJK-Regular', # Chinese (ISO 639-3)
|
# lacks Japanese kana). The pan-CJK super font is a shared fallback.
|
||||||
'chi_sim': 'NotoSansCJK-Regular', # Chinese Simplified (Tesseract)
|
'chi': 'NotoSansSC-Regular', # Chinese (generic → Simplified)
|
||||||
'chi_tra': 'NotoSansCJK-Regular', # Chinese Traditional (Tesseract)
|
'zho': 'NotoSansSC-Regular', # Chinese (ISO 639-3)
|
||||||
'jpn': 'NotoSansCJK-Regular', # Japanese
|
'chi_sim': 'NotoSansSC-Regular', # Chinese Simplified (Tesseract)
|
||||||
'kor': 'NotoSansCJK-Regular', # Korean
|
'chi_tra': 'NotoSansTC-Regular', # Chinese Traditional (Tesseract)
|
||||||
|
'jpn': 'NotoSansJP-Regular', # Japanese
|
||||||
|
'kor': 'NotoSansKR-Regular', # Korean
|
||||||
# Thai
|
# Thai
|
||||||
'tha': 'NotoSansThai-Regular', # Thai
|
'tha': 'NotoSansThai-Regular', # Thai
|
||||||
# Hebrew
|
# Hebrew
|
||||||
@@ -113,7 +115,14 @@ class MultiFontManager:
|
|||||||
'NotoSans-Regular', # Latin, Greek, Cyrillic
|
'NotoSans-Regular', # Latin, Greek, Cyrillic
|
||||||
'NotoSansArabic-Regular',
|
'NotoSansArabic-Regular',
|
||||||
'NotoSansDevanagari-Regular',
|
'NotoSansDevanagari-Regular',
|
||||||
|
# Pan-CJK super font first (full coverage), then the per-language
|
||||||
|
# subsets so a glyph missing from one CJK family is found in another.
|
||||||
'NotoSansCJK-Regular',
|
'NotoSansCJK-Regular',
|
||||||
|
'NotoSansSC-Regular',
|
||||||
|
'NotoSansTC-Regular',
|
||||||
|
'NotoSansHK-Regular',
|
||||||
|
'NotoSansJP-Regular',
|
||||||
|
'NotoSansKR-Regular',
|
||||||
'NotoSansThai-Regular',
|
'NotoSansThai-Regular',
|
||||||
'NotoSansHebrew-Regular',
|
'NotoSansHebrew-Regular',
|
||||||
'NotoSansBengali-Regular',
|
'NotoSansBengali-Regular',
|
||||||
@@ -256,19 +265,23 @@ class MultiFontManager:
|
|||||||
self._warned_scripts.add(warn_key)
|
self._warned_scripts.add(warn_key)
|
||||||
|
|
||||||
if line_language and line_language in self.LANGUAGE_FONT_MAP:
|
if line_language and line_language in self.LANGUAGE_FONT_MAP:
|
||||||
font_name = self.LANGUAGE_FONT_MAP[line_language]
|
font_family = self.LANGUAGE_FONT_MAP[line_language].removesuffix('-Regular')
|
||||||
log.warning(
|
log.warning(
|
||||||
"No font found with glyphs for '%s' text. "
|
"No installed font has glyphs for the detected '%s' text, so "
|
||||||
"Install %s for better rendering. "
|
"it was added as an invisible text layer: it stays searchable "
|
||||||
"See https://fonts.google.com/noto",
|
"and copyable, but appears blank when highlighted in a PDF "
|
||||||
|
"viewer. Install the %s font family (via your OS package "
|
||||||
|
"manager or https://fonts.google.com/noto) for full rendering.",
|
||||||
line_language,
|
line_language,
|
||||||
font_name,
|
font_family,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.warning(
|
log.warning(
|
||||||
"No font found with glyphs for some text. "
|
"No installed font has glyphs for some of the detected text, "
|
||||||
"Install Noto fonts for better rendering. "
|
"so it was added as an invisible text layer: it stays "
|
||||||
"See https://fonts.google.com/noto"
|
"searchable and copyable, but appears blank when highlighted "
|
||||||
|
"in a PDF viewer. Install the matching Noto fonts "
|
||||||
|
"(https://fonts.google.com/noto) for full rendering."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _has_all_glyphs(self, font: FontManager, text: str) -> bool:
|
def _has_all_glyphs(self, font: FontManager, text: str) -> bool:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Linux, macOS, and Windows platforms.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -75,6 +76,35 @@ class SystemFontProvider:
|
|||||||
# Variable fonts
|
# Variable fonts
|
||||||
'NotoSansCJKsc-VF.otf',
|
'NotoSansCJKsc-VF.otf',
|
||||||
],
|
],
|
||||||
|
# Per-language CJK families. Modern Google Fonts / Homebrew ship these
|
||||||
|
# as region subset variable fonts ('NotoSansJP[wght].ttf'), matched by
|
||||||
|
# the flexible base search; the legacy per-region super OTFs (full
|
||||||
|
# coverage) are listed here so they also satisfy the logical name.
|
||||||
|
'NotoSansSC-Regular': [
|
||||||
|
'NotoSansSC-Regular.otf',
|
||||||
|
'NotoSansSC-Regular.ttf',
|
||||||
|
'NotoSansCJKsc-Regular.otf',
|
||||||
|
],
|
||||||
|
'NotoSansTC-Regular': [
|
||||||
|
'NotoSansTC-Regular.otf',
|
||||||
|
'NotoSansTC-Regular.ttf',
|
||||||
|
'NotoSansCJKtc-Regular.otf',
|
||||||
|
],
|
||||||
|
'NotoSansHK-Regular': [
|
||||||
|
'NotoSansHK-Regular.otf',
|
||||||
|
'NotoSansHK-Regular.ttf',
|
||||||
|
'NotoSansCJKhk-Regular.otf',
|
||||||
|
],
|
||||||
|
'NotoSansJP-Regular': [
|
||||||
|
'NotoSansJP-Regular.otf',
|
||||||
|
'NotoSansJP-Regular.ttf',
|
||||||
|
'NotoSansCJKjp-Regular.otf',
|
||||||
|
],
|
||||||
|
'NotoSansKR-Regular': [
|
||||||
|
'NotoSansKR-Regular.otf',
|
||||||
|
'NotoSansKR-Regular.ttf',
|
||||||
|
'NotoSansCJKkr-Regular.otf',
|
||||||
|
],
|
||||||
'NotoSansThai-Regular': [
|
'NotoSansThai-Regular': [
|
||||||
'NotoSansThai-Regular.ttf',
|
'NotoSansThai-Regular.ttf',
|
||||||
'NotoSansThai-Regular.otf',
|
'NotoSansThai-Regular.otf',
|
||||||
@@ -149,6 +179,28 @@ class SystemFontProvider:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Font file extensions we know how to load.
|
||||||
|
_FONT_EXTENSIONS = ('.ttf', '.otf', '.ttc')
|
||||||
|
|
||||||
|
# Acceptable filename variants for a font family, ranked best-first.
|
||||||
|
# Lower rank wins when multiple variants of the same family are present.
|
||||||
|
_VARIANT_RANK = {'regular': 0, 'variable': 1, 'vf': 2, 'plain': 3}
|
||||||
|
|
||||||
|
# Extra family bases that can satisfy a logical font, tried after its own
|
||||||
|
# base (so the listed order is the preference). CJK is the case that needs
|
||||||
|
# this: the legacy Adobe-style 'NotoSansCJKsc-Regular.otf' is handled by
|
||||||
|
# NOTO_FONT_PATTERNS, but Homebrew casks and current Google Fonts ship the
|
||||||
|
# per-language families as variable fonts (e.g. 'NotoSansSC[wght].ttf').
|
||||||
|
_ALTERNATE_BASES: dict[str, list[str]] = {
|
||||||
|
'NotoSansCJK-Regular': [
|
||||||
|
'NotoSansSC', # Simplified Chinese
|
||||||
|
'NotoSansTC', # Traditional Chinese
|
||||||
|
'NotoSansHK', # Hong Kong
|
||||||
|
'NotoSansJP', # Japanese
|
||||||
|
'NotoSansKR', # Korean
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize system font provider with empty caches."""
|
"""Initialize system font provider with empty caches."""
|
||||||
# Cache: font_name -> FontManager (successfully loaded fonts)
|
# Cache: font_name -> FontManager (successfully loaded fonts)
|
||||||
@@ -222,14 +274,82 @@ class SystemFontProvider:
|
|||||||
try:
|
try:
|
||||||
matches = list(font_dir.rglob(pattern))
|
matches = list(font_dir.rglob(pattern))
|
||||||
if matches:
|
if matches:
|
||||||
log.debug(
|
log.debug("Found system font %s at %s", font_name, matches[0])
|
||||||
"Found system font %s at %s", font_name, matches[0]
|
|
||||||
)
|
|
||||||
return matches[0]
|
return matches[0]
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
# Skip directories we can't read
|
# Skip directories we can't read
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# No exact static '-Regular' file. Many distributors (Homebrew casks,
|
||||||
|
# current Google Fonts releases) ship Noto fonts as variable fonts with
|
||||||
|
# bracketed axis filenames such as 'NotoSansArabic[wdth,wght].ttf'.
|
||||||
|
# Fall back to a flexible search that also accepts those. See #1652.
|
||||||
|
return self._find_variant_font_file(font_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classify_variant(stem: str, base: str) -> str | None:
|
||||||
|
"""Classify a font filename stem as a usable variant of ``base``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stem: Filename without extension (e.g. 'NotoSansArabic[wdth,wght]')
|
||||||
|
base: Family base name (e.g. 'NotoSansArabic')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The variant kind ('regular', 'variable', 'vf', 'plain') or None if
|
||||||
|
the stem is not an acceptable representative of the family. The
|
||||||
|
boundary after ``base`` is required so that 'NotoSans' does not
|
||||||
|
match 'NotoSansArabic', and 'NotoSansArabicUI'/'NotoSansArabic-Bold'
|
||||||
|
do not match a request for 'NotoSansArabic'.
|
||||||
|
"""
|
||||||
|
if stem == f'{base}-Regular':
|
||||||
|
return 'regular'
|
||||||
|
if stem.startswith(f'{base}['): # variable font, e.g. Base[wdth,wght]
|
||||||
|
return 'variable'
|
||||||
|
if stem == f'{base}-VF': # alternate variable-font naming
|
||||||
|
return 'vf'
|
||||||
|
if stem == base: # bare family name
|
||||||
|
return 'plain'
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_variant_font_file(self, font_name: str) -> Path | None:
|
||||||
|
"""Search for a variable font or other acceptable filename variant.
|
||||||
|
|
||||||
|
Tries the font's own family base first, then any alternate bases (used
|
||||||
|
for the modern per-language CJK families). Within that, a static Regular
|
||||||
|
is preferred over a variable font. See issue #1652.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
font_name: Logical font name (e.g. 'NotoSansArabic-Regular')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the best-ranked matching font file, or None.
|
||||||
|
"""
|
||||||
|
bases = [font_name.removesuffix('-Regular')]
|
||||||
|
bases.extend(self._ALTERNATE_BASES.get(font_name, []))
|
||||||
|
|
||||||
|
# Selection key (base_index, variant_rank): earlier base wins, then the
|
||||||
|
# better variant. Path is carried along but not part of the comparison.
|
||||||
|
best: tuple[tuple[int, int], Path] | None = None
|
||||||
|
for base_index, base in enumerate(bases):
|
||||||
|
for font_dir in self._get_font_dirs():
|
||||||
|
if not font_dir.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
for path in font_dir.rglob(glob.escape(base) + '*'):
|
||||||
|
if path.suffix.lower() not in self._FONT_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
kind = self._classify_variant(path.stem, base)
|
||||||
|
if kind is None:
|
||||||
|
continue
|
||||||
|
key = (base_index, self._VARIANT_RANK[kind])
|
||||||
|
if best is None or key < best[0]:
|
||||||
|
best = (key, path)
|
||||||
|
except PermissionError:
|
||||||
|
# Skip directories we can't read
|
||||||
|
continue
|
||||||
|
if best is not None:
|
||||||
|
log.debug("Found system font %s at %s (variant match)", font_name, best[1])
|
||||||
|
return best[1]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_font(self, font_name: str) -> FontManager | None:
|
def get_font(self, font_name: str) -> FontManager | None:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
This module provides the PDF renderer using fpdf2 for creating
|
This module provides the PDF renderer using fpdf2 for creating
|
||||||
searchable OCR text layers.
|
searchable OCR text layers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ocrmypdf.fpdf_renderer.renderer import (
|
from ocrmypdf.fpdf_renderer.renderer import (
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ OCR text layers.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import unicodedata
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from math import atan, cos, degrees, radians, sin, sqrt
|
from math import atan, cos, degrees, radians, sin, sqrt
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from fpdf import FPDF
|
from fpdf import FPDF
|
||||||
from fpdf.enums import PDFResourceType, TextMode
|
from fpdf.enums import PDFResourceType, TextMode
|
||||||
|
from fpdf.fonts import TTFFont
|
||||||
from pikepdf import Matrix, Rectangle
|
from pikepdf import Matrix, Rectangle
|
||||||
|
|
||||||
from ocrmypdf.font import FontManager, MultiFontManager
|
from ocrmypdf.font import FontManager, MultiFontManager
|
||||||
@@ -24,6 +27,21 @@ from ocrmypdf.models.ocr_element import OcrClass, OcrElement
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rtl_text(text: str) -> bool:
|
||||||
|
"""Check if text is right-to-left based on Unicode bidi properties.
|
||||||
|
|
||||||
|
Looks for the first character with a strong directional type
|
||||||
|
(R, AL, or L) to determine the text's base direction.
|
||||||
|
"""
|
||||||
|
for char in text:
|
||||||
|
bidi = unicodedata.bidirectional(char)
|
||||||
|
if bidi in ('R', 'AL'):
|
||||||
|
return True
|
||||||
|
if bidi == 'L':
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def transform_point(matrix: Matrix, x: float, y: float) -> tuple[float, float]:
|
def transform_point(matrix: Matrix, x: float, y: float) -> tuple[float, float]:
|
||||||
"""Transform a point (x, y) by a matrix.
|
"""Transform a point (x, y) by a matrix.
|
||||||
|
|
||||||
@@ -66,6 +84,17 @@ def transform_box(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WordRenderData:
|
||||||
|
"""Rendering parameters for a single word on a line."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
x_baseline: float
|
||||||
|
font_family: str
|
||||||
|
word_tz: float
|
||||||
|
is_rtl: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DebugRenderOptions:
|
class DebugRenderOptions:
|
||||||
"""Options for debug visualization during rendering.
|
"""Options for debug visualization during rendering.
|
||||||
@@ -214,10 +243,16 @@ class Fpdf2PdfRenderer:
|
|||||||
pdf: FPDF instance to render into
|
pdf: FPDF instance to render into
|
||||||
"""
|
"""
|
||||||
# Add page with correct dimensions
|
# Add page with correct dimensions
|
||||||
|
# fpdf2's add_page() stub says format: str, but its docstring and
|
||||||
|
# get_page_format() helper confirm a (width, height) tuple is
|
||||||
|
# supported too - the annotation on add_page() itself is just wrong.
|
||||||
pdf.add_page(
|
pdf.add_page(
|
||||||
format=(
|
format=cast(
|
||||||
self.coord_transform.page_width_pt,
|
'str',
|
||||||
self.coord_transform.page_height_pt,
|
(
|
||||||
|
self.coord_transform.page_width_pt,
|
||||||
|
self.coord_transform.page_height_pt,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -421,13 +456,17 @@ class Fpdf2PdfRenderer:
|
|||||||
# entirely (slope=0, no textangle) and produced garbage text in a
|
# entirely (slope=0, no textangle) and produced garbage text in a
|
||||||
# bounding box whose shape doesn't match the text content at all.
|
# bounding box whose shape doesn't match the text content at all.
|
||||||
if not self._check_aspect_ratio_plausible(
|
if not self._check_aspect_ratio_plausible(
|
||||||
pdf, words, font_size, slope_angle_deg,
|
pdf,
|
||||||
line_size_width, line_size_height, line_language,
|
words,
|
||||||
|
font_size,
|
||||||
|
slope_angle_deg,
|
||||||
|
line_size_width,
|
||||||
|
line_size_height,
|
||||||
|
line_language,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Collect word rendering data: (text, x_baseline, font_family, word_tz)
|
word_render_data: list[WordRenderData] = []
|
||||||
word_render_data: list[tuple[str, float, str, float]] = []
|
|
||||||
for word in words:
|
for word in words:
|
||||||
if word is None or not word.text or word.bbox is None:
|
if word is None or not word.text or word.bbox is None:
|
||||||
continue
|
continue
|
||||||
@@ -459,13 +498,37 @@ class Fpdf2PdfRenderer:
|
|||||||
)
|
)
|
||||||
font_family = self._register_font(pdf, font_manager)
|
font_family = self._register_font(pdf, font_manager)
|
||||||
pdf.set_font(font_family, size=font_size)
|
pdf.set_font(font_family, size=font_size)
|
||||||
natural_width = pdf.get_string_width(word.text)
|
|
||||||
|
# For RTL words with invisible text, we use encode_text()
|
||||||
|
# (which maps characters 1:1 in logical order) combined with
|
||||||
|
# a -1 x-scale text matrix. This avoids an fpdf2 issue where
|
||||||
|
# shaped RTL ligature glyphs (e.g. lam-alef) get multi-char
|
||||||
|
# CMap entries whose character order is reversed by the bidi
|
||||||
|
# algorithm during text extraction.
|
||||||
|
# Since the text is invisible, glyph mirroring is harmless.
|
||||||
|
# Compute Tz using unshaped widths to match encode_text().
|
||||||
|
word_is_rtl = self.invisible_text and _is_rtl_text(word.text)
|
||||||
|
if word_is_rtl:
|
||||||
|
saved_shaping = pdf.text_shaping
|
||||||
|
pdf.text_shaping = None
|
||||||
|
natural_width = pdf.get_string_width(word.text)
|
||||||
|
pdf.text_shaping = saved_shaping
|
||||||
|
else:
|
||||||
|
natural_width = pdf.get_string_width(word.text)
|
||||||
if natural_width > 0 and word_width_pt > 0:
|
if natural_width > 0 and word_width_pt > 0:
|
||||||
word_tz = (word_width_pt / natural_width) * 100
|
word_tz = (word_width_pt / natural_width) * 100
|
||||||
else:
|
else:
|
||||||
word_tz = 100.0
|
word_tz = 100.0
|
||||||
|
|
||||||
word_render_data.append((word.text, box_llx, font_family, word_tz))
|
word_render_data.append(
|
||||||
|
WordRenderData(
|
||||||
|
text=word.text,
|
||||||
|
x_baseline=box_llx,
|
||||||
|
font_family=font_family,
|
||||||
|
word_tz=word_tz,
|
||||||
|
is_rtl=word_is_rtl,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if not word_render_data:
|
if not word_render_data:
|
||||||
return
|
return
|
||||||
@@ -513,9 +576,7 @@ class Fpdf2PdfRenderer:
|
|||||||
if line_size_width >= line_size_height:
|
if line_size_width >= line_size_height:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
line_text = ' '.join(
|
line_text = ' '.join(w.text for w in words if w is not None and w.text)
|
||||||
w.text for w in words if w is not None and w.text
|
|
||||||
)
|
|
||||||
if not line_text:
|
if not line_text:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -555,16 +616,14 @@ class Fpdf2PdfRenderer:
|
|||||||
line_text[:80],
|
line_text[:80],
|
||||||
)
|
)
|
||||||
if not self._logged_aspect_ratio_suppression:
|
if not self._logged_aspect_ratio_suppression:
|
||||||
log.info(
|
log.info("Suppressing OCR output text with improbable aspect ratio")
|
||||||
"Suppressing OCR output text with improbable aspect ratio"
|
|
||||||
)
|
|
||||||
self._logged_aspect_ratio_suppression = True
|
self._logged_aspect_ratio_suppression = True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _emit_line_bt_block(
|
def _emit_line_bt_block(
|
||||||
self,
|
self,
|
||||||
pdf: FPDF,
|
pdf: FPDF,
|
||||||
word_render_data: list[tuple[str, float, str, float]],
|
word_render_data: list[WordRenderData],
|
||||||
baseline_matrix: Matrix,
|
baseline_matrix: Matrix,
|
||||||
font_size: float,
|
font_size: float,
|
||||||
total_rotation_deg: float,
|
total_rotation_deg: float,
|
||||||
@@ -580,8 +639,7 @@ class Fpdf2PdfRenderer:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
pdf: FPDF instance
|
pdf: FPDF instance
|
||||||
word_render_data: List of (text, x_baseline, font_family, word_tz)
|
word_render_data: List of WordRenderData, one per word on this line
|
||||||
tuples, one per word on this line
|
|
||||||
baseline_matrix: Transform from baseline coords to page coords
|
baseline_matrix: Transform from baseline coords to page coords
|
||||||
font_size: Font size in points
|
font_size: Font size in points
|
||||||
total_rotation_deg: Total rotation angle (textangle + slope)
|
total_rotation_deg: Total rotation angle (textangle + slope)
|
||||||
@@ -626,43 +684,41 @@ class Fpdf2PdfRenderer:
|
|||||||
ops.append(f'{tr} Tr')
|
ops.append(f'{tr} Tr')
|
||||||
|
|
||||||
# Initial text position
|
# Initial text position
|
||||||
first_x_baseline = word_render_data[0][1]
|
first_x_baseline = word_render_data[0].x_baseline
|
||||||
if has_rotation:
|
if has_rotation:
|
||||||
# In the cm-transformed space, origin is at the baseline start
|
# In the cm-transformed space, origin is at the baseline start
|
||||||
ops.append(f'{first_x_baseline:.2f} 0 Td')
|
ops.append(f'{first_x_baseline:.2f} 0 Td')
|
||||||
else:
|
else:
|
||||||
# Direct PDF coordinates
|
# Direct PDF coordinates
|
||||||
page_x, page_y_fpdf = transform_point(
|
page_x, page_y_fpdf = transform_point(baseline_matrix, first_x_baseline, 0)
|
||||||
baseline_matrix, first_x_baseline, 0
|
|
||||||
)
|
|
||||||
page_y_pdf = page_height - page_y_fpdf
|
page_y_pdf = page_height - page_y_fpdf
|
||||||
ops.append(f'{page_x:.2f} {page_y_pdf:.2f} Td')
|
ops.append(f'{page_x:.2f} {page_y_pdf:.2f} Td')
|
||||||
|
|
||||||
prev_font_family: str | None = None
|
prev_font_family: str | None = None
|
||||||
prev_x_baseline = first_x_baseline
|
prev_x_baseline = first_x_baseline
|
||||||
|
|
||||||
for i, (text, x_baseline, font_family, word_tz) in enumerate(
|
for i, word in enumerate(word_render_data):
|
||||||
word_render_data
|
|
||||||
):
|
|
||||||
is_last = i == len(word_render_data) - 1
|
is_last = i == len(word_render_data) - 1
|
||||||
|
|
||||||
# Set font if changed
|
# Set font if changed
|
||||||
if font_family != prev_font_family:
|
if word.font_family != prev_font_family:
|
||||||
pdf.set_font(font_family, size=font_size)
|
pdf.set_font(word.font_family, size=font_size)
|
||||||
|
# We only ever register fonts via add_font() with a TTF file
|
||||||
|
# (see _register_font), so set_font() always resolves to a
|
||||||
|
# TTFFont, never a built-in CoreFont or leaves it unset.
|
||||||
|
assert pdf.current_font is not None
|
||||||
# Register font resource on this page
|
# Register font resource on this page
|
||||||
pdf._resource_catalog.add(
|
pdf._resource_catalog.add(
|
||||||
PDFResourceType.FONT, pdf.current_font.i, pdf.page
|
PDFResourceType.FONT, pdf.current_font.i, pdf.page
|
||||||
)
|
)
|
||||||
ops.append(
|
ops.append(f'/F{pdf.current_font.i} {pdf.font_size_pt:.2f} Tf')
|
||||||
f'/F{pdf.current_font.i} {pdf.font_size_pt:.2f} Tf'
|
prev_font_family = word.font_family
|
||||||
)
|
|
||||||
prev_font_family = font_family
|
|
||||||
|
|
||||||
# Relative positioning (for words after the first)
|
# Relative positioning (for words after the first)
|
||||||
if i > 0:
|
if i > 0:
|
||||||
if has_rotation:
|
if has_rotation:
|
||||||
# In rotated space, advance is purely along x-axis
|
# In rotated space, advance is purely along x-axis
|
||||||
dx_baseline = x_baseline - prev_x_baseline
|
dx_baseline = word.x_baseline - prev_x_baseline
|
||||||
ops.append(f'{dx_baseline:.2f} 0 Td')
|
ops.append(f'{dx_baseline:.2f} 0 Td')
|
||||||
else:
|
else:
|
||||||
# Non-rotated: compute delta in PDF coordinates
|
# Non-rotated: compute delta in PDF coordinates
|
||||||
@@ -670,44 +726,34 @@ class Fpdf2PdfRenderer:
|
|||||||
baseline_matrix, prev_x_baseline, 0
|
baseline_matrix, prev_x_baseline, 0
|
||||||
)
|
)
|
||||||
px_curr, py_curr_f = transform_point(
|
px_curr, py_curr_f = transform_point(
|
||||||
baseline_matrix, x_baseline, 0
|
baseline_matrix, word.x_baseline, 0
|
||||||
)
|
)
|
||||||
dx_pdf = px_curr - px_prev
|
dx_pdf = px_curr - px_prev
|
||||||
# Flip y delta for PDF coordinates (y-up)
|
# Flip y delta for PDF coordinates (y-up)
|
||||||
dy_pdf = -(py_curr_f - py_prev_f)
|
dy_pdf = -(py_curr_f - py_prev_f)
|
||||||
ops.append(f'{dx_pdf:.2f} {dy_pdf:.2f} Td')
|
ops.append(f'{dx_pdf:.2f} {dy_pdf:.2f} Td')
|
||||||
|
|
||||||
# Determine text to render and compute Tz
|
# Determine text to render
|
||||||
if not is_last:
|
if not is_last:
|
||||||
next_text, next_x_baseline, _, _ = word_render_data[i + 1]
|
next_word = word_render_data[i + 1]
|
||||||
advance = next_x_baseline - x_baseline
|
advance = next_word.x_baseline - word.x_baseline
|
||||||
|
|
||||||
# Add trailing space unless both words are CJK-only
|
# Add trailing space for text extraction unless both are CJK
|
||||||
if (
|
if advance > 0 and not (
|
||||||
advance > 0
|
self._is_cjk_only(word.text) and self._is_cjk_only(next_word.text)
|
||||||
and not (
|
|
||||||
self._is_cjk_only(text)
|
|
||||||
and self._is_cjk_only(next_text)
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
text_to_render = text + ' '
|
text_to_render = word.text + ' '
|
||||||
natural_w = pdf.get_string_width(text_to_render)
|
|
||||||
render_tz = (
|
|
||||||
(advance / natural_w) * 100
|
|
||||||
if natural_w > 0
|
|
||||||
else word_tz
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
text_to_render = text
|
text_to_render = word.text
|
||||||
render_tz = word_tz
|
|
||||||
else:
|
else:
|
||||||
text_to_render = text
|
text_to_render = word.text
|
||||||
render_tz = word_tz
|
|
||||||
|
|
||||||
ops.append(f'{render_tz:.2f} Tz')
|
# Use word_tz (fits word into its hOCR bbox) — Td handles
|
||||||
ops.append(self._encode_shaped_text(pdf, text_to_render))
|
# inter-word gaps, so Tz should not stretch to fill them.
|
||||||
|
ops.append(f'{word.word_tz:.2f} Tz')
|
||||||
|
ops.append(self._encode_shaped_text(pdf, text_to_render, word.is_rtl))
|
||||||
|
|
||||||
prev_x_baseline = x_baseline
|
prev_x_baseline = word.x_baseline
|
||||||
|
|
||||||
# End text object
|
# End text object
|
||||||
ops.append('ET')
|
ops.append('ET')
|
||||||
@@ -721,15 +767,37 @@ class Fpdf2PdfRenderer:
|
|||||||
# don't think Tz is still set from our raw operators
|
# don't think Tz is still set from our raw operators
|
||||||
pdf.font_stretching = 100
|
pdf.font_stretching = 100
|
||||||
|
|
||||||
def _encode_shaped_text(self, pdf: FPDF, text: str) -> str:
|
def _encode_shaped_text(self, pdf: FPDF, text: str, is_rtl: bool = False) -> str:
|
||||||
"""Encode text using HarfBuzz text shaping for complex script support.
|
"""Encode text using HarfBuzz text shaping for complex script support.
|
||||||
|
|
||||||
Unlike font.encode_text() which maps unicode characters one-by-one to
|
Unlike font.encode_text() which maps unicode characters one-by-one to
|
||||||
glyph IDs, this uses HarfBuzz to handle BiDi reordering, Arabic joining
|
glyph IDs, this uses HarfBuzz to handle BiDi reordering, Arabic joining
|
||||||
forms, Devanagari conjuncts, and other complex script shaping. Falls
|
forms, Devanagari conjuncts, and other complex script shaping. Falls
|
||||||
back to encode_text() when text shaping is not enabled.
|
back to encode_text() when text shaping is not enabled.
|
||||||
|
|
||||||
|
For RTL words with invisible text, we use encode_text() instead of
|
||||||
|
shape_text(). fpdf2's shape_text() produces RTL ligature glyphs
|
||||||
|
(e.g. lam-alef) with multi-character CMap entries whose character
|
||||||
|
order gets reversed by the bidi algorithm during text extraction,
|
||||||
|
producing garbled output (e.g. "سالح" instead of "سلاح").
|
||||||
|
encode_text() maps characters 1:1 in logical order, giving correct
|
||||||
|
extraction. Since the text is invisible (Tr=3), the lack of proper
|
||||||
|
joining forms and ligature shaping is harmless.
|
||||||
"""
|
"""
|
||||||
font = pdf.current_font
|
font = pdf.current_font
|
||||||
|
# We only ever register fonts via add_font() with a TTF file (see
|
||||||
|
# _register_font), so current_font is always a TTFFont - never the
|
||||||
|
# built-in CoreFont (which lacks shape_text()/escape_text()) or None.
|
||||||
|
assert isinstance(font, TTFFont)
|
||||||
|
if is_rtl:
|
||||||
|
# Reverse the text so that after bidi reversal by the text
|
||||||
|
# extractor, the characters end up in correct logical order.
|
||||||
|
# The text cursor advances left-to-right from the word's left
|
||||||
|
# edge (set by Td), so characters are positioned left-to-right
|
||||||
|
# in the PDF. The extractor sees RTL characters in L-to-R
|
||||||
|
# positions and applies bidi reversal, which reverses them.
|
||||||
|
# By pre-reversing, the double reversal yields the original.
|
||||||
|
return font.encode_text(text[::-1])
|
||||||
if pdf.text_shaping and pdf.text_shaping.get("use_shaping_engine"):
|
if pdf.text_shaping and pdf.text_shaping.get("use_shaping_engine"):
|
||||||
shaped = font.shape_text(text, pdf.font_size_pt, pdf.text_shaping)
|
shaped = font.shape_text(text, pdf.font_size_pt, pdf.text_shaping)
|
||||||
if shaped:
|
if shaped:
|
||||||
|
|||||||
+52
-17
@@ -18,6 +18,7 @@ from math import isclose, isfinite
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from statistics import harmonic_mean
|
from statistics import harmonic_mean
|
||||||
from typing import (
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
Generic,
|
Generic,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
@@ -26,6 +27,9 @@ from typing import (
|
|||||||
import img2pdf
|
import img2pdf
|
||||||
import pikepdf
|
import pikepdf
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from _typeshed import StrOrBytesPath
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
IMG2PDF_KWARGS = dict(engine=img2pdf.Engine.pikepdf, rotation=img2pdf.Rotation.ifvalid)
|
IMG2PDF_KWARGS = dict(engine=img2pdf.Engine.pikepdf, rotation=img2pdf.Rotation.ifvalid)
|
||||||
@@ -135,7 +139,7 @@ class Resolution(Generic[T]):
|
|||||||
return self._isclose(self.x, other.x) and self._isclose(self.y, other.y)
|
return self._isclose(self.x, other.x) and self._isclose(self.y, other.y)
|
||||||
|
|
||||||
|
|
||||||
def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike) -> None:
|
def safe_symlink(input_file: StrOrBytesPath, soft_link_name: StrOrBytesPath) -> None:
|
||||||
"""Create a symbolic link at ``soft_link_name``, which references ``input_file``.
|
"""Create a symbolic link at ``soft_link_name``, which references ``input_file``.
|
||||||
|
|
||||||
Think of this as copying ``input_file`` to ``soft_link_name`` with less overhead.
|
Think of this as copying ``input_file`` to ``soft_link_name`` with less overhead.
|
||||||
@@ -144,11 +148,11 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike) -> None:
|
|||||||
used since symlinks may require administrator privileges. An existing link at the
|
used since symlinks may require administrator privileges. An existing link at the
|
||||||
destination is removed.
|
destination is removed.
|
||||||
"""
|
"""
|
||||||
input_file = os.fspath(input_file)
|
input_path = Path(os.fsdecode(input_file))
|
||||||
soft_link_name = os.fspath(soft_link_name)
|
soft_link_path = Path(os.fsdecode(soft_link_name))
|
||||||
|
|
||||||
# Guard against soft linking to oneself
|
# Guard against soft linking to oneself
|
||||||
if input_file == soft_link_name:
|
if input_path == soft_link_path:
|
||||||
log.warning(
|
log.warning(
|
||||||
"No symbolic link created. You are using the original data directory "
|
"No symbolic link created. You are using the original data directory "
|
||||||
"as the working directory."
|
"as the working directory."
|
||||||
@@ -156,24 +160,24 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Soft link already exists: delete for relink?
|
# Soft link already exists: delete for relink?
|
||||||
if os.path.lexists(soft_link_name):
|
if os.path.lexists(soft_link_path):
|
||||||
# do not delete or overwrite real (non-soft link) file
|
# do not delete or overwrite real (non-soft link) file
|
||||||
if not os.path.islink(soft_link_name):
|
if not soft_link_path.is_symlink():
|
||||||
raise FileExistsError(f"{soft_link_name} exists and is not a link")
|
raise FileExistsError(f"{soft_link_path} exists and is not a link")
|
||||||
os.unlink(soft_link_name)
|
soft_link_path.unlink()
|
||||||
|
|
||||||
if not os.path.exists(input_file):
|
if not input_path.exists():
|
||||||
raise FileNotFoundError(f"trying to create a broken symlink to {input_file}")
|
raise FileNotFoundError(f"trying to create a broken symlink to {input_path}")
|
||||||
|
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
# Don't actually use symlinks on Windows due to permission issues
|
# Don't actually use symlinks on Windows due to permission issues
|
||||||
shutil.copyfile(input_file, soft_link_name)
|
shutil.copyfile(input_path, soft_link_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
log.debug("os.symlink(%s, %s)", input_file, soft_link_name)
|
log.debug("os.symlink(%s, %s)", input_path, soft_link_path)
|
||||||
|
|
||||||
# Create symbolic link using absolute path
|
# Create symbolic link using absolute path
|
||||||
os.symlink(os.path.abspath(input_file), soft_link_name)
|
soft_link_path.symlink_to(input_path.resolve())
|
||||||
|
|
||||||
|
|
||||||
def samefile(file1: os.PathLike, file2: os.PathLike) -> bool:
|
def samefile(file1: os.PathLike, file2: os.PathLike) -> bool:
|
||||||
@@ -184,7 +188,7 @@ def samefile(file1: os.PathLike, file2: os.PathLike) -> bool:
|
|||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
return file1 == file2
|
return file1 == file2
|
||||||
else:
|
else:
|
||||||
return os.path.samefile(file1, file2)
|
return Path(file1).samefile(file2)
|
||||||
|
|
||||||
|
|
||||||
def is_iterable_notstr(thing: Any) -> bool:
|
def is_iterable_notstr(thing: Any) -> bool:
|
||||||
@@ -199,7 +203,7 @@ def monotonic(seq: Sequence) -> bool:
|
|||||||
|
|
||||||
def page_number(input_file: os.PathLike) -> int:
|
def page_number(input_file: os.PathLike) -> int:
|
||||||
"""Get one-based page number implied by filename (000002.pdf -> 2)."""
|
"""Get one-based page number implied by filename (000002.pdf -> 2)."""
|
||||||
return int(os.path.basename(os.fspath(input_file))[0:6])
|
return int(Path(input_file).name[0:6])
|
||||||
|
|
||||||
|
|
||||||
def available_cpu_count() -> int:
|
def available_cpu_count() -> int:
|
||||||
@@ -214,7 +218,7 @@ def available_cpu_count() -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def is_file_writable(test_file: os.PathLike) -> bool:
|
def is_file_writable(test_file: StrOrBytesPath) -> bool:
|
||||||
"""Intentionally racy test if target is writable.
|
"""Intentionally racy test if target is writable.
|
||||||
|
|
||||||
We intend to write to the output file if and only if we succeed and
|
We intend to write to the output file if and only if we succeed and
|
||||||
@@ -222,7 +226,7 @@ def is_file_writable(test_file: os.PathLike) -> bool:
|
|||||||
the location is writable.
|
the location is writable.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
p = Path(test_file)
|
p = Path(os.fsdecode(test_file))
|
||||||
if p.is_symlink():
|
if p.is_symlink():
|
||||||
p = p.resolve(strict=False)
|
p = p.resolve(strict=False)
|
||||||
|
|
||||||
@@ -329,6 +333,37 @@ def pikepdf_enable_mmap() -> None:
|
|||||||
log.debug("pikepdf mmap not available")
|
log.debug("pikepdf mmap not available")
|
||||||
|
|
||||||
|
|
||||||
|
def pikepdf_get_int(obj: pikepdf.Object, key: pikepdf.Name, default: int = 0) -> int:
|
||||||
|
"""Look up a key on a pikepdf dictionary/stream, returning a plain int.
|
||||||
|
|
||||||
|
``.get(key, default)``'s return type is the ambiguous ``Object | int``,
|
||||||
|
which does not support arithmetic or comparison against a plain int. In
|
||||||
|
pikepdf's default (implicit) conversion mode, a PDF Integer is already
|
||||||
|
unboxed to a native ``int`` by the time we see it here; under explicit
|
||||||
|
conversion mode it would instead be a ``pikepdf.Object``. ``int()``
|
||||||
|
handles both, since ``Object`` implements ``__int__``.
|
||||||
|
"""
|
||||||
|
value = obj.get(key)
|
||||||
|
return int(value) if value is not None else default
|
||||||
|
|
||||||
|
|
||||||
|
def pikepdf_get_bool(
|
||||||
|
obj: pikepdf.Object, key: pikepdf.Name, default: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Look up a key on a pikepdf dictionary/stream, returning a plain bool.
|
||||||
|
|
||||||
|
Unlike ``int()``/``float()``, ``bool()`` is not supported on
|
||||||
|
``pikepdf.Object`` (it raises), so both conversion modes must be
|
||||||
|
handled explicitly. See :func:`pikepdf_get_int` for background.
|
||||||
|
"""
|
||||||
|
value = obj.get(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return value.as_bool(default)
|
||||||
|
|
||||||
|
|
||||||
def running_in_docker() -> bool:
|
def running_in_docker() -> bool:
|
||||||
"""Returns True if we seem to be running in a Docker container."""
|
"""Returns True if we seem to be running in a Docker container."""
|
||||||
return Path('/.dockerenv').exists()
|
return Path('/.dockerenv').exists()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
Derived from
|
Derived from
|
||||||
https://www.loc.gov/standards/iso639-2/ascii_8bits.html
|
https://www.loc.gov/standards/iso639-2/ascii_8bits.html
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|||||||
+37
-13
@@ -12,7 +12,7 @@ import threading
|
|||||||
from collections.abc import Callable, Iterator, MutableSet, Sequence
|
from collections.abc import Callable, Iterator, MutableSet, Sequence
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, NamedTuple, NewType
|
from typing import Any, NamedTuple, NewType, cast
|
||||||
from zlib import compress
|
from zlib import compress
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
@@ -37,7 +37,7 @@ from ocrmypdf._exec import ghostscript, jbig2enc, pngquant
|
|||||||
from ocrmypdf._jobcontext import PdfContext
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
from ocrmypdf._progressbar import ProgressBar
|
from ocrmypdf._progressbar import ProgressBar
|
||||||
from ocrmypdf.exceptions import OutputFileAccessError
|
from ocrmypdf.exceptions import OutputFileAccessError
|
||||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, safe_symlink
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, pikepdf_get_int, safe_symlink
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -260,10 +260,19 @@ def _find_image_xrefs_container(
|
|||||||
exclude_xrefs: MutableSet[Xref],
|
exclude_xrefs: MutableSet[Xref],
|
||||||
pageno_for_xref: dict[Xref, int],
|
pageno_for_xref: dict[Xref, int],
|
||||||
depth: int = 0,
|
depth: int = 0,
|
||||||
|
visited_forms: MutableSet[Xref] | None = None,
|
||||||
):
|
):
|
||||||
"""Find all image XRefs or Form XObject and add to the include/exclude sets."""
|
"""Find all image XRefs or Form XObject and add to the include/exclude sets."""
|
||||||
|
# Form XObjects are not added to include/exclude_xrefs, so the dedup
|
||||||
|
# check below doesn't catch Form-XObject cycles or DAGs. Track them in
|
||||||
|
# a shared set so each Form is only descended into once per document
|
||||||
|
# (issue #1321).
|
||||||
|
if visited_forms is None:
|
||||||
|
visited_forms = set()
|
||||||
if depth > 10:
|
if depth > 10:
|
||||||
log.warning("Recursion depth exceeded in _find_image_xrefs_page")
|
# With visited_forms memoization, this is a soft DAG-height guard
|
||||||
|
# rather than a cycle defense, so a debug log is sufficient.
|
||||||
|
log.debug("Recursion depth exceeded in _find_image_xrefs_page")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
xobjs = container.Resources.XObject
|
xobjs = container.Resources.XObject
|
||||||
@@ -276,7 +285,9 @@ def _find_image_xrefs_container(
|
|||||||
if xref in include_xrefs or xref in exclude_xrefs:
|
if xref in include_xrefs or xref in exclude_xrefs:
|
||||||
continue # Already processed
|
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
|
if xref in visited_forms:
|
||||||
|
continue
|
||||||
|
visited_forms.add(xref)
|
||||||
log.debug(f"Recursing into Form XObject {_imname} in page {pageno}")
|
log.debug(f"Recursing into Form XObject {_imname} in page {pageno}")
|
||||||
_find_image_xrefs_container(
|
_find_image_xrefs_container(
|
||||||
pdf,
|
pdf,
|
||||||
@@ -286,6 +297,7 @@ def _find_image_xrefs_container(
|
|||||||
exclude_xrefs,
|
exclude_xrefs,
|
||||||
pageno_for_xref,
|
pageno_for_xref,
|
||||||
depth + 1,
|
depth + 1,
|
||||||
|
visited_forms,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if Name.SMask in image:
|
if Name.SMask in image:
|
||||||
@@ -342,9 +354,16 @@ def extract_images(
|
|||||||
pdf=pdf, root=root, image=image, xref=xref, options=options
|
pdf=pdf, root=root, image=image, xref=xref, options=options
|
||||||
)
|
)
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
log.exception(
|
# Optimization is best-effort: an image we cannot process is simply
|
||||||
f"xref {xref}: While extracting this image, an error occurred"
|
# left unchanged in the output, which remains valid. Report this as
|
||||||
|
# a concise warning rather than an alarming traceback (issue #846);
|
||||||
|
# the full detail is still available at debug verbosity.
|
||||||
|
log.warning(
|
||||||
|
f"xref {xref}: this image could not be processed by the "
|
||||||
|
"optimizer and was left unchanged. The output file is still "
|
||||||
|
"valid."
|
||||||
)
|
)
|
||||||
|
log.debug(f"xref {xref}: image optimization error detail", exc_info=True)
|
||||||
errors += 1
|
errors += 1
|
||||||
else:
|
else:
|
||||||
if result:
|
if result:
|
||||||
@@ -508,8 +527,8 @@ def _find_deflatable_jpeg(
|
|||||||
(
|
(
|
||||||
# Don't flate very large images because it will slow down PDF viewers
|
# Don't flate very large images because it will slow down PDF viewers
|
||||||
1 <= options.optimize <= 2
|
1 <= options.optimize <= 2
|
||||||
and image.get(Name.Width, 0) < FLATE_JPEG_THRESHOLD
|
and pikepdf_get_int(image, Name.Width) < FLATE_JPEG_THRESHOLD
|
||||||
and image.get(Name.Height, 0) < FLATE_JPEG_THRESHOLD
|
and pikepdf_get_int(image, Name.Height) < FLATE_JPEG_THRESHOLD
|
||||||
)
|
)
|
||||||
or options.optimize == 3
|
or options.optimize == 3
|
||||||
)
|
)
|
||||||
@@ -589,10 +608,13 @@ def _transcode_png(pdf: Pdf, filename: Path, xref: Xref) -> bool:
|
|||||||
local_image = pdf.copy_foreign(foreign_image)
|
local_image = pdf.copy_foreign(foreign_image)
|
||||||
|
|
||||||
im_obj = pdf.get_object(xref, 0)
|
im_obj = pdf.get_object(xref, 0)
|
||||||
|
# pikepdf's Object attribute access can't statically know Filter/
|
||||||
|
# DecodeParms hold these specific subtypes, but a copied image's
|
||||||
|
# stream dictionary always does per the PDF spec.
|
||||||
im_obj.write(
|
im_obj.write(
|
||||||
local_image.read_raw_bytes(),
|
local_image.read_raw_bytes(),
|
||||||
filter=local_image.Filter,
|
filter=cast('Name | Array | list[Name] | None', local_image.Filter),
|
||||||
decode_parms=local_image.DecodeParms,
|
decode_parms=cast('Dictionary | Array | None', local_image.DecodeParms),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Don't copy keys from the new image...
|
# Don't copy keys from the new image...
|
||||||
@@ -681,9 +703,9 @@ def optimize(
|
|||||||
safe_symlink(input_file, output_file)
|
safe_symlink(input_file, output_file)
|
||||||
return output_file
|
return output_file
|
||||||
|
|
||||||
if options.jpg_quality == 0:
|
if not options.jpg_quality:
|
||||||
options.jpg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
|
options.jpg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
|
||||||
if options.png_quality == 0:
|
if not options.png_quality:
|
||||||
options.png_quality = DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
|
options.png_quality = DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
|
||||||
|
|
||||||
with Pdf.open(input_file) as pdf:
|
with Pdf.open(input_file) as pdf:
|
||||||
@@ -752,7 +774,9 @@ def main(infile, outfile, level, jobs=1):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with TemporaryDirectory() as tmpdir:
|
with TemporaryDirectory() as tmpdir:
|
||||||
context = PdfContext(options, Path(tmpdir), infile, None, None)
|
# optimize() only reads context.options on this standalone path, so
|
||||||
|
# pdfinfo and plugin_manager are not needed.
|
||||||
|
context = PdfContext(options, Path(tmpdir), infile, None, None) # type: ignore[arg-type]
|
||||||
tmpout = Path(tmpdir) / 'out.pdf'
|
tmpout = Path(tmpdir) / 'out.pdf'
|
||||||
optimize(
|
optimize(
|
||||||
infile,
|
infile,
|
||||||
|
|||||||
+67
-6
@@ -137,6 +137,65 @@ def file_claims_pdfa(filename: Path):
|
|||||||
return pdfa_dict
|
return pdfa_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _cid_font_is_embedded(type0_font: Dictionary) -> bool:
|
||||||
|
"""Return True if a Type0 font's CID descendant carries embedded glyphs."""
|
||||||
|
for descendant in type0_font.get(Name.DescendantFonts, []):
|
||||||
|
descriptor = descendant.get(Name.FontDescriptor, None)
|
||||||
|
if descriptor is not None and any(
|
||||||
|
key in descriptor for key in (Name.FontFile, Name.FontFile2, Name.FontFile3)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]:
|
||||||
|
"""Find CID-keyed (Type0) fonts that lack embedded glyph data.
|
||||||
|
|
||||||
|
PDF/A requires every font to be embedded. When Ghostscript converts a PDF
|
||||||
|
to PDF/A it must substitute and embed a replacement for any non-embedded
|
||||||
|
font. For CID-keyed fonts -- which is how CJK text is encoded, including the
|
||||||
|
OCR text layers produced by Adobe Acrobat -- this substitution routinely
|
||||||
|
corrupts the character-to-Unicode mapping, silently destroying the
|
||||||
|
searchable text. Detecting these fonts lets the caller refuse PDF/A
|
||||||
|
conversion rather than emit corrupted output.
|
||||||
|
|
||||||
|
Simple (non-CID) non-embedded fonts are not reported: Ghostscript
|
||||||
|
substitutes standard encodings for them without corrupting the text, and
|
||||||
|
they are far too common to treat as conversion blockers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf: An open ``pikepdf.Pdf`` to scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The set of ``BaseFont`` names of non-embedded CID fonts found.
|
||||||
|
"""
|
||||||
|
found: set[str] = set()
|
||||||
|
|
||||||
|
def scan_resources(resources, depth: int = 0) -> None:
|
||||||
|
if resources is None or depth > 10:
|
||||||
|
return
|
||||||
|
fonts = resources.get(Name.Font, None)
|
||||||
|
if fonts is not None:
|
||||||
|
for font in fonts.values():
|
||||||
|
try:
|
||||||
|
if font.get(Name.Subtype) != Name.Type0:
|
||||||
|
continue
|
||||||
|
if not _cid_font_is_embedded(font):
|
||||||
|
basefont = str(font.get(Name.BaseFont, '/(unnamed)'))
|
||||||
|
found.add(basefont.lstrip('/'))
|
||||||
|
except (AttributeError, TypeError, KeyError):
|
||||||
|
continue
|
||||||
|
xobjects = resources.get(Name.XObject, None)
|
||||||
|
if xobjects is not None:
|
||||||
|
for xobj in xobjects.values():
|
||||||
|
if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj:
|
||||||
|
scan_resources(xobj[Name.Resources], depth + 1)
|
||||||
|
|
||||||
|
for page in pdf.pages:
|
||||||
|
scan_resources(page.get(Name.Resources, None))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def _load_srgb_icc_profile() -> bytes:
|
def _load_srgb_icc_profile() -> bytes:
|
||||||
"""Load the sRGB ICC profile from package data."""
|
"""Load the sRGB ICC profile from package data."""
|
||||||
return (package_files('ocrmypdf.data') / SRGB_ICC_PROFILE_NAME).read_bytes()
|
return (package_files('ocrmypdf.data') / SRGB_ICC_PROFILE_NAME).read_bytes()
|
||||||
@@ -191,12 +250,14 @@ def add_srgb_output_intent(pdf: Pdf) -> None:
|
|||||||
icc_stream[Name.N] = 3 # RGB has 3 components
|
icc_stream[Name.N] = 3 # RGB has 3 components
|
||||||
|
|
||||||
# Create OutputIntent dictionary
|
# Create OutputIntent dictionary
|
||||||
output_intent = Dictionary({
|
output_intent = Dictionary(
|
||||||
'/Type': Name.OutputIntent,
|
{
|
||||||
'/S': Name('/GTS_PDFA1'),
|
'/Type': Name.OutputIntent,
|
||||||
'/OutputConditionIdentifier': 'sRGB',
|
'/S': Name('/GTS_PDFA1'),
|
||||||
'/DestOutputProfile': icc_stream,
|
'/OutputConditionIdentifier': 'sRGB',
|
||||||
})
|
'/DestOutputProfile': icc_stream,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Add to catalog's OutputIntents array
|
# Add to catalog's OutputIntents array
|
||||||
if Name.OutputIntents not in pdf.Root:
|
if Name.OutputIntents not in pdf.Root:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect
|
from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect, Ink
|
||||||
from ocrmypdf.pdfinfo.info import PageInfo, PdfInfo
|
from ocrmypdf.pdfinfo.info import PageInfo, PdfInfo
|
||||||
|
|
||||||
__all__ = ["Colorspace", "Encoding", "FloatRect", "PageInfo", "PdfInfo"]
|
__all__ = ["Colorspace", "Encoding", "FloatRect", "Ink", "PageInfo", "PdfInfo"]
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ from math import hypot, inf, isclose
|
|||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
from pikepdf import Matrix, Object, PdfInlineImage, parse_content_stream
|
from pikepdf import Matrix, Name, Object, PdfInlineImage, parse_content_stream
|
||||||
|
|
||||||
from ocrmypdf.exceptions import InputFileError
|
from ocrmypdf.exceptions import InputFileError
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
from ocrmypdf.pdfinfo._types import UNIT_SQUARE
|
from ocrmypdf.pdfinfo._types import UNIT_SQUARE, Ink
|
||||||
|
|
||||||
|
|
||||||
class XobjectSettings(NamedTuple):
|
class XobjectSettings(NamedTuple):
|
||||||
@@ -24,6 +24,7 @@ class XobjectSettings(NamedTuple):
|
|||||||
name: str
|
name: str
|
||||||
shorthand: tuple[float, float, float, float, float, float]
|
shorthand: tuple[float, float, float, float, float, float]
|
||||||
stack_depth: int
|
stack_depth: int
|
||||||
|
fill_ink: Ink
|
||||||
|
|
||||||
|
|
||||||
class InlineSettings(NamedTuple):
|
class InlineSettings(NamedTuple):
|
||||||
@@ -32,6 +33,7 @@ class InlineSettings(NamedTuple):
|
|||||||
iimage: PdfInlineImage
|
iimage: PdfInlineImage
|
||||||
shorthand: tuple[float, float, float, float, float, float]
|
shorthand: tuple[float, float, float, float, float, float]
|
||||||
stack_depth: int
|
stack_depth: int
|
||||||
|
fill_ink: Ink
|
||||||
|
|
||||||
|
|
||||||
class ContentsInfo(NamedTuple):
|
class ContentsInfo(NamedTuple):
|
||||||
@@ -67,6 +69,60 @@ def _is_unit_square(shorthand):
|
|||||||
return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise)
|
return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise)
|
||||||
|
|
||||||
|
|
||||||
|
_INK_EPSILON = 1e-3
|
||||||
|
|
||||||
|
# Maps a fill-colorspace name (set by the `cs` operator) to a device color
|
||||||
|
# family we can classify. Names not present here (Separation, ICCBased,
|
||||||
|
# Indexed, DeviceN, Pattern, resource names like /CS0) are treated as color.
|
||||||
|
_DEVICE_FILL_SPACE = {
|
||||||
|
'/DeviceGray': 'gray',
|
||||||
|
'/CalGray': 'gray',
|
||||||
|
'/G': 'gray',
|
||||||
|
'/DeviceRGB': 'rgb',
|
||||||
|
'/CalRGB': 'rgb',
|
||||||
|
'/RGB': 'rgb',
|
||||||
|
'/DeviceCMYK': 'cmyk',
|
||||||
|
'/CMYK': 'cmyk',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ink_from_components(space: str, comps: list[float]) -> Ink:
|
||||||
|
"""Classify a device-color fill into mono/gray/color.
|
||||||
|
|
||||||
|
``space`` is one of 'gray', 'rgb', 'cmyk'. Any other value is treated
|
||||||
|
conservatively as color, since we cannot prove it is achromatic.
|
||||||
|
"""
|
||||||
|
eps = _INK_EPSILON
|
||||||
|
if space == 'gray' and len(comps) == 1:
|
||||||
|
return Ink.mono if comps[0] <= eps else Ink.gray
|
||||||
|
if space == 'rgb' and len(comps) == 3:
|
||||||
|
r, g, b = comps
|
||||||
|
if max(r, g, b) <= eps:
|
||||||
|
return Ink.mono
|
||||||
|
if abs(r - g) <= eps and abs(g - b) <= eps:
|
||||||
|
return Ink.gray
|
||||||
|
return Ink.color
|
||||||
|
if space == 'cmyk' and len(comps) == 4:
|
||||||
|
c, m, y, k = comps
|
||||||
|
if c <= eps and m <= eps and y <= eps:
|
||||||
|
return Ink.mono if k <= eps else Ink.gray
|
||||||
|
return Ink.color
|
||||||
|
return Ink.color # conservative-to-color
|
||||||
|
|
||||||
|
|
||||||
|
def _operand_floats(operands) -> list[float] | None:
|
||||||
|
"""Convert color operands to floats, or None if any is non-numeric.
|
||||||
|
|
||||||
|
Color operators in a malformed content stream may carry the wrong number
|
||||||
|
of operands or a non-numeric operand (e.g. a Name). Returning None lets
|
||||||
|
the caller keep the prior fill state instead of raising.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return [float(o) for o in operands]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_stack(graphobjs):
|
def _normalize_stack(graphobjs):
|
||||||
"""Convert runs of qQ's in the stack into single graphobjs."""
|
"""Convert runs of qQ's in the stack into single graphobjs."""
|
||||||
for operands, operator in graphobjs:
|
for operands, operator in graphobjs:
|
||||||
@@ -78,12 +134,15 @@ def _normalize_stack(graphobjs):
|
|||||||
yield (operands, operator)
|
yield (operands, operator)
|
||||||
|
|
||||||
|
|
||||||
def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
def _interpret_contents(
|
||||||
|
contentstream: Object, initial_shorthand=UNIT_SQUARE, initial_fill_ink=Ink.mono
|
||||||
|
):
|
||||||
"""Interpret the PDF content stream.
|
"""Interpret the PDF content stream.
|
||||||
|
|
||||||
The stack represents the state of the PDF graphics stack. We are only
|
The stack represents the state of the PDF graphics stack. We track the
|
||||||
interested in the current transformation matrix (CTM) so we only track
|
current transformation matrix (CTM) and the current fill color (so that
|
||||||
this object; a full implementation would need to track many other items.
|
image masks, which are painted with the fill color, can be classified);
|
||||||
|
a full implementation would need to track many other items.
|
||||||
|
|
||||||
The CTM is initialized to the mapping from user space to device space.
|
The CTM is initialized to the mapping from user space to device space.
|
||||||
PDF units are 1/72". In a PDF viewer or printer this matrix is initialized
|
PDF units are 1/72". In a PDF viewer or printer this matrix is initialized
|
||||||
@@ -102,26 +161,29 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
|||||||
stack depth exceeds the spec limit and set a hard limit beyond this to
|
stack depth exceeds the spec limit and set a hard limit beyond this to
|
||||||
bound our memory requirements. If the stack underflows behavior is
|
bound our memory requirements. If the stack underflows behavior is
|
||||||
undefined in the spec, but we just pretend nothing happened and leave the
|
undefined in the spec, but we just pretend nothing happened and leave the
|
||||||
CTM unchanged.
|
graphics state unchanged.
|
||||||
"""
|
"""
|
||||||
stack = []
|
stack = []
|
||||||
ctm = Matrix(initial_shorthand)
|
ctm = Matrix(initial_shorthand)
|
||||||
|
fill_ink = initial_fill_ink # PDF default fill color is black
|
||||||
|
fill_space = '/DeviceGray' # current fill colorspace name (for sc/scn)
|
||||||
xobject_settings: list[XobjectSettings] = []
|
xobject_settings: list[XobjectSettings] = []
|
||||||
inline_images: list[InlineSettings] = []
|
inline_images: list[InlineSettings] = []
|
||||||
name_index = defaultdict(lambda: [])
|
name_index = defaultdict(lambda: [])
|
||||||
found_vector = False
|
found_vector = False
|
||||||
found_text = False
|
found_text = False
|
||||||
vector_ops = set('S s f F f* B B* b b*'.split())
|
vector_ops = set(['S', 's', 'f', 'F', 'f*', 'B', 'B*', 'b', 'b*'])
|
||||||
text_showing_ops = set("""TJ Tj " '""".split())
|
text_showing_ops = set(["TJ", "Tj", '"', "'"])
|
||||||
image_ops = set('BI ID EI q Q Do cm'.split())
|
image_ops = set(['BI', 'ID', 'EI', 'q', 'Q', 'Do', 'cm'])
|
||||||
operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops)
|
color_ops = set(['g', 'rg', 'k', 'cs', 'sc', 'scn'])
|
||||||
|
operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops | color_ops)
|
||||||
|
|
||||||
for n, graphobj in enumerate(
|
for n, graphobj in enumerate(
|
||||||
_normalize_stack(parse_content_stream(contentstream, operator_whitelist))
|
_normalize_stack(parse_content_stream(contentstream, operator_whitelist))
|
||||||
):
|
):
|
||||||
operands, operator = graphobj
|
operands, operator = graphobj
|
||||||
if operator == 'q':
|
if operator == 'q':
|
||||||
stack.append(ctm)
|
stack.append((ctm, fill_ink, fill_space))
|
||||||
if len(stack) > 32: # See docstring
|
if len(stack) > 32: # See docstring
|
||||||
if len(stack) > 128:
|
if len(stack) > 128:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -130,9 +192,9 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
|||||||
warn("PDF graphics stack overflowed spec limit")
|
warn("PDF graphics stack overflowed spec limit")
|
||||||
elif operator == 'Q':
|
elif operator == 'Q':
|
||||||
try:
|
try:
|
||||||
ctm = stack.pop()
|
ctm, fill_ink, fill_space = stack.pop()
|
||||||
except IndexError:
|
except IndexError:
|
||||||
# Keeping the ctm the same seems to be the only sensible thing
|
# Keeping the state the same seems to be the only sensible thing
|
||||||
# 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':
|
||||||
@@ -143,17 +205,51 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
|||||||
"PDF content stream is corrupt - this PDF is malformed. "
|
"PDF content stream is corrupt - this PDF is malformed. "
|
||||||
"Use a PDF editor that is capable of visually inspecting the PDF."
|
"Use a PDF editor that is capable of visually inspecting the PDF."
|
||||||
) from e
|
) from e
|
||||||
|
elif operator == 'g':
|
||||||
|
if vals := _operand_floats(operands):
|
||||||
|
fill_ink = _ink_from_components('gray', vals)
|
||||||
|
fill_space = '/DeviceGray'
|
||||||
|
elif operator == 'rg':
|
||||||
|
if vals := _operand_floats(operands):
|
||||||
|
fill_ink = _ink_from_components('rgb', vals)
|
||||||
|
fill_space = '/DeviceRGB'
|
||||||
|
elif operator == 'k':
|
||||||
|
if vals := _operand_floats(operands):
|
||||||
|
fill_ink = _ink_from_components('cmyk', vals)
|
||||||
|
fill_space = '/DeviceCMYK'
|
||||||
|
elif operator == 'cs':
|
||||||
|
# Selecting a colorspace resets the fill color to that space's
|
||||||
|
# initial value, which is black for all device colorspaces.
|
||||||
|
fill_ink = Ink.mono
|
||||||
|
if operands:
|
||||||
|
fill_space = str(operands[0])
|
||||||
|
elif operator in ('sc', 'scn'):
|
||||||
|
if any(isinstance(o, Name) for o in operands):
|
||||||
|
fill_ink = Ink.color # pattern fill
|
||||||
|
else:
|
||||||
|
space = _DEVICE_FILL_SPACE.get(fill_space)
|
||||||
|
vals = _operand_floats(operands)
|
||||||
|
if space is None or vals is None:
|
||||||
|
fill_ink = Ink.color # conservative for non-device space
|
||||||
|
else:
|
||||||
|
fill_ink = _ink_from_components(space, vals)
|
||||||
elif operator == 'Do':
|
elif operator == 'Do':
|
||||||
image_name = operands[0]
|
image_name = operands[0]
|
||||||
settings = XobjectSettings(
|
settings = XobjectSettings(
|
||||||
name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack)
|
name=image_name,
|
||||||
|
shorthand=ctm.shorthand,
|
||||||
|
stack_depth=len(stack),
|
||||||
|
fill_ink=fill_ink,
|
||||||
)
|
)
|
||||||
xobject_settings.append(settings)
|
xobject_settings.append(settings)
|
||||||
name_index[str(image_name)].append(settings)
|
name_index[str(image_name)].append(settings)
|
||||||
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
|
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
|
||||||
iimage = operands[0]
|
iimage = operands[0]
|
||||||
inline = InlineSettings(
|
inline = InlineSettings(
|
||||||
iimage=iimage, shorthand=ctm.shorthand, stack_depth=len(stack)
|
iimage=iimage,
|
||||||
|
shorthand=ctm.shorthand,
|
||||||
|
stack_depth=len(stack),
|
||||||
|
fill_ink=fill_ink,
|
||||||
)
|
)
|
||||||
inline_images.append(inline)
|
inline_images.append(inline)
|
||||||
elif operator in vector_ops:
|
elif operator in vector_ops:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from pikepdf import (
|
from pikepdf import (
|
||||||
Dictionary,
|
Dictionary,
|
||||||
@@ -20,7 +21,7 @@ from pikepdf import (
|
|||||||
UnsupportedImageTypeError,
|
UnsupportedImageTypeError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution, pikepdf_get_int
|
||||||
from ocrmypdf.pdfinfo._contentstream import (
|
from ocrmypdf.pdfinfo._contentstream import (
|
||||||
ContentsInfo,
|
ContentsInfo,
|
||||||
TextMarker,
|
TextMarker,
|
||||||
@@ -36,6 +37,7 @@ from ocrmypdf.pdfinfo._types import (
|
|||||||
UNIT_SQUARE,
|
UNIT_SQUARE,
|
||||||
Colorspace,
|
Colorspace,
|
||||||
Encoding,
|
Encoding,
|
||||||
|
Ink,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
@@ -53,6 +55,7 @@ class ImageInfo:
|
|||||||
|
|
||||||
_comp: int | None
|
_comp: int | None
|
||||||
_name: str
|
_name: str
|
||||||
|
_enc: Encoding | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -61,10 +64,12 @@ class ImageInfo:
|
|||||||
pdfimage: Object | None = None,
|
pdfimage: Object | None = None,
|
||||||
inline: PdfInlineImage | None = None,
|
inline: PdfInlineImage | None = None,
|
||||||
shorthand=None,
|
shorthand=None,
|
||||||
|
fill_ink: Ink | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize an ImageInfo."""
|
"""Initialize an ImageInfo."""
|
||||||
self._name = str(name)
|
self._name = str(name)
|
||||||
self._shorthand = shorthand
|
self._shorthand = shorthand
|
||||||
|
self._fill_ink = fill_ink
|
||||||
|
|
||||||
pim: PdfInlineImage | PdfImage
|
pim: PdfInlineImage | PdfImage
|
||||||
|
|
||||||
@@ -87,8 +92,8 @@ class ImageInfo:
|
|||||||
# itself. Some PDF writers use this to create a grayscale stencil
|
# itself. Some PDF writers use this to create a grayscale stencil
|
||||||
# mask. For our purposes, the effective size is the size of the
|
# mask. For our purposes, the effective size is the size of the
|
||||||
# larger component (image or smask).
|
# larger component (image or smask).
|
||||||
self._width = max(smask.get(Name.Width, 0), self._width)
|
self._width = max(pikepdf_get_int(smask, Name.Width), self._width)
|
||||||
self._height = max(smask.get(Name.Height, 0), self._height)
|
self._height = max(pikepdf_get_int(smask, Name.Height), self._height)
|
||||||
if (mask := pim.obj.get(Name.Mask, None)) is not None and isinstance(
|
if (mask := pim.obj.get(Name.Mask, None)) is not None and isinstance(
|
||||||
mask, Stream | Dictionary
|
mask, Stream | Dictionary
|
||||||
):
|
):
|
||||||
@@ -96,8 +101,8 @@ class ImageInfo:
|
|||||||
# /Mask can be a Stream or an Array. If it's a Stream,
|
# /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
|
# use its /Width and /Height if they are larger than the main
|
||||||
# image's.
|
# image's.
|
||||||
self._width = max(mask.get(Name.Width, 0), self._width)
|
self._width = max(pikepdf_get_int(mask, Name.Width), self._width)
|
||||||
self._height = max(mask.get(Name.Height, 0), self._height)
|
self._height = max(pikepdf_get_int(mask, Name.Height), 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
|
||||||
@@ -175,6 +180,17 @@ class ImageInfo:
|
|||||||
"""Type of image, either 'image' or 'stencil'."""
|
"""Type of image, either 'image' or 'stencil'."""
|
||||||
return self._type
|
return self._type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ink(self) -> Ink | None:
|
||||||
|
"""Fill-color classification for stencil masks, else None.
|
||||||
|
|
||||||
|
A stencil (image mask) is painted with the current fill color; this
|
||||||
|
reports whether that color is mono/gray/color so the rasterizer can
|
||||||
|
choose a device that does not discard the distinction. Non-stencil
|
||||||
|
images return None.
|
||||||
|
"""
|
||||||
|
return self._fill_ink if self._type == 'stencil' else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def width(self) -> int:
|
def width(self) -> int:
|
||||||
"""Width of the image in pixels."""
|
"""Width of the image in pixels."""
|
||||||
@@ -249,7 +265,10 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
|||||||
"""Find inline images in the contentstream."""
|
"""Find inline images in the contentstream."""
|
||||||
for n, inline in enumerate(contentsinfo.inline_images):
|
for n, inline in enumerate(contentsinfo.inline_images):
|
||||||
yield ImageInfo(
|
yield ImageInfo(
|
||||||
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
|
name=f'inline-{n:02d}',
|
||||||
|
shorthand=inline.shorthand,
|
||||||
|
inline=inline.iimage,
|
||||||
|
fill_ink=inline.fill_ink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -300,7 +319,12 @@ def _find_regular_images(
|
|||||||
# these from our DPI calculation for the page.
|
# these from our DPI calculation for the page.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
yield ImageInfo(name=draw.name, pdfimage=pdfimage, shorthand=draw.shorthand)
|
yield ImageInfo(
|
||||||
|
name=draw.name,
|
||||||
|
pdfimage=pdfimage,
|
||||||
|
shorthand=draw.shorthand,
|
||||||
|
fill_ink=draw.fill_ink,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
|
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
|
||||||
@@ -330,13 +354,19 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
|
|||||||
# but in practice both Form XObjects and multiple drawing of the
|
# but in practice both Form XObjects and multiple drawing of the
|
||||||
# same object are both very rare.
|
# same object are both very rare.
|
||||||
ctm_shorthand = settings.shorthand
|
ctm_shorthand = settings.shorthand
|
||||||
|
# A Form XObject inherits the graphics state (including fill color)
|
||||||
|
# in effect at the Do that draws it, so a mask painted with an
|
||||||
|
# inherited gray/color fill must carry that classification inward.
|
||||||
yield from _process_content_streams(
|
yield from _process_content_streams(
|
||||||
pdf=pdf, container=form_xobject, shorthand=ctm_shorthand
|
pdf=pdf,
|
||||||
|
container=form_xobject,
|
||||||
|
shorthand=ctm_shorthand,
|
||||||
|
initial_fill_ink=settings.fill_ink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _process_content_streams(
|
def _process_content_streams(
|
||||||
*, pdf: Pdf, container: Object, shorthand=None
|
*, pdf: Pdf, container: Object, shorthand=None, initial_fill_ink=Ink.mono
|
||||||
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
|
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
|
||||||
"""Find all individual instances of images drawn in the container.
|
"""Find all individual instances of images drawn in the container.
|
||||||
|
|
||||||
@@ -368,7 +398,9 @@ def _process_content_streams(
|
|||||||
# A Form XObject may provide its own matrix to map form space into
|
# A Form XObject may provide its own matrix to map form space into
|
||||||
# user space. Get this if one exists
|
# user space. Get this if one exists
|
||||||
form_shorthand = container.get(Name.Matrix, Matrix())
|
form_shorthand = container.get(Name.Matrix, Matrix())
|
||||||
form_matrix = Matrix(form_shorthand)
|
# pikepdf's Matrix() stub omits the Object/Array overload, but the
|
||||||
|
# underlying C++ implementation accepts any 6-element numeric array.
|
||||||
|
form_matrix = Matrix(cast(Matrix, form_shorthand))
|
||||||
|
|
||||||
# Concatenate form matrix with CTM to ensure CTM is correct for
|
# Concatenate form matrix with CTM to ensure CTM is correct for
|
||||||
# drawing this instance of the XObject
|
# drawing this instance of the XObject
|
||||||
@@ -377,7 +409,7 @@ def _process_content_streams(
|
|||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
contentsinfo = _interpret_contents(container, initial_shorthand)
|
contentsinfo = _interpret_contents(container, initial_shorthand, initial_fill_ink)
|
||||||
|
|
||||||
if contentsinfo.found_vector:
|
if contentsinfo.found_vector:
|
||||||
yield VectorMarker()
|
yield VectorMarker()
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ class Encoding(Enum):
|
|||||||
flate_jpeg = auto()
|
flate_jpeg = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class Ink(Enum):
|
||||||
|
"""Classification of the fill color used to paint a stencil image mask.
|
||||||
|
|
||||||
|
A stencil (image mask) is painted with the current fill color, so the
|
||||||
|
color depth needed to rasterize it for OCR depends on that fill color,
|
||||||
|
not on the mask's 1-bit data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
mono = auto() # black (or no color information to preserve)
|
||||||
|
gray = auto() # achromatic but not pure black
|
||||||
|
color = auto() # chromatic, or a fill we cannot prove is achromatic
|
||||||
|
|
||||||
|
|
||||||
FloatRect = tuple[float, float, float, float]
|
FloatRect = tuple[float, float, float, float]
|
||||||
|
|
||||||
FRIENDLY_COLORSPACE: dict[str, Colorspace] = {
|
FRIENDLY_COLORSPACE: dict[str, Colorspace] = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Container, Sequence
|
from collections.abc import Container
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -28,7 +28,7 @@ logger = logging.getLogger()
|
|||||||
worker_pdf = None # pylint: disable=invalid-name
|
worker_pdf = None # pylint: disable=invalid-name
|
||||||
|
|
||||||
|
|
||||||
def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel):
|
def _pdf_pageinfo_sync_init(pdf: Pdf | None, infile: Path, pdfminer_loglevel):
|
||||||
global worker_pdf # pylint: disable=global-statement,invalid-name
|
global worker_pdf # pylint: disable=global-statement,invalid-name
|
||||||
pikepdf_enable_mmap()
|
pikepdf_enable_mmap()
|
||||||
|
|
||||||
@@ -75,16 +75,16 @@ def _pdf_pageinfo_sync(
|
|||||||
|
|
||||||
|
|
||||||
def _pdf_pageinfo_concurrent(
|
def _pdf_pageinfo_concurrent(
|
||||||
pdf,
|
pdf: Pdf,
|
||||||
executor: Executor,
|
executor: Executor,
|
||||||
max_workers: int,
|
max_workers: int | None,
|
||||||
use_threads: bool,
|
use_threads: bool,
|
||||||
infile,
|
infile: Path,
|
||||||
progbar,
|
progbar: bool,
|
||||||
check_pages,
|
check_pages: Container[int],
|
||||||
detailed_analysis: bool = False,
|
detailed_analysis: bool = False,
|
||||||
miner_state: PdfMinerState | None = None,
|
miner_state: PdfMinerState | None = None,
|
||||||
) -> Sequence[PageInfo | None]:
|
) -> list[PageInfo | None]:
|
||||||
pages: list[PageInfo | None] = [None] * len(pdf.pages)
|
pages: list[PageInfo | None] = [None] * len(pdf.pages)
|
||||||
|
|
||||||
def update_pageinfo(page: PageInfo, pbar: ProgressBar):
|
def update_pageinfo(page: PageInfo, pbar: ProgressBar):
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ from pathlib import Path
|
|||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from pdfminer.layout import LTPage, LTTextBox
|
from pdfminer.layout import LTPage, LTTextBox
|
||||||
from pikepdf import Name, Page, Pdf
|
from pikepdf import Name, Object, Page, Pdf
|
||||||
|
|
||||||
from ocrmypdf._concurrent import Executor, SerialExecutor
|
from ocrmypdf._concurrent import Executor, SerialExecutor
|
||||||
|
from ocrmypdf._pageboxes import coerce_box
|
||||||
from ocrmypdf.exceptions import EncryptedPdfError
|
from ocrmypdf.exceptions import EncryptedPdfError
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution, pikepdf_get_bool, pikepdf_get_int
|
||||||
from ocrmypdf.pdfinfo._contentstream import TextboxInfo, TextMarker, VectorMarker
|
from ocrmypdf.pdfinfo._contentstream import TextboxInfo, TextMarker, VectorMarker
|
||||||
from ocrmypdf.pdfinfo._image import ImageInfo, _process_content_streams
|
from ocrmypdf.pdfinfo._image import ImageInfo, _process_content_streams
|
||||||
from ocrmypdf.pdfinfo._types import FloatRect
|
from ocrmypdf.pdfinfo._types import FloatRect
|
||||||
@@ -34,6 +35,12 @@ from ocrmypdf.pdfinfo.layout import (
|
|||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
def _box_rect(values: Iterable) -> FloatRect:
|
||||||
|
"""Coerce a page box to a normalized ``FloatRect`` (4-tuple)."""
|
||||||
|
b = coerce_box(values)
|
||||||
|
return (b[0], b[1], b[2], b[3])
|
||||||
|
|
||||||
|
|
||||||
def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool:
|
def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool:
|
||||||
"""Smarter text detection that ignores text in margins."""
|
"""Smarter text detection that ignores text in margins."""
|
||||||
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
|
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
|
||||||
@@ -140,19 +147,23 @@ class PageInfo:
|
|||||||
miner_state: PdfMinerState | None,
|
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(str(d)) for d in coerce_box(page.mediabox.as_list())]
|
||||||
width_pt = mediabox[2] - mediabox[0]
|
width_pt = mediabox[2] - mediabox[0]
|
||||||
height_pt = mediabox[3] - mediabox[1]
|
height_pt = mediabox[3] - mediabox[1]
|
||||||
|
|
||||||
self._artbox = [float(d) for d in page.artbox.as_list()]
|
self._artbox = _box_rect(page.artbox.as_list())
|
||||||
self._bleedbox = [float(d) for d in page.bleedbox.as_list()]
|
self._bleedbox = _box_rect(page.bleedbox.as_list())
|
||||||
self._cropbox = [float(d) for d in page.cropbox.as_list()]
|
self._cropbox = _box_rect(page.cropbox.as_list())
|
||||||
self._mediabox = [float(d) for d in page.mediabox.as_list()]
|
self._mediabox = _box_rect(page.mediabox.as_list())
|
||||||
self._trimbox = [float(d) for d in page.trimbox.as_list()]
|
self._trimbox = _box_rect(page.trimbox.as_list())
|
||||||
|
|
||||||
check_this_page = pageno in check_pages
|
check_this_page = pageno in check_pages
|
||||||
|
|
||||||
if check_this_page and detailed_analysis:
|
if check_this_page and detailed_analysis:
|
||||||
|
# miner_state is only None when detailed_analysis is False (see
|
||||||
|
# PdfInfo.__init__, which ties the two together), so it must be
|
||||||
|
# set here.
|
||||||
|
assert miner_state is not None
|
||||||
page_analysis = miner_state.get_page_analysis(pageno)
|
page_analysis = miner_state.get_page_analysis(pageno)
|
||||||
if page_analysis is not None:
|
if page_analysis is not None:
|
||||||
self._textboxes = list(
|
self._textboxes = list(
|
||||||
@@ -168,7 +179,11 @@ class PageInfo:
|
|||||||
self._has_text = None # i.e. "no information"
|
self._has_text = None # i.e. "no information"
|
||||||
|
|
||||||
userunit = page.get(Name.UserUnit, Decimal(1.0))
|
userunit = page.get(Name.UserUnit, Decimal(1.0))
|
||||||
if not isinstance(userunit, Decimal):
|
if isinstance(userunit, Object):
|
||||||
|
# Only reachable under pikepdf's explicit conversion mode; the
|
||||||
|
# default (implicit) mode already unboxes to int/float/Decimal.
|
||||||
|
userunit = Decimal(userunit.as_float())
|
||||||
|
elif not isinstance(userunit, Decimal):
|
||||||
userunit = Decimal(userunit)
|
userunit = Decimal(userunit)
|
||||||
self._userunit = userunit
|
self._userunit = userunit
|
||||||
self._width_inches = width_pt * userunit / Decimal(72.0)
|
self._width_inches = width_pt * userunit / Decimal(72.0)
|
||||||
@@ -182,7 +197,7 @@ class PageInfo:
|
|||||||
self._has_text = False
|
self._has_text = False
|
||||||
self._images = []
|
self._images = []
|
||||||
for info in _process_content_streams(
|
for info in _process_content_streams(
|
||||||
pdf=pdf, container=page, shorthand=userunit_shorthand
|
pdf=pdf, container=page.obj, shorthand=userunit_shorthand
|
||||||
):
|
):
|
||||||
if isinstance(info, VectorMarker):
|
if isinstance(info, VectorMarker):
|
||||||
self._has_vector = True
|
self._has_vector = True
|
||||||
@@ -398,6 +413,7 @@ class PdfInfo:
|
|||||||
_has_acroform: bool = False
|
_has_acroform: bool = False
|
||||||
_has_signature: bool = False
|
_has_signature: bool = False
|
||||||
_needs_rendering: bool = False
|
_needs_rendering: bool = False
|
||||||
|
_has_structure_tree: bool = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -438,17 +454,23 @@ class PdfInfo:
|
|||||||
detailed_analysis=detailed_analysis,
|
detailed_analysis=detailed_analysis,
|
||||||
miner_state=miner_state,
|
miner_state=miner_state,
|
||||||
)
|
)
|
||||||
self._needs_rendering = pdf.Root.get(Name.NeedsRendering, False)
|
self._needs_rendering = pikepdf_get_bool(pdf.Root, Name.NeedsRendering)
|
||||||
if Name.AcroForm in pdf.Root:
|
if Name.AcroForm in pdf.Root:
|
||||||
if (
|
if (
|
||||||
len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0
|
len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0
|
||||||
or Name.XFA in pdf.Root.AcroForm
|
or Name.XFA in pdf.Root.AcroForm
|
||||||
):
|
):
|
||||||
self._has_acroform = True
|
self._has_acroform = True
|
||||||
self._has_signature = bool(pdf.Root.AcroForm.get(Name.SigFlags, 0) & 1)
|
self._has_signature = bool(
|
||||||
self._is_tagged = bool(
|
pikepdf_get_int(pdf.Root.AcroForm, Name.SigFlags) & 1
|
||||||
pdf.Root.get(Name.MarkInfo, {}).get(Name.Marked, False)
|
)
|
||||||
|
mark_info = pdf.Root.get(Name.MarkInfo)
|
||||||
|
self._is_tagged = (
|
||||||
|
pikepdf_get_bool(mark_info, Name.Marked)
|
||||||
|
if mark_info is not None
|
||||||
|
else False
|
||||||
)
|
)
|
||||||
|
self._has_structure_tree = Name.StructTreeRoot in pdf.Root
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pages(self) -> list[PageInfo | None]:
|
def pages(self) -> list[PageInfo | None]:
|
||||||
@@ -481,6 +503,11 @@ class PdfInfo:
|
|||||||
"""Return True if the document catalog indicates this is a Tagged PDF."""
|
"""Return True if the document catalog indicates this is a Tagged PDF."""
|
||||||
return self._is_tagged
|
return self._is_tagged
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_structure_tree(self) -> bool:
|
||||||
|
"""Return True if the document catalog has a logical structure tree."""
|
||||||
|
return self._has_structure_tree
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> str | Path:
|
def filename(self) -> str | Path:
|
||||||
"""Return filename of PDF."""
|
"""Return filename of PDF."""
|
||||||
@@ -523,6 +550,8 @@ def main(): # pragma: no cover
|
|||||||
pprint(pdfinfo)
|
pprint(pdfinfo)
|
||||||
for page in pdfinfo.pages:
|
for page in pdfinfo.pages:
|
||||||
pprint(page)
|
pprint(page)
|
||||||
|
if page is None:
|
||||||
|
continue
|
||||||
for im in page.images:
|
for im in page.images:
|
||||||
pprint(im)
|
pprint(im)
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from collections.abc import Iterator, Mapping
|
from collections.abc import Iterator, Mapping
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from math import copysign
|
from math import copysign
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, BinaryIO
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pdfminer
|
import pdfminer
|
||||||
import pdfminer.encodingdb
|
import pdfminer.encodingdb
|
||||||
import pdfminer.pdfdevice
|
import pdfminer.pdfdevice
|
||||||
import pdfminer.pdfinterp
|
import pdfminer.pdfinterp
|
||||||
import pdfminer.psparser
|
|
||||||
from 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
|
||||||
@@ -31,6 +30,11 @@ from pdfminer.utils import Matrix, bbox2str, matrix2str
|
|||||||
|
|
||||||
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 13):
|
||||||
|
from warnings import deprecated
|
||||||
|
else:
|
||||||
|
from typing_extensions import deprecated
|
||||||
|
|
||||||
STRIP_NAME = re.compile(r'[0-9]+')
|
STRIP_NAME = re.compile(r'[0-9]+')
|
||||||
|
|
||||||
|
|
||||||
@@ -58,12 +62,7 @@ def pdfsimplefont__init__(
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
PDFSimpleFont.__init__ = pdfsimplefont__init__
|
PDFSimpleFont.__init__ = pdfsimplefont__init__ # type: ignore[method-assign]
|
||||||
|
|
||||||
# Patch pdfminer.six buffer size
|
|
||||||
# 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):
|
||||||
@@ -290,7 +289,7 @@ def patch_pdfminer(pscript5_mode: bool):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@deprecated(deprecated_in='16.6.0', details='Use PdfMinerState instead.')
|
@deprecated('Deprecated since 16.6.0; 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:
|
||||||
@@ -338,10 +337,10 @@ class PdfMinerState:
|
|||||||
self.infile = infile
|
self.infile = infile
|
||||||
self.rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
self.rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
||||||
self.disable_boxes_flow = None
|
self.disable_boxes_flow = None
|
||||||
self.page_iter = None
|
self.page_iter: Iterator[PDFPage] | None = None
|
||||||
self.page_cache: list[PDFPage] = []
|
self.page_cache: list[PDFPage] = []
|
||||||
self.pscript5_mode = pscript5_mode
|
self.pscript5_mode = pscript5_mode
|
||||||
self.file = None
|
self.file: BinaryIO | None = None
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Enter the context manager."""
|
"""Enter the context manager."""
|
||||||
@@ -357,6 +356,7 @@ class PdfMinerState:
|
|||||||
|
|
||||||
def get_page_analysis(self, pageno: int):
|
def get_page_analysis(self, pageno: int):
|
||||||
"""Get the page analysis for a given page."""
|
"""Get the page analysis for a given page."""
|
||||||
|
assert self.page_iter is not None, "must be used as a context manager"
|
||||||
while len(self.page_cache) <= pageno:
|
while len(self.page_cache) <= pageno:
|
||||||
try:
|
try:
|
||||||
self.page_cache.append(next(self.page_iter))
|
self.page_cache.append(next(self.page_iter))
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class GhostscriptRasterDevice(StrEnum):
|
|||||||
JPEGGRAY = 'jpeggray'
|
JPEGGRAY = 'jpeggray'
|
||||||
JPEGCOLOR = 'jpeg'
|
JPEGCOLOR = 'jpeg'
|
||||||
PNGMONO = 'pngmono'
|
PNGMONO = 'pngmono'
|
||||||
|
PNGMONOD = 'pngmonod'
|
||||||
PNGGRAY = 'pnggray'
|
PNGGRAY = 'pnggray'
|
||||||
PNG256 = 'png256'
|
PNG256 = 'png256'
|
||||||
PNG16M = 'png16m'
|
PNG16M = 'png16m'
|
||||||
|
|||||||
@@ -1,345 +1,31 @@
|
|||||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
"""Wrappers to manage subprocess calls."""
|
"""Wrappers to manage subprocess calls.
|
||||||
|
|
||||||
|
This package is split into three private submodules by concern:
|
||||||
|
|
||||||
|
- :mod:`ocrmypdf.subprocess._run` - low-level execution wrappers (``run``,
|
||||||
|
``run_polling_stderr``) that add OCRmyPDF-aware logging and Windows PATH
|
||||||
|
resolution. Useful as drop-in replacements for :func:`subprocess.run`.
|
||||||
|
- :mod:`ocrmypdf.subprocess._version` - version probing (``get_version``).
|
||||||
|
- :mod:`ocrmypdf.subprocess._check` - startup validation
|
||||||
|
(``check_external_program``) with platform-aware error messages.
|
||||||
|
|
||||||
|
The names below are the stable public API. Importing from the private
|
||||||
|
submodules directly is not supported for external code.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from ocrmypdf.subprocess._check import check_external_program
|
||||||
import os
|
from ocrmypdf.subprocess._run import Args, Environ, run, run_polling_stderr
|
||||||
import re
|
from ocrmypdf.subprocess._version import get_version
|
||||||
import sys
|
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
|
||||||
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
|
|
||||||
from subprocess import run as subprocess_run
|
|
||||||
|
|
||||||
from packaging.version import Version
|
__all__ = [
|
||||||
|
'Args',
|
||||||
from ocrmypdf.exceptions import MissingDependencyError
|
'Environ',
|
||||||
|
'check_external_program',
|
||||||
# pylint: disable=logging-format-interpolation
|
'get_version',
|
||||||
|
'run',
|
||||||
log = logging.getLogger(__name__)
|
'run_polling_stderr',
|
||||||
|
]
|
||||||
Args = Sequence[Path | str]
|
|
||||||
Environ = Mapping[str, str] | os._Environ # pylint: disable=protected-access
|
|
||||||
|
|
||||||
|
|
||||||
def run(
|
|
||||||
args: Args,
|
|
||||||
*,
|
|
||||||
env: Environ | None = None,
|
|
||||||
logs_errors_to_stdout: bool = False,
|
|
||||||
check: bool = False,
|
|
||||||
**kwargs,
|
|
||||||
) -> CompletedProcess:
|
|
||||||
"""Wrapper around :py:func:`subprocess.run`.
|
|
||||||
|
|
||||||
The main purpose of this wrapper is to log subprocess output in an orderly
|
|
||||||
fashion that identifies the responsible subprocess. An additional
|
|
||||||
task is that this function goes to greater lengths to find possible Windows
|
|
||||||
locations of our dependencies when they are not on the system PATH.
|
|
||||||
|
|
||||||
Arguments should be identical to ``subprocess.run``, except for following:
|
|
||||||
|
|
||||||
Args:
|
|
||||||
args: Positional arguments to pass to ``subprocess.run``.
|
|
||||||
env: A set of environment variables. If None, the OS environment is used.
|
|
||||||
logs_errors_to_stdout: If True, indicates that the process writes its error
|
|
||||||
messages to stdout rather than stderr, so stdout should be logged
|
|
||||||
if there is an error. If False, stderr is logged. Could be used with
|
|
||||||
stderr=STDOUT, stdout=PIPE for example.
|
|
||||||
check: If True, raise an exception if the process exits with a non-zero
|
|
||||||
status code. If False, the return value will indicate success or failure.
|
|
||||||
kwargs: Additional arguments to pass to ``subprocess.run``.
|
|
||||||
"""
|
|
||||||
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
|
|
||||||
|
|
||||||
stderr = None
|
|
||||||
stderr_name = 'stderr' if not logs_errors_to_stdout else 'stdout'
|
|
||||||
try:
|
|
||||||
proc = subprocess_run(args, env=env, check=check, **kwargs)
|
|
||||||
except CalledProcessError as e:
|
|
||||||
stderr = getattr(e, stderr_name, None)
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
stderr = getattr(proc, stderr_name, None)
|
|
||||||
finally:
|
|
||||||
if process_log.isEnabledFor(logging.DEBUG) and stderr:
|
|
||||||
with suppress(AttributeError, UnicodeDecodeError):
|
|
||||||
stderr = stderr.decode('utf-8', 'replace')
|
|
||||||
if logs_errors_to_stdout:
|
|
||||||
process_log.debug("stdout/stderr = %s", stderr)
|
|
||||||
else:
|
|
||||||
process_log.debug("stderr = %s", stderr)
|
|
||||||
return proc
|
|
||||||
|
|
||||||
|
|
||||||
def run_polling_stderr(
|
|
||||||
args: Args,
|
|
||||||
*,
|
|
||||||
callback: Callable[[str], None],
|
|
||||||
check: bool = False,
|
|
||||||
env: Environ | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> CompletedProcess:
|
|
||||||
"""Run a process like ``ocrmypdf.subprocess.run``, and poll stderr.
|
|
||||||
|
|
||||||
Every line of produced by stderr will be forwarded to the callback function.
|
|
||||||
The intended use is monitoring progress of subprocesses that output their
|
|
||||||
own progress indicators. In addition, each line will be logged if debug
|
|
||||||
logging is enabled.
|
|
||||||
|
|
||||||
Requires stderr to be opened in text mode for ease of handling errors. In
|
|
||||||
addition the expected encoding= and errors= arguments should be set. Note
|
|
||||||
that if stdout is already set up, it need not be binary.
|
|
||||||
"""
|
|
||||||
args, env, process_log, text = _fix_process_args(args, env, kwargs)
|
|
||||||
assert text, "Must use text=True"
|
|
||||||
|
|
||||||
with Popen(args, env=env, **kwargs) as proc:
|
|
||||||
lines = []
|
|
||||||
while proc.poll() is None:
|
|
||||||
if proc.stderr is None:
|
|
||||||
continue
|
|
||||||
for msg in iter(proc.stderr.readline, ''):
|
|
||||||
if process_log.isEnabledFor(logging.DEBUG):
|
|
||||||
process_log.debug(msg.strip())
|
|
||||||
callback(msg)
|
|
||||||
lines.append(msg)
|
|
||||||
stderr = ''.join(lines)
|
|
||||||
|
|
||||||
if check and proc.returncode != 0:
|
|
||||||
raise CalledProcessError(proc.returncode, args, output=None, stderr=stderr)
|
|
||||||
return CompletedProcess(args, proc.returncode, None, stderr=stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def _fix_process_args(
|
|
||||||
args: Args, env: Environ | None, kwargs
|
|
||||||
) -> tuple[Args, Environ, logging.Logger, bool]:
|
|
||||||
if not env:
|
|
||||||
env = os.environ
|
|
||||||
|
|
||||||
# Search in spoof path if necessary
|
|
||||||
program = str(args[0])
|
|
||||||
|
|
||||||
if sys.platform == 'win32':
|
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
from ocrmypdf.subprocess._windows import fix_windows_args
|
|
||||||
|
|
||||||
args = fix_windows_args(program, args, env)
|
|
||||||
|
|
||||||
log.debug("Running: %s", args)
|
|
||||||
process_log = log.getChild(os.path.basename(program))
|
|
||||||
text = bool(kwargs.get('text', False))
|
|
||||||
|
|
||||||
return args, env, process_log, text
|
|
||||||
|
|
||||||
|
|
||||||
def get_version(
|
|
||||||
program: str,
|
|
||||||
*,
|
|
||||||
version_arg: str = '--version',
|
|
||||||
regex=r'(\d+(\.\d+)*)',
|
|
||||||
env: Environ | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Get the version of the specified program.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
program: The program to version check.
|
|
||||||
version_arg: The argument needed to ask for its version, e.g. ``--version``.
|
|
||||||
regex: A regular expression to parse the program's output and obtain the
|
|
||||||
version.
|
|
||||||
env: Custom ``os.environ`` in which to run program.
|
|
||||||
"""
|
|
||||||
args_prog = [program, version_arg]
|
|
||||||
try:
|
|
||||||
proc = run(
|
|
||||||
args_prog,
|
|
||||||
close_fds=True,
|
|
||||||
text=True,
|
|
||||||
stdout=PIPE,
|
|
||||||
stderr=STDOUT,
|
|
||||||
check=True,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
output: str = proc.stdout
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
raise MissingDependencyError(
|
|
||||||
f"Could not find program '{program}' on the PATH"
|
|
||||||
) from e
|
|
||||||
except CalledProcessError as e:
|
|
||||||
if e.returncode != 0:
|
|
||||||
log.exception(e)
|
|
||||||
raise MissingDependencyError(
|
|
||||||
f"Ran program '{program}' but it exited with an error:\n{e.output}"
|
|
||||||
) from e
|
|
||||||
raise MissingDependencyError(
|
|
||||||
f"Could not find program '{program}' on the PATH"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
match = re.match(regex, output.strip())
|
|
||||||
if not match:
|
|
||||||
raise MissingDependencyError(
|
|
||||||
f"The program '{program}' did not report its version. "
|
|
||||||
f"Message was:\n{output}"
|
|
||||||
)
|
|
||||||
version = match.group(1)
|
|
||||||
|
|
||||||
return version
|
|
||||||
|
|
||||||
|
|
||||||
MISSING_PROGRAM = '''
|
|
||||||
The program '{program}' could not be executed or was not found on your
|
|
||||||
system PATH.
|
|
||||||
'''
|
|
||||||
|
|
||||||
MISSING_OPTIONAL_PROGRAM = '''
|
|
||||||
The program '{program}' could not be executed or was not found on your
|
|
||||||
system PATH. This program is required when you use the
|
|
||||||
{required_for} arguments. You could try omitting these arguments, or install
|
|
||||||
the package.
|
|
||||||
'''
|
|
||||||
|
|
||||||
MISSING_RECOMMEND_PROGRAM = '''
|
|
||||||
The program '{program}' could not be executed or was not found on your
|
|
||||||
system PATH. This program is recommended when using the {required_for} arguments,
|
|
||||||
but not required, so we will proceed. For best results, install the program.
|
|
||||||
'''
|
|
||||||
|
|
||||||
OLD_VERSION = '''
|
|
||||||
OCRmyPDF requires '{program}' {need_version} or higher. Your system appears
|
|
||||||
to have {found_version}. Please update this program.
|
|
||||||
'''
|
|
||||||
|
|
||||||
OLD_VERSION_REQUIRED_FOR = '''
|
|
||||||
OCRmyPDF requires '{program}' {need_version} or higher when run with the
|
|
||||||
{required_for} arguments. {program} {found_version} is installed.
|
|
||||||
|
|
||||||
If you omit these arguments, OCRmyPDF may be able to
|
|
||||||
proceed. For best results, update the program.
|
|
||||||
'''
|
|
||||||
|
|
||||||
OSX_INSTALL_ADVICE = '''
|
|
||||||
If you have homebrew installed, try these command to install the missing
|
|
||||||
package:
|
|
||||||
brew install {package}
|
|
||||||
'''
|
|
||||||
|
|
||||||
LINUX_INSTALL_ADVICE = '''
|
|
||||||
On systems with the aptitude package manager (Debian, Ubuntu), try these
|
|
||||||
commands:
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install {package}
|
|
||||||
|
|
||||||
On RPM-based systems (Red Hat, Fedora), try this command:
|
|
||||||
sudo dnf install {package}
|
|
||||||
'''
|
|
||||||
|
|
||||||
WINDOWS_INSTALL_ADVICE = '''
|
|
||||||
If not already installed, install the Chocolatey package manager. Then use
|
|
||||||
a command prompt to install the missing package:
|
|
||||||
choco install {package}
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _get_platform() -> str:
|
|
||||||
if sys.platform.startswith('freebsd'):
|
|
||||||
return 'freebsd'
|
|
||||||
elif sys.platform.startswith('linux'):
|
|
||||||
return 'linux'
|
|
||||||
elif sys.platform.startswith('win'):
|
|
||||||
return 'windows'
|
|
||||||
return sys.platform
|
|
||||||
|
|
||||||
|
|
||||||
def _error_trailer(program: str, package: str | Mapping[str, str], **kwargs) -> None:
|
|
||||||
del kwargs
|
|
||||||
if isinstance(package, Mapping):
|
|
||||||
package = package.get(_get_platform(), program)
|
|
||||||
|
|
||||||
if _get_platform() == 'darwin':
|
|
||||||
log.info(OSX_INSTALL_ADVICE.format(**locals()))
|
|
||||||
elif _get_platform() == 'linux':
|
|
||||||
log.info(LINUX_INSTALL_ADVICE.format(**locals()))
|
|
||||||
elif _get_platform() == 'windows':
|
|
||||||
log.info(WINDOWS_INSTALL_ADVICE.format(**locals()))
|
|
||||||
|
|
||||||
|
|
||||||
def _error_missing_program(
|
|
||||||
program: str, package: str, required_for: str | None, recommended: bool
|
|
||||||
) -> None:
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
if recommended:
|
|
||||||
log.warning(MISSING_RECOMMEND_PROGRAM.format(**locals()))
|
|
||||||
elif required_for:
|
|
||||||
log.error(MISSING_OPTIONAL_PROGRAM.format(**locals()))
|
|
||||||
else:
|
|
||||||
log.error(MISSING_PROGRAM.format(**locals()))
|
|
||||||
_error_trailer(**locals())
|
|
||||||
|
|
||||||
|
|
||||||
def _error_old_version(
|
|
||||||
program: str,
|
|
||||||
package: str,
|
|
||||||
need_version: str,
|
|
||||||
found_version: str,
|
|
||||||
required_for: str | None,
|
|
||||||
) -> None:
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
if required_for:
|
|
||||||
log.error(OLD_VERSION_REQUIRED_FOR.format(**locals()))
|
|
||||||
else:
|
|
||||||
log.error(OLD_VERSION.format(**locals()))
|
|
||||||
_error_trailer(**locals())
|
|
||||||
|
|
||||||
|
|
||||||
def check_external_program(
|
|
||||||
*,
|
|
||||||
program: str,
|
|
||||||
package: str,
|
|
||||||
version_checker: Callable[[], Version],
|
|
||||||
need_version: str | Version,
|
|
||||||
required_for: str | None = None,
|
|
||||||
recommended: bool = False,
|
|
||||||
version_parser: type[Version] = Version,
|
|
||||||
) -> None:
|
|
||||||
"""Check for required version of external program and raise exception if not.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
program: The name of the program to test.
|
|
||||||
package: The name of a software package that typically supplies this program.
|
|
||||||
Usually the same as program.
|
|
||||||
version_checker: A callable without arguments that retrieves the installed
|
|
||||||
version of program.
|
|
||||||
need_version: The minimum required version.
|
|
||||||
required_for: The name of an argument of feature that requires this program.
|
|
||||||
recommended: If this external program is recommended, instead of raising
|
|
||||||
an exception, log a warning and allow execution to continue.
|
|
||||||
version_parser: A class that should be used to parse and compare version
|
|
||||||
numbers. Used when version numbers do not follow standard conventions.
|
|
||||||
"""
|
|
||||||
if not isinstance(need_version, Version):
|
|
||||||
need_version = version_parser(need_version)
|
|
||||||
try:
|
|
||||||
found_version = version_checker()
|
|
||||||
except (CalledProcessError, FileNotFoundError) as e:
|
|
||||||
_error_missing_program(program, package, required_for, recommended)
|
|
||||||
if not recommended:
|
|
||||||
raise MissingDependencyError(program) from e
|
|
||||||
return
|
|
||||||
except MissingDependencyError:
|
|
||||||
_error_missing_program(program, package, required_for, recommended)
|
|
||||||
if not recommended:
|
|
||||||
raise
|
|
||||||
return
|
|
||||||
|
|
||||||
if found_version and found_version < need_version:
|
|
||||||
_error_old_version(
|
|
||||||
program, package, str(need_version), str(found_version), required_for
|
|
||||||
)
|
|
||||||
if not recommended:
|
|
||||||
raise MissingDependencyError(program)
|
|
||||||
|
|
||||||
log.debug('Found %s %s', program, found_version)
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
"""Validate that required external programs are installed and new enough."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from subprocess import CalledProcessError
|
||||||
|
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
|
|
||||||
|
log = logging.getLogger('ocrmypdf.subprocess')
|
||||||
|
|
||||||
|
|
||||||
|
MISSING_PROGRAM = '''
|
||||||
|
The program '{program}' could not be executed or was not found on your
|
||||||
|
system PATH.
|
||||||
|
'''
|
||||||
|
|
||||||
|
MISSING_OPTIONAL_PROGRAM = '''
|
||||||
|
The program '{program}' could not be executed or was not found on your
|
||||||
|
system PATH. This program is required when you use the
|
||||||
|
{required_for} arguments. You could try omitting these arguments, or install
|
||||||
|
the package.
|
||||||
|
'''
|
||||||
|
|
||||||
|
MISSING_RECOMMEND_PROGRAM = '''
|
||||||
|
The program '{program}' could not be executed or was not found on your
|
||||||
|
system PATH. This program is recommended when using the {required_for} arguments,
|
||||||
|
but not required, so we will proceed. For best results, install the program.
|
||||||
|
'''
|
||||||
|
|
||||||
|
OLD_VERSION = '''
|
||||||
|
OCRmyPDF requires '{program}' {need_version} or higher. Your system appears
|
||||||
|
to have {found_version}. Please update this program.
|
||||||
|
'''
|
||||||
|
|
||||||
|
OLD_VERSION_REQUIRED_FOR = '''
|
||||||
|
OCRmyPDF requires '{program}' {need_version} or higher when run with the
|
||||||
|
{required_for} arguments. {program} {found_version} is installed.
|
||||||
|
|
||||||
|
If you omit these arguments, OCRmyPDF may be able to
|
||||||
|
proceed. For best results, update the program.
|
||||||
|
'''
|
||||||
|
|
||||||
|
OSX_INSTALL_ADVICE = '''
|
||||||
|
If you have homebrew installed, try these command to install the missing
|
||||||
|
package:
|
||||||
|
brew install {package}
|
||||||
|
'''
|
||||||
|
|
||||||
|
LINUX_INSTALL_ADVICE = '''
|
||||||
|
On systems with the aptitude package manager (Debian, Ubuntu), try these
|
||||||
|
commands:
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install {package}
|
||||||
|
|
||||||
|
On RPM-based systems (Red Hat, Fedora), try this command:
|
||||||
|
sudo dnf install {package}
|
||||||
|
'''
|
||||||
|
|
||||||
|
WINDOWS_INSTALL_ADVICE = '''
|
||||||
|
If not already installed, install the Chocolatey package manager. Then use
|
||||||
|
a command prompt to install the missing package:
|
||||||
|
choco install {package}
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _get_platform() -> str:
|
||||||
|
if sys.platform.startswith('freebsd'):
|
||||||
|
return 'freebsd'
|
||||||
|
elif sys.platform.startswith('linux'):
|
||||||
|
return 'linux'
|
||||||
|
elif sys.platform.startswith('win'):
|
||||||
|
return 'windows'
|
||||||
|
return sys.platform
|
||||||
|
|
||||||
|
|
||||||
|
def _error_trailer(program: str, package: str | Mapping[str, str], **kwargs) -> None:
|
||||||
|
del kwargs
|
||||||
|
if isinstance(package, Mapping):
|
||||||
|
package = package.get(_get_platform(), program)
|
||||||
|
|
||||||
|
if _get_platform() == 'darwin':
|
||||||
|
log.info(OSX_INSTALL_ADVICE.format(**locals()))
|
||||||
|
elif _get_platform() == 'linux':
|
||||||
|
log.info(LINUX_INSTALL_ADVICE.format(**locals()))
|
||||||
|
elif _get_platform() == 'windows':
|
||||||
|
log.info(WINDOWS_INSTALL_ADVICE.format(**locals()))
|
||||||
|
|
||||||
|
|
||||||
|
def _error_missing_program(
|
||||||
|
program: str,
|
||||||
|
package: str | Mapping[str, str],
|
||||||
|
required_for: str | None,
|
||||||
|
recommended: bool,
|
||||||
|
) -> None:
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
if recommended:
|
||||||
|
log.warning(MISSING_RECOMMEND_PROGRAM.format(**locals()))
|
||||||
|
elif required_for:
|
||||||
|
log.error(MISSING_OPTIONAL_PROGRAM.format(**locals()))
|
||||||
|
else:
|
||||||
|
log.error(MISSING_PROGRAM.format(**locals()))
|
||||||
|
_error_trailer(**locals())
|
||||||
|
|
||||||
|
|
||||||
|
def _error_old_version(
|
||||||
|
program: str,
|
||||||
|
package: str | Mapping[str, str],
|
||||||
|
need_version: str,
|
||||||
|
found_version: str,
|
||||||
|
required_for: str | None,
|
||||||
|
) -> None:
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
if required_for:
|
||||||
|
log.error(OLD_VERSION_REQUIRED_FOR.format(**locals()))
|
||||||
|
else:
|
||||||
|
log.error(OLD_VERSION.format(**locals()))
|
||||||
|
_error_trailer(**locals())
|
||||||
|
|
||||||
|
|
||||||
|
def check_external_program(
|
||||||
|
*,
|
||||||
|
program: str,
|
||||||
|
package: str | Mapping[str, str],
|
||||||
|
version_checker: Callable[[], Version],
|
||||||
|
need_version: str | Version,
|
||||||
|
required_for: str | None = None,
|
||||||
|
recommended: bool = False,
|
||||||
|
version_parser: type[Version] = Version,
|
||||||
|
) -> None:
|
||||||
|
"""Check for required version of external program and raise exception if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
program: The name of the program to test.
|
||||||
|
package: The name of a software package that typically supplies this program.
|
||||||
|
Usually the same as program.
|
||||||
|
version_checker: A callable without arguments that retrieves the installed
|
||||||
|
version of program.
|
||||||
|
need_version: The minimum required version.
|
||||||
|
required_for: The name of an argument of feature that requires this program.
|
||||||
|
recommended: If this external program is recommended, instead of raising
|
||||||
|
an exception, log a warning and allow execution to continue.
|
||||||
|
version_parser: A class that should be used to parse and compare version
|
||||||
|
numbers. Used when version numbers do not follow standard conventions.
|
||||||
|
"""
|
||||||
|
if not isinstance(need_version, Version):
|
||||||
|
need_version = version_parser(need_version)
|
||||||
|
try:
|
||||||
|
found_version = version_checker()
|
||||||
|
except (CalledProcessError, FileNotFoundError) as e:
|
||||||
|
_error_missing_program(program, package, required_for, recommended)
|
||||||
|
if not recommended:
|
||||||
|
raise MissingDependencyError(program) from e
|
||||||
|
return
|
||||||
|
except MissingDependencyError:
|
||||||
|
_error_missing_program(program, package, required_for, recommended)
|
||||||
|
if not recommended:
|
||||||
|
raise
|
||||||
|
return
|
||||||
|
|
||||||
|
if found_version and found_version < need_version:
|
||||||
|
_error_old_version(
|
||||||
|
program, package, str(need_version), str(found_version), required_for
|
||||||
|
)
|
||||||
|
if not recommended:
|
||||||
|
raise MissingDependencyError(program)
|
||||||
|
|
||||||
|
log.debug('Found %s %s', program, found_version)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
"""Low-level wrappers around :py:mod:`subprocess`.
|
||||||
|
|
||||||
|
These functions exist to give OCRmyPDF child processes uniform logging
|
||||||
|
behavior and to route through any platform-specific PATH fix-ups before
|
||||||
|
invocation. They are intended as drop-in replacements for
|
||||||
|
:py:func:`subprocess.run` in contexts where that routing is desirable
|
||||||
|
(for example, plugin-provided tools).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CalledProcessError, CompletedProcess, Popen
|
||||||
|
from subprocess import run as subprocess_run
|
||||||
|
|
||||||
|
log = logging.getLogger('ocrmypdf.subprocess')
|
||||||
|
|
||||||
|
Args = Sequence[Path | str]
|
||||||
|
Environ = Mapping[str, str] | os._Environ # pylint: disable=protected-access
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
args: Args,
|
||||||
|
*,
|
||||||
|
env: Environ | None = None,
|
||||||
|
logs_errors_to_stdout: bool = False,
|
||||||
|
check: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> CompletedProcess:
|
||||||
|
"""Wrapper around :py:func:`subprocess.run`.
|
||||||
|
|
||||||
|
The main purpose of this wrapper is to log subprocess output in an orderly
|
||||||
|
fashion that identifies the responsible subprocess. An additional
|
||||||
|
task is that this function goes to greater lengths to find possible Windows
|
||||||
|
locations of our dependencies when they are not on the system PATH.
|
||||||
|
|
||||||
|
Arguments should be identical to ``subprocess.run``, except for following:
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Positional arguments to pass to ``subprocess.run``.
|
||||||
|
env: A set of environment variables. If None, the OS environment is used.
|
||||||
|
logs_errors_to_stdout: If True, indicates that the process writes its error
|
||||||
|
messages to stdout rather than stderr, so stdout should be logged
|
||||||
|
if there is an error. If False, stderr is logged. Could be used with
|
||||||
|
stderr=STDOUT, stdout=PIPE for example.
|
||||||
|
check: If True, raise an exception if the process exits with a non-zero
|
||||||
|
status code. If False, the return value will indicate success or failure.
|
||||||
|
kwargs: Additional arguments to pass to ``subprocess.run``.
|
||||||
|
"""
|
||||||
|
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
|
||||||
|
|
||||||
|
stderr = None
|
||||||
|
stderr_name = 'stderr' if not logs_errors_to_stdout else 'stdout'
|
||||||
|
try:
|
||||||
|
proc = subprocess_run(args, env=env, check=check, **kwargs)
|
||||||
|
except CalledProcessError as e:
|
||||||
|
stderr = getattr(e, stderr_name, None)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
stderr = getattr(proc, stderr_name, None)
|
||||||
|
finally:
|
||||||
|
if process_log.isEnabledFor(logging.DEBUG) and stderr:
|
||||||
|
with suppress(AttributeError, UnicodeDecodeError):
|
||||||
|
stderr = stderr.decode('utf-8', 'replace')
|
||||||
|
if logs_errors_to_stdout:
|
||||||
|
process_log.debug("stdout/stderr = %s", stderr)
|
||||||
|
else:
|
||||||
|
process_log.debug("stderr = %s", stderr)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def run_polling_stderr(
|
||||||
|
args: Args,
|
||||||
|
*,
|
||||||
|
callback: Callable[[str], None],
|
||||||
|
check: bool = False,
|
||||||
|
env: Environ | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> CompletedProcess:
|
||||||
|
"""Run a process like ``ocrmypdf.subprocess.run``, and poll stderr.
|
||||||
|
|
||||||
|
Every line of produced by stderr will be forwarded to the callback function.
|
||||||
|
The intended use is monitoring progress of subprocesses that output their
|
||||||
|
own progress indicators. In addition, each line will be logged if debug
|
||||||
|
logging is enabled.
|
||||||
|
|
||||||
|
Requires stderr to be opened in text mode for ease of handling errors. In
|
||||||
|
addition the expected encoding= and errors= arguments should be set. Note
|
||||||
|
that if stdout is already set up, it need not be binary.
|
||||||
|
"""
|
||||||
|
args, env, process_log, text = _fix_process_args(args, env, kwargs)
|
||||||
|
assert text, "Must use text=True"
|
||||||
|
|
||||||
|
with Popen(args, env=env, **kwargs) as proc:
|
||||||
|
lines = []
|
||||||
|
while proc.poll() is None:
|
||||||
|
if proc.stderr is None:
|
||||||
|
continue
|
||||||
|
for msg in iter(proc.stderr.readline, ''):
|
||||||
|
if process_log.isEnabledFor(logging.DEBUG):
|
||||||
|
process_log.debug(msg.strip())
|
||||||
|
callback(msg)
|
||||||
|
lines.append(msg)
|
||||||
|
stderr = ''.join(lines)
|
||||||
|
|
||||||
|
if check and proc.returncode != 0:
|
||||||
|
raise CalledProcessError(proc.returncode, args, output=None, stderr=stderr)
|
||||||
|
return CompletedProcess(args, proc.returncode, None, stderr=stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_process_args(
|
||||||
|
args: Args, env: Environ | None, kwargs
|
||||||
|
) -> tuple[Args, Environ, logging.Logger, bool]:
|
||||||
|
if not env:
|
||||||
|
env = os.environ
|
||||||
|
|
||||||
|
# Search in spoof path if necessary
|
||||||
|
program = str(args[0])
|
||||||
|
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
from ocrmypdf.subprocess._windows import fix_windows_args
|
||||||
|
|
||||||
|
args = fix_windows_args(program, args, env)
|
||||||
|
|
||||||
|
log.debug("Running: %s", args)
|
||||||
|
process_log = log.getChild(Path(program).name)
|
||||||
|
text = bool(kwargs.get('text', False))
|
||||||
|
|
||||||
|
return args, env, process_log, text
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
"""Extract version strings from external programs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from subprocess import PIPE, STDOUT, CalledProcessError
|
||||||
|
|
||||||
|
from ocrmypdf.exceptions import MissingDependencyError
|
||||||
|
from ocrmypdf.subprocess._run import Environ
|
||||||
|
|
||||||
|
log = logging.getLogger('ocrmypdf.subprocess')
|
||||||
|
|
||||||
|
|
||||||
|
def get_version(
|
||||||
|
program: str,
|
||||||
|
*,
|
||||||
|
version_arg: str = '--version',
|
||||||
|
regex=r'(\d+(\.\d+)*)',
|
||||||
|
env: Environ | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Get the version of the specified program.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
program: The program to version check.
|
||||||
|
version_arg: The argument needed to ask for its version, e.g. ``--version``.
|
||||||
|
regex: A regular expression to parse the program's output and obtain the
|
||||||
|
version.
|
||||||
|
env: Custom ``os.environ`` in which to run program.
|
||||||
|
"""
|
||||||
|
# Late import of the public ``run`` so that tests patching
|
||||||
|
# ``ocrmypdf.subprocess.run`` affect this function. Binding ``run`` at
|
||||||
|
# module load time would capture the real implementation and bypass the
|
||||||
|
# patch.
|
||||||
|
from ocrmypdf import subprocess as _sp
|
||||||
|
|
||||||
|
args_prog = [program, version_arg]
|
||||||
|
try:
|
||||||
|
proc = _sp.run(
|
||||||
|
args_prog,
|
||||||
|
close_fds=True,
|
||||||
|
text=True,
|
||||||
|
stdout=PIPE,
|
||||||
|
stderr=STDOUT,
|
||||||
|
check=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
output: str = proc.stdout
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise MissingDependencyError(
|
||||||
|
f"Could not find program '{program}' on the PATH"
|
||||||
|
) from e
|
||||||
|
except CalledProcessError as e:
|
||||||
|
if e.returncode != 0:
|
||||||
|
log.exception(e)
|
||||||
|
raise MissingDependencyError(
|
||||||
|
f"Ran program '{program}' but it exited with an error:\n{e.output}"
|
||||||
|
) from e
|
||||||
|
raise MissingDependencyError(
|
||||||
|
f"Could not find program '{program}' on the PATH"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# Some tools (e.g. veraPDF launched on a recent JDK) print warnings before
|
||||||
|
# the version line, so scan each line rather than only the start of output.
|
||||||
|
version = None
|
||||||
|
for line in output.splitlines():
|
||||||
|
match = re.match(regex, line.strip())
|
||||||
|
if match:
|
||||||
|
version = match.group(1)
|
||||||
|
break
|
||||||
|
if version is None:
|
||||||
|
raise MissingDependencyError(
|
||||||
|
f"The program '{program}' did not report its version. "
|
||||||
|
f"Message was:\n{output}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return version
|
||||||
+3
-6
@@ -19,14 +19,11 @@ from ocrmypdf.exceptions import ExitCode
|
|||||||
|
|
||||||
|
|
||||||
class Gs106WarningFilter(logging.Filter):
|
class Gs106WarningFilter(logging.Filter):
|
||||||
"""Filter out expected Ghostscript 10.6.x warning from test logs."""
|
"""Filter out expected Ghostscript 10.6+ JPEG encoding warning from test logs."""
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
# Allow all records except the expected Ghostscript 10.6.x warning
|
# Allow all records except the expected Ghostscript 10.6+ warning
|
||||||
return (
|
return "contains JPEG encoding errors" not in record.getMessage()
|
||||||
"Ghostscript 10.6.x contains JPEG encoding errors"
|
|
||||||
not in record.getMessage()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
"""Test plugin that deliberately writes garbage to stdout.
|
||||||
|
|
||||||
|
Used to verify that OCRmyPDF's stdout protection diverts stray writes (from
|
||||||
|
plugins or libraries) to stderr, so that a PDF written to stdout is never
|
||||||
|
corrupted. Pollutes at three points: plugin import (main process), the
|
||||||
|
``validate`` hook (main process), and the ``filter_ocr_image`` hook (worker
|
||||||
|
process/thread).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from ocrmypdf import hookimpl
|
||||||
|
|
||||||
|
POLLUTION = b'POLLUTION'
|
||||||
|
|
||||||
|
|
||||||
|
def _pollute(where: bytes) -> None:
|
||||||
|
# Write to file descriptor 1 directly (as a careless C library might) and
|
||||||
|
# via Python's sys.stdout (as a stray print() might).
|
||||||
|
os.write(1, POLLUTION + b'-fd1-' + where + b'\n')
|
||||||
|
print(POLLUTION.decode() + '-stdout-' + where.decode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# Pollute at import time, which happens while plugins are being loaded.
|
||||||
|
_pollute(b'import')
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def validate(pdfinfo, options):
|
||||||
|
_pollute(b'validate')
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def filter_ocr_image(page, image):
|
||||||
|
_pollute(b'filter_ocr_image')
|
||||||
|
return image
|
||||||
@@ -74,11 +74,11 @@ class FixedRotateNoopOcrEngine(OcrEngine):
|
|||||||
def generate_hocr(input_file, output_hocr, output_text, options):
|
def generate_hocr(input_file, output_hocr, output_text, options):
|
||||||
with (
|
with (
|
||||||
Image.open(input_file) as im,
|
Image.open(input_file) as im,
|
||||||
open(output_hocr, 'w', encoding='utf-8') as f,
|
output_hocr.open('w', encoding='utf-8') as f,
|
||||||
):
|
):
|
||||||
w, h = im.size
|
w, h = im.size
|
||||||
f.write(HOCR_TEMPLATE.format(str(w), str(h)))
|
f.write(HOCR_TEMPLATE.format(str(w), str(h)))
|
||||||
with open(output_text, 'w') as f:
|
with output_text.open('w') as f:
|
||||||
f.write('')
|
f.write('')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -72,11 +72,11 @@ class NoopOcrEngine(OcrEngine):
|
|||||||
def generate_hocr(input_file, output_hocr, output_text, options):
|
def generate_hocr(input_file, output_hocr, output_text, options):
|
||||||
with (
|
with (
|
||||||
Image.open(input_file) as im,
|
Image.open(input_file) as im,
|
||||||
open(output_hocr, 'w', encoding='utf-8') as f,
|
output_hocr.open('w', encoding='utf-8') as f,
|
||||||
):
|
):
|
||||||
w, h = im.size
|
w, h = im.size
|
||||||
f.write(HOCR_TEMPLATE.format(str(w), str(h)))
|
f.write(HOCR_TEMPLATE.format(str(w), str(h)))
|
||||||
with open(output_text, 'w') as f:
|
with output_text.open('w') as f:
|
||||||
f.write('')
|
f.write('')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ the copyright holder(s) and license(s) applicable to these resources.
|
|||||||
* - missing_docinfo.pdf
|
* - missing_docinfo.pdf
|
||||||
- synthetic
|
- synthetic
|
||||||
- PDF file with no /DocumentInfo section
|
- PDF file with no /DocumentInfo section
|
||||||
|
* - docinfo_latin1_key.pdf
|
||||||
|
- synthetic
|
||||||
|
- PDF whose /DocumentInfo dictionary has a /Name key with Latin-1 bytes (/Saks#e5r) that is not valid UTF-8
|
||||||
* - overlay.pdf
|
* - overlay.pdf
|
||||||
- synthetic
|
- synthetic
|
||||||
- PDF file generated by PDFPen pro that triggered content stream parse errors
|
- PDF file generated by PDFPen pro that triggered content stream parse errors
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
%PDF-1.3
|
||||||
|
%¿÷¢þ
|
||||||
|
1 0 obj
|
||||||
|
<< /Pages 3 0 R /Type /Catalog >>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<< /Author (Geomatikk AS) /Beskrivelse () /Creator (OCRmyPDF 16.10.0 / EasyOCR-PDF 1.7.2) /CreatorVersion (6.36.0.918) /Dokumentidplanreg () /Enhetsnavn () /Hyperlink (1) /Opprinnelse () /Producer (pikepdf 9.5.2) /RegistrationDate (N/A) /Saksansvarlig#20enhet () /Saksbehandler () /Saksnr () /Saks#e5r () /Status () >>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<< /Count 1 /Kids [ 4 0 R ] /Type /Pages >>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<< /Contents 5 0 R /MediaBox [ 0 0 612 792 ] /Parent 3 0 R /Resources << >> /Type /Page >>
|
||||||
|
endobj
|
||||||
|
5 0 obj
|
||||||
|
<< /Length 0 /Filter /FlateDecode >>
|
||||||
|
stream
|
||||||
|
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 6
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000015 00000 n
|
||||||
|
0000000064 00000 n
|
||||||
|
0000000398 00000 n
|
||||||
|
0000000457 00000 n
|
||||||
|
0000000563 00000 n
|
||||||
|
trailer << /Info 2 0 R /Root 1 0 R /Size 6 /ID [<c5231b8cfab9c82526c0da7475add5da><c5231b8cfab9c82526c0da7475add5da>] >>
|
||||||
|
startxref
|
||||||
|
633
|
||||||
|
%%EOF
|
||||||
@@ -28,7 +28,6 @@ def test_language_parameter_mapped_to_languages():
|
|||||||
Regression test for GitHub issue #1640: the Python API ignored the language
|
Regression test for GitHub issue #1640: the Python API ignored the language
|
||||||
parameter, always defaulting to 'eng'.
|
parameter, always defaulting to 'eng'.
|
||||||
"""
|
"""
|
||||||
from ocrmypdf._options import OcrOptions
|
|
||||||
from ocrmypdf.api import create_options, setup_plugin_infrastructure
|
from ocrmypdf.api import create_options, setup_plugin_infrastructure
|
||||||
from ocrmypdf.cli import get_parser
|
from ocrmypdf.cli import get_parser
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -37,6 +39,26 @@ def resources():
|
|||||||
return Path(__file__).parent / "resources"
|
return Path(__file__).parent / "resources"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pdftotext():
|
||||||
|
"""Return a function to extract text from PDF using pdftotext.
|
||||||
|
|
||||||
|
Skips the test if pdftotext is not available.
|
||||||
|
"""
|
||||||
|
pdftotext_path = shutil.which('pdftotext')
|
||||||
|
if pdftotext_path is None:
|
||||||
|
pytest.skip("pdftotext not available")
|
||||||
|
|
||||||
|
def extract_text(pdf_path: Path) -> str:
|
||||||
|
return subprocess.check_output(
|
||||||
|
['pdftotext', '-enc', 'UTF-8', str(pdf_path), '-'],
|
||||||
|
text=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
return extract_text
|
||||||
|
|
||||||
|
|
||||||
class TestFpdf2RendererImports:
|
class TestFpdf2RendererImports:
|
||||||
"""Test that all fpdf2 renderer modules can be imported."""
|
"""Test that all fpdf2 renderer modules can be imported."""
|
||||||
|
|
||||||
@@ -197,7 +219,7 @@ class TestFpdf2MultiPageRenderer:
|
|||||||
for i in range(3):
|
for i in range(3):
|
||||||
word = OcrElement(
|
word = OcrElement(
|
||||||
ocr_class=OcrClass.WORD,
|
ocr_class=OcrClass.WORD,
|
||||||
text=f"Page{i+1}",
|
text=f"Page{i + 1}",
|
||||||
bbox=BoundingBox(left=100, top=100, right=200, bottom=130),
|
bbox=BoundingBox(left=100, top=100, right=200, bottom=130),
|
||||||
)
|
)
|
||||||
line = OcrElement(
|
line = OcrElement(
|
||||||
@@ -443,14 +465,16 @@ class TestWordSegmentation:
|
|||||||
assert "World" in words_found
|
assert "World" in words_found
|
||||||
assert "Test" in words_found
|
assert "Test" in words_found
|
||||||
|
|
||||||
def test_cjk_no_spurious_spaces(self, multi_font_manager, tmp_path):
|
def test_cjk_no_spurious_spaces(self, multi_font_manager, tmp_path, pdftotext):
|
||||||
"""Test that CJK text does not get spurious spaces inserted.
|
"""Test that CJK text does not get spurious spaces inserted.
|
||||||
|
|
||||||
CJK scripts don't use spaces between characters/words, so we should
|
CJK scripts don't use spaces between characters/words, so we should
|
||||||
not insert spaces between adjacent CJK words.
|
not insert spaces between adjacent CJK words.
|
||||||
"""
|
|
||||||
from pdfminer.high_level import extract_text
|
|
||||||
|
|
||||||
|
Uses pdftotext (poppler) instead of pdfminer.six because the latter
|
||||||
|
cannot decode the custom Encoding CMap that fpdf2 >= 2.8.7 emits for
|
||||||
|
subsetted CFF-based CID fonts (e.g. NotoSansCJK).
|
||||||
|
"""
|
||||||
from ocrmypdf.models.ocr_element import BoundingBox, OcrElement
|
from ocrmypdf.models.ocr_element import BoundingBox, OcrElement
|
||||||
|
|
||||||
# Create a page with CJK words (Chinese characters)
|
# Create a page with CJK words (Chinese characters)
|
||||||
@@ -487,15 +511,15 @@ class TestWordSegmentation:
|
|||||||
output_path = tmp_path / "test_cjk_segmentation.pdf"
|
output_path = tmp_path / "test_cjk_segmentation.pdf"
|
||||||
renderer.render(output_path)
|
renderer.render(output_path)
|
||||||
|
|
||||||
# Extract text using pdfminer.six
|
extracted_text = pdftotext(output_path)
|
||||||
extracted_text = extract_text(str(output_path))
|
|
||||||
|
|
||||||
# CJK text should be present
|
# CJK text should be present
|
||||||
assert "你好" in extracted_text
|
assert "你好" in extracted_text
|
||||||
assert "世界" in extracted_text
|
assert "世界" in extracted_text
|
||||||
|
|
||||||
# There should NOT be spaces between CJK characters
|
# There should NOT be spaces between CJK characters
|
||||||
# (but pdfminer may add some whitespace, so we check the raw chars)
|
# (a space between the two words is acceptable, since they are
|
||||||
|
# separated horizontally on the rendered page)
|
||||||
extracted_chars = extracted_text.replace(" ", "").replace("\n", "")
|
extracted_chars = extracted_text.replace(" ", "").replace("\n", "")
|
||||||
assert "你好世界" in extracted_chars or (
|
assert "你好世界" in extracted_chars or (
|
||||||
"你好" in extracted_chars and "世界" in extracted_chars
|
"你好" in extracted_chars and "世界" in extracted_chars
|
||||||
|
|||||||
+327
-5
@@ -17,7 +17,11 @@ from PIL import Image, UnidentifiedImageError
|
|||||||
|
|
||||||
from ocrmypdf._exec import ghostscript
|
from ocrmypdf._exec import ghostscript
|
||||||
from ocrmypdf._exec.ghostscript import DuplicateFilter, rasterize_pdf
|
from ocrmypdf._exec.ghostscript import DuplicateFilter, rasterize_pdf
|
||||||
from ocrmypdf.builtin_plugins.ghostscript import _repair_gs106_jpeg_corruption
|
from ocrmypdf.builtin_plugins.ghostscript import (
|
||||||
|
PdfaImageCompression,
|
||||||
|
_repair_gs106_jpeg_corruption,
|
||||||
|
_resolve_auto_compression,
|
||||||
|
)
|
||||||
from ocrmypdf.exceptions import ColorConversionNeededError, ExitCode, InputFileError
|
from ocrmypdf.exceptions import ColorConversionNeededError, ExitCode, InputFileError
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
||||||
@@ -137,6 +141,185 @@ def test_rasterize_low_dpi_one_axis(francais, outdir):
|
|||||||
assert im.info['dpi'] == forced_dpi
|
assert im.info['dpi'] == forced_dpi
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_rasterize_args(resources, outdir, raster_device):
|
||||||
|
"""Run rasterize_pdf with the gs subprocess mocked; return the gs argv."""
|
||||||
|
out = outdir / 'out.png'
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
captured['args'] = list(args)
|
||||||
|
# Produce a valid PNG so rasterize_pdf's post-processing succeeds.
|
||||||
|
Image.new('RGB', (2, 2)).save(out)
|
||||||
|
return subprocess.CompletedProcess(args, returncode=0, stdout=b'', stderr=b'')
|
||||||
|
|
||||||
|
with patch('ocrmypdf._exec.ghostscript.run', side_effect=fake_run):
|
||||||
|
rasterize_pdf(
|
||||||
|
resources / 'francais.pdf',
|
||||||
|
out,
|
||||||
|
raster_device=raster_device,
|
||||||
|
raster_dpi=Resolution(150.0, 150.0),
|
||||||
|
)
|
||||||
|
return captured['args']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'raster_device',
|
||||||
|
[
|
||||||
|
GhostscriptRasterDevice.PNGGRAY,
|
||||||
|
GhostscriptRasterDevice.PNG256,
|
||||||
|
GhostscriptRasterDevice.PNG16M,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rasterize_antialiases_contone_devices(resources, outdir, raster_device):
|
||||||
|
"""Contone raster devices receive anti-aliasing flags to aid OCR.
|
||||||
|
|
||||||
|
Ghostscript 10.x renders aliased glyphs that OCR misreads as extra word
|
||||||
|
breaks; -dTextAlphaBits/-dGraphicsAlphaBits markedly improve accuracy,
|
||||||
|
especially for small fonts at moderate DPI (see issue #1439).
|
||||||
|
"""
|
||||||
|
args = _capture_rasterize_args(resources, outdir, raster_device)
|
||||||
|
assert '-dTextAlphaBits=4' in args
|
||||||
|
assert '-dGraphicsAlphaBits=4' in args
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'raster_device',
|
||||||
|
[GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD],
|
||||||
|
)
|
||||||
|
def test_rasterize_no_antialias_on_mono_devices(resources, outdir, raster_device):
|
||||||
|
"""1-bit mono devices must not receive alpha-bit flags.
|
||||||
|
|
||||||
|
Older Ghostscript versions reject -dTextAlphaBits on 1-bit devices, and
|
||||||
|
pngmonod performs its own anti-aliased downscaling.
|
||||||
|
"""
|
||||||
|
args = _capture_rasterize_args(resources, outdir, raster_device)
|
||||||
|
assert not any(a.startswith('-dTextAlphaBits') for a in args)
|
||||||
|
assert not any(a.startswith('-dGraphicsAlphaBits') for a in args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdfa_default_jpeg_quality(outdir):
|
||||||
|
"""When jpeg_quality is None, Ghostscript receives -dJPEGQ=95 (default)."""
|
||||||
|
with (
|
||||||
|
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
|
||||||
|
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
|
||||||
|
):
|
||||||
|
run_mock.return_value = subprocess.CompletedProcess(
|
||||||
|
['gs'], returncode=0, stdout='', stderr=''
|
||||||
|
)
|
||||||
|
ghostscript.generate_pdfa(
|
||||||
|
pdf_pages=[outdir / 'input.pdf'],
|
||||||
|
output_file=outdir / 'out.pdf',
|
||||||
|
compression='auto',
|
||||||
|
color_conversion_strategy='LeaveColorUnchanged',
|
||||||
|
)
|
||||||
|
|
||||||
|
args = run_mock.call_args.args[0]
|
||||||
|
assert '-dJPEGQ=95' in args
|
||||||
|
# No downsample switches when jpeg_maxdpi is not set
|
||||||
|
assert not any(a.startswith('-dDownsampleColorImages') for a in args)
|
||||||
|
assert not any(a.startswith('-dColorImageResolution') for a in args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdfa_uses_user_jpeg_quality(outdir):
|
||||||
|
with (
|
||||||
|
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
|
||||||
|
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
|
||||||
|
):
|
||||||
|
run_mock.return_value = subprocess.CompletedProcess(
|
||||||
|
['gs'], returncode=0, stdout='', stderr=''
|
||||||
|
)
|
||||||
|
ghostscript.generate_pdfa(
|
||||||
|
pdf_pages=[outdir / 'input.pdf'],
|
||||||
|
output_file=outdir / 'out.pdf',
|
||||||
|
compression='jpeg',
|
||||||
|
color_conversion_strategy='RGB',
|
||||||
|
jpeg_quality=72,
|
||||||
|
)
|
||||||
|
|
||||||
|
args = run_mock.call_args.args[0]
|
||||||
|
assert '-dJPEGQ=72' in args
|
||||||
|
assert '-dJPEGQ=95' not in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdfa_jpeg_quality_zero_is_max_compression(outdir):
|
||||||
|
"""Explicit jpeg_quality=0 must reach Ghostscript as -dJPEGQ=0.
|
||||||
|
|
||||||
|
Ghostscript accepts 0 as a valid quality value (maximum compression);
|
||||||
|
it must not be silently replaced by the default 95.
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
|
||||||
|
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
|
||||||
|
):
|
||||||
|
run_mock.return_value = subprocess.CompletedProcess(
|
||||||
|
['gs'], returncode=0, stdout='', stderr=''
|
||||||
|
)
|
||||||
|
ghostscript.generate_pdfa(
|
||||||
|
pdf_pages=[outdir / 'input.pdf'],
|
||||||
|
output_file=outdir / 'out.pdf',
|
||||||
|
compression='jpeg',
|
||||||
|
color_conversion_strategy='RGB',
|
||||||
|
jpeg_quality=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
args = run_mock.call_args.args[0]
|
||||||
|
assert '-dJPEGQ=0' in args
|
||||||
|
assert '-dJPEGQ=95' not in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pdfa_honors_jpeg_maxdpi(outdir):
|
||||||
|
with (
|
||||||
|
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
|
||||||
|
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
|
||||||
|
):
|
||||||
|
run_mock.return_value = subprocess.CompletedProcess(
|
||||||
|
['gs'], returncode=0, stdout='', stderr=''
|
||||||
|
)
|
||||||
|
ghostscript.generate_pdfa(
|
||||||
|
pdf_pages=[outdir / 'input.pdf'],
|
||||||
|
output_file=outdir / 'out.pdf',
|
||||||
|
compression='auto',
|
||||||
|
color_conversion_strategy='LeaveColorUnchanged',
|
||||||
|
jpeg_maxdpi=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
args = run_mock.call_args.args[0]
|
||||||
|
assert '-dJPEGQ=95' in args
|
||||||
|
assert '-dDownsampleColorImages=true' in args
|
||||||
|
assert '-dColorImageDownsampleThreshold=1.0' in args
|
||||||
|
assert '-dDownsampleGrayImages=true' in args
|
||||||
|
assert '-dGrayImageDownsampleThreshold=1.0' in args
|
||||||
|
assert '-dDownsampleMonoImages=true' in args
|
||||||
|
assert '-dMonoImageDownsampleThreshold=1.0' in args
|
||||||
|
assert '-dColorImageResolution=300' in args
|
||||||
|
assert '-dGrayImageResolution=300' in args
|
||||||
|
assert '-dMonoImageResolution=300' in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_ghostscript_jpeg_options_via_cli(resources, outpdf):
|
||||||
|
"""End-to-end: CLI flags reach the ghostscript plugin namespace."""
|
||||||
|
with patch(
|
||||||
|
'ocrmypdf._exec.ghostscript.generate_pdfa',
|
||||||
|
wraps=ghostscript.generate_pdfa,
|
||||||
|
) as gen_mock:
|
||||||
|
run_ocrmypdf_api(
|
||||||
|
resources / 'francais.pdf',
|
||||||
|
outpdf,
|
||||||
|
'--output-type',
|
||||||
|
'pdfa',
|
||||||
|
'--ghostscript-jpeg-quality',
|
||||||
|
'60',
|
||||||
|
'--ghostscript-jpeg-maxdpi',
|
||||||
|
'150',
|
||||||
|
'--plugin',
|
||||||
|
'tests/plugins/tesseract_noop.py',
|
||||||
|
)
|
||||||
|
assert gen_mock.called
|
||||||
|
call_kwargs = gen_mock.call_args.kwargs
|
||||||
|
assert call_kwargs['jpeg_quality'] == 60
|
||||||
|
assert call_kwargs['jpeg_maxdpi'] == 150
|
||||||
|
|
||||||
|
|
||||||
def test_gs_render_failure(resources, outpdf, caplog):
|
def test_gs_render_failure(resources, outpdf, caplog):
|
||||||
exitcode = run_ocrmypdf_api(
|
exitcode = run_ocrmypdf_api(
|
||||||
resources / 'blank.pdf',
|
resources / 'blank.pdf',
|
||||||
@@ -176,9 +359,9 @@ def test_ghostscript_pdfa_failure(resources, outpdf, caplog):
|
|||||||
'--plugin',
|
'--plugin',
|
||||||
'tests/plugins/gs_pdfa_failure.py',
|
'tests/plugins/gs_pdfa_failure.py',
|
||||||
)
|
)
|
||||||
assert (
|
assert exitcode == ExitCode.pdfa_conversion_failed, (
|
||||||
exitcode == ExitCode.pdfa_conversion_failed
|
"Unexpected return when PDF/A fails"
|
||||||
), "Unexpected return when PDF/A fails"
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_ghostscript_feature_elision(resources, outpdf):
|
def test_ghostscript_feature_elision(resources, outpdf):
|
||||||
@@ -204,6 +387,88 @@ def test_ghostscript_mandatory_color_conversion(resources, outpdf):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_generate_pdfa_with_devicen_warning(outdir, color_conversion_strategy):
|
||||||
|
"""Invoke generate_pdfa with Ghostscript mocked to emit the DeviceN warning.
|
||||||
|
|
||||||
|
Ghostscript emits this warning when it writes a DeviceN colorspace with an
|
||||||
|
inappropriate alternate, i.e. when it could not normalize the colorspace for
|
||||||
|
PDF/A. The output is then liable to render blank in viewers such as Adobe
|
||||||
|
Reader (see issue #1187), regardless of which conversion strategy was
|
||||||
|
requested.
|
||||||
|
"""
|
||||||
|
(outdir / 'input.pdf').write_bytes(b'%PDF-1.5\n%fake\n')
|
||||||
|
with (
|
||||||
|
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
|
||||||
|
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
|
||||||
|
):
|
||||||
|
run_mock.return_value = subprocess.CompletedProcess(
|
||||||
|
['gs'],
|
||||||
|
returncode=0,
|
||||||
|
stdout='',
|
||||||
|
stderr='Attempting to write a DeviceN space with an inappropriate '
|
||||||
|
'alternate, reverting to the alternate color space.',
|
||||||
|
)
|
||||||
|
ghostscript.generate_pdfa(
|
||||||
|
pdf_pages=[outdir / 'input.pdf'],
|
||||||
|
output_file=outdir / 'out.pdf',
|
||||||
|
compression='auto',
|
||||||
|
color_conversion_strategy=color_conversion_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_devicen_warning_default_strategy_raises_with_guidance(outdir):
|
||||||
|
"""Default (no conversion): raise and tell the user to pick a strategy."""
|
||||||
|
with pytest.raises(ColorConversionNeededError) as exc_info:
|
||||||
|
_run_generate_pdfa_with_devicen_warning(outdir, 'LeaveColorUnchanged')
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert '--color-conversion-strategy' in message
|
||||||
|
assert 'RGB' in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'strategy',
|
||||||
|
[
|
||||||
|
# A strategy that genuinely cannot fix the colorspace; confirmed in #1187.
|
||||||
|
'UseDeviceIndependentColor',
|
||||||
|
# A normally-effective strategy that nonetheless failed on this input:
|
||||||
|
# if Ghostscript still warns, the output is still broken and we must not
|
||||||
|
# silently pass it through (the behaviour PR #1692 would have introduced).
|
||||||
|
'RGB',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_devicen_warning_persists_despite_strategy_still_raises(outdir, strategy):
|
||||||
|
"""If the warning survives the requested conversion, the output is broken.
|
||||||
|
|
||||||
|
We must still raise rather than silently emit a PDF/A that may render blank.
|
||||||
|
The guidance should acknowledge that the chosen strategy did not work and
|
||||||
|
point at strategies that do (or --output-type pdf).
|
||||||
|
"""
|
||||||
|
with pytest.raises(ColorConversionNeededError) as exc_info:
|
||||||
|
_run_generate_pdfa_with_devicen_warning(outdir, strategy)
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert strategy in message
|
||||||
|
assert '--output-type pdf' in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_devicen_warning_does_not_raise(outdir):
|
||||||
|
"""When Ghostscript does not warn, conversion succeeded; never raise."""
|
||||||
|
(outdir / 'input.pdf').write_bytes(b'%PDF-1.5\n%fake\n')
|
||||||
|
with (
|
||||||
|
patch('ocrmypdf._exec.ghostscript.version', return_value=Version('10.05.1')),
|
||||||
|
patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as run_mock,
|
||||||
|
):
|
||||||
|
run_mock.return_value = subprocess.CompletedProcess(
|
||||||
|
['gs'], returncode=0, stdout='', stderr=''
|
||||||
|
)
|
||||||
|
# Must not raise for any strategy when there is no DeviceN warning.
|
||||||
|
ghostscript.generate_pdfa(
|
||||||
|
pdf_pages=[outdir / 'input.pdf'],
|
||||||
|
output_file=outdir / 'out.pdf',
|
||||||
|
compression='auto',
|
||||||
|
color_conversion_strategy='RGB',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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 empty files with return code 0
|
# ghostscript can produce empty files with return code 0
|
||||||
@@ -439,7 +704,9 @@ class TestGs106JpegCorruptionRepair:
|
|||||||
repaired_bytes_list.append(obj.read_raw_bytes())
|
repaired_bytes_list.append(obj.read_raw_bytes())
|
||||||
|
|
||||||
assert len(repaired_bytes_list) == len(original_bytes_list)
|
assert len(repaired_bytes_list) == len(original_bytes_list)
|
||||||
for orig, repaired_bytes in zip(original_bytes_list, repaired_bytes_list, strict=False):
|
for orig, repaired_bytes in zip(
|
||||||
|
original_bytes_list, repaired_bytes_list, strict=False
|
||||||
|
):
|
||||||
assert orig == repaired_bytes, "Repaired bytes should match original"
|
assert orig == repaired_bytes, "Repaired bytes should match original"
|
||||||
|
|
||||||
# Check that error/warning was logged
|
# Check that error/warning was logged
|
||||||
@@ -468,3 +735,58 @@ class TestGs106JpegCorruptionRepair:
|
|||||||
repaired = _repair_gs106_jpeg_corruption(source_path, damaged_path)
|
repaired = _repair_gs106_jpeg_corruption(source_path, damaged_path)
|
||||||
assert repaired is False, "Should not repair truncation > 15 bytes"
|
assert repaired is False, "Should not repair truncation > 15 bytes"
|
||||||
assert "JPEG corruption detected" not in caplog.text
|
assert "JPEG corruption detected" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('compression', 'optimize', 'expected'),
|
||||||
|
[
|
||||||
|
# auto coerces to lossless only at -O0; -O1 is a historical exception
|
||||||
|
# that keeps Ghostscript's (possibly lossy) heuristic, as do -O2/-O3
|
||||||
|
(PdfaImageCompression.AUTO, 0, PdfaImageCompression.LOSSLESS),
|
||||||
|
(PdfaImageCompression.AUTO, 1, PdfaImageCompression.AUTO),
|
||||||
|
(PdfaImageCompression.AUTO, 2, PdfaImageCompression.AUTO),
|
||||||
|
(PdfaImageCompression.AUTO, 3, PdfaImageCompression.AUTO),
|
||||||
|
# explicit choices are always respected, regardless of optimize level
|
||||||
|
(PdfaImageCompression.JPEG, 0, PdfaImageCompression.JPEG),
|
||||||
|
(PdfaImageCompression.JPEG, 1, PdfaImageCompression.JPEG),
|
||||||
|
(PdfaImageCompression.LOSSLESS, 1, PdfaImageCompression.LOSSLESS),
|
||||||
|
(PdfaImageCompression.LOSSLESS, 3, PdfaImageCompression.LOSSLESS),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_auto_compression(compression, optimize, expected):
|
||||||
|
assert _resolve_auto_compression(compression, optimize) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_generate_pdfa_args(tmp_path, compression):
|
||||||
|
"""Run generate_pdfa with a mocked Ghostscript and return the argv it built."""
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
captured['args'] = list(args)
|
||||||
|
return CompletedProcess(args, 0, None, stderr='')
|
||||||
|
|
||||||
|
out = tmp_path / 'out.pdf'
|
||||||
|
with patch('ocrmypdf._exec.ghostscript.run_polling_stderr', side_effect=fake_run):
|
||||||
|
ghostscript.generate_pdfa(
|
||||||
|
pdf_pages=['dummy.pdf'],
|
||||||
|
output_file=out,
|
||||||
|
compression=compression,
|
||||||
|
color_conversion_strategy='RGB',
|
||||||
|
)
|
||||||
|
return captured['args']
|
||||||
|
|
||||||
|
|
||||||
|
def test_lossless_compression_passes_through_jpegs(tmp_path):
|
||||||
|
# Re-encoding an existing JPEG losslessly only bloats it (the lossy data is
|
||||||
|
# already baked in), so lossless mode must let Ghostscript pass JPEGs through
|
||||||
|
# untouched while still keeping lossless images lossless.
|
||||||
|
args = _capture_generate_pdfa_args(tmp_path, 'lossless')
|
||||||
|
assert '-dPassThroughJPEGImages=true' in args
|
||||||
|
assert '-dColorImageFilter=/FlateEncode' in args
|
||||||
|
|
||||||
|
|
||||||
|
def test_jpeg_compression_does_not_force_passthrough(tmp_path):
|
||||||
|
args = _capture_generate_pdfa_args(tmp_path, 'jpeg')
|
||||||
|
assert '-dPassThroughJPEGImages=true' not in args
|
||||||
|
|||||||
+6
-6
@@ -78,9 +78,9 @@ def test_redo_ocr_with_offset_mediabox(resources, outdir):
|
|||||||
mediabox = list(page.MediaBox)
|
mediabox = list(page.MediaBox)
|
||||||
|
|
||||||
# MediaBox origin should be preserved
|
# MediaBox origin should be preserved
|
||||||
assert (
|
assert float(mediabox[1]) == y_offset, (
|
||||||
float(mediabox[1]) == y_offset
|
f"MediaBox Y origin should be preserved at {y_offset}, got {mediabox[1]}"
|
||||||
), f"MediaBox Y origin should be preserved at {y_offset}, got {mediabox[1]}"
|
)
|
||||||
|
|
||||||
# The content stream should include a CTM with the Y origin translation.
|
# The content stream should include a CTM with the Y origin translation.
|
||||||
# Without the fix, the CTM was omitted for rotation==0, causing a shift.
|
# Without the fix, the CTM was omitted for rotation==0, causing a shift.
|
||||||
@@ -153,7 +153,7 @@ def test_strip_invisble_text():
|
|||||||
nr_visible_pre = count('visible', page)
|
nr_visible_pre = count('visible', page)
|
||||||
ocrmypdf._graft.strip_invisible_text(pdf, page)
|
ocrmypdf._graft.strip_invisible_text(pdf, page)
|
||||||
nr_visible_post = count('visible', page)
|
nr_visible_post = count('visible', page)
|
||||||
assert (
|
assert nr_visible_pre == nr_visible_post, (
|
||||||
nr_visible_pre == nr_visible_post
|
'Number of visible text elements did not change'
|
||||||
), 'Number of visible text elements did not change'
|
)
|
||||||
assert count('invisible', page) == 0, 'No invisible elems left'
|
assert count('invisible', page) == 0, 'No invisible elems left'
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ def test_shim_paths(tmp_path):
|
|||||||
results = result_str.split(os.pathsep)
|
results = result_str.split(os.pathsep)
|
||||||
assert results[0] == str(syspath), results
|
assert results[0] == str(syspath), results
|
||||||
assert results[-3].endswith('tesseract-ocr'), results
|
assert results[-3].endswith('tesseract-ocr'), results
|
||||||
assert results[-2].endswith(os.path.join('gs9.52.3', 'bin')), results
|
assert results[-2].endswith(str(Path('gs9.52.3', 'bin'))), results
|
||||||
assert results[-1].endswith(os.path.join('gs', '9.51', 'bin')), results
|
assert results[-1].endswith(str(Path('gs', '9.51', 'bin'))), results
|
||||||
|
|
||||||
|
|
||||||
def test_resolution():
|
def test_resolution():
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from .conftest import check_ocrmypdf
|
|||||||
|
|
||||||
def text_from_pdf(filename):
|
def text_from_pdf(filename):
|
||||||
output_string = StringIO()
|
output_string = StringIO()
|
||||||
with open(filename, 'rb') as in_file:
|
with filename.open('rb') as in_file:
|
||||||
parser = PDFParser(in_file)
|
parser = PDFParser(in_file)
|
||||||
doc = PDFDocument(parser)
|
doc = PDFDocument(parser)
|
||||||
rsrcmgr = PDFResourceManager()
|
rsrcmgr = PDFResourceManager()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Test JSON serialization of OcrOptions for multiprocessing compatibility."""
|
"""Test JSON serialization of OcrOptions for multiprocessing compatibility."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
|
|||||||
+32
-11
@@ -100,14 +100,14 @@ def test_redo_ocr(resources, outpdf):
|
|||||||
out = check_ocrmypdf(in_, out, '--redo-ocr')
|
out = check_ocrmypdf(in_, out, '--redo-ocr')
|
||||||
after = PdfInfo(out, detailed_analysis=True)
|
after = PdfInfo(out, detailed_analysis=True)
|
||||||
assert before[0].has_text and after[0].has_text
|
assert before[0].has_text and after[0].has_text
|
||||||
assert (
|
assert before[0].get_textareas() != after[0].get_textareas(), (
|
||||||
before[0].get_textareas() != after[0].get_textareas()
|
"Expected text to be different after re-OCR"
|
||||||
), "Expected text to be different after re-OCR"
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_argsfile(resources, outdir):
|
def test_argsfile(resources, outdir):
|
||||||
path_argsfile = outdir / 'test_argsfile.txt'
|
path_argsfile = outdir / 'test_argsfile.txt'
|
||||||
with open(str(path_argsfile), 'w') as argsfile:
|
with path_argsfile.open('w') as argsfile:
|
||||||
print(
|
print(
|
||||||
'--title',
|
'--title',
|
||||||
'ArgsFile Test',
|
'ArgsFile Test',
|
||||||
@@ -646,7 +646,7 @@ def test_compression_preserved(ocrmypdf_exec, resources, image, outpdf):
|
|||||||
|
|
||||||
im = Image.open(input_file)
|
im = Image.open(input_file)
|
||||||
# Runs: ocrmypdf - output.pdf < testfile
|
# Runs: ocrmypdf - output.pdf < testfile
|
||||||
with open(input_file, 'rb') as input_stream:
|
with Path(input_file).open('rb') as input_stream:
|
||||||
p_args = ocrmypdf_exec + [
|
p_args = ocrmypdf_exec + [
|
||||||
'--optimize',
|
'--optimize',
|
||||||
'0',
|
'0',
|
||||||
@@ -704,7 +704,7 @@ def test_compression_changed(ocrmypdf_exec, resources, image, compression, outpd
|
|||||||
im = Image.open(input_file)
|
im = Image.open(input_file)
|
||||||
|
|
||||||
# Runs: ocrmypdf - output.pdf < testfile
|
# Runs: ocrmypdf - output.pdf < testfile
|
||||||
with open(input_file, 'rb') as input_stream:
|
with Path(input_file).open('rb') as input_stream:
|
||||||
p_args = ocrmypdf_exec + [
|
p_args = ocrmypdf_exec + [
|
||||||
'--image-dpi',
|
'--image-dpi',
|
||||||
'150',
|
'150',
|
||||||
@@ -763,14 +763,14 @@ def test_sidecar_pagecount(resources, outpdf):
|
|||||||
pdfinfo = PdfInfo(resources / '3small.pdf')
|
pdfinfo = PdfInfo(resources / '3small.pdf')
|
||||||
num_pages = len(pdfinfo)
|
num_pages = len(pdfinfo)
|
||||||
|
|
||||||
with open(sidecar, encoding='utf-8') as f:
|
with sidecar.open(encoding='utf-8') as f:
|
||||||
ocr_text = f.read()
|
ocr_text = f.read()
|
||||||
|
|
||||||
# There should a formfeed between each pair of pages, so the count of
|
# There should a formfeed between each pair of pages, so the count of
|
||||||
# formfeeds is the page count less one
|
# formfeeds is the page count less one
|
||||||
assert (
|
assert ocr_text.count('\f') == num_pages - 1, (
|
||||||
ocr_text.count('\f') == num_pages - 1
|
"Sidecar page count does not match PDF page count"
|
||||||
), "Sidecar page count does not match PDF page count"
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_sidecar_nonempty(resources, outpdf):
|
def test_sidecar_nonempty(resources, outpdf):
|
||||||
@@ -784,7 +784,7 @@ def test_sidecar_nonempty(resources, outpdf):
|
|||||||
'tests/plugins/tesseract_cache.py',
|
'tests/plugins/tesseract_cache.py',
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(sidecar, encoding='utf-8') as f:
|
with sidecar.open(encoding='utf-8') as f:
|
||||||
ocr_text = f.read()
|
ocr_text = f.read()
|
||||||
assert 'the' in ocr_text
|
assert 'the' in ocr_text
|
||||||
|
|
||||||
@@ -889,6 +889,27 @@ def test_version_check():
|
|||||||
get_version('echo')
|
get_version('echo')
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_skips_leading_warning_lines(monkeypatch):
|
||||||
|
"""VeraPDF 1.30.0 prints JVM warnings before its version line."""
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
|
||||||
|
import ocrmypdf.subprocess as sp
|
||||||
|
|
||||||
|
output = (
|
||||||
|
"WARNING: Final field flavour has been mutated reflectively\n"
|
||||||
|
"WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid this\n"
|
||||||
|
"veraPDF 1.30.0\n"
|
||||||
|
"Built: Wed Jun 03 13:29:00 PDT 2026\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
return CompletedProcess(args, 0, stdout=output, stderr="")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sp, 'run', fake_run)
|
||||||
|
version = get_version('verapdf', regex=r'veraPDF (\d+(\.\d+)*)')
|
||||||
|
assert version == '1.30.0'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'threshold, optimize, output_type, expected',
|
'threshold, optimize, output_type, expected',
|
||||||
[
|
[
|
||||||
|
|||||||
+29
-2
@@ -6,13 +6,14 @@ from __future__ import annotations
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import warnings
|
import warnings
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import pytest
|
import pytest
|
||||||
from pikepdf.models.metadata import decode_pdf_date
|
from pikepdf.models.metadata import decode_pdf_date
|
||||||
|
|
||||||
from ocrmypdf._jobcontext import PdfContext
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
from ocrmypdf._metadata import metadata_fixup
|
from ocrmypdf._metadata import metadata_fixup, repair_docinfo_nuls
|
||||||
from ocrmypdf._pipeline import convert_to_pdfa
|
from ocrmypdf._pipeline import convert_to_pdfa
|
||||||
from ocrmypdf.api import setup_plugin_infrastructure
|
from ocrmypdf.api import setup_plugin_infrastructure
|
||||||
from ocrmypdf.cli import get_options_and_plugins
|
from ocrmypdf.cli import get_options_and_plugins
|
||||||
@@ -43,6 +44,32 @@ def test_preserve_docinfo(output_type, resources, outpdf):
|
|||||||
assert pdfa_info['output'] == output_type
|
assert pdfa_info['output'] == output_type
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_docinfo_nuls_undecodable_key(caplog):
|
||||||
|
"""A DocumentInfo key with bytes that don't decode must not crash.
|
||||||
|
|
||||||
|
Some PDFs use a /Name dictionary key in DocumentInfo whose bytes are not
|
||||||
|
valid PDFDocEncoding/UTF-8 (e.g. Latin-1 ``/Saks#e5r``). Older pikepdf
|
||||||
|
raised UnicodeDecodeError while iterating such a dictionary. The repair
|
||||||
|
must log and continue rather than propagate the exception. See #1540.
|
||||||
|
"""
|
||||||
|
pdf = MagicMock()
|
||||||
|
pdf.docinfo.items.side_effect = UnicodeDecodeError(
|
||||||
|
'utf-8', b'Saks\xe5r', 4, 5, 'invalid continuation byte'
|
||||||
|
)
|
||||||
|
# Make isinstance(pdf.docinfo, Dictionary) succeed so we reach the loop.
|
||||||
|
with patch('ocrmypdf._metadata.Dictionary', MagicMock):
|
||||||
|
result = repair_docinfo_nuls(pdf)
|
||||||
|
assert result is False
|
||||||
|
assert 'malformed DocumentInfo' in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_docinfo_nuls_undecodable_key_real_file(resources):
|
||||||
|
"""Opening a real file with a Latin-1 DocumentInfo key must not crash."""
|
||||||
|
with pikepdf.open(resources / 'docinfo_latin1_key.pdf') as pdf:
|
||||||
|
# Should return without raising regardless of pikepdf's decode behavior.
|
||||||
|
repair_docinfo_nuls(pdf)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
|
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
|
||||||
def test_override_metadata(output_type, resources, outpdf, caplog):
|
def test_override_metadata(output_type, resources, outpdf, caplog):
|
||||||
input_file = resources / 'c02-22.pdf'
|
input_file = resources / 'c02-22.pdf'
|
||||||
@@ -110,7 +137,7 @@ def test_unset_metadata(output_type, field, resources, outpdf, caplog):
|
|||||||
# isn't contained anywhere in the output pdf. We'll also check to ensure
|
# isn't contained anywhere in the output pdf. We'll also check to ensure
|
||||||
# it's in the input pdf and that any values not unset are still in the
|
# it's in the input pdf and that any values not unset are still in the
|
||||||
# output pdf.
|
# output pdf.
|
||||||
with open(input_file, 'rb') as before, open(outpdf, 'rb') as after:
|
with input_file.open('rb') as before, outpdf.open('rb') as after:
|
||||||
before_data = before.read()
|
before_data = before.read()
|
||||||
after_data = after.read()
|
after_data = after.read()
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def has_devanagari_font(manager: MultiFontManager) -> bool:
|
|||||||
# Marker for tests that require CJK fonts
|
# Marker for tests that require CJK fonts
|
||||||
requires_cjk = pytest.mark.skipif(
|
requires_cjk = pytest.mark.skipif(
|
||||||
"not has_cjk_font(MultiFontManager())",
|
"not has_cjk_font(MultiFontManager())",
|
||||||
reason="CJK font not available (not installed on system)"
|
reason="CJK font not available (not installed on system)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -149,7 +149,9 @@ def test_select_font_for_chinese_language(multi_font_manager):
|
|||||||
if not has_cjk_font(multi_font_manager):
|
if not has_cjk_font(multi_font_manager):
|
||||||
pytest.skip("CJK font not available")
|
pytest.skip("CJK font not available")
|
||||||
font_manager = multi_font_manager.select_font_for_word("你好", "zho")
|
font_manager = multi_font_manager.select_font_for_word("你好", "zho")
|
||||||
assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
|
# A real, glyph-covering CJK font is selected (which specific family
|
||||||
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
||||||
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
||||||
|
|
||||||
|
|
||||||
def test_select_font_for_chinese_generic(multi_font_manager):
|
def test_select_font_for_chinese_generic(multi_font_manager):
|
||||||
@@ -157,7 +159,9 @@ def test_select_font_for_chinese_generic(multi_font_manager):
|
|||||||
if not has_cjk_font(multi_font_manager):
|
if not has_cjk_font(multi_font_manager):
|
||||||
pytest.skip("CJK font not available")
|
pytest.skip("CJK font not available")
|
||||||
font_manager = multi_font_manager.select_font_for_word("中文", "chi")
|
font_manager = multi_font_manager.select_font_for_word("中文", "chi")
|
||||||
assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
|
# A real, glyph-covering CJK font is selected (which specific family
|
||||||
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
||||||
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
||||||
|
|
||||||
|
|
||||||
def test_select_font_for_chinese_simplified(multi_font_manager):
|
def test_select_font_for_chinese_simplified(multi_font_manager):
|
||||||
@@ -165,7 +169,9 @@ def test_select_font_for_chinese_simplified(multi_font_manager):
|
|||||||
if not has_cjk_font(multi_font_manager):
|
if not has_cjk_font(multi_font_manager):
|
||||||
pytest.skip("CJK font not available")
|
pytest.skip("CJK font not available")
|
||||||
font_manager = multi_font_manager.select_font_for_word("简体字", "chi_sim")
|
font_manager = multi_font_manager.select_font_for_word("简体字", "chi_sim")
|
||||||
assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
|
# A real, glyph-covering CJK font is selected (which specific family
|
||||||
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
||||||
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
||||||
|
|
||||||
|
|
||||||
def test_select_font_for_chinese_traditional(multi_font_manager):
|
def test_select_font_for_chinese_traditional(multi_font_manager):
|
||||||
@@ -173,7 +179,9 @@ def test_select_font_for_chinese_traditional(multi_font_manager):
|
|||||||
if not has_cjk_font(multi_font_manager):
|
if not has_cjk_font(multi_font_manager):
|
||||||
pytest.skip("CJK font not available")
|
pytest.skip("CJK font not available")
|
||||||
font_manager = multi_font_manager.select_font_for_word("漢字", "chi_tra")
|
font_manager = multi_font_manager.select_font_for_word("漢字", "chi_tra")
|
||||||
assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
|
# A real, glyph-covering CJK font is selected (which specific family
|
||||||
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
||||||
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
||||||
|
|
||||||
|
|
||||||
def test_select_font_for_japanese_language(multi_font_manager):
|
def test_select_font_for_japanese_language(multi_font_manager):
|
||||||
@@ -181,7 +189,9 @@ def test_select_font_for_japanese_language(multi_font_manager):
|
|||||||
if not has_cjk_font(multi_font_manager):
|
if not has_cjk_font(multi_font_manager):
|
||||||
pytest.skip("CJK font not available")
|
pytest.skip("CJK font not available")
|
||||||
font_manager = multi_font_manager.select_font_for_word("こんにちは", "jpn")
|
font_manager = multi_font_manager.select_font_for_word("こんにちは", "jpn")
|
||||||
assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
|
# A real, glyph-covering CJK font is selected (which specific family
|
||||||
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
||||||
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
||||||
|
|
||||||
|
|
||||||
def test_select_font_for_korean_language(multi_font_manager):
|
def test_select_font_for_korean_language(multi_font_manager):
|
||||||
@@ -189,7 +199,9 @@ def test_select_font_for_korean_language(multi_font_manager):
|
|||||||
if not has_cjk_font(multi_font_manager):
|
if not has_cjk_font(multi_font_manager):
|
||||||
pytest.skip("CJK font not available")
|
pytest.skip("CJK font not available")
|
||||||
font_manager = multi_font_manager.select_font_for_word("안녕하세요", "kor")
|
font_manager = multi_font_manager.select_font_for_word("안녕하세요", "kor")
|
||||||
assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
|
# A real, glyph-covering CJK font is selected (which specific family
|
||||||
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
||||||
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
||||||
|
|
||||||
|
|
||||||
# --- Latin/English Tests ---
|
# --- Latin/English Tests ---
|
||||||
@@ -232,7 +244,9 @@ def test_cjk_text_without_language_hint(multi_font_manager):
|
|||||||
if not has_cjk_font(multi_font_manager):
|
if not has_cjk_font(multi_font_manager):
|
||||||
pytest.skip("CJK font not available")
|
pytest.skip("CJK font not available")
|
||||||
font_manager = multi_font_manager.select_font_for_word("你好", None)
|
font_manager = multi_font_manager.select_font_for_word("你好", None)
|
||||||
assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
|
# A real, glyph-covering CJK font is selected (which specific family
|
||||||
|
# depends on what is installed: pan-CJK super font or a per-language subset).
|
||||||
|
assert font_manager.font_path.name != 'Occulta.ttf'
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_to_occulta_font(multi_font_manager):
|
def test_fallback_to_occulta_font(multi_font_manager):
|
||||||
@@ -342,9 +356,7 @@ def test_get_all_fonts(multi_font_manager):
|
|||||||
class MockFontProvider:
|
class MockFontProvider:
|
||||||
"""Mock FontProvider for testing missing fonts."""
|
"""Mock FontProvider for testing missing fonts."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, available_fonts: dict[str, FontManager], fallback: FontManager):
|
||||||
self, available_fonts: dict[str, FontManager], fallback: FontManager
|
|
||||||
):
|
|
||||||
"""Initialize mock font provider with given fonts."""
|
"""Initialize mock font provider with given fonts."""
|
||||||
self._fonts = available_fonts
|
self._fonts = available_fonts
|
||||||
self._fallback = fallback
|
self._fallback = fallback
|
||||||
@@ -444,3 +456,116 @@ def test_builtin_font_provider_missing_occulta_raises(tmp_path):
|
|||||||
"""Test that missing Occulta.ttf raises FileNotFoundError."""
|
"""Test that missing Occulta.ttf raises FileNotFoundError."""
|
||||||
with pytest.raises(FileNotFoundError, match="Required fallback font"):
|
with pytest.raises(FileNotFoundError, match="Required fallback font"):
|
||||||
BuiltinFontProvider(tmp_path)
|
BuiltinFontProvider(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubHbFont:
|
||||||
|
"""Minimal uharfbuzz Font stand-in with controllable glyph coverage."""
|
||||||
|
|
||||||
|
def __init__(self, covered_codepoints: set[int]):
|
||||||
|
self._covered = covered_codepoints
|
||||||
|
|
||||||
|
def get_nominal_glyph(self, codepoint: int) -> int:
|
||||||
|
return 1 if codepoint in self._covered else 0
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFontManager:
|
||||||
|
"""FontManager stand-in whose glyph coverage is fixed per test."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, covered_chars: str):
|
||||||
|
self.font_path = Path(name)
|
||||||
|
self._hb = _StubHbFont({ord(c) for c in covered_chars})
|
||||||
|
|
||||||
|
def get_hb_font(self) -> _StubHbFont:
|
||||||
|
return self._hb
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeFontProvider:
|
||||||
|
"""FontProvider returning controlled fonts by logical name."""
|
||||||
|
|
||||||
|
def __init__(self, fonts: dict[str, _FakeFontManager]):
|
||||||
|
self._fonts = fonts
|
||||||
|
self._fallback = _FakeFontManager('Occulta.ttf', '')
|
||||||
|
|
||||||
|
def get_font(self, name: str) -> _FakeFontManager | None:
|
||||||
|
return self._fonts.get(name)
|
||||||
|
|
||||||
|
def get_available_fonts(self) -> list[str]:
|
||||||
|
return list(self._fonts)
|
||||||
|
|
||||||
|
def get_fallback_font(self) -> _FakeFontManager:
|
||||||
|
return self._fallback
|
||||||
|
|
||||||
|
|
||||||
|
def test_japanese_prefers_jp_family_over_other_cjk():
|
||||||
|
"""A Japanese language hint selects NotoSansJP, not another CJK family."""
|
||||||
|
fonts = {
|
||||||
|
'NotoSansSC-Regular': _FakeFontManager('NotoSansSC.ttf', '中'),
|
||||||
|
'NotoSansJP-Regular': _FakeFontManager('NotoSansJP.ttf', '中こ'),
|
||||||
|
}
|
||||||
|
manager = MultiFontManager(font_provider=_FakeFontProvider(fonts))
|
||||||
|
# 'こ' (kana) is only covered by JP; both cover the kanji '中'.
|
||||||
|
font = manager.select_font_for_word('中こ', 'jpn')
|
||||||
|
assert font.font_path.name == 'NotoSansJP.ttf'
|
||||||
|
|
||||||
|
|
||||||
|
def test_chinese_simplified_prefers_sc_family():
|
||||||
|
"""A Simplified Chinese hint selects NotoSansSC over the pan-CJK font."""
|
||||||
|
fonts = {
|
||||||
|
'NotoSansSC-Regular': _FakeFontManager('NotoSansSC.ttf', '简'),
|
||||||
|
'NotoSansCJK-Regular': _FakeFontManager('NotoSansCJK.ttc', '简'),
|
||||||
|
}
|
||||||
|
manager = MultiFontManager(font_provider=_FakeFontProvider(fonts))
|
||||||
|
font = manager.select_font_for_word('简', 'chi_sim')
|
||||||
|
assert font.font_path.name == 'NotoSansSC.ttf'
|
||||||
|
|
||||||
|
|
||||||
|
def test_cjk_fails_over_when_preferred_subset_lacks_glyph():
|
||||||
|
"""If the language's subset font lacks a glyph, another CJK family is used."""
|
||||||
|
fonts = {
|
||||||
|
# Simplified Chinese subset cannot render Japanese kana.
|
||||||
|
'NotoSansSC-Regular': _FakeFontManager('NotoSansSC.ttf', '中'),
|
||||||
|
'NotoSansJP-Regular': _FakeFontManager('NotoSansJP.ttf', '中こ'),
|
||||||
|
}
|
||||||
|
manager = MultiFontManager(font_provider=_FakeFontProvider(fonts))
|
||||||
|
# Tagged Simplified Chinese, but the text needs kana only JP covers.
|
||||||
|
font = manager.select_font_for_word('こ', 'chi_sim')
|
||||||
|
assert font.font_path.name == 'NotoSansJP.ttf'
|
||||||
|
|
||||||
|
|
||||||
|
def test_cjk_falls_back_to_pan_cjk_super_font():
|
||||||
|
"""When only the full-coverage pan-CJK font exists, it serves any CJK lang."""
|
||||||
|
fonts = {
|
||||||
|
'NotoSansCJK-Regular': _FakeFontManager('NotoSansCJK.ttc', '中こ안'),
|
||||||
|
}
|
||||||
|
manager = MultiFontManager(font_provider=_FakeFontProvider(fonts))
|
||||||
|
assert manager.select_font_for_word('こ', 'jpn').font_path.name == 'NotoSansCJK.ttc'
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_cjk_font_warning_names_language_font(font_dir, caplog):
|
||||||
|
"""The missing-font warning names the language-specific CJK family (#1652)."""
|
||||||
|
manager = MultiFontManager(font_provider=BuiltinFontProvider(font_dir))
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
manager.select_font_for_word("こんにちは", "jpn")
|
||||||
|
assert 'NotoSansJP' in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_font_warning_explains_consequences(font_dir, caplog):
|
||||||
|
"""The glyphless-fallback warning should be actionable, not cryptic (#1652).
|
||||||
|
|
||||||
|
With only builtin fonts available, Arabic text cannot be covered, so the
|
||||||
|
manager falls back to glyphless Occulta and must warn helpfully.
|
||||||
|
"""
|
||||||
|
# Builtin-only provider: NotoSansArabic is never available, forcing fallback.
|
||||||
|
manager = MultiFontManager(font_provider=BuiltinFontProvider(font_dir))
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
manager.select_font_for_word("سلام", "fas")
|
||||||
|
|
||||||
|
msg = caplog.text
|
||||||
|
# Identifies the affected language and the font family to install.
|
||||||
|
assert 'fas' in msg
|
||||||
|
assert 'NotoSansArabic' in msg
|
||||||
|
# Explains the user-visible consequence so the message is not cryptic:
|
||||||
|
# the text stays searchable but renders blank when highlighted.
|
||||||
|
assert 'searchable' in msg.lower()
|
||||||
|
assert 'highlight' in msg.lower() or 'select' in msg.lower()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ This tests the fpdf2 renderer with various language groups:
|
|||||||
- CJK (Chinese Simplified/Traditional, Japanese, Korean)
|
- CJK (Chinese Simplified/Traditional, Japanese, Korean)
|
||||||
- Devanagari (Hindi, Sanskrit)
|
- Devanagari (Hindi, Sanskrit)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
@@ -208,9 +209,9 @@ class TestArabicScript:
|
|||||||
for para in page.paragraphs:
|
for para in page.paragraphs:
|
||||||
if para.language in ('ara', 'per'):
|
if para.language in ('ara', 'per'):
|
||||||
# Arabic paragraphs should have RTL direction
|
# Arabic paragraphs should have RTL direction
|
||||||
assert (
|
assert para.direction == 'rtl', (
|
||||||
para.direction == 'rtl'
|
"Arabic paragraph should have RTL direction"
|
||||||
), "Arabic paragraph should have RTL direction"
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -529,9 +530,9 @@ class TestBaselineHandling:
|
|||||||
for line in page.lines:
|
for line in page.lines:
|
||||||
if line.baseline:
|
if line.baseline:
|
||||||
# Baseline should be reasonable
|
# Baseline should be reasonable
|
||||||
assert (
|
assert -1.0 <= line.baseline.slope <= 1.0, (
|
||||||
-1.0 <= line.baseline.slope <= 1.0
|
"Baseline slope should be reasonable"
|
||||||
), "Baseline slope should be reasonable"
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -556,9 +557,9 @@ class TestFontCoverage:
|
|||||||
]
|
]
|
||||||
|
|
||||||
for sample in latin_samples:
|
for sample in latin_samples:
|
||||||
assert multi_font_manager.has_all_glyphs(
|
assert multi_font_manager.has_all_glyphs('NotoSans-Regular', sample), (
|
||||||
'NotoSans-Regular', sample
|
f"NotoSans should cover: {sample}"
|
||||||
), f"NotoSans should cover: {sample}"
|
)
|
||||||
|
|
||||||
def test_noto_sans_arabic_coverage(self, multi_font_manager_arabic):
|
def test_noto_sans_arabic_coverage(self, multi_font_manager_arabic):
|
||||||
"""Test NotoSansArabic covers Arabic characters."""
|
"""Test NotoSansArabic covers Arabic characters."""
|
||||||
@@ -602,9 +603,9 @@ class TestFontCoverage:
|
|||||||
]
|
]
|
||||||
|
|
||||||
for sample in cjk_samples:
|
for sample in cjk_samples:
|
||||||
assert multi_font_manager.has_all_glyphs(
|
assert multi_font_manager.has_all_glyphs('NotoSansCJK-Regular', sample), (
|
||||||
'NotoSansCJK-Regular', sample
|
f"NotoSansCJK should cover: {sample}"
|
||||||
), f"NotoSansCJK should cover: {sample}"
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+72
-6
@@ -197,14 +197,14 @@ def test_optimize_off(resources, outpdf):
|
|||||||
def test_group3(resources):
|
def test_group3(resources):
|
||||||
with pikepdf.open(resources / 'ccitt.pdf') as pdf:
|
with pikepdf.open(resources / 'ccitt.pdf') as pdf:
|
||||||
im = pdf.pages[0].Resources.XObject['/Im1']
|
im = pdf.pages[0].Resources.XObject['/Im1']
|
||||||
assert (
|
assert opt.extract_image_filter(im, im.objgen[0]) is not None, (
|
||||||
opt.extract_image_filter(im, im.objgen[0]) is not None
|
"Group 4 should be allowed"
|
||||||
), "Group 4 should be allowed"
|
)
|
||||||
|
|
||||||
im.DecodeParms['/K'] = 0
|
im.DecodeParms['/K'] = 0
|
||||||
assert (
|
assert opt.extract_image_filter(im, im.objgen[0]) is None, (
|
||||||
opt.extract_image_filter(im, im.objgen[0]) is None
|
"Group 3 should be disallowed"
|
||||||
), "Group 3 should be disallowed"
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_find_formx(resources):
|
def test_find_formx(resources):
|
||||||
@@ -215,6 +215,72 @@ def test_find_formx(resources):
|
|||||||
assert pagenos[xref] == 0
|
assert pagenos[xref] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_formx_circular_reference(resources, tmp_path, caplog):
|
||||||
|
"""Regression for issue #1321.
|
||||||
|
|
||||||
|
Some PDFs (notably PowerPoint exports) contain Form XObjects that
|
||||||
|
reference themselves or each other in a cycle. The recursion guard in
|
||||||
|
_find_image_xrefs_container only deduplicates *image* xrefs, so a Form
|
||||||
|
XObject cycle would re-enter every branch until the depth limit fired,
|
||||||
|
producing thousands of "Recursion depth exceeded" warnings (and minutes
|
||||||
|
of wall-clock time on real-world inputs).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
src = resources / 'formxobject.pdf'
|
||||||
|
out = tmp_path / 'circular_form.pdf'
|
||||||
|
with pikepdf.open(src) as pdf:
|
||||||
|
# /Form1 lives at xref 10. Replace its Resources.XObject with three
|
||||||
|
# entries that all point back to /Form1 itself, creating a fan-out
|
||||||
|
# cycle of branching factor 3.
|
||||||
|
form = pdf.pages[0].obj.Resources.XObject.Form1
|
||||||
|
form.Resources.XObject = Dictionary({'/Fm0': form, '/Fm1': form, '/Fm2': form})
|
||||||
|
pdf.save(out)
|
||||||
|
|
||||||
|
caplog.set_level(logging.WARNING, logger='ocrmypdf.optimize')
|
||||||
|
with pikepdf.open(out) as pdf:
|
||||||
|
opt._find_image_xrefs(pdf)
|
||||||
|
|
||||||
|
n_warnings = sum(
|
||||||
|
1 for r in caplog.records if 'Recursion depth exceeded' in r.getMessage()
|
||||||
|
)
|
||||||
|
# Without the fix this is in the tens of thousands.
|
||||||
|
assert n_warnings == 0, (
|
||||||
|
f"Form XObject cycle should be detected without depth-limit warnings; "
|
||||||
|
f"got {n_warnings}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_images_traps_errors_as_warning(resources, tmp_path, caplog):
|
||||||
|
"""Regression for issue #846.
|
||||||
|
|
||||||
|
The optimizer is best-effort: any image it cannot process can simply be
|
||||||
|
passed through unchanged. When extraction of an image raises (e.g. an
|
||||||
|
exotic colorspace pikepdf cannot transcode), the user should see a concise
|
||||||
|
warning that the image was left unchanged, not an alarming traceback
|
||||||
|
logged at ERROR level.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
def boom(*, pdf, root, image, xref, options):
|
||||||
|
raise NotImplementedError("synthetic extraction failure")
|
||||||
|
|
||||||
|
caplog.set_level(logging.DEBUG, logger='ocrmypdf.optimize')
|
||||||
|
with pikepdf.open(resources / 'francais.pdf') as pdf:
|
||||||
|
results = list(opt.extract_images(pdf, tmp_path, Mock(), boom))
|
||||||
|
|
||||||
|
# The error is trapped, not propagated, and nothing is extracted.
|
||||||
|
assert results == []
|
||||||
|
# A friendly warning is emitted...
|
||||||
|
assert any(
|
||||||
|
r.levelno == logging.WARNING and 'left unchanged' in r.getMessage()
|
||||||
|
for r in caplog.records
|
||||||
|
)
|
||||||
|
# ...and no traceback is logged at ERROR level or above.
|
||||||
|
assert not any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
def test_extract_image_filter_with_pdf_image():
|
def test_extract_image_filter_with_pdf_image():
|
||||||
image = Dictionary()
|
image = Dictionary()
|
||||||
image.Subtype = Name.Image
|
image.Subtype = Name.Image
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user