Compare commits
27
Commits
release/v17
...
v17.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da952a23d | ||
|
|
716ce6324c | ||
|
|
76fe2f7e28 | ||
|
|
c85c8941d3 | ||
|
|
9a0dadbd4c | ||
|
|
4d7e398c4b | ||
|
|
56c0b41f97 | ||
|
|
5c83dab8a7 | ||
|
|
e62e73e441 | ||
|
|
d68e2f6e34 | ||
|
|
1684982cde | ||
|
|
4d97dfd218 | ||
|
|
a35fcc9c43 | ||
|
|
3dd4cde7ce | ||
|
|
92beb474a5 | ||
|
|
9dcd882c83 | ||
|
|
9d8aa5a0c3 | ||
|
|
e036a902ae | ||
|
|
0a980fb11b | ||
|
|
3abe8f71c7 | ||
|
|
64f45b7fdb | ||
|
|
7e939ad44d | ||
|
|
297fb786a0 | ||
|
|
ad30dd94f7 | ||
|
|
e77f79ac6f | ||
|
|
c84fc56e45 | ||
|
|
8930efe787 |
+10
-8
@@ -15,11 +15,13 @@ sphinx:
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.11"
|
||||
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
python: "3.13"
|
||||
jobs:
|
||||
pre_create_environment:
|
||||
- asdf plugin add uv
|
||||
- asdf install uv latest
|
||||
- asdf global uv latest
|
||||
create_environment:
|
||||
- uv venv "${READTHEDOCS_VIRTUALENV_PATH}"
|
||||
install:
|
||||
- UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs
|
||||
@@ -84,12 +84,12 @@ For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/la
|
||||
OCRmyPDF uses Tesseract for OCR, and relies on its language packs. For Linux users, you can often find packages that provide language packs:
|
||||
|
||||
```bash
|
||||
# Display a list of all Tesseract language packs
|
||||
apt-cache search tesseract-ocr
|
||||
|
||||
# Debian/Ubuntu users
|
||||
apt-cache search tesseract-ocr # Display a list of all Tesseract language packs
|
||||
apt-get install tesseract-ocr-chi-sim # Example: Install Chinese Simplified language pack
|
||||
|
||||
|
||||
# Arch Linux users
|
||||
pacman -S tesseract-data-eng tesseract-data-deu # Example: Install the English and German language packs
|
||||
|
||||
@@ -99,6 +99,12 @@ pkg_add tesseract-cym # Example: Install the Welsh language pack
|
||||
|
||||
# brew macOS users
|
||||
brew install tesseract-lang
|
||||
|
||||
# Fedora users
|
||||
dnf search tesseract-langpack # Display a list of all Tesseract language packs
|
||||
dnf install tesseract-langpack-ita # Example: Install the Italian language pack
|
||||
|
||||
|
||||
```
|
||||
|
||||
You can then pass the `-l LANG` argument to OCRmyPDF to give a hint as to what languages it should search for. Multiple languages can be requested.
|
||||
|
||||
+173
-150
@@ -1,42 +1,42 @@
|
||||
---
|
||||
myst:
|
||||
substitutions:
|
||||
deb_11: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/debian_11/ocrmypdf.svg
|
||||
:alt: Debian 11
|
||||
:::
|
||||
deb_12: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/debian_12/ocrmypdf.svg
|
||||
:alt: Debian 12
|
||||
:::
|
||||
deb_13: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/debian_13/ocrmypdf.svg
|
||||
:alt: Debian 13
|
||||
:::
|
||||
deb_unstable: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg
|
||||
:alt: Debian unstable
|
||||
:::
|
||||
fedora_38: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/fedora_38/ocrmypdf.svg
|
||||
:alt: Fedora 38
|
||||
fedora_40: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/fedora_40/ocrmypdf.svg
|
||||
:alt: Fedora 40
|
||||
:::
|
||||
fedora_39: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/fedora_39/ocrmypdf.svg
|
||||
:alt: Fedora 39
|
||||
fedora_41: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/fedora_41/ocrmypdf.svg
|
||||
:alt: Fedora 41
|
||||
:::
|
||||
fedora_rawhide: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
||||
:alt: Fedore Rawhide
|
||||
:alt: Fedora Rawhide
|
||||
:::
|
||||
latest: |-
|
||||
:::{image} https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||
: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: |-
|
||||
:::{image} https://repology.org/badge/version-for-repo/ubuntu_22_04/ocrmypdf.svg
|
||||
: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
|
||||
@@ -54,18 +54,16 @@ These platforms have one-liner installs:
|
||||
:::{list-table}
|
||||
:header-rows: 0
|
||||
|
||||
* - Homebrew (macOS and Linux)
|
||||
- ``brew install ocrmypdf``
|
||||
* - Debian, Ubuntu
|
||||
- ``apt install ocrmypdf``
|
||||
* - Windows Subsystem for Linux
|
||||
- ``apt install ocrmypdf``
|
||||
* - Fedora
|
||||
- ``dnf install ocrmypdf tesseract-osd``
|
||||
* - macOS (Homebrew)
|
||||
- ``brew install ocrmypdf``
|
||||
* - macOS (MacPorts)
|
||||
- ``port install ocrmypdf``
|
||||
* - LinuxBrew
|
||||
- ``brew install ocrmypdf``
|
||||
* - FreeBSD
|
||||
- ``pkg install textproc/py-ocrmypdf``
|
||||
* - Snap (snapcraft packaging)
|
||||
@@ -82,15 +80,15 @@ install, or install a more recent version than your platform provides, read on.
|
||||
|
||||
## Installing on Linux
|
||||
|
||||
### Debian and Ubuntu 20.04 or newer
|
||||
### Debian and Ubuntu 22.04 or newer
|
||||
|
||||
:::{list-table}
|
||||
:header-rows: 1
|
||||
|
||||
* - OCRmyPDF versions in Debian & Ubuntu
|
||||
* - {{ latest }}
|
||||
* - {{ deb_11 }} {{ deb_12 }} {{ deb_unstable }}
|
||||
* - {{ ubu_2004 }} {{ ubu_2204 }}
|
||||
* - {{ deb_12 }} {{ deb_13 }} {{ deb_unstable }}
|
||||
* - {{ ubu_2204 }} {{ ubu_2404 }}
|
||||
:::
|
||||
|
||||
Users of Debian or Ubuntu may simply
|
||||
@@ -112,9 +110,9 @@ For full details on version availability for your platform, check the
|
||||
:::{note}
|
||||
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder.
|
||||
OCRmyPDF works fine without it but will produce larger output files.
|
||||
If you build jbig2enc from source, ocrmypdf will
|
||||
automatically detect it (specifically the `jbig2` binary) on the
|
||||
`PATH`. To add JBIG2 encoding, see {ref}`jbig2`.
|
||||
All JBIG2 patents expired in 2017, so if you build jbig2enc from source,
|
||||
OCRmyPDF will automatically detect it on the `PATH`.
|
||||
To add JBIG2 encoding, see {ref}`jbig2`.
|
||||
:::
|
||||
|
||||
### Fedora
|
||||
@@ -124,7 +122,7 @@ automatically detect it (specifically the `jbig2` binary) on the
|
||||
|
||||
* - OCRmyPDF version
|
||||
* - {{latest}}
|
||||
* - {{fedora_38}} {{fedora_39}} {{fedora_rawhide}}
|
||||
* - {{fedora_40}} {{fedora_41}} {{fedora_rawhide}}
|
||||
:::
|
||||
|
||||
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).
|
||||
|
||||
:::{note}
|
||||
OCRmyPDF for Fedora currently omits the JBIG2 encoder due to patent
|
||||
issues. OCRmyPDF works fine without it but will produce larger output
|
||||
files. If you build jbig2enc from source, ocrmypdf 7.0.0 and later
|
||||
will automatically detect it on the `PATH`. To add JBIG2 encoding,
|
||||
see {ref}`Installing the JBIG2 encoder <jbig2>`.
|
||||
OCRmyPDF for Fedora currently omits the JBIG2 encoder. All JBIG2 patents
|
||||
expired in 2017. OCRmyPDF works fine without it but will produce larger
|
||||
output files. If you build jbig2enc from source, OCRmyPDF will automatically
|
||||
detect it on the `PATH`. To add JBIG2 encoding, see {ref}`jbig2`.
|
||||
:::
|
||||
|
||||
(ubuntu-lts-latest)=
|
||||
|
||||
### RHEL 9
|
||||
|
||||
Prepare the environment by getting Python 3.11:
|
||||
Prepare the environment by getting Python 3.12:
|
||||
|
||||
```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:
|
||||
@@ -167,42 +164,47 @@ dnf install ghostscript tesseract
|
||||
and build ocrmypdf in virtual environment:
|
||||
|
||||
```bash
|
||||
python3.11 -m venv .venv
|
||||
python3.12 -m venv .venv
|
||||
```
|
||||
|
||||
To add JBIG2 encoding, see {ref}`Installing the JBIG2 encoder <jbig2>`.
|
||||
|
||||
Note Fedora packages for language data haven't been branched for RHEL/EPEL, but you can get traineddata files directly from [tesseract](https://github.com/tesseract-ocr/tessdata/) and place them in `/usr/share/tesseract/tessdata`.
|
||||
|
||||
### Installing the latest version on Ubuntu 22.04 LTS
|
||||
### Installing the latest version on Ubuntu 22.04/24.04 LTS
|
||||
|
||||
Ubuntu 22.04 includes ocrmypdf 13.4.0 - you can install that with
|
||||
`apt install ocrmypdf`. To install a more recent version for the current
|
||||
user, follow these steps:
|
||||
Ubuntu includes an older version of OCRmyPDF - you can install that with
|
||||
`apt install ocrmypdf`. To install the latest version, we recommend using uv:
|
||||
|
||||
```bash
|
||||
# Install system dependencies first
|
||||
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
|
||||
'/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.
|
||||
Alternatively, use Homebrew on Linux for a full-featured installation (see below).
|
||||
|
||||
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
|
||||
most convenient way to install recent OCRmyPDF on older Ubuntu is to use
|
||||
Homebrew on Linux (Linuxbrew).
|
||||
:::{note}
|
||||
Ubuntu 20.04 is approaching end of life. Consider upgrading to Ubuntu 22.04 or 24.04 LTS.
|
||||
:::
|
||||
|
||||
For older distributions, the most convenient way to install a recent version of
|
||||
OCRmyPDF is to use Homebrew on Linux:
|
||||
|
||||
```bash
|
||||
brew install ocrmypdf
|
||||
```
|
||||
|
||||
See {ref}`homebrew-linux` for more information on using Homebrew on Linux.
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
:::{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
|
||||
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
|
||||
:alt: homebrew
|
||||
:target: https://formulae.brew.sh/formula/ocrmypdf
|
||||
:::
|
||||
|
||||
OCRmyPDF is now a standard [Homebrew](https://brew.sh) formula. To
|
||||
install on macOS:
|
||||
[Homebrew](https://brew.sh) provides a full-featured OCRmyPDF installation
|
||||
on both macOS and Linux with all recommended dependencies. This is often
|
||||
the easiest way to get a complete, up-to-date installation.
|
||||
|
||||
```bash
|
||||
brew install ocrmypdf
|
||||
```
|
||||
|
||||
This will include only the English language pack. If you need other
|
||||
languages you can optionally install them all:
|
||||
This includes Tesseract, Ghostscript, and all required dependencies. English
|
||||
language support is included by default. For other languages:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
:::{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
|
||||
:::
|
||||
|
||||
OCRmyPDF is includes in MacPorts:
|
||||
OCRmyPDF is included in MacPorts:
|
||||
|
||||
```bash
|
||||
sudo port install ocrmypdf
|
||||
@@ -341,14 +359,13 @@ the appropriate tesseract [language ports](https://ports.macports.org/search/?se
|
||||
|
||||
### Manual installation on macOS
|
||||
|
||||
These instructions probably work on all macOS supported by Homebrew, and are
|
||||
for installing a more current version of OCRmyPDF than is available from
|
||||
Homebrew. Note that the Homebrew versions usually track the release versions
|
||||
fairly closely.
|
||||
These instructions are for installing a more current version of OCRmyPDF than
|
||||
is available from Homebrew. Note that Homebrew versions usually track
|
||||
releases fairly closely.
|
||||
|
||||
If it's not already present, [install Homebrew](http://brew.sh/).
|
||||
|
||||
Update Homebrew:
|
||||
Update Homebrew and install dependencies:
|
||||
|
||||
```bash
|
||||
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
|
||||
> ```
|
||||
|
||||
Update the homebrew pip:
|
||||
Install uv and OCRmyPDF:
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
```
|
||||
|
||||
You can then install OCRmyPDF from PyPI for the current user:
|
||||
|
||||
```bash
|
||||
pip install --user ocrmypdf
|
||||
pip install uv
|
||||
uv pip install --user ocrmypdf
|
||||
```
|
||||
|
||||
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/)
|
||||
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`
|
||||
|
||||
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)
|
||||
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}
|
||||
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 uv (recommended)
|
||||
|
||||
OCRmyPDF is delivered by PyPI because it is a convenient way to install
|
||||
the latest version. However, PyPI and `pip` cannot address the fact
|
||||
that `ocrmypdf` depends on certain non-Python system libraries and
|
||||
programs being installed.
|
||||
We recommend using [uv](https://docs.astral.sh/uv/) for installing OCRmyPDF from PyPI.
|
||||
uv is a fast, modern Python package manager that provides better dependency resolution
|
||||
and consistent behavior across all platforms.
|
||||
|
||||
For best results, first install [your platform's
|
||||
version](https://repology.org/metapackage/ocrmypdf/versions) of
|
||||
`ocrmypdf`, using the instructions elsewhere in this document. Then
|
||||
you can use `pip` to get the latest version if your platform version
|
||||
is out of date. Chances are that this will satisfy most dependencies.
|
||||
`ocrmypdf` using the instructions elsewhere in this document to satisfy system
|
||||
dependencies. Then use uv to get the latest OCRmyPDF version.
|
||||
|
||||
```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.
|
||||
|
||||
Then you can install the latest OCRmyPDF from the Python wheels. First
|
||||
try:
|
||||
### Installing with pip
|
||||
|
||||
If you prefer pip, you can still use it:
|
||||
|
||||
```bash
|
||||
pip install --user ocrmypdf
|
||||
@@ -576,21 +593,20 @@ pip install --user ocrmypdf
|
||||
(If the message appears `Requirement already satisfied: ocrmypdf in...`,
|
||||
you will need to use `pip install --user --upgrade ocrmypdf`.)
|
||||
|
||||
You should then be able to run `ocrmypdf --version` and see that the
|
||||
latest version was located.
|
||||
### Installing with pipx
|
||||
|
||||
## 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
|
||||
satisfy all non-Python dependencies. Then if pipx is installed, you
|
||||
can use
|
||||
```bash
|
||||
pipx install ocrmypdf
|
||||
```
|
||||
|
||||
Or run without permanent installation:
|
||||
|
||||
```bash
|
||||
pipx run ocrmypdf
|
||||
```
|
||||
|
||||
(If not installed, pipx will install first.)
|
||||
|
||||
(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:
|
||||
|
||||
- Python 3.11 or newer
|
||||
- Python 3.11 or newer (3.12+ recommended)
|
||||
- Tesseract 4.1.1 or newer
|
||||
- One of: Ghostscript 9.54+ **or** pypdfium2 (Python package)
|
||||
- One of: Ghostscript 9.54+ **or** verapdf (for PDF/A output)
|
||||
- fpdf2 2.8 or newer (Python package)
|
||||
- uharfbuzz (Python package)
|
||||
- fonts-noto or equivalent (system package, recommended)
|
||||
- jbig2enc 0.29 or newer (optional)
|
||||
- pngquant 2.5 or newer (optional)
|
||||
- unpaper 6.1 (optional)
|
||||
|
||||
:::{note}
|
||||
For the best user experience, install both Ghostscript and pypdfium2.
|
||||
pypdfium2 is faster for rasterization, while Ghostscript provides
|
||||
broader compatibility and is required for certain PDF/A conversions.
|
||||
For the best user experience, install both Ghostscript and pypdfium2. pypdfium2 is
|
||||
faster for rasterization, while Ghostscript provides is required for certain PDF/A
|
||||
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
|
||||
supported, although on Linux, they may still work.)
|
||||
|
||||
**fpdf2** is a required dependency that provides the text layer
|
||||
rendering engine. It replaces the legacy hOCR-based renderer with improved
|
||||
multilingual support. Install with: `pip install fpdf2`
|
||||
**fpdf2** and **uharfbuzz** are required dependencies that provide the text
|
||||
layer rendering engine. fpdf2 generates the PDF text layer, while uharfbuzz
|
||||
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
|
||||
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
|
||||
monochrome images. This can significantly reduce the file size of the
|
||||
output file. It is not required.
|
||||
[jbig2enc](https://github.com/agl/jbig2enc) is not generally
|
||||
available for Ubuntu or Debian due to lingering concerns about patent
|
||||
issues, but can easily be built from source. To add JBIG2 encoding, see
|
||||
{ref}`jbig2`.
|
||||
[jbig2enc](https://github.com/agl/jbig2enc) is not available in some
|
||||
distributions due to historical patent concerns, but all JBIG2 patents
|
||||
expired in 2017. It can easily be built from source. To add JBIG2 encoding,
|
||||
see {ref}`jbig2`.
|
||||
|
||||
:::{warning}
|
||||
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
|
||||
|
||||
If you have `git` and Python 3.11 or newer installed, you can install
|
||||
from source. When the `pip` installer runs, it will alert you if
|
||||
If you have `git` and Python 3.12 or newer installed, you can install
|
||||
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.
|
||||
|
||||
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).
|
||||
First ensure you can build and install pikepdf.
|
||||
|
||||
To install the HEAD revision from sources in the current Python 3
|
||||
environment:
|
||||
We recommend using uv to install from sources:
|
||||
|
||||
```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
|
||||
pip install git+https://github.com/ocrmypdf/OCRmyPDF.git
|
||||
```
|
||||
|
||||
Or, to install in editable mode
|
||||
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:
|
||||
Or, to install in editable mode allowing customization:
|
||||
|
||||
```bash
|
||||
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
cd OCRmyPDF
|
||||
pip install .
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
However, `ocrmypdf` will only be accessible on the system PATH when
|
||||
you activate the virtual environment.
|
||||
Note: `ocrmypdf` will only be accessible when the virtual environment
|
||||
is activated.
|
||||
|
||||
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 webservice # Streamlit web UI
|
||||
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
|
||||
# Testing infrastructure
|
||||
@@ -754,11 +794,6 @@ uv sync --group streamlit-dev
|
||||
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?**
|
||||
|
||||
- Modern, fast Python package manager
|
||||
@@ -766,7 +801,7 @@ uv sync
|
||||
- Better dependency resolution
|
||||
- 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
|
||||
|
||||
@@ -775,12 +810,9 @@ To install all of the development and test requirements:
|
||||
```bash
|
||||
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
||||
cd OCRmyPDF
|
||||
pip install uv # Install uv if not already installed
|
||||
uv sync --group test
|
||||
uv sync --all-groups
|
||||
```
|
||||
|
||||
Note: Development requires `uv`. The old `pip install -e .[test]` method is no longer supported.
|
||||
|
||||
To add JBIG2 encoding, see {ref}`jbig2`.
|
||||
|
||||
## Shell completions
|
||||
@@ -800,14 +832,5 @@ To manually install the `fish` completion, copy
|
||||
|
||||
## Note on 32-bit support
|
||||
|
||||
Many Python libraries no longer provide 32-bit binary wheels for Linux. This
|
||||
includes many of the libraries that OCRmyPDF depends on, such as
|
||||
Pillow. The easiest way to express this to end users is to say we don't
|
||||
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.
|
||||
We don't support any 32-bit system, including 32-bit Python or 32-bit
|
||||
Ghostscript on Windows.
|
||||
+30
-1
@@ -25,7 +25,36 @@ about a forthcoming release that has not been tagged yet. A release is only
|
||||
official when it's tagged and posted to PyPI.
|
||||
:::
|
||||
|
||||
## v17.0.0 (unreleased)
|
||||
|
||||
## 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
|
||||
|
||||
- Fixed output file size inflation when using pypdfium as rasterizer and force-ocr
|
||||
mode.
|
||||
|
||||
## v17.0.0
|
||||
|
||||
**Breaking changes**
|
||||
|
||||
|
||||
@@ -21,18 +21,18 @@ __ocrmypdf_arguments()
|
||||
--subject (set metadata)
|
||||
--keywords (set metadata)
|
||||
--rotate-pages (rotate pages to correct orientation)
|
||||
--remove-background (attempt to remove background from pages)
|
||||
--deskew (fix small horizontal alignment skew)
|
||||
--clean (clean document images before OCR)
|
||||
--clean-final (clean document images and keep result)
|
||||
--unpaper-args (a quoted string of arguments to pass to unpaper)
|
||||
--oversample (oversample images to this DPI)
|
||||
--remove-vectors (don\'t send vector objects to OCR)
|
||||
--threshold (threshold images before OCR)
|
||||
--mode (processing mode for pages with existing text)
|
||||
--force-ocr (OCR documents that already have printable 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)
|
||||
--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)
|
||||
--optimize (select optimization level)
|
||||
--jpeg-quality (JPEG quality [0..100])
|
||||
@@ -42,9 +42,12 @@ __ocrmypdf_arguments()
|
||||
--pages (apply OCR to only the specified pages)
|
||||
--max-image-mpixels (image decompression bomb threshold)
|
||||
--pdf-renderer (select PDF renderer options)
|
||||
--ocr-engine (OCR engine to use)
|
||||
--rasterizer (PDF page rasterizer)
|
||||
--rotate-pages-threshold (page rotation confidence)
|
||||
--pdfa-image-compression (set PDF/A image compression options)
|
||||
--fast-web-view (if file size if above this amount in MB linearize PDF)
|
||||
--continue-on-soft-render-error (continue after recoverable render errors)
|
||||
--plugin (name of plugin to import)
|
||||
--keep-temporary-files (keep temporary files (debug)
|
||||
--tesseract-config (set custom tesseract config file)
|
||||
@@ -52,6 +55,10 @@ __ocrmypdf_arguments()
|
||||
--tesseract-oem (set tesseract --oem)
|
||||
--tesseract-thresholding (set tesseract image thresholding)
|
||||
--tesseract-timeout (maximum number of seconds to wait for OCR)
|
||||
--tesseract-non-ocr-timeout (maximum seconds for non-OCR operations)
|
||||
--tesseract-downsample-large-images (downsample large images before OCR)
|
||||
--no-tesseract-downsample-large-images (do not downsample large images)
|
||||
--tesseract-downsample-above (downsample images larger than this pixel size)
|
||||
--user-words (specify location of user words file)
|
||||
--user-patterns (specify location of user patterns file)
|
||||
--no-progress-bar (disable the progress bar)
|
||||
@@ -68,7 +75,8 @@ __ocrmypdf_arguments()
|
||||
|
||||
__ocrmypdf_output-type()
|
||||
{
|
||||
local choices="pdfa (output a PDF/A (default))
|
||||
local choices="auto (best-effort PDF/A without Ghostscript (default))
|
||||
pdfa (output a PDF/A-2b)
|
||||
pdf (output a standard PDF)
|
||||
pdfa-1 (output a PDF/A-1b)
|
||||
pdfa-2 (output a PDF/A-2b)
|
||||
@@ -114,10 +122,11 @@ __ocrmypdf_optimize()
|
||||
|
||||
__ocrmypdf_pdf-renderer()
|
||||
{
|
||||
local choices="auto (auto select PDF renderer)
|
||||
hocr (use hOCR renderer)
|
||||
hocrdebug (uses hOCR renderer in debug mode, showing recognized text)
|
||||
sandwich (use sandwich renderer)"
|
||||
local choices="auto (auto select PDF renderer, uses fpdf2)
|
||||
fpdf2 (use fpdf2 renderer with full language support)
|
||||
sandwich (use sandwich renderer)
|
||||
hocr (use hOCR renderer - deprecated)
|
||||
hocrdebug (uses hOCR renderer in debug mode - deprecated)"
|
||||
|
||||
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
|
||||
|
||||
@@ -210,6 +219,58 @@ UseDeviceIndependentColor (convert with device independent color)"
|
||||
fi
|
||||
}
|
||||
|
||||
__ocrmypdf_mode()
|
||||
{
|
||||
local choices="default (error if text is found)
|
||||
force (rasterize all content and run OCR)
|
||||
skip (skip pages with existing text)
|
||||
redo (re-OCR pages, replacing old invisible text)"
|
||||
|
||||
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
|
||||
# Remove description if only one completion exists
|
||||
if [[ ${#COMPREPLY[*]} -eq 1 ]]; then
|
||||
COMPREPLY=( ${COMPREPLY[0]%% *} )
|
||||
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()
|
||||
{
|
||||
local choices="auto (select best available engine)
|
||||
tesseract (use Tesseract OCR)
|
||||
none (skip OCR entirely)"
|
||||
|
||||
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
|
||||
# Remove description if only one completion exists
|
||||
if [[ ${#COMPREPLY[*]} -eq 1 ]]; then
|
||||
COMPREPLY=( ${COMPREPLY[0]%% *} )
|
||||
fi
|
||||
}
|
||||
|
||||
__ocrmypdf_rasterizer()
|
||||
{
|
||||
local choices="auto (prefer pypdfium, fall back to Ghostscript)
|
||||
ghostscript (use Ghostscript rasterizer)
|
||||
pypdfium (use pypdfium rasterizer - faster)"
|
||||
|
||||
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
|
||||
# Remove description if only one completion exists
|
||||
if [[ ${#COMPREPLY[*]} -eq 1 ]]; then
|
||||
COMPREPLY=( ${COMPREPLY[0]%% *} )
|
||||
fi
|
||||
}
|
||||
|
||||
__ocrmypdf_check_previous()
|
||||
{
|
||||
case $prev in
|
||||
@@ -241,6 +302,22 @@ __ocrmypdf_check_previous()
|
||||
__ocrmypdf_pdf-renderer
|
||||
return 0
|
||||
;;
|
||||
-m|--mode)
|
||||
__ocrmypdf_mode
|
||||
return 0
|
||||
;;
|
||||
--tagged-pdf-mode)
|
||||
__ocrmypdf_tagged-pdf-mode
|
||||
return 0
|
||||
;;
|
||||
--ocr-engine)
|
||||
__ocrmypdf_ocr-engine
|
||||
return 0
|
||||
;;
|
||||
--rasterizer)
|
||||
__ocrmypdf_rasterizer
|
||||
return 0
|
||||
;;
|
||||
--pdfa-image-compression)
|
||||
__ocrmypdf_pdfa-image-compression
|
||||
return 0
|
||||
@@ -260,7 +337,8 @@ __ocrmypdf_check_previous()
|
||||
|
||||
--title|--author|--subject|--keywords|--unpaper-args|--pages|--plugin|\
|
||||
--jpeg-quality|--png-quality|--image-dpi|--oversample|--skip-big|--max-image-mpixels|\
|
||||
--tesseract-timeout|--rotate-pages-threshold|--fast-web-view)
|
||||
--tesseract-timeout|--tesseract-non-ocr-timeout|--tesseract-downsample-above|\
|
||||
--rotate-pages-threshold|--fast-web-view)
|
||||
# argument required but no completions available
|
||||
return 0
|
||||
;;
|
||||
|
||||
@@ -11,13 +11,27 @@ complete -c ocrmypdf -s r -l rotate-pages -d "rotate pages to correct orientatio
|
||||
complete -c ocrmypdf -s d -l deskew -d "fix small horizontal alignment skew"
|
||||
complete -c ocrmypdf -s c -l clean -d "clean document images before OCR"
|
||||
complete -c ocrmypdf -s i -l clean-final -d "clean document images and keep result"
|
||||
complete -c ocrmypdf -x -l unpaper-args -d "quoted string of arguments to pass to unpaper"
|
||||
complete -c ocrmypdf -l remove-vectors -d "don't send vector objects to OCR"
|
||||
|
||||
function __fish_ocrmypdf_mode
|
||||
echo -e "default\t"(_ "error if text is found")
|
||||
echo -e "force\t"(_ "rasterize all content and run OCR")
|
||||
echo -e "skip\t"(_ "skip pages with existing text")
|
||||
echo -e "redo\t"(_ "re-OCR pages, replacing old invisible text")
|
||||
end
|
||||
complete -c ocrmypdf -x -s m -l mode -a '(__fish_ocrmypdf_mode)' -d "processing mode for pages with existing text"
|
||||
complete -c ocrmypdf -s f -l force-ocr -d "OCR documents that already have printable text"
|
||||
complete -c ocrmypdf -s s -l skip-text -d "skip OCR on any pages that already contain text"
|
||||
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"
|
||||
|
||||
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)"
|
||||
|
||||
function __fish_ocrmypdf_languages
|
||||
@@ -32,7 +46,8 @@ complete -c ocrmypdf -x -s l -l language -a '(__fish_ocrmypdf_languages)' -d lan
|
||||
complete -c ocrmypdf -x -l image-dpi -d "assume this DPI if input image DPI is unknown"
|
||||
|
||||
function __fish_ocrmypdf_output_type
|
||||
echo -e "pdfa\t"(_ "output a PDF/A (default)")
|
||||
echo -e "auto\t"(_ "best-effort PDF/A without requiring Ghostscript (default)")
|
||||
echo -e "pdfa\t"(_ "output a PDF/A-2b")
|
||||
echo -e "pdf\t"(_ "output a standard PDF")
|
||||
echo -e "pdfa-1\t"(_ "output a PDF/A-1b")
|
||||
echo -e "pdfa-2\t"(_ "output a PDF/A-2b")
|
||||
@@ -42,13 +57,28 @@ end
|
||||
complete -c ocrmypdf -x -l output-type -a '(__fish_ocrmypdf_output_type)' -d "select PDF output options"
|
||||
|
||||
function __fish_ocrmypdf_pdf_renderer
|
||||
echo -e "auto\t"(_ "auto select PDF renderer")
|
||||
echo -e "hocr\t"(_ "use hOCR renderer")
|
||||
echo -e "hocrdebug\t"(_ "uses hOCR renderer in debug mode, showing recognized text")
|
||||
echo -e "auto\t"(_ "auto select PDF renderer (default, uses fpdf2)")
|
||||
echo -e "fpdf2\t"(_ "use fpdf2 renderer with full language support")
|
||||
echo -e "sandwich\t"(_ "use sandwich renderer")
|
||||
echo -e "hocr\t"(_ "use hOCR renderer (deprecated)")
|
||||
echo -e "hocrdebug\t"(_ "uses hOCR renderer in debug mode (deprecated)")
|
||||
end
|
||||
complete -c ocrmypdf -x -l pdf-renderer -a '(__fish_ocrmypdf_pdf_renderer)' -d "select PDF renderer options"
|
||||
|
||||
function __fish_ocrmypdf_ocr_engine
|
||||
echo -e "auto\t"(_ "select best available engine (default)")
|
||||
echo -e "tesseract\t"(_ "use Tesseract OCR")
|
||||
echo -e "none\t"(_ "skip OCR entirely")
|
||||
end
|
||||
complete -c ocrmypdf -x -l ocr-engine -a '(__fish_ocrmypdf_ocr_engine)' -d "OCR engine to use"
|
||||
|
||||
function __fish_ocrmypdf_rasterizer
|
||||
echo -e "auto\t"(_ "prefer pypdfium, fall back to Ghostscript (default)")
|
||||
echo -e "ghostscript\t"(_ "use Ghostscript rasterizer")
|
||||
echo -e "pypdfium\t"(_ "use pypdfium rasterizer (faster)")
|
||||
end
|
||||
complete -c ocrmypdf -x -l rasterizer -a '(__fish_ocrmypdf_rasterizer)' -d "PDF page rasterizer"
|
||||
|
||||
function __fish_ocrmypdf_optimize
|
||||
echo -e "0\t"(_ "do not optimize")
|
||||
echo -e "1\t"(_ "do safe, lossless optimizations (default)")
|
||||
@@ -124,11 +154,17 @@ end
|
||||
complete -c ocrmypdf -x -l tesseract-thresholding -a '(__fish_ocrmypdf_tesseract_thresholding)' -d "set tesseract thresholding method (needs Tesseract 5.x)"
|
||||
|
||||
complete -c ocrmypdf -x -l tesseract-timeout -d "maximum number of seconds to wait for OCR"
|
||||
complete -c ocrmypdf -x -l tesseract-non-ocr-timeout -d "maximum seconds to wait for non-OCR operations"
|
||||
complete -c ocrmypdf -l tesseract-downsample-large-images -d "downsample large images before OCR"
|
||||
complete -c ocrmypdf -l no-tesseract-downsample-large-images -d "do not downsample large images"
|
||||
complete -c ocrmypdf -x -l tesseract-downsample-above -d "downsample images larger than this pixel size"
|
||||
complete -c ocrmypdf -x -l rotate-pages-threshold -d "page rotation confidence"
|
||||
|
||||
complete -c ocrmypdf -r -l user-words -d "specify location of user words file"
|
||||
complete -c ocrmypdf -r -l user-patterns -d "specify location of user patterns file"
|
||||
complete -c ocrmypdf -x -l fast-web-view -d "if file size if above this amount in MB, linearize PDF"
|
||||
complete -c ocrmypdf -l continue-on-soft-render-error -d "continue processing after recoverable render errors"
|
||||
complete -c ocrmypdf -r -l plugin -d "name of plugin to import"
|
||||
|
||||
function __fish_ocrmypdf_color_conversion_strategy
|
||||
echo -e "LeaveColorUnchanged\t"(_ "do not convert color spaces (default)")
|
||||
|
||||
@@ -11,7 +11,7 @@ from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo
|
||||
from ocrmypdf._concurrent import Executor
|
||||
from ocrmypdf._defaults import PROGRAM_NAME
|
||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||
from ocrmypdf._options import OcrOptions
|
||||
from ocrmypdf._options import OcrOptions, TaggedPdfMode
|
||||
from ocrmypdf._pipelines._common import (
|
||||
configure_debug_logging,
|
||||
)
|
||||
@@ -78,6 +78,7 @@ __all__ = [
|
||||
'PriorOcrFoundError',
|
||||
'PROGRAM_NAME',
|
||||
'SubprocessOutputError',
|
||||
'TaggedPdfMode',
|
||||
'TesseractConfigError',
|
||||
'UnsupportedImageFormatError',
|
||||
'Verbosity',
|
||||
|
||||
@@ -127,6 +127,19 @@ def rasterize_pdf(
|
||||
if not page_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 = (
|
||||
[
|
||||
GS,
|
||||
@@ -137,7 +150,7 @@ def rasterize_pdf(
|
||||
f'-sDEVICE={raster_device}',
|
||||
f'-dFirstPage={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 [])
|
||||
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
||||
@@ -173,6 +186,16 @@ def rasterize_pdf(
|
||||
|
||||
try:
|
||||
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:
|
||||
log.debug("Rotating output by %i", rotation)
|
||||
# rotation is a clockwise angle and Image.ROTATE_* is
|
||||
|
||||
@@ -7,7 +7,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from decimal import Decimal
|
||||
@@ -101,13 +100,6 @@ def run_unpaper(
|
||||
) 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(
|
||||
input_file: Path,
|
||||
output_file: Path,
|
||||
|
||||
@@ -447,7 +447,7 @@ class OcrGrafter:
|
||||
xobj.Type = Name.XObject
|
||||
xobj.Subtype = Name.Form
|
||||
xobj.FormType = 1
|
||||
xobj.BBox = mediabox
|
||||
xobj.BBox = base_mediabox
|
||||
|
||||
# Copy resources from text page's Resources to xobj
|
||||
# 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 logging
|
||||
import os
|
||||
import shlex
|
||||
import unicodedata
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum
|
||||
@@ -50,6 +51,20 @@ class ProcessingMode(StrEnum):
|
||||
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]:
|
||||
"""Convert page range string to set of page numbers."""
|
||||
pages: list[int] = []
|
||||
@@ -142,14 +157,13 @@ class OcrOptions(BaseModel):
|
||||
remove_background: bool = False
|
||||
remove_vectors: bool = False
|
||||
oversample: int = 0
|
||||
unpaper_args: str | list[str] | None = (
|
||||
None # Can be string or list after validation
|
||||
)
|
||||
unpaper_args: list[str] | None = None
|
||||
|
||||
# OCR behavior
|
||||
skip_big: float | None = None
|
||||
pages: str | set[int] | None = None # Can be string or set after validation
|
||||
invalidate_digital_signatures: bool = False
|
||||
tagged_pdf_mode: TaggedPdfMode = TaggedPdfMode.default
|
||||
|
||||
# Metadata
|
||||
title: str | None = None
|
||||
@@ -324,6 +338,20 @@ class OcrOptions(BaseModel):
|
||||
# Convert string ranges to set of page numbers
|
||||
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')
|
||||
@classmethod
|
||||
def handle_special_cases(cls, data):
|
||||
|
||||
+15
-11
@@ -28,7 +28,7 @@ from ocrmypdf._concurrent import Executor
|
||||
from ocrmypdf._exec import unpaper
|
||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||
from ocrmypdf._metadata import repair_docinfo_nuls
|
||||
from ocrmypdf._options import OcrOptions, ProcessingMode
|
||||
from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode
|
||||
from ocrmypdf.exceptions import (
|
||||
DigitalSignatureError,
|
||||
DpiError,
|
||||
@@ -251,14 +251,17 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
|
||||
"will be 'flattened' and will no longer be fillable."
|
||||
)
|
||||
if pdfinfo.is_tagged:
|
||||
if options.mode != ProcessingMode.default:
|
||||
log.warning(
|
||||
"This PDF is marked as a Tagged PDF. This often indicates "
|
||||
"that the PDF was generated from an office document and does "
|
||||
"not need OCR. PDF pages processed by OCRmyPDF may not be "
|
||||
"tagged correctly."
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
"This PDF is marked as a Tagged PDF. This often indicates "
|
||||
"that the PDF was generated from an office document and does "
|
||||
"not need OCR. PDF pages processed by OCRmyPDF may not be "
|
||||
"tagged correctly."
|
||||
)
|
||||
if (
|
||||
options.tagged_pdf_mode == TaggedPdfMode.default
|
||||
and options.mode == ProcessingMode.default
|
||||
):
|
||||
log.info("Use --tagged-pdf-mode ignore to ignore Tagged PDFs.")
|
||||
raise TaggedPDFError()
|
||||
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:
|
||||
"""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:
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
self, *, page: PageContext, image_filename: Path, output_pdf: Path
|
||||
) -> Path | None:
|
||||
) -> Path:
|
||||
"""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
|
||||
)
|
||||
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.
|
||||
|
||||
Args:
|
||||
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(
|
||||
self,
|
||||
@@ -218,15 +226,18 @@ class OcrmypdfPluginManager:
|
||||
context: PdfContext,
|
||||
executor: Executor,
|
||||
linearize: bool,
|
||||
) -> tuple[Path, Sequence[str]] | None:
|
||||
) -> tuple[Path, Sequence[str]]:
|
||||
"""Optimize a PDF after OCR processing."""
|
||||
return self._pm.hook.optimize_pdf(
|
||||
result = self._pm.hook.optimize_pdf(
|
||||
input_pdf=input_pdf,
|
||||
output_pdf=output_pdf,
|
||||
context=context,
|
||||
executor=executor,
|
||||
linearize=linearize,
|
||||
)
|
||||
if result is None:
|
||||
return input_pdf, []
|
||||
return result
|
||||
|
||||
def is_optimization_enabled(self, *, context: PdfContext) -> bool | None:
|
||||
"""Returns whether optimization is enabled for given context."""
|
||||
|
||||
@@ -114,15 +114,8 @@ def check_options_preprocessing(options: OcrOptions) -> None:
|
||||
package='unpaper',
|
||||
version_checker=unpaper.version,
|
||||
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:
|
||||
|
||||
@@ -408,6 +408,7 @@ def ocr(
|
||||
fast_web_view: float | None = None,
|
||||
continue_on_soft_render_error: bool | None = None,
|
||||
invalidate_digital_signatures: bool | None = None,
|
||||
tagged_pdf_mode: str | None = None,
|
||||
plugins: Iterable[Path | str] | None = None,
|
||||
plugin_manager: OcrmypdfPluginManager | None = None,
|
||||
keep_temporary_files: bool | None = None,
|
||||
@@ -469,6 +470,7 @@ def ocr( # noqa: D417
|
||||
fast_web_view: float | None = None,
|
||||
continue_on_soft_render_error: bool | None = None,
|
||||
invalidate_digital_signatures: bool | None = None,
|
||||
tagged_pdf_mode: str | None = None,
|
||||
plugins: Iterable[Path | str] | None = None,
|
||||
plugin_manager: OcrmypdfPluginManager | None = None,
|
||||
keep_temporary_files: bool | None = None,
|
||||
|
||||
@@ -8,12 +8,15 @@ import logging
|
||||
import threading
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
try:
|
||||
if TYPE_CHECKING:
|
||||
import pypdfium2 as pdfium
|
||||
except ImportError:
|
||||
pdfium = None
|
||||
|
||||
else:
|
||||
try:
|
||||
import pypdfium2 as pdfium
|
||||
except ImportError:
|
||||
pdfium = None
|
||||
from PIL import Image
|
||||
|
||||
from ocrmypdf import hookimpl
|
||||
@@ -69,12 +72,12 @@ def _calculate_mediabox_crop(page) -> tuple[float, float, float, float]:
|
||||
|
||||
|
||||
def _render_page_to_bitmap(
|
||||
page,
|
||||
page: pdfium.PdfPage,
|
||||
raster_device: str,
|
||||
raster_dpi: Resolution,
|
||||
rotation: int | None,
|
||||
use_cropbox: bool,
|
||||
):
|
||||
) -> tuple[pdfium.PdfBitmap, int, int]:
|
||||
"""Render a PDF page to a bitmap."""
|
||||
# Round DPI to match Ghostscript's precision
|
||||
raster_dpi = raster_dpi.round(6)
|
||||
@@ -101,7 +104,8 @@ def _render_page_to_bitmap(
|
||||
|
||||
# Render the page to a bitmap
|
||||
# The scale parameter controls the resolution
|
||||
grayscale = raster_device.lower() in ('pnggray', 'jpeggray')
|
||||
# Render in grayscale for mono and gray devices (better input for 1-bit conversion)
|
||||
grayscale = raster_device.lower() in ('pngmono', 'pnggray', 'jpeggray')
|
||||
|
||||
# Calculate crop to render the appropriate box
|
||||
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
|
||||
@@ -121,14 +125,14 @@ def _render_page_to_bitmap(
|
||||
|
||||
|
||||
def _process_image_for_output(
|
||||
pil_image,
|
||||
pil_image: Image.Image,
|
||||
raster_device: str,
|
||||
raster_dpi: Resolution,
|
||||
page_dpi: Resolution | None,
|
||||
stop_on_soft_error: bool,
|
||||
expected_width: int | None = None,
|
||||
expected_height: int | None = None,
|
||||
):
|
||||
) -> tuple[Image.Image, Literal['PNG', 'TIFF', 'JPEG']]:
|
||||
"""Process PIL image for output format and set DPI metadata."""
|
||||
# Correct dimensions if slightly off (within 2 pixels tolerance)
|
||||
if expected_width and expected_height:
|
||||
@@ -146,8 +150,7 @@ def _process_image_for_output(
|
||||
f"{expected_width}x{expected_height}"
|
||||
)
|
||||
pil_image = pil_image.resize(
|
||||
(expected_width, expected_height),
|
||||
Image.Resampling.LANCZOS
|
||||
(expected_width, expected_height), Image.Resampling.LANCZOS
|
||||
)
|
||||
|
||||
# Set the DPI metadata if page_dpi is specified
|
||||
@@ -160,20 +163,43 @@ def _process_image_for_output(
|
||||
dpi_tuple = (float(raster_dpi.x), float(raster_dpi.y))
|
||||
pil_image.info['dpi'] = dpi_tuple
|
||||
|
||||
# Determine output format based on raster_device
|
||||
if raster_device.lower() in ('png', 'pngmono', 'pnggray', 'png16m', 'pngalpha'):
|
||||
format_name = 'PNG'
|
||||
elif raster_device.lower() in ('jpeg', 'jpeggray', 'jpg'):
|
||||
format_name = 'JPEG'
|
||||
# Convert RGBA to RGB for JPEG
|
||||
# Convert image mode to match raster_device
|
||||
# This ensures pypdfium output matches Ghostscript's native device output
|
||||
raster_device_lower = raster_device.lower()
|
||||
|
||||
if raster_device_lower == 'pngmono':
|
||||
# Convert to 1-bit black and white (matches Ghostscript pngmono device)
|
||||
if pil_image.mode != '1':
|
||||
if pil_image.mode not in ('L', '1'):
|
||||
pil_image = pil_image.convert('L')
|
||||
pil_image = pil_image.convert('1')
|
||||
elif raster_device_lower in ('pnggray', 'jpeggray'):
|
||||
# Convert to 8-bit grayscale
|
||||
if pil_image.mode not in ('L', '1'):
|
||||
pil_image = pil_image.convert('L')
|
||||
elif raster_device_lower == 'png256':
|
||||
# Convert to 8-bit indexed color (256 colors)
|
||||
if pil_image.mode != 'P':
|
||||
if pil_image.mode not in ('RGB', 'RGBA'):
|
||||
pil_image = pil_image.convert('RGB')
|
||||
pil_image = pil_image.quantize(colors=256)
|
||||
elif raster_device_lower in ('png16m', 'jpeg'):
|
||||
# Convert to RGB
|
||||
if pil_image.mode == 'RGBA':
|
||||
# Create white background
|
||||
background = pil_image.new('RGB', pil_image.size, (255, 255, 255))
|
||||
background.paste(
|
||||
pil_image, mask=pil_image.split()[-1]
|
||||
) # Use alpha channel as mask
|
||||
background = Image.new('RGB', pil_image.size, (255, 255, 255))
|
||||
background.paste(pil_image, mask=pil_image.split()[-1])
|
||||
pil_image = background
|
||||
elif raster_device.lower() in ('tiff', 'tif'):
|
||||
elif pil_image.mode not in ('RGB',):
|
||||
pil_image = pil_image.convert('RGB')
|
||||
# pngalpha: keep RGBA as-is
|
||||
|
||||
# Determine output format based on raster_device
|
||||
png_devices = ('png', 'pngmono', 'pnggray', 'png256', 'png16m', 'pngalpha')
|
||||
if raster_device_lower in png_devices:
|
||||
format_name = 'PNG'
|
||||
elif raster_device_lower in ('jpeg', 'jpeggray', 'jpg'):
|
||||
format_name = 'JPEG'
|
||||
elif raster_device_lower in ('tiff', 'tif'):
|
||||
format_name = 'TIFF'
|
||||
else:
|
||||
# Default to PNG for unknown formats
|
||||
@@ -186,7 +212,7 @@ def _process_image_for_output(
|
||||
return pil_image, format_name
|
||||
|
||||
|
||||
def _save_image(pil_image, output_file: Path, format_name: str):
|
||||
def _save_image(pil_image: Image.Image, output_file: Path, format_name: str) -> None:
|
||||
"""Save PIL image to file with appropriate DPI metadata."""
|
||||
save_kwargs = {}
|
||||
if (
|
||||
|
||||
+9
-1
@@ -12,7 +12,7 @@ from typing import Any, TypeVar
|
||||
|
||||
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||
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._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 "
|
||||
"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", "Advanced options to control OCRmyPDF"
|
||||
|
||||
@@ -11,12 +11,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from itertools import pairwise
|
||||
from math import atan, degrees
|
||||
from math import atan, cos, degrees, radians, sin, sqrt
|
||||
from pathlib import Path
|
||||
|
||||
from fpdf import FPDF
|
||||
from fpdf.enums import TextMode
|
||||
from fpdf.enums import PDFResourceType, TextMode
|
||||
from pikepdf import Matrix, Rectangle
|
||||
|
||||
from ocrmypdf.font import FontManager, MultiFontManager
|
||||
@@ -163,6 +162,8 @@ class Fpdf2PdfRenderer:
|
||||
|
||||
# Registered fonts: font_path -> fpdf_family_name
|
||||
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:
|
||||
"""Render page to PDF file.
|
||||
@@ -189,9 +190,9 @@ class Fpdf2PdfRenderer:
|
||||
|
||||
# Set text mode for invisible text
|
||||
if self.invisible_text:
|
||||
pdf.text_rendering_mode = TextMode.INVISIBLE
|
||||
pdf.text_mode = TextMode.INVISIBLE
|
||||
else:
|
||||
pdf.text_rendering_mode = TextMode.FILL
|
||||
pdf.text_mode = TextMode.FILL
|
||||
|
||||
# Render content to PDF
|
||||
self.render_to_pdf(pdf)
|
||||
@@ -299,6 +300,30 @@ class Fpdf2PdfRenderer:
|
||||
# Get textangle (rotation of the entire line)
|
||||
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
|
||||
# line coords. The hOCR bbox is the minimum axis-aligned bounding box
|
||||
# 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
|
||||
)
|
||||
|
||||
# Get baseline information (slope and intercept)
|
||||
slope = 0.0
|
||||
intercept_pt = 0.0
|
||||
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
|
||||
else:
|
||||
# No baseline provided: calculate from font metrics
|
||||
# Get baseline intercept
|
||||
if not has_meaningful_baseline:
|
||||
# No baseline provided or baseline was used for rotation detection:
|
||||
# calculate intercept from font metrics
|
||||
default_font_manager = self.multi_font_manager.fonts['NotoSans-Regular']
|
||||
ascent, descent, units_per_em = default_font_manager.get_font_metrics()
|
||||
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
|
||||
]
|
||||
|
||||
# Render each word followed by space (except last)
|
||||
# Use pairwise to iterate over consecutive word pairs, pairing the last
|
||||
# word with a None to signal the end of the line.
|
||||
for current_word, next_word in pairwise(words + [None]):
|
||||
if current_word: # Don't render EOL sentinel
|
||||
# Render the current word
|
||||
self._render_word(
|
||||
pdf,
|
||||
current_word,
|
||||
baseline_matrix,
|
||||
inv_baseline_matrix,
|
||||
font_size,
|
||||
total_rotation_deg,
|
||||
line_language,
|
||||
)
|
||||
if next_word: # Don't render EOL sentinel
|
||||
self._maybe_render_space(
|
||||
pdf,
|
||||
current_word,
|
||||
next_word,
|
||||
baseline_matrix,
|
||||
inv_baseline_matrix,
|
||||
font_size,
|
||||
total_rotation_deg,
|
||||
line_language,
|
||||
line.direction,
|
||||
# Suppress lines where the text aspect ratio is implausible.
|
||||
# This catches cases where Tesseract failed to detect rotation
|
||||
# entirely (slope=0, no textangle) and produced garbage text in a
|
||||
# bounding box whose shape doesn't match the text content at all.
|
||||
if not self._check_aspect_ratio_plausible(
|
||||
pdf, words, font_size, slope_angle_deg,
|
||||
line_size_width, line_size_height, line_language,
|
||||
):
|
||||
return
|
||||
|
||||
# Collect word rendering data: (text, x_baseline, font_family, word_tz)
|
||||
word_render_data: list[tuple[str, float, str, float]] = []
|
||||
for word in words:
|
||||
if word is None or not word.text or word.bbox is None:
|
||||
continue
|
||||
|
||||
word_left_pt = self.coord_transform.px_to_pt(word.bbox.left)
|
||||
word_top_pt = self.coord_transform.px_to_pt(word.bbox.top)
|
||||
word_right_pt = self.coord_transform.px_to_pt(word.bbox.right)
|
||||
word_bottom_pt = self.coord_transform.px_to_pt(word.bbox.bottom)
|
||||
word_width_pt = word_right_pt - word_left_pt
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
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,
|
||||
pdf: FPDF,
|
||||
word: OcrElement,
|
||||
baseline_matrix: Matrix,
|
||||
inv_baseline_matrix: Matrix,
|
||||
words: list[OcrElement | None],
|
||||
font_size: float,
|
||||
rotation_deg: float,
|
||||
slope_angle_deg: float,
|
||||
line_size_width: float,
|
||||
line_size_height: float,
|
||||
line_language: str | None,
|
||||
) -> None:
|
||||
"""Render a word using word bbox positioning.
|
||||
) -> bool:
|
||||
"""Check whether the line's aspect ratio is plausible for its text.
|
||||
|
||||
Position text so its visual bounding box matches the hOCR word bbox.
|
||||
This provides more accurate placement than baseline-relative positioning
|
||||
because we match the actual glyph bounds rather than relying on font
|
||||
metrics which may not exactly match the OCR'd text appearance.
|
||||
Compares the aspect ratio of the OCR bounding box to the aspect ratio
|
||||
the text would have if rendered normally (accounting for baseline
|
||||
slope). A large mismatch indicates Tesseract misread rotated text
|
||||
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:
|
||||
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
|
||||
inv_baseline_matrix: Transform from page coords to baseline coords
|
||||
font_size: Font size in points (from line calculation)
|
||||
rotation_deg: Total rotation angle for text
|
||||
line_language: Language code from line for font selection
|
||||
font_size: Font size in points
|
||||
total_rotation_deg: Total rotation angle (textangle + slope)
|
||||
"""
|
||||
if not word.text or word.bbox is None:
|
||||
return
|
||||
page_height = self.coord_transform.page_height_pt
|
||||
|
||||
# Select appropriate font for this word
|
||||
font_manager = self.multi_font_manager.select_font_for_word(
|
||||
word.text, line_language
|
||||
)
|
||||
# Compute baseline direction in PDF coordinates for rotation
|
||||
has_rotation = abs(total_rotation_deg) > 0.01
|
||||
bx0, by0_fpdf = transform_point(baseline_matrix, 0, 0)
|
||||
by0_pdf = page_height - by0_fpdf
|
||||
|
||||
# Register font with fpdf2
|
||||
font_family = self._register_font(pdf, font_manager)
|
||||
ops: list[str] = []
|
||||
|
||||
# Convert word bbox to PDF points
|
||||
word_left_pt = self.coord_transform.px_to_pt(word.bbox.left)
|
||||
word_top_pt = self.coord_transform.px_to_pt(word.bbox.top)
|
||||
word_right_pt = self.coord_transform.px_to_pt(word.bbox.right)
|
||||
word_bottom_pt = self.coord_transform.px_to_pt(word.bbox.bottom)
|
||||
word_width_pt = word_right_pt - word_left_pt
|
||||
if has_rotation:
|
||||
# Compute direction vector along the baseline in PDF coordinates
|
||||
bx1, by1_fpdf = transform_point(baseline_matrix, 100, 0)
|
||||
by1_pdf = page_height - by1_fpdf
|
||||
dx = bx1 - bx0
|
||||
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
|
||||
box_llx, _, _, _ = transform_box(
|
||||
inv_baseline_matrix,
|
||||
word_left_pt,
|
||||
word_top_pt,
|
||||
word_right_pt,
|
||||
word_bottom_pt,
|
||||
)
|
||||
|
||||
# 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
|
||||
# Save graphics state, apply rotation+translation via cm.
|
||||
# The cm maps local coordinates (baseline-aligned, x along text)
|
||||
# to PDF page coordinates.
|
||||
ops.append('q')
|
||||
ops.append(
|
||||
f'{cos_a:.6f} {sin_a:.6f} {-sin_a:.6f} {cos_a:.6f} '
|
||||
f'{bx0:.2f} {by0_pdf:.2f} cm'
|
||||
)
|
||||
|
||||
# Use line-based font_size for consistent vertical sizing
|
||||
word_font_size = font_size
|
||||
# Begin text object
|
||||
ops.append('BT')
|
||||
|
||||
# Set font
|
||||
pdf.set_font(font_family, size=word_font_size)
|
||||
# Text render mode: 3 = invisible, 0 = fill
|
||||
tr = 3 if self.invisible_text else 0
|
||||
ops.append(f'{tr} Tr')
|
||||
|
||||
# Calculate natural text width at this font size
|
||||
natural_width = pdf.get_string_width(word.text)
|
||||
|
||||
# Calculate horizontal scale to fit word bbox width
|
||||
if natural_width > 0 and word_width_pt > 0:
|
||||
scale_x = (word_width_pt / natural_width) * 100
|
||||
# Initial text position
|
||||
first_x_baseline = word_render_data[0][1]
|
||||
if has_rotation:
|
||||
# In the cm-transformed space, origin is at the baseline start
|
||||
ops.append(f'{first_x_baseline:.2f} 0 Td')
|
||||
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
|
||||
pdf.set_stretching(scale_x)
|
||||
prev_font_family: str | None = None
|
||||
prev_x_baseline = first_x_baseline
|
||||
|
||||
# Get left side bearing of first character to compensate for glyph offset
|
||||
lsb_pt = font_manager.get_left_side_bearing(word.text[0], word_font_size)
|
||||
for i, (text, x_baseline, font_family, word_tz) in enumerate(
|
||||
word_render_data
|
||||
):
|
||||
is_last = i == len(word_render_data) - 1
|
||||
|
||||
# Transform the baseline-relative x position back to page coordinates
|
||||
# The word sits at (box_llx, 0) in baseline coords (on the baseline)
|
||||
page_x, page_y = transform_point(baseline_matrix, box_llx, 0)
|
||||
# Set font if changed
|
||||
if font_family != prev_font_family:
|
||||
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)
|
||||
adjusted_x = page_x - lsb_pt * (scale_x / 100)
|
||||
# Relative positioning (for words after the first)
|
||||
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
|
||||
# In fpdf2, set_xy(x, y) positions text such that the baseline is at:
|
||||
# baseline_y = set_y + font_size * (ascent / (ascent + |descent|))
|
||||
# We want baseline at page_y, so:
|
||||
# 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
|
||||
# Determine text to render and compute Tz
|
||||
if not is_last:
|
||||
next_text, next_x_baseline, _, _ = word_render_data[i + 1]
|
||||
advance = next_x_baseline - x_baseline
|
||||
|
||||
# Position and draw text with rotation
|
||||
if abs(rotation_deg) > 0.1:
|
||||
with pdf.rotation(-rotation_deg, x=page_x, y=page_y):
|
||||
pdf.set_xy(adjusted_x, adjusted_y)
|
||||
pdf.cell(text=word.text)
|
||||
else:
|
||||
pdf.set_xy(adjusted_x, adjusted_y)
|
||||
pdf.cell(text=word.text)
|
||||
# Add trailing space unless both words are CJK-only
|
||||
if (
|
||||
advance > 0
|
||||
and not (
|
||||
self._is_cjk_only(text)
|
||||
and self._is_cjk_only(next_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
|
||||
pdf.set_stretching(100)
|
||||
ops.append(f'{render_tz:.2f} Tz')
|
||||
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:
|
||||
"""Check if text contains only CJK characters.
|
||||
@@ -559,157 +766,6 @@ class Fpdf2PdfRenderer:
|
||||
return False
|
||||
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(
|
||||
self,
|
||||
pdf: FPDF,
|
||||
@@ -802,9 +858,9 @@ class Fpdf2MultiPageRenderer:
|
||||
|
||||
# Set text mode for invisible text
|
||||
if self.invisible_text:
|
||||
pdf.text_rendering_mode = TextMode.INVISIBLE
|
||||
pdf.text_mode = TextMode.INVISIBLE
|
||||
else:
|
||||
pdf.text_rendering_mode = TextMode.FILL
|
||||
pdf.text_mode = TextMode.FILL
|
||||
|
||||
# Shared font registration across all pages
|
||||
shared_registered_fonts: dict[str, str] = {}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Simple CLI for testing HOCR to PDF conversion using fpdf2 renderer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -62,7 +63,7 @@ if __name__ == "__main__":
|
||||
page=ocr_page,
|
||||
dpi=dpi,
|
||||
multi_font_manager=multi_font_manager,
|
||||
invisible_text=not args.boundingboxes, # Visible text in debug mode
|
||||
invisible_text=False,
|
||||
debug_render_options=debug_options,
|
||||
)
|
||||
renderer.render(Path(args.outputfile))
|
||||
|
||||
@@ -108,10 +108,18 @@ class ImageInfo:
|
||||
self._type = 'image'
|
||||
|
||||
self._bpc = int(pim.bits_per_component)
|
||||
try:
|
||||
self._enc = FRIENDLY_ENCODING.get(pim.filters[0])
|
||||
except IndexError:
|
||||
self._enc = None
|
||||
if (
|
||||
len(pim.filters) == 2
|
||||
and pim.filters[0] == '/FlateDecode'
|
||||
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:
|
||||
self._color = FRIENDLY_COLORSPACE.get(pim.colorspace or '')
|
||||
|
||||
@@ -36,6 +36,7 @@ class Encoding(Enum):
|
||||
lzw = auto()
|
||||
flate = auto()
|
||||
runlength = auto()
|
||||
flate_jpeg = auto()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
exitcode = run_ocrmypdf_api(
|
||||
resources / 'blank.pdf',
|
||||
|
||||
@@ -42,6 +42,69 @@ def test_links(resources, outpdf):
|
||||
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():
|
||||
pdf = pikepdf.Pdf.new()
|
||||
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)
|
||||
|
||||
|
||||
@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):
|
||||
filename = resources / 'formxobject.pdf'
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
|
||||
from ocrmypdf import _pipeline, pdfinfo
|
||||
from ocrmypdf.helpers import Resolution
|
||||
from ocrmypdf.pdfinfo import Encoding
|
||||
|
||||
warnings.filterwarnings(
|
||||
"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):
|
||||
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'],
|
||||
)
|
||||
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
|
||||
from packaging.version import Version
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ocrmypdf._exec import unpaper
|
||||
from ocrmypdf._validation import check_options
|
||||
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
|
||||
|
||||
@@ -87,7 +88,7 @@ def test_unpaper_args_valid(resources, outpdf):
|
||||
|
||||
@needs_unpaper
|
||||
def test_unpaper_args_invalid_filename(resources, outpdf, caplog):
|
||||
with pytest.raises(BadArgsError):
|
||||
with pytest.raises(ValidationError, match="No filenames allowed"):
|
||||
run_ocrmypdf_api(
|
||||
resources / "skew.pdf",
|
||||
outpdf,
|
||||
|
||||
Reference in New Issue
Block a user