Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1fc77e1b6 | ||
|
|
17eed0529a | ||
|
|
f02353686d | ||
|
|
32813a3c3d | ||
|
|
073a434ab3 | ||
|
|
f390e7f9d1 | ||
|
|
bfbe571f12 | ||
|
|
368568b8ea | ||
|
|
55e7177dbe | ||
|
|
b486df7e2d | ||
|
|
74a84b6ae9 | ||
|
|
cfebf1dc8b | ||
|
|
1aaff4af6f | ||
|
|
36c82e0659 | ||
|
|
522f9d5f56 | ||
|
|
796e424ee5 | ||
|
|
d87db6cad0 | ||
|
|
dd6ed4c5f8 | ||
|
|
206bab74bc | ||
|
|
b333480749 | ||
|
|
f71a5ffd61 | ||
|
|
b7c3ea70ed | ||
|
|
636623ab49 | ||
|
|
74253e5fc8 | ||
|
|
02d85ff070 | ||
|
|
179c36151b | ||
|
|
3c4b099cb1 | ||
|
|
15df9c370c | ||
|
|
86d92ef490 | ||
|
|
8f44b29ca3 | ||
|
|
5a08a6cfeb | ||
|
|
cc058be4b2 | ||
|
|
7565d20c0a | ||
|
|
9a075039b5 | ||
|
|
5a1c043331 | ||
|
|
fe89be5dc0 | ||
|
|
d70296b97a | ||
|
|
7d7658018d | ||
|
|
8fb8e9f72c | ||
|
|
85d6fb8ce9 | ||
|
|
828e741c24 | ||
|
|
36837f8353 | ||
|
|
12fd4f70f1 | ||
|
|
250615561d | ||
|
|
a659f83d67 |
+17
-5
@@ -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:22.04 AS base
|
FROM ubuntu:24.04 AS base
|
||||||
|
|
||||||
ENV LANG=C.UTF-8
|
ENV LANG=C.UTF-8
|
||||||
ENV TZ=UTC
|
ENV TZ=UTC
|
||||||
@@ -36,16 +36,27 @@ RUN \
|
|||||||
&& cd .. \
|
&& cd .. \
|
||||||
&& rm -rf jbig2
|
&& rm -rf jbig2
|
||||||
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/0.4.27/install.sh | sh
|
# Copy uv from ghcr
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
|
||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
# Instead of restarting the shell, use uv directly from its installed location.
|
# Install the project's dependencies using the lockfile and settings
|
||||||
RUN /root/.cargo/bin/uv sync --extra test --extra webservice --extra watcher
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
# Then, add the rest of the project source code and install it
|
||||||
|
# Installing separately from its dependencies allows optimal layer caching
|
||||||
|
COPY . /app
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen \
|
||||||
|
--extra test --extra webservice --extra watcher --no-dev \
|
||||||
|
--no-install-package pyarrow
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
|
|
||||||
@@ -65,6 +76,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
tesseract-ocr-fra \
|
tesseract-ocr-fra \
|
||||||
tesseract-ocr-por \
|
tesseract-ocr-por \
|
||||||
tesseract-ocr-spa \
|
tesseract-ocr-spa \
|
||||||
|
ttyd \
|
||||||
unpaper \
|
unpaper \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -19,22 +19,35 @@ RUN apk add --no-cache \
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
|
# Yes it really is python3-dev, and py3-package
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
git \
|
git \
|
||||||
python3-dev \
|
python3-dev \
|
||||||
|
py3-pyarrow \
|
||||||
curl
|
curl
|
||||||
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/0.4.27/install.sh | sh
|
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
|
||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
# Instead of restarting the shell, use uv directly from its installed location.
|
RUN uv venv --system-site-packages .venv
|
||||||
RUN /root/.cargo/bin/uv sync --extra test --extra webservice --extra watcher
|
|
||||||
|
# Install the project's dependencies using the lockfile and settings
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
# Then, add the rest of the project source code and install it
|
||||||
|
# Installing separately from its dependencies allows optimal layer caching
|
||||||
|
COPY . /app
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen \
|
||||||
|
--extra test --extra webservice --extra watcher --no-dev \
|
||||||
|
--no-install-package pyarrow
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
|
|
||||||
@@ -52,6 +65,7 @@ RUN apk add --no-cache \
|
|||||||
tesseract-ocr-data-por \
|
tesseract-ocr-data-por \
|
||||||
tesseract-ocr-data-spa \
|
tesseract-ocr-data-spa \
|
||||||
ttf-droid \
|
ttf-droid \
|
||||||
|
ttyd \
|
||||||
unpaper \
|
unpaper \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
|||||||
+22
-22
@@ -40,9 +40,9 @@ jobs:
|
|||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
version: "0.4.27"
|
version: "0.5.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -84,7 +84,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra test
|
uv sync --extra test --no-dev
|
||||||
|
|
||||||
- name: Report versions
|
- name: Report versions
|
||||||
run: |
|
run: |
|
||||||
@@ -92,14 +92,14 @@ jobs:
|
|||||||
gs --version
|
gs --version
|
||||||
pngquant --version
|
pngquant --version
|
||||||
unpaper --version
|
unpaper --version
|
||||||
uv run img2pdf --version
|
uv run --no-dev img2pdf --version
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
uv run pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -136,9 +136,9 @@ jobs:
|
|||||||
tesseract
|
tesseract
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
version: "0.4.27"
|
version: "0.5.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -147,21 +147,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra test
|
uv sync --extra test --no-dev
|
||||||
|
|
||||||
- name: Report versions
|
- name: Report versions
|
||||||
run: |
|
run: |
|
||||||
tesseract --version
|
tesseract --version
|
||||||
gs --version
|
gs --version
|
||||||
pngquant --version
|
pngquant --version
|
||||||
uv run img2pdf --version
|
uv run --no-dev img2pdf --version
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
uv run pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -186,9 +186,9 @@ jobs:
|
|||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
version: "0.4.27"
|
version: "0.5.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -202,14 +202,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra test
|
uv sync --extra test --no-dev
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
uv run pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -225,9 +225,9 @@ jobs:
|
|||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
version: "0.4.27"
|
version: "0.5.x"
|
||||||
|
|
||||||
- name: Make wheels and sdist
|
- name: Make wheels and sdist
|
||||||
run: |
|
run: |
|
||||||
@@ -275,14 +275,14 @@ jobs:
|
|||||||
- name: Sign the dists with Sigstore
|
- name: Sign the dists with Sigstore
|
||||||
uses: sigstore/gh-action-sigstore-python@v3.0.0
|
uses: sigstore/gh-action-sigstore-python@v3.0.0
|
||||||
with:
|
with:
|
||||||
inputs: >-
|
inputs: |
|
||||||
./dist/*.tar.gz
|
./dist/*.tar.gz
|
||||||
./dist/*.whl
|
./dist/*.whl
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: >-
|
run: |
|
||||||
gh release create
|
gh release create
|
||||||
'${{ github.ref_name }}'
|
'${{ github.ref_name }}'
|
||||||
--repo '${{ github.repository }}'
|
--repo '${{ github.repository }}'
|
||||||
@@ -294,7 +294,7 @@ jobs:
|
|||||||
# Upload to GitHub Release using the `gh` CLI.
|
# Upload to GitHub Release using the `gh` CLI.
|
||||||
# `dist/` contains the built packages, and the
|
# `dist/` contains the built packages, and the
|
||||||
# sigstore-produced signatures and certificates.
|
# sigstore-produced signatures and certificates.
|
||||||
run: >-
|
run: |
|
||||||
gh release upload
|
gh release upload
|
||||||
'${{ github.ref_name }}' dist/**
|
'${{ github.ref_name }}' dist/**
|
||||||
--repo '${{ github.repository }}'
|
--repo '${{ github.repository }}'
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
name: Remove Triage Label on Reply
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types:
|
||||||
|
- created
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
remove-triage-label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check if comment is by the repository owner
|
||||||
|
id: check_comment
|
||||||
|
run: |
|
||||||
|
echo "::set-output name=is_owner::$(
|
||||||
|
if [[ '${{ github.event.comment.user.login }}' == 'jbarlow83' ]]; then
|
||||||
|
echo 'true';
|
||||||
|
else
|
||||||
|
echo 'false';
|
||||||
|
fi
|
||||||
|
)"
|
||||||
|
|
||||||
|
- name: Remove 'triage' label
|
||||||
|
if: ${{ steps.check_comment.outputs.is_owner == 'true' }}
|
||||||
|
uses: actions-ecosystem/action-remove-labels@v1
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
labels: triage
|
||||||
@@ -74,7 +74,6 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
|
|||||||
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
||||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||||
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
||||||
| Conda | ``conda install ocrmypdf`` |
|
|
||||||
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
||||||
|
|
||||||
For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for installation steps.
|
For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for installation steps.
|
||||||
@@ -113,9 +112,33 @@ Our [documentation is served on Read the Docs](https://ocrmypdf.readthedocs.io/e
|
|||||||
|
|
||||||
Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) page, and follow the issue template for quick response.
|
Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) page, and follow the issue template for quick response.
|
||||||
|
|
||||||
|
## Feature demo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add an OCR layer and convert to PDF/A
|
||||||
|
ocrmypdf input.pdf output.pdf
|
||||||
|
|
||||||
|
# Convert an image to single page PDF
|
||||||
|
ocrmypdf input.jpg output.pdf
|
||||||
|
|
||||||
|
# Add OCR to a file in place (only modifies file on success)
|
||||||
|
ocrmypdf myfile.pdf myfile.pdf
|
||||||
|
|
||||||
|
# OCR with non-English languages (look up your language's ISO 639-3 code)
|
||||||
|
ocrmypdf -l fra LeParisien.pdf LeParisien.pdf
|
||||||
|
|
||||||
|
# OCR multilingual documents
|
||||||
|
ocrmypdf -l eng+fra Bilingual-English-French.pdf Bilingual-English-French.pdf
|
||||||
|
|
||||||
|
# Deskew (straighten crooked pages)
|
||||||
|
ocrmypdf --deskew input.pdf output.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
For more features, see the [documentation](https://ocrmypdf.readthedocs.io/en/latest/index.html).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
In addition to the required Python version (3.8+), OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
|
In addition to the required Python version, OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
|
||||||
|
|
||||||
## Press & Media
|
## Press & Media
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -235,7 +235,7 @@ The directive ``--tesseract-pagesegmode Nmode`` forwards the desired page segmen
|
|||||||
mode to Tesseract OCR. The default is 3.
|
mode to Tesseract OCR. The default is 3.
|
||||||
|
|
||||||
Page segmentation can improve OCR results when you know that a PDF ought to be
|
Page segmentation can improve OCR results when you know that a PDF ought to be
|
||||||
analyzed a particular way, such as PDFs whose pages contain only a single line of
|
analyzed a particular way, such as PDFs whose pages contain only a single line of
|
||||||
text. For the vast majority of users, changing the page segmentation mode will only
|
text. For the vast majority of users, changing the page segmentation mode will only
|
||||||
make things worse.
|
make things worse.
|
||||||
|
|
||||||
@@ -244,37 +244,37 @@ As of June 2024, the Tesseract page segmentation modes are:
|
|||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| ID | Description |
|
| ID | Description |
|
||||||
+=====+==================================================================================+
|
+=====+==================================================================================+
|
||||||
| 0 | Orientation and script detection (OSD) only. |
|
| 0 | Orientation and script detection (OSD) only. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 1 | Automatic page segmentation with OSD. |
|
| 1 | Automatic page segmentation with OSD. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 2 | Automatic page segmentation, but no OSD, or OCR. (not implemented) |
|
| 2 | Automatic page segmentation, but no OSD, or OCR. (not implemented) |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 3 | Fully automatic page segmentation, but no OSD. (Default) |
|
| 3 | Fully automatic page segmentation, but no OSD. (Default) |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 4 | Assume a single column of text of variable sizes. |
|
| 4 | Assume a single column of text of variable sizes. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 5 | Assume a single uniform block of vertically aligned text. |
|
| 5 | Assume a single uniform block of vertically aligned text. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 6 | Assume a single uniform block of text. |
|
| 6 | Assume a single uniform block of text. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 7 | Treat the image as a single text line. |
|
| 7 | Treat the image as a single text line. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 8 | Treat the image as a single word. |
|
| 8 | Treat the image as a single word. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 9 | Treat the image as a single word in a circle. |
|
| 9 | Treat the image as a single word in a circle. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 10 | Treat the image as a single character. |
|
| 10 | Treat the image as a single character. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 11 | Sparse text. Find as much text as possible in no particular order. |
|
| 11 | Sparse text. Find as much text as possible in no particular order. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 12 | Sparse text with OSD. |
|
| 12 | Sparse text with OSD. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
| 13 | Raw line. Treat the image as a single text line, bypassing hacks that are |
|
| 13 | Raw line. Treat the image as a single text line, bypassing hacks that are |
|
||||||
| | Tesseract-specific. |
|
| | Tesseract-specific. |
|
||||||
+-----+----------------------------------------------------------------------------------+
|
+-----+----------------------------------------------------------------------------------+
|
||||||
|
|
||||||
Modes 0, 1, 2, and 12 (all of those that enable orientation and script detection)
|
Modes 0, 1, 2, and 12 (all of those that enable orientation and script detection)
|
||||||
are not compatible with OCRmyPDF, which performs OSD in a separate step from OCR.
|
are not compatible with OCRmyPDF, which performs OSD in a separate step from OCR.
|
||||||
Their use may interfere with ``--rotate-pages`` and other features.
|
Their use may interfere with ``--rotate-pages`` and other features.
|
||||||
|
|
||||||
@@ -445,7 +445,7 @@ Debugging the intermediate files
|
|||||||
OCRmyPDF normally saves its intermediate results to a temporary folder
|
OCRmyPDF normally saves its intermediate results to a temporary folder
|
||||||
and deletes this folder when it exits, whether it succeeded or failed.
|
and deletes this folder when it exits, whether it succeeded or failed.
|
||||||
|
|
||||||
If the ``--keep-temporary-files`` (``-k```) argument is issued on the
|
If the ``--keep-temporary-files`` (``-k``) argument is issued on the
|
||||||
command line, OCRmyPDF will keep the temporary folder and print the location,
|
command line, OCRmyPDF will keep the temporary folder and print the location,
|
||||||
whether it succeeded or failed. An example message is:
|
whether it succeeded or failed. An example message is:
|
||||||
|
|
||||||
|
|||||||
+3
-18
@@ -9,27 +9,12 @@ API reference
|
|||||||
This page summarizes the rest of the public API. Generally speaking this
|
This page summarizes the rest of the public API. Generally speaking this
|
||||||
should be mainly of interest to plugin developers.
|
should be mainly of interest to plugin developers.
|
||||||
|
|
||||||
ocrmypdf
|
ocrmypdf.api
|
||||||
========
|
============
|
||||||
|
|
||||||
.. autoclass:: ocrmypdf.PageContext
|
.. automodule:: ocrmypdf.api
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
.. autoclass:: ocrmypdf.PdfContext
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. autoclass:: ocrmypdf.Verbosity
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autofunction:: ocrmypdf.configure_logging
|
|
||||||
|
|
||||||
.. autofunction:: ocrmypdf.ocr
|
|
||||||
|
|
||||||
.. autofunction:: ocrmypdf.pdf_to_hocr
|
|
||||||
|
|
||||||
.. autofunction:: ocrmypdf.hocr_to_ocr_pdf
|
|
||||||
|
|
||||||
ocrmypdf.exceptions
|
ocrmypdf.exceptions
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -135,7 +135,7 @@ Users may need to customize the script to meet their requirements.
|
|||||||
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed original file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
|
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed original file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
|
||||||
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
|
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
|
||||||
"OCR_DESKEW", "Apply deskew to crooked input PDFs"
|
"OCR_DESKEW", "Apply deskew to crooked input PDFs"
|
||||||
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true, ""optimize"": "3"}'``."
|
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true, ""optimize"": ""3""}'``."
|
||||||
"OCR_POLL_NEW_FILE_SECONDS", "Polling interval"
|
"OCR_POLL_NEW_FILE_SECONDS", "Polling interval"
|
||||||
"OCR_LOGLEVEL", "Level of log messages to report"
|
"OCR_LOGLEVEL", "Level of log messages to report"
|
||||||
|
|
||||||
|
|||||||
+14
-11
@@ -30,6 +30,8 @@
|
|||||||
#
|
#
|
||||||
# needs_sphinx = '1.0'
|
# needs_sphinx = '1.0'
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings. They can be
|
# Add any Sphinx extension module names here, as strings. They can be
|
||||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||||
# ones.
|
# ones.
|
||||||
@@ -38,11 +40,12 @@ extensions = [
|
|||||||
'sphinx.ext.intersphinx',
|
'sphinx.ext.intersphinx',
|
||||||
'sphinx.ext.autosummary',
|
'sphinx.ext.autosummary',
|
||||||
'sphinx.ext.napoleon',
|
'sphinx.ext.napoleon',
|
||||||
|
'sphinx.ext.imgconverter', # PDF docs needs this for SVG to PNG conversion
|
||||||
'sphinx_issues',
|
'sphinx_issues',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Extension settings
|
# Extension settings
|
||||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
|
||||||
napoleon_use_rtype = False
|
napoleon_use_rtype = False
|
||||||
issues_github_path = "ocrmypdf/OCRmyPDF"
|
issues_github_path = "ocrmypdf/OCRmyPDF"
|
||||||
|
|
||||||
@@ -50,10 +53,7 @@ issues_github_path = "ocrmypdf/OCRmyPDF"
|
|||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
|
|
||||||
# The suffix(es) of source filenames.
|
# The suffix(es) of source filenames.
|
||||||
# You can specify multiple suffix as a list of string:
|
source_suffix = {'.rst': 'restructuredtext', '.md': 'markdown'}
|
||||||
#
|
|
||||||
# source_suffix = ['.rst', '.md']
|
|
||||||
source_suffix = '.rst'
|
|
||||||
|
|
||||||
# The encoding of source files.
|
# The encoding of source files.
|
||||||
#
|
#
|
||||||
@@ -64,8 +64,11 @@ master_doc = 'index'
|
|||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = 'ocrmypdf'
|
project = 'ocrmypdf'
|
||||||
|
|
||||||
|
year = str(datetime.date.today().year)
|
||||||
copyright = (
|
copyright = (
|
||||||
'2023, James R. Barlow. Licensed under Creative Commons Attribution-ShareAlike 4.0.'
|
f'{year}, James R. Barlow. ',
|
||||||
|
'Licensed under Creative Commons Attribution-ShareAlike 4.0.',
|
||||||
)
|
)
|
||||||
author = 'James R. Barlow'
|
author = 'James R. Barlow'
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ if on_rtd:
|
|||||||
|
|
||||||
MOCK_MODULES = [
|
MOCK_MODULES = [
|
||||||
'pikepdf',
|
'pikepdf',
|
||||||
|
'pikepdf.canvas',
|
||||||
'pikepdf.models',
|
'pikepdf.models',
|
||||||
'pikepdf.models.metadata',
|
'pikepdf.models.metadata',
|
||||||
]
|
]
|
||||||
@@ -108,7 +112,7 @@ version = '.'.join(release.split('.')[:2])
|
|||||||
#
|
#
|
||||||
# This is also used if you do content translation via gettext catalogs.
|
# This is also used if you do content translation via gettext catalogs.
|
||||||
# Usually you set "language" from the command line for these cases.
|
# Usually you set "language" from the command line for these cases.
|
||||||
language = None
|
language = 'en'
|
||||||
|
|
||||||
# There are two options for replacing |today|: either, you set today to some
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
# non-false value, then it is used:
|
# non-false value, then it is used:
|
||||||
@@ -158,19 +162,18 @@ todo_include_todos = False
|
|||||||
|
|
||||||
# -- Options for HTML output ----------------------------------------------
|
# -- Options for HTML output ----------------------------------------------
|
||||||
|
|
||||||
import sphinx_rtd_theme
|
import sphinx_rtd_theme # noqa: F401
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
# a list of builtin themes.
|
# a list of builtin themes.
|
||||||
#
|
#
|
||||||
html_theme = 'sphinx_rtd_theme'
|
html_theme = 'sphinx_rtd_theme'
|
||||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
|
||||||
|
|
||||||
# Theme options are theme-specific and customize the look and feel of a theme
|
# Theme options are theme-specific and customize the look and feel of a theme
|
||||||
# further. For a list of options available for each theme, see the
|
# further. For a list of options available for each theme, see the
|
||||||
# documentation.
|
# documentation.
|
||||||
#
|
#
|
||||||
html_theme_options = {'display_version': False}
|
html_theme_options = {}
|
||||||
|
|
||||||
# Add any paths that contain custom themes here, relative to this directory.
|
# Add any paths that contain custom themes here, relative to this directory.
|
||||||
# html_theme_path = []
|
# html_theme_path = []
|
||||||
@@ -198,7 +201,7 @@ html_theme_options = {'display_version': False}
|
|||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||||
html_static_path = ['_static']
|
# html_static_path = ['_static']
|
||||||
|
|
||||||
# Add any extra paths that contain custom files (such as robots.txt or
|
# Add any extra paths that contain custom files (such as robots.txt or
|
||||||
# .htaccess) here, relative to this directory. These files are copied
|
# .htaccess) here, relative to this directory. These files are copied
|
||||||
|
|||||||
+1
-1
@@ -399,7 +399,7 @@ Some users may consider enabling lossy JBIG2. See: :ref:`jbig2-lossy`.
|
|||||||
Digitally signed PDFs
|
Digitally signed PDFs
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
OCRmyPDF cannot preserve digital signatures in PDFs and also add to OCR to them.
|
OCRmyPDF cannot preserve digital signatures in PDFs and also add OCR to them.
|
||||||
By default, it will refuse to modify a signed PDF regardless of other settings. You can
|
By default, it will refuse to modify a signed PDF regardless of other settings. You can
|
||||||
override this behavior with ``--invalidate-digital-signatures``; as the name suggests,
|
override this behavior with ``--invalidate-digital-signatures``; as the name suggests,
|
||||||
any digital signatures will be invalidated.
|
any digital signatures will be invalidated.
|
||||||
|
|||||||
+4
-4
@@ -35,7 +35,7 @@ execute the image:
|
|||||||
docker run hello-world
|
docker run hello-world
|
||||||
|
|
||||||
.. list-table:: Docker images
|
.. list-table:: Docker images
|
||||||
:width: 30 20 50
|
:widths: 30 20 50
|
||||||
:header-rows: 1
|
:header-rows: 1
|
||||||
|
|
||||||
* - Image
|
* - Image
|
||||||
@@ -69,9 +69,9 @@ OCRmyPDF will use all available CPU cores. See the Docker documentation for
|
|||||||
if you are using Docker on macOS or Windows, where you may need to manually assign
|
if you are using Docker on macOS or Windows, where you may need to manually assign
|
||||||
more resources. On Linux, all resources will be available automatically.
|
more resources. On Linux, all resources will be available automatically.
|
||||||
|
|
||||||
The underlying operating system and other details in Docker images are subject
|
The underlying operating system and other details in Docker images are considered
|
||||||
to change at minor releases. If you are modifying the image, you should pin
|
implementation details and **subject to change at minor releases**. If you are
|
||||||
the version you intend to use.
|
modifying the image, you should pin the version you intend to use.
|
||||||
|
|
||||||
Using the Docker image on the command line
|
Using the Docker image on the command line
|
||||||
==========================================
|
==========================================
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ These platforms have one-liner installs:
|
|||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| FreeBSD | ``pkg install textproc/py-ocrmypdf`` |
|
| FreeBSD | ``pkg install textproc/py-ocrmypdf`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| Conda (WSL, macOS, Linux) | ``conda install ocrmypdf`` |
|
|
||||||
+-------------------------------+-----------------------------------------+
|
|
||||||
| Snap (snapcraft packaging) | ``snap install ocrmypdf`` |
|
| Snap (snapcraft packaging) | ``snap install ocrmypdf`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -37,8 +37,6 @@ For all other platforms, you would need to build the JBIG2 encoder from source:
|
|||||||
./configure && make
|
./configure && make
|
||||||
[sudo] make install
|
[sudo] make install
|
||||||
|
|
||||||
.. _jbig2-lossy:
|
|
||||||
|
|
||||||
Dependencies include libtoolize and libleptonica, which on Ubuntu systems
|
Dependencies include libtoolize and libleptonica, which on Ubuntu systems
|
||||||
are packaged as libtool and libleptonica-dev. On Fedora (35) they are packaged
|
are packaged as libtool and libleptonica-dev. On Fedora (35) they are packaged
|
||||||
as libtool and leptonica-devel. For this to work, please make sure to install
|
as libtool and leptonica-devel. For this to work, please make sure to install
|
||||||
@@ -48,8 +46,8 @@ installed.
|
|||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
[sudo] apt install autotools-dev automake libtool libleptonica-dev
|
[sudo] apt install autotools-dev automake libtool libleptonica-dev
|
||||||
..
|
|
||||||
|
|
||||||
|
.. _jbig2-lossy:
|
||||||
|
|
||||||
Lossy mode JBIG2
|
Lossy mode JBIG2
|
||||||
================
|
================
|
||||||
|
|||||||
+2
-2
@@ -68,8 +68,8 @@ to what languages it should search for. Multiple languages can be
|
|||||||
requested using either ``-l eng+fra`` (English and French) or
|
requested using either ``-l eng+fra`` (English and French) or
|
||||||
``-l eng -l fra``.
|
``-l eng -l fra``.
|
||||||
|
|
||||||
Archlinux
|
Arch Linux
|
||||||
------
|
----------
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@ perform other possible optimizations such as deduplicating resources,
|
|||||||
consolidating fonts, simplifying vector drawings, or anything of that nature.
|
consolidating fonts, simplifying vector drawings, or anything of that nature.
|
||||||
|
|
||||||
.. list-table:: Title
|
.. list-table:: Title
|
||||||
:widths: 33 6 60
|
:widths: 33 6 60
|
||||||
:header-rows: 1
|
:header-rows: 1
|
||||||
|
|
||||||
* - Optimization level
|
* - Optimization level
|
||||||
- Shorthand
|
- Shorthand
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
..
|
..
|
||||||
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
|
|
||||||
|
.. _security:
|
||||||
|
|
||||||
===================
|
===================
|
||||||
PDF security issues
|
PDF security issues
|
||||||
===================
|
===================
|
||||||
|
|||||||
@@ -30,6 +30,37 @@ OCRmyPDF typically supports the three most recent Python versions.
|
|||||||
|
|
||||||
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||||
|
|
||||||
|
|
||||||
|
v16.8.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Upgraded webservice.py demonstration using streamlit. It's now possible to
|
||||||
|
exercise most of OCRmyPDF's functionality in a simple web UI.
|
||||||
|
- Added cache to Dockerfiles to improve build speed.
|
||||||
|
- Fixed numerous formatting errors in the documentation that prevented some
|
||||||
|
parts of documentation from generating correctly.
|
||||||
|
- Improved OCR text rendering by suppressing negative-width spaces. Thanks
|
||||||
|
@pajowu. :issue:`1446`
|
||||||
|
- Improved detecting of invisible text when using `--redo-ocr`. Thanks
|
||||||
|
@pajowu. :issue:`1448``
|
||||||
|
|
||||||
|
v16.7.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed further issues with Docker build and updated some versions.
|
||||||
|
- Main Docker image returned to Ubuntu 24.04 since the fix in v16.6.2 resolved
|
||||||
|
that concern.
|
||||||
|
- Code that previously sent Ghostscript output to stdout has been changed to
|
||||||
|
output to temporary files, since Ghostscript was doing that anyway internally.
|
||||||
|
This is a modest efficiency improvement.
|
||||||
|
- Fixed an issue with debug log output being parsed as rich markup. :issue:`1444`
|
||||||
|
|
||||||
|
v16.6.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Remove invalid hyperlink annotations to satisfy Ghostscript 10.x during PDF/A
|
||||||
|
conversion. :issue:`1425`
|
||||||
|
|
||||||
v16.6.1
|
v16.6.1
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
"""This is a simple web service/HTTP wrapper for OCRmyPDF.
|
||||||
|
|
||||||
|
This may be more convenient than the command line tool for some Docker users.
|
||||||
|
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
|
||||||
|
OCRmyPDF is under GPLv3, this file is distributed under the Affero GPLv3+ license,
|
||||||
|
to emphasize that SaaS deployments should make sure they comply with
|
||||||
|
Ghostscript's license as well as OCRmyPDF's.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib
|
||||||
|
from functools import partial
|
||||||
|
from operator import getitem
|
||||||
|
from pathlib import Path
|
||||||
|
from shutil import which
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
import streamlit as st
|
||||||
|
from port_for import get_port
|
||||||
|
from streamlit.components.v1 import iframe
|
||||||
|
|
||||||
|
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def get_host_url_with_port(port: int) -> str:
|
||||||
|
"""Get the host URL for the web service. Hacky."""
|
||||||
|
host_url = st.context.headers["host"]
|
||||||
|
try:
|
||||||
|
host, _streamlit_port = host_url.split(":", maxsplit=1)
|
||||||
|
except ValueError:
|
||||||
|
host = host_url
|
||||||
|
return f"//{host}:{port}" # Use the same protocol
|
||||||
|
|
||||||
|
|
||||||
|
st.title("OCRmyPDF Web Service")
|
||||||
|
|
||||||
|
if not which("ttyd"):
|
||||||
|
st.error("Missing dependency: ttyd. Please install ttyd to the local environment.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
uploaded = st.file_uploader("Upload input PDF or image", type=["pdf"], key="file")
|
||||||
|
|
||||||
|
mode = st.selectbox("Mode", options=["normal", "skip-text", "force-ocr", "redo-ocr"])
|
||||||
|
|
||||||
|
with st.expander("Input options"):
|
||||||
|
invalidate_digital_signatures = st.checkbox(
|
||||||
|
"Invalidate digital signatures", value=False
|
||||||
|
)
|
||||||
|
language = st.selectbox("Language", options=["eng", "deu", "fra", "spa"])
|
||||||
|
|
||||||
|
image_dpi = st.slider(
|
||||||
|
"Image DPI", value=300, key="image_dpi", min_value=1, max_value=5000, step=50
|
||||||
|
)
|
||||||
|
with st.expander("Preprocessing"):
|
||||||
|
skip_big = st.checkbox("Skip OCR on big pages", value=False, key="skip_big")
|
||||||
|
oversample = st.slider("Oversample", min_value=0, max_value=5000, value=0, step=50)
|
||||||
|
rotate_pages = st.checkbox("Rotate pages", value=False, key="rotate")
|
||||||
|
deskew = st.checkbox("Deskew pages", value=False, key="deskew")
|
||||||
|
clean = st.checkbox("Clean pages before OCR", value=False, key="clean")
|
||||||
|
clean_final = st.checkbox("Clean final", value=False, key="clean_final")
|
||||||
|
remove_vectors = st.checkbox("Remove vectors", value=False, key="remove_vectors")
|
||||||
|
|
||||||
|
|
||||||
|
with st.expander("Output options"):
|
||||||
|
output_type = st.selectbox(
|
||||||
|
"Output type", options=["pdfa", "pdfa", "pdfa-1", "pdfa-2", "pdfa-3", "none"]
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_renderer = st.selectbox(
|
||||||
|
"PDF rendereer", options=["auto", "hocr", "hocrdebug", "sandwich"]
|
||||||
|
)
|
||||||
|
|
||||||
|
optimize = st.selectbox("Optimize", options=["0", "1", "2", "3"])
|
||||||
|
|
||||||
|
st.selectbox("PDF/A compression", options=["auto", "jpeg", "lossless"])
|
||||||
|
|
||||||
|
with st.expander("Metadata"):
|
||||||
|
title = author = keywords = subject = None
|
||||||
|
if uploaded:
|
||||||
|
with pikepdf.open(uploaded) as pdf, pdf.open_metadata() as meta:
|
||||||
|
st.code(str(meta), language="xml")
|
||||||
|
title = st.text_input("Title", value=meta.get('dc:title', ''))
|
||||||
|
author = st.text_input("Author", value=meta.get('dc:creator', ''))
|
||||||
|
keywords = st.text_input("Keywords", value=meta.get('dc:subject', ''))
|
||||||
|
subject = st.text_input("Subject", value=meta.get('dc:description', ''))
|
||||||
|
|
||||||
|
|
||||||
|
with st.expander("Optimization after OCR"):
|
||||||
|
jpeg_quality = st.slider(
|
||||||
|
"JPEG quality", min_value=0, max_value=100, value=75, key="jpeg_quality"
|
||||||
|
)
|
||||||
|
png_quality = st.slider(
|
||||||
|
"PNG quality", min_value=0, max_value=100, value=75, key="png_quality"
|
||||||
|
)
|
||||||
|
jbig2_lossy = st.checkbox("JBIG2 lossy (dangerous)", value=False, key="jbig2_lossy")
|
||||||
|
jbig2_threshold = st.number_input("JBIG2 threshold", value=0, key="jbig2_threshold")
|
||||||
|
|
||||||
|
with st.expander("Advanced options"):
|
||||||
|
jobs = st.slider(
|
||||||
|
"Threads",
|
||||||
|
min_value=1,
|
||||||
|
max_value=os.cpu_count(),
|
||||||
|
value=os.cpu_count(),
|
||||||
|
key="threads",
|
||||||
|
)
|
||||||
|
pages = st.text_input(
|
||||||
|
"Pages", value="", help="Comma-separated list of pages to process"
|
||||||
|
)
|
||||||
|
max_image_mpixels = st.number_input(
|
||||||
|
"Max image size",
|
||||||
|
value=250.0,
|
||||||
|
min_value=0.0,
|
||||||
|
help="Maximum image size in megapixels",
|
||||||
|
)
|
||||||
|
rotate_pages_threshold = st.number_input(
|
||||||
|
"Rotate pages threshold",
|
||||||
|
value=DEFAULT_ROTATE_PAGES_THRESHOLD,
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=1000.0,
|
||||||
|
help="Threshold for automatic page rotation",
|
||||||
|
)
|
||||||
|
fast_web_view = st.number_input(
|
||||||
|
"Fast web view",
|
||||||
|
value=1.0,
|
||||||
|
min_value=0.0,
|
||||||
|
help="Linearize files above this size in MB",
|
||||||
|
)
|
||||||
|
continue_on_soft_render_error = st.checkbox(
|
||||||
|
"Continue on soft render error", value=True
|
||||||
|
)
|
||||||
|
verbose_labels = ["quiet", "default", "debug", "debug_all"]
|
||||||
|
verbose = st.selectbox(
|
||||||
|
"Verbosity level",
|
||||||
|
options=[-1, 0, 1, 2],
|
||||||
|
index=1,
|
||||||
|
format_func=partial(getitem, verbose_labels),
|
||||||
|
)
|
||||||
|
|
||||||
|
if uploaded:
|
||||||
|
args = []
|
||||||
|
if mode and mode != 'normal':
|
||||||
|
args.append(f"--{mode}")
|
||||||
|
if language:
|
||||||
|
args.append(f"--language={language}")
|
||||||
|
if not uploaded.name.lower().endswith(".pdf") and image_dpi:
|
||||||
|
args.append(f"--image-dpi={image_dpi}")
|
||||||
|
if skip_big:
|
||||||
|
args.append("--skip-big")
|
||||||
|
if oversample:
|
||||||
|
args.append(f"--oversample={oversample}")
|
||||||
|
if rotate_pages:
|
||||||
|
args.append("--rotate-pages")
|
||||||
|
if deskew:
|
||||||
|
args.append("--deskew")
|
||||||
|
if clean:
|
||||||
|
args.append("--clean")
|
||||||
|
if clean_final:
|
||||||
|
args.append("--clean-final")
|
||||||
|
if remove_vectors:
|
||||||
|
args.append("--remove-vectors")
|
||||||
|
if output_type:
|
||||||
|
args.append(f"--output-type={output_type}")
|
||||||
|
if pdf_renderer:
|
||||||
|
args.append(f"--pdf-renderer={pdf_renderer}")
|
||||||
|
if optimize:
|
||||||
|
args.append(f"--optimize={optimize}")
|
||||||
|
if title:
|
||||||
|
args.append(f"--title={title}")
|
||||||
|
if author:
|
||||||
|
args.append(f"--author={author}")
|
||||||
|
if keywords:
|
||||||
|
args.append(f"--keywords={keywords}")
|
||||||
|
if subject:
|
||||||
|
args.append(f"--subject={subject}")
|
||||||
|
if pages:
|
||||||
|
args.append(f"--pages={pages}")
|
||||||
|
if max_image_mpixels:
|
||||||
|
args.append(f"--max-image-mpixels={max_image_mpixels}")
|
||||||
|
if rotate_pages_threshold:
|
||||||
|
args.append(f"--rotate-pages-threshold={rotate_pages_threshold}")
|
||||||
|
if fast_web_view:
|
||||||
|
args.append(f"--fast-web-view={fast_web_view}")
|
||||||
|
if continue_on_soft_render_error:
|
||||||
|
args.append("--continue-on-soft-render-error")
|
||||||
|
if verbose:
|
||||||
|
args.append(f"--verbose={verbose}")
|
||||||
|
if optimize > '0' and jpeg_quality:
|
||||||
|
args.append(f"--jpeg-quality={jpeg_quality}")
|
||||||
|
if optimize > '0' and png_quality:
|
||||||
|
args.append(f"--png-quality={png_quality}")
|
||||||
|
if jbig2_lossy:
|
||||||
|
args.append("--jbig2-lossy")
|
||||||
|
if jbig2_threshold:
|
||||||
|
args.append(f"--jbig2-threshold={jbig2_threshold}")
|
||||||
|
if jobs:
|
||||||
|
args.append(f"--jobs={jobs}")
|
||||||
|
input_file = NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}")
|
||||||
|
input_file.write(uploaded.getvalue())
|
||||||
|
input_file.flush()
|
||||||
|
input_file.seek(0)
|
||||||
|
args.append(str(input_file.name))
|
||||||
|
output_file = NamedTemporaryFile(delete=True, suffix=".pdf")
|
||||||
|
args.append(str(output_file.name))
|
||||||
|
|
||||||
|
st.session_state['running'] = (
|
||||||
|
'run_button' in st.session_state and st.session_state.run_button
|
||||||
|
)
|
||||||
|
if st.button(
|
||||||
|
"Run OCRmyPDF",
|
||||||
|
disabled=st.session_state.get("running", False),
|
||||||
|
key='run_button',
|
||||||
|
):
|
||||||
|
st.session_state['running'] = True
|
||||||
|
args = [sys.executable, '-m', "ocrmypdf"] + args
|
||||||
|
cmdline = " ".join(args)
|
||||||
|
st.code(cmdline, language="bash")
|
||||||
|
|
||||||
|
port = get_port((5000, 7000))
|
||||||
|
ttyd_args = ['ttyd', '--port', str(port), '--once', '--readonly']
|
||||||
|
|
||||||
|
ttyd_proc = subprocess.Popen(
|
||||||
|
ttyd_args + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
iframe(src=get_host_url_with_port(port), height=400)
|
||||||
|
|
||||||
|
while ttyd_proc.poll() is None:
|
||||||
|
ttyd_proc.poll()
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
if ttyd_proc.returncode == 0:
|
||||||
|
if Path(output_file.name).stat().st_size == 0:
|
||||||
|
st.error("No output PDF file was generated")
|
||||||
|
else:
|
||||||
|
st.download_button(
|
||||||
|
label="Download output PDF",
|
||||||
|
data=input_file.read(),
|
||||||
|
file_name=uploaded.name,
|
||||||
|
mime="application/pdf",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
st.error(f"ttyd failed with exit code {ttyd_proc.returncode}")
|
||||||
|
st.session_state['running'] = False
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Run OCRmyPDF on the same PDF with different options."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import check_output, run
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
import pymupdf
|
||||||
|
import streamlit as st
|
||||||
|
from lxml import etree
|
||||||
|
from streamlit_pdf_viewer import pdf_viewer
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
st.set_page_config(layout="wide")
|
||||||
|
|
||||||
|
st.title("OCRmyPDF Compare")
|
||||||
|
st.write("Run OCRmyPDF on the same PDF with different options.")
|
||||||
|
|
||||||
|
uploaded_pdf = st.file_uploader("Upload a PDF", type=["pdf"])
|
||||||
|
if uploaded_pdf is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
pdf_bytes = uploaded_pdf.read()
|
||||||
|
|
||||||
|
with pikepdf.open(BytesIO(pdf_bytes)) as p, TemporaryDirectory() as d:
|
||||||
|
with st.expander("PDF Metadata"):
|
||||||
|
with p.open_metadata() as meta:
|
||||||
|
xml_txt = str(meta)
|
||||||
|
parser = etree.XMLParser(remove_blank_text=True)
|
||||||
|
tree = etree.fromstring(xml_txt, parser=parser)
|
||||||
|
st.code(
|
||||||
|
etree.tostring(tree, pretty_print=True).decode("utf-8"),
|
||||||
|
language="xml",
|
||||||
|
)
|
||||||
|
st.write(p.docinfo)
|
||||||
|
st.write("Number of pages:", len(p.pages))
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
cli1 = st.text_area(
|
||||||
|
"Command line arguments for A",
|
||||||
|
key="args1",
|
||||||
|
value="ocrmypdf {in_} {out}",
|
||||||
|
)
|
||||||
|
env1 = st.text_area("Environment variables for A", key="env1")
|
||||||
|
args1 = shlex.split(
|
||||||
|
cli1.format(
|
||||||
|
in_=os.path.join(d, "input.pdf"),
|
||||||
|
out=os.path.join(d, "output1.pdf"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
st.code(shlex.join(args1))
|
||||||
|
with col2:
|
||||||
|
cli2 = st.text_area(
|
||||||
|
"Command line arguments for B",
|
||||||
|
key="args2",
|
||||||
|
value="ocrmypdf {in_} {out}",
|
||||||
|
)
|
||||||
|
env2 = st.text_area("Environment variables for B", key="env2")
|
||||||
|
args2 = shlex.split(
|
||||||
|
cli2.format(
|
||||||
|
in_=os.path.join(d, "input.pdf"),
|
||||||
|
out=os.path.join(d, "output2.pdf"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
st.code(shlex.join(args2))
|
||||||
|
|
||||||
|
if not st.button("Execute and Compare"):
|
||||||
|
return
|
||||||
|
with st.spinner("Executing..."):
|
||||||
|
Path(d, "input.pdf").write_bytes(pdf_bytes)
|
||||||
|
run(args1, env=dict(os.environ, **eval(env1 or "{}")))
|
||||||
|
run(args2, env=dict(os.environ, **eval(env2 or "{}")))
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
st.text(
|
||||||
|
"Ghostscript version A: "
|
||||||
|
+ check_output(
|
||||||
|
["gs", "--version"],
|
||||||
|
env=dict(os.environ, **eval(env1 or "{}")),
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with col2:
|
||||||
|
st.text(
|
||||||
|
"Ghostscript version B: "
|
||||||
|
+ check_output(
|
||||||
|
["gs", "--version"],
|
||||||
|
env=dict(os.environ, **eval(env2 or "{}")),
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
doc1 = pymupdf.open(os.path.join(d, "output1.pdf"))
|
||||||
|
doc2 = pymupdf.open(os.path.join(d, "output2.pdf"))
|
||||||
|
for i, page1_2 in enumerate(zip(doc1, doc2)):
|
||||||
|
st.write(f"Page {i+1}")
|
||||||
|
page1, page2 = page1_2
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1, st.container(border=True):
|
||||||
|
st.write(page1.get_text())
|
||||||
|
with col2, st.container(border=True):
|
||||||
|
st.write(page2.get_text())
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1, st.expander("PDF Viewer"):
|
||||||
|
pdf_viewer(Path(d, "output1.pdf"))
|
||||||
|
with col2, st.expander("PDF Viewer"):
|
||||||
|
pdf_viewer(Path(d, "output2.pdf"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Compare two PDFs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
import pymupdf
|
||||||
|
import streamlit as st
|
||||||
|
from lxml import etree
|
||||||
|
from streamlit_pdf_viewer import pdf_viewer
|
||||||
|
|
||||||
|
|
||||||
|
def do_metadata(pdf):
|
||||||
|
with pikepdf.open(pdf) as pdf:
|
||||||
|
with pdf.open_metadata() as meta:
|
||||||
|
xml_txt = str(meta)
|
||||||
|
parser = etree.XMLParser(remove_blank_text=True)
|
||||||
|
tree = etree.fromstring(xml_txt, parser=parser)
|
||||||
|
st.code(
|
||||||
|
etree.tostring(tree, pretty_print=True).decode("utf-8"),
|
||||||
|
language="xml",
|
||||||
|
)
|
||||||
|
st.write(pdf.docinfo)
|
||||||
|
st.write("Number of pages:", len(pdf.pages))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
st.set_page_config(layout="wide")
|
||||||
|
|
||||||
|
st.title("PDF Compare")
|
||||||
|
st.write("Compare two PDFs.")
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
uploaded_pdf1 = st.file_uploader("Upload a PDF", type=["pdf"], key='pdf1')
|
||||||
|
with col2:
|
||||||
|
uploaded_pdf2 = st.file_uploader("Upload a PDF", type=["pdf"], key='pdf2')
|
||||||
|
if uploaded_pdf1 is None or uploaded_pdf2 is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
pdf_bytes1 = uploaded_pdf1.getvalue()
|
||||||
|
pdf_bytes2 = uploaded_pdf2.getvalue()
|
||||||
|
|
||||||
|
with st.expander("PDF Metadata"):
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
do_metadata(BytesIO(pdf_bytes1))
|
||||||
|
with col2:
|
||||||
|
do_metadata(BytesIO(pdf_bytes2))
|
||||||
|
|
||||||
|
with TemporaryDirectory() as d:
|
||||||
|
Path(d, "1.pdf").write_bytes(pdf_bytes1)
|
||||||
|
Path(d, "2.pdf").write_bytes(pdf_bytes2)
|
||||||
|
|
||||||
|
with st.expander("Text"):
|
||||||
|
doc1 = pymupdf.open(os.path.join(d, "1.pdf"))
|
||||||
|
doc2 = pymupdf.open(os.path.join(d, "2.pdf"))
|
||||||
|
for i, page1_2 in enumerate(zip(doc1, doc2)):
|
||||||
|
st.write(f"Page {i+1}")
|
||||||
|
page1, page2 = page1_2
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1, st.container(border=True):
|
||||||
|
st.write(page1.get_text())
|
||||||
|
with col2, st.container(border=True):
|
||||||
|
st.write(page2.get_text())
|
||||||
|
|
||||||
|
with st.expander("PDF Viewer"):
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
pdf_viewer(Path(d, "1.pdf"), key='pdf_viewer1', render_text=True)
|
||||||
|
with col2:
|
||||||
|
pdf_viewer(Path(d, "2.pdf"), key='pdf_viewer2', render_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""Compare text in PDFs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from subprocess import run
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
pdf1: Annotated[typer.FileBinaryRead, typer.Argument()],
|
||||||
|
pdf2: Annotated[typer.FileBinaryRead, typer.Argument()],
|
||||||
|
engine: Annotated[str, typer.Option()] = 'pdftotext',
|
||||||
|
):
|
||||||
|
"""Compare text in PDFs."""
|
||||||
|
|
||||||
|
text1 = run(
|
||||||
|
['pdftotext', '-layout', '-', '-'], stdin=pdf1, capture_output=True, check=True
|
||||||
|
)
|
||||||
|
text2 = run(
|
||||||
|
['pdftotext', '-layout', '-', '-'], stdin=pdf2, capture_output=True, check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with NamedTemporaryFile() as f1, NamedTemporaryFile() as f2:
|
||||||
|
f1.write(text1.stdout)
|
||||||
|
f1.flush()
|
||||||
|
f2.write(text2.stdout)
|
||||||
|
f2.flush()
|
||||||
|
diff = run(
|
||||||
|
['diff', '--color=always', '--side-by-side', f1.name, f2.name],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
run(['less', '-R'], input=diff.stdout, check=True)
|
||||||
|
if text1.stdout.strip() != text2.stdout.strip():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
typer.run(main)
|
||||||
Regular → Executable
+23
-101
@@ -1,107 +1,29 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python
|
||||||
# SPDX-FileCopyrightText: 2019 James R. Barlow
|
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
"""This is a simple web service/HTTP wrapper for OCRmyPDF.
|
"""Run the OCRmyPDF web service."""
|
||||||
|
|
||||||
This may be more convenient than the command line tool for some Docker users.
|
|
||||||
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
|
|
||||||
OCRmyPDF is under GPLv3, this file is distributed under the Affero GPLv3+ license,
|
|
||||||
to emphasize that SaaS deployments should make sure they comply with
|
|
||||||
Ghostscript's license as well as OCRmyPDF's.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shlex
|
import sys
|
||||||
from subprocess import run
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
|
|
||||||
from flask import Flask, Response, request, send_from_directory
|
try:
|
||||||
from werkzeug.utils import secure_filename
|
import streamlit # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
'You need to install streamlit in the Python environment '
|
||||||
|
'to run the web service.\n'
|
||||||
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
if __name__ == '__main__':
|
||||||
app.secret_key = "secret"
|
os.execvp(
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 50_000_000
|
sys.executable,
|
||||||
app.config.from_envvar("OCRMYPDF_WEBSERVICE_SETTINGS", silent=True)
|
[
|
||||||
|
sys.executable,
|
||||||
ALLOWED_EXTENSIONS = {"pdf"}
|
'-m',
|
||||||
|
'streamlit',
|
||||||
|
'run',
|
||||||
def allowed_file(filename):
|
'misc/_webservice.py',
|
||||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
*sys.argv[1:],
|
||||||
|
],
|
||||||
|
)
|
||||||
def do_ocrmypdf(file):
|
|
||||||
uploaddir = TemporaryDirectory(prefix="ocrmypdf-upload")
|
|
||||||
downloaddir = TemporaryDirectory(prefix="ocrmypdf-download")
|
|
||||||
|
|
||||||
filename = secure_filename(file.filename)
|
|
||||||
up_file = os.path.join(uploaddir.name, filename)
|
|
||||||
file.save(up_file)
|
|
||||||
|
|
||||||
down_file = os.path.join(downloaddir.name, filename)
|
|
||||||
|
|
||||||
cmd_args = [arg for arg in shlex.split(request.form["params"])]
|
|
||||||
if "--sidecar" in cmd_args:
|
|
||||||
return Response("--sidecar not supported", 501, mimetype='text/plain')
|
|
||||||
|
|
||||||
ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file]
|
|
||||||
proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8", check=False)
|
|
||||||
if proc.returncode != 0:
|
|
||||||
stderr = proc.stderr
|
|
||||||
return Response(stderr, 400, mimetype='text/plain')
|
|
||||||
|
|
||||||
return send_from_directory(downloaddir.name, filename)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
|
||||||
def upload_file():
|
|
||||||
if request.method == "POST":
|
|
||||||
if "file" not in request.files:
|
|
||||||
return Response("No file in POST", 400, mimetype='text/plain')
|
|
||||||
file = request.files["file"]
|
|
||||||
if file.filename == "":
|
|
||||||
return Response("Empty filename", 400, mimetype='text/plain')
|
|
||||||
if not allowed_file(file.filename):
|
|
||||||
return Response("Invalid filename", 400, mimetype='text/plain')
|
|
||||||
if file and allowed_file(file.filename):
|
|
||||||
return do_ocrmypdf(file)
|
|
||||||
return Response("Some other problem", 400, mimetype='text/plain')
|
|
||||||
|
|
||||||
return """
|
|
||||||
<!doctype html>
|
|
||||||
<title>OCRmyPDF webservice</title>
|
|
||||||
<h1>Upload a PDF (debug UI)</h1>
|
|
||||||
<form method=post enctype=multipart/form-data>
|
|
||||||
<label for="args">Command line parameters</label>
|
|
||||||
<input type=textbox name=params>
|
|
||||||
<label for="file">File to upload</label>
|
|
||||||
<input type=file name=file>
|
|
||||||
<input type=submit value=Upload>
|
|
||||||
</form>
|
|
||||||
<h4>Notice</h2>
|
|
||||||
<div style="font-size: 70%; max-width: 34em;">
|
|
||||||
<p>This is a webservice wrapper for OCRmyPDF.</p>
|
|
||||||
<p>Copyright 2019 James R. Barlow</p>
|
|
||||||
<p>This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
</p>
|
|
||||||
<p>This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(host='0.0.0.0', port=5000)
|
|
||||||
|
|||||||
+13
-3
@@ -63,7 +63,10 @@ test = [
|
|||||||
"types-humanfriendly",
|
"types-humanfriendly",
|
||||||
]
|
]
|
||||||
watcher = ["watchdog>=1.0.2", "typer-slim[standard]", "python-dotenv"]
|
watcher = ["watchdog>=1.0.2", "typer-slim[standard]", "python-dotenv"]
|
||||||
webservice = ["Flask>=2.0.1"]
|
webservice = [
|
||||||
|
"port-for>=0.7.4",
|
||||||
|
"streamlit>=1.41.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ocrmypdf = "ocrmypdf.__main__:run"
|
ocrmypdf = "ocrmypdf.__main__:run"
|
||||||
@@ -118,8 +121,6 @@ filterwarnings = [
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
'pluggy',
|
'pluggy',
|
||||||
'tqdm',
|
|
||||||
'coloredlogs',
|
|
||||||
'img2pdf',
|
'img2pdf',
|
||||||
'pdfminer.*',
|
'pdfminer.*',
|
||||||
'reportlab.*',
|
'reportlab.*',
|
||||||
@@ -155,3 +156,12 @@ convention = "google"
|
|||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
quote-style = "preserve"
|
quote-style = "preserve"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.13.0",
|
||||||
|
"pymupdf>=1.24.14",
|
||||||
|
"streamlit-pdf-viewer>=0.0.19",
|
||||||
|
"streamlit>=1.40.2",
|
||||||
|
"ipykernel>=6.29.5",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""OCRmyPDF PDF annotation cleanup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pikepdf import Dictionary, Name, NameTree, Pdf
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_broken_goto_annotations(pdf: Pdf) -> bool:
|
||||||
|
"""Remove broken goto annotations from a PDF.
|
||||||
|
|
||||||
|
If a PDF contains a GoTo Action that points to a named destination that does not
|
||||||
|
exist, Ghostscript PDF/A conversion will fail. In any event, a named destination
|
||||||
|
that is not defined is not useful.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf: Opened PDF file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the file was modified, False if not.
|
||||||
|
"""
|
||||||
|
modified = False
|
||||||
|
|
||||||
|
# Check if there are any named destinations
|
||||||
|
if Name.Names not in pdf.Root:
|
||||||
|
return modified
|
||||||
|
if Name.Dests not in pdf.Root[Name.Names]:
|
||||||
|
return modified
|
||||||
|
|
||||||
|
dests = pdf.Root[Name.Names][Name.Dests]
|
||||||
|
if not isinstance(dests, Dictionary):
|
||||||
|
return modified
|
||||||
|
nametree = NameTree(dests)
|
||||||
|
|
||||||
|
# Create a set of all named destinations
|
||||||
|
names = set(k for k in nametree.keys())
|
||||||
|
|
||||||
|
for n, page in enumerate(pdf.pages):
|
||||||
|
if Name.Annots not in page:
|
||||||
|
continue
|
||||||
|
for annot in page[Name.Annots]:
|
||||||
|
if not isinstance(annot, Dictionary):
|
||||||
|
continue
|
||||||
|
if Name.A not in annot or Name.D not in annot[Name.A]:
|
||||||
|
continue
|
||||||
|
# We found an annotation that points to a named destination
|
||||||
|
named_destination = str(annot[Name.A][Name.D])
|
||||||
|
if named_destination not in names:
|
||||||
|
# If there is no corresponding named destination, remove the
|
||||||
|
# annotation. Having no destination set is still valid and just
|
||||||
|
# makes the link non-functional.
|
||||||
|
log.warning(
|
||||||
|
f"Disabling a hyperlink annotation on page {n + 1} to a "
|
||||||
|
"non-existent named destination "
|
||||||
|
f"{named_destination}."
|
||||||
|
)
|
||||||
|
del annot[Name.A][Name.D]
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
return modified
|
||||||
@@ -125,7 +125,7 @@ def rasterize_pdf(
|
|||||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||||
+ [
|
+ [
|
||||||
'-o',
|
'-o',
|
||||||
'-',
|
fspath(output_file),
|
||||||
'-sstdout=%stderr', # Literal %s, not string interpolation
|
'-sstdout=%stderr', # Literal %s, not string interpolation
|
||||||
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
||||||
'-f',
|
'-f',
|
||||||
@@ -137,14 +137,15 @@ def rasterize_pdf(
|
|||||||
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
log.error(e.stderr.decode(errors='replace'))
|
log.error(e.stderr.decode(errors='replace'))
|
||||||
raise SubprocessOutputError('Ghostscript rasterizing failed') from e
|
Path(output_file).unlink(missing_ok=True)
|
||||||
else:
|
raise SubprocessOutputError("Ghostscript rasterizing failed") from e
|
||||||
stderr = p.stderr.decode(errors='replace')
|
|
||||||
if _gs_error_reported(stderr):
|
stderr = p.stderr.decode(errors='replace')
|
||||||
log.error(stderr)
|
if _gs_error_reported(stderr):
|
||||||
|
log.error(stderr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Image.open(BytesIO(p.stdout)) as im:
|
with Image.open(output_file) as im:
|
||||||
if rotation is not None:
|
if rotation is not None:
|
||||||
log.debug("Rotating output by %i", rotation)
|
log.debug("Rotating output by %i", rotation)
|
||||||
# rotation is a clockwise angle and Image.ROTATE_* is
|
# rotation is a clockwise angle and Image.ROTATE_* is
|
||||||
@@ -157,13 +158,19 @@ def rasterize_pdf(
|
|||||||
im = im.transpose(Image.Transpose.ROTATE_270)
|
im = im.transpose(Image.Transpose.ROTATE_270)
|
||||||
if rotation % 180 == 90:
|
if rotation % 180 == 90:
|
||||||
page_dpi = page_dpi.flip_axis()
|
page_dpi = page_dpi.flip_axis()
|
||||||
im.save(fspath(output_file), dpi=page_dpi)
|
im.save(output_file, dpi=page_dpi)
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
log.error(
|
log.error(
|
||||||
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
||||||
"an invalid page image file."
|
"an invalid page image file."
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
log.error(
|
||||||
|
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
||||||
|
"an invalid page image file."
|
||||||
|
)
|
||||||
|
raise UnidentifiedImageError() from e
|
||||||
|
|
||||||
|
|
||||||
class GhostscriptFollower:
|
class GhostscriptFollower:
|
||||||
@@ -271,19 +278,15 @@ def generate_pdfa(
|
|||||||
f"-dPDFA={pdfa_part}",
|
f"-dPDFA={pdfa_part}",
|
||||||
"-dPDFACompatibilityPolicy=1",
|
"-dPDFACompatibilityPolicy=1",
|
||||||
"-o",
|
"-o",
|
||||||
"-",
|
fspath(output_file),
|
||||||
"-sstdout=%stderr", # Literal %s, not string interpolation
|
"-sstdout=%stderr", # Literal %s, not string interpolation
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
||||||
try:
|
try:
|
||||||
with (
|
with GhostscriptFollower(progressbar_class) as pbar:
|
||||||
Path(output_file).open('wb') as output,
|
|
||||||
GhostscriptFollower(progressbar_class) as pbar,
|
|
||||||
):
|
|
||||||
p = run_polling_stderr(
|
p = run_polling_stderr(
|
||||||
args_gs,
|
args_gs,
|
||||||
stdout=output,
|
|
||||||
stderr=PIPE,
|
stderr=PIPE,
|
||||||
check=True,
|
check=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
+15
-3
@@ -61,19 +61,31 @@ def strip_invisible_text(pdf: Pdf, page: Page):
|
|||||||
stream = []
|
stream = []
|
||||||
in_text_obj = False
|
in_text_obj = False
|
||||||
render_mode = 0
|
render_mode = 0
|
||||||
|
render_mode_stack = []
|
||||||
text_objects = []
|
text_objects = []
|
||||||
|
|
||||||
for operands, operator in parse_content_stream(page, ''):
|
for operands, operator in parse_content_stream(page, ''):
|
||||||
|
if operator == Operator('Tr'):
|
||||||
|
render_mode = operands[0]
|
||||||
|
|
||||||
|
if operator == Operator('q'):
|
||||||
|
render_mode_stack.append(render_mode)
|
||||||
|
|
||||||
|
if operator == Operator('Q'):
|
||||||
|
try:
|
||||||
|
render_mode = render_mode_stack.pop()
|
||||||
|
except IndexError:
|
||||||
|
# Stack underflow: content stream is malformed
|
||||||
|
# but try to carry on
|
||||||
|
pass
|
||||||
|
|
||||||
if not in_text_obj:
|
if not in_text_obj:
|
||||||
if operator == Operator('BT'):
|
if operator == Operator('BT'):
|
||||||
in_text_obj = True
|
in_text_obj = True
|
||||||
render_mode = 0
|
|
||||||
text_objects.append((operands, operator))
|
text_objects.append((operands, operator))
|
||||||
else:
|
else:
|
||||||
stream.append((operands, operator))
|
stream.append((operands, operator))
|
||||||
else:
|
else:
|
||||||
if operator == Operator('Tr'):
|
|
||||||
render_mode = operands[0]
|
|
||||||
text_objects.append((operands, operator))
|
text_objects.append((operands, operator))
|
||||||
if operator == Operator('ET'):
|
if operator == Operator('ET'):
|
||||||
in_text_obj = False
|
in_text_obj = False
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ class PageNumberFilter(logging.Filter):
|
|||||||
class RichLoggingHandler(RichHandler):
|
class RichLoggingHandler(RichHandler):
|
||||||
def __init__(self, console: Console, **kwargs):
|
def __init__(self, console: Console, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
console=console, show_level=False, show_time=False, markup=True, **kwargs
|
console=console, show_level=False, show_time=False, markup=False, **kwargs
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pikepdf import Dictionary, Name, Pdf
|
|||||||
from pikepdf import __version__ as PIKEPDF_VERSION
|
from pikepdf import __version__ as PIKEPDF_VERSION
|
||||||
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
|
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
from ocrmypdf._defaults import PROGRAM_NAME
|
from ocrmypdf._defaults import PROGRAM_NAME
|
||||||
from ocrmypdf._jobcontext import PdfContext
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
|
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ def triage_image_file(input_file: Path, output_file: Path, options) -> None:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
# Recover the original filename
|
# Recover the original filename
|
||||||
log.error(str(e).replace(str(input_file), str(options.input_file)))
|
log.error(str(e).replace(str(input_file), str(options.input_file)))
|
||||||
|
if not input_file.exists():
|
||||||
|
log.error("Input file does not exist: %s", input_file)
|
||||||
|
if input_file.is_dir():
|
||||||
|
log.error("Input file is a directory: %s", input_file)
|
||||||
|
if input_file.is_file():
|
||||||
|
log.error("Input file is a file: %s", input_file)
|
||||||
|
if input_file.stat().st_size == 0:
|
||||||
|
log.error("Input file is empty: %s", input_file)
|
||||||
raise UnsupportedImageFormatError() from e
|
raise UnsupportedImageFormatError() from e
|
||||||
|
|
||||||
with im:
|
with im:
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ from pathlib import Path
|
|||||||
from typing import NamedTuple, cast
|
from typing import NamedTuple, cast
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
|
from pikepdf import Pdf
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
from ocrmypdf._concurrent import Executor, setup_executor
|
from ocrmypdf._concurrent import Executor, setup_executor
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._logging import PageNumberFilter
|
from ocrmypdf._logging import PageNumberFilter
|
||||||
@@ -438,7 +440,14 @@ def postprocess(
|
|||||||
pdf_file: Path, context: PdfContext, executor: Executor
|
pdf_file: Path, context: PdfContext, executor: Executor
|
||||||
) -> tuple[Path, Sequence[str]]:
|
) -> tuple[Path, Sequence[str]]:
|
||||||
"""Postprocess the PDF file."""
|
"""Postprocess the PDF file."""
|
||||||
pdf_out = pdf_file
|
# pdf_out = pdf_file
|
||||||
|
with Pdf.open(pdf_file) as pdf:
|
||||||
|
fix_annots = context.get_path('fix_annots.pdf')
|
||||||
|
if remove_broken_goto_annotations(pdf):
|
||||||
|
pdf.save(fix_annots)
|
||||||
|
pdf_out = fix_annots
|
||||||
|
else:
|
||||||
|
pdf_out = pdf_file
|
||||||
if context.options.output_type.startswith('pdfa'):
|
if context.options.output_type.startswith('pdfa'):
|
||||||
ps_stub_out = generate_postscript_stub(context)
|
ps_stub_out = generate_postscript_stub(context)
|
||||||
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
|
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ class HocrTransform:
|
|||||||
)
|
)
|
||||||
if hocr_next_box is None:
|
if hocr_next_box is None:
|
||||||
return
|
return
|
||||||
# Render a space this word and the next word. The explicit space helps
|
# Render a space between this word and the next word. The explicit space helps
|
||||||
# PDF viewers identify the word break, and horizontally scaling it to
|
# PDF viewers identify the word break, and horizontally scaling it to
|
||||||
# occupy the space the between the words helps the PDF viewer
|
# occupy the space the between the words helps the PDF viewer
|
||||||
# avoid combiningthewordstogether.
|
# avoid combiningthewordstogether.
|
||||||
@@ -409,7 +409,7 @@ class HocrTransform:
|
|||||||
space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury)
|
space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury)
|
||||||
self._debug_draw_space_bbox(canvas, space_box)
|
self._debug_draw_space_bbox(canvas, space_box)
|
||||||
space_width = self._font.text_width(' ', fontsize)
|
space_width = self._font.text_width(' ', fontsize)
|
||||||
if space_width > 0:
|
if space_width > 0 and space_box.width > 0:
|
||||||
if text_direction == TextDirection.LTR:
|
if text_direction == TextDirection.LTR:
|
||||||
text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0))
|
text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0))
|
||||||
elif text_direction == TextDirection.RTL:
|
elif text_direction == TextDirection.RTL:
|
||||||
|
|||||||
@@ -145,13 +145,15 @@ def get_progressbar_class() -> type[ProgressBar]:
|
|||||||
The class returned by this function must be compatible with the
|
The class returned by this function must be compatible with the
|
||||||
:class:`ProgressBar` protocol.
|
:class:`ProgressBar` protocol.
|
||||||
|
|
||||||
Here is how OCRmyPDF will use the progress bar:
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
pbar_class = pm.hook.get_progressbar_class()
|
Here is how OCRmyPDF will use the progress bar:
|
||||||
with pbar_class(**progress_kwargs) as pbar:
|
|
||||||
...
|
.. code-block:: python
|
||||||
pbar.update(1)
|
|
||||||
|
pbar_class = pm.hook.get_progressbar_class()
|
||||||
|
with pbar_class(**progress_kwargs) as pbar:
|
||||||
|
... # do some work
|
||||||
|
pbar.update(1)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pikepdf import Array, Dictionary, Name, NameTree, Pdf
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_broken_goto_annotations(resources):
|
||||||
|
with Pdf.open(resources / 'link.pdf') as pdf:
|
||||||
|
assert not remove_broken_goto_annotations(pdf), "File should not be modified"
|
||||||
|
|
||||||
|
# Construct Dests nametree
|
||||||
|
nt = NameTree.new(pdf)
|
||||||
|
names = pdf.Root[Name.Names] = pdf.make_indirect(Dictionary())
|
||||||
|
names[Name.Dests] = nt.obj
|
||||||
|
# Create a broken named destination
|
||||||
|
nt['Invalid'] = pdf.make_indirect(Dictionary())
|
||||||
|
# Create a valid named destination
|
||||||
|
nt['Valid'] = Array([pdf.pages[0].obj, Name.XYZ, 0, 0, 0])
|
||||||
|
|
||||||
|
pdf.pages[0].Annots[0].A.D = 'Missing'
|
||||||
|
pdf.pages[1].Annots[0].A.D = 'Valid'
|
||||||
|
|
||||||
|
assert remove_broken_goto_annotations(pdf), "File should be modified"
|
||||||
|
|
||||||
|
assert Name.D not in pdf.pages[0].Annots[0].A
|
||||||
|
assert Name.D in pdf.pages[1].Annots[0].A
|
||||||
@@ -139,7 +139,7 @@ def test_ghostscript_mandatory_color_conversion(resources, outpdf):
|
|||||||
|
|
||||||
def test_rasterize_pdf_errors(resources, no_outpdf, caplog):
|
def test_rasterize_pdf_errors(resources, no_outpdf, caplog):
|
||||||
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
||||||
# ghostscript can produce
|
# ghostscript can produce empty files with return code 0
|
||||||
mock.return_value = subprocess.CompletedProcess(
|
mock.return_value = subprocess.CompletedProcess(
|
||||||
['fakegs'], returncode=0, stdout=b'', stderr=b'error this is an error'
|
['fakegs'], returncode=0, stdout=b'', stderr=b'error this is an error'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,3 +40,72 @@ def test_links(resources, outpdf):
|
|||||||
p2 = pdf.pages[1]
|
p2 = pdf.pages[1]
|
||||||
assert p1.Annots[0].A.D[0].objgen == p2.objgen
|
assert p1.Annots[0].A.D[0].objgen == p2.objgen
|
||||||
assert p2.Annots[0].A.D[0].objgen == p1.objgen
|
assert p2.Annots[0].A.D[0].objgen == p1.objgen
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_invisble_text():
|
||||||
|
pdf = pikepdf.Pdf.new()
|
||||||
|
print(pikepdf.parse_content_stream(pikepdf.Stream(pdf, b'3 Tr')))
|
||||||
|
page = pdf.add_blank_page()
|
||||||
|
visible_text = [
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('BT')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.Name('/F0'), 12), pikepdf.Operator('Tf')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((288, 720), pikepdf.Operator('Td')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.String('visible'),), pikepdf.Operator('Tj')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('ET')),
|
||||||
|
]
|
||||||
|
invisible_text = [
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('BT')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.Name('/F0'), 12), pikepdf.Operator('Tf')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((288, 720), pikepdf.Operator('Td')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.String('invisible'),), pikepdf.Operator('Tj')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('ET')),
|
||||||
|
]
|
||||||
|
invisible_text_setting_tr = [
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('BT')),
|
||||||
|
pikepdf.ContentStreamInstruction([3], pikepdf.Operator('Tr')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.Name('/F0'), 12), pikepdf.Operator('Tf')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((288, 720), pikepdf.Operator('Td')),
|
||||||
|
pikepdf.ContentStreamInstruction(
|
||||||
|
(pikepdf.String('invisible'),), pikepdf.Operator('Tj')
|
||||||
|
),
|
||||||
|
pikepdf.ContentStreamInstruction((), pikepdf.Operator('ET')),
|
||||||
|
]
|
||||||
|
stream = [
|
||||||
|
pikepdf.ContentStreamInstruction([], pikepdf.Operator('q')),
|
||||||
|
pikepdf.ContentStreamInstruction([3], pikepdf.Operator('Tr')),
|
||||||
|
*invisible_text,
|
||||||
|
pikepdf.ContentStreamInstruction([], pikepdf.Operator('Q')),
|
||||||
|
*visible_text,
|
||||||
|
*invisible_text_setting_tr,
|
||||||
|
*invisible_text,
|
||||||
|
]
|
||||||
|
content_stream = pikepdf.unparse_content_stream(stream)
|
||||||
|
page.Contents = pikepdf.Stream(pdf, content_stream)
|
||||||
|
|
||||||
|
def count(string, page):
|
||||||
|
return len(
|
||||||
|
[
|
||||||
|
True
|
||||||
|
for operands, operator in pikepdf.parse_content_stream(page)
|
||||||
|
if operator == pikepdf.Operator('Tj')
|
||||||
|
and operands[0] == pikepdf.String(string)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
nr_visible_pre = count('visible', page)
|
||||||
|
ocrmypdf._graft.strip_invisible_text(pdf, page)
|
||||||
|
nr_visible_post = count('visible', page)
|
||||||
|
assert (
|
||||||
|
nr_visible_pre == nr_visible_post
|
||||||
|
), 'Number of visible text elements did not change'
|
||||||
|
assert count('invisible', page) == 0, 'No invisible elems left'
|
||||||
|
|||||||
Reference in New Issue
Block a user