Compare commits

...
27 Commits
Author SHA1 Message Date
James R. Barlow 3da952a23d Fix garbled Arabic/Devanagari text by using HarfBuzz text shaping
encode_text() maps unicode characters one-by-one to glyph IDs without
any text shaping, producing incorrect output for complex scripts:
Arabic glyphs in wrong order without joining forms, Devanagari conjuncts
broken apart. Replace with shape_text() which runs HarfBuzz for proper
BiDi reordering, Arabic shaping, and Devanagari conjunct formation.
2026-02-11 01:30:15 -08:00
James R. Barlow 716ce6324c Update dependencies 2026-02-11 00:43:01 -08:00
James R. Barlow 76fe2f7e28 Merge remote-tracking branch 'origin/dependabot/uv/cryptography-46.0.5' 2026-02-11 00:42:21 -08:00
James R. Barlow c85c8941d3 Fix pdftotext word spacing by emitting single BT block per line
poppler/pdftotext does not carry Tz (horizontal scaling) across
BT/ET boundaries, causing words to appear on separate lines.
Replace per-word BT blocks (via fpdf2's cell/set_stretching API)
with a single BT block per line using raw PDF operators. Each
non-last word gets a trailing space with Tz calculated to span
exactly to the next word's start position.
2026-02-11 00:42:10 -08:00
dependabot[bot]andGitHub 9a0dadbd4c Bump cryptography from 46.0.4 to 46.0.5
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.4 to 46.0.5.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.4...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-11 02:57:37 +00:00
James R. Barlow 4d7e398c4b Suppress rendering of text lines with improbable aspect ratios
When Tesseract completely fails to detect text rotation (no textangle,
slope=0), it produces garbage text in tall-narrow bounding boxes. Add
an aspect ratio plausibility check that compares the OCR bounding box
shape to the expected shape of the rendered text (accounting for
baseline slope). Lines where the ratio of aspect ratios is < 0.1 are
suppressed.

Uses a fast path (width >= height) to skip the expensive font
measurement for the common case of normal horizontal text.
2026-02-10 17:42:33 -08:00
James R. Barlow 56c0b41f97 Fix extreme font sizes for rotated text in fpdf2 renderer
Tesseract doesn't output the textangle hOCR attribute for 90-degree
rotated text. Instead, the rotation is encoded as extreme baseline
slope values (e.g., 462.2). Without detecting this, the renderer used
the axis-aligned bounding box height as line_size_height, producing
font sizes of 2000+ pt instead of ~10 pt.

Detect steep baseline slopes (|slope| > 1.0, i.e., > 45° from
horizontal) and extract the effective text rotation angle via atan().
The meaningless slope/intercept values are then replaced with
font-metrics-based defaults.
2026-02-10 17:02:25 -08:00
James R. Barlow 5c83dab8a7 Fix fpdf text mode in multi-page renderer; add v17.2.0 release notes
The previous fix (e62e73e4) only corrected text_rendering_mode →
text_mode in the single-page Fpdf2PdfRenderer, but the main OCR
pipeline uses Fpdf2MultiPageRenderer which still had the old
attribute name. Since fpdf2 has no text_rendering_mode property,
setting it silently created a no-op attribute while text_mode stayed
at FILL — so 3 Tr (invisible text) was never emitted.

Fixes #1631, #1632
2026-02-10 14:12:49 -08:00
James R. Barlow e62e73e441 Fix fpdf text mode 2026-02-09 02:05:23 -08:00
James R. Barlow d68e2f6e34 Fix OCR text layer misalignment with non-zero mediabox origins
Fixes #1630 where --redo-ocr would shift OCR text vertically on PDFs
with non-zero mediabox origins (e.g., [0, 100, width, height+100]).

The bug occurred in _graft_fpdf2_text_layer where the Form XObject BBox
was set to the text layer's mediabox [0, 0, w, h] instead of the base
page's mediabox [0, 100, w, h+100]. This caused a coordinate mismatch
between the BBox and the transformation matrix, resulting in text being
positioned incorrectly.

The fix changes line 450 in _graft.py to use base_mediabox instead of
mediabox, making the fpdf2 renderer consistent with the sandwich renderer
which already used base_mediabox correctly.

This issue commonly affected:
- JSTOR PDFs (generated by iText with cropping)
- Cropped PDFs from various tools
- PDFs with non-standard coordinate systems

Added regression test that creates a PDF with offset mediabox origin
and verifies --redo-ocr preserves coordinates correctly.
2026-02-08 23:55:26 -08:00
James R. Barlow 1684982cde Further adjustments to install docs 2026-02-06 17:17:44 -08:00
James R. Barlow 4d97dfd218 Update installation docs for modern tooling
- Prioritize uv over pip throughout, with uv as the recommended installer
- Update repology badges: Debian 13, Ubuntu 24.04, Fedora 40/41
- Make Python 3.12 the default (3.11 still supported)
- Promote Homebrew as full-featured option for macOS and Linux
- Add dependency summary table aligned with maintainers.md
- Document uharfbuzz and fonts-noto requirements
- Remove outdated warnings and simplify 32-bit section
2026-02-05 15:04:12 -08:00
James R. Barlow a35fcc9c43 Handle Ghostscript rasterization with DPI below 10
Ghostscript may fail when asked to rasterize at very low DPI values
(below 10 on either axis). This adds a workaround that uses a minimum
of 10 DPI for the Ghostscript call, then resizes the output image to
match the dimensions that would have resulted from the original low
DPI request.

Fixes #1612
2026-01-31 13:01:04 -08:00
James R. Barlow 3dd4cde7ce Tighten plugin manager return types to non-optional
Make filter_pdf_page, get_ocr_engine, and optimize_pdf return
non-optional types by handling None cases explicitly: raise errors
for required results, return sensible defaults for optional ones.
2026-01-31 12:12:07 -08:00
James R. Barlow 92beb474a5 Normalize unpaper_args to list at construction time
Use a Pydantic field validator to convert string input to list[str]
during OcrOptions construction, simplifying the type from
`str | list[str] | None` to `list[str] | None`. Security validation
(path injection check) now happens at construction rather than in
check_options_preprocessing().
2026-01-31 12:05:37 -08:00
James R. Barlow 9dcd882c83 Use uv to install docs with dependency groups 2026-01-31 00:05:27 -08:00
James R. Barlow 9d8aa5a0c3 v17.1.0 release notes 2026-01-30 16:15:50 -08:00
James R. Barlow e036a902ae Add --tagged-pdf-mode option to control Tagged PDF handling
Allow users to bypass the TaggedPDFError when processing Tagged PDFs
by setting --tagged-pdf-mode=ignore. This is useful when users know
they want to OCR a Tagged PDF despite the warning.

- 'default': Error if --mode is default, otherwise warn (current behavior)
- 'ignore': Always warn but continue processing (never error)
2026-01-30 16:15:43 -08:00
James R. Barlow 0a980fb11b Add Encoding.flate_jpeg to recognize deflated JPEG images
FlateDecode+DCTDecode compressed images are essentially deflated JPEGs,
typically created by OCRmyPDF's optimizer. This change ensures pdfinfo
correctly identifies them and should_visible_page_image_use_jpg treats
them as JPEG-origin images, allowing JPEG output when appropriate.
2026-01-30 12:53:59 -08:00
James R. Barlow 3abe8f71c7 v17.0.1 release notes 2026-01-30 00:15:13 -08:00
James R. Barlow 64f45b7fdb Fix pypdfium type checking 2026-01-30 00:14:02 -08:00
James R. Barlow 7e939ad44d Fix pypdfium rasterizer to respect raster_device colorspace
pypdfium was not converting images to the correct colorspace/mode based
on the raster_device parameter. For example, pngmono should produce a
1-bit image, but pypdfium was outputting full-color or grayscale images.

This caused images to be incorrectly promoted to PNG instead of being
preserved as CCITT/JBIG2 when using --force-ocr with pypdfium, because
the optimizer relies on the image being in the correct mode.

Changes:
- Render in grayscale for pngmono device (better input for 1-bit conversion)
- Add mode conversion to match Ghostscript's native device output:
  - pngmono: convert to mode '1' (1-bit)
  - pnggray/jpeggray: convert to mode 'L' (8-bit grayscale)
  - png256: convert to mode 'P' (8-bit indexed)
  - png16m/jpeg: convert to mode 'RGB'
2026-01-30 00:04:02 -08:00
James R. Barlow 297fb786a0 Update uv.lock (for protobuf) 2026-01-29 18:33:00 -08:00
James R. Barlow ad30dd94f7 Merge branch 'release/v17' 2026-01-29 18:31:11 -08:00
James R. Barlow e77f79ac6f Merge branch 'main' of github.com:ocrmypdf/OCRmyPDF 2026-01-29 18:30:54 -08:00
James R. Barlow c84fc56e45 Update CLI completions to match current options
Add new options: --mode, --ocr-engine, --rasterizer,
--continue-on-soft-render-error, --tesseract-non-ocr-timeout,
--tesseract-downsample-large-images, --tesseract-downsample-above,
--unpaper-args (fish), --plugin (fish).

Update --output-type to include 'auto' as default.
Update --pdf-renderer to include 'fpdf2' and mark hocr as deprecated.

Remove non-working options: --remove-background, --threshold.
2026-01-29 12:41:56 -08:00
SuperCowProductsandGitHub 8930efe787 Update README with Fedora installation instructions (#1610)
Added instructions for Fedora users to install Tesseract language packs.
2025-12-27 01:15:45 -08:00
28 changed files with 1621 additions and 1081 deletions
+10 -8
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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**
+86 -8
View File
@@ -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
;;
+40 -4
View File
@@ -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)")
+2 -1
View File
@@ -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',
+24 -1
View File
@@ -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
-8
View File
@@ -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,
+1 -1
View File
@@ -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
+31 -3
View File
@@ -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
View File
@@ -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
)
+17 -6
View File
@@ -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."""
+1 -8
View File
@@ -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:
+2
View File
@@ -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,
+50 -24
View File
@@ -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
View File
@@ -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"
+333 -277
View File
@@ -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 -1
View File
@@ -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))
+12 -4
View File
@@ -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 '')
+1
View File
@@ -36,6 +36,7 @@ class Encoding(Enum):
lzw = auto()
flate = auto()
runlength = auto()
flate_jpeg = auto()
FloatRect = tuple[float, float, float, float]
+56
View File
@@ -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',
+63
View File
@@ -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')))
+49
View File
@@ -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'
+26
View File
@@ -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
+26
View File
@@ -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
+3 -2
View File
@@ -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,
Generated
+551 -560
View File
File diff suppressed because it is too large Load Diff