Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da952a23d | ||
|
|
716ce6324c | ||
|
|
76fe2f7e28 | ||
|
|
c85c8941d3 | ||
|
|
9a0dadbd4c | ||
|
|
4d7e398c4b | ||
|
|
56c0b41f97 | ||
|
|
5c83dab8a7 | ||
|
|
e62e73e441 | ||
|
|
d68e2f6e34 | ||
|
|
1684982cde | ||
|
|
4d97dfd218 | ||
|
|
a35fcc9c43 | ||
|
|
3dd4cde7ce | ||
|
|
92beb474a5 | ||
|
|
9dcd882c83 | ||
|
|
9d8aa5a0c3 | ||
|
|
e036a902ae | ||
|
|
0a980fb11b |
+10
-8
@@ -15,11 +15,13 @@ sphinx:
|
|||||||
build:
|
build:
|
||||||
os: ubuntu-22.04
|
os: ubuntu-22.04
|
||||||
tools:
|
tools:
|
||||||
python: "3.11"
|
python: "3.13"
|
||||||
|
jobs:
|
||||||
python:
|
pre_create_environment:
|
||||||
install:
|
- asdf plugin add uv
|
||||||
- method: pip
|
- asdf install uv latest
|
||||||
path: .
|
- asdf global uv latest
|
||||||
extra_requirements:
|
create_environment:
|
||||||
- docs
|
- uv venv "${READTHEDOCS_VIRTUALENV_PATH}"
|
||||||
|
install:
|
||||||
|
- UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs
|
||||||
+173
-150
@@ -1,42 +1,42 @@
|
|||||||
---
|
---
|
||||||
myst:
|
myst:
|
||||||
substitutions:
|
substitutions:
|
||||||
deb_11: |-
|
|
||||||
:::{image} https://repology.org/badge/version-for-repo/debian_11/ocrmypdf.svg
|
|
||||||
:alt: Debian 11
|
|
||||||
:::
|
|
||||||
deb_12: |-
|
deb_12: |-
|
||||||
:::{image} https://repology.org/badge/version-for-repo/debian_12/ocrmypdf.svg
|
:::{image} https://repology.org/badge/version-for-repo/debian_12/ocrmypdf.svg
|
||||||
:alt: Debian 12
|
:alt: Debian 12
|
||||||
:::
|
:::
|
||||||
|
deb_13: |-
|
||||||
|
:::{image} https://repology.org/badge/version-for-repo/debian_13/ocrmypdf.svg
|
||||||
|
:alt: Debian 13
|
||||||
|
:::
|
||||||
deb_unstable: |-
|
deb_unstable: |-
|
||||||
:::{image} https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg
|
:::{image} https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg
|
||||||
:alt: Debian unstable
|
:alt: Debian unstable
|
||||||
:::
|
:::
|
||||||
fedora_38: |-
|
fedora_40: |-
|
||||||
:::{image} https://repology.org/badge/version-for-repo/fedora_38/ocrmypdf.svg
|
:::{image} https://repology.org/badge/version-for-repo/fedora_40/ocrmypdf.svg
|
||||||
:alt: Fedora 38
|
:alt: Fedora 40
|
||||||
:::
|
:::
|
||||||
fedora_39: |-
|
fedora_41: |-
|
||||||
:::{image} https://repology.org/badge/version-for-repo/fedora_39/ocrmypdf.svg
|
:::{image} https://repology.org/badge/version-for-repo/fedora_41/ocrmypdf.svg
|
||||||
:alt: Fedora 39
|
:alt: Fedora 41
|
||||||
:::
|
:::
|
||||||
fedora_rawhide: |-
|
fedora_rawhide: |-
|
||||||
:::{image} https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
:::{image} https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
||||||
:alt: Fedore Rawhide
|
:alt: Fedora Rawhide
|
||||||
:::
|
:::
|
||||||
latest: |-
|
latest: |-
|
||||||
:::{image} https://img.shields.io/pypi/v/ocrmypdf.svg
|
:::{image} https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||||
:alt: OCRmyPDF latest released version on PyPI
|
:alt: OCRmyPDF latest released version on PyPI
|
||||||
:::
|
:::
|
||||||
ubu_2004: |-
|
|
||||||
:::{image} https://repology.org/badge/version-for-repo/ubuntu_20_04/ocrmypdf.svg
|
|
||||||
:alt: Ubuntu 20.04 LTS
|
|
||||||
:::
|
|
||||||
ubu_2204: |-
|
ubu_2204: |-
|
||||||
:::{image} https://repology.org/badge/version-for-repo/ubuntu_22_04/ocrmypdf.svg
|
:::{image} https://repology.org/badge/version-for-repo/ubuntu_22_04/ocrmypdf.svg
|
||||||
:alt: Ubuntu 22.04 LTS
|
:alt: Ubuntu 22.04 LTS
|
||||||
:::
|
:::
|
||||||
|
ubu_2404: |-
|
||||||
|
:::{image} https://repology.org/badge/version-for-repo/ubuntu_24_04/ocrmypdf.svg
|
||||||
|
:alt: Ubuntu 24.04 LTS
|
||||||
|
:::
|
||||||
---
|
---
|
||||||
|
|
||||||
% SPDX-FileCopyrightText: 2022 James R. Barlow
|
% SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||||
@@ -54,18 +54,16 @@ These platforms have one-liner installs:
|
|||||||
:::{list-table}
|
:::{list-table}
|
||||||
:header-rows: 0
|
:header-rows: 0
|
||||||
|
|
||||||
|
* - Homebrew (macOS and Linux)
|
||||||
|
- ``brew install ocrmypdf``
|
||||||
* - Debian, Ubuntu
|
* - Debian, Ubuntu
|
||||||
- ``apt install ocrmypdf``
|
- ``apt install ocrmypdf``
|
||||||
* - Windows Subsystem for Linux
|
* - Windows Subsystem for Linux
|
||||||
- ``apt install ocrmypdf``
|
- ``apt install ocrmypdf``
|
||||||
* - Fedora
|
* - Fedora
|
||||||
- ``dnf install ocrmypdf tesseract-osd``
|
- ``dnf install ocrmypdf tesseract-osd``
|
||||||
* - macOS (Homebrew)
|
|
||||||
- ``brew install ocrmypdf``
|
|
||||||
* - macOS (MacPorts)
|
* - macOS (MacPorts)
|
||||||
- ``port install ocrmypdf``
|
- ``port install ocrmypdf``
|
||||||
* - LinuxBrew
|
|
||||||
- ``brew install ocrmypdf``
|
|
||||||
* - FreeBSD
|
* - FreeBSD
|
||||||
- ``pkg install textproc/py-ocrmypdf``
|
- ``pkg install textproc/py-ocrmypdf``
|
||||||
* - Snap (snapcraft packaging)
|
* - Snap (snapcraft packaging)
|
||||||
@@ -82,15 +80,15 @@ install, or install a more recent version than your platform provides, read on.
|
|||||||
|
|
||||||
## Installing on Linux
|
## Installing on Linux
|
||||||
|
|
||||||
### Debian and Ubuntu 20.04 or newer
|
### Debian and Ubuntu 22.04 or newer
|
||||||
|
|
||||||
:::{list-table}
|
:::{list-table}
|
||||||
:header-rows: 1
|
:header-rows: 1
|
||||||
|
|
||||||
* - OCRmyPDF versions in Debian & Ubuntu
|
* - OCRmyPDF versions in Debian & Ubuntu
|
||||||
* - {{ latest }}
|
* - {{ latest }}
|
||||||
* - {{ deb_11 }} {{ deb_12 }} {{ deb_unstable }}
|
* - {{ deb_12 }} {{ deb_13 }} {{ deb_unstable }}
|
||||||
* - {{ ubu_2004 }} {{ ubu_2204 }}
|
* - {{ ubu_2204 }} {{ ubu_2404 }}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Users of Debian or Ubuntu may simply
|
Users of Debian or Ubuntu may simply
|
||||||
@@ -112,9 +110,9 @@ For full details on version availability for your platform, check the
|
|||||||
:::{note}
|
:::{note}
|
||||||
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder.
|
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder.
|
||||||
OCRmyPDF works fine without it but will produce larger output files.
|
OCRmyPDF works fine without it but will produce larger output files.
|
||||||
If you build jbig2enc from source, ocrmypdf will
|
All JBIG2 patents expired in 2017, so if you build jbig2enc from source,
|
||||||
automatically detect it (specifically the `jbig2` binary) on the
|
OCRmyPDF will automatically detect it on the `PATH`.
|
||||||
`PATH`. To add JBIG2 encoding, see {ref}`jbig2`.
|
To add JBIG2 encoding, see {ref}`jbig2`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Fedora
|
### Fedora
|
||||||
@@ -124,7 +122,7 @@ automatically detect it (specifically the `jbig2` binary) on the
|
|||||||
|
|
||||||
* - OCRmyPDF version
|
* - OCRmyPDF version
|
||||||
* - {{latest}}
|
* - {{latest}}
|
||||||
* - {{fedora_38}} {{fedora_39}} {{fedora_rawhide}}
|
* - {{fedora_40}} {{fedora_41}} {{fedora_rawhide}}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Users of Fedora may simply
|
Users of Fedora may simply
|
||||||
@@ -141,21 +139,20 @@ to install the latest version from source. See [Installing HEAD revision
|
|||||||
from sources](#installing-head-revision-from-sources).
|
from sources](#installing-head-revision-from-sources).
|
||||||
|
|
||||||
:::{note}
|
:::{note}
|
||||||
OCRmyPDF for Fedora currently omits the JBIG2 encoder due to patent
|
OCRmyPDF for Fedora currently omits the JBIG2 encoder. All JBIG2 patents
|
||||||
issues. OCRmyPDF works fine without it but will produce larger output
|
expired in 2017. OCRmyPDF works fine without it but will produce larger
|
||||||
files. If you build jbig2enc from source, ocrmypdf 7.0.0 and later
|
output files. If you build jbig2enc from source, OCRmyPDF will automatically
|
||||||
will automatically detect it on the `PATH`. To add JBIG2 encoding,
|
detect it on the `PATH`. To add JBIG2 encoding, see {ref}`jbig2`.
|
||||||
see {ref}`Installing the JBIG2 encoder <jbig2>`.
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
(ubuntu-lts-latest)=
|
(ubuntu-lts-latest)=
|
||||||
|
|
||||||
### RHEL 9
|
### RHEL 9
|
||||||
|
|
||||||
Prepare the environment by getting Python 3.11:
|
Prepare the environment by getting Python 3.12:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dnf install python3.11 python3.11-pip
|
dnf install python3.12 python3.12-pip
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, follow [Requirements for pip and HEAD install](#requirements-for-pip-and-head-install) to install dependencies:
|
Then, follow [Requirements for pip and HEAD install](#requirements-for-pip-and-head-install) to install dependencies:
|
||||||
@@ -167,42 +164,47 @@ dnf install ghostscript tesseract
|
|||||||
and build ocrmypdf in virtual environment:
|
and build ocrmypdf in virtual environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3.11 -m venv .venv
|
python3.12 -m venv .venv
|
||||||
```
|
```
|
||||||
|
|
||||||
To add JBIG2 encoding, see {ref}`Installing the JBIG2 encoder <jbig2>`.
|
To add JBIG2 encoding, see {ref}`Installing the JBIG2 encoder <jbig2>`.
|
||||||
|
|
||||||
Note Fedora packages for language data haven't been branched for RHEL/EPEL, but you can get traineddata files directly from [tesseract](https://github.com/tesseract-ocr/tessdata/) and place them in `/usr/share/tesseract/tessdata`.
|
Note Fedora packages for language data haven't been branched for RHEL/EPEL, but you can get traineddata files directly from [tesseract](https://github.com/tesseract-ocr/tessdata/) and place them in `/usr/share/tesseract/tessdata`.
|
||||||
|
|
||||||
### Installing the latest version on Ubuntu 22.04 LTS
|
### Installing the latest version on Ubuntu 22.04/24.04 LTS
|
||||||
|
|
||||||
Ubuntu 22.04 includes ocrmypdf 13.4.0 - you can install that with
|
Ubuntu includes an older version of OCRmyPDF - you can install that with
|
||||||
`apt install ocrmypdf`. To install a more recent version for the current
|
`apt install ocrmypdf`. To install the latest version, we recommend using uv:
|
||||||
user, follow these steps:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install system dependencies first
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get -y install ocrmypdf python3-pip
|
sudo apt-get -y install ocrmypdf
|
||||||
|
|
||||||
pip install --user --upgrade ocrmypdf
|
# Install uv and upgrade to the latest OCRmyPDF
|
||||||
|
pip install uv
|
||||||
|
uv pip install --user --upgrade ocrmypdf
|
||||||
```
|
```
|
||||||
|
|
||||||
If you get the message `WARNING: The script ocrmypdf is installed in
|
Alternatively, use Homebrew on Linux for a full-featured installation (see below).
|
||||||
'/home/$USER/.local/bin' which is not on PATH.`, you may need to re-login
|
|
||||||
or open a new shell, or manually adjust your PATH.
|
|
||||||
|
|
||||||
To add JBIG2 encoding, see {ref}`jbig2`.
|
To add JBIG2 encoding, see {ref}`jbig2`.
|
||||||
|
|
||||||
### Ubuntu 20.04 LTS
|
### Ubuntu 20.04 LTS (and other older distributions)
|
||||||
|
|
||||||
Ubuntu 20.04 includes ocrmypdf 9.6.0 - you can install that with `apt`. The
|
:::{note}
|
||||||
most convenient way to install recent OCRmyPDF on older Ubuntu is to use
|
Ubuntu 20.04 is approaching end of life. Consider upgrading to Ubuntu 22.04 or 24.04 LTS.
|
||||||
Homebrew on Linux (Linuxbrew).
|
:::
|
||||||
|
|
||||||
|
For older distributions, the most convenient way to install a recent version of
|
||||||
|
OCRmyPDF is to use Homebrew on Linux:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install ocrmypdf
|
brew install ocrmypdf
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See {ref}`homebrew-linux` for more information on using Homebrew on Linux.
|
||||||
|
|
||||||
### Arch Linux (AUR)
|
### Arch Linux (AUR)
|
||||||
|
|
||||||
:::{image} https://repology.org/badge/version-for-repo/aur/ocrmypdf.svg
|
:::{image} https://repology.org/badge/version-for-repo/aur/ocrmypdf.svg
|
||||||
@@ -300,29 +302,45 @@ In general, first install the OCRmyPDF package for your system, then
|
|||||||
optionally use the procedure [Installing with Python
|
optionally use the procedure [Installing with Python
|
||||||
pip](#installing-with-python-pip) to install a more recent version.
|
pip](#installing-with-python-pip) to install a more recent version.
|
||||||
|
|
||||||
## Installing on macOS
|
(homebrew-linux)=
|
||||||
|
|
||||||
### Homebrew
|
## Installing with Homebrew (macOS and Linux)
|
||||||
|
|
||||||
:::{image} https://img.shields.io/homebrew/v/ocrmypdf.svg
|
:::{image} https://img.shields.io/homebrew/v/ocrmypdf.svg
|
||||||
:alt: homebrew
|
:alt: homebrew
|
||||||
:target: https://formulae.brew.sh/formula/ocrmypdf
|
:target: https://formulae.brew.sh/formula/ocrmypdf
|
||||||
:::
|
:::
|
||||||
|
|
||||||
OCRmyPDF is now a standard [Homebrew](https://brew.sh) formula. To
|
[Homebrew](https://brew.sh) provides a full-featured OCRmyPDF installation
|
||||||
install on macOS:
|
on both macOS and Linux with all recommended dependencies. This is often
|
||||||
|
the easiest way to get a complete, up-to-date installation.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install ocrmypdf
|
brew install ocrmypdf
|
||||||
```
|
```
|
||||||
|
|
||||||
This will include only the English language pack. If you need other
|
This includes Tesseract, Ghostscript, and all required dependencies. English
|
||||||
languages you can optionally install them all:
|
language support is included by default. For other languages:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install tesseract-lang # Optional: Install all language packs
|
brew install tesseract-lang # Optional: Install all language packs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::{tip}
|
||||||
|
**For Linux users:** Homebrew on Linux is an excellent choice when your
|
||||||
|
distribution's package is outdated or missing optional dependencies like
|
||||||
|
jbig2enc, pngquant, or unpaper. Homebrew provides a consistent, full-featured
|
||||||
|
installation that works across many Linux distributions.
|
||||||
|
|
||||||
|
Install Homebrew on Linux: https://brew.sh
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Installing on macOS
|
||||||
|
|
||||||
|
### Homebrew
|
||||||
|
|
||||||
|
See {ref}`homebrew-linux` above - the installation is identical on macOS.
|
||||||
|
|
||||||
### MacPorts
|
### MacPorts
|
||||||
|
|
||||||
:::{image} https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fports.macports.org%2Fapi%2Fv1%2Fports%2Focrmypdf%2F%3Fformat%3Djson&query=version&label=MacPorts
|
:::{image} https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fports.macports.org%2Fapi%2Fv1%2Fports%2Focrmypdf%2F%3Fformat%3Djson&query=version&label=MacPorts
|
||||||
@@ -330,7 +348,7 @@ brew install tesseract-lang # Optional: Install all language packs
|
|||||||
:target: https://ports.macports.org/port/ocrmypdf
|
:target: https://ports.macports.org/port/ocrmypdf
|
||||||
:::
|
:::
|
||||||
|
|
||||||
OCRmyPDF is includes in MacPorts:
|
OCRmyPDF is included in MacPorts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo port install ocrmypdf
|
sudo port install ocrmypdf
|
||||||
@@ -341,14 +359,13 @@ the appropriate tesseract [language ports](https://ports.macports.org/search/?se
|
|||||||
|
|
||||||
### Manual installation on macOS
|
### Manual installation on macOS
|
||||||
|
|
||||||
These instructions probably work on all macOS supported by Homebrew, and are
|
These instructions are for installing a more current version of OCRmyPDF than
|
||||||
for installing a more current version of OCRmyPDF than is available from
|
is available from Homebrew. Note that Homebrew versions usually track
|
||||||
Homebrew. Note that the Homebrew versions usually track the release versions
|
releases fairly closely.
|
||||||
fairly closely.
|
|
||||||
|
|
||||||
If it's not already present, [install Homebrew](http://brew.sh/).
|
If it's not already present, [install Homebrew](http://brew.sh/).
|
||||||
|
|
||||||
Update Homebrew:
|
Update Homebrew and install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew update
|
brew update
|
||||||
@@ -367,16 +384,11 @@ packs. If you need other languages you can optionally install them all:
|
|||||||
> brew install tesseract-lang # Option 2: for all language packs
|
> brew install tesseract-lang # Option 2: for all language packs
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
Update the homebrew pip:
|
Install uv and OCRmyPDF:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install --upgrade pip
|
pip install uv
|
||||||
```
|
uv pip install --user ocrmypdf
|
||||||
|
|
||||||
You can then install OCRmyPDF from PyPI for the current user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install --user ocrmypdf
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The command line program should now be available:
|
The command line program should now be available:
|
||||||
@@ -405,7 +417,7 @@ You must install the following for Windows:
|
|||||||
Using the [winget](https://docs.microsoft.com/en-us/windows/package-manager/winget/)
|
Using the [winget](https://docs.microsoft.com/en-us/windows/package-manager/winget/)
|
||||||
package manager:
|
package manager:
|
||||||
|
|
||||||
- `winget install -e --id Python.Python.3.11`
|
- `winget install -e --id Python.Python.3.12`
|
||||||
- `winget install -e --id UB-Mannheim.TesseractOCR`
|
- `winget install -e --id UB-Mannheim.TesseractOCR`
|
||||||
|
|
||||||
You will need to install Ghostscript manually, [since it does not support automated
|
You will need to install Ghostscript manually, [since it does not support automated
|
||||||
@@ -452,13 +464,6 @@ override the versions OCRmyPDF selects, you can modify the `PATH` environment
|
|||||||
variable. [Follow these directions](https://www.computerhope.com/issues/ch000549.htm#dospath)
|
variable. [Follow these directions](https://www.computerhope.com/issues/ch000549.htm#dospath)
|
||||||
to change the PATH.
|
to change the PATH.
|
||||||
|
|
||||||
:::{warning}
|
|
||||||
As of early 2021, users have reported problems with the Microsoft Store version of
|
|
||||||
Python and OCRmyPDF. These issues affect many other third party Python packages.
|
|
||||||
Please download Python from Python.org or a package manager instead of the
|
|
||||||
Microsoft Store version.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::{warning}
|
:::{warning}
|
||||||
32-bit Windows is not supported.
|
32-bit Windows is not supported.
|
||||||
:::
|
:::
|
||||||
@@ -551,23 +556,35 @@ See [Installing the Docker image](docker) for more information.
|
|||||||
|
|
||||||
(installing-with-python-pip)=
|
(installing-with-python-pip)=
|
||||||
|
|
||||||
## Installing with Python pip
|
## Installing with uv (recommended)
|
||||||
|
|
||||||
OCRmyPDF is delivered by PyPI because it is a convenient way to install
|
We recommend using [uv](https://docs.astral.sh/uv/) for installing OCRmyPDF from PyPI.
|
||||||
the latest version. However, PyPI and `pip` cannot address the fact
|
uv is a fast, modern Python package manager that provides better dependency resolution
|
||||||
that `ocrmypdf` depends on certain non-Python system libraries and
|
and consistent behavior across all platforms.
|
||||||
programs being installed.
|
|
||||||
|
|
||||||
For best results, first install [your platform's
|
For best results, first install [your platform's
|
||||||
version](https://repology.org/metapackage/ocrmypdf/versions) of
|
version](https://repology.org/metapackage/ocrmypdf/versions) of
|
||||||
`ocrmypdf`, using the instructions elsewhere in this document. Then
|
`ocrmypdf` using the instructions elsewhere in this document to satisfy system
|
||||||
you can use `pip` to get the latest version if your platform version
|
dependencies. Then use uv to get the latest OCRmyPDF version.
|
||||||
is out of date. Chances are that this will satisfy most dependencies.
|
|
||||||
|
```bash
|
||||||
|
# Install uv if you don't have it
|
||||||
|
pip install uv
|
||||||
|
|
||||||
|
# Install ocrmypdf in a virtual environment (recommended)
|
||||||
|
uv venv
|
||||||
|
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||||
|
uv pip install ocrmypdf
|
||||||
|
|
||||||
|
# Or install globally
|
||||||
|
uv pip install --system ocrmypdf
|
||||||
|
```
|
||||||
|
|
||||||
Use `ocrmypdf --version` to confirm what version was installed.
|
Use `ocrmypdf --version` to confirm what version was installed.
|
||||||
|
|
||||||
Then you can install the latest OCRmyPDF from the Python wheels. First
|
### Installing with pip
|
||||||
try:
|
|
||||||
|
If you prefer pip, you can still use it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install --user ocrmypdf
|
pip install --user ocrmypdf
|
||||||
@@ -576,21 +593,20 @@ pip install --user ocrmypdf
|
|||||||
(If the message appears `Requirement already satisfied: ocrmypdf in...`,
|
(If the message appears `Requirement already satisfied: ocrmypdf in...`,
|
||||||
you will need to use `pip install --user --upgrade ocrmypdf`.)
|
you will need to use `pip install --user --upgrade ocrmypdf`.)
|
||||||
|
|
||||||
You should then be able to run `ocrmypdf --version` and see that the
|
### Installing with pipx
|
||||||
latest version was located.
|
|
||||||
|
|
||||||
## Installing with pipx
|
Some users may prefer pipx for isolated command-line tool installations:
|
||||||
|
|
||||||
Some users may prefer pipx. As with the method above, you will need to
|
```bash
|
||||||
satisfy all non-Python dependencies. Then if pipx is installed, you
|
pipx install ocrmypdf
|
||||||
can use
|
```
|
||||||
|
|
||||||
|
Or run without permanent installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pipx run ocrmypdf
|
pipx run ocrmypdf
|
||||||
```
|
```
|
||||||
|
|
||||||
(If not installed, pipx will install first.)
|
|
||||||
|
|
||||||
(requirements-for-pip-and-head-install)=
|
(requirements-for-pip-and-head-install)=
|
||||||
|
|
||||||
### Requirements for pip and HEAD install
|
### Requirements for pip and HEAD install
|
||||||
@@ -606,27 +622,50 @@ and verapdf can validate speculative PDF/A conversion.
|
|||||||
|
|
||||||
The following versions are required:
|
The following versions are required:
|
||||||
|
|
||||||
- Python 3.11 or newer
|
- Python 3.11 or newer (3.12+ recommended)
|
||||||
- Tesseract 4.1.1 or newer
|
- Tesseract 4.1.1 or newer
|
||||||
- One of: Ghostscript 9.54+ **or** pypdfium2 (Python package)
|
- One of: Ghostscript 9.54+ **or** pypdfium2 (Python package)
|
||||||
- One of: Ghostscript 9.54+ **or** verapdf (for PDF/A output)
|
- One of: Ghostscript 9.54+ **or** verapdf (for PDF/A output)
|
||||||
- fpdf2 2.8 or newer (Python package)
|
- fpdf2 2.8 or newer (Python package)
|
||||||
|
- uharfbuzz (Python package)
|
||||||
|
- fonts-noto or equivalent (system package, recommended)
|
||||||
- jbig2enc 0.29 or newer (optional)
|
- jbig2enc 0.29 or newer (optional)
|
||||||
- pngquant 2.5 or newer (optional)
|
- pngquant 2.5 or newer (optional)
|
||||||
- unpaper 6.1 (optional)
|
- unpaper 6.1 (optional)
|
||||||
|
|
||||||
:::{note}
|
:::{note}
|
||||||
For the best user experience, install both Ghostscript and pypdfium2.
|
For the best user experience, install both Ghostscript and pypdfium2. pypdfium2 is
|
||||||
pypdfium2 is faster for rasterization, while Ghostscript provides
|
faster for rasterization, while Ghostscript provides is required for certain PDF/A
|
||||||
broader compatibility and is required for certain PDF/A conversions.
|
conversions.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
**Dependency summary:**
|
||||||
|
|
||||||
|
| Feature | Option 1 | Option 2 | Notes |
|
||||||
|
|---------|----------|----------|-------|
|
||||||
|
| PDF rasterization | pypdfium2 (Python) | Ghostscript (binary) | pypdfium2 preferred when available |
|
||||||
|
| PDF/A conversion | verapdf + pikepdf | Ghostscript | verapdf validates speculative conversion |
|
||||||
|
| Text rendering | fpdf2 + uharfbuzz | - | Required |
|
||||||
|
| OCR | tesseract-ocr | `--ocr-engine none` | Can be skipped entirely |
|
||||||
|
|
||||||
|
**Minimum viable installation:**
|
||||||
|
tesseract-ocr + (pypdfium2 OR Ghostscript) + fpdf2 + uharfbuzz
|
||||||
|
|
||||||
|
**Recommended installation:**
|
||||||
|
tesseract-ocr + pypdfium2 + Ghostscript + verapdf + fpdf2 + uharfbuzz + fonts-noto + unpaper + pngquant + jbig2enc
|
||||||
|
|
||||||
We recommend 64-bit versions of all software. (32-bit versions are not
|
We recommend 64-bit versions of all software. (32-bit versions are not
|
||||||
supported, although on Linux, they may still work.)
|
supported, although on Linux, they may still work.)
|
||||||
|
|
||||||
**fpdf2** is a required dependency that provides the text layer
|
**fpdf2** and **uharfbuzz** are required dependencies that provide the text
|
||||||
rendering engine. It replaces the legacy hOCR-based renderer with improved
|
layer rendering engine. fpdf2 generates the PDF text layer, while uharfbuzz
|
||||||
multilingual support. Install with: `pip install fpdf2`
|
provides text shaping for proper multilingual support. These replace the
|
||||||
|
legacy hOCR-based renderer. Install with: `pip install fpdf2 uharfbuzz`
|
||||||
|
|
||||||
|
**fonts-noto** (or an equivalent comprehensive font package) is recommended
|
||||||
|
for proper text rendering, especially for non-Latin scripts. On Debian/Ubuntu:
|
||||||
|
`apt install fonts-noto`. On Fedora: `dnf install google-noto-fonts-common`.
|
||||||
|
On macOS with Homebrew: `brew install font-noto`.
|
||||||
|
|
||||||
**pypdfium2**, if present, provides fast PDF page rasterization using
|
**pypdfium2**, if present, provides fast PDF page rasterization using
|
||||||
the pdfium library (the same library used by Google Chrome). It is
|
the pdfium library (the same library used by Google Chrome). It is
|
||||||
@@ -642,10 +681,10 @@ or visit [verapdf.org](https://verapdf.org/).
|
|||||||
**jbig2enc**, if present, will be used to optimize the encoding of
|
**jbig2enc**, if present, will be used to optimize the encoding of
|
||||||
monochrome images. This can significantly reduce the file size of the
|
monochrome images. This can significantly reduce the file size of the
|
||||||
output file. It is not required.
|
output file. It is not required.
|
||||||
[jbig2enc](https://github.com/agl/jbig2enc) is not generally
|
[jbig2enc](https://github.com/agl/jbig2enc) is not available in some
|
||||||
available for Ubuntu or Debian due to lingering concerns about patent
|
distributions due to historical patent concerns, but all JBIG2 patents
|
||||||
issues, but can easily be built from source. To add JBIG2 encoding, see
|
expired in 2017. It can easily be built from source. To add JBIG2 encoding,
|
||||||
{ref}`jbig2`.
|
see {ref}`jbig2`.
|
||||||
|
|
||||||
:::{warning}
|
:::{warning}
|
||||||
Lossy JBIG2 encoding (`--jbig2-lossy`) has been removed in v17.0.0 due to
|
Lossy JBIG2 encoding (`--jbig2-lossy`) has been removed in v17.0.0 due to
|
||||||
@@ -668,8 +707,8 @@ unfortunately, the `pip install` command cannot satisfy all of them.
|
|||||||
|
|
||||||
## Installing HEAD revision from sources
|
## Installing HEAD revision from sources
|
||||||
|
|
||||||
If you have `git` and Python 3.11 or newer installed, you can install
|
If you have `git` and Python 3.12 or newer installed, you can install
|
||||||
from source. When the `pip` installer runs, it will alert you if
|
from source. (Python 3.11 is supported but 3.12+ is recommended.) When the `pip` installer runs, it will alert you if
|
||||||
dependencies are missing.
|
dependencies are missing.
|
||||||
|
|
||||||
If you prefer to build every from source, you will need to [build
|
If you prefer to build every from source, you will need to [build
|
||||||
@@ -677,33 +716,39 @@ pikepdf from
|
|||||||
source](https://pikepdf.readthedocs.io/en/latest/installation.html#building-from-source).
|
source](https://pikepdf.readthedocs.io/en/latest/installation.html#building-from-source).
|
||||||
First ensure you can build and install pikepdf.
|
First ensure you can build and install pikepdf.
|
||||||
|
|
||||||
To install the HEAD revision from sources in the current Python 3
|
We recommend using uv to install from sources:
|
||||||
environment:
|
|
||||||
|
```bash
|
||||||
|
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
|
cd OCRmyPDF
|
||||||
|
pip install uv # If not already installed
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a virtual environment and installs all dependencies. Activate
|
||||||
|
the environment to use ocrmypdf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
ocrmypdf --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, install directly from GitHub using pip:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install git+https://github.com/ocrmypdf/OCRmyPDF.git
|
pip install git+https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Or, to install in editable mode
|
Or, to install in editable mode allowing customization:
|
||||||
allowing customization of OCRmyPDF, use the `-e` flag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e git+https://github.com/ocrmypdf/OCRmyPDF.git
|
|
||||||
```
|
|
||||||
|
|
||||||
You may find it easiest to install in a virtual environment, rather than
|
|
||||||
system-wide:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
cd OCRmyPDF
|
cd OCRmyPDF
|
||||||
pip install .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
However, `ocrmypdf` will only be accessible on the system PATH when
|
Note: `ocrmypdf` will only be accessible when the virtual environment
|
||||||
you activate the virtual environment.
|
is activated.
|
||||||
|
|
||||||
To run the program:
|
To run the program:
|
||||||
|
|
||||||
@@ -729,16 +774,11 @@ User features are available as optional dependencies. Install them with `uv` (re
|
|||||||
uv sync --extra watcher # File watching service
|
uv sync --extra watcher # File watching service
|
||||||
uv sync --extra webservice # Streamlit web UI
|
uv sync --extra webservice # Streamlit web UI
|
||||||
uv sync --extra watcher --extra webservice # Multiple features
|
uv sync --extra watcher --extra webservice # Multiple features
|
||||||
|
|
||||||
# Using pip (also works)
|
|
||||||
pip install ocrmypdf[watcher]
|
|
||||||
pip install ocrmypdf[webservice]
|
|
||||||
pip install ocrmypdf[watcher,webservice]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development Tools (uv only)
|
### Development Tools
|
||||||
|
|
||||||
Development tools use dependency groups and require `uv`:
|
Development tools use dependency groups:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Testing infrastructure
|
# Testing infrastructure
|
||||||
@@ -754,11 +794,6 @@ uv sync --group streamlit-dev
|
|||||||
uv sync
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
:::{note}
|
|
||||||
**User features** (`watcher`, `webservice`) work with both `uv` and `pip`.
|
|
||||||
**Developer tools** (`test`, `docs`, `streamlit-dev`) require `uv` and use dependency groups (PEP 735).
|
|
||||||
:::
|
|
||||||
|
|
||||||
**Why use uv?**
|
**Why use uv?**
|
||||||
|
|
||||||
- Modern, fast Python package manager
|
- Modern, fast Python package manager
|
||||||
@@ -766,7 +801,7 @@ uv sync
|
|||||||
- Better dependency resolution
|
- Better dependency resolution
|
||||||
- Consistent across all platforms
|
- Consistent across all platforms
|
||||||
|
|
||||||
Install uv: `pip install uv` or visit https://docs.astral.sh/uv/
|
Install uv: `curl -LsSf https://astral.sh/uv/install.sh | sh` or visit https://docs.astral.sh/uv/
|
||||||
|
|
||||||
### For development
|
### For development
|
||||||
|
|
||||||
@@ -775,12 +810,9 @@ To install all of the development and test requirements:
|
|||||||
```bash
|
```bash
|
||||||
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
||||||
cd OCRmyPDF
|
cd OCRmyPDF
|
||||||
pip install uv # Install uv if not already installed
|
uv sync --all-groups
|
||||||
uv sync --group test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: Development requires `uv`. The old `pip install -e .[test]` method is no longer supported.
|
|
||||||
|
|
||||||
To add JBIG2 encoding, see {ref}`jbig2`.
|
To add JBIG2 encoding, see {ref}`jbig2`.
|
||||||
|
|
||||||
## Shell completions
|
## Shell completions
|
||||||
@@ -800,14 +832,5 @@ To manually install the `fish` completion, copy
|
|||||||
|
|
||||||
## Note on 32-bit support
|
## Note on 32-bit support
|
||||||
|
|
||||||
Many Python libraries no longer provide 32-bit binary wheels for Linux. This
|
We don't support any 32-bit system, including 32-bit Python or 32-bit
|
||||||
includes many of the libraries that OCRmyPDF depends on, such as
|
Ghostscript on Windows.
|
||||||
Pillow. The easiest way to express this to end users is to say we don't
|
|
||||||
support 32-bit Linux.
|
|
||||||
|
|
||||||
However, if your Linux distribution still supports 32-bit binaries, you
|
|
||||||
can still install and use OCRmyPDF. A warning message will appear.
|
|
||||||
In practice, OCRmyPDF may need more than 32-bit memory space to run when
|
|
||||||
large documents are processed, so there are practical limitations to what
|
|
||||||
users can accomplish with it. Still, for the common use case of an 32-bit
|
|
||||||
ARM NAS or Raspberry Pi processing small documents, it should work.
|
|
||||||
@@ -25,6 +25,30 @@ about a forthcoming release that has not been tagged yet. A release is only
|
|||||||
official when it's tagged and posted to PyPI.
|
official when it's tagged and posted to PyPI.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
||||||
|
## v17.2.0
|
||||||
|
|
||||||
|
- Fixed incorrect word spacing in poppler-based PDF viewers and tools (Evince,
|
||||||
|
pdftotext, and others) where words on the same line appeared separated by
|
||||||
|
double newlines. This works around a poppler bug where Tz (horizontal scaling)
|
||||||
|
is not carried across BT/ET boundaries. {issue}`1632`
|
||||||
|
- Fixed OCR text layer being visible instead of invisible due to incorrect fpdf2
|
||||||
|
text rendering mode attribute. This caused OCR text to appear when images were
|
||||||
|
removed from the PDF. {issue}`1631`
|
||||||
|
- Fixed OCR text layer misalignment with non-zero mediabox origins, which
|
||||||
|
affected cropped PDFs and JSTOR PDFs generated by iText. The ``--redo-ocr``
|
||||||
|
mode would shift text vertically on these files. {issue}`1630`
|
||||||
|
- Fixed Ghostscript rasterization failure with very low DPI values (below 10).
|
||||||
|
OCRmyPDF now renders at a minimum of 10 DPI and resizes the output to match
|
||||||
|
the originally requested dimensions. {issue}`1612`
|
||||||
|
|
||||||
|
## v17.1.0
|
||||||
|
|
||||||
|
- Added `--tagged-pdf-mode` to allow skipping the TaggedPDF error message, if desired.
|
||||||
|
- Fixed an issue where deflated JPEGs (FlateDecode + DCTDecode) were counted as
|
||||||
|
lossless images for the purpose of determining whether to compress to JPEG,
|
||||||
|
causing file size inflation with some workflows (`--mode force` in particular).
|
||||||
|
|
||||||
## v17.0.1
|
## v17.0.1
|
||||||
|
|
||||||
- Fixed output file size inflation when using pypdfium as rasterizer and force-ocr
|
- Fixed output file size inflation when using pypdfium as rasterizer and force-ocr
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ __ocrmypdf_arguments()
|
|||||||
--skip-text (skip OCR on any pages that already contain text)
|
--skip-text (skip OCR on any pages that already contain text)
|
||||||
--redo-ocr (redo OCR on any pages that seem to have OCR already)
|
--redo-ocr (redo OCR on any pages that seem to have OCR already)
|
||||||
--invalidate-digital-signatures (remove digital signatures from PDF)
|
--invalidate-digital-signatures (remove digital signatures from PDF)
|
||||||
|
--tagged-pdf-mode (control behavior for Tagged PDFs)
|
||||||
--skip-big (skip OCR on pages larger than this many MPixels)
|
--skip-big (skip OCR on pages larger than this many MPixels)
|
||||||
--optimize (select optimization level)
|
--optimize (select optimization level)
|
||||||
--jpeg-quality (JPEG quality [0..100])
|
--jpeg-quality (JPEG quality [0..100])
|
||||||
@@ -232,6 +233,18 @@ redo (re-OCR pages, replacing old invisible text)"
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
__ocrmypdf_tagged-pdf-mode()
|
||||||
|
{
|
||||||
|
local choices="default (error if --mode is default, otherwise warn)
|
||||||
|
ignore (always warn but continue processing)"
|
||||||
|
|
||||||
|
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
|
||||||
|
# Remove description if only one completion exists
|
||||||
|
if [[ ${#COMPREPLY[*]} -eq 1 ]]; then
|
||||||
|
COMPREPLY=( ${COMPREPLY[0]%% *} )
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
__ocrmypdf_ocr-engine()
|
__ocrmypdf_ocr-engine()
|
||||||
{
|
{
|
||||||
local choices="auto (select best available engine)
|
local choices="auto (select best available engine)
|
||||||
@@ -293,6 +306,10 @@ __ocrmypdf_check_previous()
|
|||||||
__ocrmypdf_mode
|
__ocrmypdf_mode
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
--tagged-pdf-mode)
|
||||||
|
__ocrmypdf_tagged-pdf-mode
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
--ocr-engine)
|
--ocr-engine)
|
||||||
__ocrmypdf_ocr-engine
|
__ocrmypdf_ocr-engine
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ complete -c ocrmypdf -s s -l skip-text -d "skip OCR on any pages that already co
|
|||||||
complete -c ocrmypdf -l redo-ocr -d "redo OCR on any pages that seem to have OCR already"
|
complete -c ocrmypdf -l redo-ocr -d "redo OCR on any pages that seem to have OCR already"
|
||||||
complete -c ocrmypdf -l invalidate-digital-signatures -d "invalidate digital signatures and allow OCR to proceed"
|
complete -c ocrmypdf -l invalidate-digital-signatures -d "invalidate digital signatures and allow OCR to proceed"
|
||||||
|
|
||||||
|
function __fish_ocrmypdf_tagged_pdf_mode
|
||||||
|
echo -e "default\t"(_ "error if --mode is default, otherwise warn")
|
||||||
|
echo -e "ignore\t"(_ "always warn but continue processing")
|
||||||
|
end
|
||||||
|
complete -c ocrmypdf -x -l tagged-pdf-mode -a '(__fish_ocrmypdf_tagged_pdf_mode)' -d "control behavior for Tagged PDFs"
|
||||||
|
|
||||||
complete -c ocrmypdf -s k -l keep-temporary-files -d "keep temporary files (debug)"
|
complete -c ocrmypdf -s k -l keep-temporary-files -d "keep temporary files (debug)"
|
||||||
|
|
||||||
function __fish_ocrmypdf_languages
|
function __fish_ocrmypdf_languages
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo
|
|||||||
from ocrmypdf._concurrent import Executor
|
from ocrmypdf._concurrent import Executor
|
||||||
from ocrmypdf._defaults import PROGRAM_NAME
|
from ocrmypdf._defaults import PROGRAM_NAME
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._options import OcrOptions
|
from ocrmypdf._options import OcrOptions, TaggedPdfMode
|
||||||
from ocrmypdf._pipelines._common import (
|
from ocrmypdf._pipelines._common import (
|
||||||
configure_debug_logging,
|
configure_debug_logging,
|
||||||
)
|
)
|
||||||
@@ -78,6 +78,7 @@ __all__ = [
|
|||||||
'PriorOcrFoundError',
|
'PriorOcrFoundError',
|
||||||
'PROGRAM_NAME',
|
'PROGRAM_NAME',
|
||||||
'SubprocessOutputError',
|
'SubprocessOutputError',
|
||||||
|
'TaggedPdfMode',
|
||||||
'TesseractConfigError',
|
'TesseractConfigError',
|
||||||
'UnsupportedImageFormatError',
|
'UnsupportedImageFormatError',
|
||||||
'Verbosity',
|
'Verbosity',
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ def rasterize_pdf(
|
|||||||
if not page_dpi:
|
if not page_dpi:
|
||||||
page_dpi = raster_dpi
|
page_dpi = raster_dpi
|
||||||
|
|
||||||
|
# Ghostscript may fail with very low DPI values (below 10). If the requested
|
||||||
|
# DPI is too low, use a minimum of 10 DPI and resize the output afterward.
|
||||||
|
MIN_RASTER_DPI = 10
|
||||||
|
needs_low_dpi_resize = (
|
||||||
|
raster_dpi.x < MIN_RASTER_DPI or raster_dpi.y < MIN_RASTER_DPI
|
||||||
|
)
|
||||||
|
if needs_low_dpi_resize:
|
||||||
|
effective_dpi = Resolution(
|
||||||
|
max(raster_dpi.x, MIN_RASTER_DPI), max(raster_dpi.y, MIN_RASTER_DPI)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
effective_dpi = raster_dpi
|
||||||
|
|
||||||
args_gs = (
|
args_gs = (
|
||||||
[
|
[
|
||||||
GS,
|
GS,
|
||||||
@@ -137,7 +150,7 @@ def rasterize_pdf(
|
|||||||
f'-sDEVICE={raster_device}',
|
f'-sDEVICE={raster_device}',
|
||||||
f'-dFirstPage={pageno}',
|
f'-dFirstPage={pageno}',
|
||||||
f'-dLastPage={pageno}',
|
f'-dLastPage={pageno}',
|
||||||
f'-r{raster_dpi.x:f}x{raster_dpi.y:f}',
|
f'-r{effective_dpi.x:f}x{effective_dpi.y:f}',
|
||||||
]
|
]
|
||||||
+ (['-dUseCropBox'] if use_cropbox else [])
|
+ (['-dUseCropBox'] if use_cropbox else [])
|
||||||
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
||||||
@@ -173,6 +186,16 @@ def rasterize_pdf(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with Image.open(output_file) as im:
|
with Image.open(output_file) as im:
|
||||||
|
if needs_low_dpi_resize:
|
||||||
|
# Resize to the dimensions that would have resulted from the
|
||||||
|
# original low DPI request
|
||||||
|
scale_x = raster_dpi.x / effective_dpi.x
|
||||||
|
scale_y = raster_dpi.y / effective_dpi.y
|
||||||
|
new_size = (
|
||||||
|
max(1, int(round(im.width * scale_x))),
|
||||||
|
max(1, int(round(im.height * scale_y))),
|
||||||
|
)
|
||||||
|
im = im.resize(new_size, Image.Resampling.LANCZOS)
|
||||||
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
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shlex
|
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -101,13 +100,6 @@ def run_unpaper(
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
def validate_custom_args(args: str) -> list[str]:
|
|
||||||
unpaper_args = shlex.split(args)
|
|
||||||
if any(('/' in arg or arg == '.' or arg == '..') for arg in unpaper_args):
|
|
||||||
raise ValueError('No filenames allowed in --unpaper-args')
|
|
||||||
return unpaper_args
|
|
||||||
|
|
||||||
|
|
||||||
def clean(
|
def clean(
|
||||||
input_file: Path,
|
input_file: Path,
|
||||||
output_file: Path,
|
output_file: Path,
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ class OcrGrafter:
|
|||||||
xobj.Type = Name.XObject
|
xobj.Type = Name.XObject
|
||||||
xobj.Subtype = Name.Form
|
xobj.Subtype = Name.Form
|
||||||
xobj.FormType = 1
|
xobj.FormType = 1
|
||||||
xobj.BBox = mediabox
|
xobj.BBox = base_mediabox
|
||||||
|
|
||||||
# Copy resources from text page's Resources to xobj
|
# Copy resources from text page's Resources to xobj
|
||||||
# We need to handle this carefully since text_page is from a foreign PDF
|
# We need to handle this carefully since text_page is from a foreign PDF
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -50,6 +51,20 @@ class ProcessingMode(StrEnum):
|
|||||||
redo = 'redo'
|
redo = 'redo'
|
||||||
|
|
||||||
|
|
||||||
|
class TaggedPdfMode(StrEnum):
|
||||||
|
"""Control behavior when encountering a Tagged PDF.
|
||||||
|
|
||||||
|
Tagged PDFs often indicate documents generated from office applications
|
||||||
|
that may not need OCR. This enum controls how OCRmyPDF handles them:
|
||||||
|
|
||||||
|
- ``default``: Error if ProcessingMode is default, otherwise warn
|
||||||
|
- ``ignore``: Always warn but continue processing (never error)
|
||||||
|
"""
|
||||||
|
|
||||||
|
default = 'default'
|
||||||
|
ignore = 'ignore'
|
||||||
|
|
||||||
|
|
||||||
def _pages_from_ranges(ranges: str) -> set[int]:
|
def _pages_from_ranges(ranges: str) -> set[int]:
|
||||||
"""Convert page range string to set of page numbers."""
|
"""Convert page range string to set of page numbers."""
|
||||||
pages: list[int] = []
|
pages: list[int] = []
|
||||||
@@ -142,14 +157,13 @@ class OcrOptions(BaseModel):
|
|||||||
remove_background: bool = False
|
remove_background: bool = False
|
||||||
remove_vectors: bool = False
|
remove_vectors: bool = False
|
||||||
oversample: int = 0
|
oversample: int = 0
|
||||||
unpaper_args: str | list[str] | None = (
|
unpaper_args: list[str] | None = None
|
||||||
None # Can be string or list after validation
|
|
||||||
)
|
|
||||||
|
|
||||||
# OCR behavior
|
# OCR behavior
|
||||||
skip_big: float | None = None
|
skip_big: float | None = None
|
||||||
pages: str | set[int] | None = None # Can be string or set after validation
|
pages: str | set[int] | None = None # Can be string or set after validation
|
||||||
invalidate_digital_signatures: bool = False
|
invalidate_digital_signatures: bool = False
|
||||||
|
tagged_pdf_mode: TaggedPdfMode = TaggedPdfMode.default
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
@@ -324,6 +338,20 @@ class OcrOptions(BaseModel):
|
|||||||
# 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)
|
||||||
|
|
||||||
|
@field_validator('unpaper_args', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def validate_unpaper_args(cls, v):
|
||||||
|
"""Normalize unpaper_args from string to list and validate security."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = shlex.split(v)
|
||||||
|
if isinstance(v, list):
|
||||||
|
if any(('/' in arg or arg == '.' or arg == '..') for arg in v):
|
||||||
|
raise ValueError('No filenames allowed in --unpaper-args')
|
||||||
|
return v
|
||||||
|
raise ValueError(f'unpaper_args must be a string or list, got {type(v)}')
|
||||||
|
|
||||||
@model_validator(mode='before')
|
@model_validator(mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def handle_special_cases(cls, data):
|
def handle_special_cases(cls, data):
|
||||||
|
|||||||
+15
-11
@@ -28,7 +28,7 @@ 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
|
from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode
|
||||||
from ocrmypdf.exceptions import (
|
from ocrmypdf.exceptions import (
|
||||||
DigitalSignatureError,
|
DigitalSignatureError,
|
||||||
DpiError,
|
DpiError,
|
||||||
@@ -251,14 +251,17 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
|
|||||||
"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:
|
||||||
if options.mode != ProcessingMode.default:
|
log.warning(
|
||||||
log.warning(
|
"This PDF is marked as a Tagged PDF. This often indicates "
|
||||||
"This PDF is marked as a Tagged PDF. This often indicates "
|
"that the PDF was generated from an office document and does "
|
||||||
"that the PDF was generated from an office document and does "
|
"not need OCR. PDF pages processed by OCRmyPDF may not be "
|
||||||
"not need OCR. PDF pages processed by OCRmyPDF may not be "
|
"tagged correctly."
|
||||||
"tagged correctly."
|
)
|
||||||
)
|
if (
|
||||||
else:
|
options.tagged_pdf_mode == TaggedPdfMode.default
|
||||||
|
and options.mode == ProcessingMode.default
|
||||||
|
):
|
||||||
|
log.info("Use --tagged-pdf-mode ignore to ignore Tagged PDFs.")
|
||||||
raise TaggedPDFError()
|
raise TaggedPDFError()
|
||||||
context.plugin_manager.validate(pdfinfo=pdfinfo, options=options)
|
context.plugin_manager.validate(pdfinfo=pdfinfo, options=options)
|
||||||
|
|
||||||
@@ -735,7 +738,8 @@ def ocr_engine_direct(
|
|||||||
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
|
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
|
||||||
"""Determines whether the visible page image should be saved as a JPEG.
|
"""Determines whether the visible page image should be saved as a JPEG.
|
||||||
|
|
||||||
If all images were JPEGs originally, permit a JPEG as output.
|
If all images were JPEGs originally (including FlateDecode+DCTDecode),
|
||||||
|
permit a JPEG as output.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pageinfo: The PageInfo object containing information about the page.
|
pageinfo: The PageInfo object containing information about the page.
|
||||||
@@ -744,7 +748,7 @@ def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
|
|||||||
A boolean indicating whether the visible page image should be saved as a JPEG.
|
A boolean indicating whether the visible page image should be saved as a JPEG.
|
||||||
"""
|
"""
|
||||||
return bool(pageinfo.images) and all(
|
return bool(pageinfo.images) and all(
|
||||||
im.enc == Encoding.jpeg for im in pageinfo.images
|
im.enc in (Encoding.jpeg, Encoding.flate_jpeg) for im in pageinfo.images
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -172,19 +172,27 @@ class OcrmypdfPluginManager:
|
|||||||
|
|
||||||
def filter_pdf_page(
|
def filter_pdf_page(
|
||||||
self, *, page: PageContext, image_filename: Path, output_pdf: Path
|
self, *, page: PageContext, image_filename: Path, output_pdf: Path
|
||||||
) -> Path | None:
|
) -> Path:
|
||||||
"""Convert a filtered whole page image into a PDF."""
|
"""Convert a filtered whole page image into a PDF."""
|
||||||
return self._pm.hook.filter_pdf_page(
|
result = self._pm.hook.filter_pdf_page(
|
||||||
page=page, image_filename=image_filename, output_pdf=output_pdf
|
page=page, image_filename=image_filename, output_pdf=output_pdf
|
||||||
)
|
)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError('No PDF produced')
|
||||||
|
if result != output_pdf:
|
||||||
|
raise ValueError('filter_pdf_page must return output_pdf')
|
||||||
|
return result
|
||||||
|
|
||||||
def get_ocr_engine(self, *, options: OcrOptions | None = None) -> OcrEngine | None:
|
def get_ocr_engine(self, *, options: OcrOptions | None = None) -> OcrEngine:
|
||||||
"""Returns an OcrEngine to use for processing.
|
"""Returns an OcrEngine to use for processing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
options: OcrOptions to pass to the hook for engine selection.
|
options: OcrOptions to pass to the hook for engine selection.
|
||||||
"""
|
"""
|
||||||
return self._pm.hook.get_ocr_engine(options=options)
|
result = self._pm.hook.get_ocr_engine(options=options)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError('No OCR engine selected')
|
||||||
|
return result
|
||||||
|
|
||||||
def generate_pdfa(
|
def generate_pdfa(
|
||||||
self,
|
self,
|
||||||
@@ -218,15 +226,18 @@ class OcrmypdfPluginManager:
|
|||||||
context: PdfContext,
|
context: PdfContext,
|
||||||
executor: Executor,
|
executor: Executor,
|
||||||
linearize: bool,
|
linearize: bool,
|
||||||
) -> tuple[Path, Sequence[str]] | None:
|
) -> tuple[Path, Sequence[str]]:
|
||||||
"""Optimize a PDF after OCR processing."""
|
"""Optimize a PDF after OCR processing."""
|
||||||
return self._pm.hook.optimize_pdf(
|
result = self._pm.hook.optimize_pdf(
|
||||||
input_pdf=input_pdf,
|
input_pdf=input_pdf,
|
||||||
output_pdf=output_pdf,
|
output_pdf=output_pdf,
|
||||||
context=context,
|
context=context,
|
||||||
executor=executor,
|
executor=executor,
|
||||||
linearize=linearize,
|
linearize=linearize,
|
||||||
)
|
)
|
||||||
|
if result is None:
|
||||||
|
return input_pdf, []
|
||||||
|
return result
|
||||||
|
|
||||||
def is_optimization_enabled(self, *, context: PdfContext) -> bool | None:
|
def is_optimization_enabled(self, *, context: PdfContext) -> bool | None:
|
||||||
"""Returns whether optimization is enabled for given context."""
|
"""Returns whether optimization is enabled for given context."""
|
||||||
|
|||||||
@@ -114,15 +114,8 @@ def check_options_preprocessing(options: OcrOptions) -> None:
|
|||||||
package='unpaper',
|
package='unpaper',
|
||||||
version_checker=unpaper.version,
|
version_checker=unpaper.version,
|
||||||
need_version='6.1',
|
need_version='6.1',
|
||||||
required_for="--clean, --clean-final", # Problem arguments
|
required_for="--clean, --clean-final",
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
if options.unpaper_args:
|
|
||||||
options.unpaper_args = unpaper.validate_custom_args(
|
|
||||||
options.unpaper_args
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise BadArgsError("--unpaper-args: " + str(e)) from e
|
|
||||||
|
|
||||||
|
|
||||||
def _check_plugin_invariant_options(options: OcrOptions) -> None:
|
def _check_plugin_invariant_options(options: OcrOptions) -> None:
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ def ocr(
|
|||||||
fast_web_view: float | None = None,
|
fast_web_view: float | None = None,
|
||||||
continue_on_soft_render_error: bool | None = None,
|
continue_on_soft_render_error: bool | None = None,
|
||||||
invalidate_digital_signatures: bool | None = None,
|
invalidate_digital_signatures: bool | None = None,
|
||||||
|
tagged_pdf_mode: str | 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,
|
||||||
@@ -469,6 +470,7 @@ def ocr( # noqa: D417
|
|||||||
fast_web_view: float | None = None,
|
fast_web_view: float | None = None,
|
||||||
continue_on_soft_render_error: bool | None = None,
|
continue_on_soft_render_error: bool | None = None,
|
||||||
invalidate_digital_signatures: bool | None = None,
|
invalidate_digital_signatures: bool | None = None,
|
||||||
|
tagged_pdf_mode: str | 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,
|
||||||
|
|||||||
+9
-1
@@ -12,7 +12,7 @@ from typing import Any, TypeVar
|
|||||||
|
|
||||||
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||||
from ocrmypdf._defaults import PROGRAM_NAME as _PROGRAM_NAME
|
from ocrmypdf._defaults import PROGRAM_NAME as _PROGRAM_NAME
|
||||||
from ocrmypdf._options import OcrOptions, ProcessingMode
|
from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode
|
||||||
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
|
||||||
from ocrmypdf._version import __version__ as _VERSION
|
from ocrmypdf._version import __version__ as _VERSION
|
||||||
|
|
||||||
@@ -360,6 +360,14 @@ Online documentation is located at:
|
|||||||
"signature. This option allows OCR to proceed, but the digital signature "
|
"signature. This option allows OCR to proceed, but the digital signature "
|
||||||
"will be invalidated.",
|
"will be invalidated.",
|
||||||
)
|
)
|
||||||
|
ocrsettings.add_argument(
|
||||||
|
'--tagged-pdf-mode',
|
||||||
|
choices=[mode.value for mode in TaggedPdfMode],
|
||||||
|
default=TaggedPdfMode.default.value,
|
||||||
|
help="Control behavior when a Tagged PDF is encountered. "
|
||||||
|
"'default' errors if --mode is default, otherwise warns. "
|
||||||
|
"'ignore' always warns but continues processing.",
|
||||||
|
)
|
||||||
|
|
||||||
advanced = parser.add_argument_group(
|
advanced = parser.add_argument_group(
|
||||||
"Advanced", "Advanced options to control OCRmyPDF"
|
"Advanced", "Advanced options to control OCRmyPDF"
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from itertools import pairwise
|
from math import atan, cos, degrees, radians, sin, sqrt
|
||||||
from math import atan, degrees
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fpdf import FPDF
|
from fpdf import FPDF
|
||||||
from fpdf.enums import TextMode
|
from fpdf.enums import PDFResourceType, TextMode
|
||||||
from pikepdf import Matrix, Rectangle
|
from pikepdf import Matrix, Rectangle
|
||||||
|
|
||||||
from ocrmypdf.font import FontManager, MultiFontManager
|
from ocrmypdf.font import FontManager, MultiFontManager
|
||||||
@@ -163,6 +162,8 @@ class Fpdf2PdfRenderer:
|
|||||||
|
|
||||||
# Registered fonts: font_path -> fpdf_family_name
|
# Registered fonts: font_path -> fpdf_family_name
|
||||||
self._registered_fonts: dict[str, str] = {}
|
self._registered_fonts: dict[str, str] = {}
|
||||||
|
# Track whether we've already logged the info-level suppression message
|
||||||
|
self._logged_aspect_ratio_suppression = False
|
||||||
|
|
||||||
def render(self, output_path: Path) -> None:
|
def render(self, output_path: Path) -> None:
|
||||||
"""Render page to PDF file.
|
"""Render page to PDF file.
|
||||||
@@ -189,9 +190,9 @@ class Fpdf2PdfRenderer:
|
|||||||
|
|
||||||
# Set text mode for invisible text
|
# Set text mode for invisible text
|
||||||
if self.invisible_text:
|
if self.invisible_text:
|
||||||
pdf.text_rendering_mode = TextMode.INVISIBLE
|
pdf.text_mode = TextMode.INVISIBLE
|
||||||
else:
|
else:
|
||||||
pdf.text_rendering_mode = TextMode.FILL
|
pdf.text_mode = TextMode.FILL
|
||||||
|
|
||||||
# Render content to PDF
|
# Render content to PDF
|
||||||
self.render_to_pdf(pdf)
|
self.render_to_pdf(pdf)
|
||||||
@@ -299,6 +300,30 @@ class Fpdf2PdfRenderer:
|
|||||||
# Get textangle (rotation of the entire line)
|
# Get textangle (rotation of the entire line)
|
||||||
textangle = line.textangle or 0.0
|
textangle = line.textangle or 0.0
|
||||||
|
|
||||||
|
# Read baseline early so we can detect rotation from steep slopes.
|
||||||
|
# When Tesseract doesn't report textangle for rotated text, the
|
||||||
|
# rotation gets encoded as a very steep baseline slope instead.
|
||||||
|
slope = 0.0
|
||||||
|
intercept_pt = 0.0
|
||||||
|
has_meaningful_baseline = False
|
||||||
|
if line.baseline is not None:
|
||||||
|
slope = line.baseline.slope
|
||||||
|
intercept_pt = self.coord_transform.px_to_pt(line.baseline.intercept)
|
||||||
|
if abs(slope) < 0.005:
|
||||||
|
slope = 0.0
|
||||||
|
has_meaningful_baseline = True
|
||||||
|
|
||||||
|
# Detect text rotation from steep baseline slope.
|
||||||
|
# A slope magnitude > 1.0 corresponds to > 45° from horizontal,
|
||||||
|
# which indicates the line is rotated, not merely skewed.
|
||||||
|
if textangle == 0.0 and abs(slope) > 1.0:
|
||||||
|
textangle = degrees(atan(slope))
|
||||||
|
# The original baseline slope and intercept are not meaningful
|
||||||
|
# after extracting rotation; recalculate intercept from font
|
||||||
|
# metrics below.
|
||||||
|
slope = 0.0
|
||||||
|
has_meaningful_baseline = False
|
||||||
|
|
||||||
# Build line_size_aabb_matrix: transforms from page coords to un-rotated
|
# Build line_size_aabb_matrix: transforms from page coords to un-rotated
|
||||||
# line coords. The hOCR bbox is the minimum axis-aligned bounding box
|
# line coords. The hOCR bbox is the minimum axis-aligned bounding box
|
||||||
# enclosing the rotated text.
|
# enclosing the rotated text.
|
||||||
@@ -317,16 +342,10 @@ class Fpdf2PdfRenderer:
|
|||||||
inv_line_matrix, line_left_pt, line_top_pt, line_right_pt, line_bottom_pt
|
inv_line_matrix, line_left_pt, line_top_pt, line_right_pt, line_bottom_pt
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get baseline information (slope and intercept)
|
# Get baseline intercept
|
||||||
slope = 0.0
|
if not has_meaningful_baseline:
|
||||||
intercept_pt = 0.0
|
# No baseline provided or baseline was used for rotation detection:
|
||||||
if line.baseline is not None:
|
# calculate intercept from font metrics
|
||||||
slope = line.baseline.slope
|
|
||||||
intercept_pt = self.coord_transform.px_to_pt(line.baseline.intercept)
|
|
||||||
if abs(slope) < 0.005:
|
|
||||||
slope = 0.0
|
|
||||||
else:
|
|
||||||
# No baseline provided: calculate from font metrics
|
|
||||||
default_font_manager = self.multi_font_manager.fonts['NotoSans-Regular']
|
default_font_manager = self.multi_font_manager.fonts['NotoSans-Regular']
|
||||||
ascent, descent, units_per_em = default_font_manager.get_font_metrics()
|
ascent, descent, units_per_em = default_font_manager.get_font_metrics()
|
||||||
ascent_norm = ascent / units_per_em
|
ascent_norm = ascent / units_per_em
|
||||||
@@ -383,143 +402,331 @@ class Fpdf2PdfRenderer:
|
|||||||
w for w in line.children if w.ocr_class == OcrClass.WORD and w.text
|
w for w in line.children if w.ocr_class == OcrClass.WORD and w.text
|
||||||
]
|
]
|
||||||
|
|
||||||
# Render each word followed by space (except last)
|
# Suppress lines where the text aspect ratio is implausible.
|
||||||
# Use pairwise to iterate over consecutive word pairs, pairing the last
|
# This catches cases where Tesseract failed to detect rotation
|
||||||
# word with a None to signal the end of the line.
|
# entirely (slope=0, no textangle) and produced garbage text in a
|
||||||
for current_word, next_word in pairwise(words + [None]):
|
# bounding box whose shape doesn't match the text content at all.
|
||||||
if current_word: # Don't render EOL sentinel
|
if not self._check_aspect_ratio_plausible(
|
||||||
# Render the current word
|
pdf, words, font_size, slope_angle_deg,
|
||||||
self._render_word(
|
line_size_width, line_size_height, line_language,
|
||||||
pdf,
|
):
|
||||||
current_word,
|
return
|
||||||
baseline_matrix,
|
|
||||||
inv_baseline_matrix,
|
# Collect word rendering data: (text, x_baseline, font_family, word_tz)
|
||||||
font_size,
|
word_render_data: list[tuple[str, float, str, float]] = []
|
||||||
total_rotation_deg,
|
for word in words:
|
||||||
line_language,
|
if word is None or not word.text or word.bbox is None:
|
||||||
)
|
continue
|
||||||
if next_word: # Don't render EOL sentinel
|
|
||||||
self._maybe_render_space(
|
word_left_pt = self.coord_transform.px_to_pt(word.bbox.left)
|
||||||
pdf,
|
word_top_pt = self.coord_transform.px_to_pt(word.bbox.top)
|
||||||
current_word,
|
word_right_pt = self.coord_transform.px_to_pt(word.bbox.right)
|
||||||
next_word,
|
word_bottom_pt = self.coord_transform.px_to_pt(word.bbox.bottom)
|
||||||
baseline_matrix,
|
word_width_pt = word_right_pt - word_left_pt
|
||||||
inv_baseline_matrix,
|
|
||||||
font_size,
|
# Debug rendering: draw word bbox (in page coordinates)
|
||||||
total_rotation_deg,
|
if self.debug_options.render_word_bbox:
|
||||||
line_language,
|
self._render_debug_word_bbox(
|
||||||
line.direction,
|
pdf, word_left_pt, word_top_pt, word_right_pt, word_bottom_pt
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_word(
|
# Get x position in baseline coordinate system
|
||||||
|
box_llx, _, _, _ = transform_box(
|
||||||
|
inv_baseline_matrix,
|
||||||
|
word_left_pt,
|
||||||
|
word_top_pt,
|
||||||
|
word_right_pt,
|
||||||
|
word_bottom_pt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select font and compute word-only Tz
|
||||||
|
font_manager = self.multi_font_manager.select_font_for_word(
|
||||||
|
word.text, line_language
|
||||||
|
)
|
||||||
|
font_family = self._register_font(pdf, font_manager)
|
||||||
|
pdf.set_font(font_family, size=font_size)
|
||||||
|
natural_width = pdf.get_string_width(word.text)
|
||||||
|
if natural_width > 0 and word_width_pt > 0:
|
||||||
|
word_tz = (word_width_pt / natural_width) * 100
|
||||||
|
else:
|
||||||
|
word_tz = 100.0
|
||||||
|
|
||||||
|
word_render_data.append((word.text, box_llx, font_family, word_tz))
|
||||||
|
|
||||||
|
if not word_render_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Emit single BT block for the entire line using raw PDF operators.
|
||||||
|
# This avoids a poppler bug where Tz (horizontal scaling) is not
|
||||||
|
# carried across BT/ET boundaries, affecting all poppler-based tools
|
||||||
|
# and viewers (Evince, pdftotext, etc.). By keeping all words in a
|
||||||
|
# single BT block with relative Td positioning and per-word Tz, we
|
||||||
|
# ensure correct inter-word spacing.
|
||||||
|
self._emit_line_bt_block(
|
||||||
|
pdf,
|
||||||
|
word_render_data,
|
||||||
|
baseline_matrix,
|
||||||
|
font_size,
|
||||||
|
total_rotation_deg,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_aspect_ratio_plausible(
|
||||||
self,
|
self,
|
||||||
pdf: FPDF,
|
pdf: FPDF,
|
||||||
word: OcrElement,
|
words: list[OcrElement | None],
|
||||||
baseline_matrix: Matrix,
|
|
||||||
inv_baseline_matrix: Matrix,
|
|
||||||
font_size: float,
|
font_size: float,
|
||||||
rotation_deg: float,
|
slope_angle_deg: float,
|
||||||
|
line_size_width: float,
|
||||||
|
line_size_height: float,
|
||||||
line_language: str | None,
|
line_language: str | None,
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Render a word using word bbox positioning.
|
"""Check whether the line's aspect ratio is plausible for its text.
|
||||||
|
|
||||||
Position text so its visual bounding box matches the hOCR word bbox.
|
Compares the aspect ratio of the OCR bounding box to the aspect ratio
|
||||||
This provides more accurate placement than baseline-relative positioning
|
the text would have if rendered normally (accounting for baseline
|
||||||
because we match the actual glyph bounds rather than relying on font
|
slope). A large mismatch indicates Tesseract misread rotated text
|
||||||
metrics which may not exactly match the OCR'd text appearance.
|
without detecting the rotation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if plausible (rendering should proceed), False to suppress.
|
||||||
|
"""
|
||||||
|
if line_size_width <= 0 or line_size_height <= 0 or font_size <= 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Fast path: most lines are wider than they are tall, which is
|
||||||
|
# the normal shape for horizontal text. Only tall-narrow boxes
|
||||||
|
# (height > width) need the expensive font measurement check.
|
||||||
|
if line_size_width >= line_size_height:
|
||||||
|
return True
|
||||||
|
|
||||||
|
line_text = ' '.join(
|
||||||
|
w.text for w in words if w is not None and w.text
|
||||||
|
)
|
||||||
|
if not line_text:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Measure the natural rendered width of the line text
|
||||||
|
font_manager = self.multi_font_manager.select_font_for_word(
|
||||||
|
line_text, line_language
|
||||||
|
)
|
||||||
|
font_family = self._register_font(pdf, font_manager)
|
||||||
|
pdf.set_font(font_family, size=round(font_size))
|
||||||
|
natural_width = pdf.get_string_width(line_text)
|
||||||
|
|
||||||
|
if natural_width <= 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Compute the AABB the text would occupy considering baseline slope
|
||||||
|
theta = radians(abs(slope_angle_deg))
|
||||||
|
expected_w = natural_width * cos(theta) + font_size * sin(theta)
|
||||||
|
expected_h = natural_width * sin(theta) + font_size * cos(theta)
|
||||||
|
|
||||||
|
if expected_h <= 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
actual_aspect = line_size_width / line_size_height
|
||||||
|
expected_aspect = expected_w / expected_h
|
||||||
|
ratio = actual_aspect / expected_aspect
|
||||||
|
|
||||||
|
if ratio >= 0.1:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Implausible aspect ratio — suppress this line
|
||||||
|
log.debug(
|
||||||
|
"Suppressing text with improbable aspect ratio: "
|
||||||
|
"actual=%.3f expected=%.3f ratio=%.4f text=%r",
|
||||||
|
actual_aspect,
|
||||||
|
expected_aspect,
|
||||||
|
ratio,
|
||||||
|
line_text[:80],
|
||||||
|
)
|
||||||
|
if not self._logged_aspect_ratio_suppression:
|
||||||
|
log.info(
|
||||||
|
"Suppressing OCR output text with improbable aspect ratio"
|
||||||
|
)
|
||||||
|
self._logged_aspect_ratio_suppression = True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _emit_line_bt_block(
|
||||||
|
self,
|
||||||
|
pdf: FPDF,
|
||||||
|
word_render_data: list[tuple[str, float, str, float]],
|
||||||
|
baseline_matrix: Matrix,
|
||||||
|
font_size: float,
|
||||||
|
total_rotation_deg: float,
|
||||||
|
) -> None:
|
||||||
|
"""Emit a single BT block for the entire line using raw PDF operators.
|
||||||
|
|
||||||
|
Writes all words in a single BT..ET block with relative Td positioning
|
||||||
|
and per-word Tz. Each non-last word gets a trailing space appended, with
|
||||||
|
Tz calculated so the rendered width of "word " spans from the current
|
||||||
|
word's start to the next word's start. This works around a poppler bug
|
||||||
|
where Tz is not carried across BT/ET boundaries, which affects all
|
||||||
|
poppler-based viewers and tools (Evince, pdftotext, etc.).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pdf: FPDF instance
|
pdf: FPDF instance
|
||||||
word: Word OCR element
|
word_render_data: List of (text, x_baseline, font_family, word_tz)
|
||||||
|
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
|
||||||
inv_baseline_matrix: Transform from page coords to baseline coords
|
font_size: Font size in points
|
||||||
font_size: Font size in points (from line calculation)
|
total_rotation_deg: Total rotation angle (textangle + slope)
|
||||||
rotation_deg: Total rotation angle for text
|
|
||||||
line_language: Language code from line for font selection
|
|
||||||
"""
|
"""
|
||||||
if not word.text or word.bbox is None:
|
page_height = self.coord_transform.page_height_pt
|
||||||
return
|
|
||||||
|
|
||||||
# Select appropriate font for this word
|
# Compute baseline direction in PDF coordinates for rotation
|
||||||
font_manager = self.multi_font_manager.select_font_for_word(
|
has_rotation = abs(total_rotation_deg) > 0.01
|
||||||
word.text, line_language
|
bx0, by0_fpdf = transform_point(baseline_matrix, 0, 0)
|
||||||
)
|
by0_pdf = page_height - by0_fpdf
|
||||||
|
|
||||||
# Register font with fpdf2
|
ops: list[str] = []
|
||||||
font_family = self._register_font(pdf, font_manager)
|
|
||||||
|
|
||||||
# Convert word bbox to PDF points
|
if has_rotation:
|
||||||
word_left_pt = self.coord_transform.px_to_pt(word.bbox.left)
|
# Compute direction vector along the baseline in PDF coordinates
|
||||||
word_top_pt = self.coord_transform.px_to_pt(word.bbox.top)
|
bx1, by1_fpdf = transform_point(baseline_matrix, 100, 0)
|
||||||
word_right_pt = self.coord_transform.px_to_pt(word.bbox.right)
|
by1_pdf = page_height - by1_fpdf
|
||||||
word_bottom_pt = self.coord_transform.px_to_pt(word.bbox.bottom)
|
dx = bx1 - bx0
|
||||||
word_width_pt = word_right_pt - word_left_pt
|
dy = by1_pdf - by0_pdf
|
||||||
|
length = sqrt(dx * dx + dy * dy)
|
||||||
|
if length > 0:
|
||||||
|
cos_a = dx / length
|
||||||
|
sin_a = dy / length
|
||||||
|
else:
|
||||||
|
cos_a = 1.0
|
||||||
|
sin_a = 0.0
|
||||||
|
|
||||||
# Transform word bbox into baseline coordinate system to get x position
|
# Save graphics state, apply rotation+translation via cm.
|
||||||
box_llx, _, _, _ = transform_box(
|
# The cm maps local coordinates (baseline-aligned, x along text)
|
||||||
inv_baseline_matrix,
|
# to PDF page coordinates.
|
||||||
word_left_pt,
|
ops.append('q')
|
||||||
word_top_pt,
|
ops.append(
|
||||||
word_right_pt,
|
f'{cos_a:.6f} {sin_a:.6f} {-sin_a:.6f} {cos_a:.6f} '
|
||||||
word_bottom_pt,
|
f'{bx0:.2f} {by0_pdf:.2f} cm'
|
||||||
)
|
|
||||||
|
|
||||||
# Debug rendering: draw word bbox (in page coordinates)
|
|
||||||
if self.debug_options.render_word_bbox:
|
|
||||||
self._render_debug_word_bbox(
|
|
||||||
pdf, word_left_pt, word_top_pt, word_right_pt, word_bottom_pt
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use line-based font_size for consistent vertical sizing
|
# Begin text object
|
||||||
word_font_size = font_size
|
ops.append('BT')
|
||||||
|
|
||||||
# Set font
|
# Text render mode: 3 = invisible, 0 = fill
|
||||||
pdf.set_font(font_family, size=word_font_size)
|
tr = 3 if self.invisible_text else 0
|
||||||
|
ops.append(f'{tr} Tr')
|
||||||
|
|
||||||
# Calculate natural text width at this font size
|
# Initial text position
|
||||||
natural_width = pdf.get_string_width(word.text)
|
first_x_baseline = word_render_data[0][1]
|
||||||
|
if has_rotation:
|
||||||
# Calculate horizontal scale to fit word bbox width
|
# In the cm-transformed space, origin is at the baseline start
|
||||||
if natural_width > 0 and word_width_pt > 0:
|
ops.append(f'{first_x_baseline:.2f} 0 Td')
|
||||||
scale_x = (word_width_pt / natural_width) * 100
|
|
||||||
else:
|
else:
|
||||||
scale_x = 100
|
# Direct PDF coordinates
|
||||||
|
page_x, page_y_fpdf = transform_point(
|
||||||
|
baseline_matrix, first_x_baseline, 0
|
||||||
|
)
|
||||||
|
page_y_pdf = page_height - page_y_fpdf
|
||||||
|
ops.append(f'{page_x:.2f} {page_y_pdf:.2f} Td')
|
||||||
|
|
||||||
# Apply horizontal stretching
|
prev_font_family: str | None = None
|
||||||
pdf.set_stretching(scale_x)
|
prev_x_baseline = first_x_baseline
|
||||||
|
|
||||||
# Get left side bearing of first character to compensate for glyph offset
|
for i, (text, x_baseline, font_family, word_tz) in enumerate(
|
||||||
lsb_pt = font_manager.get_left_side_bearing(word.text[0], word_font_size)
|
word_render_data
|
||||||
|
):
|
||||||
|
is_last = i == len(word_render_data) - 1
|
||||||
|
|
||||||
# Transform the baseline-relative x position back to page coordinates
|
# Set font if changed
|
||||||
# The word sits at (box_llx, 0) in baseline coords (on the baseline)
|
if font_family != prev_font_family:
|
||||||
page_x, page_y = transform_point(baseline_matrix, box_llx, 0)
|
pdf.set_font(font_family, size=font_size)
|
||||||
|
# Register font resource on this page
|
||||||
|
pdf._resource_catalog.add(
|
||||||
|
PDFResourceType.FONT, pdf.current_font.i, pdf.page
|
||||||
|
)
|
||||||
|
ops.append(
|
||||||
|
f'/F{pdf.current_font.i} {pdf.font_size_pt:.2f} Tf'
|
||||||
|
)
|
||||||
|
prev_font_family = font_family
|
||||||
|
|
||||||
# Adjust x position to account for lsb (scaled by horizontal stretch)
|
# Relative positioning (for words after the first)
|
||||||
adjusted_x = page_x - lsb_pt * (scale_x / 100)
|
if i > 0:
|
||||||
|
if has_rotation:
|
||||||
|
# In rotated space, advance is purely along x-axis
|
||||||
|
dx_baseline = x_baseline - prev_x_baseline
|
||||||
|
ops.append(f'{dx_baseline:.2f} 0 Td')
|
||||||
|
else:
|
||||||
|
# Non-rotated: compute delta in PDF coordinates
|
||||||
|
px_prev, py_prev_f = transform_point(
|
||||||
|
baseline_matrix, prev_x_baseline, 0
|
||||||
|
)
|
||||||
|
px_curr, py_curr_f = transform_point(
|
||||||
|
baseline_matrix, x_baseline, 0
|
||||||
|
)
|
||||||
|
dx_pdf = px_curr - px_prev
|
||||||
|
# Flip y delta for PDF coordinates (y-up)
|
||||||
|
dy_pdf = -(py_curr_f - py_prev_f)
|
||||||
|
ops.append(f'{dx_pdf:.2f} {dy_pdf:.2f} Td')
|
||||||
|
|
||||||
# Calculate y position based on baseline
|
# Determine text to render and compute Tz
|
||||||
# In fpdf2, set_xy(x, y) positions text such that the baseline is at:
|
if not is_last:
|
||||||
# baseline_y = set_y + font_size * (ascent / (ascent + |descent|))
|
next_text, next_x_baseline, _, _ = word_render_data[i + 1]
|
||||||
# We want baseline at page_y, so:
|
advance = next_x_baseline - x_baseline
|
||||||
# page_y = set_y + font_size * (ascent / (ascent + |descent|))
|
|
||||||
# set_y = page_y - font_size * (ascent / (ascent + |descent|))
|
|
||||||
ascent, descent, _ = font_manager.get_font_metrics()
|
|
||||||
total_height = ascent + abs(descent)
|
|
||||||
baseline_offset_ratio = ascent / total_height
|
|
||||||
adjusted_y = page_y - word_font_size * baseline_offset_ratio
|
|
||||||
|
|
||||||
# Position and draw text with rotation
|
# Add trailing space unless both words are CJK-only
|
||||||
if abs(rotation_deg) > 0.1:
|
if (
|
||||||
with pdf.rotation(-rotation_deg, x=page_x, y=page_y):
|
advance > 0
|
||||||
pdf.set_xy(adjusted_x, adjusted_y)
|
and not (
|
||||||
pdf.cell(text=word.text)
|
self._is_cjk_only(text)
|
||||||
else:
|
and self._is_cjk_only(next_text)
|
||||||
pdf.set_xy(adjusted_x, adjusted_y)
|
)
|
||||||
pdf.cell(text=word.text)
|
):
|
||||||
|
text_to_render = text + ' '
|
||||||
|
natural_w = pdf.get_string_width(text_to_render)
|
||||||
|
render_tz = (
|
||||||
|
(advance / natural_w) * 100
|
||||||
|
if natural_w > 0
|
||||||
|
else word_tz
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text_to_render = text
|
||||||
|
render_tz = word_tz
|
||||||
|
else:
|
||||||
|
text_to_render = text
|
||||||
|
render_tz = word_tz
|
||||||
|
|
||||||
# Reset stretching
|
ops.append(f'{render_tz:.2f} Tz')
|
||||||
pdf.set_stretching(100)
|
ops.append(self._encode_shaped_text(pdf, text_to_render))
|
||||||
|
|
||||||
|
prev_x_baseline = x_baseline
|
||||||
|
|
||||||
|
# End text object
|
||||||
|
ops.append('ET')
|
||||||
|
|
||||||
|
if has_rotation:
|
||||||
|
ops.append('Q')
|
||||||
|
|
||||||
|
pdf._out('\n'.join(ops))
|
||||||
|
|
||||||
|
# Reset fpdf2's internal stretching tracking so subsequent API calls
|
||||||
|
# don't think Tz is still set from our raw operators
|
||||||
|
pdf.font_stretching = 100
|
||||||
|
|
||||||
|
def _encode_shaped_text(self, pdf: FPDF, text: str) -> str:
|
||||||
|
"""Encode text using HarfBuzz text shaping for complex script support.
|
||||||
|
|
||||||
|
Unlike font.encode_text() which maps unicode characters one-by-one to
|
||||||
|
glyph IDs, this uses HarfBuzz to handle BiDi reordering, Arabic joining
|
||||||
|
forms, Devanagari conjuncts, and other complex script shaping. Falls
|
||||||
|
back to encode_text() when text shaping is not enabled.
|
||||||
|
"""
|
||||||
|
font = pdf.current_font
|
||||||
|
if pdf.text_shaping and pdf.text_shaping.get("use_shaping_engine"):
|
||||||
|
shaped = font.shape_text(text, pdf.font_size_pt, pdf.text_shaping)
|
||||||
|
if shaped:
|
||||||
|
mapped = "".join(
|
||||||
|
chr(ti["mapped_char"])
|
||||||
|
for ti in shaped
|
||||||
|
if ti["mapped_char"] is not None
|
||||||
|
)
|
||||||
|
if mapped:
|
||||||
|
return f"({font.escape_text(mapped)}) Tj"
|
||||||
|
return font.encode_text(text)
|
||||||
|
|
||||||
def _is_cjk_only(self, text: str) -> bool:
|
def _is_cjk_only(self, text: str) -> bool:
|
||||||
"""Check if text contains only CJK characters.
|
"""Check if text contains only CJK characters.
|
||||||
@@ -559,157 +766,6 @@ class Fpdf2PdfRenderer:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _maybe_render_space(
|
|
||||||
self,
|
|
||||||
pdf: FPDF,
|
|
||||||
current_word: OcrElement,
|
|
||||||
next_word: OcrElement,
|
|
||||||
baseline_matrix: Matrix,
|
|
||||||
inv_baseline_matrix: Matrix,
|
|
||||||
font_size: float,
|
|
||||||
rotation_deg: float,
|
|
||||||
line_language: str | None,
|
|
||||||
direction: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Render a space character between two words if a gap exists.
|
|
||||||
|
|
||||||
This ensures that PDF readers like pdfminer.six can properly segment
|
|
||||||
words during text extraction. Some PDF readers rely on explicit space
|
|
||||||
characters rather than inferring word boundaries from positioning.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pdf: FPDF instance
|
|
||||||
current_word: The word that was just rendered
|
|
||||||
next_word: The next word to be rendered
|
|
||||||
baseline_matrix: Transform from baseline coords to page coords
|
|
||||||
inv_baseline_matrix: Transform from page coords to baseline coords
|
|
||||||
font_size: Font size in points
|
|
||||||
rotation_deg: Total rotation angle for text
|
|
||||||
line_language: Language code from line for font selection
|
|
||||||
direction: Text direction ("ltr" or "rtl")
|
|
||||||
"""
|
|
||||||
if current_word.bbox is None or next_word.bbox is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Skip if both words are CJK-only (no spaces in CJK text)
|
|
||||||
if self._is_cjk_only(current_word.text) and self._is_cjk_only(next_word.text):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Calculate gap between words
|
|
||||||
if direction == "rtl":
|
|
||||||
gap_left = next_word.bbox.right
|
|
||||||
gap_right = current_word.bbox.left
|
|
||||||
else:
|
|
||||||
gap_left = current_word.bbox.right
|
|
||||||
gap_right = next_word.bbox.left
|
|
||||||
|
|
||||||
gap_width_px = gap_right - gap_left
|
|
||||||
|
|
||||||
# Use word height as proxy for line height
|
|
||||||
line_height_px = current_word.bbox.height
|
|
||||||
|
|
||||||
# Skip if gap is too small (noise) or words are overlapping
|
|
||||||
if gap_width_px <= line_height_px * 0.05:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Render space in the gap
|
|
||||||
self._render_space(
|
|
||||||
pdf,
|
|
||||||
gap_left,
|
|
||||||
gap_right,
|
|
||||||
current_word.bbox.top,
|
|
||||||
current_word.bbox.bottom,
|
|
||||||
baseline_matrix,
|
|
||||||
inv_baseline_matrix,
|
|
||||||
font_size,
|
|
||||||
rotation_deg,
|
|
||||||
line_language,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _render_space(
|
|
||||||
self,
|
|
||||||
pdf: FPDF,
|
|
||||||
gap_left_px: float,
|
|
||||||
gap_right_px: float,
|
|
||||||
gap_top_px: float,
|
|
||||||
gap_bottom_px: float,
|
|
||||||
baseline_matrix: Matrix,
|
|
||||||
inv_baseline_matrix: Matrix,
|
|
||||||
font_size: float,
|
|
||||||
rotation_deg: float,
|
|
||||||
line_language: str | None,
|
|
||||||
) -> None:
|
|
||||||
"""Render a space character in a gap between words.
|
|
||||||
|
|
||||||
Uses the same baseline transformation logic as word rendering to ensure
|
|
||||||
proper alignment on rotated or sloped baselines.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pdf: FPDF instance
|
|
||||||
gap_left_px: Left edge of gap in pixels
|
|
||||||
gap_right_px: Right edge of gap in pixels
|
|
||||||
gap_top_px: Top edge of gap in pixels
|
|
||||||
gap_bottom_px: Bottom edge of gap in pixels
|
|
||||||
baseline_matrix: Transform from baseline coords to page coords
|
|
||||||
inv_baseline_matrix: Transform from page coords to baseline coords
|
|
||||||
font_size: Font size in points
|
|
||||||
rotation_deg: Total rotation angle for text
|
|
||||||
line_language: Language code from line for font selection
|
|
||||||
"""
|
|
||||||
# Convert gap to PDF points
|
|
||||||
gap_left_pt = self.coord_transform.px_to_pt(gap_left_px)
|
|
||||||
gap_top_pt = self.coord_transform.px_to_pt(gap_top_px)
|
|
||||||
gap_right_pt = self.coord_transform.px_to_pt(gap_right_px)
|
|
||||||
gap_bottom_pt = self.coord_transform.px_to_pt(gap_bottom_px)
|
|
||||||
gap_width_pt = gap_right_pt - gap_left_pt
|
|
||||||
|
|
||||||
# Transform gap bbox into baseline coordinate system to get x position
|
|
||||||
box_llx, _, _, _ = transform_box(
|
|
||||||
inv_baseline_matrix,
|
|
||||||
gap_left_pt,
|
|
||||||
gap_top_pt,
|
|
||||||
gap_right_pt,
|
|
||||||
gap_bottom_pt,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Select font (use default font for space)
|
|
||||||
font_manager = self.multi_font_manager.select_font_for_word(" ", line_language)
|
|
||||||
font_family = self._register_font(pdf, font_manager)
|
|
||||||
|
|
||||||
# Set font
|
|
||||||
pdf.set_font(font_family, size=font_size)
|
|
||||||
|
|
||||||
# Calculate natural space width and scaling
|
|
||||||
natural_width = pdf.get_string_width(" ")
|
|
||||||
if natural_width > 0 and gap_width_pt > 0:
|
|
||||||
scale_x = (gap_width_pt / natural_width) * 100
|
|
||||||
else:
|
|
||||||
scale_x = 100
|
|
||||||
|
|
||||||
# Apply horizontal stretching
|
|
||||||
pdf.set_stretching(scale_x)
|
|
||||||
|
|
||||||
# Transform the baseline-relative x position back to page coordinates
|
|
||||||
page_x, page_y = transform_point(baseline_matrix, box_llx, 0)
|
|
||||||
|
|
||||||
# Calculate y position based on baseline (same as _render_word)
|
|
||||||
ascent, descent, _ = font_manager.get_font_metrics()
|
|
||||||
total_height = ascent + abs(descent)
|
|
||||||
baseline_offset_ratio = ascent / total_height
|
|
||||||
adjusted_y = page_y - font_size * baseline_offset_ratio
|
|
||||||
|
|
||||||
# Position and draw space with rotation
|
|
||||||
if abs(rotation_deg) > 0.1:
|
|
||||||
with pdf.rotation(-rotation_deg, x=page_x, y=page_y):
|
|
||||||
pdf.set_xy(page_x, adjusted_y)
|
|
||||||
pdf.cell(text=" ")
|
|
||||||
else:
|
|
||||||
pdf.set_xy(page_x, adjusted_y)
|
|
||||||
pdf.cell(text=" ")
|
|
||||||
|
|
||||||
# Reset stretching
|
|
||||||
pdf.set_stretching(100)
|
|
||||||
|
|
||||||
def _render_debug_line_bbox(
|
def _render_debug_line_bbox(
|
||||||
self,
|
self,
|
||||||
pdf: FPDF,
|
pdf: FPDF,
|
||||||
@@ -802,9 +858,9 @@ class Fpdf2MultiPageRenderer:
|
|||||||
|
|
||||||
# Set text mode for invisible text
|
# Set text mode for invisible text
|
||||||
if self.invisible_text:
|
if self.invisible_text:
|
||||||
pdf.text_rendering_mode = TextMode.INVISIBLE
|
pdf.text_mode = TextMode.INVISIBLE
|
||||||
else:
|
else:
|
||||||
pdf.text_rendering_mode = TextMode.FILL
|
pdf.text_mode = TextMode.FILL
|
||||||
|
|
||||||
# Shared font registration across all pages
|
# Shared font registration across all pages
|
||||||
shared_registered_fonts: dict[str, str] = {}
|
shared_registered_fonts: dict[str, str] = {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
"""Simple CLI for testing HOCR to PDF conversion using fpdf2 renderer."""
|
"""Simple CLI for testing HOCR to PDF conversion using fpdf2 renderer."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -62,7 +63,7 @@ if __name__ == "__main__":
|
|||||||
page=ocr_page,
|
page=ocr_page,
|
||||||
dpi=dpi,
|
dpi=dpi,
|
||||||
multi_font_manager=multi_font_manager,
|
multi_font_manager=multi_font_manager,
|
||||||
invisible_text=not args.boundingboxes, # Visible text in debug mode
|
invisible_text=False,
|
||||||
debug_render_options=debug_options,
|
debug_render_options=debug_options,
|
||||||
)
|
)
|
||||||
renderer.render(Path(args.outputfile))
|
renderer.render(Path(args.outputfile))
|
||||||
|
|||||||
@@ -108,10 +108,18 @@ class ImageInfo:
|
|||||||
self._type = 'image'
|
self._type = 'image'
|
||||||
|
|
||||||
self._bpc = int(pim.bits_per_component)
|
self._bpc = int(pim.bits_per_component)
|
||||||
try:
|
if (
|
||||||
self._enc = FRIENDLY_ENCODING.get(pim.filters[0])
|
len(pim.filters) == 2
|
||||||
except IndexError:
|
and pim.filters[0] == '/FlateDecode'
|
||||||
self._enc = None
|
and pim.filters[1] == '/DCTDecode'
|
||||||
|
):
|
||||||
|
# Special case: FlateDecode followed by DCTDecode
|
||||||
|
self._enc = Encoding.flate_jpeg
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self._enc = FRIENDLY_ENCODING.get(pim.filters[0])
|
||||||
|
except IndexError:
|
||||||
|
self._enc = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._color = FRIENDLY_COLORSPACE.get(pim.colorspace or '')
|
self._color = FRIENDLY_COLORSPACE.get(pim.colorspace or '')
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class Encoding(Enum):
|
|||||||
lzw = auto()
|
lzw = auto()
|
||||||
flate = auto()
|
flate = auto()
|
||||||
runlength = auto()
|
runlength = auto()
|
||||||
|
flate_jpeg = auto()
|
||||||
|
|
||||||
|
|
||||||
FloatRect = tuple[float, float, float, float]
|
FloatRect = tuple[float, float, float, float]
|
||||||
|
|||||||
@@ -81,6 +81,62 @@ def test_rasterize_rotated(francais, outdir, caplog):
|
|||||||
assert im.info['dpi'] == forced_dpi.flip_axis()
|
assert im.info['dpi'] == forced_dpi.flip_axis()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rasterize_low_dpi(francais, outdir):
|
||||||
|
"""Test that very low DPI values (below 10) produce correctly sized output.
|
||||||
|
|
||||||
|
Ghostscript may fail with DPI values below 10. The workaround renders at
|
||||||
|
a minimum of 10 DPI and resizes the output to match the expected dimensions.
|
||||||
|
"""
|
||||||
|
path, pdf = francais
|
||||||
|
page_size_pts = (pdf.pages[0].mediabox[2], pdf.pages[0].mediabox[3])
|
||||||
|
assert pdf.pages[0].mediabox[0] == pdf.pages[0].mediabox[1] == 0
|
||||||
|
page_size = (float(page_size_pts[0]) / 72, float(page_size_pts[1]) / 72)
|
||||||
|
|
||||||
|
# Request a very small output (DPI below 10 on both axes)
|
||||||
|
target_size = (5, 3)
|
||||||
|
forced_dpi = Resolution(72.0, 72.0)
|
||||||
|
|
||||||
|
rasterize_pdf(
|
||||||
|
path,
|
||||||
|
outdir / 'out_low_dpi.png',
|
||||||
|
raster_device=GhostscriptRasterDevice.PNGMONO,
|
||||||
|
raster_dpi=Resolution(
|
||||||
|
target_size[0] / page_size[0], target_size[1] / page_size[1]
|
||||||
|
),
|
||||||
|
page_dpi=forced_dpi,
|
||||||
|
)
|
||||||
|
|
||||||
|
with Image.open(outdir / 'out_low_dpi.png') as im:
|
||||||
|
assert im.size == target_size
|
||||||
|
assert im.info['dpi'] == forced_dpi
|
||||||
|
|
||||||
|
|
||||||
|
def test_rasterize_low_dpi_one_axis(francais, outdir):
|
||||||
|
"""Test low DPI on only one axis produces correctly sized output."""
|
||||||
|
path, pdf = francais
|
||||||
|
page_size_pts = (pdf.pages[0].mediabox[2], pdf.pages[0].mediabox[3])
|
||||||
|
assert pdf.pages[0].mediabox[0] == pdf.pages[0].mediabox[1] == 0
|
||||||
|
page_size = (float(page_size_pts[0]) / 72, float(page_size_pts[1]) / 72)
|
||||||
|
|
||||||
|
# Request low DPI on X axis only (below 10), normal on Y axis
|
||||||
|
target_size = (5, 50)
|
||||||
|
forced_dpi = Resolution(72.0, 72.0)
|
||||||
|
|
||||||
|
rasterize_pdf(
|
||||||
|
path,
|
||||||
|
outdir / 'out_low_dpi_x.png',
|
||||||
|
raster_device=GhostscriptRasterDevice.PNGMONO,
|
||||||
|
raster_dpi=Resolution(
|
||||||
|
target_size[0] / page_size[0], target_size[1] / page_size[1]
|
||||||
|
),
|
||||||
|
page_dpi=forced_dpi,
|
||||||
|
)
|
||||||
|
|
||||||
|
with Image.open(outdir / 'out_low_dpi_x.png') as im:
|
||||||
|
assert im.size == target_size
|
||||||
|
assert im.info['dpi'] == forced_dpi
|
||||||
|
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
@@ -42,6 +42,69 @@ def test_links(resources, outpdf):
|
|||||||
assert p2.Annots[0].A.D[0].objgen == p1.objgen
|
assert p2.Annots[0].A.D[0].objgen == p1.objgen
|
||||||
|
|
||||||
|
|
||||||
|
def test_redo_ocr_with_offset_mediabox(resources, outdir):
|
||||||
|
"""Test that --redo-ocr handles non-zero mediabox origins correctly.
|
||||||
|
|
||||||
|
Regression test for issue #1630 where PDFs with mediabox origins like
|
||||||
|
[0, 100, width, height+100] (common in cropped PDFs)
|
||||||
|
would have OCR text shifted vertically because the Form XObject BBox
|
||||||
|
used the text layer's mediabox [0, 0, w, h] instead of the base page's
|
||||||
|
mediabox [0, 100, w, h+100].
|
||||||
|
|
||||||
|
Before the fix, the BBox would be [0, 0, w, h] but the transformation
|
||||||
|
matrix would expect [0, 100, w, h+100], causing a 100pt vertical shift.
|
||||||
|
"""
|
||||||
|
# Create a PDF with a non-zero mediabox origin
|
||||||
|
input_pdf = outdir / 'offset_mediabox_input.pdf'
|
||||||
|
|
||||||
|
with pikepdf.open(resources / 'graph_ocred.pdf') as pdf:
|
||||||
|
page = pdf.pages[0]
|
||||||
|
original_mb = list(page.MediaBox)
|
||||||
|
|
||||||
|
# Shift mediabox Y origin to simulate cropped/JSTOR-style PDFs
|
||||||
|
# This is the scenario that triggers the bug
|
||||||
|
y_offset = 100
|
||||||
|
page.MediaBox = [
|
||||||
|
original_mb[0],
|
||||||
|
original_mb[1] + y_offset,
|
||||||
|
original_mb[2],
|
||||||
|
original_mb[3] + y_offset,
|
||||||
|
]
|
||||||
|
|
||||||
|
pdf.save(input_pdf)
|
||||||
|
|
||||||
|
# Run --redo-ocr (this is where the bug occurred)
|
||||||
|
output_pdf = outdir / 'offset_redo_ocr.pdf'
|
||||||
|
ocrmypdf.ocr(input_pdf, output_pdf, redo_ocr=True)
|
||||||
|
|
||||||
|
# Verify the output
|
||||||
|
with pikepdf.open(output_pdf) as pdf:
|
||||||
|
page = pdf.pages[0]
|
||||||
|
mediabox = list(page.MediaBox)
|
||||||
|
|
||||||
|
# MediaBox origin should be preserved
|
||||||
|
assert (
|
||||||
|
float(mediabox[1]) == 100.0
|
||||||
|
), f"MediaBox Y origin should be preserved at 100, got {mediabox[1]}"
|
||||||
|
|
||||||
|
# MediaBox should have valid dimensions
|
||||||
|
width = float(mediabox[2]) - float(mediabox[0])
|
||||||
|
height = float(mediabox[3]) - float(mediabox[1])
|
||||||
|
assert width > 0 and height > 0, "MediaBox should have positive dimensions"
|
||||||
|
|
||||||
|
# Text content should be present
|
||||||
|
# With the fix, OCR text layer coordinates will be correct
|
||||||
|
# Without the fix, the text would be shifted outside the visible area
|
||||||
|
text_content = page.Contents.read_bytes()
|
||||||
|
assert len(text_content) > 0, "Page should have content"
|
||||||
|
|
||||||
|
# The fix ensures text operators are present and positioned correctly
|
||||||
|
# (BT/ET mark text blocks in PDF)
|
||||||
|
assert (
|
||||||
|
b'BT' in text_content or b'/Im' in text_content
|
||||||
|
), "Content should include text operators or image references"
|
||||||
|
|
||||||
|
|
||||||
def test_strip_invisble_text():
|
def test_strip_invisble_text():
|
||||||
pdf = pikepdf.Pdf.new()
|
pdf = pikepdf.Pdf.new()
|
||||||
print(pikepdf.parse_content_stream(pikepdf.Stream(pdf, b'3 Tr')))
|
print(pikepdf.parse_content_stream(pikepdf.Stream(pdf, b'3 Tr')))
|
||||||
|
|||||||
@@ -131,6 +131,55 @@ def test_jpeg(resources):
|
|||||||
assert isclose(pdfimage.dpi.x, 150)
|
assert isclose(pdfimage.dpi.x, 150)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def flate_jpeg_pdf(outpdf):
|
||||||
|
"""Create a PDF with a FlateDecode+DCTDecode (flate+jpeg) encoded image.
|
||||||
|
|
||||||
|
This simulates what OCRmyPDF's optimizer does when it deflates JPEGs.
|
||||||
|
"""
|
||||||
|
from zlib import compress
|
||||||
|
|
||||||
|
# Create an RGB image and save as JPEG
|
||||||
|
im = Image.new('RGB', (64, 64), color=(128, 64, 192))
|
||||||
|
bio = BytesIO()
|
||||||
|
im.save(bio, format='JPEG')
|
||||||
|
jpeg_data = bio.getvalue()
|
||||||
|
|
||||||
|
# Compress the JPEG data with flate
|
||||||
|
flate_jpeg_data = compress(jpeg_data)
|
||||||
|
|
||||||
|
# Create a PDF with the flate+jpeg image
|
||||||
|
with pikepdf.Pdf.new() as pdf:
|
||||||
|
pdf.add_blank_page(page_size=(72, 72))
|
||||||
|
image_dict = pikepdf.Stream(
|
||||||
|
pdf,
|
||||||
|
flate_jpeg_data,
|
||||||
|
BitsPerComponent=8,
|
||||||
|
ColorSpace=pikepdf.Name.DeviceRGB,
|
||||||
|
Filter=[pikepdf.Name.FlateDecode, pikepdf.Name.DCTDecode],
|
||||||
|
Height=64,
|
||||||
|
Subtype=pikepdf.Name.Image,
|
||||||
|
Type=pikepdf.Name.XObject,
|
||||||
|
Width=64,
|
||||||
|
)
|
||||||
|
objname = pdf.pages[0].add_resource(
|
||||||
|
image_dict, pikepdf.Name.XObject, pikepdf.Name.Im0
|
||||||
|
)
|
||||||
|
pdf.pages[0].Contents = pikepdf.Stream(
|
||||||
|
pdf, b"q 72 0 0 72 0 0 cm %s Do Q" % bytes(objname)
|
||||||
|
)
|
||||||
|
pdf.save(outpdf)
|
||||||
|
return outpdf
|
||||||
|
|
||||||
|
|
||||||
|
def test_flate_jpeg(flate_jpeg_pdf):
|
||||||
|
"""Test that pdfinfo correctly identifies FlateDecode+DCTDecode as flate_jpeg."""
|
||||||
|
pdf = pdfinfo.PdfInfo(flate_jpeg_pdf)
|
||||||
|
|
||||||
|
pdfimage = pdf[0].images[0]
|
||||||
|
assert pdfimage.enc == Encoding.flate_jpeg
|
||||||
|
|
||||||
|
|
||||||
def test_form_xobject(resources):
|
def test_form_xobject(resources):
|
||||||
filename = resources / 'formxobject.pdf'
|
filename = resources / 'formxobject.pdf'
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from reportlab.pdfgen.canvas import Canvas
|
|||||||
|
|
||||||
from ocrmypdf import _pipeline, pdfinfo
|
from ocrmypdf import _pipeline, pdfinfo
|
||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
|
from ocrmypdf.pdfinfo import Encoding
|
||||||
|
|
||||||
warnings.filterwarnings(
|
warnings.filterwarnings(
|
||||||
"ignore", category=DeprecationWarning, module="reportlab.lib.rl_safe_eval"
|
"ignore", category=DeprecationWarning, module="reportlab.lib.rl_safe_eval"
|
||||||
@@ -150,3 +151,28 @@ def test_dpi_needed(image, text, vector, result, rgb_image, outdir):
|
|||||||
)
|
)
|
||||||
def test_enumerate_compress_ranges(name, input, output):
|
def test_enumerate_compress_ranges(name, input, output):
|
||||||
assert output == tuple(_pipeline.enumerate_compress_ranges(input))
|
assert output == tuple(_pipeline.enumerate_compress_ranges(input))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'encodings, expected',
|
||||||
|
[
|
||||||
|
# Empty images list returns False
|
||||||
|
([], False),
|
||||||
|
# Single JPEG returns True
|
||||||
|
([Encoding.jpeg], True),
|
||||||
|
# Single flate_jpeg returns True
|
||||||
|
([Encoding.flate_jpeg], True),
|
||||||
|
# Mix of jpeg and flate_jpeg returns True
|
||||||
|
([Encoding.jpeg, Encoding.flate_jpeg], True),
|
||||||
|
# Non-JPEG encoding returns False
|
||||||
|
([Encoding.flate], False),
|
||||||
|
# Mix with non-JPEG returns False
|
||||||
|
([Encoding.jpeg, Encoding.flate], False),
|
||||||
|
([Encoding.flate_jpeg, Encoding.flate], False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_should_visible_page_image_use_jpg(encodings, expected):
|
||||||
|
"""Test that should_visible_page_image_use_jpg correctly handles flate_jpeg."""
|
||||||
|
pageinfo = Mock()
|
||||||
|
pageinfo.images = [Mock(enc=enc) for enc in encodings]
|
||||||
|
assert _pipeline.should_visible_page_image_use_jpg(pageinfo) == expected
|
||||||
|
|||||||
@@ -22,3 +22,29 @@ def test_force_tagged_warns(resources, outpdf, caplog):
|
|||||||
plugins=['tests/plugins/tesseract_noop.py'],
|
plugins=['tests/plugins/tesseract_noop.py'],
|
||||||
)
|
)
|
||||||
assert 'marked as a Tagged PDF' in caplog.text
|
assert 'marked as a Tagged PDF' in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_tagged_pdf_mode_ignore_with_skip_text(resources, outpdf, caplog):
|
||||||
|
"""Ignore tagged_pdf_mode should warn but not error."""
|
||||||
|
caplog.set_level('WARNING')
|
||||||
|
ocrmypdf.ocr(
|
||||||
|
resources / 'tagged.pdf',
|
||||||
|
outpdf,
|
||||||
|
tagged_pdf_mode='ignore',
|
||||||
|
skip_text=True, # Tagged PDF has text, so skip pages with text
|
||||||
|
plugins=['tests/plugins/tesseract_noop.py'],
|
||||||
|
)
|
||||||
|
assert 'marked as a Tagged PDF' in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_tagged_pdf_mode_ignore_with_force(resources, outpdf, caplog):
|
||||||
|
"""Ignore tagged_pdf_mode with force mode should warn."""
|
||||||
|
caplog.set_level('WARNING')
|
||||||
|
ocrmypdf.ocr(
|
||||||
|
resources / 'tagged.pdf',
|
||||||
|
outpdf,
|
||||||
|
tagged_pdf_mode='ignore',
|
||||||
|
force_ocr=True,
|
||||||
|
plugins=['tests/plugins/tesseract_noop.py'],
|
||||||
|
)
|
||||||
|
assert 'marked as a Tagged PDF' in caplog.text
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from ocrmypdf._exec import unpaper
|
from ocrmypdf._exec import unpaper
|
||||||
from ocrmypdf._validation import check_options
|
from ocrmypdf._validation import check_options
|
||||||
from ocrmypdf.cli import get_options_and_plugins
|
from ocrmypdf.cli import get_options_and_plugins
|
||||||
from ocrmypdf.exceptions import BadArgsError, ExitCode, MissingDependencyError
|
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
|
||||||
|
|
||||||
from .conftest import check_ocrmypdf, have_unpaper, run_ocrmypdf_api
|
from .conftest import check_ocrmypdf, have_unpaper, run_ocrmypdf_api
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ def test_unpaper_args_valid(resources, outpdf):
|
|||||||
|
|
||||||
@needs_unpaper
|
@needs_unpaper
|
||||||
def test_unpaper_args_invalid_filename(resources, outpdf, caplog):
|
def test_unpaper_args_invalid_filename(resources, outpdf, caplog):
|
||||||
with pytest.raises(BadArgsError):
|
with pytest.raises(ValidationError, match="No filenames allowed"):
|
||||||
run_ocrmypdf_api(
|
run_ocrmypdf_api(
|
||||||
resources / "skew.pdf",
|
resources / "skew.pdf",
|
||||||
outpdf,
|
outpdf,
|
||||||
|
|||||||
Reference in New Issue
Block a user