Merge branch 'release/v17'

This commit is contained in:
James R. Barlow
2026-01-29 18:31:11 -08:00
274 changed files with 23208 additions and 13132 deletions
+3 -1
View File
@@ -55,7 +55,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen \
--extra test --extra webservice --extra watcher --no-dev \
--extra webservice --extra watcher --no-dev \
--no-install-package pyarrow
FROM base
@@ -67,6 +67,8 @@ RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5
RUN apt-get update && apt-get install -y --no-install-recommends \
ghostscript \
fonts-droid-fallback \
fonts-noto-core \
fonts-noto-cjk \
jbig2dec \
pngquant \
tesseract-ocr \
+2 -1
View File
@@ -39,7 +39,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen \
--extra test --extra webservice --extra watcher --no-dev \
--extra webservice --extra watcher --no-dev \
--no-install-package pyarrow
FROM base
@@ -57,6 +57,7 @@ RUN apk add --no-cache \
tesseract-ocr-data-osd \
tesseract-ocr-data-por \
tesseract-ocr-data-spa \
font-noto \
ttf-droid \
unpaper \
&& rm -rf /var/cache/apk/*
+1
View File
@@ -13,5 +13,6 @@
*.jpg binary
*.bin binary
*.afdesign binary
*.ttf binary
.git_archival.txt export-subst
+14 -9
View File
@@ -22,11 +22,11 @@ jobs:
strategy:
matrix:
os: [ubuntu-22.04, ubuntu-24.04]
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python: ["3.11", "3.12", "3.13", "3.14"]
include:
- os: ubuntu-22.04
tesseract_ppa: "ppa"
python: "3.10"
python: "3.11"
env:
OS: ${{ matrix.os }}
@@ -57,6 +57,8 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
curl \
fonts-noto-core \
fonts-noto-cjk \
ghostscript \
jbig2dec \
img2pdf \
@@ -74,7 +76,7 @@ jobs:
- name: Install Python packages
run: |
uv sync --extra test --no-dev
uv sync --group test
- name: Report versions
run: |
@@ -102,7 +104,7 @@ jobs:
strategy:
matrix:
os: [macos-latest]
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python: ["3.11", "3.12", "3.13", "3.14"]
env:
OS: ${{ matrix.os }}
@@ -123,7 +125,9 @@ jobs:
jbig2enc \
openjpeg \
pngquant \
tesseract
poppler \
tesseract \
verapdf
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -137,7 +141,7 @@ jobs:
- name: Install Python packages
run: |
uv sync --extra test --no-dev
uv sync --group test
- name: Report versions
run: |
@@ -164,7 +168,7 @@ jobs:
strategy:
matrix:
os: [windows-latest]
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python: ["3.11", "3.12", "3.13", "3.14"]
env:
OS: ${{ matrix.os }}
@@ -187,12 +191,13 @@ jobs:
- name: Install system packages
run: |
choco install --yes --no-progress --pre tesseract
choco install --yes --no-progress tesseract
choco install --yes --no-progress --ignore-checksums ghostscript --version 9.56.1
choco install --yes --no-progress poppler --version=25.11.0
- name: Install Python packages
run: |
uv sync --extra test --no-dev
uv sync --group test
- name: Test
run: |
+1
View File
@@ -48,3 +48,4 @@ src/ocrmypdf/_version.py
.idea/
.aider*
CLAUDE.md
+5 -10
View File
@@ -10,17 +10,12 @@ repos:
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.0.261"
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.14.11"
hooks:
- id: ruff
files: "src/.*\\.pyi?$"
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
language_version: python
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.2.0
hooks:
+1 -1
View File
@@ -15,7 +15,7 @@ sphinx:
build:
os: ubuntu-22.04
tools:
python: "3.10"
python: "3.11"
python:
install:
+12 -2
View File
@@ -126,8 +126,8 @@ Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF
## Feature demo
```bash
# Add an OCR layer and convert to PDF/A
ocrmypdf input.pdf output.pdf
# Add an OCR layer and require PDF/A
ocrmypdf --output-type pdfa input.pdf output.pdf
# Convert an image to single page PDF
ocrmypdf input.jpg output.pdf
@@ -151,6 +151,16 @@ For more features, see the [documentation](https://ocrmypdf.readthedocs.io/en/la
In addition to the required Python version, OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
## Plugins
OCRmyPDF provides a plugin interface allowing its capabilities to be extended or replaced. Here are some plugins we are aware of:
- [OCRmyPDF-AppleOCR](https://github.com/mkyt/ocrmypdf-AppleOCR): replaces the standard Tesseract OCR engine with Apple Vision Framework. Requires macOS.
- [OCRmyPDF-EasyOCR](https://github.com/ocrmypdf/OCRmyPDF-EasyOCR): replaces the standard Tesseract OCR engine with EasyOCR, a newer OCR engine based on PyTorch. GPU strongly recommended.
- [OCRmyPDF-PaddleOCR](https://github.com/clefru/ocrmypdf-paddleocr): replaces the standard Tesseract OCR engine with PaddleOCR, a powerful GPU accelerated OCR engine.
[paperless-ngx](https://docs.paperless-ngx.com/) provides integration of OCRmyPDF into a searchable document management system.
## Press & Media
- [Going paperless with OCRmyPDF](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
+2 -8
View File
@@ -167,15 +167,9 @@ SPDX-FileCopyrightText = [
SPDX-License-Identifier = "Zlib"
[[annotations]]
path = "src/ocrmypdf/data/pdf.ttf"
path = "src/ocrmypdf/data/Occulta.ttf"
precedence = "aggregate"
SPDX-FileCopyrightText = [
"(C) 2014 Ray Smith",
"(C) 2015 Ken Sharp",
"(C) 2016 James R. Barlow",
"(C) 2016 Jeff Breidenbach",
"(C) 2017 Zdenko Podobný",
]
SPDX-FileCopyrightText = ["(C) 2026 James R. Barlow"]
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
+176 -23
View File
@@ -58,6 +58,38 @@ disk space.
OCRmyPDF provides many features to control the behavior of the OCR
engine, Tesseract.
### OCR processing mode
:::{versionadded} 17.0.0
The `--mode` (`-m`) argument consolidates OCR processing options.
:::
OCRmyPDF provides a unified `--mode` argument to control how pages with
existing text are handled:
| Mode | Behavior | Legacy equivalent |
|------|----------|-------------------|
| `default` | Error if text is found | (no flag) |
| `force` | Rasterize all content and run OCR | `--force-ocr` |
| `skip` | Skip pages with existing text | `--skip-text` |
| `redo` | Re-OCR pages, stripping old OCR layer | `--redo-ocr` |
```bash
# Skip pages that already have text
ocrmypdf --mode skip input.pdf output.pdf
# or equivalently:
ocrmypdf -m skip input.pdf output.pdf
# Force OCR on all pages (rasterizes everything)
ocrmypdf --mode force input.pdf output.pdf
# Re-do OCR, replacing old invisible text
ocrmypdf --mode redo input.pdf output.pdf
```
The legacy flags (`--force-ocr`, `--skip-text`, `--redo-ocr`) remain as
silent aliases for backward compatibility.
### When OCR is skipped
If a page in a PDF seems to have text, by default OCRmyPDF will exit
@@ -65,13 +97,13 @@ without modifying the PDF. This is to ensure that PDFs that were
previously OCRed or were "born digital" rather than scanned are not
processed.
If `--skip-text` is issued, then no image processing or OCR will be
If `--mode skip` (or `--skip-text`) is issued, then no image processing or OCR will be
performed on pages that already have text. The page will be copied to
the output. This may be useful for documents that contain both "born
digital" and scanned content, or to use OCRmyPDF to normalize and
convert to PDF/A regardless of their contents.
If `--redo-ocr` is issued, then a detailed text analysis is performed.
If `--mode redo` (or `--redo-ocr`) is issued, then a detailed text analysis is performed.
Text is categorized as either visible or invisible. Invisible text (OCR)
is stripped out. Then an image of each page is created with visible text
masked out. The page image is sent for OCR, and any additional text is
@@ -82,7 +114,7 @@ technically printable or visible in some way, perhaps by drawing it and
then painting over it. OCRmyPDF cannot distinguish this type of OCR
text from real text, so it will not be "redone".
If `--force-ocr` is issued, then all pages will be rasterized to
If `--mode force` (or `--force-ocr`) is issued, then all pages will be rasterized to
images, discarding any hidden OCR text, rasterizing any printable
text, and flattening form fields or interactive objects into their visual
representation. This is useful for redoing OCR, for fixing OCR text
@@ -257,44 +289,85 @@ Their use may interfere with `--rotate-pages` and other features.
It is currently not possible to use advanced Tesseract OCR features, such as creating
OCR information, when using Tesseract through OCRmyPDF.
## Changing the PDF renderer
## Choosing a PDF rasterizer
:::{versionadded} 17.0.0
:::
rasterizing
: Converting a PDF to an image for display.
: Converting a PDF page to an image for OCR processing.
OCRmyPDF supports two PDF rasterizers:
| Rasterizer | Package | Advantages | Disadvantages |
|------------|---------|------------|---------------|
| pypdfium2 | Python package | Faster, fewer version issues | Requires pypdfium2 package |
| Ghostscript | System binary | More widely packaged | Version consistency issues, restrictive AGPLv3 |
The `--rasterizer` argument controls which rasterizer is used:
```bash
# Automatic selection (default) - prefers pypdfium when available
ocrmypdf --rasterizer auto input.pdf output.pdf
# Force pypdfium2
ocrmypdf --rasterizer pypdfium input.pdf output.pdf
# Force Ghostscript
ocrmypdf --rasterizer ghostscript input.pdf output.pdf
```
pypdfium2 is a Python binding for pdfium, the PDF rendering library used
by Google Chrome and Chromium. It generally produces output identical to
Ghostscript but with better performance.
:::{note}
If pypdfium2 is not installed and `--rasterizer pypdfium` is requested,
OCRmyPDF will exit with an error. Install it with: `pip install pypdfium2`
:::
## Changing the PDF renderer
rendering
: Creating a new PDF from other data (such as an existing PDF).
OCRmyPDF has these PDF renderers: `sandwich` and `hocr`. The
:::{versionchanged} 17.0.0
The fpdf2 renderer is now the default, replacing the legacy hOCR renderer.
:::
OCRmyPDF uses PDF renderers to create the invisible text layer. The
renderer may be selected using `--pdf-renderer`. The default is
`auto` which lets OCRmyPDF select the renderer to use. Currently,
`auto` always selects `hocr`.
`auto` which selects `fpdf2`.
### The `hocr` renderer
### The `fpdf2` renderer (default)
:::{versionchanged} 16.0.0
:::{versionadded} 17.0.0
:::
The fpdf2 renderer creates text layers using the fpdf2 library. It provides:
- Full multilingual support including RTL languages (Arabic, Hebrew, Persian)
- Accurate text positioning aligned with OCR bounding boxes
- Improved "Occulta" glyphless font handling:
- Zero-width markers are properly handled
- Double-width CJK characters are properly sized
- Direct OcrElement tree input (no hOCR intermediate format required)
The fpdf2 renderer is the recommended choice for all installations.
:::{note}
The fpdf2 renderer may be slightly slower than the legacy hocrtransform
renderer for some workloads. This is an area of ongoing optimization.
:::
In both renderers, a text-only layer is rendered and sandwiched (overlaid)
on to either the original PDF page, or newly rasterized version of the
original PDF page (when `--force-ocr` is used). In this way, loss
original PDF page (when `--mode force` is used). In this way, loss
of PDF information is generally avoided. (You may need to disable PDF/A
conversion and optimization to eliminate all lossy transformations.)
The current approach used by the new hOCR renderer is a re-implementation
of Tesseract's PDF renderer, using the same Glyphless font and general
ideas, but fixing many technical issues that impeded it. The new hocr
provides better text placement accuracy, avoids issues with word
segmentation, and provides better positioning of skewed text.
Using the experimental API, it is also possible to edit the OCR output
from Tesseract, using any tool that is capable of editing hOCR files.
Older versions of this renderer did not support non-Latin languages, but
it is now universal.
### The `sandwich` renderer
The `sandwich` renderer uses Tesseract's text-only PDF feature,
@@ -310,6 +383,11 @@ When image preprocessing features like `--deskew` are used, the
original PDF will be rendered as a full page and the OCR layer will be
placed on top.
### Legacy renderer options
The `hocr` and `hocrdebug` renderer options are deprecated and
automatically redirect to `fpdf2`. They will be removed in a future version.
## Rendering and rasterizing options
:::{versionadded} 14.3.0
@@ -341,6 +419,81 @@ curves. In this case, you may want to use a different color conversion
strategy. The `--color-conversion-strategy` option allows you to select a
different strategy, such as `RGB`.
## PDF/A output modes
:::{versionchanged} 17.0.0
The default `--output-type` is now `auto` instead of `pdfa`.
:::
OCRmyPDF can produce PDF/A compliant output for long-term archival. The
`--output-type` argument controls PDF/A conversion:
| Output type | Behavior |
|-------------|----------|
| `auto` | Best-effort PDF/A without requiring Ghostscript (default) |
| `pdfa` | PDF/A-2b via Ghostscript |
| `pdfa-1` | PDF/A-1b via Ghostscript |
| `pdfa-2` | PDF/A-2b via Ghostscript (same as `pdfa`) |
| `pdfa-3` | PDF/A-3b via Ghostscript |
| `pdf` | Standard PDF, no PDF/A conversion |
| `none` | No output file (useful with `--sidecar`) |
### Speculative PDF/A conversion
:::{versionadded} 17.0.0
:::
When `--output-type auto` is used (the default), OCRmyPDF attempts a
fast "speculative" PDF/A conversion that avoids Ghostscript when possible:
1. OCRmyPDF adds an sRGB ICC profile and PDF/A XMP metadata using pikepdf
2. If verapdf is available, it validates the result
3. If validation passes, Ghostscript is skipped entirely
4. If validation fails or verapdf is unavailable, falls back to Ghostscript
This approach is faster and avoids some Ghostscript limitations (such as
image transcoding), but only works for PDFs that are already "mostly"
PDF/A compliant.
### PDF/A conversion flow
The following diagram illustrates the PDF/A conversion decision tree:
```{mermaid}
flowchart TD
A[Start] --> B{--output-type?}
B -->|pdf| C[Output standard PDF]
B -->|pdfa/pdfa-N| D[Use Ghostscript]
B -->|auto| E[Attempt speculative conversion]
E --> F["Add sRGB ICC + XMP metadata (pikepdf)"]
F --> G{verapdf available?}
G -->|No| H{Ghostscript available?}
G -->|Yes| I[Validate with verapdf]
I --> J{Validation passed?}
J -->|Yes| K[Output PDF/A - Ghostscript skipped]
J -->|No| H
H -->|Yes| D
H -->|No| L[Output standard PDF + WARNING]
D --> M[Ghostscript PDF/A conversion]
M --> N[Output PDF/A]
style K fill:#90EE90
style N fill:#90EE90
style L fill:#FFB6C1
```
:::{warning}
**Breaking change:** If neither Ghostscript nor verapdf is installed,
`--output-type auto` will produce a standard PDF instead of PDF/A.
This is a change from previous versions where Ghostscript was required
and PDF/A was always produced.
:::
## Return code policy
OCRmyPDF writes all messages to `stderr`. `stdout` is reserved for
+62 -4
View File
@@ -13,8 +13,49 @@ subprocess call anyway, as this provides isolation of its activities.
## Example
OCRmyPDF provides one high-level function to run its main engine from an
application. The parameters are symmetric to the command line arguments
and largely have the same functions.
application.
```{versionchanged} 17.0
The {func}`ocrmypdf.ocr` function now accepts an {class}`~ocrmypdf.OcrOptions`
object as its first argument, providing a cleaner API with full type hints
and validation. The previous positional argument style remains supported.
```
### Modern API (recommended)
The recommended way to call {func}`ocrmypdf.ocr` is to construct an
{class}`~ocrmypdf.OcrOptions` object with all settings, then pass it
as the sole argument:
```python
import ocrmypdf
from ocrmypdf import OcrOptions
if __name__ == '__main__': # To ensure correct behavior on Windows and macOS
options = OcrOptions(
input_file='input.pdf',
output_file='output.pdf',
deskew=True,
languages=['eng'],
)
ocrmypdf.ocr(options)
```
{class}`~ocrmypdf.OcrOptions` is a Pydantic model that provides:
- Full type hints and IDE autocompletion
- Validation of option values at construction time
- Clear documentation of all available options
```{versionadded} 17.0
The {class}`~ocrmypdf.OcrOptions` class is now exported from the top-level
`ocrmypdf` module.
```
### Legacy API
For compatibility with OCRmyPDF < v17, the traditional calling style
with positional arguments is still fully supported:
```python
import ocrmypdf
@@ -23,7 +64,7 @@ if __name__ == '__main__': # To ensure correct behavior on Windows and macOS
ocrmypdf.ocr('input.pdf', 'output.pdf', deskew=True)
```
With some exceptions, all of the command line arguments are available
With this style, all of the command line arguments are available
and may be passed as equivalent keywords.
A few differences are that `verbose` and `quiet` are not available.
@@ -51,9 +92,12 @@ OCRmyPDF fails for any reason. For example:
```python
from multiprocessing import Process
import ocrmypdf
from ocrmypdf import OcrOptions
def ocrmypdf_process():
ocrmypdf.ocr('input.pdf', 'output.pdf')
options = OcrOptions(input_file='input.pdf', output_file='output.pdf')
ocrmypdf.ocr(options)
def call_ocrmypdf_from_my_app():
p = Process(target=ocrmypdf_process)
@@ -117,3 +161,17 @@ handler. OCRmyPDF will clean up its temporary files and worker processes
automatically when an exception occurs.
When OCRmyPDF succeeds conditionally, it returns an integer exit code.
### Plugin Development Changes
```{versionchanged} 16.13
Plugin hooks now receive {class}`~ocrmypdf.OcrOptions` objects instead of
`argparse.Namespace`.
```
- {class}`~ocrmypdf.OcrOptions` provides the same attribute access as `Namespace` (duck-typing compatible)
- Plugin developers should update type hints: `from ocrmypdf import OcrOptions`
- Built-in plugins no longer modify options in-place for better immutability
Most existing plugins will continue working without modification due to the
duck-typing compatibility between {class}`~ocrmypdf.OcrOptions` and `Namespace`.
+7
View File
@@ -13,6 +13,13 @@ should be mainly of interest to plugin developers.
:members:
```
## ocrmypdf._options
```{eval-rst}
.. automodule:: ocrmypdf._options
:members: OcrOptions
```
## ocrmypdf.exceptions
```{eval-rst}
+4
View File
@@ -117,6 +117,10 @@ tend to give better performance. watcher.py works on all platforms.
Users may need to customize the script to meet their requirements.
:::{code} bash
# Using uv (recommended)
uv sync --extra watcher
# Or using pip
pip3 install ocrmypdf[watcher]
env OCR_INPUT_DIRECTORY=/mnt/input-pdfs \
+4 -2
View File
@@ -25,10 +25,11 @@
# sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------
from __future__ import annotations
needs_sphinx = '8'
import datetime
import datetime as dt
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
@@ -41,6 +42,7 @@ extensions = [
'sphinx.ext.napoleon',
'sphinx.ext.imgconverter', # PDF docs needs this for SVG to PNG conversion
'sphinx_issues',
'sphinxcontrib.mermaid',
]
myst_enable_extensions = ['colon_fence', 'attrs_block', 'attrs_inline', 'substitution']
@@ -62,7 +64,7 @@ master_doc = 'index'
# General information about the project.
project = 'ocrmypdf'
year = str(datetime.date.today().year)
year = str(dt.date.today().year)
copyright = (
f'{year}, James R. Barlow. '
+ 'Licensed under Creative Commons Attribution-ShareAlike 4.0'
+73 -6
View File
@@ -215,15 +215,22 @@ ocrmypdf --deskew --clean --rotate-pages input.pdf output.pdf
Don\'t actually OCR my PDF
--------------------------
If you set `--tesseract-timeout 0` OCRmyPDF will apply its image
processing without performing OCR (by causing OCR to time out). This
works if all you want to is to apply image processing or PDF/A
If you set `--ocr-engine none` OCRmyPDF will apply its image processing without
performing OCR. This works if all you want to is to apply image processing or PDF/A
conversion.
```bash
ocrmypdf --tesseract-timeout 0 --remove-background input.pdf output.pdf
ocrmypdf --ocr-engine none --deskew --output-type pdfa input.pdf output.pdf
```
:::{versionchanged} v17.0.0
Prior to this version, `--tesseract-timeout 0` was recommended as an idiom
to turn off OCR. This is not longer recommended, as we move away from
Tesseract OCR as the primary OCR engine.
:::
:::{versionchanged} v14.1.0
Prior to this version, `--tesseract-timeout 0` would prevent other uses
@@ -238,7 +245,7 @@ This is getting ridiculous, but OCRmyPDF can complete strip all textual
information from a PDF and reconstruct it as a \"bag of images\" PDF.
```bash
ocrmypdf --tesseract-timeout 0 --force-ocr input.pdf output.pdf
ocrmypdf --ocr-engine none --force-ocr input.pdf output.pdf
```
Why would you want to do this? Perhaps you have a PDF where OCR fails to
@@ -250,9 +257,69 @@ This command also removes OCR generated by third party tools.
You can also optimize all images without performing any OCR:
```bash
ocrmypdf --tesseract-timeout 0 --optimize 3 --skip-text input.pdf output.pdf
ocrmypdf --ocr-engine none --optimize 3 --skip-text input.pdf output.pdf
```
## Using v17 features
### Select a rasterizer
:::{versionadded} 17.0.0
:::
OCRmyPDF can use pypdfium2 or Ghostscript to rasterize PDF pages. pypdfium2
is generally faster and is preferred when available.
```bash
# Automatic selection (default) - prefers pypdfium when available
ocrmypdf --rasterizer auto input.pdf output.pdf
# Explicitly use pypdfium2 (requires pip install pypdfium2)
ocrmypdf --rasterizer pypdfium input.pdf output.pdf
# Explicitly use Ghostscript
ocrmypdf --rasterizer ghostscript input.pdf output.pdf
```
### PDF/A without Ghostscript
:::{versionadded} 17.0.0
:::
With verapdf installed, OCRmyPDF can produce PDF/A without using Ghostscript
for conversion. This is faster and avoids some Ghostscript limitations.
```bash
# Uses speculative conversion with verapdf validation (default)
ocrmypdf --output-type auto input.pdf output.pdf
# Explicitly request Ghostscript-based PDF/A conversion
ocrmypdf --output-type pdfa input.pdf output.pdf
```
### Using --mode instead of legacy flags
:::{versionadded} 17.0.0
:::
The `--mode` (`-m`) flag consolidates OCR behavior options:
```bash
# Instead of --skip-text
ocrmypdf --mode skip input.pdf output.pdf
# Instead of --force-ocr
ocrmypdf --mode force input.pdf output.pdf
# Instead of --redo-ocr
ocrmypdf --mode redo input.pdf output.pdf
# Short form
ocrmypdf -m skip input.pdf output.pdf
```
The legacy flags continue to work as aliases.
### Process only certain pages
You can ask OCRmyPDF to only apply [image processing](#image-processing)
+96 -13
View File
@@ -487,7 +487,7 @@ You can then run OCRmyPDF in the Windows command prompt or Powershell, prefixing
First install the the following prerequisite Cygwin packages using `setup-x86_64.exe`:
```
python310 (or later)
python311 (or later)
python3?-devel
python3?-pip
python3?-lxml
@@ -599,21 +599,45 @@ OCRmyPDF currently requires these external programs and libraries to be
installed, and must be satisfied using the operating system package
manager. `pip` cannot provide them.
:::{versionchanged} 17.0.0
Ghostscript is now optional. pypdfium2 can be used for PDF rasterization,
and verapdf can validate speculative PDF/A conversion.
:::
The following versions are required:
- Python 3.10 or newer
- Ghostscript 9.54 or newer
- Python 3.11 or newer
- Tesseract 4.1.1 or newer
- jbig2enc 0.29 or newer
- pngquant 2.5 or newer
- unpaper 6.1
- 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)
- 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.
:::
We recommend 64-bit versions of all software. (32-bit versions are not
supported, although on Linux, they may still work.)
jbig2enc, pngquant, and unpaper are optional. If missing certain
features are disabled. OCRmyPDF will discover them as soon as they are
available.
**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`
**pypdfium2**, if present, provides fast PDF page rasterization using
the pdfium library (the same library used by Google Chrome). It is
preferred over Ghostscript when available due to better performance.
Install with: `pip install pypdfium2`
**verapdf**, if present, enables fast speculative PDF/A conversion.
OCRmyPDF attempts to create PDF/A by adding metadata and ICC profiles
using pikepdf, then validates with verapdf. If validation passes,
Ghostscript is skipped entirely. See your distribution's package manager
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
@@ -623,6 +647,12 @@ 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`.
:::{warning}
Lossy JBIG2 encoding (`--jbig2-lossy`) has been removed in v17.0.0 due to
well-documented risks of character substitution errors. Only lossless
JBIG2 compression is now supported.
:::
**pngquant**, if present, is optionally used to optimize the encoding of
PNG-style images in PDFs (actually, any that are that losslessly
encoded) by lossily quantizing to a smaller color palette. It is only
@@ -638,7 +668,7 @@ unfortunately, the `pip install` command cannot satisfy all of them.
## Installing HEAD revision from sources
If you have `git` and Python 3.10 or newer installed, you can install
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
dependencies are missing.
@@ -686,18 +716,71 @@ need to be installed. The script requires specific versions of the
dependencies. Older version than the ones mentioned in the release notes
are likely not to be compatible to OCRmyPDF.
## Optional Features
OCRmyPDF provides optional features and development tools. We recommend using `uv` as your package manager.
### Installing User Features
User features are available as optional dependencies. Install them with `uv` (recommended) or `pip`:
```bash
# Using uv (recommended)
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 use dependency groups and require `uv`:
```bash
# Testing infrastructure
uv sync --group test
# Documentation building
uv sync --group docs
# Enhanced Streamlit development
uv sync --group streamlit-dev
# All development groups
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
- Required for development (testing, docs)
- Better dependency resolution
- Consistent across all platforms
Install uv: `pip install uv` or visit https://docs.astral.sh/uv/
### For development
To install all of the development and test requirements:
```bash
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
python -m venv .venv
source .venv/bin/activate
cd OCRmyPDF
pip install -e .[test]
pip install uv # Install uv if not already installed
uv sync --group test
```
Note: Development requires `uv`. The old `pip install -e .[test]` method is no longer supported.
To add JBIG2 encoding, see {ref}`jbig2`.
## Shell completions
+25 -5
View File
@@ -77,10 +77,17 @@ straightforward, and any PDF viewer can handle PDF/A files.
OCRmyPDF analyzes each page of a PDF to determine the required colorspace
and resolution (DPI) for capturing all the information on that page without
losing content. It uses
[Ghostscript](http://ghostscript.com/) to rasterize each page and subsequently
performs OCR on the rasterized image to generate an OCR "layer." This layer
is then integrated back into the original PDF.
losing content. It uses a PDF rasterizer (pypdfium2 or
[Ghostscript](http://ghostscript.com/)) to convert each page to an image and
subsequently performs OCR on the rasterized image to generate an OCR "layer."
This layer is then integrated back into the original PDF.
:::{versionchanged} 17.0.0
OCRmyPDF now supports pypdfium2 as an alternative rasterizer to Ghostscript.
pypdfium2 is a Python binding for pdfium, the PDF rendering library used by
Google Chrome. The `--rasterizer auto` setting (default) prefers pypdfium2
when available.
:::
While it is possible to use a program like Ghostscript or ImageMagick to
obtain an image and then run that image through Tesseract OCR, this process
@@ -156,7 +163,16 @@ These limitations are inherent to any software relying on Tesseract:
the text and its bounding box. As such, the generated PDF does not
contain any information about the document's structure.
Ghostscript also imposes some limitations:
### Ghostscript considerations
:::{versionchanged} 17.0.0
Ghostscript is no longer strictly required. OCRmyPDF can use pypdfium2
for rasterization and verapdf for PDF/A validation.
:::
While Ghostscript remains a capable and feature-rich tool with a long history,
recent releases have introduced some compatibility challenges that OCRmyPDF
v17 addresses through alternative codepaths. When Ghostscript is used:
- PDFs containing JPEG 2000-encoded content may be converted to JPEG
encoding, which may introduce compression artifacts, if Ghostscript
@@ -173,6 +189,10 @@ Ghostscript also imposes some limitations:
- Ghostscript's PDF/A conversion may remove or deactivate
hyperlinks and other active content.
When pypdfium2 and verapdf are available, many of these limitations can be
avoided by using the speculative PDF/A conversion path (enabled by default
with `--output-type auto`).
You can use `--output-type pdf` to disable PDF/A conversion and produce
a standard, non-archival PDF.
+15 -27
View File
@@ -43,33 +43,21 @@ be required depending on your system.
[sudo] apt install autotools-dev automake libtool libleptonica-dev pkg-config
:::
{#jbig2-lossy}
## JBIG2 Compression
## Lossy mode JBIG2
OCRmyPDF uses JBIG2 lossless compression for bitonal (black and white)
images. This provides excellent compression ratios compared to the older
CCITT G4 standard, while preserving the exact pixel content of the
original image.
OCRmyPDF provides lossy mode JBIG2 as an advanced and potentially
dangerous feature. Users should [review the technical concerns with
JBIG2 in lossy mode](https://en.wikipedia.org/wiki/JBIG2#Disadvantages)
and decide if this feature is acceptable for their use case. In general,
this mode should not be used for archival purposes, should not be used
when the original document is not available or will be destroyed, and
should not be used when numbers present in the document are important,
because there is a risk of 6/8 and 8/6 substitution errors.
You can adjust the threshold for JBIG2 compression with
`--jbig2-threshold`. The default is 0.85.
JBIG2 lossy mode does achieve higher compression ratios than any other
monochrome (bitonal) compression technology; for large text documents
the savings are considerable. JBIG2 lossless still gives great
compression ratios and is a major improvement over the older CCITT G4
standard.
To turn on JBIG2 lossy mode, add the argument `--jbig2-lossy`.
`--optimize {1,2,3}` are necessary for the argument to take effect also
required. Also, a JBIG2 encoder must be installed as described in the
previous section.
You can adjust the threshold for JBIG2 compression with the
`--jbig2-threshold`. The default is 0.85, meaning that if two symbols
are 85% similar, they will be compressed together.
*Due to an oversight, ocrmypdf v7.0 and v7.1 used lossy mode by
default.*
:::{note}
Previous versions of OCRmyPDF supported a lossy JBIG2 mode
(`--jbig2-lossy`). This feature has been removed due to the well-known
risk of character substitution errors (e.g., 6/8 confusion). See
[JBIG2 disadvantages](https://en.wikipedia.org/wiki/JBIG2#Disadvantages)
for more information on why lossy JBIG2 is problematic. The `--jbig2-lossy`
and `--jbig2-page-group-size` arguments are now ignored with a warning.
:::
+114 -6
View File
@@ -20,12 +20,48 @@ with much stiffer build requirements. If you want to use OCRmyPDF on
some novel platform or distribution, first make sure you can package
pikepdf.
### Non-Python dependencies
### Core dependencies
Note that we have non-Python dependencies. In particular, OCRmyPDF
requires Ghostscript and Tesseract OCR to be installed and needs to be
able to locate their binaries on the system PATH. On Windows, OCRmyPDF
will also check the registry for their locations.
:::{versionchanged} 17.0.0
Ghostscript is no longer strictly required. OCRmyPDF now supports alternative
codepaths for both PDF rasterization and PDF/A conversion.
:::
OCRmyPDF has the following runtime dependencies:
**For PDF rasterization** (converting PDF pages to images for OCR):
- `pypdfium2` (Python package) - OR -
- `ghostscript` (system binary)
- Recommendation: Install both for best compatibility
**For PDF/A conversion**:
- `verapdf` (system binary) with pikepdf's speculative conversion - OR -
- `ghostscript` (system binary)
- Recommendation: Install both for best compatibility
**For OCR**:
- `tesseract-ocr` (system binary) - Required for MVP
**For text rendering** (expressing OCR results in PDF):
- `fpdf2` (Python package) - Required for text layer rendering
- `uharfbuzz` (Python package) - Required for text layer rendering
- `font-noto` (system package) - Recommended for text layer rendering
**Other dependencies**:
- `unpaper` (system binary) - Optional, enables `--clean` and `--clean-final`
- `pngquant` (system binary) - Optional, enables `--optimize 2` and `--optimize 3`
- `jbig2enc` (system binary) - Optional, improves compression of monochrome images
While Ghostscript remains a capable and feature-rich tool with a long history,
recent releases have introduced some compatibility challenges that OCRmyPDF v17
addresses through alternative codepaths. For the best user experience, packagers
should install both Ghostscript and the alternative tools (pypdfium2, verapdf)
when available.
On Windows, OCRmyPDF will also check the registry for Tesseract and Ghostscript
locations.
Tesseract OCR relies on SIMD for performance and only has proper support
for this on ARM and x86\_64. Performance may be poor on other processor
@@ -48,7 +84,79 @@ override versioning for some reason.
OCRmyPDF will use jbig2enc, a JBIG2 encoder, if one can be found. Some
distributions have shied away from packaging JBIG2 because it contains
patented algorithms, but all patents have expired since 2017. If
possible, consider packaging it too to improve OCRmyPDF\'s compression.
possible, consider packaging it too to improve OCRmyPDF's compression.
:::{note}
Lossy JBIG2 encoding has been removed in v17.0.0 due to well-documented
risks of character substitution errors. Previously we provided this feature
on a "caveat emptor" basis but in the interest of focusing and eliminating
risks, we decided to remove this option. Now, only lossless JBIG2 compression
is supported.
:::
### Dependency matrix for packagers
:::{versionadded} 17.0.0
:::
The following table summarizes the dependency options introduced in v17.0.0:
| 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 (Python) | - | Required, replaces legacy hOCR renderer |
| OCR | tesseract-ocr | `--ocr-engine none` | Can be skipped entirely |
**Minimum viable installation:**
- tesseract-ocr + (pypdfium2 OR ghostscript) + fpdf2
**Recommended installation:**
- tesseract-ocr + pypdfium2 + ghostscript + verapdf + fpdf2 + unpaper + pngquant + jbig2enc
:::{warning}
If Ghostscript is not installed and verapdf is not available, PDF/A output
cannot be produced. The output will be a standard PDF instead. This is a
breaking change for rare configurations that previously relied on PDF/A
output without Ghostscript alternatives.
:::
**Sample debian/control dependency specification**
```
Depends:
fonts-noto,
fpdf2 (>= 2.8),
ghostscript (>= 9.55), # Not strictly required, but best user experience
icc-profiles-free,
img2pdf,
python3-coloredlogs,
python3-deprecation,
python3-pdfminer (>= 20181108+dfsg-3),
python3-pikepdf (>= 8.14.0),
python3-pil,
python3-pluggy,
python3-reportlab,
python3-rich,
python3-uharfbuzz, # Not currently in Debian
tesseract-ocr (>= 5.0.0),
zlib1g,
${misc:Depends},
${python3:Depends},
Recommends:
cyclopts, # Not currently in Debian
jbig2
paddleocr, # Not currently in Debian
pngquant,
pypdfium2, # Not currently in Debian
unpaper,
verapdf, # Not currently in Debian
Suggests:
ocrmypdf-doc,
python-watchdog,
```
### Command line completions
-7
View File
@@ -28,9 +28,6 @@ header-rows: 1
- Enables lossless optimizations, such as transcoding images to more
efficient formats. Also compress other uncompressed objects in the
PDF and enables the more efficient "object streams" within the PDF.
(If ``--jbig2-lossy`` is issued, then lossy JBIG2 optimization is used.
The decision to use lossy JBIG2 is separate from standard optimization
settings.)
* - ``--optimize 2``
- ``-O2``
- All of the above, and enables lossy optimizations and color quantization.
@@ -105,7 +102,3 @@ quality image may be suitable for storage after OCR.
It is not possible to optimize all image types. Uncommon image types may
be skipped by the optimizer.
OCRmyPDF provides `lossy mode JBIG2 <jbig2-lossy>`{.interpreted-text
role="ref"} as an advanced feature that additional requires the argument
`--jbig2-lossy`.
+166
View File
@@ -164,6 +164,77 @@ chaining operations.
.. autofunction:: ocrmypdf.pluginspec.check_options
```
### Plugin option models
Plugins can define their own option models using Pydantic. This allows plugins to:
- Define type-safe option structures with validation
- Add CLI arguments that map to their option model fields
- Access options via nested namespaces (e.g., `options.tesseract.timeout`)
```{eval-rst}
.. autofunction:: ocrmypdf.pluginspec.register_options
```
Plugin options can be accessed in two ways:
1. **Flat access** (backward compatible): `options.tesseract_timeout`
2. **Nested access**: `options.tesseract.timeout`
Both access patterns are equivalent and return the same values.
:::{note}
**Plugin Interface Change**: Starting in OCRmyPDF v17.0.0, plugin hooks receive
`OcrOptions` objects instead of `argparse.Namespace` objects. Most plugins will
continue working due to duck-typing compatibility, but plugin developers should
update their type hints accordingly.
:::
### Migration guide for plugin developers
:::{versionadded} 17.0.0
:::
**Update imports:**
```python
from ocrmypdf._options import OcrOptions
```
**Update type hints:**
```python
# Before (v16 and earlier)
def check_options(options: argparse.Namespace) -> None:
...
# After (v17+)
def check_options(options: OcrOptions) -> None:
...
```
**Attribute access unchanged:**
```python
# These work exactly as before
options.languages
options.output_type
options.tesseract_timeout
```
**Remove in-place modifications:**
```python
# Before (v16 pattern - no longer recommended)
def check_options(options):
options.some_computed_value = compute_value(options)
# After (v17 pattern - compute at point of use)
def some_function(options):
computed = compute_value(options)
use_computed(computed)
```
### Execution and progress reporting
```{eval-rst}
@@ -248,3 +319,98 @@ chaining operations.
```{eval-rst}
.. autofunction:: ocrmypdf.pluginspec.is_optimization_enabled
```
### Working with OcrElement trees
:::{versionadded} 17.0.0
:::
OCRmyPDF v17 introduces the `OcrElement` dataclass for representing OCR
output in an engine-agnostic format. This enables plugins to work with
OCR results without parsing hOCR XML.
**Key classes:**
```python
from ocrmypdf import OcrElement, OcrClass, BoundingBox
# OcrElement - represents any OCR structural unit
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(0, 0, 612, 792),
children=[...]
)
# BoundingBox - axis-aligned bounding box (left, top, right, bottom)
bbox = BoundingBox(left=100, top=50, right=300, bottom=80)
# OcrClass - constants for element types
OcrClass.PAGE # "ocr_page"
OcrClass.LINE # "ocr_line"
OcrClass.WORD # "ocrx_word"
OcrClass.PARAGRAPH # "ocr_par"
```
**Navigating the tree:**
```python
# Get all words in a page
words = page.words # Returns list[OcrElement]
# Get all lines
lines = page.lines
# Get combined text
text = page.get_text_recursive()
# Iterate by class
for para in page.paragraphs:
print(para.get_text_recursive())
```
**OCR engine plugins:**
Plugins implementing custom OCR engines can now output `OcrElement` trees
directly via the `generate_ocr()` method, bypassing hOCR entirely:
```python
from pathlib import Path
from ocrmypdf.pluginspec import OcrEngine
from ocrmypdf import OcrElement, OcrClass, BoundingBox
class MyOcrEngine(OcrEngine):
def generate_ocr(
self,
input_file: Path,
options,
context,
) -> OcrElement:
# Perform OCR and return OcrElement tree directly
# No need to generate hOCR XML
return OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(0, 0, width, height),
dpi=300,
children=[
OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(100, 50, 500, 80),
children=[
OcrElement(
ocr_class=OcrClass.WORD,
bbox=BoundingBox(100, 50, 200, 80),
text="Hello",
),
# ... more words
]
),
# ... more lines
]
)
def supports_generate_ocr(self) -> bool:
return True # Indicate this engine uses generate_ocr()
```
This approach is simpler than generating hOCR and allows modern OCR
engines to integrate more naturally with OCRmyPDF.
+96
View File
@@ -25,6 +25,102 @@ 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)
**Breaking changes**
- **Plugin interface migration**: Plugin hooks now receive `OcrOptions` objects instead of
`argparse.Namespace` objects. Most plugins will continue working due to duck-typing
compatibility, but plugin developers should update their type hints from `Namespace`
to `OcrOptions`.
- Built-in plugins no longer modify options in-place, improving immutability and
code clarity.
- **Lossy JBIG2 removed**: The `--jbig2-lossy` and `--jbig2-page-group-size` options have been
removed due to well-documented risks of character substitution errors. These options are now
deprecated and will emit warnings if used. Only lossless JBIG2 compression is supported.
- **PDF/A output behavior change**: If neither Ghostscript nor verapdf is installed,
`--output-type auto` (the new default) will produce a standard PDF instead of PDF/A. This is
a change from previous versions where Ghostscript was required and PDF/A was always produced.
This configuration is rare but users should be aware of the change.
**New features**
- **pypdfium2 rasterizer**: Added optional pypdfium2-based PDF rasterization plugin as an
alternative to Ghostscript for page rendering. Use `--rasterizer pypdfium` to enable
(requires `pip install pypdfium2`). The default `--rasterizer auto` prefers pypdfium when
available and falls back to Ghostscript.
- **Pluggable OCR engines**: New `--ocr-engine` option allows selecting OCR engines:
- `auto` (default): Uses Tesseract
- `tesseract`: Explicit Tesseract selection
- `none`: Skip OCR entirely for PDF processing-only workflows
This prepares the foundation for future third-party OCR engine plugins.
- **Smart PDF/A conversion**: New `--output-type auto` (now the default) produces best-effort
PDF/A output without requiring Ghostscript when the verapdf validator is available. Falls back
to traditional Ghostscript conversion when needed.
- **verapdf integration**: Added optional verapdf validation for fast PDF/A conversion. When
available, OCRmyPDF attempts speculative PDF/A conversion using pikepdf, validates with verapdf,
and skips Ghostscript if validation passes.
- **Optional Ghostscript**: As a consequence of the changes above, Ghostscript is no longer a required dependency. It is optional.
- **fpdf2 text renderer**: Replaced legacy hOCR text renderer with new fpdf2-based implementation,
providing better multilingual support and more accurate text positioning.
- **Improved Occulta glyphless font**: The new Occulta font provides better handling of
zero-width markers and double-width CJK characters for accurate text layer positioning.
- **Expanded multilingual font support**: Added FontProvider infrastructure with language-aware
font selection for Devanagari (Hindi, Sanskrit, Marathi, Nepali), CJK (Chinese, Japanese,
Korean), Arabic script, and many other scripts. System font discovery reduces package size.
- **Simplified mode selection**: New `--mode` (`-m`) argument consolidates processing options:
- `default`: Error if text is found (standard behavior)
- `force`: Rasterize all content and run OCR (replaces `--force-ocr`)
- `skip`: Skip pages with existing text (replaces `--skip-text`)
- `redo`: Re-OCR pages, stripping old text layer (replaces `--redo-ocr`)
Legacy flags remain as silent aliases for backward compatibility.
**API improvements**
- Centralized validation logic in the `OcrOptions` Pydantic model
- Removed scattered option mutation throughout the codebase
- Better type safety for plugin development
- Simplified plugin option handling
- New `OcrElement`, `OcrClass`, and `BoundingBox` exports for OCR engine plugin developers
- Extended `OcrEngine` ABC with `generate_ocr()` method for direct OCR tree output, eliding the need to translate a modern engine's output to hOCR or directly write to PDF.
**Bug fixes**
- Fixed double-compression of already-deflated JPEGs.
- Fixed tesseract_cache plugin to properly handle cache misses.
- Fixed handling of PDF page boxes (ArtBox, BleedBox) which were not being processed correctly.
- Added thread safety lock to pypdfium plugin for concurrent operations.
- Improved pdfminer.six compatibility with explicit word spacing.
**Documentation**
- Updated cookbook to replace deprecated `--tesseract-timeout 0` with `--ocr-engine none`.
- Added comprehensive plugin documentation for new OCR engine framework.
**Dependency changes**
- Requires: one of `pypdfium2` or `ghostscript` for PDF rasterization (PDF to image)
- Preferred: both
- Requires: one of `verapdf` or `ghostscript` for PDF/A generation
- Preferred: both
- Recommended: `pypdfium2` for PDF rasterization (new dependency)
- Recommended: `ghostscript` (used to be Required)
- Recommended: Noto fonts for improved OCR text positioning
- Optional: `verapdf` for fast PDF/A validation (new dependency)
- Requires: `fpdf2` for text layer rendering (new dependency)
- Recommended: replace `typer` with `cyclopts` in misc scripts (new dependency)
- See docs/maintainers.md for details.
**Migration guide for plugin developers**
- Update imports: `from ocrmypdf._options import OcrOptions`
- Update type hints: `def check_options(options: OcrOptions)` instead of `options: Namespace`
- Attribute access remains unchanged: `options.languages`, `options.output_type`, etc.
- Remove any in-place option modifications - compute values at point of use instead
- Most existing plugins will continue working without changes due to duck-typing
## v16.13.0
- Added detection and repair for Ghostscript 10.6 JPEG corruption. When GS 10.6
+42 -41
View File
@@ -96,8 +96,9 @@ with st.expander("Optimization after OCR"):
png_quality = st.slider(
"PNG quality", min_value=0, max_value=100, value=75, key="png_quality"
)
jbig2_lossy = st.checkbox("JBIG2 lossy (dangerous)", value=False, key="jbig2_lossy")
jbig2_threshold = st.number_input("JBIG2 threshold", value=0, key="jbig2_threshold")
jbig2_threshold = st.number_input(
"JBIG2 threshold", value=0.85, key="jbig2_threshold"
)
with st.expander("Advanced options"):
jobs = st.slider(
@@ -189,51 +190,51 @@ if uploaded:
args.append(f"--jpeg-quality={jpeg_quality}")
if optimize > '0' and png_quality:
args.append(f"--png-quality={png_quality}")
if jbig2_lossy:
args.append("--jbig2-lossy")
if jbig2_threshold:
args.append(f"--jbig2-threshold={jbig2_threshold}")
if jobs:
args.append(f"--jobs={jobs}")
input_file = NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}")
input_file.write(uploaded.getvalue())
input_file.flush()
input_file.seek(0)
args.append(str(input_file.name))
output_file = NamedTemporaryFile(delete=True, suffix=".pdf")
args.append(str(output_file.name))
with NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}") as input_file:
input_file.write(uploaded.getvalue())
input_file.flush()
input_file.seek(0)
args.append(str(input_file.name))
with NamedTemporaryFile(delete=True, suffix=".pdf") as output_file:
args.append(str(output_file.name))
st.session_state['running'] = (
'run_button' in st.session_state and st.session_state.run_button
)
if st.button(
"Run OCRmyPDF",
disabled=st.session_state.get("running", False),
key='run_button',
):
st.session_state['running'] = True
args = [sys.executable, '-u', '-m', "ocrmypdf"] + args
st.session_state['running'] = (
'run_button' in st.session_state and st.session_state.run_button
)
if st.button(
"Run OCRmyPDF",
disabled=st.session_state.get("running", False),
key='run_button',
):
st.session_state['running'] = True
args = [sys.executable, '-u', '-m', "ocrmypdf"] + args
proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
with st.container(border=True):
while proc.poll() is None:
line = proc.stderr.readline()
if line:
st.html("<code>" + line.decode().strip() + "</code>")
proc = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
with st.container(border=True):
while proc.poll() is None:
line = proc.stderr.readline()
if line:
st.html("<code>" + line.decode().strip() + "</code>")
if proc.returncode != 0:
st.error(f"ocrmypdf failed with exit code {proc.returncode}")
st.session_state['running'] = False
st.stop()
if proc.returncode != 0:
st.error(f"ocrmypdf failed with exit code {proc.returncode}")
st.session_state['running'] = False
st.stop()
if Path(output_file.name).stat().st_size == 0:
st.error("No output PDF file was generated")
st.stop()
if Path(output_file.name).stat().st_size == 0:
st.error("No output PDF file was generated")
st.stop()
st.download_button(
label="Download output PDF",
data=output_file.read(),
file_name=uploaded.name,
mime="application/pdf",
)
st.session_state['running'] = False
st.download_button(
label="Download output PDF",
data=output_file.read(),
file_name=uploaded.name,
mime="application/pdf",
)
st.session_state['running'] = False
+1 -4
View File
@@ -39,10 +39,7 @@ script_dir = Path(__file__).parent
# set archive_dir to a path for backup original documents. Leave empty if not required.
archive_dir = "/pdfbak"
if len(sys.argv) > 1:
start_dir = Path(sys.argv[1])
else:
start_dir = Path(".")
start_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
if len(sys.argv) > 2:
log_file = Path(sys.argv[2])
+1
View File
@@ -3,6 +3,7 @@
# SPDX-License-Identifier: MIT
"""Helper script for bisecting PDFs to find a page with an issue."""
from __future__ import annotations
import sys
+69 -8
View File
@@ -21,14 +21,13 @@ __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)
@@ -42,9 +41,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 +54,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 +74,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 +121,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 +218,46 @@ 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_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 +289,18 @@ __ocrmypdf_check_previous()
__ocrmypdf_pdf-renderer
return 0
;;
-m|--mode)
__ocrmypdf_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 +320,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
;;
+34 -4
View File
@@ -11,8 +11,16 @@ 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"
@@ -32,7 +40,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 +51,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 +148,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)")
+1 -1
View File
@@ -108,7 +108,7 @@ def main():
doc1 = pymupdf.open(os.path.join(d, "output1.pdf"))
doc2 = pymupdf.open(os.path.join(d, "output2.pdf"))
for i, page1_2 in enumerate(zip(doc1, doc2)):
for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)):
st.write(f"Page {i+1}")
page1, page2 = page1_2
col1, col2 = st.columns(2)
+1 -1
View File
@@ -62,7 +62,7 @@ def main():
with st.expander("Text"):
doc1 = pymupdf.open(os.path.join(d, "1.pdf"))
doc2 = pymupdf.open(os.path.join(d, "2.pdf"))
for i, page1_2 in enumerate(zip(doc1, doc2)):
for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)):
st.write(f"Page {i+1}")
page1, page2 = page1_2
col1, col2 = st.columns(2)
+29 -18
View File
@@ -5,34 +5,45 @@
from __future__ import annotations
from pathlib import Path
from subprocess import run
from tempfile import NamedTemporaryFile
from typing import Annotated
import typer
import cyclopts
app = cyclopts.App()
@app.default
def main(
pdf1: Annotated[typer.FileBinaryRead, typer.Argument()],
pdf2: Annotated[typer.FileBinaryRead, typer.Argument()],
engine: Annotated[str, typer.Option()] = 'pdftotext',
pdf1: Annotated[Path, cyclopts.Parameter()],
pdf2: Annotated[Path, cyclopts.Parameter()],
*,
engine: Annotated[str, cyclopts.Parameter()] = 'pdftotext',
):
"""Compare text in PDFs."""
with open(pdf1, 'rb') as f1, open(pdf2, 'rb') as f2:
text1 = run(
['pdftotext', '-layout', '-', '-'],
stdin=f1,
capture_output=True,
check=True,
)
text2 = run(
['pdftotext', '-layout', '-', '-'],
stdin=f2,
capture_output=True,
check=True,
)
text1 = run(
['pdftotext', '-layout', '-', '-'], stdin=pdf1, capture_output=True, check=True
)
text2 = run(
['pdftotext', '-layout', '-', '-'], stdin=pdf2, capture_output=True, check=True
)
with NamedTemporaryFile() as f1, NamedTemporaryFile() as f2:
f1.write(text1.stdout)
f1.flush()
f2.write(text2.stdout)
f2.flush()
with NamedTemporaryFile() as t1, NamedTemporaryFile() as t2:
t1.write(text1.stdout)
t1.flush()
t2.write(text2.stdout)
t2.flush()
diff = run(
['diff', '--color=always', '--side-by-side', f1.name, f2.name],
['diff', '--color=always', '--side-by-side', t1.name, t2.name],
capture_output=True,
)
run(['less', '-R'], input=diff.stdout, check=True)
@@ -43,4 +54,4 @@ def main(
if __name__ == '__main__':
typer.run(main)
app()
+45 -62
View File
@@ -7,18 +7,18 @@
from __future__ import annotations
import datetime as dt
import json
import logging
import shutil
import sys
import time
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Annotated, Any
import cyclopts
import pikepdf
import typer
from dotenv import load_dotenv
from watchdog.events import PatternMatchingEventHandler
from watchdog.observers import Observer
@@ -30,7 +30,7 @@ load_dotenv()
# pylint: disable=logging-format-interpolation
app = typer.Typer(name="ocrmypdf-watcher")
app = cyclopts.App(name="ocrmypdf-watcher")
log = logging.getLogger('ocrmypdf-watcher')
@@ -48,7 +48,7 @@ class LoggingLevelEnum(str, Enum):
def get_output_path(root: Path, basename: str, output_dir_year_month: bool) -> Path:
assert '/' not in basename, "basename must not contain '/'"
if output_dir_year_month:
today = datetime.today()
today = dt.datetime.today()
output_directory_year_month = root / str(today.year) / f'{today.month:02d}'
if not output_directory_year_month.exists():
output_directory_year_month.mkdir(parents=True, exist_ok=True)
@@ -114,9 +114,11 @@ def execute_ocrmypdf(
f'kwargs: {ocrmypdf_kwargs}'
)
exit_code = ocrmypdf.ocr(
input_file=file_path,
output_file=output_path,
**ocrmypdf_kwargs,
ocrmypdf.OcrOptions(
input_file=file_path,
output_file=output_path,
**ocrmypdf_kwargs,
)
)
if exit_code == 0:
if on_success_delete:
@@ -138,7 +140,7 @@ class HandleObserverEvent(PatternMatchingEventHandler):
ignore_patterns=None,
ignore_directories=False,
case_sensitive=False,
settings={},
settings=None,
):
super().__init__(
patterns=patterns,
@@ -146,117 +148,101 @@ class HandleObserverEvent(PatternMatchingEventHandler):
ignore_directories=ignore_directories,
case_sensitive=case_sensitive,
)
self._settings = settings
self._settings = settings if settings else {}
def on_any_event(self, event):
if event.event_type in ['created']:
execute_ocrmypdf(file_path=Path(event.src_path), **self._settings)
@app.command()
@app.default
def main(
input_dir: Annotated[
Path,
typer.Argument(
envvar='OCR_INPUT_DIRECTORY',
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
resolve_path=True,
cyclopts.Parameter(
env_var='OCR_INPUT_DIRECTORY',
),
] = '/input',
] = Path('/input'),
output_dir: Annotated[
Path,
typer.Argument(
envvar='OCR_OUTPUT_DIRECTORY',
exists=True,
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
cyclopts.Parameter(
env_var='OCR_OUTPUT_DIRECTORY',
),
] = '/output',
] = Path('/output'),
archive_dir: Annotated[
Path,
typer.Argument(
envvar='OCR_ARCHIVE_DIRECTORY',
exists=True,
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
cyclopts.Parameter(
env_var='OCR_ARCHIVE_DIRECTORY',
),
] = '/processed',
] = Path('/processed'),
*,
output_dir_year_month: Annotated[
bool,
typer.Option(
envvar='OCR_OUTPUT_DIRECTORY_YEAR_MONTH',
cyclopts.Parameter(
env_var='OCR_OUTPUT_DIRECTORY_YEAR_MONTH',
help='Create a subdirectory in the output directory for each year/month',
),
] = False,
on_success_delete: Annotated[
bool,
typer.Option(
envvar='OCR_ON_SUCCESS_DELETE',
cyclopts.Parameter(
env_var='OCR_ON_SUCCESS_DELETE',
help='Delete the input file after successful OCR',
),
] = False,
on_success_archive: Annotated[
bool,
typer.Option(
envvar='OCR_ON_SUCCESS_ARCHIVE',
cyclopts.Parameter(
env_var='OCR_ON_SUCCESS_ARCHIVE',
help='Archive the input file after successful OCR',
),
] = False,
deskew: Annotated[
bool,
typer.Option(
envvar='OCR_DESKEW',
cyclopts.Parameter(
env_var='OCR_DESKEW',
help='Deskew the input file before OCR',
),
] = False,
ocr_json_settings: Annotated[
str,
typer.Option(
envvar='OCR_JSON_SETTINGS',
str | None,
cyclopts.Parameter(
env_var='OCR_JSON_SETTINGS',
help='JSON settings to pass to OCRmyPDF (JSON string or file path)',
),
] = None,
poll_new_file_seconds: Annotated[
int,
typer.Option(
envvar='OCR_POLL_NEW_FILE_SECONDS',
cyclopts.Parameter(
env_var='OCR_POLL_NEW_FILE_SECONDS',
help='Seconds to wait before polling a new file',
min=0,
),
] = 1,
use_polling: Annotated[
bool,
typer.Option(
envvar='OCR_USE_POLLING',
cyclopts.Parameter(
env_var='OCR_USE_POLLING',
help='Use polling instead of filesystem events',
),
] = False,
retries_loading_file: Annotated[
int,
typer.Option(
envvar='OCR_RETRIES_LOADING_FILE',
cyclopts.Parameter(
env_var='OCR_RETRIES_LOADING_FILE',
help='Number of times to retry loading a file before giving up',
min=0,
),
] = 5,
loglevel: Annotated[
LoggingLevelEnum,
typer.Option(
envvar='OCR_LOGLEVEL',
cyclopts.Parameter(
env_var='OCR_LOGLEVEL',
help='Logging level',
),
] = LoggingLevelEnum.INFO,
patterns: Annotated[
str,
typer.Option(
envvar='OCR_PATTERNS',
cyclopts.Parameter(
env_var='OCR_PATTERNS',
help='File patterns to watch',
),
] = '*.pdf,*.PDF',
@@ -316,13 +302,10 @@ def main(
'output_dir_year_month': output_dir_year_month,
},
)
if use_polling:
observer = PollingObserver()
else:
observer = Observer()
observer = PollingObserver() if use_polling else Observer()
observer.schedule(handler, input_dir, recursive=True)
observer.start()
typer.echo(f"Watching {input_dir} for new PDFs. Press Ctrl+C to exit.")
print(f"Watching {input_dir} for new PDFs. Press Ctrl+C to exit.")
try:
while True:
time.sleep(30)
+3 -1
View File
@@ -4,6 +4,8 @@
"""Run the OCRmyPDF web service."""
from __future__ import annotations
import os
import sys
@@ -13,7 +15,7 @@ except ImportError:
raise ImportError(
'You need to install streamlit in the Python environment '
'to run the web service.\n'
)
) from None
if __name__ == '__main__':
os.execvp(
+56 -29
View File
@@ -10,9 +10,10 @@ dynamic = ["version"]
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
readme = "README.md"
license = "MPL-2.0"
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"deprecation>=2.1.0",
"fpdf2>=2.8.0",
"img2pdf>=0.5",
"packaging>=20",
"pdfminer.six>=20220319",
@@ -20,7 +21,10 @@ dependencies = [
"pikepdf>=10",
"Pillow>=10.0.1",
"pluggy>=1",
"pydantic>=2.12.5",
"pypdfium2>=5.0.0",
"rich>=13",
"uharfbuzz>=0.53.2",
]
authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }]
classifiers = [
@@ -48,20 +52,8 @@ Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues"
Changelog = "https://github.com/ocrmypdf/OCRmyPDF/docs/release_notes.md"
[project.optional-dependencies]
docs = ["myst-parser>=4.0.1", "sphinx", "sphinx-issues", "sphinx-rtd-theme"]
extended_test = ["PyMuPDF>=1.19.1"]
test = [
"coverage[toml]>=6.2",
"hypothesis>=6.36.0",
"pytest>=6.2.5",
"pytest-cov>=3.0.0",
"pytest-xdist>=2.5.0",
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
"reportlab>=3.6.8",
"types-Pillow",
"types-humanfriendly",
]
watcher = ["watchdog>=1.0.2", "typer-slim[standard]", "python-dotenv"]
# User-installable features - use `uv sync --extra <name>` or `pip install ocrmypdf[name]`
watcher = ["watchdog>=1.0.2", "cyclopts>=3", "python-dotenv"]
webservice = ["streamlit>=1.41.0"]
[project.scripts]
@@ -74,7 +66,7 @@ source = "vcs"
version-file = "src/ocrmypdf/_version.py"
[tool.distutils.bdist_wheel]
python-tag = "py310"
python-tag = "py311"
[tool.coverage.run]
branch = true
@@ -125,27 +117,43 @@ module = [
ignore_missing_imports = true
[tool.ruff]
target-version = "py310"
target-version = "py311"
exclude = ["src/ocrmypdf/_version.py"] # Autogenerated
[tool.ruff.lint]
"select" = [
"D", # pydocstyle
"E", # pycodestyle
"W", # pycodestyle
"F", # pyflakes
"I001", # isort
"UP", # pyupgrade
"D", # pydocstyle
"E", # pycodestyle
"W", # pycodestyle
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"SIM", # simplify
"B", # flake8-bugbear
"ICN", # flake8-import-conventions
]
ignore = [
"B028", # warning with no explicit stacklevel
# rule is key in dict instead of key in dict.keys(); but pikepdf semantics differ
"SIM118",
]
[tool.ruff.lint.isort]
known-first-party = ["ocrmypdf"]
required-imports = ["from __future__ import annotations"]
[tool.ruff.lint.flake8-import-conventions]
# Prohibit explicit imports from the 'datetime' module
banned-from = ["datetime"]
# Optionally, suggest an alias for 'import datetime' (e.g., as dt)
extend-aliases = { "datetime" = "dt" }
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.per-file-ignores]
"docs/conf.py" = ["D100", "D101", "D105"]
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105"]
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105", "E501"]
"misc/*.py" = ["D103", "D101", "D102"]
"src/ocrmypdf/builtin_plugins/*.py" = ["D103", "D102", "D105"]
@@ -153,10 +161,29 @@ convention = "google"
quote-style = "preserve"
[dependency-groups]
dev = [
"mypy>=1.13.0",
# Developer-only tools - use `uv sync --group <name>`
dev = ["mypy>=1.13.0", "ipykernel>=6.29.5", "reportlab>=4.4.4"]
test = [
# Core testing framework
"coverage[toml]>=6.2",
"hypothesis>=6.36.0",
"pytest>=6.2.5",
"pytest-cov>=3.0.0",
"pytest-xdist>=2.5.0",
# Test dependencies
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
"reportlab>=3.6.8",
# Type stubs for testing
"types-Pillow",
"types-humanfriendly",
# Extended test capabilities (merged from extended_test)
"pymupdf>=1.24.14",
"streamlit-pdf-viewer>=0.0.19",
"streamlit>=1.40.2",
"ipykernel>=6.29.5",
]
docs = [
"myst-parser>=4.0.1",
"sphinx",
"sphinx-issues",
"sphinx-rtd-theme",
"sphinxcontrib-mermaid",
]
streamlit-dev = ["streamlit>=1.40.2", "streamlit-pdf-viewer>=0.0.19"]
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Generate the Occulta glyphless font for OCRmyPDF.
Occulta (Latin for "hidden") is a glyphless font designed for invisible text layers
in searchable PDFs. It has proper Unicode cmap coverage using format 13 (many-to-one)
for efficient mapping of all BMP codepoints to a small set of width-specific glyphs.
Features:
- Full BMP coverage (U+0000 to U+FFFF)
- Width-aware glyphs for proper text selection:
- Zero-width for combining marks and invisible characters
- Regular width (500 units) for Latin, Greek, Cyrillic, Arabic, Hebrew, etc.
- Double width (1000 units) for CJK and fullwidth characters
- Uses cmap format 13 (many-to-one) for ~12KB size vs ~780KB with format 12
- Compatible with fpdf2 and other modern PDF libraries
Usage:
python scripts/generate_glyphless_font.py
Output:
src/ocrmypdf/data/Occulta.ttf
"""
from __future__ import annotations
import unicodedata
from pathlib import Path
from fontTools.fontBuilder import FontBuilder
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._c_m_a_p import CmapSubtable
from fontTools.ttLib.tables._g_l_y_f import Glyph
# Output path relative to this script
OUTPUT_PATH = Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" / "Occulta.ttf"
# Font metrics (units per em = 1000)
UNITS_PER_EM = 1000
ASCENT = 800
DESCENT = -200
# Glyph definitions: (name, advance_width, left_side_bearing)
GLYPHS = [
(".notdef", 500, 0), # Required, used for unmapped characters
("space", 500, 0), # U+0020 SPACE
("nbspace", 500, 0), # U+00A0 NO-BREAK SPACE
("blank0", 0, 0), # Zero-width (combining marks, ZWNJ, ZWJ, BOM)
("blank1", 500, 0), # Regular width (most scripts)
("blank2", 1000, 0), # Double width (CJK, fullwidth)
]
# Explicit zero-width character codepoints
ZERO_WIDTH_CHARS = frozenset(
[
0x200B, # ZERO WIDTH SPACE
0x200C, # ZERO WIDTH NON-JOINER
0x200D, # ZERO WIDTH JOINER
0xFEFF, # ZERO WIDTH NO-BREAK SPACE (BOM)
0x200E, # LEFT-TO-RIGHT MARK
0x200F, # RIGHT-TO-LEFT MARK
0x202A, # LEFT-TO-RIGHT EMBEDDING
0x202B, # RIGHT-TO-LEFT EMBEDDING
0x202C, # POP DIRECTIONAL FORMATTING
0x202D, # LEFT-TO-RIGHT OVERRIDE
0x202E, # RIGHT-TO-LEFT OVERRIDE
0x2060, # WORD JOINER
0x2061, # FUNCTION APPLICATION
0x2062, # INVISIBLE TIMES
0x2063, # INVISIBLE SEPARATOR
0x2064, # INVISIBLE PLUS
]
)
def classify_codepoint(codepoint: int) -> str:
"""Classify a Unicode codepoint into one of our glyph categories.
Args:
codepoint: Unicode codepoint (0x0000 to 0xFFFF)
Returns:
Glyph name to map this codepoint to
"""
# Special cases first
if codepoint == 0x0020:
return "space"
if codepoint == 0x00A0:
return "nbspace"
if codepoint in ZERO_WIDTH_CHARS:
return "blank0"
# Use Unicode properties for the rest
char = chr(codepoint)
try:
category = unicodedata.category(char)
east_asian_width = unicodedata.east_asian_width(char)
# Combining marks are zero-width
if category.startswith("M"):
return "blank0"
# Wide and Fullwidth characters are double-width
if east_asian_width in ("W", "F"):
return "blank2"
# Everything else is regular width
return "blank1"
except (ValueError, TypeError):
# Fallback for any edge cases
return "blank1"
def build_cmap() -> dict[int, str]:
"""Build the Unicode to glyph name mapping for the entire BMP.
Returns:
Dictionary mapping codepoints to glyph names
"""
return {cp: classify_codepoint(cp) for cp in range(0x10000)}
def create_font() -> TTFont:
"""Create the Occulta glyphless font.
Returns:
TTFont object ready to be saved
"""
glyph_names = [g[0] for g in GLYPHS]
# Start building the font
fb = FontBuilder(UNITS_PER_EM, isTTF=True)
fb.setupGlyphOrder(glyph_names)
# Create empty (invisible) glyphs
glyphs = {}
for name, _, _ in GLYPHS:
glyph = Glyph()
glyph.numberOfContours = 0
glyphs[name] = glyph
fb.setupGlyf(glyphs)
# Set up horizontal metrics
metrics = {name: (width, lsb) for name, width, lsb in GLYPHS}
fb.setupHorizontalMetrics(metrics)
# Minimal cmap to satisfy FontBuilder (we'll replace it later)
fb.setupCharacterMap({0x0020: "space", 0x00A0: "nbspace"})
# Set up other required tables
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
fb.setupOS2(
sTypoAscender=ASCENT,
sTypoDescender=DESCENT,
sTypoLineGap=0,
usWinAscent=UNITS_PER_EM,
usWinDescent=abs(DESCENT),
sxHeight=500,
sCapHeight=700,
)
import time
# Use current time for font timestamps
now = int(time.time())
fb.setupHead(unitsPerEm=UNITS_PER_EM, created=now, modified=now)
fb.setupPost()
fb.setupNameTable(
{
"familyName": "Occulta",
"styleName": "Regular",
"uniqueFontIdentifier": "OCRmyPDF;Occulta-Regular;2026",
"fullName": "Occulta Regular",
"version": "Version 2.0",
"psName": "Occulta-Regular",
}
)
# Build the font
font = fb.font
# Now replace the cmap with format 13 for efficient many-to-one mapping
char_to_glyph = build_cmap()
cmap13 = CmapSubtable.newSubtable(13)
cmap13.platformID = 3 # Windows
cmap13.platEncID = 10 # Unicode full repertoire
cmap13.language = 0
cmap13.cmap = char_to_glyph
font["cmap"].tables = [cmap13]
return font
def main() -> None:
"""Generate the Occulta font and save it."""
print("Generating Occulta glyphless font...")
font = create_font()
# Create output directory if needed
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
# Save the font
font.save(str(OUTPUT_PATH))
font.close()
# Report statistics
size = OUTPUT_PATH.stat().st_size
print(f"Saved to: {OUTPUT_PATH}")
print(f"Size: {size:,} bytes")
# Verify cmap
font = TTFont(str(OUTPUT_PATH))
for table in font["cmap"].tables:
print(
f"cmap: Platform {table.platformID}, "
f"Encoding {table.platEncID}, "
f"Format {table.format}, "
f"{len(table.cmap)} mappings"
)
font.close()
print("Done!")
if __name__ == "__main__":
main()
+8 -13
View File
@@ -5,7 +5,7 @@
name: ocrmypdf
title: OCRmyPDF
base: core22
base: core24
version: git
summary: OCRmyPDF adds a searchable text layer to scanned PDF files
description: OCRmyPDF packaged for snap
@@ -14,12 +14,13 @@ confinement: strict
icon: docs/images/logo-square-256.svg
license: MPL-2.0
architectures: [amd64]
platforms:
amd64:
environment:
TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/4.00/tessdata
GS_LIB: $SNAP/usr/share/ghostscript/9.55.0/Resource/Init
GS_FONTPATH: $SNAP/usr/share/ghostscript/9.55.0/Resource/Font
TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/5/tessdata
GS_LIB: $SNAP/usr/share/ghostscript/10.02.1/Resource/Init
GS_FONTPATH: $SNAP/usr/share/ghostscript/10.02.1/Resource/Font
LD_LIBRARY_PATH: $SNAP/usr/lib/x86_64-linux-gnu
apps:
@@ -84,11 +85,5 @@ parts:
- wheel
override-build: |
pip3 install --user dephell[full]
$HOME/.local/bin/dephell deps convert \
--from-path pyproject.toml \
--from-format pyproject \
--to-path setup.py \
--to-format setuppy
snapcraftctl build
ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
craftctl default
ln -sf ../usr/lib/libsnapcraft-preload.so $CRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
+14
View File
@@ -11,6 +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._pipelines._common import (
configure_debug_logging,
)
@@ -34,6 +35,13 @@ from ocrmypdf.exceptions import (
TesseractConfigError,
UnsupportedImageFormatError,
)
from ocrmypdf.models.ocr_element import (
Baseline,
BoundingBox,
FontInfo,
OcrClass,
OcrElement,
)
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
hookimpl = _HookimplMarker('ocrmypdf')
@@ -41,6 +49,8 @@ hookimpl = _HookimplMarker('ocrmypdf')
__all__ = [
'__version__',
'BadArgsError',
'Baseline',
'BoundingBox',
'configure_debug_logging',
'configure_logging',
'DpiError',
@@ -48,13 +58,17 @@ __all__ = [
'Executor',
'ExitCode',
'ExitCodeException',
'FontInfo',
'helpers',
'hocrtransform',
'hookimpl',
'InputFileError',
'MissingDependencyError',
'ocr',
'OcrClass',
'OcrElement',
'OcrEngine',
'OcrOptions',
'OrientationConfidence',
'OutputFileAccessError',
'PageContext',
+2 -2
View File
@@ -15,9 +15,9 @@ from contextlib import suppress
from ocrmypdf import __version__
from ocrmypdf._pipelines.ocr import run_pipeline_cli
from ocrmypdf._plugin_manager import get_parser_options_plugins
from ocrmypdf._validation import check_options
from ocrmypdf.api import Verbosity, configure_logging
from ocrmypdf.cli import get_options_and_plugins
from ocrmypdf.exceptions import (
BadArgsError,
ExitCode,
@@ -39,7 +39,7 @@ def sigbus(*args):
def run(args=None):
"""Run the ocrmypdf command line interface."""
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
options, plugin_manager = get_options_and_plugins(args=args)
with suppress(AttributeError, PermissionError):
os.nice(5)
+3 -3
View File
@@ -15,7 +15,7 @@ from ocrmypdf._progressbar import NullProgressBar, ProgressBar
T = TypeVar('T')
def _task_noop(*_args, **_kwargs):
def _task_noop(*_args, **_kwargs) -> None:
return
@@ -101,8 +101,8 @@ class Executor(ABC):
def setup_executor(plugin_manager) -> Executor:
pbar_class = plugin_manager.hook.get_progressbar_class()
return plugin_manager.hook.get_executor(progressbar_class=pbar_class)
pbar_class = plugin_manager.get_progressbar_class()
return plugin_manager.get_executor(progressbar_class=pbar_class)
class SerialExecutor(Executor):
+2
View File
@@ -2,6 +2,8 @@
# SPDX-License-Identifier: MPL-2.0
# Enforce English hegemony
from __future__ import annotations
DEFAULT_LANGUAGE = 'eng'
# Default rotation threshold
+19 -3
View File
@@ -9,7 +9,6 @@ import logging
import os
import re
from collections import deque
from io import BytesIO
from os import fspath
from pathlib import Path
from subprocess import PIPE, CalledProcessError
@@ -23,6 +22,7 @@ from ocrmypdf.exceptions import (
SubprocessOutputError,
)
from ocrmypdf.helpers import Resolution
from ocrmypdf.pluginspec import GhostscriptRasterDevice
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
COLOR_CONVERSION_STRATEGIES = frozenset(
@@ -99,15 +99,30 @@ def rasterize_pdf(
input_file: os.PathLike,
output_file: os.PathLike,
*,
raster_device: str,
raster_device: GhostscriptRasterDevice,
raster_dpi: Resolution,
pageno: int = 1,
page_dpi: Resolution | None = None,
rotation: int | None = None,
filter_vector: bool = False,
stop_on_error: bool = False,
use_cropbox: bool = False,
):
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units."""
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units.
Args:
input_file: The PDF file to rasterize.
output_file: The file to write the rasterized PDF to.
raster_device: The Ghostscript raster device to use to rasterize the PDF.
raster_dpi: Resolution in dots per inch at which to rasterize page.
pageno: Page number to rasterize (beginning at page 1).
page_dpi: Resolution, overriding output image DPI.
rotation: Cardinal angle, clockwise, to rotate page.
filter_vector: If True, remove vector graphics objects.
stop_on_error: If True, stop rasterizing on the first error.
use_cropbox: If True, rasterize the CropBox instead of MediaBox.
Default is False (use MediaBox).
"""
raster_dpi = raster_dpi.round(6)
if not page_dpi:
page_dpi = raster_dpi
@@ -124,6 +139,7 @@ def rasterize_pdf(
f'-dLastPage={pageno}',
f'-r{raster_dpi.x:f}x{raster_dpi.y:f}',
]
+ (['-dUseCropBox'] if use_cropbox else [])
+ (['-dFILTERVECTOR'] if filter_vector else [])
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
+ [
-18
View File
@@ -31,24 +31,6 @@ def available():
return True
def convert_group(cwd, infiles, out_prefix, threshold):
args = [
'jbig2',
'-b',
out_prefix,
'--symbol-mode', # symbol mode (lossy)
'-t',
str(threshold), # threshold
# '-r', # refinement mode (lossless symbol mode, currently disabled in
# jbig2)
'--pdf',
]
args.extend(infiles)
proc = run(args, cwd=cwd, stdout=PIPE, stderr=PIPE)
proc.check_returncode()
return proc
def convert_single(cwd, infile, outfile, threshold):
args = ['jbig2', '--pdf', '-t', str(threshold), infile]
with open(outfile, 'wb') as fstdout:
+79 -19
View File
@@ -6,8 +6,10 @@
from __future__ import annotations
import logging
import os
import re
from contextlib import suppress
from enum import IntEnum
from math import pi
from os import fspath
from pathlib import Path
@@ -26,11 +28,30 @@ from ocrmypdf.subprocess import get_version, run
log = logging.getLogger(__name__)
def _tesseract_env(omp_thread_limit: int | None) -> dict[str, str] | None:
"""Create environment dict with OMP_THREAD_LIMIT set for Tesseract subprocesses."""
if omp_thread_limit is None:
return None
env = os.environ.copy()
env['OMP_THREAD_LIMIT'] = str(omp_thread_limit)
return env
class ThresholdingMethod(IntEnum):
"""Tesseract thresholding methods for image binarization."""
AUTO = 0
OTSU = 0 # Alias for AUTO - uses Tesseract's default (legacy Otsu)
ADAPTIVE_OTSU = 1
SAUVOLA = 2
# Legacy dictionary for backward compatibility
TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
'auto': 0,
'otsu': 0,
'adaptive-otsu': 1,
'sauvola': 2,
'auto': ThresholdingMethod.AUTO,
'otsu': ThresholdingMethod.OTSU,
'adaptive-otsu': ThresholdingMethod.ADAPTIVE_OTSU,
'sauvola': ThresholdingMethod.SAUVOLA,
}
@@ -155,7 +176,10 @@ def _parse_tesseract_output(binary_output: bytes) -> dict[str, str]:
def get_orientation(
input_file: Path, engine_mode: int | None, timeout: float
input_file: Path,
engine_mode: int | None,
timeout: float,
omp_thread_limit: int | None = None,
) -> OrientationConfidence:
args_tesseract = tess_base_args(['osd'], engine_mode) + [
'--psm',
@@ -165,15 +189,24 @@ def get_orientation(
]
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
except TimeoutExpired:
return OrientationConfidence(angle=0, confidence=0.0)
except CalledProcessError as e:
tesseract_log_output(e.stdout)
tesseract_log_output(e.stderr)
# Check both stdout (e.output) and stderr for known non-fatal messages
all_output = (e.output or b'') + (e.stderr or b'')
if (
b'Too few characters. Skipping this page' in e.output
or b'Image too large' in e.output
b'Too few characters. Skipping this page' in all_output
or b'Image too large' in all_output
):
return OrientationConfidence(0, 0)
raise SubprocessOutputError() from e
@@ -199,7 +232,11 @@ def _is_empty_page_error(exc):
def get_deskew(
input_file: Path, languages: list[str], engine_mode: int | None, timeout: float
input_file: Path,
languages: list[str],
engine_mode: int | None,
timeout: float,
omp_thread_limit: int | None = None,
) -> float:
"""Gets angle to deskew this page, in degrees."""
args_tesseract = tess_base_args(languages, engine_mode) + [
@@ -210,7 +247,14 @@ def get_deskew(
]
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
except TimeoutExpired:
return 0.0
except CalledProcessError as e:
@@ -243,9 +287,9 @@ def tesseract_log_output(stream: bytes) -> None:
lines = text.splitlines()
for line in lines:
if line.startswith("Tesseract Open Source"):
continue
elif line.startswith("Warning in pixReadMem"):
if line.startswith(
("Tesseract Open Source", "Warning in pixReadMem")
):
continue
elif 'diacritics' in line:
tlog.warning("lots of diacritics - possibly poor OCR")
@@ -294,9 +338,10 @@ def generate_hocr(
tessconfig: list[str],
timeout: float,
pagesegmode: int,
thresholding: int,
thresholding: ThresholdingMethod,
user_words,
user_patterns,
omp_thread_limit: int | None = None,
) -> None:
"""Generate a hOCR file, which must be converted to PDF."""
prefix = output_hocr.with_suffix('')
@@ -306,7 +351,7 @@ def generate_hocr(
if pagesegmode is not None:
args_tesseract.extend(['--psm', str(pagesegmode)])
if thresholding != 0 and has_thresholding():
if thresholding != ThresholdingMethod.AUTO and has_thresholding():
args_tesseract.extend(['-c', f'thresholding_method={thresholding}'])
if user_words:
@@ -320,7 +365,14 @@ def generate_hocr(
args_tesseract.extend([fspath(input_file), fspath(prefix), 'hocr', 'txt'])
args_tesseract.extend(tessconfig)
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
stdout = p.stdout
except TimeoutExpired:
# Generate a HOCR file with no recognized text if tesseract times out
@@ -360,9 +412,10 @@ def generate_pdf(
tessconfig: list[str],
timeout: float,
pagesegmode: int,
thresholding: int,
thresholding: ThresholdingMethod,
user_words,
user_patterns,
omp_thread_limit: int | None = None,
) -> None:
"""Generate a PDF using Tesseract's internal PDF generator.
@@ -376,7 +429,7 @@ def generate_pdf(
args_tesseract.extend(['-c', 'textonly_pdf=1'])
if thresholding != 0 and has_thresholding():
if thresholding != ThresholdingMethod.AUTO and has_thresholding():
args_tesseract.extend(['-c', f'thresholding_method={thresholding}'])
if user_words:
@@ -393,7 +446,14 @@ def generate_pdf(
args_tesseract.extend([fspath(input_file), fspath(prefix), 'pdf', 'txt'])
args_tesseract.extend(tessconfig)
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
stdout = p.stdout
with suppress(FileNotFoundError):
prefix.with_suffix('.txt').replace(output_text)
+108
View File
@@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Interface to verapdf executable."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from subprocess import PIPE
from typing import NamedTuple
from packaging.version import Version
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version, run
log = logging.getLogger(__name__)
class ValidationResult(NamedTuple):
"""Result of PDF/A validation."""
valid: bool
failed_rules: int
message: str
def version() -> Version:
"""Get verapdf version."""
return Version(get_version('verapdf', regex=r'veraPDF (\d+(\.\d+)*)'))
def available() -> bool:
"""Check if verapdf is available."""
try:
version()
except MissingDependencyError:
return False
return True
def output_type_to_flavour(output_type: str) -> str:
"""Map OCRmyPDF output_type to verapdf flavour.
Args:
output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'
Returns:
verapdf flavour string like '1b', '2b', '3b'
"""
mapping = {
'pdfa': '2b',
'pdfa-1': '1b',
'pdfa-2': '2b',
'pdfa-3': '3b',
}
return mapping.get(output_type, '2b')
def validate(input_file: Path, flavour: str) -> ValidationResult:
"""Validate a PDF against a PDF/A profile.
Args:
input_file: Path to PDF file to validate
flavour: verapdf flavour (1a, 1b, 2a, 2b, 2u, 3a, 3b, 3u)
Returns:
ValidationResult with validation status
"""
args = [
'verapdf',
'--format',
'json',
'--flavour',
flavour,
str(input_file),
]
try:
proc = run(args, stdout=PIPE, stderr=PIPE, check=False)
except FileNotFoundError as e:
raise MissingDependencyError('verapdf') from e
try:
result = json.loads(proc.stdout)
jobs = result.get('report', {}).get('jobs', [])
if not jobs:
return ValidationResult(False, -1, 'No validation jobs in result')
validation_results = jobs[0].get('validationResult', [])
if not validation_results:
return ValidationResult(False, -1, 'No validation result in output')
validation_result = validation_results[0]
details = validation_result.get('details', {})
failed_rules = details.get('failedRules', 0)
if failed_rules == 0:
return ValidationResult(True, 0, 'PDF/A validation passed')
else:
return ValidationResult(
False,
failed_rules,
f'PDF/A validation failed with {failed_rules} rule violations',
)
except (json.JSONDecodeError, KeyError, TypeError) as e:
log.debug('Failed to parse verapdf output: %s', e)
return ValidationResult(False, -1, f'Failed to parse verapdf output: {e}')
+463 -195
View File
@@ -7,30 +7,155 @@ from __future__ import annotations
import logging
from contextlib import suppress
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ocrmypdf.hocrtransform import OcrElement
from pikepdf import (
Dictionary,
Matrix,
Name,
Operator,
Page,
Pdf,
PdfError,
Stream,
parse_content_stream,
unparse_content_stream,
)
from ocrmypdf._jobcontext import PdfContext
from ocrmypdf._options import ProcessingMode
from ocrmypdf._pipeline import VECTOR_PAGE_DPI
class RenderMode(Enum):
"""Controls where the OCR text layer is placed relative to page content.
ON_TOP: Text layer renders above page content (reserved for future use).
UNDERNEATH: Text layer renders below page content (current default behavior).
"""
ON_TOP = 0
UNDERNEATH = 1
@dataclass
class Fpdf2PageInfo:
"""Information needed to render and graft an fpdf2 page."""
pageno: int
hocr_path: Path
dpi: float
autorotate_correction: int
emplaced_page: bool
@dataclass
class Fpdf2ParsedPage:
"""Parsed page data ready for fpdf2 rendering."""
pageno: int
ocr_tree: OcrElement
dpi: float
autorotate_correction: int
emplaced_page: bool
# Alias for backward compatibility with plan documentation
Fpdf2DirectPage = Fpdf2ParsedPage
def _compute_text_misalignment(
content_rotation: int, autorotate_correction: int, emplaced_page: bool
) -> int:
"""Compute rotation needed to align text layer with page content.
Args:
content_rotation: Original page /Rotate value (degrees).
autorotate_correction: Rotation applied during rasterization (degrees).
emplaced_page: Whether the page content was replaced with rasterized image.
Returns:
Rotation in degrees to apply to text layer to align with content.
"""
if emplaced_page:
# New image is upright after autorotation was applied
content_rotation = autorotate_correction
text_rotation = autorotate_correction
return (text_rotation - content_rotation) % 360
def _compute_page_rotation(
content_rotation: int, autorotate_correction: int, emplaced_page: bool
) -> int:
"""Compute final page /Rotate value after grafting.
Args:
content_rotation: Original page /Rotate value (degrees).
autorotate_correction: Rotation applied during rasterization (degrees).
emplaced_page: Whether the page content was replaced with rasterized image.
Returns:
Final /Rotate value for the page.
"""
if emplaced_page:
content_rotation = autorotate_correction
return (content_rotation - autorotate_correction) % 360
def _build_text_layer_ctm(
text_width: float,
text_height: float,
page_width: float,
page_height: float,
page_origin_x: float,
page_origin_y: float,
text_rotation: int,
):
"""Build transformation matrix to align text layer with page content.
Args:
text_width: Width of text layer mediabox.
text_height: Height of text layer mediabox.
page_width: Width of target page mediabox.
page_height: Height of target page mediabox.
page_origin_x: X origin of target page mediabox.
page_origin_y: Y origin of target page mediabox.
text_rotation: Rotation in degrees (clockwise) to apply to text layer.
Returns:
pikepdf.Matrix transformation matrix, or None if no rotation needed.
"""
if text_rotation == 0:
return None
from pikepdf import Matrix
wt, ht = text_width, text_height
# Center text, rotate, scale to fit page, then position at page origin
translate = Matrix().translated(-wt / 2, -ht / 2)
untranslate = Matrix().translated(page_width / 2, page_height / 2)
corner = Matrix().translated(page_origin_x, page_origin_y)
# Negate rotation because input is clockwise angle
rotate = Matrix().rotated(-text_rotation % 360)
# Swap dimensions if 90 or 270 degree rotation
if text_rotation in (90, 270):
wt, ht = ht, wt
# Scale to fit page dimensions
scale_x = page_width / wt if wt else 1.0
scale_y = page_height / ht if ht else 1.0
scale = Matrix().scaled(scale_x, scale_y)
return translate @ rotate @ scale @ untranslate @ corner
log = logging.getLogger(__name__)
MAX_REPLACE_PAGES = 100
@@ -41,22 +166,6 @@ def _ensure_dictionary(obj: Dictionary | Stream, name: Name):
return obj[name]
def _update_resources(
*,
obj: Dictionary | Stream,
font: Dictionary | None,
font_key: Name | None,
):
"""Update this obj's fonts with a reference to the Glyphless font.
obj can be a page or Form XObject.
"""
resources = _ensure_dictionary(obj, Name.Resources)
fonts = _ensure_dictionary(resources, Name.Font)
if font_key is not None and font_key not in fonts:
fonts[font_key] = font
def strip_invisible_text(pdf: Pdf, page: Page):
stream = []
in_text_obj = False
@@ -72,12 +181,9 @@ def strip_invisible_text(pdf: Pdf, page: Page):
render_mode_stack.append(render_mode)
if operator == Operator('Q'):
try:
# IndexError is raised if stack is empty; try to carry on
with suppress(IndexError):
render_mode = render_mode_stack.pop()
except IndexError:
# Stack underflow: content stream is malformed
# but try to carry on
pass
if not in_text_obj:
if operator == Operator('BT'):
@@ -105,27 +211,45 @@ class OcrGrafter:
self.path_base = context.origin
self.pdf_base = Pdf.open(self.path_base)
self.font: Dictionary | None = None
self.font_key: Name | None = None
self.pdfinfo = context.pdfinfo
self.output_file = context.get_path('graft_layers.pdf')
self.emplacements = 1
self.interim_count = 0
self.render_mode = RenderMode.UNDERNEATH
# Check renderer type
pdf_renderer = context.options.pdf_renderer
self.use_sandwich_renderer = pdf_renderer == 'sandwich'
# For fpdf2: accumulate pages before rendering
self.fpdf2_hocr_pages: list[Fpdf2PageInfo] = []
self.fpdf2_parsed_pages: list[Fpdf2ParsedPage] = []
def graft_page(
self,
*,
pageno: int,
image: Path | None,
textpdf: Path | None,
ocr_output: Path | None,
ocr_tree: OcrElement | None,
autorotate_correction: int,
):
if textpdf and not self.font:
self.font, self.font_key = self._find_font(textpdf)
"""Graft OCR output onto a page of the base PDF.
Args:
pageno: Zero-based page number.
image: Path to the visible page image PDF, or None if not replacing.
ocr_output: Path to OCR output file. For fpdf2 renderer this is an
hOCR file; for sandwich renderer this is a text-only PDF.
ocr_tree: OCR tree for fpdf2 renderer.
autorotate_correction: Orientation correction in degrees (0, 90, 180, 270).
"""
if ocr_output and ocr_tree:
raise ValueError(
'Cannot specify both ocr_output and ocr_tree for fpdf2 renderer'
)
# Handle image emplacement first
emplaced_page = False
content_rotation = self.pdfinfo[pageno].rotation
path_image = Path(image).resolve() if image else None
@@ -144,195 +268,339 @@ class OcrGrafter:
del self.pdf_base.pages[-1]
emplaced_page = True
# Calculate if the text is misaligned compared to the content
if emplaced_page:
content_rotation = autorotate_correction
text_rotation = autorotate_correction
text_misaligned = (text_rotation - content_rotation) % 360
log.debug(
f"Text rotation: (text, autorotate, content) -> text misalignment = "
f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> "
f"{text_misaligned}"
)
if textpdf and self.font:
if self.font_key is None:
raise ValueError("Font key is not set")
# Graft the text layer onto this page, whether new or old, possibly
# rotating the text layer by the amount is misaligned.
strip_old = self.context.options.redo_ocr
self._graft_text_layer(
page_num=pageno + 1,
textpdf=textpdf,
font=self.font,
font_key=self.font_key,
text_rotation=text_misaligned,
strip_old_text=strip_old,
)
# Correct the overall page rotation if needed, now that the text and content
# are aligned
page_rotation = (content_rotation - autorotate_correction) % 360
self.pdf_base.pages[pageno].Rotate = page_rotation
log.debug(
f"Page rotation: (content, auto) -> page = "
f"({content_rotation}, {autorotate_correction}) -> {page_rotation}"
)
if self.emplacements % MAX_REPLACE_PAGES == 0:
self.save_and_reload()
def save_and_reload(self) -> None:
"""Save and reload the Pdf.
This will keep a lid on our memory usage for very large files. Attach
the font to page 1 even if page 1 doesn't use it, so we have a way to get it
back.
"""
page0 = self.pdf_base.pages[0]
_update_resources(obj=page0.obj, font=self.font, font_key=self.font_key)
# We cannot read and write the same file, that will corrupt it
# but we don't to keep more copies than we need to. Delete intermediates.
# {interim_count} is the opened file we were updating
# {interim_count - 1} can be deleted
# {interim_count + 1} is the new file will produce and open
old_file = self.output_file.with_suffix(f'.working{self.interim_count - 1}.pdf')
if not self.context.options.keep_temporary_files:
with suppress(FileNotFoundError):
old_file.unlink()
next_file = self.output_file.with_suffix(
f'.working{self.interim_count + 1}.pdf'
)
self.pdf_base.save(next_file)
self.pdf_base.close()
self.pdf_base = Pdf.open(next_file)
self.font, self.font_key = None, None # Ensure we reacquire this information
self.interim_count += 1
if self.use_sandwich_renderer:
# Sandwich renderer: graft pre-rendered PDF immediately
if ocr_output:
text_misaligned = _compute_text_misalignment(
content_rotation, autorotate_correction, emplaced_page
)
self._graft_sandwich_text_layer(
pageno=pageno,
textpdf=ocr_output,
text_rotation=text_misaligned,
)
page_rotation = _compute_page_rotation(
content_rotation, autorotate_correction, emplaced_page
)
self.pdf_base.pages[pageno].Rotate = page_rotation
else:
# fpdf2 renderer: accumulate page info for batch rendering.
# The hOCR coordinates are in the corrected (upright) coordinate system.
# We store autorotate_correction and emplaced_page to set the final
# page /Rotate tag after grafting.
if ocr_tree:
self.fpdf2_parsed_pages.append(
Fpdf2ParsedPage(
ocr_tree=ocr_tree,
pageno=pageno,
autorotate_correction=autorotate_correction,
emplaced_page=emplaced_page,
dpi=self.pdfinfo[pageno].dpi.to_scalar(),
)
)
if ocr_output:
self.fpdf2_hocr_pages.append(
Fpdf2PageInfo(
hocr_path=ocr_output,
pageno=pageno,
autorotate_correction=autorotate_correction,
emplaced_page=emplaced_page,
dpi=self.pdfinfo[pageno].dpi.to_scalar(),
)
)
def finalize(self):
# Can have hocr OR parsed pages OR neither (no OCR), but not both
assert not (
self.fpdf2_hocr_pages and self.fpdf2_parsed_pages
), "Can't have both hocr and ocrtree pages"
if self.fpdf2_hocr_pages:
# Render all pages with fpdf2, then graft
parsed_pages = self._parse_hocr_pages()
self.fpdf2_parsed_pages = parsed_pages
if self.fpdf2_parsed_pages:
self._render_and_graft_fpdf2_pages()
self.pdf_base.save(self.output_file)
self.pdf_base.close()
return self.output_file
def _find_font(self, text: Path) -> tuple[Dictionary | None, Name | None]:
"""Copy a font from the filename text into pdf_base."""
font, font_key = None, None
possible_font_names = ('/f-0-0', '/F1')
try:
with Pdf.open(text) as pdf_text:
try:
pdf_text_fonts = pdf_text.pages[0].Resources.get(
Name.Font, Dictionary()
)
except (AttributeError, IndexError, KeyError):
return None, None
if not isinstance(pdf_text_fonts, Dictionary):
log.warning("Page fonts are not stored in a dictionary")
return None, None
pdf_text_font = None
for f in possible_font_names:
pdf_text_font = pdf_text_fonts.get(f, None)
if pdf_text_font is not None:
font_key = Name(f)
break
if pdf_text_font:
font = self.pdf_base.copy_foreign(pdf_text_font)
if not isinstance(font, Dictionary):
log.warning("Font is not a dictionary")
font, font_key = None, None
return font, font_key
except (FileNotFoundError, PdfError):
# PdfError occurs if a 0-length file is written e.g. due to OCR timeout
return None, None
def _parse_hocr_pages(self):
"""Render all pages to multi-page PDF with shared fonts, then graft."""
from ocrmypdf.hocrtransform.hocr_parser import HocrParser
def _graft_text_layer(
log.info(
"Parsing %d pages with HocrParser",
len(self.fpdf2_hocr_pages),
)
# Parse all hOCR files and collect OcrElements
pages_data: list[Fpdf2ParsedPage] = []
for page_info in self.fpdf2_hocr_pages:
if page_info.hocr_path.stat().st_size == 0:
continue # Skip empty pages
# Parse hOCR to OcrElement
parser = HocrParser(page_info.hocr_path)
ocr_tree = parser.parse()
# Use DPI from hOCR (scan_res) which reflects actual rasterization DPI.
# Fall back to pdfinfo DPI or VECTOR_PAGE_DPI for vector-only pages.
effective_dpi = ocr_tree.dpi or page_info.dpi or float(VECTOR_PAGE_DPI)
pages_data.append(
Fpdf2ParsedPage(
pageno=page_info.pageno,
ocr_tree=ocr_tree,
dpi=effective_dpi,
autorotate_correction=page_info.autorotate_correction,
emplaced_page=page_info.emplaced_page,
)
)
return pages_data
def _render_and_graft_fpdf2_pages(self):
font_dir = Path(__file__).parent / "data"
# Render all pages to single PDF
multi_page_pdf_path = self.context.get_path('fpdf2_multipage.pdf')
from ocrmypdf.font import MultiFontManager
from ocrmypdf.fpdf_renderer import Fpdf2MultiPageRenderer
multi_font_manager = MultiFontManager(font_dir)
# Build renderer input as (pageno, ocr_tree, dpi) tuples
renderer_pages_data = [
(parsed.pageno, parsed.ocr_tree, parsed.dpi)
for parsed in self.fpdf2_parsed_pages
]
renderer = Fpdf2MultiPageRenderer(
pages_data=renderer_pages_data,
multi_font_manager=multi_font_manager,
invisible_text=True,
)
renderer.render(multi_page_pdf_path)
# Now graft each page from the multi-page PDF
with Pdf.open(multi_page_pdf_path) as pdf_text:
for idx, parsed in enumerate(self.fpdf2_parsed_pages):
# Copy page from multi-page PDF
text_page = pdf_text.pages[idx]
content_rotation = self.pdfinfo[parsed.pageno].rotation
text_misaligned = _compute_text_misalignment(
content_rotation,
parsed.autorotate_correction,
parsed.emplaced_page,
)
self._graft_fpdf2_text_layer(parsed.pageno, text_page, text_misaligned)
page_rotation = _compute_page_rotation(
content_rotation,
parsed.autorotate_correction,
parsed.emplaced_page,
)
self.pdf_base.pages[parsed.pageno].Rotate = page_rotation
# Clean up multi-page PDF if not keeping temp files
if not self.context.options.keep_temporary_files:
with suppress(FileNotFoundError):
multi_page_pdf_path.unlink()
def _graft_fpdf2_text_layer(self, pageno: int, text_page: Page, text_rotation: int):
"""Graft a single text page onto the base PDF.
Similar to existing _graft_text_layer but works with
already-rendered pikepdf Page instead of file path.
Args:
pageno: Zero-based page number.
text_page: The text-only PDF page to graft.
text_rotation: Rotation to apply to align text with content (degrees).
"""
from pikepdf import Array
base_page = self.pdf_base.pages[pageno]
# Extract content stream from text_page
text_contents = text_page.Contents.read_bytes()
# Get the mediabox from the text page
mediabox = Array([float(x) for x in text_page.mediabox]) # type: ignore[misc]
wt = float(mediabox[2]) - float(mediabox[0])
ht = float(mediabox[3]) - float(mediabox[1])
# Get base page mediabox
base_mediabox = base_page.mediabox
wp = float(base_mediabox[2]) - float(base_mediabox[0])
hp = float(base_mediabox[3]) - float(base_mediabox[1])
# Create Form XObject from text page content
base_resources = _ensure_dictionary(base_page.obj, Name.Resources)
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
text_xobj_name = Name.random(prefix="OCR-")
xobj = self.pdf_base.make_stream(text_contents)
base_xobjs[text_xobj_name] = xobj
xobj.Type = Name.XObject
xobj.Subtype = Name.Form
xobj.FormType = 1
xobj.BBox = mediabox
# Copy resources from text page's Resources to xobj
# We need to handle this carefully since text_page is from a foreign PDF
if hasattr(text_page, 'Resources') and text_page.Resources:
# Create empty Resources dictionary for xobj
xobj_resources = _ensure_dictionary(xobj, Name.Resources)
# Copy fonts if they exist
if Name.Font in text_page.Resources:
xobj_fonts = _ensure_dictionary(xobj_resources, Name.Font)
text_fonts = text_page.Resources[Name.Font]
# Copy each font from the foreign PDF
for font_name, font_obj in text_fonts.items():
xobj_fonts[font_name] = self.pdf_base.copy_foreign(font_obj)
# Copy ExtGState (graphics state) if it exists - needed for transparency
if Name.ExtGState in text_page.Resources:
xobj_extstates = _ensure_dictionary(xobj_resources, Name.ExtGState)
text_extstates = text_page.Resources[Name.ExtGState]
# Copy each graphics state from the foreign PDF
for gs_name, gs_obj in text_extstates.items():
xobj_extstates[gs_name] = self.pdf_base.copy_foreign(gs_obj)
# Build transformation matrix for rotation and scaling
ctm = _build_text_layer_ctm(
wt,
ht,
wp,
hp,
float(base_mediabox[0]),
float(base_mediabox[1]),
text_rotation,
)
if ctm is not None:
pdf_draw_xobj = (
(b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'Q\n'
)
else:
pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
# Strip old invisible text if redo mode is enabled
if self.context.options.mode == ProcessingMode.redo:
strip_invisible_text(self.pdf_base, base_page)
# Add text layer to base page
base_page.contents_coalesce()
base_page.contents_add(
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
)
base_page.contents_coalesce()
def _graft_sandwich_text_layer(
self,
*,
page_num: int,
pageno: int,
textpdf: Path,
font: Dictionary,
font_key: Name,
text_rotation: int,
strip_old_text: bool,
):
"""Insert the text layer from text page 0 on to pdf_base at page_num."""
# pylint: disable=invalid-name
"""Graft a pre-rendered text-only PDF onto the base PDF.
log.debug("Grafting")
This is used by the sandwich renderer which generates PDFs directly
from Tesseract rather than going through hOCR.
"""
from pikepdf import PdfError
log.debug("Grafting sandwich text layer")
if Path(textpdf).stat().st_size == 0:
return
# This is a pointer indicating a specific page in the base file
with Pdf.open(textpdf) as pdf_text:
pdf_text_contents = pdf_text.pages[0].Contents.read_bytes()
try:
with Pdf.open(textpdf) as pdf_text:
pdf_text_contents = pdf_text.pages[0].Contents.read_bytes()
base_page = self.pdf_base.pages.p(page_num)
base_page = self.pdf_base.pages[pageno]
# The text page always will be oriented up by this stage but the original
# content may have a rotation applied. Wrap the text stream with a rotation
# so it will be oriented the same way as the rest of the page content.
# (Previous versions OCRmyPDF rotated the content layer to match the text.)
mediabox = pdf_text.pages[0].mediabox
wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
# Get font from the text PDF
pdf_text_fonts = pdf_text.pages[0].Resources.get(
Name.Font, Dictionary()
)
font = None
font_key = None
for f in ('/f-0-0', '/F1'):
pdf_text_font = pdf_text_fonts.get(f, None)
if pdf_text_font is not None:
font_key = Name(f)
font = self.pdf_base.copy_foreign(pdf_text_font)
break
mediabox = base_page.mediabox
wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
# Get mediabox dimensions for rotation calculations
mediabox = pdf_text.pages[0].mediabox
wt = float(mediabox[2]) - float(mediabox[0])
ht = float(mediabox[3]) - float(mediabox[1])
translate = Matrix().translated(-wt / 2, -ht / 2)
untranslate = Matrix().translated(wp / 2, hp / 2)
corner = Matrix().translated(mediabox[0], mediabox[1])
# -rotation because the input is a clockwise angle and this formula
# uses CCW
text_rotation = -text_rotation % 360
rotate = Matrix().rotated(text_rotation)
base_mediabox = base_page.mediabox
wp = float(base_mediabox[2]) - float(base_mediabox[0])
hp = float(base_mediabox[3]) - float(base_mediabox[1])
# Because of rounding of DPI, we might get a text layer that is not
# identically sized to the target page. Scale to adjust. Normally this
# is within 0.998.
if text_rotation in (90, 270):
wt, ht = ht, wt
scale_x = wp / wt
scale_y = hp / ht
# Build transformation matrix for rotation and scaling
ctm = _build_text_layer_ctm(
wt,
ht,
wp,
hp,
float(base_mediabox[0]),
float(base_mediabox[1]),
text_rotation,
)
log.debug("Grafting with ctm %r", ctm)
# log.debug('%r', scale_x, scale_y)
scale = Matrix().scaled(scale_x, scale_y)
# Create Form XObject
base_resources = _ensure_dictionary(base_page.obj, Name.Resources)
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
text_xobj_name = Name.random(prefix="OCR-")
xobj = self.pdf_base.make_stream(pdf_text_contents)
base_xobjs[text_xobj_name] = xobj
xobj.Type = Name.XObject
xobj.Subtype = Name.Form
xobj.FormType = 1
xobj.BBox = base_mediabox
# Translate the text so it is centered at (0, 0), rotate it there, adjust
# for a size different between initial and text PDF, then untranslate, and
# finally move the lower left corner to match the mediabox.
ctm = translate @ rotate @ scale @ untranslate @ corner
log.debug("Grafting with ctm %r", ctm)
# Add font to xobj resources
if font_key is not None and font is not None:
xobj_resources = _ensure_dictionary(xobj, Name.Resources)
xobj_fonts = _ensure_dictionary(xobj_resources, Name.Font)
if font_key not in xobj_fonts:
xobj_fonts[font_key] = font
base_resources = _ensure_dictionary(base_page.obj, Name.Resources)
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
text_xobj_name = Name.random(prefix="OCR-")
xobj = self.pdf_base.make_stream(pdf_text_contents)
base_xobjs[text_xobj_name] = xobj
xobj.Type = Name.XObject
xobj.Subtype = Name.Form
xobj.FormType = 1
xobj.BBox = mediabox
_update_resources(obj=xobj, font=font, font_key=font_key)
if ctm is not None:
pdf_draw_xobj = (
(b'q %s cm\n' % ctm.encode())
+ (b'%s Do\n' % text_xobj_name)
+ b'\nQ\n'
)
else:
pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
pdf_draw_xobj = (
(b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
)
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
if self.context.options.mode == ProcessingMode.redo:
strip_invisible_text(self.pdf_base, base_page)
base_page.contents_coalesce()
base_page.contents_add(
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
)
base_page.contents_coalesce()
if strip_old_text:
strip_invisible_text(self.pdf_base, base_page)
base_page.contents_coalesce()
if self.render_mode == RenderMode.ON_TOP:
# Add q/Q to ensure content we append is drawn correctly
# Strictly speaking this needs to trace the whole q/Q stack in case
# stack is not balanced.
original = base_page.Contents.read_bytes()
base_page.Contents.write(b'q\n' + original + b'\nQ\n')
base_page.contents_add(
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
)
base_page.contents_coalesce()
_update_resources(obj=base_page.obj, font=font, font_key=font_key)
# Add font to page resources
if font_key is not None and font is not None:
page_resources = _ensure_dictionary(base_page.obj, Name.Resources)
page_fonts = _ensure_dictionary(page_resources, Name.Font)
if font_key not in page_fonts:
page_fonts[font_key] = font
except (FileNotFoundError, PdfError):
# PdfError occurs if a 0-length file is written e.g. due to OCR timeout
pass
+36 -15
View File
@@ -5,29 +5,31 @@
from __future__ import annotations
import os
from argparse import Namespace
from collections.abc import Iterator
from copy import copy
from pathlib import Path
from typing import TYPE_CHECKING
from pluggy import PluginManager
from ocrmypdf._options import OcrOptions
from ocrmypdf.pdfinfo import PdfInfo
from ocrmypdf.pdfinfo.info import PageInfo
if TYPE_CHECKING:
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
class PdfContext:
"""Holds the context for a particular run of the pipeline."""
options: Namespace #: The specified options for processing this PDF.
options: OcrOptions #: The specified options for processing this PDF.
origin: Path #: The filename of the original input file.
pdfinfo: PdfInfo #: Detailed data for this PDF.
plugin_manager: PluginManager #: PluginManager for processing the current PDF.
plugin_manager: (
OcrmypdfPluginManager #: PluginManager for processing the current PDF.
)
def __init__(
self,
options: Namespace,
options: OcrOptions,
work_folder: Path,
origin: Path,
pdfinfo: PdfInfo,
@@ -65,21 +67,27 @@ class PageContext:
Must be pickle-able, so stores only intrinsic/simple data elements or those
capable of their serializing themselves via ``__getstate__``.
Note: Uses OcrOptions with JSON serialization for multiprocessing compatibility.
"""
options: Namespace #: The specified options for processing this PDF.
origin: Path #: The filename of the original input file.
pageno: int #: This page number (zero-based).
pageinfo: PageInfo #: Information on this page.
plugin_manager: PluginManager #: PluginManager for processing the current PDF.
plugin_manager: (
OcrmypdfPluginManager #: PluginManager for processing the current PDF.
)
def __init__(self, pdf_context: PdfContext, pageno):
self.work_folder = pdf_context.work_folder
self.origin = pdf_context.origin
# Store OcrOptions directly instead of Namespace
self.options = pdf_context.options
self.pageno = pageno
self.pageinfo = pdf_context.pdfinfo[pageno]
self.plugin_manager = pdf_context.plugin_manager
# Ensure no reference to PdfContext which contains OcrOptions
self._pdf_context = None
def get_path(self, name: str) -> Path:
"""Generate a ``Path`` for a file that is part of processing this page.
@@ -92,9 +100,22 @@ class PageContext:
def __getstate__(self):
state = self.__dict__.copy()
state['options'] = copy(self.options)
if not isinstance(state['options'].input_file, str | bytes | os.PathLike):
state['options'].input_file = 'stream'
if not isinstance(state['options'].output_file, str | bytes | os.PathLike):
state['options'].output_file = 'stream'
options_json = self.options.model_dump_json_safe()
state['options_json'] = options_json
# Remove the OcrOptions object to avoid pickle issues
del state['options']
# Remove any potential references to Pydantic objects
state.pop('_pdf_context', None)
return state
def __setstate__(self, state):
self.__dict__.update(state)
# Reconstruct OcrOptions from JSON if available
if 'options_json' in state:
from ocrmypdf._options import OcrOptions
self.options = OcrOptions.model_validate_json_safe(state['options_json'])
# Otherwise, we have a fallback Namespace (shouldn't happen in normal operation)
# Leave it as-is for compatibility
+9 -11
View File
@@ -5,9 +5,9 @@
from __future__ import annotations
import datetime as dt
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -15,7 +15,6 @@ from pikepdf import Dictionary, Name, Pdf
from pikepdf import __version__ as PIKEPDF_VERSION
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
from ocrmypdf._annots import remove_broken_goto_annotations
from ocrmypdf._defaults import PROGRAM_NAME
from ocrmypdf._jobcontext import PdfContext
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
@@ -48,11 +47,13 @@ def get_docinfo(base_pdf: Pdf, context: PdfContext) -> dict[str, str]:
if options.subject:
pdfmark['/Subject'] = options.subject
creator_tag = context.plugin_manager.hook.get_ocr_engine().creator_tag(options)
creator_tag = context.plugin_manager.get_ocr_engine(options=options).creator_tag(
options
)
pdfmark['/Creator'] = f'{PROGRAM_NAME} {OCRMYPF_VERSION} / {creator_tag}'
pdfmark['/Producer'] = f'pikepdf {PIKEPDF_VERSION}'
pdfmark['/ModDate'] = encode_pdf_date(datetime.now(timezone.utc))
pdfmark['/ModDate'] = encode_pdf_date(dt.datetime.now(dt.UTC))
return pdfmark
@@ -99,9 +100,7 @@ def should_linearize(working_file: Path, context: PdfContext) -> bool:
For smaller files, linearization is not worth the effort.
"""
filesize = os.stat(working_file).st_size
if filesize > (context.options.fast_web_view * 1_000_000):
return True
return False
return filesize > (context.options.fast_web_view * 1_000_000)
def _fix_metadata(meta_original: PdfMetadata, meta_pdf: PdfMetadata):
@@ -109,12 +108,11 @@ def _fix_metadata(meta_original: PdfMetadata, meta_pdf: PdfMetadata):
# ensure consistency with Ghostscript.
if 'xmp:CreateDate' not in meta_pdf:
meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '')
if meta_pdf.get('dc:title') == 'Untitled':
if meta_pdf.get('dc:title') == 'Untitled' and ('dc:title' not in meta_original):
# Ghostscript likes to set title to Untitled if omitted from input.
# Reverse this, because PDF/A TechNote 0003:Metadata in PDF/A-1
# and the XMP Spec do not make this recommendation.
if 'dc:title' not in meta_original:
del meta_pdf['dc:title']
del meta_pdf['dc:title']
def _unset_empty_metadata(meta: PdfMetadata, options):
@@ -187,7 +185,7 @@ def metadata_fixup(
output_file = context.get_path('metafix.pdf')
options = context.options
pbar_class = context.plugin_manager.hook.get_progressbar_class()
pbar_class = context.plugin_manager.get_progressbar_class()
with (
Pdf.open(context.origin) as original,
Pdf.open(working_file) as pdf,
+613
View File
@@ -0,0 +1,613 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Internal options model for OCRmyPDF."""
from __future__ import annotations
import json
import logging
import os
import unicodedata
from collections.abc import Sequence
from enum import StrEnum
from io import IOBase
from pathlib import Path
from typing import Any, BinaryIO
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf.exceptions import BadArgsError
from ocrmypdf.helpers import monotonic
# Import plugin option models - these will be available after plugins are loaded
# We'll use forward references and handle imports dynamically
log = logging.getLogger(__name__)
# Module-level registry for plugin option models
# This is populated by setup_plugin_infrastructure() after plugins are loaded
_plugin_option_models: dict[str, type] = {}
PathOrIO = BinaryIO | IOBase | Path | str | bytes
class ProcessingMode(StrEnum):
"""OCR processing mode for handling pages with existing text.
This enum controls how OCRmyPDF handles pages that already contain text:
- ``default``: Error if text is found (standard OCR behavior)
- ``force``: Rasterize all content and run OCR regardless of existing text
- ``skip``: Skip OCR on pages that already have text
- ``redo``: Re-OCR pages, stripping old invisible text layer
"""
default = 'default'
force = 'force'
skip = 'skip'
redo = 'redo'
def _pages_from_ranges(ranges: str) -> set[int]:
"""Convert page range string to set of page numbers."""
pages: list[int] = []
page_groups = ranges.replace(' ', '').split(',')
for group in page_groups:
if not group:
continue
try:
start, end = group.split('-')
except ValueError:
pages.append(int(group) - 1)
else:
try:
new_pages = list(range(int(start) - 1, int(end)))
if not new_pages:
raise BadArgsError(
f"invalid page subrange '{start}-{end}'"
) from None
pages.extend(new_pages)
except ValueError:
raise BadArgsError(f"invalid page subrange '{group}'") from None
if not pages:
raise BadArgsError(
f"The string of page ranges '{ranges}' did not contain any recognizable "
f"page ranges."
)
if not monotonic(pages):
log.warning(
"List of pages to process contains duplicate pages, or pages that are "
"out of order"
)
if any(page < 0 for page in pages):
raise BadArgsError("pages refers to a page number less than 1")
log.debug("OCRing only these pages: %s", pages)
return set(pages)
class OcrOptions(BaseModel):
"""Internal options model that can masquerade as argparse.Namespace.
This model provides proper typing and validation while maintaining
compatibility with existing code that expects argparse.Namespace behavior.
"""
# I/O options
input_file: PathOrIO
output_file: PathOrIO
sidecar: PathOrIO | None = None
output_folder: Path | None = None
work_folder: Path | None = None
# Core OCR options
languages: list[str] = Field(default_factory=lambda: [DEFAULT_LANGUAGE])
output_type: str = 'auto'
mode: ProcessingMode = ProcessingMode.default
# Backward compatibility properties for force_ocr, skip_text, redo_ocr
@property
def force_ocr(self) -> bool:
"""Backward compatibility alias for mode == ProcessingMode.force."""
return self.mode == ProcessingMode.force
@property
def skip_text(self) -> bool:
"""Backward compatibility alias for mode == ProcessingMode.skip."""
return self.mode == ProcessingMode.skip
@property
def redo_ocr(self) -> bool:
"""Backward compatibility alias for mode == ProcessingMode.redo."""
return self.mode == ProcessingMode.redo
# Job control
jobs: int | None = None
use_threads: bool = True
progress_bar: bool = True
quiet: bool = False
verbose: int = 0
keep_temporary_files: bool = False
# Image processing
image_dpi: int | None = None
deskew: bool = False
clean: bool = False
clean_final: bool = False
rotate_pages: bool = False
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
)
# 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
# Metadata
title: str | None = None
author: str | None = None
subject: str | None = None
keywords: str | None = None
# Optimization
optimize: int = 1
jpg_quality: int | None = None
png_quality: int | None = None
jbig2_threshold: float = 0.85
# Compatibility alias for plugins that expect jpeg_quality
@property
def jpeg_quality(self):
"""Compatibility alias for jpg_quality."""
return self.jpg_quality
@jpeg_quality.setter
def jpeg_quality(self, value):
"""Compatibility alias for jpg_quality."""
self.jpg_quality = value
# Advanced options
max_image_mpixels: float = 250.0
pdf_renderer: str = 'auto'
ocr_engine: str = 'auto'
rasterizer: str = 'auto'
rotate_pages_threshold: float = DEFAULT_ROTATE_PAGES_THRESHOLD
user_words: os.PathLike | None = None
user_patterns: os.PathLike | None = None
fast_web_view: float = 1.0
continue_on_soft_render_error: bool | None = None
# Tesseract options - also accessible via options.tesseract.<field>
tesseract_config: list[str] = []
tesseract_pagesegmode: int | None = None
tesseract_oem: int | None = None
tesseract_thresholding: int | None = None
tesseract_timeout: float = 0.0
tesseract_non_ocr_timeout: float | None = None
tesseract_downsample_above: int = 32767
tesseract_downsample_large_images: bool | None = None
# Ghostscript options - also accessible via options.ghostscript.<field>
pdfa_image_compression: str | None = None
color_conversion_strategy: str = "LeaveColorUnchanged"
# Optimize/JBIG2 options - also accessible via options.optimize.<field>
jbig2_threshold: float = 0.85
# Plugin system
plugins: Sequence[Path | str] | None = None
# Store any extra attributes (for plugins and dynamic options)
extra_attrs: dict[str, Any] = Field(
default_factory=dict, exclude=True, alias='_extra_attrs'
)
@field_validator('languages')
@classmethod
def validate_languages(cls, v):
"""Ensure languages list is not empty."""
if not v:
return [DEFAULT_LANGUAGE]
return v
@field_validator('output_type')
@classmethod
def validate_output_type(cls, v):
"""Validate output type is one of the allowed values."""
valid_types = {'auto', 'pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'}
if v not in valid_types:
raise ValueError(f"output_type must be one of {valid_types}")
return v
@field_validator('pdf_renderer')
@classmethod
def validate_pdf_renderer(cls, v):
"""Validate PDF renderer is one of the allowed values."""
valid_renderers = {'auto', 'sandwich', 'fpdf2'}
# Legacy hocr/hocrdebug are accepted but redirected to fpdf2
legacy_renderers = {'hocr', 'hocrdebug'}
all_accepted = valid_renderers | legacy_renderers
if v not in all_accepted:
raise ValueError(f"pdf_renderer must be one of {all_accepted}")
return v
@field_validator('rasterizer')
@classmethod
def validate_rasterizer(cls, v):
"""Validate rasterizer is one of the allowed values."""
valid_rasterizers = {'auto', 'ghostscript', 'pypdfium'}
if v not in valid_rasterizers:
raise ValueError(f"rasterizer must be one of {valid_rasterizers}")
return v
@field_validator('clean_final')
@classmethod
def validate_clean_final(cls, v, info):
"""If clean_final is True, also set clean to True."""
if v and hasattr(info, 'data') and 'clean' in info.data:
info.data['clean'] = True
return v
@field_validator('jobs')
@classmethod
def validate_jobs(cls, v):
"""Validate jobs is a reasonable number."""
if v is not None and (v < 0 or v > 256):
raise ValueError("jobs must be between 0 and 256")
return v
@field_validator('verbose')
@classmethod
def validate_verbose(cls, v):
"""Validate verbose level."""
if v < 0 or v > 2:
raise ValueError("verbose must be between 0 and 2")
return v
@field_validator('oversample')
@classmethod
def validate_oversample(cls, v):
"""Validate oversample DPI."""
if v < 0 or v > 5000:
raise ValueError("oversample must be between 0 and 5000")
return v
@field_validator('max_image_mpixels')
@classmethod
def validate_max_image_mpixels(cls, v):
"""Validate max image megapixels."""
if v < 0:
raise ValueError("max_image_mpixels must be non-negative")
return v
@field_validator('rotate_pages_threshold')
@classmethod
def validate_rotate_pages_threshold(cls, v):
"""Validate rotate pages threshold."""
if v < 0 or v > 1000:
raise ValueError("rotate_pages_threshold must be between 0 and 1000")
return v
@field_validator('title', 'author', 'keywords', 'subject')
@classmethod
def validate_metadata_unicode(cls, v):
"""Validate metadata strings don't contain unsupported Unicode characters."""
if v is None:
return v
for char in v:
if unicodedata.category(char) == 'Co' or ord(char) >= 0x10000:
hexchar = hex(ord(char))[2:].upper()
raise ValueError(
f"Metadata string contains unsupported Unicode character: "
f"{char} (U+{hexchar})"
)
return v
@field_validator('pages')
@classmethod
def validate_pages_format(cls, v):
"""Convert page ranges string to set of page numbers."""
if v is None:
return v
if isinstance(v, set):
return v # Already processed
# Convert string ranges to set of page numbers
return _pages_from_ranges(v)
@model_validator(mode='before')
@classmethod
def handle_special_cases(cls, data):
"""Handle special cases for API compatibility and legacy options."""
if isinstance(data, dict):
# For hOCR API, output_file might not be present
if 'output_folder' in data and 'output_file' not in data:
data['output_file'] = '/dev/null' # Placeholder
# Convert legacy boolean options (force_ocr, skip_text, redo_ocr) to mode
force = data.pop('force_ocr', None)
skip = data.pop('skip_text', None)
redo = data.pop('redo_ocr', None)
# Count how many legacy options are set to True
legacy_set = [
(force, ProcessingMode.force),
(skip, ProcessingMode.skip),
(redo, ProcessingMode.redo),
]
legacy_true = [(val, mode) for val, mode in legacy_set if val]
legacy_count = len(legacy_true)
# Get current mode value (may be string or enum)
current_mode = data.get('mode', ProcessingMode.default)
if isinstance(current_mode, str):
current_mode = ProcessingMode(current_mode)
mode_is_set = current_mode != ProcessingMode.default
if legacy_count > 1:
raise ValueError(
"Choose only one of --force-ocr, --skip-text, --redo-ocr."
)
if legacy_count == 1:
expected_mode = legacy_true[0][1]
if mode_is_set and current_mode != expected_mode:
legacy_flag = f"--{expected_mode.value.replace('_', '-')}-ocr"
raise ValueError(
f"Conflicting options: --mode {current_mode.value} "
f"cannot be used with {legacy_flag} or similar legacy flag."
)
# Set mode from legacy option
data['mode'] = expected_mode
return data
@model_validator(mode='after')
def validate_redo_ocr_options(self):
"""Validate options compatible with redo mode."""
if self.mode == ProcessingMode.redo and (
self.deskew or self.clean_final or self.remove_background
):
raise ValueError(
"--redo-ocr (or --mode redo) is not currently compatible with "
"--deskew, --clean-final, and --remove-background"
)
return self
@model_validator(mode='after')
def validate_output_type_compatibility(self):
"""Validate output type is compatible with output file."""
if self.output_type == 'none' and str(self.output_file) not in (
os.devnull,
'-',
):
raise ValueError(
"Since you specified `--output-type none`, the output file "
f"{self.output_file} cannot be produced. Set the output file to "
f"`-` to suppress this message."
)
return self
@property
def lossless_reconstruction(self):
"""Determine lossless_reconstruction based on other options."""
lossless = not any(
[
self.deskew,
self.clean_final,
self.mode == ProcessingMode.force,
self.remove_background,
]
)
return lossless
def model_dump_json_safe(self) -> str:
"""Serialize to JSON with special handling for non-serializable types."""
# Create a copy of the model data for serialization
data = self.model_dump()
# Handle special types that don't serialize to JSON directly
def _serialize_value(value):
if isinstance(value, Path):
return {'__type__': 'Path', 'value': str(value)}
elif (
isinstance(value, BinaryIO | IOBase)
or hasattr(value, 'read')
or hasattr(value, 'write')
):
# Stream object - replace with placeholder
return {'__type__': 'Stream', 'value': 'stream'}
elif hasattr(value, '__class__') and 'Iterator' in value.__class__.__name__:
# Handle Pydantic serialization iterators
return {'__type__': 'Stream', 'value': 'stream'}
elif isinstance(value, property):
# Handle property objects that shouldn't be serialized
return None
elif isinstance(value, list | tuple):
return [_serialize_value(item) for item in value]
elif isinstance(value, dict):
return {k: _serialize_value(v) for k, v in value.items()}
else:
return value
# Process all fields
serializable_data = {}
for key, value in data.items():
serialized_value = _serialize_value(value)
if serialized_value is not None: # Skip None values from properties
serializable_data[key] = serialized_value
# Add extra_attrs, excluding plugin cache entries (they'll be recreated lazily)
if self.extra_attrs:
filtered_extra = {
k: v
for k, v in self.extra_attrs.items()
if not k.startswith('_plugin_cache_')
}
if filtered_extra:
serializable_data['_extra_attrs'] = _serialize_value(filtered_extra)
return json.dumps(serializable_data)
@classmethod
def model_validate_json_safe(cls, json_str: str) -> OcrOptions:
"""Reconstruct from JSON with special handling for non-serializable types."""
data = json.loads(json_str)
# Handle special types during deserialization
def _deserialize_value(value):
if isinstance(value, dict) and '__type__' in value:
if value['__type__'] == 'Path':
return Path(value['value'])
elif value['__type__'] == 'Stream':
# For streams, we'll use a placeholder string
return value['value']
else:
return value['value']
elif isinstance(value, list):
return [_deserialize_value(item) for item in value]
elif isinstance(value, dict):
return {k: _deserialize_value(v) for k, v in value.items()}
else:
return value
# Process all fields
deserialized_data = {}
extra_attrs = {}
for key, value in data.items():
if key == '_extra_attrs':
extra_attrs = _deserialize_value(value)
else:
deserialized_data[key] = _deserialize_value(value)
# Create instance
instance = cls(**deserialized_data)
instance.extra_attrs = extra_attrs
return instance
model_config = ConfigDict(
extra="forbid", # Force use of extra_attrs for unknown fields
arbitrary_types_allowed=True, # Allow BinaryIO, Path, etc.
validate_assignment=True, # Validate on attribute assignment
)
@classmethod
def register_plugin_models(cls, models: dict[str, type]) -> None:
"""Register plugin option model classes for nested access.
Args:
models: Dictionary mapping namespace to model class
"""
global _plugin_option_models
_plugin_option_models.update(models)
def _get_plugin_options(self, namespace: str) -> Any:
"""Get or create a plugin options instance for the given namespace.
This method creates plugin option instances lazily from flat field values.
Args:
namespace: The plugin namespace (e.g., 'tesseract', 'optimize')
Returns:
An instance of the plugin's option model, or None if not registered
"""
# Use extra_attrs to cache plugin option instances
cache_key = f'_plugin_cache_{namespace}'
if cache_key in self.extra_attrs:
return self.extra_attrs[cache_key]
if namespace not in _plugin_option_models:
raise AttributeError(
f"Plugin namespace '{namespace}' is not registered. "
f"Ensure setup_plugin_infrastructure() was called."
)
model_class = _plugin_option_models[namespace]
def _convert_value(value):
"""Convert value to be compatible with plugin model fields."""
if isinstance(value, os.PathLike):
return os.fspath(value)
return value
# Build kwargs from flat fields
kwargs = {}
for field_name in model_class.model_fields:
# Try namespace_field pattern first (e.g., tesseract_timeout)
flat_name = f"{namespace}_{field_name}"
if flat_name in OcrOptions.model_fields:
value = getattr(self, flat_name)
if value is not None:
kwargs[field_name] = _convert_value(value)
# Also check direct field name (for fields like jbig2_lossy)
elif field_name in OcrOptions.model_fields:
value = getattr(self, field_name)
if value is not None:
kwargs[field_name] = _convert_value(value)
# Check for special mappings
elif namespace == 'optimize' and field_name == 'level':
# 'optimize' field maps to 'level' in OptimizeOptions
if 'optimize' in OcrOptions.model_fields:
value = self.optimize
if value is not None:
kwargs[field_name] = _convert_value(value)
elif namespace == 'optimize' and field_name == 'jpeg_quality':
# jpg_quality maps to jpeg_quality
if 'jpg_quality' in OcrOptions.model_fields:
value = self.jpg_quality
if value is not None:
kwargs[field_name] = _convert_value(value)
# Create and cache the plugin options instance
instance = model_class(**kwargs)
self.extra_attrs[cache_key] = instance
return instance
def __getattr__(self, name: str) -> Any:
"""Support dynamic access to plugin option namespaces.
This allows accessing plugin options like:
options.tesseract.timeout
options.optimize.level
Plugin models must be registered via register_plugin_models() for
namespace access to work. Built-in plugins register their models
during initialization.
Args:
name: Attribute name
Returns:
Plugin options instance if name is a registered namespace,
otherwise raises AttributeError
"""
# Check if this is a plugin namespace
if name.startswith('_'):
# Private attributes should not trigger plugin lookup
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
# Try to get plugin options for this namespace
if name in _plugin_option_models:
return self._get_plugin_options(name)
# Check extra_attrs
if 'extra_attrs' in self.__dict__ and name in self.extra_attrs:
return self.extra_attrs[name]
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
+237 -98
View File
@@ -15,7 +15,10 @@ from contextlib import suppress
from io import BytesIO
from pathlib import Path
from shutil import copyfileobj
from typing import Any, BinaryIO, TypeVar, cast
from typing import TYPE_CHECKING, Any, BinaryIO, TypeVar, cast
if TYPE_CHECKING:
from ocrmypdf.hocrtransform import OcrElement
import img2pdf
import pikepdf
@@ -25,6 +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.exceptions import (
DigitalSignatureError,
DpiError,
@@ -35,12 +39,13 @@ from ocrmypdf.exceptions import (
UnsupportedImageFormatError,
)
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
from ocrmypdf.hocrtransform import DebugRenderOptions, HocrTransform
from ocrmypdf.hocrtransform._font import Courier
from ocrmypdf.pdfa import generate_pdfa_ps
from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo
from ocrmypdf.pdfinfo.info import FloatRect
from ocrmypdf.pluginspec import OrientationConfidence
from ocrmypdf.pdfa import (
file_claims_pdfa,
generate_pdfa_ps,
speculative_pdfa_conversion,
)
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, PageInfo, PdfInfo
from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence
try:
from pi_heif import register_heif_opener
@@ -59,7 +64,7 @@ VECTOR_PAGE_DPI = 400
register_heif_opener()
def triage_image_file(input_file: Path, output_file: Path, options) -> None:
def triage_image_file(input_file: Path, output_file: Path, options: OcrOptions) -> None:
"""Triage the input image file.
If the input file is an image, check its resolution and convert it to PDF.
@@ -158,7 +163,7 @@ def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
def triage(
original_filename: str, input_file: Path, output_file: Path, options
original_filename: str, input_file: Path, output_file: Path, options: OcrOptions
) -> Path:
"""Triage the input file. We can handle PDFs and images."""
try:
@@ -228,10 +233,10 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
else:
raise DigitalSignatureError()
if pdfinfo.has_acroform:
if options.redo_ocr:
if options.mode == ProcessingMode.redo:
raise InputFileError(
"This PDF has a user fillable form. --redo-ocr is not "
"currently possible on such files."
"This PDF has a user fillable form. --redo-ocr (or --mode redo) "
"is not currently possible on such files."
)
else:
log.warning(
@@ -239,14 +244,14 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
"Chances are it is a pure digital "
"document that does not need OCR."
)
if not options.force_ocr:
if options.mode != ProcessingMode.force:
log.info(
"Use the option --force-ocr to produce an image of the "
"form and all filled form fields. The output PDF will be "
"'flattened' and will no longer be fillable."
"Use the option --force-ocr (or --mode force) to produce an "
"image of the form and all filled form fields. The output PDF "
"will be 'flattened' and will no longer be fillable."
)
if pdfinfo.is_tagged:
if options.force_ocr or options.skip_text or options.redo_ocr:
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 "
@@ -255,7 +260,7 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
)
else:
raise TaggedPDFError()
context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options)
context.plugin_manager.validate(pdfinfo=pdfinfo, options=options)
def _vector_page_dpi(pageinfo: PageInfo) -> int:
@@ -323,24 +328,24 @@ def is_ocr_required(page_context: PageContext) -> bool:
log.debug(f"skipped {pageinfo.pageno} as requested by --pages {options.pages}")
ocr_required = False
elif pageinfo.has_text:
if not options.force_ocr and not (options.skip_text or options.redo_ocr):
if options.mode == ProcessingMode.default:
raise PriorOcrFoundError(
"page already has text! - aborting (use --force-ocr to force OCR; "
" see also help for the arguments --skip-text and --redo-ocr"
"page already has text! - aborting (use --force-ocr or --mode force "
"to force OCR; see also help for --skip-text, --redo-ocr, and --mode)"
)
elif options.force_ocr:
elif options.mode == ProcessingMode.force:
log.info("page already has text! - rasterizing text and running OCR anyway")
ocr_required = True
elif options.redo_ocr:
elif options.mode == ProcessingMode.redo:
if pageinfo.has_corrupt_text:
log.warning(
"some text on this page cannot be mapped to characters: "
"consider using --force-ocr instead"
"consider using --force-ocr (or --mode force) instead"
)
else:
log.info("redoing OCR")
ocr_required = True
elif options.skip_text:
elif options.mode == ProcessingMode.skip:
log.info("skipping all processing on this page")
ocr_required = False
elif not pageinfo.images and not options.lossless_reconstruction:
@@ -351,14 +356,14 @@ def is_ocr_required(page_context: PageContext) -> bool:
# ahead and rasterize. If not forced, then pretend there's no text
# on the page at all so we don't lose anything.
# This could be made smarter by explicitly searching for vector art.
if options.force_ocr and options.oversample:
if options.mode == ProcessingMode.force and options.oversample:
# The user really wants to reprocess this file
log.info(
"page has no images - "
f"rasterizing at {options.oversample} DPI because "
"--force-ocr --oversample was specified"
"--force-ocr --oversample (or --mode force --oversample) was specified"
)
elif options.force_ocr:
elif options.mode == ProcessingMode.force:
# Warn the user they might not want to do this
log.warning(
"page has no images - "
@@ -371,8 +376,8 @@ def is_ocr_required(page_context: PageContext) -> bool:
log.info(
"page has no images - "
"skipping all processing on this page to avoid losing detail. "
"Use --force-ocr if you wish to perform OCR on pages that "
"have vector content."
"Use --force-ocr (or --mode force) if you wish to perform OCR on "
"pages that have vector content."
)
ocr_required = False
@@ -395,16 +400,18 @@ def rasterize_preview(input_file: Path, page_context: PageContext) -> Path:
[get_canvas_square_dpi(page_context)]
)
page_dpi = Resolution(300.0, 300.0).take_min([get_page_square_dpi(page_context)])
page_context.plugin_manager.hook.rasterize_pdf_page(
page_context.plugin_manager.rasterize_pdf_page(
input_file=input_file,
output_file=output_file,
raster_device='jpeggray',
raster_device=GhostscriptRasterDevice.JPEGGRAY,
raster_dpi=canvas_dpi,
pageno=page_context.pageinfo.pageno + 1,
page_dpi=page_dpi,
rotation=0,
filter_vector=False,
stop_on_soft_error=not page_context.options.continue_on_soft_render_error,
options=page_context.options,
use_cropbox=False,
)
return output_file
@@ -424,10 +431,7 @@ def describe_rotation(
else:
action = 'rotation appears correct'
else:
if correction != 0:
action = 'confidence too low to rotate'
else:
action = 'no change'
action = "confidence too low to rotate" if correction != 0 else "no change"
facing = ''
@@ -453,9 +457,10 @@ def get_orientation_correction(preview: Path, page_context: PageContext) -> int:
which points it (hopefully) upright. _graft.py takes care of the orienting
the image and text layers.
"""
orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation(
preview, page_context.options
ocr_engine = page_context.plugin_manager.get_ocr_engine(
options=page_context.options
)
orient_conf = ocr_engine.get_orientation(preview, page_context.options)
correction = orient_conf.angle % 360
log.info(describe_rotation(page_context, orient_conf, correction))
@@ -521,7 +526,12 @@ def rasterize(
Returns:
Path: The output PNG file path.
"""
colorspaces = ['pngmono', 'pnggray', 'png256', 'png16m']
colorspaces = [
GhostscriptRasterDevice.PNGMONO,
GhostscriptRasterDevice.PNGGRAY,
GhostscriptRasterDevice.PNG256,
GhostscriptRasterDevice.PNG16M,
]
device_idx = 0
if remove_vectors is None:
@@ -538,15 +548,15 @@ def rasterize(
continue # ignore masks
if image.bpc > 1:
if image.color == Colorspace.index:
device_idx = at_least('png256')
device_idx = at_least(GhostscriptRasterDevice.PNG256)
elif image.color == Colorspace.gray:
device_idx = at_least('pnggray')
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
else:
device_idx = at_least('png16m')
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
if pageinfo.has_vector:
log.debug("Page has vector content, using png16m")
device_idx = at_least('png16m')
log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}")
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
device = colorspaces[device_idx]
@@ -556,7 +566,7 @@ def rasterize(
canvas_dpi, page_dpi = calculate_raster_dpi(page_context)
page_context.plugin_manager.hook.rasterize_pdf_page(
page_context.plugin_manager.rasterize_pdf_page(
input_file=input_file,
output_file=output_file,
raster_device=device,
@@ -566,6 +576,8 @@ def rasterize(
rotation=correction,
filter_vector=remove_vectors,
stop_on_soft_error=not page_context.options.continue_on_soft_render_error,
options=page_context.options,
use_cropbox=False,
)
return output_file
@@ -594,7 +606,9 @@ def preprocess_deskew(input_file: Path, page_context: PageContext) -> Path:
output_file = page_context.get_path('pp_deskew.png')
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
ocr_engine = page_context.plugin_manager.hook.get_ocr_engine()
ocr_engine = page_context.plugin_manager.get_ocr_engine(
options=page_context.options
)
deskew_angle_degrees = ocr_engine.get_deskew(input_file, page_context.options)
with Image.open(input_file) as im:
@@ -633,11 +647,11 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path:
with Image.open(image) as im:
log.debug('resolution %r', im.info['dpi'])
if not options.force_ocr:
if options.mode != ProcessingMode.force:
# Do not mask text areas when forcing OCR, because we need to OCR
# all text areas
mask = None # Exclude both visible and invisible text from OCR
if options.redo_ocr:
if options.mode == ProcessingMode.redo:
mask = True # Mask visible text, but not invisible text
draw = ImageDraw.ImageDraw(im)
@@ -659,7 +673,7 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path:
draw.rectangle(pixcoords, fill='white')
# draw.rectangle(pixcoords, outline='pink')
filter_im = page_context.plugin_manager.hook.filter_ocr_image(
filter_im = page_context.plugin_manager.filter_ocr_image(
page=page_context, image=im
)
if filter_im is not None:
@@ -677,7 +691,7 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path,
hocr_text_out = page_context.get_path('ocr_hocr.txt')
options = page_context.options
ocr_engine = page_context.plugin_manager.hook.get_ocr_engine()
ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options)
ocr_engine.generate_hocr(
input_file=input_file,
output_hocr=hocr_out,
@@ -687,6 +701,37 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path,
return hocr_out, hocr_text_out
def ocr_engine_direct(
input_file: Path, page_context: PageContext
) -> tuple[OcrElement, Path]:
"""Run the OCR engine and return OcrElement tree directly.
This is the modern path for OCR engines that support the generate_ocr() API.
It bypasses hOCR file generation for better performance and richer data.
Args:
input_file: The image file to OCR.
page_context: The page context with options and path utilities.
Returns:
A tuple of (OcrElement tree, path to text sidecar file).
"""
text_out = page_context.get_path('ocr_direct.txt')
options = page_context.options
ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options)
ocr_tree, text_content = ocr_engine.generate_ocr(
input_file=input_file,
options=options,
page_number=page_context.pageno,
)
# Write text sidecar file
text_out.write_text(text_content, encoding='utf-8')
return ocr_tree, text_out
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
"""Determines whether the visible page image should be saved as a JPEG.
@@ -764,47 +809,12 @@ def create_pdf_page_from_image(
bio.seek(0)
fix_pagepdf_boxes(bio, output_file, page_context, swap_axis=swap_axis)
output_file = page_context.plugin_manager.hook.filter_pdf_page(
output_file = page_context.plugin_manager.filter_pdf_page(
page=page_context, image_filename=image, output_pdf=output_file
)
return output_file
def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
"""Render the hOCR page to a PDF."""
options = page_context.options
output_file = page_context.get_path('ocr_hocr.pdf')
if hocr.stat().st_size == 0:
# If hOCR file is empty (skipped page marker), create an empty PDF file
output_file.touch()
return output_file
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
debug_kwargs = {}
if options.pdf_renderer == 'hocrdebug':
debug_kwargs = dict(
debug_render_options=DebugRenderOptions(
render_baseline=True,
render_triangle=True,
render_line_bbox=False,
render_word_bbox=True,
render_paragraph_bbox=False,
render_space_bbox=False,
),
font=Courier(),
)
HocrTransform(
hocr_filename=hocr,
dpi=dpi.to_scalar(),
**debug_kwargs, # square
).to_pdf(
out_filename=output_file,
image_filename=None,
invisible_text=True if not debug_kwargs else False,
)
return output_file
def ocr_engine_textonly_pdf(
input_image: Path, page_context: PageContext
) -> tuple[Path, Path]:
@@ -813,7 +823,7 @@ def ocr_engine_textonly_pdf(
output_text = page_context.get_path('ocr_tess.txt')
options = page_context.options
ocr_engine = page_context.plugin_manager.hook.get_ocr_engine()
ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options)
ocr_engine.generate_pdf(
input_file=input_image,
output_pdf=output_pdf,
@@ -936,15 +946,26 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
else:
safe_symlink(input_pdf, fix_docinfo_file)
context.plugin_manager.hook.generate_pdfa(
# Extract PDF/A part correctly
if options.output_type.startswith('pdfa'):
if options.output_type == 'pdfa':
pdfa_part = '2' # Default to PDF/A-2
else:
pdfa_part = options.output_type.split('-')[
-1
] # Extract number from pdfa-1, pdfa-2, etc.
else:
pdfa_part = '2' # Fallback
context.plugin_manager.generate_pdfa(
pdf_version=input_pdfinfo.min_version,
pdf_pages=[fix_docinfo_file],
pdfmark=input_ps_stub,
output_file=output_file,
context=context,
pdfa_part=options.output_type[-1], # is pdfa-1, pdfa-2, or pdfa-3
pdfa_part=pdfa_part,
progressbar_class=(
context.plugin_manager.hook.get_progressbar_class()
context.plugin_manager.get_progressbar_class()
if options.progress_bar
else None
),
@@ -954,15 +975,136 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
return output_file
def try_speculative_pdfa(input_pdf: Path, context: PdfContext) -> Path | None:
"""Try speculative PDF/A conversion with verapdf validation.
This attempts a fast PDF/A conversion by adding PDF/A structures
directly with pikepdf, then validating with verapdf. If validation
passes, returns the converted file. If it fails or verapdf is not
available, returns None to signal that Ghostscript should be used.
Args:
input_pdf: Path to the PDF to convert
context: The PDF context
Returns:
Path to valid PDF/A file, or None if speculative conversion failed
"""
from ocrmypdf._exec import verapdf
options = context.options
# Skip speculative conversion if user requested specific image compression,
# since that requires Ghostscript to apply
gs_opts = getattr(options, 'ghostscript', None)
if gs_opts is not None:
compression = getattr(gs_opts, 'pdfa_image_compression', 'auto')
if compression != 'auto':
log.debug(
'Skipping speculative PDF/A: --pdfa-image-compression=%s requires '
'Ghostscript',
compression,
)
return None
if not verapdf.available():
log.debug('verapdf not available, skipping speculative PDF/A conversion')
return None
output_file = context.get_path('speculative_pdfa.pdf')
try:
speculative_pdfa_conversion(input_pdf, output_file, options.output_type)
flavour = verapdf.output_type_to_flavour(options.output_type)
result = verapdf.validate(output_file, flavour)
if result.valid:
log.info('Speculative PDF/A conversion succeeded - skipping Ghostscript')
return output_file
else:
log.debug(
'Speculative PDF/A validation failed (%d rule violations), '
'falling back to Ghostscript',
result.failed_rules,
)
return None
except Exception as e:
log.debug('Speculative PDF/A conversion failed: %s', e)
return None
def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]:
"""Best-effort PDF/A for 'auto' output type.
This function attempts to produce PDF/A without requiring Ghostscript:
1. If verapdf is available, tries speculative conversion with validation
2. Without verapdf, passes through as PDF/A if safe (input already PDF/A
or force-ocr was used)
3. Falls back to regular PDF if neither condition is met
Args:
input_pdf: Path to the PDF to convert
context: The PDF context
Returns:
Tuple of (output_path, actual_output_type) where actual_output_type
is 'pdfa' if PDF/A was achieved, 'pdf' otherwise
"""
from ocrmypdf._exec import verapdf
# If verapdf available, try speculative conversion with validation
if verapdf.available():
result = try_speculative_pdfa(input_pdf, context)
if result is not None:
return (result, 'pdfa')
# verapdf validation failed - fall through to regular PDF
log.info(
'Auto mode: speculative PDF/A validation failed, outputting regular PDF'
)
return (input_pdf, 'pdf')
# Without verapdf, check if we can pass through as PDF/A
if _is_safe_pdfa(input_pdf, context.options):
# Pass through as-is (no modifications needed)
log.info('Auto mode: passing through as PDF/A (input already compliant)')
return (input_pdf, 'pdfa')
# Fall through to regular PDF
log.info('Auto mode: no verapdf available and input is not PDF/A, outputting PDF')
return (input_pdf, 'pdf')
def _is_safe_pdfa(input_pdf: Path, options) -> bool:
"""Check if file can be considered PDF/A without validation.
These are cases where our modifications don't break PDF/A compliance:
1. Input already claims PDF/A (we just grafted OCR text onto it)
2. We used force-ocr (we rewrote the entire PDF from scratch)
Args:
input_pdf: Path to the PDF to check
options: OCR options
Returns:
True if file can safely be considered PDF/A
"""
# Safe if input already claims PDF/A
pdfa_status = file_claims_pdfa(input_pdf)
if pdfa_status['pass']:
return True
# Safe if we rewrote the PDF with force mode
return options.mode == ProcessingMode.force
def should_linearize(working_file: Path, context: PdfContext) -> bool:
"""Determine whether the PDF should be linearized.
For smaller files, linearization is not worth the effort.
"""
filesize = os.stat(working_file).st_size
if filesize > (context.options.fast_web_view * 1_000_000):
return True
return False
return filesize > (context.options.fast_web_view * 1_000_000)
def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
@@ -1016,7 +1158,7 @@ def optimize_pdf(
) -> tuple[Path, Sequence[str]]:
"""Optimize the given PDF file."""
output_file = context.get_path('optimize.pdf')
output_pdf, messages = context.plugin_manager.hook.optimize_pdf(
output_pdf, messages = context.plugin_manager.optimize_pdf(
input_pdf=input_file,
output_pdf=output_file,
context=context,
@@ -1080,10 +1222,7 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
# others don't. Remove it if it exists, since we add one manually.
stream.write(txt.removesuffix('\f'))
else:
if from_ != to_:
pages = f'{from_}-{to_}'
else:
pages = f'{from_}'
pages = f"{from_}-{to_}" if from_ != to_ else f"{from_}"
stream.write(f'[OCR skipped on page(s) {pages}]')
return output_file
+78 -20
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import argparse
import json
import logging
import logging.handlers
@@ -17,9 +16,13 @@ from concurrent.futures.thread import BrokenThreadPool
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple, cast
from typing import TYPE_CHECKING, NamedTuple, cast
if TYPE_CHECKING:
from ocrmypdf.hocrtransform import OcrElement
import PIL
import PIL.Image
from pikepdf import Pdf
from ocrmypdf._annots import remove_broken_goto_annotations
@@ -27,6 +30,7 @@ from ocrmypdf._concurrent import Executor, setup_executor
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._logging import PageNumberFilter
from ocrmypdf._metadata import metadata_fixup
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipeline import (
convert_to_pdfa,
create_ocr_image,
@@ -44,6 +48,8 @@ from ocrmypdf._pipeline import (
rasterize_preview,
should_linearize,
should_visible_page_image_use_jpg,
try_auto_pdfa,
try_speculative_pdfa,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._validation import (
@@ -51,7 +57,6 @@ from ocrmypdf._validation import (
)
from ocrmypdf.exceptions import ExitCode, ExitCodeException
from ocrmypdf.helpers import (
available_cpu_count,
check_pdf,
pikepdf_enable_mmap,
running_in_docker,
@@ -105,6 +110,9 @@ class PageResult(NamedTuple):
orientation_correction: int = 0
"""Orientation correction in degrees."""
ocr_tree: OcrElement | None = None
"""Direct OcrElement tree (when using generate_ocr() API)."""
class HOCRResultEncoder(json.JSONEncoder):
def default(self, obj):
@@ -115,7 +123,8 @@ class HOCRResultEncoder(json.JSONEncoder):
class HOCRResultDecoder(json.JSONDecoder):
def __init__(self, *args, **kwargs):
super().__init__(object_hook=self.dict_to_object, *args, **kwargs)
kwargs['object_hook'] = self.dict_to_object
super().__init__(*args, **kwargs)
def dict_to_object(self, d):
if 'Path' in d:
@@ -142,6 +151,9 @@ class HOCRResult:
orientation_correction: int = 0
"""Orientation correction in degrees."""
ocr_tree: OcrElement | None = None
"""Direct OcrElement tree (when using generate_ocr() API)."""
@classmethod
def from_json(cls, json_str: str) -> HOCRResult:
"""Create an instance from a dict."""
@@ -194,7 +206,7 @@ def worker_init(max_pixels: int | None) -> None:
@contextmanager
def manage_debug_log_handler(
*,
options: argparse.Namespace,
options: OcrOptions,
work_folder: Path,
):
remover = None
@@ -243,8 +255,8 @@ def manage_work_folder(*, work_folder: Path, retain: bool, print_location: bool)
def cli_exception_handler(
fn: Callable[[argparse.Namespace, OcrmypdfPluginManager], ExitCode],
options: argparse.Namespace,
fn: Callable[[OcrOptions, OcrmypdfPluginManager], ExitCode],
options: OcrOptions,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Convert exceptions into command line error messages and exit codes.
@@ -274,6 +286,16 @@ def cli_exception_handler(
else:
log.error(type(e).__name__)
return e.exit_code
except ValueError as e:
# Convert Pydantic validation errors to BadArgsError for proper exit code
if "validation error" in str(e).lower() or "value error" in str(e).lower():
if options.verbose >= 1:
log.exception("Validation error")
else:
log.error("Invalid argument: %s", str(e))
return ExitCode.bad_args
# Re-raise other ValueErrors to be caught by the general exception handler
raise
except PIL.Image.DecompressionBombError:
log.exception(
"A decompression bomb error was encountered while executing the "
@@ -298,23 +320,33 @@ def cli_exception_handler(
def setup_pipeline(
options: argparse.Namespace,
options: OcrOptions,
plugin_manager: OcrmypdfPluginManager,
) -> Executor:
# Any changes to options will not take effect for options that are already
# bound to function parameters in the pipeline. (For example
# options.input_file, options.pdf_renderer are already bound.)
if not options.jobs:
options.jobs = available_cpu_count()
# Note: OcrOptions is immutable, so we can't modify options.jobs directly
# The jobs field should already be set correctly during OcrOptions creation
# Apply PIL max image pixels side effect
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
if PIL.Image.MAX_IMAGE_PIXELS == 0:
PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
pikepdf_enable_mmap()
executor = setup_executor(plugin_manager)
return executor
def do_get_pdfinfo(
pdf_path: Path, executor: Executor, options: argparse.Namespace
) -> PdfInfo:
def do_get_pdfinfo(pdf_path: Path, executor: Executor, options) -> PdfInfo:
# Handle pages field - it might be a string that needs conversion
check_pages = options.pages
if isinstance(check_pages, str):
from ocrmypdf._options import _pages_from_ranges
check_pages = _pages_from_ranges(check_pages)
return get_pdfinfo(
pdf_path,
executor=executor,
@@ -322,7 +354,7 @@ def do_get_pdfinfo(
progbar=options.progress_bar,
max_workers=options.jobs,
use_threads=options.use_threads,
check_pages=options.pages,
check_pages=check_pages,
)
@@ -425,7 +457,7 @@ def process_page(page_context: PageContext) -> tuple[Path, Path | None, int]:
visible_image_out = preprocess_out
if should_visible_page_image_use_jpg(page_context.pageinfo):
visible_image_out = create_visible_page_jpg(visible_image_out, page_context)
filtered_image = page_context.plugin_manager.hook.filter_page_image(
filtered_image = page_context.plugin_manager.filter_page_image(
page=page_context, image_filename=visible_image_out
)
if filtered_image is not None: # None if no hook is present
@@ -448,11 +480,22 @@ def postprocess(
pdf_out = fix_annots
else:
pdf_out = pdf_file
if context.options.output_type.startswith('pdfa'):
ps_stub_out = generate_postscript_stub(context)
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
if context.options.output_type == 'auto':
# Best effort PDF/A - never uses Ghostscript
pdf_out, actual_type = try_auto_pdfa(pdf_out, context)
# Store actual output type for reporting
context.options.extra_attrs['_actual_output_type'] = actual_type
elif context.options.output_type.startswith('pdfa'):
# Required PDF/A - uses Ghostscript as fallback
speculative_result = try_speculative_pdfa(pdf_out, context)
if speculative_result is not None:
pdf_out = speculative_result
else:
# Fall back to Ghostscript conversion
ps_stub_out = generate_postscript_stub(context)
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
optimizing = context.plugin_manager.hook.is_optimization_enabled(context=context)
optimizing = context.plugin_manager.is_optimization_enabled(context=context)
save_settings = get_pdf_save_settings(context.options.output_type)
save_settings['linearize'] = not optimizing and should_linearize(pdf_out, context)
@@ -468,7 +511,22 @@ def report_output_pdf(options, start_input_file, optimize_messages) -> ExitCode:
elif samefile(options.output_file, Path(os.devnull)):
pass # Say nothing when sending to dev null
else:
if options.output_type.startswith('pdfa'):
if options.output_type == 'auto':
# For 'auto' mode, check what we actually produced
actual_type = options.extra_attrs.get('_actual_output_type', 'pdf')
pdfa_info = file_claims_pdfa(options.output_file)
if actual_type == 'pdfa' and pdfa_info['pass']:
log.info(
"Output file is a %s (auto mode achieved PDF/A)",
pdfa_info['conformance'],
)
elif pdfa_info['pass']:
# Unexpectedly got PDF/A
log.info("Output file is a %s", pdfa_info['conformance'])
else:
# Regular PDF - this is expected for auto mode fallback
log.info("Output file is a PDF (auto mode)")
elif options.output_type.startswith('pdfa'):
pdfa_info = file_claims_pdfa(options.output_file)
if pdfa_info['pass']:
log.info("Output file is a %s (as expected)", pdfa_info['conformance'])
+11 -12
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import argparse
import logging
import logging.handlers
from collections.abc import Sequence
@@ -17,10 +16,8 @@ import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._graft import OcrGrafter
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._pipeline import (
copy_final,
render_hocr_page,
)
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipeline import copy_final
from ocrmypdf._pipelines._common import (
HOCRResult,
do_get_pdfinfo,
@@ -34,6 +31,7 @@ from ocrmypdf._pipelines._common import (
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.exceptions import ExitCode
from ocrmypdf.helpers import available_cpu_count
log = logging.getLogger(__name__)
@@ -45,9 +43,8 @@ def _exec_hocrtransform_sync(page_context: PageContext) -> HOCRResult:
# No hOCR file, so no OCR was performed on this page.
return HOCRResult(pageno=page_context.pageno)
hocr_result = HOCRResult.from_json(hocr_json.read_text())
hocr_result.textpdf = render_hocr_page(
page_context.get_path('ocr_hocr.hocr'), page_context
)
# hOCR path is passed directly to the grafting phase where fpdf2 renders it
hocr_result.textpdf = page_context.get_path('ocr_hocr.hocr')
return hocr_result
@@ -55,7 +52,8 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st
"""Convert hOCR files to OCR PDF."""
# Run exec_page_sync on every page
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
jobs = options.jobs or available_cpu_count()
max_workers = min(len(context.pdfinfo), jobs)
if max_workers > 1:
log.info("Continue processing %d pages concurrently", max_workers)
@@ -69,7 +67,8 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st
ocrgraft.graft_page(
pageno=result.pageno,
image=result.pdf_page_from_image,
textpdf=result.textpdf,
ocr_output=result.textpdf,
ocr_tree=result.ocr_tree,
autorotate_correction=result.orientation_correction,
)
pbar.update()
@@ -105,7 +104,7 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st
def run_hocr_to_ocr_pdf_pipeline(
options: argparse.Namespace,
options: OcrOptions,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
@@ -119,7 +118,7 @@ def run_hocr_to_ocr_pdf_pipeline(
# Gather pdfinfo and create context
pdfinfo = do_get_pdfinfo(origin_pdf, executor, options)
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
plugin_manager.hook.check_options(options=options)
plugin_manager.check_options(options=options)
optimize_messages = exec_hocr_to_ocr_pdf(context, executor)
return report_output_pdf(options, origin_pdf, optimize_messages)
+31 -19
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import argparse
import logging
import logging.handlers
from collections.abc import Sequence
@@ -19,13 +18,14 @@ import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._graft import OcrGrafter
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipeline import (
copy_final,
is_ocr_required,
merge_sidecars,
ocr_engine_direct,
ocr_engine_hocr,
ocr_engine_textonly_pdf,
render_hocr_page,
triage,
validate_pdfinfo_options,
)
@@ -49,23 +49,32 @@ from ocrmypdf._validation import (
create_input_file,
)
from ocrmypdf.exceptions import ExitCode
from ocrmypdf.helpers import available_cpu_count
from ocrmypdf.models.ocr_element import OcrElement
log = logging.getLogger(__name__)
def _image_to_ocr_text(
page_context: PageContext, ocr_image_out: Path
) -> tuple[Path, Path]:
) -> tuple[Path | None, Path, OcrElement | None]:
"""Run OCR engine on image to create OCR PDF and text file."""
options = page_context.options
if options.pdf_renderer.startswith('hocr'):
hocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context)
ocr_out = render_hocr_page(hocr_out, page_context)
elif options.pdf_renderer == 'sandwich':
pdf_renderer = options.pdf_renderer
# fpdf2 is the default renderer (auto resolves to fpdf2)
if pdf_renderer in ('auto', 'fpdf2'):
# Use generate_ocr() if the engine supports it, otherwise use hOCR path
ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options)
if ocr_engine and ocr_engine.supports_generate_ocr():
ocr_tree, text_out = ocr_engine_direct(ocr_image_out, page_context)
return None, text_out, ocr_tree
ocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context)
elif pdf_renderer == 'sandwich':
ocr_out, text_out = ocr_engine_textonly_pdf(ocr_image_out, page_context)
else:
raise NotImplementedError(f"pdf_renderer {options.pdf_renderer}")
return ocr_out, text_out
raise NotImplementedError(f"pdf_renderer {pdf_renderer}")
return ocr_out, text_out, None
def _exec_page_sync(page_context: PageContext) -> PageResult:
@@ -78,22 +87,24 @@ def _exec_page_sync(page_context: PageContext) -> PageResult:
ocr_image_out, pdf_page_from_image_out, orientation_correction = process_page(
page_context
)
ocr_out, text_out = _image_to_ocr_text(page_context, ocr_image_out)
ocr_out, text_out, ocr_tree = _image_to_ocr_text(page_context, ocr_image_out)
return PageResult(
pageno=page_context.pageno,
pdf_page_from_image=pdf_page_from_image_out,
ocr=ocr_out,
text=text_out,
orientation_correction=orientation_correction,
ocr_tree=ocr_tree,
)
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
"""Execute the OCR pipeline concurrently."""
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
jobs = options.jobs or available_cpu_count()
max_workers = min(len(context.pdfinfo), jobs)
if max_workers > 1:
log.info("Start processing %d pages concurrently", max_workers)
log.info("Starting processing with %d workers concurrently", max_workers)
sidecars: list[Path | None] = [None] * len(context.pdfinfo)
ocrgraft = OcrGrafter(context)
@@ -107,7 +118,8 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
ocrgraft.graft_page(
pageno=result.pageno,
image=result.pdf_page_from_image,
textpdf=result.ocr,
ocr_output=result.ocr,
ocr_tree=result.ocr_tree,
autorotate_correction=result.orientation_correction,
)
pbar.update(0.5)
@@ -119,7 +131,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
max_workers=max_workers,
progress_kwargs=dict(
total=len(context.pdfinfo),
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
desc='OCR' if options.ocr_engine != 'none' else 'Image processing',
unit='page',
disable=not options.progress_bar,
),
@@ -150,7 +162,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
def _run_pipeline(
options: argparse.Namespace,
options: OcrOptions,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
with (
@@ -185,14 +197,14 @@ def _run_pipeline(
def run_pipeline_cli(
options: argparse.Namespace,
options: OcrOptions,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Run the OCR pipeline with command line exception handling.
Args:
options: The parsed command line options.
options: The parsed OCR options.
plugin_manager: The plugin manager to use. If not provided, one will be
created.
"""
@@ -200,14 +212,14 @@ def run_pipeline_cli(
def run_pipeline(
options: argparse.Namespace,
options: OcrOptions,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Run the OCR pipeline without command line exception handling.
Args:
options: The parsed command line options.
options: The parsed OCR options.
plugin_manager: The plugin manager to use. If not provided, one will be
created.
"""
+8 -8
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import argparse
import logging
import logging.handlers
import shutil
@@ -16,6 +15,7 @@ import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipeline import (
is_ocr_required,
ocr_engine_hocr,
@@ -31,9 +31,7 @@ from ocrmypdf._pipelines._common import (
worker_init,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._validation import (
set_lossless_reconstruction,
)
from ocrmypdf.helpers import available_cpu_count
log = logging.getLogger(__name__)
@@ -64,9 +62,10 @@ def exec_pdf_to_hocr(context: PdfContext, executor: Executor) -> None:
"""Execute the OCR pipeline concurrently and output hOCR."""
# Run exec_page_sync on every page
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
jobs = options.jobs or available_cpu_count()
max_workers = min(len(context.pdfinfo), jobs)
if max_workers > 1:
log.info("Start processing %d pages concurrently", max_workers)
log.info("Starting processing with %d workers concurrently", max_workers)
executor(
use_threads=options.use_threads,
@@ -85,11 +84,13 @@ def exec_pdf_to_hocr(context: PdfContext, executor: Executor) -> None:
def run_hocr_pipeline(
options: argparse.Namespace,
options: OcrOptions,
*,
plugin_manager: OcrmypdfPluginManager,
) -> None:
"""Run pipeline to output hOCR."""
if options.output_folder is None:
raise ValueError("output_folder must be specified for hOCR pipeline")
with manage_work_folder(
work_folder=options.output_folder, retain=True, print_location=False
) as work_folder:
@@ -103,6 +104,5 @@ def run_hocr_pipeline(
options, work_folder, options.input_file, pdfinfo, plugin_manager
)
# Validate options are okay for this pdf
set_lossless_reconstruction(options)
validate_pdfinfo_options(context)
exec_pdf_to_hocr(context, executor)
+197 -44
View File
@@ -1,33 +1,44 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Plugin manager using pluggy."""
"""Plugin manager using pluggy with type-safe interface."""
from __future__ import annotations
import argparse
import importlib
import importlib.util
import pkgutil
import sys
from argparse import ArgumentParser
from collections.abc import Sequence
from logging import Handler
from pathlib import Path
from typing import TYPE_CHECKING
import pluggy
from pydantic import BaseModel
import ocrmypdf.builtin_plugins
from ocrmypdf import pluginspec
from ocrmypdf.cli import get_parser, plugins_only_parser
from ocrmypdf import Executor, PdfContext, pluginspec
from ocrmypdf._options import OcrOptions
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.helpers import Resolution
from ocrmypdf.pluginspec import OcrEngine
if TYPE_CHECKING:
from PIL import Image
from ocrmypdf._jobcontext import PageContext
from ocrmypdf.pdfinfo import PdfInfo
class OcrmypdfPluginManager(pluggy.PluginManager):
"""pluggy.PluginManager that can fork.
class OcrmypdfPluginManager:
"""Type-safe wrapper around pluggy.PluginManager.
Capable of reconstructing itself in child workers.
Capable of reconstructing itself in child workers via pickle.
Arguments:
setup_func: callback that initializes the plugin manager with all
standard plugins
This class provides type-safe methods for all hooks defined in pluginspec.py,
removing the need for unsafe `hook.method_name()` calls.
"""
def __init__(
@@ -37,19 +48,28 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
builtins: bool = True,
**kwargs,
):
self.__init_args = args
self.__init_kwargs = kwargs
self.__plugins = plugins
self.__builtins = builtins
super().__init__(*args, **kwargs)
self.setup_plugins()
self._init_args = args
self._init_kwargs = kwargs
self._plugins = plugins
self._builtins = builtins
self._pm = pluggy.PluginManager(*args, **kwargs)
self._setup_plugins()
@property
def pluggy(self) -> pluggy.PluginManager:
"""Access the underlying pluggy.PluginManager for advanced use cases.
This is useful for plugins that need to call methods like set_blocked()
in their initialize hook.
"""
return self._pm
def __getstate__(self):
state = dict(
init_args=self.__init_args,
plugins=self.__plugins,
builtins=self.__builtins,
init_kwargs=self.__init_kwargs,
init_args=self._init_args,
plugins=self._plugins,
builtins=self._builtins,
init_kwargs=self._init_kwargs,
)
return state
@@ -61,23 +81,23 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
**state['init_kwargs'],
)
def setup_plugins(self):
self.add_hookspecs(pluginspec)
def _setup_plugins(self):
self._pm.add_hookspecs(pluginspec)
# 1. Register builtins
if self.__builtins:
if self._builtins:
for module in sorted(
pkgutil.iter_modules(ocrmypdf.builtin_plugins.__path__)
):
name = f'ocrmypdf.builtin_plugins.{module.name}'
module = importlib.import_module(name)
self.register(module)
self._pm.register(module)
# 2. Register setuptools plugins
self.load_setuptools_entrypoints('ocrmypdf')
self._pm.load_setuptools_entrypoints('ocrmypdf')
# 3. Register plugins specified on command line
for name in self.__plugins:
for name in self._plugins:
if isinstance(name, Path) or name.endswith('.py'):
# Import by filename
module_name = Path(name).stem
@@ -88,7 +108,156 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
else:
# Import by dotted module name
module = importlib.import_module(name)
self.register(module)
self._pm.register(module)
# =========================================================================
# Type-safe hook methods
# =========================================================================
# --- firstresult hooks ---
def get_logging_console(self) -> Handler | None:
"""Returns a custom logging handler for progress bar compatibility."""
return self._pm.hook.get_logging_console()
def get_executor(self, *, progressbar_class: type[ProgressBar]) -> Executor | None:
"""Returns an executor for parallel processing."""
return self._pm.hook.get_executor(progressbar_class=progressbar_class)
def get_progressbar_class(self) -> type[ProgressBar] | None:
"""Returns a progress bar class."""
return self._pm.hook.get_progressbar_class()
def rasterize_pdf_page(
self,
*,
input_file: Path,
output_file: Path,
raster_device: str,
raster_dpi: Resolution,
pageno: int,
page_dpi: Resolution | None,
rotation: int | None,
filter_vector: bool,
stop_on_soft_error: bool,
options: OcrOptions | None,
use_cropbox: bool,
) -> Path | None:
"""Rasterize one page of a PDF at specified resolution."""
return self._pm.hook.rasterize_pdf_page(
input_file=input_file,
output_file=output_file,
raster_device=raster_device,
raster_dpi=raster_dpi,
pageno=pageno,
page_dpi=page_dpi,
rotation=rotation,
filter_vector=filter_vector,
stop_on_soft_error=stop_on_soft_error,
options=options,
use_cropbox=use_cropbox,
)
def filter_ocr_image(
self, *, page: PageContext, image: Image.Image
) -> Image.Image | None:
"""Filter the image before it is sent to OCR."""
return self._pm.hook.filter_ocr_image(page=page, image=image)
def filter_page_image(
self, *, page: PageContext, image_filename: Path
) -> Path | None:
"""Filter the whole page image before it is inserted into the PDF."""
return self._pm.hook.filter_page_image(page=page, image_filename=image_filename)
def filter_pdf_page(
self, *, page: PageContext, image_filename: Path, output_pdf: Path
) -> Path | None:
"""Convert a filtered whole page image into a PDF."""
return self._pm.hook.filter_pdf_page(
page=page, image_filename=image_filename, output_pdf=output_pdf
)
def get_ocr_engine(self, *, options: OcrOptions | None = None) -> OcrEngine | None:
"""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)
def generate_pdfa(
self,
*,
pdf_pages: list[Path],
pdfmark: Path,
output_file: Path,
context: PdfContext,
pdf_version: str,
pdfa_part: str,
progressbar_class: type[ProgressBar] | None,
stop_on_soft_error: bool,
) -> Path | None:
"""Generate a PDF/A file."""
return self._pm.hook.generate_pdfa(
pdf_pages=pdf_pages,
pdfmark=pdfmark,
output_file=output_file,
context=context,
pdf_version=pdf_version,
pdfa_part=pdfa_part,
progressbar_class=progressbar_class,
stop_on_soft_error=stop_on_soft_error,
)
def optimize_pdf(
self,
*,
input_pdf: Path,
output_pdf: Path,
context: PdfContext,
executor: Executor,
linearize: bool,
) -> tuple[Path, Sequence[str]] | None:
"""Optimize a PDF after OCR processing."""
return self._pm.hook.optimize_pdf(
input_pdf=input_pdf,
output_pdf=output_pdf,
context=context,
executor=executor,
linearize=linearize,
)
def is_optimization_enabled(self, *, context: PdfContext) -> bool | None:
"""Returns whether optimization is enabled for given context."""
return self._pm.hook.is_optimization_enabled(context=context)
# --- non-firstresult hooks ---
def initialize(self, *, plugin_manager: pluggy.PluginManager) -> list[None]:
"""Called when plugins are first loaded.
Args:
plugin_manager: The underlying pluggy.PluginManager, allowing
plugins to call methods like set_blocked().
"""
return self._pm.hook.initialize(plugin_manager=plugin_manager)
def add_options(self, *, parser: ArgumentParser) -> list[None]:
"""Allows plugins to add command line and API arguments."""
return self._pm.hook.add_options(parser=parser)
def register_options(self) -> list[dict[str, type[BaseModel]]]:
"""Returns plugin option models keyed by namespace."""
return self._pm.hook.register_options()
def check_options(self, *, options: OcrOptions) -> list[None]:
"""Called to validate options after parsing."""
return self._pm.hook.check_options(options=options)
def validate(self, *, pdfinfo: PdfInfo, options: OcrOptions) -> list[None]:
"""Called to validate options and pdfinfo after PDF is loaded."""
return self._pm.hook.validate(pdfinfo=pdfinfo, options=options)
def get_plugin_manager(
@@ -101,20 +270,4 @@ def get_plugin_manager(
)
def get_parser_options_plugins(
args: Sequence[str],
) -> tuple[argparse.ArgumentParser, argparse.Namespace, pluggy.PluginManager]:
pre_options, _unused = plugins_only_parser.parse_known_args(args=args)
plugin_manager = get_plugin_manager(pre_options.plugins)
parser = get_parser()
plugin_manager.hook.initialize( # pylint: disable=no-member
plugin_manager=plugin_manager
)
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
options = parser.parse_args(args=args)
return parser, options, plugin_manager
__all__ = ['OcrmypdfPluginManager', 'get_plugin_manager', 'get_parser_options_plugins']
__all__ = ['OcrmypdfPluginManager', 'get_plugin_manager']
+50
View File
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Plugin option registry for dynamic model composition."""
from __future__ import annotations
import logging
from pydantic import BaseModel
log = logging.getLogger(__name__)
class PluginOptionRegistry:
"""Registry for plugin option models.
This registry collects option models from plugins during initialization.
Plugin options can be accessed via nested namespaces on OcrOptions
(e.g., options.tesseract.timeout) or via flat field names for backward
compatibility (e.g., options.tesseract_timeout).
"""
def __init__(self):
self._option_models: dict[str, type[BaseModel]] = {}
def register_option_model(
self, namespace: str, model_class: type[BaseModel]
) -> None:
"""Register a plugin's option model.
Args:
namespace: The namespace for the plugin options (e.g., 'tesseract')
model_class: The Pydantic model class for the plugin options
"""
if namespace in self._option_models:
log.warning(
f"Plugin option namespace '{namespace}' already registered, overriding"
)
self._option_models[namespace] = model_class
log.debug(
f"Registered plugin option model for namespace '{namespace}': "
f"{model_class.__name__}"
)
def get_registered_models(self) -> dict[str, type[BaseModel]]:
"""Get all registered plugin option models."""
return self._option_models.copy()
+14 -6
View File
@@ -48,9 +48,11 @@ class ProgressBar(Protocol):
A brief description of the current step (e.g. "Scanning contents",
"OCR", "PDF/A conversion"). OCRmyPDF updates this before each major step.
unit (str | None):
A short label for the type of work being tracked (e.g. "page", "%", "image").
A short label for the type of work being tracked
(e.g. "page", "%", "image").
disable (bool):
If ``True``, progress updates are suppressed (no output). Defaults to ``False``.
If ``True``, progress updates are suppressed (no output).
Defaults to ``False``.
**kwargs:
Future or extra parameters that OCRmyPDF might pass. Implementations
should accept and ignore unrecognized keywords gracefully.
@@ -64,7 +66,8 @@ class ProgressBar(Protocol):
from ocrmypdf import hookimpl
class ConsoleProgressBar(ProgressBar):
def __init__(self, *, total=None, desc=None, unit=None, disable=False, **kwargs):
def __init__(self, *, total=None, desc=None, unit=None, disable=False,
**kwargs):
self.total = total
self.desc = desc
self.unit = unit
@@ -73,7 +76,9 @@ class ProgressBar(Protocol):
def __enter__(self):
if not self.disable:
print(f"Starting {self.desc or 'an OCR task'} (total={self.total} {self.unit})")
print(f"Starting {self.desc or 'an OCR task'} "
f"(total={self.total} {self.unit})"
)
return self
def __exit__(self, exc_type, exc_value, traceback):
@@ -86,7 +91,7 @@ class ProgressBar(Protocol):
def update(self, n=1, *, completed=None):
if completed is not None:
# If 'completed' is given, you could set self.current = completed
# If 'completed' is given, set self.current
# but let's just read it to show usage
print(f"Absolute completion reported: {completed}")
# Otherwise, we increment by 'n'
@@ -94,7 +99,10 @@ class ProgressBar(Protocol):
if not self.disable:
if self.total:
percent = (self.current / self.total) * 100
print(f"{self.desc}: {self.current}/{self.total} ({percent:.1f}%)")
print(
f"{self.desc}: {self.current}"
f"/{self.total} ({percent:.1f}%)"
)
else:
print(f"{self.desc}: {self.current} units done")
+49 -127
View File
@@ -6,22 +6,19 @@
from __future__ import annotations
import locale
import logging
import os
import sys
import unicodedata
from argparse import Namespace
from collections.abc import Sequence
from pathlib import Path
from shutil import copyfileobj
import pikepdf
import PIL
from pluggy import PluginManager
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._exec import unpaper
from ocrmypdf._options import OcrOptions
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf.exceptions import (
BadArgsError,
InputFileError,
@@ -30,7 +27,6 @@ from ocrmypdf.exceptions import (
)
from ocrmypdf.helpers import (
is_file_writable,
monotonic,
running_in_docker,
running_in_snap,
safe_symlink,
@@ -51,13 +47,19 @@ def check_platform() -> None:
def check_options_languages(
options: Namespace, ocr_engine_languages: list[str]
options: OcrOptions, ocr_engine_languages: list[str]
) -> None:
if not options.languages:
options.languages = [DEFAULT_LANGUAGE]
system_lang = locale.getlocale()[0]
if system_lang and not system_lang.startswith('en'):
log.debug("No language specified; assuming --language %s", DEFAULT_LANGUAGE)
# Check for blocked languages first, before checking if they're installed
DENIED_LANGUAGES = {'equ', 'osd'}
blocked = DENIED_LANGUAGES & set(options.languages)
if blocked:
raise BadArgsError(
"The following languages are for Tesseract's internal use and "
"should not be issued explicitly: "
f"{', '.join(blocked)}\n"
"Remove them from the -l/--language argument."
)
if not ocr_engine_languages:
return
@@ -81,36 +83,7 @@ def check_options_languages(
raise MissingDependencyError(msg)
def check_options_output(options: Namespace) -> None:
if options.output_type == 'none' and options.output_file not in (os.devnull, '-'):
raise BadArgsError(
"Since you specified `--output-type none`, the output file "
f"{options.output_file} cannot be produced. Set the output file to "
f"`-` to suppress this message."
)
def set_lossless_reconstruction(options: Namespace) -> None:
lossless_reconstruction = False
if not any(
(
options.deskew,
options.clean_final,
options.force_ocr,
options.remove_background,
)
):
lossless_reconstruction = True
options.lossless_reconstruction = lossless_reconstruction
if not options.lossless_reconstruction and options.redo_ocr:
raise BadArgsError(
"--redo-ocr is not currently compatible with --deskew, "
"--clean-final, and --remove-background"
)
def check_options_sidecar(options: Namespace) -> None:
def check_options_sidecar(options: OcrOptions) -> None:
if options.sidecar == '\0':
if options.output_file == '-':
raise BadArgsError("--sidecar filename needed when output file is stdout.")
@@ -125,7 +98,7 @@ def check_options_sidecar(options: Namespace) -> None:
)
def check_options_preprocessing(options: Namespace) -> None:
def check_options_preprocessing(options: OcrOptions) -> None:
if options.clean_final:
options.clean = True
if options.unpaper_args and not options.clean:
@@ -152,98 +125,45 @@ def check_options_preprocessing(options: Namespace) -> None:
raise BadArgsError("--unpaper-args: " + str(e)) from e
def _pages_from_ranges(ranges: str) -> set[int]:
pages: list[int] = []
page_groups = ranges.replace(' ', '').split(',')
for group in page_groups:
if not group:
continue
try:
start, end = group.split('-')
except ValueError:
pages.append(int(group) - 1)
else:
try:
new_pages = list(range(int(start) - 1, int(end)))
if not new_pages:
raise BadArgsError(
f"invalid page subrange '{start}-{end}'"
) from None
pages.extend(new_pages)
except ValueError:
raise BadArgsError(f"invalid page subrange '{group}'") from None
if not pages:
raise BadArgsError(
f"The string of page ranges '{ranges}' did not contain any recognizable "
f"page ranges."
)
if not monotonic(pages):
log.warning(
"List of pages to process contains duplicate pages, or pages that are "
"out of order"
)
if any(page < 0 for page in pages):
raise BadArgsError("pages refers to a page number less than 1")
log.debug("OCRing only these pages: %s", pages)
return set(pages)
def check_options_ocr_behavior(options: Namespace) -> None:
exclusive_options = sum(
(1 if opt else 0)
for opt in (options.force_ocr, options.skip_text, options.redo_ocr)
)
if exclusive_options >= 2:
raise BadArgsError("Choose only one of --force-ocr, --skip-text, --redo-ocr.")
if options.pages:
options.pages = _pages_from_ranges(options.pages)
def check_options_metadata(options: Namespace) -> None:
docinfo = [options.title, options.author, options.keywords, options.subject]
for s in (m for m in docinfo if m):
for char in s:
if unicodedata.category(char) == 'Co' or ord(char) >= 0x10000:
hexchar = hex(ord(char))[2:].upper()
raise ValueError(
"One of the metadata strings contains "
"an unsupported Unicode character: "
f"{char} (U+{hexchar})"
)
def check_options_pillow(options: Namespace) -> None:
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
if PIL.Image.MAX_IMAGE_PIXELS == 0:
PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
def _check_plugin_invariant_options(options: Namespace) -> None:
def _check_plugin_invariant_options(options: OcrOptions) -> None:
check_platform()
check_options_metadata(options)
check_options_output(options)
set_lossless_reconstruction(options)
check_options_sidecar(options)
check_options_preprocessing(options)
check_options_ocr_behavior(options)
check_options_pillow(options)
def _check_plugin_options(options: Namespace, plugin_manager: PluginManager) -> None:
plugin_manager.hook.check_options(options=options)
ocr_engine_languages = plugin_manager.hook.get_ocr_engine().languages(options)
def _check_plugin_options(
options: OcrOptions, plugin_manager: OcrmypdfPluginManager
) -> None:
# First, let plugins check their external dependencies
plugin_manager.check_options(options=options)
# Then check OCR engine language support
ocr_engine_languages = plugin_manager.get_ocr_engine(options=options).languages(
options
)
check_options_languages(options, ocr_engine_languages)
# Finally, run comprehensive validation using the coordinator
from ocrmypdf._validation_coordinator import ValidationCoordinator
def check_options(options: Namespace, plugin_manager: PluginManager) -> None:
coordinator = ValidationCoordinator(plugin_manager)
coordinator.validate_all_options(options)
def check_options(options: OcrOptions, plugin_manager: OcrmypdfPluginManager) -> None:
"""Check options for validity and consistency.
This function coordinates validation across the entire system:
1. Core validation (platform, files, preprocessing)
2. Plugin external dependency validation
3. Plugin-specific validation (handled by plugin models)
4. Cross-cutting validation (handled by validation coordinator)
"""
_check_plugin_invariant_options(options)
_check_plugin_options(options, plugin_manager)
def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]:
def create_input_file(options: OcrOptions, work_folder: Path) -> tuple[Path, str]:
if options.input_file == '-':
# stdin
log.info('reading file from standard input')
@@ -288,7 +208,7 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
raise InputFileError(msg) from e
def check_requested_output_file(options: Namespace) -> None:
def check_requested_output_file(options: OcrOptions) -> None:
if options.output_file == '-':
if sys.stdout.isatty():
raise BadArgsError(
@@ -306,7 +226,7 @@ def check_requested_output_file(options: Namespace) -> None:
def report_output_file_size(
options: Namespace,
options: OcrOptions,
input_file: Path,
output_file: Path,
optimize_messages: Sequence[str] | None = None,
@@ -335,13 +255,15 @@ def report_output_file_size(
'clean_final',
'remove_background',
'oversample',
'force_ocr',
}
for arg in image_preproc:
if getattr(options, arg, False):
reasons.append(
f"--{arg.replace('_', '-')} was issued, causing transcoding."
)
# Check force_ocr via the backward-compatible property
if options.force_ocr:
reasons.append("--force-ocr (or --mode force) was issued, causing transcoding.")
reasons.extend(optimize_messages)
+144
View File
@@ -0,0 +1,144 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Validation coordinator for plugin options and cross-cutting concerns."""
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import pluggy
from ocrmypdf._options import OcrOptions
log = logging.getLogger(__name__)
class ValidationCoordinator:
"""Coordinates validation across plugin models and core options."""
def __init__(self, plugin_manager: pluggy.PluginManager):
self.plugin_manager = plugin_manager
self.registry = getattr(plugin_manager, '_option_registry', None)
def validate_all_options(self, options: OcrOptions) -> None:
"""Run comprehensive validation on all options.
This runs validation in the correct order:
1. Plugin self-validation (already done by Pydantic)
2. Plugin context validation (requires external context)
3. Cross-cutting validation (between plugins and core)
Args:
options: The options to validate
"""
# Step 1: Plugin context validation
self._validate_plugin_contexts(options)
# Step 2: Cross-cutting validation
self._validate_cross_cutting_concerns(options)
def _validate_plugin_contexts(self, options: OcrOptions) -> None:
"""Validate plugin options that require external context."""
# For now, we'll run the plugin validation directly since the models
# are still being integrated. This ensures the validation warnings
# and checks still work as expected.
# Run Tesseract validation
self._validate_tesseract_options(options)
# Run Optimize validation
self._validate_optimize_options(options)
def _validate_tesseract_options(self, options: OcrOptions) -> None:
"""Validate Tesseract options."""
# Check pagesegmode warning
if options.tesseract.pagesegmode in (0, 2):
log.warning(
"The tesseract-pagesegmode you selected will disable OCR. "
"This may cause processing to fail."
)
# Check downsample consistency
if (
options.tesseract.downsample_above != 32767
and not options.tesseract.downsample_large_images
):
log.warning(
"The --tesseract-downsample-above argument will have no effect unless "
"--tesseract-downsample-large-images is also given."
)
# Note: blocked languages (equ, osd) are checked earlier in
# check_options_languages() to ensure the check runs before
# the missing language check.
def _validate_optimize_options(self, options: OcrOptions) -> None:
"""Validate optimization options."""
# Check optimization consistency
if options.optimize == 0 and any(
[
options.png_quality and options.png_quality > 0,
options.jpeg_quality and options.jpeg_quality > 0,
]
):
log.warning(
"The arguments --png-quality and --jpeg-quality "
"will be ignored because --optimize=0."
)
def _validate_cross_cutting_concerns(self, options: OcrOptions) -> None:
"""Validate cross-cutting concerns that span multiple plugins."""
from ocrmypdf._options import ProcessingMode
# Handle deprecated pdf_renderer values
self._handle_deprecated_pdf_renderer(options)
# Note: Mutual exclusivity of force_ocr/skip_text/redo_ocr is now enforced
# by the ProcessingMode enum - only one mode can be active at a time.
# Validate redo mode compatibility
if options.mode == ProcessingMode.redo and (
options.deskew or options.clean_final or options.remove_background
):
raise ValueError(
"--redo-ocr (or --mode redo) is not currently compatible with "
"--deskew, --clean-final, and --remove-background"
)
# Validate output type compatibility
if options.output_type == 'none' and str(options.output_file) not in (
os.devnull,
'-',
):
raise ValueError(
"Since you specified `--output-type none`, the output file "
f"{options.output_file} cannot be produced. Set the output file to "
"`-` to suppress this message."
)
# Validate PDF/A image compression compatibility
if (
options.ghostscript.pdfa_image_compression
and options.ghostscript.pdfa_image_compression != 'auto'
and not options.output_type.startswith('pdfa')
):
log.warning(
"--pdfa-image-compression argument only applies when "
"--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'"
)
def _handle_deprecated_pdf_renderer(self, options: OcrOptions) -> None:
"""Handle deprecated pdf_renderer values by redirecting to fpdf2."""
if options.pdf_renderer in ('hocr', 'hocrdebug'):
log.info(
"The '%s' PDF renderer has been removed. Using 'fpdf2' instead, "
"which provides full international language support, proper RTL "
"rendering, and improved text positioning.",
options.pdf_renderer,
)
# Modify the options object to use fpdf2
object.__setattr__(options, 'pdf_renderer', 'fpdf2')
+517 -171
View File
@@ -43,24 +43,22 @@ import logging
import os
import sys
import threading
from argparse import Namespace
from collections.abc import Iterable, Sequence
from enum import IntEnum
from io import IOBase
from pathlib import Path
from typing import BinaryIO
from typing import BinaryIO, overload
from warnings import warn
import pluggy
from ocrmypdf._logging import PageNumberFilter
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline
from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli
from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
from ocrmypdf._plugin_manager import get_plugin_manager
from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager
from ocrmypdf._validation import check_options
from ocrmypdf.cli import ArgumentParser, get_parser
from ocrmypdf.helpers import is_iterable_notstr
from ocrmypdf.exceptions import ExitCode
StrPath = Path | str | bytes
PathOrIO = BinaryIO | StrPath
@@ -71,6 +69,67 @@ PathOrIO = BinaryIO | StrPath
_api_lock = threading.Lock()
def setup_plugin_infrastructure(
plugins: Sequence[Path | str] | None = None,
plugin_manager: OcrmypdfPluginManager | None = None,
) -> OcrmypdfPluginManager:
"""Set up plugin infrastructure with proper initialization.
This function handles:
1. Creating or validating the plugin manager
2. Calling plugin initialization hooks
3. Setting up plugin option registry
Args:
plugins: List of plugin paths/names to load
plugin_manager: Existing plugin manager (if any)
Returns:
Properly initialized plugin manager
Raises:
ValueError: If both plugins and plugin_manager are provided
"""
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
if not plugins:
plugins = []
elif isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins)
# Create plugin manager if not provided
if not plugin_manager:
plugin_manager = get_plugin_manager(plugins)
# Initialize plugins (pass the underlying pluggy manager)
plugin_manager.initialize(plugin_manager=plugin_manager.pluggy)
# Initialize plugin option registry
from ocrmypdf._plugin_registry import PluginOptionRegistry
registry = PluginOptionRegistry()
# Let plugins register their option models
option_models = plugin_manager.register_options()
all_plugin_models: dict[str, type] = {}
for plugin_options in option_models:
if plugin_options: # Skip None returns
for namespace, model_class in plugin_options.items():
registry.register_option_model(namespace, model_class)
all_plugin_models[namespace] = model_class
# Register plugin models with OcrOptions for dynamic nested access
OcrOptions.register_plugin_models(all_plugin_models)
# Store registry in plugin manager for later access
plugin_manager._option_registry = registry
return plugin_manager
class Verbosity(IntEnum):
"""Verbosity level for configure_logging."""
@@ -86,7 +145,7 @@ def configure_logging(
*,
progress_bar_friendly: bool = True,
manage_root_logger: bool = False,
plugin_manager: pluggy.PluginManager | None = None,
plugin_manager: OcrmypdfPluginManager | None = None,
):
"""Set up logging.
@@ -133,7 +192,7 @@ def configure_logging(
console = None
if plugin_manager and progress_bar_friendly:
console = plugin_manager.hook.get_logging_console()
console = plugin_manager.get_logging_console()
if not console:
console = logging.StreamHandler(stream=sys.stderr)
@@ -165,6 +224,8 @@ def configure_logging(
pdfminer_log.setLevel(logging.ERROR)
pil_log = logging.getLogger('PIL')
pil_log.setLevel(logging.INFO)
fonttools_log = logging.getLogger('fontTools')
fonttools_log.setLevel(logging.ERROR)
if manage_root_logger:
logging.captureWarnings(True)
@@ -172,98 +233,130 @@ def configure_logging(
return log
def _kwargs_to_cmdline(
*, defer_kwargs: set[str], **kwargs
) -> tuple[list[str | bytes], dict[str, str | bytes]]:
"""Convert kwargs to command line arguments."""
cmdline: list[str | bytes] = []
deferred = {}
for arg, val in kwargs.items():
if val is None:
continue
def _check_no_conflicting_ocr_params(
locals_dict: dict,
kwargs: dict,
excluded: set[str] | None = None,
) -> None:
"""Check that no individual OCR parameters conflict with OcrOptions.
# Skip arguments that are handled elsewhere
if arg in defer_kwargs:
deferred[arg] = val
continue
When a user passes an OcrOptions object, they should not also pass
individual OCR parameters (except plugins/plugin_manager which are
handled separately).
cmd_style_arg = arg.replace('_', '-')
Args:
locals_dict: The locals() dict from the calling function.
kwargs: The **kwargs dict from the calling function.
excluded: Parameter names to exclude from conflict checking.
# Booleans are special: add only if True, omit for False
if isinstance(val, bool):
if val:
cmdline.append(f"--{cmd_style_arg}")
continue
Raises:
ValueError: If conflicting parameters are found.
"""
if excluded is None:
excluded = set()
if is_iterable_notstr(val):
for elem in val:
cmdline.append(f"--{cmd_style_arg}")
cmdline.append(elem)
continue
# Parameters that are allowed alongside OcrOptions
allowed_with_options = {
'input_file_or_options',
'options', # The OcrOptions object itself after assignment
'plugins',
'plugin_manager',
'kwargs',
} | excluded
# We have a parameter
cmdline.append(f"--{cmd_style_arg}")
if isinstance(val, int | float):
cmdline.append(str(val))
elif isinstance(val, str):
cmdline.append(val)
elif isinstance(val, Path):
cmdline.append(str(val))
else:
raise TypeError(f"{arg}: {val} ({type(val)})")
return cmdline, deferred
# Check all locals that are OCR parameters (not None and not allowed)
conflicts = [
name
for name, value in locals_dict.items()
if value is not None and name not in allowed_with_options
]
# Check kwargs
conflicts.extend(kwargs.keys())
if conflicts:
raise ValueError(
f"When passing OcrOptions as the first argument, do not pass "
f"additional OCR parameters. Conflicting parameters: "
f"{', '.join(sorted(conflicts))}. "
f"Set these values in OcrOptions instead."
)
def create_options(
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
) -> Namespace:
) -> OcrOptions:
"""Construct an options object from the input/output files and keyword arguments.
Args:
input_file: Input file path or file object.
output_file: Output file path or file object.
parser: ArgumentParser object.
parser: ArgumentParser object (kept for compatibility,
may be used for plugin validation).
**kwargs: Keyword arguments.
Returns:
argparse.Namespace: A Namespace object containing the parsed arguments.
OcrOptions: An options object containing the parsed arguments.
Raises:
TypeError: If the type of a keyword argument is not supported.
"""
cmdline, deferred = _kwargs_to_cmdline(
defer_kwargs={'progress_bar', 'plugins', 'parser', 'input_file', 'output_file'},
**kwargs,
)
if isinstance(input_file, BinaryIO | IOBase):
cmdline.append('stream://input_file')
else:
cmdline.append(os.fspath(input_file))
if isinstance(output_file, BinaryIO | IOBase):
cmdline.append('stream://output_file')
else:
cmdline.append(os.fspath(output_file))
if 'sidecar' in kwargs and isinstance(kwargs['sidecar'], BinaryIO | IOBase):
cmdline.append('--sidecar')
cmdline.append('stream://sidecar')
# Prepare kwargs for direct OcrOptions construction
options_kwargs = kwargs.copy()
parser.enable_api_mode()
options = parser.parse_args(cmdline)
for keyword, val in deferred.items():
setattr(options, keyword, val)
# Set input and output files
options_kwargs['input_file'] = input_file
options_kwargs['output_file'] = output_file
if options.input_file == 'stream://input_file':
options.input_file = input_file
if options.output_file == 'stream://output_file':
options.output_file = output_file
if options.sidecar == 'stream://sidecar':
options.sidecar = kwargs['sidecar']
# Handle special stream cases for sidecar
if 'sidecar' in options_kwargs and isinstance(
options_kwargs['sidecar'], BinaryIO | IOBase
):
# Keep the stream object as-is - OcrOptions can handle it
pass
return options
# Remove None values to let OcrOptions use its defaults
options_kwargs = {k: v for k, v in options_kwargs.items() if v is not None}
# Remove any kwargs that aren't OcrOptions fields and store in extra_attrs
extra_attrs = {}
ocr_fields = set(OcrOptions.model_fields.keys())
# Legacy mode flags are handled by OcrOptions model validator
legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'}
# Known extra attributes that should be preserved
known_extra = {'progress_bar', 'plugins'}
for key in list(options_kwargs.keys()):
if key in ocr_fields or key in legacy_mode_flags or key in known_extra:
continue
extra_attrs[key] = options_kwargs.pop(key)
# Create OcrOptions directly
try:
options = OcrOptions(**options_kwargs)
# Add any extra attributes
if extra_attrs:
options.extra_attrs.update(extra_attrs)
return options
except Exception as e:
# If direct construction fails, provide a helpful error message
raise TypeError(f"Failed to create OcrOptions: {e}") from e
def ocr( # noqa: D417
input_file: PathOrIO,
@overload
def ocr(
options: OcrOptions,
/,
*,
plugins: Iterable[Path | str] | None = None,
plugin_manager: OcrmypdfPluginManager | None = None,
) -> ExitCode: ...
@overload
def ocr(
input_file_or_options: PathOrIO,
output_file: PathOrIO,
*,
language: Iterable[str] | None = None,
@@ -284,6 +377,7 @@ def ocr( # noqa: D417
unpaper_args: str | None = None,
oversample: int | None = None,
remove_vectors: bool | None = None,
mode: str | None = None,
force_ocr: bool | None = None,
skip_text: bool | None = None,
redo_ocr: bool | None = None,
@@ -301,6 +395,7 @@ def ocr( # noqa: D417
tesseract_oem: int | None = None,
tesseract_thresholding: int | None = None,
pdf_renderer: str | None = None,
rasterizer: str | None = None,
tesseract_timeout: float | None = None,
tesseract_non_ocr_timeout: float | None = None,
tesseract_downsample_above: int | None = None,
@@ -314,13 +409,89 @@ def ocr( # noqa: D417
continue_on_soft_render_error: bool | None = None,
invalidate_digital_signatures: bool | None = None,
plugins: Iterable[Path | str] | None = None,
plugin_manager=None,
plugin_manager: OcrmypdfPluginManager | None = None,
keep_temporary_files: bool | None = None,
progress_bar: bool | None = None,
**kwargs,
):
) -> ExitCode: ...
def ocr( # noqa: D417
input_file_or_options: PathOrIO | OcrOptions,
output_file: PathOrIO | None = None,
*,
language: Iterable[str] | None = None,
image_dpi: int | None = None,
output_type: str | None = None,
sidecar: PathOrIO | None = None,
jobs: int | None = None,
use_threads: bool | None = None,
title: str | None = None,
author: str | None = None,
subject: str | None = None,
keywords: str | None = None,
rotate_pages: bool | None = None,
remove_background: bool | None = None,
deskew: bool | None = None,
clean: bool | None = None,
clean_final: bool | None = None,
unpaper_args: str | None = None,
oversample: int | None = None,
remove_vectors: bool | None = None,
mode: str | None = None,
force_ocr: bool | None = None, # Legacy, use mode='force' instead
skip_text: bool | None = None, # Legacy, use mode='skip' instead
redo_ocr: bool | None = None, # Legacy, use mode='redo' instead
skip_big: float | None = None,
optimize: int | None = None,
jpg_quality: int | None = None,
png_quality: int | None = None,
jbig2_lossy: bool | None = None, # Deprecated, ignored
jbig2_page_group_size: int | None = None, # Deprecated, ignored
jbig2_threshold: float | None = None,
pages: str | None = None,
max_image_mpixels: float | None = None,
tesseract_config: Iterable[str] | None = None,
tesseract_pagesegmode: int | None = None,
tesseract_oem: int | None = None,
tesseract_thresholding: int | None = None,
pdf_renderer: str | None = None,
rasterizer: str | None = None,
tesseract_timeout: float | None = None,
tesseract_non_ocr_timeout: float | None = None,
tesseract_downsample_above: int | None = None,
tesseract_downsample_large_images: bool | None = None,
rotate_pages_threshold: float | None = None,
pdfa_image_compression: str | None = None,
color_conversion_strategy: str | None = None,
user_words: os.PathLike | None = None,
user_patterns: os.PathLike | None = None,
fast_web_view: float | None = None,
continue_on_soft_render_error: bool | None = None,
invalidate_digital_signatures: bool | None = None,
plugins: Iterable[Path | str] | None = None,
plugin_manager: OcrmypdfPluginManager | None = None,
keep_temporary_files: bool | None = None,
progress_bar: bool | None = None,
**kwargs,
) -> ExitCode:
"""Run OCRmyPDF on one PDF or image.
This function supports two calling conventions:
**New style (recommended):**
>>> from ocrmypdf import ocr
>>> from ocrmypdf._options import OcrOptions
>>> options = OcrOptions(
... input_file="input.pdf",
... output_file="output.pdf",
... languages=["eng"],
... )
>>> ocr(options)
**Old style:**
>>> ocr("input.pdf", "output.pdf", language=["eng"])
For most arguments, see documentation for the equivalent command line parameter.
This API takes a threading lock, because OCRmyPDF uses global state in particular
@@ -337,24 +508,33 @@ def ocr( # noqa: D417
A few specific arguments are discussed here:
Args:
input_file_or_options: Either an OcrOptions object containing all settings,
or a path/stream for the input file (old-style API).
output_file: Output file path or stream. Required when using old-style API
with input_file as first argument. Must be None when passing OcrOptions.
use_threads: Use worker threads instead of processes. This reduces
performance but may make debugging easier since it is easier to set
breakpoints.
input_file: If a :class:`pathlib.Path`, ``str`` or ``bytes``, this is
interpreted as file system path to the input file. If the object
appears to be a readable stream (with methods such as ``.read()``
and ``.seek()``), the object will be read in its entirety and saved to
a temporary file. If ``input_file`` is ``"-"``, standard input will be
read.
output_file: If a :class:`pathlib.Path`, ``str`` or ``bytes``, this is
interpreted as file system path to the output file. If the object
appears to be a writable stream (with methods such as ``.write()`` and
``.seek()``), the output will be written to this stream. If
``output_file`` is ``"-"``, the output will be written to ``sys.stdout``
(provided that standard output does not seem to be a terminal device).
When a stream is used as output, whether via a writable object or
``"-"``, some final validation steps are not performed (we do not read
back the stream after it is written).
plugins: List of plugin paths to load. Can be passed alongside OcrOptions.
plugin_manager: Pre-configured plugin manager. Can be passed alongside
OcrOptions.
For input_file (old-style API): If a :class:`pathlib.Path`, ``str`` or
``bytes``, this is interpreted as file system path to the input file.
If the object appears to be a readable stream (with methods such as
``.read()`` and ``.seek()``), the object will be read in its entirety
and saved to a temporary file. If ``input_file`` is ``"-"``, standard
input will be read.
For output_file (old-style API): If a :class:`pathlib.Path`, ``str`` or
``bytes``, this is interpreted as file system path to the output file.
If the object appears to be a writable stream (with methods such as
``.write()`` and ``.seek()``), the output will be written to this
stream. If ``output_file`` is ``"-"``, the output will be written to
``sys.stdout`` (provided that standard output does not seem to be a
terminal device). When a stream is used as output, whether via a
writable object or ``"-"``, some final validation steps are not
performed (we do not read back the stream after it is written).
Raises:
ocrmypdf.MissingDependencyError: If a required dependency program is missing or
@@ -373,45 +553,119 @@ def ocr( # noqa: D417
OCRmyPDF does not remove passwords.
ocrmypdf.TesseractConfigError: If Tesseract reported its configuration was not
valid.
ValueError: If OcrOptions is passed along with other OCR parameters, or if
both plugins and plugin_manager are provided.
TypeError: If output_file is missing when using the old-style API.
Returns:
:class:`ocrmypdf.ExitCode`
"""
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
# Detect calling convention: OcrOptions object vs individual parameters
if isinstance(input_file_or_options, OcrOptions):
# New-style API: OcrOptions passed directly
options = input_file_or_options
# Check for conflicting parameters
# (all should be None except plugins/plugin_manager)
_check_no_conflicting_ocr_params(locals(), kwargs)
# plugins and plugin_manager can still be passed alongside OcrOptions
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
# Use plugins from OcrOptions if not explicitly passed
if plugins is None:
plugins = options.plugins or []
if isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins) if plugins else []
# Run the pipeline with the OcrOptions
with _api_lock:
plugin_manager = setup_plugin_infrastructure(
plugins=plugins, plugin_manager=plugin_manager
)
parser = get_parser()
plugin_manager.add_options(parser=parser)
check_options(options, plugin_manager)
return run_pipeline(options=options, plugin_manager=plugin_manager)
if not plugins:
plugins = []
elif isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins)
# Old-style API: positional arguments
input_file = input_file_or_options
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {
k: v
for k, v in locals().items()
if k not in {'input_file', 'output_file', 'kwargs', 'plugin_manager'}
}
create_options_kwargs.update(kwargs)
if output_file is None:
raise TypeError(
"ocr() missing required argument: 'output_file'. "
"Either pass output_file as the second argument, or pass "
"an OcrOptions object as the first argument."
)
parser = get_parser()
with _api_lock:
if not plugin_manager:
plugin_manager = get_plugin_manager(plugins)
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
if 'verbose' in kwargs:
warn("ocrmypdf.ocr(verbose=) is ignored. Use ocrmypdf.configure_logging().")
if not plugins:
plugins = []
elif isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins)
options = create_options(
input_file=input_file,
output_file=output_file,
parser=parser,
**create_options_kwargs,
)
check_options(options, plugin_manager)
return run_pipeline(options=options, plugin_manager=plugin_manager)
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {
k: v
for k, v in locals().items()
if k
not in {
'input_file_or_options',
'input_file',
'output_file',
'kwargs',
'plugin_manager',
}
}
create_options_kwargs.update(kwargs)
parser = get_parser()
with _api_lock:
# Set up plugin infrastructure with proper initialization
plugin_manager = setup_plugin_infrastructure(
plugins=plugins, plugin_manager=plugin_manager
)
# Get parser and let plugins add their options
parser = get_parser()
plugin_manager.add_options(parser=parser)
if 'verbose' in kwargs:
warn(
"ocrmypdf.ocr(verbose=) is ignored. "
"Use ocrmypdf.configure_logging()."
)
# Warn about deprecated jbig2 options and remove from kwargs
if jbig2_lossy:
warn(
"jbig2_lossy is deprecated and will be ignored. "
"Lossy JBIG2 has been removed due to character substitution risks."
)
create_options_kwargs.pop('jbig2_lossy', None)
if jbig2_page_group_size:
warn("jbig2_page_group_size is deprecated and will be ignored.")
create_options_kwargs.pop('jbig2_page_group_size', None)
options = create_options(
input_file=input_file,
output_file=output_file,
parser=parser,
**create_options_kwargs,
)
check_options(options, plugin_manager)
return run_pipeline(options=options, plugin_manager=plugin_manager)
def _pdf_to_hocr( # noqa: D417
@@ -434,9 +688,10 @@ def _pdf_to_hocr( # noqa: D417
unpaper_args: str | None = None,
oversample: int | None = None,
remove_vectors: bool | None = None,
force_ocr: bool | None = None,
skip_text: bool | None = None,
redo_ocr: bool | None = None,
mode: str | None = None,
force_ocr: bool | None = None, # Legacy, use mode='force' instead
skip_text: bool | None = None, # Legacy, use mode='skip' instead
redo_ocr: bool | None = None, # Legacy, use mode='redo' instead
skip_big: float | None = None,
pages: str | None = None,
max_image_mpixels: float | None = None,
@@ -449,6 +704,7 @@ def _pdf_to_hocr( # noqa: D417
tesseract_downsample_above: int | None = None,
tesseract_downsample_large_images: bool | None = None,
rotate_pages_threshold: float | None = None,
rasterizer: str | None = None,
user_words: os.PathLike | None = None,
user_patterns: os.PathLike | None = None,
continue_on_soft_render_error: bool | None = None,
@@ -478,33 +734,72 @@ def _pdf_to_hocr( # noqa: D417
output_folder: Output folder path.
**kwargs: Keyword arguments.
"""
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {
k: v
for k, v in locals().items()
if k not in {'input_pdf', 'output_folder', 'kwargs'}
}
create_options_kwargs.update(kwargs)
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
parser = get_parser()
if not plugins:
plugins = []
elif isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins)
# Prepare kwargs for direct OcrOptions construction
options_kwargs = kwargs.copy()
# Set input file and handle special output_folder case
options_kwargs['input_file'] = input_pdf
options_kwargs['output_file'] = '/dev/null' # Placeholder for hOCR pipeline
# Add all the function parameters
for param_name, param_value in locals().items():
if (
param_name
not in {'input_pdf', 'output_folder', 'kwargs', 'plugin_manager', 'plugins'}
and param_value is not None
):
options_kwargs[param_name] = param_value
# Handle plugins
if plugins:
options_kwargs['plugins'] = plugins
# Remove None values to let OcrOptions use its defaults
options_kwargs = {k: v for k, v in options_kwargs.items() if v is not None}
# Add output_folder to options_kwargs since it's now a proper field
options_kwargs['output_folder'] = output_folder
# Remove any kwargs that aren't OcrOptions fields and store in extra_attrs
extra_attrs = {}
ocr_fields = set(OcrOptions.model_fields.keys())
# Legacy mode flags are handled by OcrOptions model validator
legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'}
known_extra = {'progress_bar', 'plugins'}
for key in list(options_kwargs.keys()):
if key in ocr_fields or key in legacy_mode_flags or key in known_extra:
continue
extra_attrs[key] = options_kwargs.pop(key)
with _api_lock:
if not plugin_manager:
plugin_manager = get_plugin_manager(plugins)
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
cmdline, deferred = _kwargs_to_cmdline(
defer_kwargs={'input_pdf', 'output_folder', 'plugins'},
**create_options_kwargs,
# Set up plugin infrastructure with proper initialization
plugin_manager = setup_plugin_infrastructure(
plugins=plugins, plugin_manager=plugin_manager
)
cmdline.append(str(input_pdf))
cmdline.append(str(output_folder))
parser.enable_api_mode()
options = parser.parse_args(cmdline)
for keyword, val in deferred.items():
setattr(options, keyword, val)
delattr(options, 'output_file')
setattr(options, 'output_folder', output_folder)
plugin_manager.add_options(parser=get_parser())
# Create OcrOptions directly
try:
options = OcrOptions(**options_kwargs)
# Add any extra attributes
if extra_attrs:
options.extra_attrs.update(extra_attrs)
except Exception as e:
raise TypeError(
f"Failed to create OcrOptions for hOCR pipeline: {e}"
) from e
return run_hocr_pipeline(options=options, plugin_manager=plugin_manager)
@@ -518,8 +813,8 @@ def _hocr_to_ocr_pdf( # noqa: D417
optimize: int | None = None,
jpg_quality: int | None = None,
png_quality: int | None = None,
jbig2_lossy: bool | None = None,
jbig2_page_group_size: int | None = None,
jbig2_lossy: bool | None = None, # Deprecated, ignored
jbig2_page_group_size: int | None = None, # Deprecated, ignored
jbig2_threshold: float | None = None,
pdfa_image_compression: str | None = None,
color_conversion_strategy: str | None = None,
@@ -544,33 +839,83 @@ def _hocr_to_ocr_pdf( # noqa: D417
output_file: Output PDF file path.
**kwargs: Keyword arguments.
"""
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {
k: v
for k, v in locals().items()
if k not in {'work_folder', 'output_pdf', 'kwargs'}
}
create_options_kwargs.update(kwargs)
if plugins and plugin_manager:
raise ValueError("plugins= and plugin_manager are mutually exclusive")
parser = get_parser()
if not plugins:
plugins = []
elif isinstance(plugins, str | Path):
plugins = [plugins]
else:
plugins = list(plugins)
# Prepare kwargs for direct OcrOptions construction
options_kwargs = kwargs.copy()
# Set output file and handle special work_folder case
options_kwargs['input_file'] = '/dev/null' # Placeholder for hOCR to PDF pipeline
options_kwargs['output_file'] = output_file
# Add all the function parameters
for param_name, param_value in locals().items():
if (
param_name
not in {'work_folder', 'output_file', 'kwargs', 'plugin_manager', 'plugins'}
and param_value is not None
):
options_kwargs[param_name] = param_value
# Handle plugins
if plugins:
options_kwargs['plugins'] = plugins
# Remove None values to let OcrOptions use its defaults
options_kwargs = {k: v for k, v in options_kwargs.items() if v is not None}
# Warn about deprecated jbig2 options and remove from kwargs
if jbig2_lossy:
warn(
"jbig2_lossy is deprecated and will be ignored. "
"Lossy JBIG2 has been removed due to character substitution risks."
)
options_kwargs.pop('jbig2_lossy', None)
if jbig2_page_group_size:
warn("jbig2_page_group_size is deprecated and will be ignored.")
options_kwargs.pop('jbig2_page_group_size', None)
# Add work_folder to options_kwargs since it's now a proper field
options_kwargs['work_folder'] = work_folder
# Remove any kwargs that aren't OcrOptions fields and store in extra_attrs
extra_attrs = {}
ocr_fields = set(OcrOptions.model_fields.keys())
# Legacy mode flags are handled by OcrOptions model validator
legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'}
known_extra = {'progress_bar', 'plugins'}
for key in list(options_kwargs.keys()):
if key in ocr_fields or key in legacy_mode_flags or key in known_extra:
continue
extra_attrs[key] = options_kwargs.pop(key)
with _api_lock:
if not plugin_manager:
plugin_manager = get_plugin_manager(plugins)
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
cmdline, deferred = _kwargs_to_cmdline(
defer_kwargs={'work_folder', 'output_file', 'plugins'},
**create_options_kwargs,
# Set up plugin infrastructure with proper initialization
plugin_manager = setup_plugin_infrastructure(
plugins=plugins, plugin_manager=plugin_manager
)
cmdline.append(str(work_folder))
cmdline.append(str(output_file))
parser.enable_api_mode()
options = parser.parse_args(cmdline)
for keyword, val in deferred.items():
setattr(options, keyword, val)
delattr(options, 'input_file')
setattr(options, 'work_folder', work_folder)
plugin_manager.add_options(parser=get_parser())
# Create OcrOptions directly
try:
options = OcrOptions(**options_kwargs)
# Add any extra attributes
if extra_attrs:
options.extra_attrs.update(extra_attrs)
except Exception as e:
raise TypeError(
f"Failed to create OcrOptions for hOCR to PDF pipeline: {e}"
) from e
return run_hocr_to_ocr_pdf_pipeline(
options=options, plugin_manager=plugin_manager
@@ -588,4 +933,5 @@ __all__ = [
'ocr',
'run_pipeline',
'run_pipeline_cli',
'setup_plugin_infrastructure',
]
+10 -7
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import logging
import logging.handlers
import multiprocessing
import multiprocessing.queues
import os
import queue
import signal
@@ -15,7 +16,7 @@ import threading
from collections.abc import Callable, Iterable
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from contextlib import suppress
from typing import Union
from typing import TYPE_CHECKING
from rich.console import Console as RichConsole
@@ -25,12 +26,14 @@ from ocrmypdf._progressbar import RichProgressBar
from ocrmypdf.exceptions import InputFileError
from ocrmypdf.helpers import remove_all_log_handlers
FuturesExecutorClass = Union[ # noqa: UP007
type[ThreadPoolExecutor], type[ProcessPoolExecutor]
]
Queue = Union[multiprocessing.Queue, queue.Queue] # noqa: UP007
UserInit = Callable[[], None]
WorkerInit = Callable[[Queue, UserInit, int], None]
if TYPE_CHECKING:
from typing import TypeAlias
Queue: TypeAlias = multiprocessing.queues.Queue | queue.Queue
UserInit: TypeAlias = Callable[[], None]
WorkerInit: TypeAlias = Callable[[Queue, UserInit, int], None]
FuturesExecutorClass = type[ThreadPoolExecutor] | type[ProcessPoolExecutor]
def log_listener(q: Queue):
+133 -61
View File
@@ -5,13 +5,17 @@
from __future__ import annotations
import logging
from enum import StrEnum
from pathlib import Path
from typing import Annotated
from packaging.version import Version
from pikepdf import Name, Pdf, Stream
from pydantic import BaseModel, Field
from ocrmypdf import hookimpl
from ocrmypdf._exec import ghostscript
from ocrmypdf._options import ProcessingMode
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import check_external_program
@@ -22,79 +26,134 @@ log = logging.getLogger(__name__)
BLACKLISTED_GS_VERSIONS: frozenset[Version] = frozenset()
class ColorConversionStrategy(StrEnum):
"""Ghostscript color conversion strategies."""
CMYK = 'CMYK'
GRAY = 'Gray'
LEAVE_COLOR_UNCHANGED = 'LeaveColorUnchanged'
RGB = 'RGB'
USE_DEVICE_INDEPENDENT_COLOR = 'UseDeviceIndependentColor'
class PdfaImageCompression(StrEnum):
"""PDF/A image compression methods."""
AUTO = 'auto'
JPEG = 'jpeg'
LOSSLESS = 'lossless'
class GhostscriptOptions(BaseModel):
"""Options specific to Ghostscript operations."""
color_conversion_strategy: Annotated[
ColorConversionStrategy,
Field(description="Ghostscript color conversion strategy"),
] = ColorConversionStrategy.LEAVE_COLOR_UNCHANGED
pdfa_image_compression: Annotated[
PdfaImageCompression, Field(description="PDF/A image compression method")
] = PdfaImageCompression.AUTO
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'ghostscript'):
"""Add Ghostscript-specific arguments to the argument parser.
Args:
parser: The argument parser to add arguments to
namespace: The namespace prefix for argument names (not used for ghostscript
for backward compatibility)
"""
gs = parser.add_argument_group("Ghostscript", "Advanced control of Ghostscript")
gs.add_argument(
'--color-conversion-strategy',
action='store',
type=str,
choices=[ccs.value for ccs in ColorConversionStrategy],
default=ColorConversionStrategy.LEAVE_COLOR_UNCHANGED.value,
help="Set Ghostscript color conversion strategy",
)
gs.add_argument(
'--pdfa-image-compression',
choices=[pc.value for pc in PdfaImageCompression],
default=PdfaImageCompression.AUTO.value,
help="Specify how to compress images in the output PDF/A. 'auto' lets "
"OCRmyPDF decide. 'jpeg' changes all grayscale and color images to "
"JPEG compression. 'lossless' uses PNG-style lossless compression "
"for all images. Monochrome images are always compressed using a "
"lossless codec. Compression settings "
"are applied to all pages, including those for which OCR was "
"skipped. Not supported for --output-type=pdf ; that setting "
"preserves the original compression of all images.",
)
@hookimpl
def register_options():
"""Register Ghostscript option model."""
return {'ghostscript': GhostscriptOptions}
@hookimpl
def add_options(parser):
gs = parser.add_argument_group("Ghostscript", "Advanced control of Ghostscript")
gs.add_argument(
'--color-conversion-strategy',
action='store',
type=str,
metavar='STRATEGY',
choices=ghostscript.COLOR_CONVERSION_STRATEGIES,
default='LeaveColorUnchanged',
help="Set Ghostscript color conversion strategy",
)
gs.add_argument(
'--pdfa-image-compression',
choices=['auto', 'jpeg', 'lossless'],
default='auto',
help="Specify how to compress images in the output PDF/A. 'auto' lets "
"OCRmyPDF decide. 'jpeg' changes all grayscale and color images to "
"JPEG compression. 'lossless' uses PNG-style lossless compression "
"for all images. Monochrome images are always compressed using a "
"lossless codec. Compression settings "
"are applied to all pages, including those for which OCR was "
"skipped. Not supported for --output-type=pdf ; that setting "
"preserves the original compression of all images.",
)
# Use the model's CLI generation method
GhostscriptOptions.add_arguments_to_parser(parser)
@hookimpl
def check_options(options):
"""Check that the options are valid for this plugin."""
check_external_program(
program='gs',
package='ghostscript',
version_checker=ghostscript.version,
need_version='9.54', # RHEL 9's version; Ubuntu 22.04 has 9.55
)
gs_version = ghostscript.version()
if gs_version in BLACKLISTED_GS_VERSIONS:
raise MissingDependencyError(
f"Ghostscript {gs_version} contains serious regressions and is not "
"supported. Please upgrade to a newer version."
)
if Version('10.0.0') <= gs_version < Version('10.02.1') and (
options.skip_text or options.redo_ocr
):
raise MissingDependencyError(
f"Ghostscript 10.0.0 through 10.02.0 (your version: {gs_version}) "
"contain serious regressions that corrupt PDFs with existing text, "
"such as those processed using --skip-text or --redo-ocr. "
"Please upgrade to a "
"newer version, or use --output-type pdf to avoid Ghostscript, or "
"use --force-ocr to discard existing text."
# Only require Ghostscript for pdfa* output types (not 'auto' or 'pdf')
# 'auto' mode uses best-effort PDF/A without Ghostscript fallback
if options.output_type.startswith('pdfa'):
check_external_program(
program='gs',
package='ghostscript',
version_checker=ghostscript.version,
need_version='9.54', # RHEL 9's version; Ubuntu 22.04 has 9.55
)
gs_version = ghostscript.version()
if gs_version in BLACKLISTED_GS_VERSIONS:
raise MissingDependencyError(
f"Ghostscript {gs_version} contains serious regressions and is not "
"supported. Please upgrade to a newer version."
)
if Version('10.0.0') <= gs_version < Version('10.02.1') and (
options.mode in (ProcessingMode.skip, ProcessingMode.redo)
):
raise MissingDependencyError(
f"Ghostscript 10.0.0 through 10.02.0 (your version: {gs_version}) "
"contain serious regressions that corrupt PDFs with existing text, "
"such as those processed using --skip-text or --redo-ocr "
"(or --mode skip/redo). Please upgrade to a newer version, or use "
"--output-type pdf to avoid Ghostscript, or use --force-ocr "
"(or --mode force) to discard existing text."
)
if gs_version >= Version('10.6.0'):
log.warning(
"Ghostscript 10.6.x contains JPEG encoding errors that may corrupt "
"images. OCRmyPDF will attempt to mitigate, but this version is "
"strongly not recommended. Please upgrade to a newer version. "
"As of 2025-12, 10.6.0 is the latest version of Ghostscript."
)
if options.output_type == 'pdfa':
options.output_type = 'pdfa-2'
if gs_version >= Version('10.6.0') and options.output_type.startswith('pdfa'):
log.warning(
"Ghostscript 10.6.x contains JPEG encoding errors that may corrupt "
"images. OCRmyPDF will attempt to mitigate, but this version is "
"strongly not recommended. Please upgrade to a newer version. "
"As of 2025-12, 10.6.0 is the latest version of Ghostscript."
)
if options.output_type == 'pdfa':
options.output_type = 'pdfa-2'
if options.color_conversion_strategy not in ghostscript.COLOR_CONVERSION_STRATEGIES:
if (
options.ghostscript.color_conversion_strategy
not in ghostscript.COLOR_CONVERSION_STRATEGIES
):
raise ValueError(
f"Invalid color conversion strategy: {options.color_conversion_strategy}"
f"Invalid color conversion strategy: "
f"{options.ghostscript.color_conversion_strategy}"
)
if options.pdfa_image_compression != 'auto' and not options.output_type.startswith(
'pdfa'
if (
options.ghostscript.pdfa_image_compression != 'auto'
and options.output_type not in ('auto', 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3')
):
log.warning(
"--pdfa-image-compression argument only applies when "
"--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'"
"--output-type is 'auto' or one of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'"
)
@@ -109,8 +168,15 @@ def rasterize_pdf_page(
rotation,
filter_vector,
stop_on_soft_error,
options,
use_cropbox,
):
"""Rasterize a single page of a PDF file using Ghostscript."""
# Check if user explicitly requested a different rasterizer
if options is not None and options.rasterizer == 'pypdfium':
# Let pypdfium handle it (it will error in check_options if unavailable)
return None
ghostscript.rasterize_pdf(
input_file,
output_file,
@@ -121,6 +187,7 @@ def rasterize_pdf_page(
rotation=rotation,
filter_vector=filter_vector,
stop_on_error=stop_on_soft_error,
use_cropbox=use_cropbox,
)
return output_file
@@ -275,11 +342,16 @@ def generate_pdfa(
stop_on_soft_error,
):
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
# Normalize output_type at point of use
output_type = context.options.output_type
if output_type == 'pdfa':
output_type = 'pdfa-2'
ghostscript.generate_pdfa(
pdf_pages=[pdfmark, *pdf_pages],
output_file=output_file,
compression=context.options.pdfa_image_compression,
color_conversion_strategy=context.options.color_conversion_strategy,
compression=context.options.ghostscript.pdfa_image_compression,
color_conversion_strategy=context.options.ghostscript.color_conversion_strategy,
pdf_version=pdf_version,
pdfa_part=pdfa_part,
progressbar_class=progressbar_class,
+159
View File
@@ -0,0 +1,159 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Built-in plugin implementing a null OCR engine (no OCR).
This plugin provides an OCR engine that produces no text output. It is useful
when users want OCRmyPDF's image processing, PDF/A conversion, or optimization
features without performing actual OCR.
Usage:
ocrmypdf --ocr-engine none input.pdf output.pdf
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from PIL import Image
from ocrmypdf import hookimpl
from ocrmypdf.hocrtransform import BoundingBox, OcrClass, OcrElement
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
if TYPE_CHECKING:
from ocrmypdf._options import OcrOptions
class NullOcrEngine(OcrEngine):
"""A no-op OCR engine that produces no text output.
Use this when you want OCRmyPDF's image processing, PDF/A conversion,
or optimization features without performing actual OCR.
"""
@staticmethod
def version() -> str:
"""Return version string."""
return "none"
@staticmethod
def creator_tag(options: OcrOptions) -> str:
"""Return creator tag for PDF metadata."""
return "OCRmyPDF (no OCR)"
def __str__(self) -> str:
"""Return human-readable engine name."""
return "No OCR engine"
@staticmethod
def languages(options: OcrOptions) -> set[str]:
"""Return supported languages (empty set for null engine)."""
return set()
@staticmethod
def get_orientation(input_file: Path, options: OcrOptions) -> OrientationConfidence:
"""Return neutral orientation (no rotation detected)."""
return OrientationConfidence(angle=0, confidence=0.0)
@staticmethod
def get_deskew(input_file: Path, options: OcrOptions) -> float:
"""Return zero deskew angle."""
return 0.0
@staticmethod
def supports_generate_ocr() -> bool:
"""Return True - this engine supports the generate_ocr() API."""
return True
@staticmethod
def generate_ocr(
input_file: Path,
options: OcrOptions,
page_number: int = 0,
) -> tuple[OcrElement, str]:
"""Generate empty OCR results.
Args:
input_file: The image file (used to get dimensions).
options: OCR options (ignored).
page_number: Page number (stored in result).
Returns:
A tuple of (empty OcrElement page, empty string).
"""
# Get image dimensions
with Image.open(input_file) as img:
width, height = img.size
dpi_info = img.info.get('dpi', (72, 72))
dpi = dpi_info[0] if isinstance(dpi_info, tuple) else dpi_info
# Create empty page element with correct dimensions
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=width, bottom=height),
dpi=float(dpi),
page_number=page_number,
)
return page, ""
@staticmethod
def generate_hocr(
input_file: Path,
output_hocr: Path,
output_text: Path,
options: OcrOptions,
) -> None:
"""Generate empty hOCR file.
Creates minimal valid hOCR output with no text content.
"""
# Get image dimensions for hOCR bbox
with Image.open(input_file) as img:
width, height = img.size
hocr_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>OCRmyPDF - No OCR</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='OCRmyPDF null engine'/>
</head>
<body>
<div class='ocr_page' title='bbox 0 0 {width} {height}'>
</div>
</body>
</html>
'''
output_hocr.write_text(hocr_content, encoding='utf-8')
output_text.write_text('', encoding='utf-8')
@staticmethod
def generate_pdf(
input_file: Path,
output_pdf: Path,
output_text: Path,
options: OcrOptions,
) -> None:
"""NullOcrEngine cannot generate PDFs directly.
Use pdf_renderer='fpdf2' instead of 'sandwich'.
"""
raise NotImplementedError(
"NullOcrEngine cannot generate PDFs directly. "
"Use --pdf-renderer fpdf2 instead of sandwich mode."
)
@hookimpl
def get_ocr_engine(options):
"""Return NullOcrEngine when --ocr-engine none is selected."""
if options is not None:
ocr_engine = getattr(options, 'ocr_engine', 'auto')
if ocr_engine != 'none':
return None
return NullOcrEngine()
+158 -85
View File
@@ -8,6 +8,9 @@ import argparse
import logging
from collections.abc import Sequence
from pathlib import Path
from typing import Annotated
from pydantic import BaseModel, Field, model_validator
from ocrmypdf import Executor, PdfContext, hookimpl
from ocrmypdf._exec import jbig2enc, pngquant
@@ -19,87 +22,165 @@ from ocrmypdf.subprocess import check_external_program
log = logging.getLogger(__name__)
class OptimizeOptions(BaseModel):
"""Options specific to PDF optimization."""
level: Annotated[
int,
Field(
ge=0,
le=3,
description="Optimization level (0=none, 1=safe, 2=lossy, 3=aggressive)",
),
] = 1
jpeg_quality: Annotated[
int, Field(ge=0, le=100, description="JPEG quality level for optimization")
] = 0
png_quality: Annotated[
int, Field(ge=0, le=100, description="PNG quality level for optimization")
] = 0
jbig2_threshold: Annotated[
float,
Field(ge=0.4, le=0.9, description="JBIG2 symbol classification threshold"),
] = 0.85
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'optimize'):
"""Add optimization-specific arguments to the argument parser.
Args:
parser: The argument parser to add arguments to
namespace: The namespace prefix for argument names
(not used for optimize for backward compatibility)
"""
optimizing = parser.add_argument_group(
"Optimization options", "Control how the PDF is optimized after OCR"
)
optimizing.add_argument(
'-O',
'--optimize',
type=int,
choices=range(0, 4),
default=1,
help=(
"Control how PDF is optimized after processing:"
"0 - do not optimize; "
"1 - do safe, lossless optimizations (default); "
"2 - do lossy JPEG and JPEG2000 optimizations; "
"3 - do more aggressive lossy JPEG and JPEG2000 optimizations. "
"To enable lossy JBIG2, see --jbig2-lossy."
),
)
optimizing.add_argument(
'--jpeg-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
help=(
"Adjust JPEG quality level for JPEG optimization. "
"100 is best quality and largest output size; "
"1 is lowest quality and smallest output; "
"0 uses the default."
),
)
optimizing.add_argument(
'--jpg-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
dest='jpeg_quality',
help=argparse.SUPPRESS, # Alias for --jpeg-quality
)
optimizing.add_argument(
'--png-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
help=(
"Adjust PNG quality level to use when quantizing PNGs. "
"Values have same meaning as with --jpeg-quality"
),
)
# Deprecated arguments - kept for backward compatibility, emit warnings
optimizing.add_argument(
'--jbig2-lossy',
action='store_true',
help=argparse.SUPPRESS, # Deprecated, hidden from help
)
optimizing.add_argument(
'--jbig2-page-group-size',
type=numeric(int, 1, 10000),
default=0,
metavar='N',
help=argparse.SUPPRESS, # Deprecated, hidden from help
)
optimizing.add_argument(
'--jbig2-threshold',
type=numeric(float, 0.4, 0.9),
default=0.85,
metavar='T',
help=(
"Adjust JBIG2 symbol code classification threshold "
"(default 0.85), range 0.4 to 0.9."
),
)
@model_validator(mode='after')
def validate_optimization_consistency(self):
"""Validate optimization options are consistent."""
if self.level == 0 and any([self.png_quality > 0, self.jpeg_quality > 0]):
log.warning(
"The arguments --png-quality and --jpeg-quality "
"will be ignored because --optimize=0."
)
return self
def validate_with_context(
self, external_programs_available: dict[str, bool]
) -> None:
"""Validate options that require external context.
Args:
external_programs_available: Dict of program name -> availability
"""
if self.level >= 2:
if not external_programs_available.get('pngquant', False):
log.warning(
"pngquant is not available, so PNG optimization will be limited"
)
if not external_programs_available.get('jbig2enc', False):
log.warning(
"jbig2enc is not available, so JBIG2 optimization will be limited"
)
@hookimpl
def register_options():
"""Register optimization option model."""
return {'optimize': OptimizeOptions}
@hookimpl
def add_options(parser):
optimizing = parser.add_argument_group(
"Optimization options", "Control how the PDF is optimized after OCR"
)
optimizing.add_argument(
'-O',
'--optimize',
type=int,
choices=range(0, 4),
default=1,
help=(
"Control how PDF is optimized after processing:"
"0 - do not optimize; "
"1 - do safe, lossless optimizations (default); "
"2 - do lossy JPEG and JPEG2000 optimizations; "
"3 - do more aggressive lossy JPEG and JPEG2000 optimizations. "
"To enable lossy JBIG2, see --jbig2-lossy."
),
)
optimizing.add_argument(
'--jpeg-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
help=(
"Adjust JPEG quality level for JPEG optimization. "
"100 is best quality and largest output size; "
"1 is lowest quality and smallest output; "
"0 uses the default."
),
)
optimizing.add_argument(
'--jpg-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
dest='jpeg_quality',
help=argparse.SUPPRESS, # Alias for --jpeg-quality
)
optimizing.add_argument(
'--png-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
help=(
"Adjust PNG quality level to use when quantizing PNGs. "
"Values have same meaning as with --jpeg-quality"
),
)
optimizing.add_argument(
'--jbig2-lossy',
action='store_true',
help=(
"Enable JBIG2 lossy mode (better compression, not suitable for some "
"use cases - see documentation). Only takes effect if --optimize 1 or "
"higher is also enabled."
),
)
optimizing.add_argument(
'--jbig2-page-group-size',
type=numeric(int, 1, 10000),
default=0,
metavar='N',
# Adjust number of pages to consider at once for JBIG2 compression
help=argparse.SUPPRESS,
)
optimizing.add_argument(
'--jbig2-threshold',
type=numeric(float, 0.4, 0.9),
default=0.85,
metavar='T',
help=(
"Adjust JBIG2 symbol code classification threshold "
"(default 0.85), range 0.4 to 0.9."
),
)
# Use the model's CLI generation method
OptimizeOptions.add_arguments_to_parser(parser)
@hookimpl
def check_options(options):
"""Check external dependencies for optimization."""
# Warn about deprecated options
if getattr(options, 'jbig2_lossy', False):
log.warning(
"The --jbig2-lossy option is deprecated and will be ignored. "
"Lossy JBIG2 compression has been removed due to risks of "
"character substitution errors."
)
if getattr(options, 'jbig2_page_group_size', 0) not in (0, None):
log.warning(
"The --jbig2-page-group-size option is deprecated and will be ignored."
)
if options.optimize >= 2:
check_external_program(
program='pngquant',
@@ -117,16 +198,8 @@ def check_options(options):
package='jbig2enc',
version_checker=jbig2enc.version,
need_version='0.28',
required_for='--optimize {2,3} | --jbig2-lossy',
recommended=True if not options.jbig2_lossy else False,
)
if options.optimize == 0 and any(
[options.jbig2_lossy, options.png_quality, options.jpeg_quality]
):
log.warning(
"The arguments --jbig2-lossy, --png-quality, and --jpeg-quality "
"will be ignored because --optimize=0."
required_for='--optimize {2,3}',
recommended=True,
)
+256
View File
@@ -0,0 +1,256 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Built-in plugin to implement PDF page rasterization using pypdfium2."""
from __future__ import annotations
import logging
import threading
from contextlib import closing
from pathlib import Path
try:
import pypdfium2 as pdfium
except ImportError:
pdfium = None
from PIL import Image
from ocrmypdf import hookimpl
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.helpers import Resolution
log = logging.getLogger(__name__)
# pypdfium2/PDFium is not thread-safe. All calls to the library must be serialized.
# See: https://pypdfium2.readthedocs.io/en/stable/python_api.html#incompatibility-with-threading
# When using process-based parallelism (use_threads=False), each process has its own
# pdfium instance, so locking is not needed across processes.
_pdfium_lock = threading.Lock()
@hookimpl
def check_options(options):
"""Check that pypdfium2 is available if explicitly requested."""
if options.rasterizer == 'pypdfium' and pdfium is None:
raise MissingDependencyError(
"The --rasterizer pypdfium option requires the pypdfium2 package. "
"Install it with: pip install pypdfium2"
)
def _open_pdf_document(input_file: Path):
"""Open a PDF document using pypdfium2."""
assert pdfium is not None, "pypdfium2 must be available to call this function"
return pdfium.PdfDocument(input_file)
def _calculate_mediabox_crop(page) -> tuple[float, float, float, float]:
"""Calculate crop values to expand rendering from CropBox to MediaBox.
By default pypdfium2 renders to the CropBox. To render the full MediaBox,
we need negative crop values to expand the rendering area.
Returns:
Tuple of (left, bottom, right, top) crop values. Negative values
expand the rendering area beyond the CropBox to the MediaBox.
"""
mediabox = page.get_mediabox() # (left, bottom, right, top)
cropbox = page.get_cropbox() # (left, bottom, right, top), defaults to mediabox
# Calculate how much to expand from cropbox to mediabox
# Negative values = expand, positive = shrink
return (
mediabox[0] - cropbox[0], # Expand left
mediabox[1] - cropbox[1], # Expand bottom
cropbox[2] - mediabox[2], # Expand right
cropbox[3] - mediabox[3], # Expand top
)
def _render_page_to_bitmap(
page,
raster_device: str,
raster_dpi: Resolution,
rotation: int | None,
use_cropbox: bool,
):
"""Render a PDF page to a bitmap."""
# Round DPI to match Ghostscript's precision
raster_dpi = raster_dpi.round(6)
# Get page dimensions BEFORE applying rotation
page_width_pts, page_height_pts = page.get_size()
# Calculate expected output dimensions using separate x/y DPI
expected_width = int(round(page_width_pts * raster_dpi.x / 72.0))
expected_height = int(round(page_height_pts * raster_dpi.y / 72.0))
# Calculate the scale factor based on DPI
# pypdfium2 uses points (72 DPI) as base unit
scale = raster_dpi.to_scalar() / 72.0
# Apply rotation if specified
if rotation:
# pypdfium2 rotation is in degrees, same as our input
# we track rotation in CCW, and pypdfium2 expects CW, so negate
page.set_rotation(-rotation % 360)
# When rotation is 90 or 270, dimensions are swapped in output
if rotation % 180 == 90:
expected_width, expected_height = expected_height, expected_width
# Render the page to a bitmap
# The scale parameter controls the resolution
grayscale = raster_device.lower() in ('pnggray', 'jpeggray')
# Calculate crop to render the appropriate box
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
crop = (0, 0, 0, 0) if use_cropbox else _calculate_mediabox_crop(page)
bitmap = page.render(
scale=scale,
rotation=0, # We already set rotation on the page
crop=crop,
may_draw_forms=True,
draw_annots=True,
grayscale=grayscale,
# Note: pypdfium2 doesn't have a direct equivalent to filter_vector
# This would require more complex implementation if needed
)
return bitmap, expected_width, expected_height
def _process_image_for_output(
pil_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,
):
"""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:
actual_width, actual_height = pil_image.width, pil_image.height
width_diff = abs(actual_width - expected_width)
height_diff = abs(actual_height - expected_height)
# Only resize if off by small amount (1-2 pixels)
if (width_diff <= 2 or height_diff <= 2) and (
width_diff > 0 or height_diff > 0
):
log.debug(
f"Adjusting rendered dimensions from "
f"{actual_width}x{actual_height} to expected "
f"{expected_width}x{expected_height}"
)
pil_image = pil_image.resize(
(expected_width, expected_height),
Image.Resampling.LANCZOS
)
# Set the DPI metadata if page_dpi is specified
if page_dpi:
# PIL expects DPI as a tuple
dpi_tuple = (float(page_dpi.x), float(page_dpi.y))
pil_image.info['dpi'] = dpi_tuple
else:
# Use the raster DPI
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
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
pil_image = background
elif raster_device.lower() in ('tiff', 'tif'):
format_name = 'TIFF'
else:
# Default to PNG for unknown formats
format_name = 'PNG'
if stop_on_soft_error:
raise ValueError(f"Unsupported raster device: {raster_device}")
else:
log.warning(f"Unsupported raster device {raster_device}, using PNG")
return pil_image, format_name
def _save_image(pil_image, output_file: Path, format_name: str):
"""Save PIL image to file with appropriate DPI metadata."""
save_kwargs = {}
if (
format_name in ('PNG', 'TIFF')
and 'dpi' in pil_image.info
or format_name == 'JPEG'
and 'dpi' in pil_image.info
):
save_kwargs['dpi'] = pil_image.info['dpi']
pil_image.save(output_file, format=format_name, **save_kwargs)
@hookimpl
def rasterize_pdf_page(
input_file: Path,
output_file: Path,
raster_device: str,
raster_dpi: Resolution,
pageno: int,
page_dpi: Resolution | None,
rotation: int | None,
filter_vector: bool,
stop_on_soft_error: bool,
options,
use_cropbox: bool,
) -> Path | None:
"""Rasterize a single page of a PDF file using pypdfium2.
Returns None if pypdfium2 is not available or if the user has selected
a different rasterizer, allowing Ghostscript to be used.
"""
# Check if user explicitly requested a different rasterizer
if options is not None and options.rasterizer == 'ghostscript':
return None # Let Ghostscript handle it
if pdfium is None:
return None # Fall back to Ghostscript
# Acquire lock to ensure thread-safe access to pypdfium2
with (
_pdfium_lock,
closing(_open_pdf_document(input_file)) as pdf,
closing(pdf[pageno - 1]) as page,
):
# Render the page to a bitmap
bitmap, expected_width, expected_height = _render_page_to_bitmap(
page, raster_device, raster_dpi, rotation, use_cropbox
)
with closing(bitmap):
# Convert to PIL Image
pil_image = bitmap.to_pil()
# Process and save image outside the lock (PIL operations are thread-safe)
pil_image, format_name = _process_image_for_output(
pil_image,
raster_device,
raster_dpi,
page_dpi,
stop_on_soft_error,
expected_width,
expected_height,
)
_save_image(pil_image, output_file, format_name)
return output_file
+341 -161
View File
@@ -7,15 +7,18 @@ from __future__ import annotations
import argparse
import logging
import os
from typing import Annotated
from PIL import Image
from pydantic import BaseModel, Field, field_validator, model_validator
from ocrmypdf import hookimpl
from ocrmypdf._exec import tesseract
from ocrmypdf._exec.tesseract import ThresholdingMethod
from ocrmypdf._jobcontext import PageContext
from ocrmypdf.cli import numeric, str_to_int
from ocrmypdf.cli import numeric
from ocrmypdf.exceptions import BadArgsError, MissingDependencyError
from ocrmypdf.helpers import clamp
from ocrmypdf.helpers import available_cpu_count, clamp
from ocrmypdf.imageops import calculate_downsample, downsample_image
from ocrmypdf.pluginspec import OcrEngine
from ocrmypdf.subprocess import check_external_program
@@ -23,121 +26,286 @@ from ocrmypdf.subprocess import check_external_program
log = logging.getLogger(__name__)
def _thresholding_method_converter(value: str) -> ThresholdingMethod:
"""Convert string argument to ThresholdingMethod enum.
Args:
value: String name of thresholding method (auto, otsu, adaptive-otsu, sauvola)
Returns:
ThresholdingMethod enum value
Raises:
argparse.ArgumentTypeError: If value is not a valid thresholding method
"""
method_map = {
'auto': ThresholdingMethod.AUTO,
'otsu': ThresholdingMethod.OTSU,
'adaptive-otsu': ThresholdingMethod.ADAPTIVE_OTSU,
'sauvola': ThresholdingMethod.SAUVOLA,
}
if value.lower() not in method_map:
import argparse
valid = ', '.join(method_map.keys())
raise argparse.ArgumentTypeError(
f"Invalid thresholding method '{value}'. Must be one of: {valid}"
)
return method_map[value.lower()]
class TesseractOptions(BaseModel):
"""Options specific to Tesseract OCR engine."""
config: Annotated[
list[str], Field(description="Additional Tesseract configuration files")
] = []
pagesegmode: Annotated[
int | None,
Field(ge=0, le=13, description="Set Tesseract page segmentation mode"),
] = None
oem: Annotated[
int | None, Field(ge=0, le=3, description="Set Tesseract OCR engine mode")
] = None
thresholding: Annotated[
ThresholdingMethod,
Field(description="Set Tesseract input image thresholding mode"),
] = ThresholdingMethod.AUTO
timeout: Annotated[
float, Field(ge=0, description="Timeout for OCR operations in seconds")
] = 180.0
non_ocr_timeout: Annotated[
float, Field(ge=0, description="Timeout for non-OCR operations in seconds")
] = 180.0
downsample_large_images: Annotated[
bool, Field(description="Downsample large images before OCR")
] = True
downsample_above: Annotated[
int,
Field(
ge=100,
le=32767,
description="Downsample images larger than this pixel size",
),
] = 32767
user_words: Annotated[
str | None, Field(description="Path to Tesseract user words file")
] = None
user_patterns: Annotated[
str | None, Field(description="Path to Tesseract user patterns file")
] = None
omp_thread_limit: Annotated[
int | None,
Field(
description="Calculated OMP_THREAD_LIMIT for Tesseract subprocesses",
exclude=True,
),
] = None
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'tesseract'):
"""Add Tesseract-specific arguments to the argument parser.
Args:
parser: The argument parser to add arguments to
namespace: The namespace prefix for argument names
"""
tess = parser.add_argument_group(
"Tesseract", "Advanced control of Tesseract OCR"
)
tess.add_argument(
f'--{namespace}-config',
action='append',
metavar='CFG',
default=[],
dest=f'{namespace}_config',
help="Additional Tesseract configuration files -- see documentation.",
)
tess.add_argument(
f'--{namespace}-pagesegmode',
action='store',
type=int,
metavar='PSM',
choices=range(0, 14),
dest=f'{namespace}_pagesegmode',
help="Set Tesseract page segmentation mode (see tesseract --help).",
)
tess.add_argument(
f'--{namespace}-oem',
action='store',
type=int,
metavar='MODE',
choices=range(0, 4),
dest=f'{namespace}_oem',
help=(
"Set Tesseract 4+ OCR engine mode: "
"0 - original Tesseract only; "
"1 - neural nets LSTM only; "
"2 - Tesseract + LSTM; "
"3 - default."
),
)
tess.add_argument(
f'--{namespace}-thresholding',
action='store',
type=_thresholding_method_converter,
default='auto',
dest=f'{namespace}_thresholding',
help=(
"Set Tesseract 5.0+ input image thresholding mode. This may improve "
"OCR results on low quality images or those that contain high "
"contrast color. Options: auto, otsu, adaptive-otsu, sauvola. "
"auto/otsu is the Tesseract default (legacy Otsu); adaptive-otsu "
"is an improved Otsu algorithm with improved sort for background "
"color changes; sauvola is based on local standard deviation."
),
)
tess.add_argument(
f'--{namespace}-timeout',
default=180.0,
type=numeric(float, 0),
metavar='SECONDS',
dest=f'{namespace}_timeout',
help=(
"Give up on OCR after the timeout, but copy the preprocessed page "
"into the final output. This timeout is only used when using Tesseract "
"for OCR. When Tesseract is used for other operations such as "
"deskewing and orientation, the timeout is controlled by "
f"--{namespace}-non-ocr-timeout."
),
)
tess.add_argument(
f'--{namespace}-non-ocr-timeout',
default=180.0,
type=numeric(float, 0),
metavar='SECONDS',
dest=f'{namespace}_non_ocr_timeout',
help=(
"Give up on non-OCR operations such as deskewing and orientation "
f"after timeout. This is a separate timeout from --{namespace}-timeout "
"because these operations are not as expensive as OCR."
),
)
tess.add_argument(
f'--{namespace}-downsample-large-images',
action=argparse.BooleanOptionalAction,
default=True,
dest=f'{namespace}_downsample_large_images',
help=(
"Downsample large images before OCR. Tesseract has "
"an upper limit on the size images it will support."
" If this argument is given, OCRmyPDF will "
"downsample large images to fit Tesseract. This "
"may reduce OCR quality, on large images the most"
" desirable text is usually larger. If this "
"parameter is not supplied, Tesseract will error "
"out and produce no OCR on the page in question. "
"This argument should be used with a high value "
f"of --{namespace}-timeout to ensure Tesseract "
"has enough to time."
),
)
tess.add_argument(
f'--{namespace}-downsample-above',
action='store',
type=numeric(int, 100, 32767),
default=32767,
dest=f'{namespace}_downsample_above',
help=(
"Downsample images larger than this size pixel size (either dimension) "
f"before OCR. --{namespace}-downsample-large-images downsamples when "
"an image exceeds Tesseract's internal limits. This argument causes "
"downsampling to occur when an image exceeds the given size. This may "
"reduce OCR quality, but on large images the most desirable text is "
"usually larger."
),
)
tess.add_argument(
'--user-words',
metavar='FILE',
dest='user_words',
help="Specify the location of the Tesseract user words file. This is a "
"list of words Tesseract should consider while performing OCR in "
"addition to its standard language dictionaries. This can improve "
"OCR quality especially for specialized and technical documents.",
)
tess.add_argument(
'--user-patterns',
metavar='FILE',
dest='user_patterns',
help="Specify the location of the Tesseract user patterns file.",
)
@field_validator('timeout', 'non_ocr_timeout')
@classmethod
def validate_timeout_reasonable(cls, v):
"""Validate timeout values are reasonable."""
if v > 3600: # 1 hour
log.warning(f"Timeout of {v} seconds is very long and may cause issues")
return v
@field_validator('pagesegmode')
@classmethod
def validate_pagesegmode_warning(cls, v):
"""Validate page segmentation mode and warn about problematic values."""
if v in (0, 2):
log.warning(
"The tesseract-pagesegmode you selected will disable OCR. "
"This may cause processing to fail."
)
return v
@model_validator(mode='after')
def validate_downsample_consistency(self):
"""Validate downsample options are consistent."""
if self.downsample_above != 32767 and not self.downsample_large_images:
log.warning(
"The --tesseract-downsample-above argument will have no effect unless "
"--tesseract-downsample-large-images is also given."
)
return self
def validate_with_context(self, languages: list[str]) -> None:
"""Validate options that require external context.
Args:
languages: List of languages being used for OCR
"""
# Validate languages are not internal Tesseract languages
DENIED_LANGUAGES = {'equ', 'osd'}
if DENIED_LANGUAGES & set(languages):
raise BadArgsError(
"The following languages are for Tesseract's internal use "
"and should not be issued explicitly: "
f"{', '.join(DENIED_LANGUAGES & set(languages))}\n"
"Remove them from the -l/--language argument."
)
@hookimpl
def register_options():
"""Register Tesseract option model."""
return {'tesseract': TesseractOptions}
@hookimpl
def add_options(parser):
tess = parser.add_argument_group("Tesseract", "Advanced control of Tesseract OCR")
tess.add_argument(
'--tesseract-config',
action='append',
metavar='CFG',
default=[],
help="Additional Tesseract configuration files -- see documentation.",
)
tess.add_argument(
'--tesseract-pagesegmode',
action='store',
type=int,
metavar='PSM',
choices=range(0, 14),
help="Set Tesseract page segmentation mode (see tesseract --help).",
)
tess.add_argument(
'--tesseract-oem',
action='store',
type=int,
metavar='MODE',
choices=range(0, 4),
help=(
"Set Tesseract 4+ OCR engine mode: "
"0 - original Tesseract only; "
"1 - neural nets LSTM only; "
"2 - Tesseract + LSTM; "
"3 - default."
),
)
tess.add_argument(
'--tesseract-thresholding',
action='store',
type=str_to_int(tesseract.TESSERACT_THRESHOLDING_METHODS),
default='auto',
metavar='METHOD',
help=(
"Set Tesseract 5.0+ input image thresholding mode. This may improve OCR "
"results on low quality images or those that contain high contrast color. "
"legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu "
"algorithm with improved sort for background color changes; sauvola is "
"based on local standard deviation."
),
)
tess.add_argument(
'--tesseract-timeout',
default=180.0,
type=numeric(float, 0),
metavar='SECONDS',
help=(
"Give up on OCR after the timeout, but copy the preprocessed page "
"into the final output. This timeout is only used when using Tesseract "
"for OCR. When Tesseract is used for other operations such as "
"deskewing and orientation, the timeout is controlled by "
"--tesseract-non-ocr-timeout."
),
)
tess.add_argument(
'--tesseract-non-ocr-timeout',
default=180.0,
type=numeric(float, 0),
metavar='SECONDS',
help=(
"Give up on non-OCR operations such as deskewing and orientation "
"after timeout. This is a separate timeout from --tesseract-timeout "
"because these operations are not as expensive as OCR."
),
)
tess.add_argument(
'--tesseract-downsample-large-images',
action=argparse.BooleanOptionalAction,
default=True,
help=(
"Downsample large images before OCR. Tesseract has an upper limit on the "
"size images it will support. If this argument is given, OCRmyPDF will "
"downsample large images to fit Tesseract. This may reduce OCR quality, "
"on large images the most desirable text is usually larger. If this "
"parameter is not supplied, Tesseract will error out and produce no OCR "
"on the page in question. This argument should be used with a high value "
"of --tesseract-timeout to ensure Tesseract has enough to time."
),
)
tess.add_argument(
'--tesseract-downsample-above',
action='store',
type=numeric(int, 100, 32767),
default=32767,
help=(
"Downsample images larger than this size pixel size in either dimension "
"before OCR. --tesseract-downsample-large-images downsamples only when "
"an image exceeds Tesseract's internal limits. This argument causes "
"downsampling to occur when an image exceeds the given size. This may "
"reduce OCR quality, but on large images the most desirable text is "
"usually larger."
),
)
tess.add_argument(
'--user-words',
metavar='FILE',
help="Specify the location of the Tesseract user words file. This is a "
"list of words Tesseract should consider while performing OCR in "
"addition to its standard language dictionaries. This can improve "
"OCR quality especially for specialized and technical documents.",
)
tess.add_argument(
'--user-patterns',
metavar='FILE',
help="Specify the location of the Tesseract user patterns file.",
)
# Use the model's CLI generation method - it now handles all Tesseract options
TesseractOptions.add_arguments_to_parser(parser)
@hookimpl
def check_options(options):
"""Check external dependencies and version compatibility for Tesseract."""
check_external_program(
program='tesseract',
package={'linux': 'tesseract-ocr'},
@@ -152,33 +320,16 @@ def check_options(options):
"Please upgrade to a newer or supported older version."
)
# Decide on what renderer to use
if options.pdf_renderer == 'auto':
if {'ara', 'heb', 'fas', 'per'} & set(options.languages):
log.info("Using sandwich renderer since there is an RTL language")
options.pdf_renderer = 'sandwich'
else:
options.pdf_renderer = 'hocr'
if not tesseract.has_thresholding() and options.tesseract_thresholding != 0:
# Check version-specific feature compatibility
if (
not tesseract.has_thresholding()
and options.tesseract.thresholding != ThresholdingMethod.AUTO
):
log.warning(
"The installed version of Tesseract does not support changes to its "
"thresholding method. The --tesseract-threshold argument will be "
"ignored."
)
if options.tesseract_pagesegmode in (0, 2):
log.warning(
"The --tesseract-pagesegmode argument you select will disable OCR. "
"This may cause processing to fail."
)
DENIED_LANGUAGES = {'equ', 'osd'}
if DENIED_LANGUAGES & set(options.languages):
raise BadArgsError(
"The following languages for Tesseract's internal use and should not "
"be issued explicitly: "
f"{', '.join(DENIED_LANGUAGES & set(options.languages))}\n"
"Remove them from the -l/--language argument."
)
@hookimpl
@@ -192,15 +343,17 @@ def validate(pdfinfo, options):
# constraint: (ocrmypdf workers) * (tesseract threads) <= max_workers.
# As of Tesseract 4.1, 3 threads is the most effective on a 4 core/8 thread system.
if not os.environ.get('OMP_THREAD_LIMIT', '').isnumeric():
tess_threads = clamp(options.jobs // len(pdfinfo), 1, 3)
os.environ['OMP_THREAD_LIMIT'] = str(tess_threads)
jobs = options.jobs or available_cpu_count()
tess_threads = clamp(jobs // len(pdfinfo), 1, 3)
else:
tess_threads = int(os.environ['OMP_THREAD_LIMIT'])
# Store the thread limit in options - it will be passed to subprocess env
options.tesseract.omp_thread_limit = tess_threads
log.debug("Using Tesseract OpenMP thread limit %d", tess_threads)
if (
options.tesseract_downsample_above != 32767
and not options.tesseract_downsample_large_images
options.tesseract.downsample_above != 32767
and not options.tesseract.downsample_large_images
):
log.warning(
"The --tesseract-downsample-above argument will have no effect unless "
@@ -216,10 +369,12 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
or more than 2**31 bytes. This function resizes the image to fit within
those limits.
"""
threshold = min(page.options.tesseract_downsample_above, 32767)
options = page.options
if options.tesseract_downsample_large_images:
if getattr(options, 'tesseract', None) is None:
return image
threshold = min(options.tesseract.downsample_above, 32767)
if options.tesseract.downsample_large_images:
size = calculate_downsample(
image, max_size=(threshold, threshold), max_bytes=(2**31) - 1
)
@@ -234,10 +389,25 @@ class TesseractOcrEngine(OcrEngine):
def version():
return str(tesseract.version())
@staticmethod
def _determine_renderer(options):
"""Determine the PDF renderer to use based on options and languages."""
if options.pdf_renderer == 'auto':
return 'fpdf2'
return options.pdf_renderer
@staticmethod
def creator_tag(options):
tag = '-PDF' if options.pdf_renderer == 'sandwich' else '-hOCR'
return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}"
renderer = TesseractOcrEngine._determine_renderer(options)
match renderer:
case 'hocr':
return f"OCRmyPDF hOCR + Tesseract OCR {TesseractOcrEngine.version()}"
case 'fpdf2':
return f"OCRmyPDF fpdf2 + Tesseract OCR {TesseractOcrEngine.version()}"
case "sandwich":
return f"Tesseract OCR + PDF {TesseractOcrEngine.version()}"
case _:
return f"Tesseract OCR {TesseractOcrEngine.version()}"
def __str__(self):
return f"Tesseract OCR {TesseractOcrEngine.version()}"
@@ -250,8 +420,9 @@ class TesseractOcrEngine(OcrEngine):
def get_orientation(input_file, options):
return tesseract.get_orientation(
input_file,
engine_mode=options.tesseract_oem,
timeout=options.tesseract_non_ocr_timeout,
engine_mode=options.tesseract.oem,
timeout=options.tesseract.non_ocr_timeout,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -259,8 +430,9 @@ class TesseractOcrEngine(OcrEngine):
return tesseract.get_deskew(
input_file,
languages=options.languages,
engine_mode=options.tesseract_oem,
timeout=options.tesseract_non_ocr_timeout,
engine_mode=options.tesseract.oem,
timeout=options.tesseract.non_ocr_timeout,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -270,13 +442,14 @@ class TesseractOcrEngine(OcrEngine):
output_hocr=output_hocr,
output_text=output_text,
languages=options.languages,
engine_mode=options.tesseract_oem,
tessconfig=options.tesseract_config,
timeout=options.tesseract_timeout,
pagesegmode=options.tesseract_pagesegmode,
thresholding=options.tesseract_thresholding,
user_words=options.user_words,
user_patterns=options.user_patterns,
engine_mode=options.tesseract.oem,
tessconfig=options.tesseract.config,
timeout=options.tesseract.timeout,
pagesegmode=options.tesseract.pagesegmode,
thresholding=options.tesseract.thresholding,
user_words=options.tesseract.user_words,
user_patterns=options.tesseract.user_patterns,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -286,16 +459,23 @@ class TesseractOcrEngine(OcrEngine):
output_pdf=output_pdf,
output_text=output_text,
languages=options.languages,
engine_mode=options.tesseract_oem,
tessconfig=options.tesseract_config,
timeout=options.tesseract_timeout,
pagesegmode=options.tesseract_pagesegmode,
thresholding=options.tesseract_thresholding,
user_words=options.user_words,
user_patterns=options.user_patterns,
engine_mode=options.tesseract.oem,
tessconfig=options.tesseract.config,
timeout=options.tesseract.timeout,
pagesegmode=options.tesseract.pagesegmode,
thresholding=options.tesseract.thresholding,
user_words=options.tesseract.user_words,
user_patterns=options.tesseract.user_patterns,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@hookimpl
def get_ocr_engine():
def get_ocr_engine(options):
"""Return TesseractOcrEngine when selected or as default."""
if options is not None:
ocr_engine = getattr(options, 'ocr_engine', 'auto')
# Tesseract is selected if explicitly requested or if 'auto'
if ocr_engine not in ('auto', 'tesseract'):
return None
return TesseractOcrEngine()
+128 -50
View File
@@ -6,11 +6,14 @@
from __future__ import annotations
import argparse
from argparse import ArgumentParser
from collections.abc import Callable, Mapping
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._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._version import __version__ as _VERSION
T = TypeVar('T', int, float)
@@ -51,39 +54,6 @@ def str_to_int(mapping: Mapping[str, int]):
return _str_to_int
class ArgumentParser(argparse.ArgumentParser):
"""Override parser's default behavior of calling sys.exit().
https://stackoverflow.com/questions/5943249/python-argparse-and-controlling-overriding-the-exit-status-code
OCRmyPDF began as a CLI but eventually acquired an API. The API works inside out,
by synthesizing a command line argument. So we subclass the standard parser with
one that doesn't call sys.exit(). Obviously this is not the ideal way to do things
but it works for us.
"""
def __init__(self, *args, **kwargs):
"""Initialize the parser."""
super().__init__(*args, **kwargs)
self._api_mode = False
def enable_api_mode(self):
"""Enable API mode.
When set, the parser will not call sys.exit() on error. OCRmyPDF was originally
a command line program, but now it has an API. The API works by synthesizing
command line arguments.
"""
self._api_mode = True
def error(self, message):
"""Override the default argparse error behavior."""
if not self._api_mode:
super().error(message)
return
raise ValueError(message)
class LanguageSetAction(argparse.Action):
"""Manages a list of languages."""
@@ -96,7 +66,7 @@ class LanguageSetAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
"""Add a language to the set."""
dest = getattr(namespace, self.dest)
if '+' in values:
if isinstance(values, str) and '+' in values:
[dest.append(lang) for lang in values.split('+')]
else:
dest.append(values)
@@ -189,16 +159,17 @@ Online documentation is located at:
)
parser.add_argument(
'--output-type',
choices=['pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'],
default='pdfa',
help="Choose output type. 'pdfa' creates a PDF/A-2b compliant file for "
"long term archiving (default, recommended) but may not suitable "
"for users who want their file altered as little as possible. 'pdfa' "
"also has problems with full Unicode text. 'pdf' minimizes changes "
"to the input file. 'pdf-a1' creates a "
"PDF/A-1b file. 'pdf-a2' is equivalent to 'pdfa'. 'pdf-a3' creates a "
"PDF/A-3b file. 'none' will produce no output, which may be helpful if "
"only the --sidecar is desired.",
choices=['auto', 'pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'],
default='auto',
help="Choose output type. 'auto' (default) produces best-effort PDF/A "
"without requiring Ghostscript - uses verapdf validation when available, "
"otherwise passes through as PDF/A if safe (input already PDF/A or "
"force-ocr was used), or falls back to regular PDF. 'pdfa' creates a "
"PDF/A-2b compliant file for long term archiving (requires Ghostscript "
"as fallback). 'pdf' minimizes changes to the input file. 'pdfa-1' "
"creates a PDF/A-1b file. 'pdfa-2' is equivalent to 'pdfa'. 'pdfa-3' "
"creates a PDF/A-3b file. 'none' will produce no output, which may be "
"helpful if only the --sidecar is desired.",
)
# Use null string '\0' as sentinel to indicate the user supplied no argument,
@@ -337,12 +308,25 @@ Online documentation is located at:
)
ocrsettings = parser.add_argument_group("OCR options", "Control how OCR is applied")
ocrsettings.add_argument(
'-m',
'--mode',
choices=[mode.value for mode in ProcessingMode],
default=ProcessingMode.default.value,
help="Processing mode for pages with existing text. "
"'default' errors if text is found. "
"'force' rasterizes all content and runs OCR (same as --force-ocr). "
"'skip' skips pages with existing text (same as --skip-text). "
"'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr).",
)
# Legacy flags for backward compatibility - these set the mode internally
ocrsettings.add_argument(
'-f',
'--force-ocr',
action='store_true',
help="Rasterize any text or vector objects on each page, apply OCR, and "
"save the rastered output (this rewrites the PDF)",
"save the rastered output (this rewrites the PDF). "
"Equivalent to --mode force.",
)
ocrsettings.add_argument(
'-s',
@@ -350,7 +334,8 @@ Online documentation is located at:
action='store_true',
help="Skip OCR on any pages that already contain text, but include the "
"page in final output; useful for PDFs that contain a mix of "
"images, text pages, and/or previously OCRed pages",
"images, text pages, and/or previously OCRed pages. "
"Equivalent to --mode skip.",
)
ocrsettings.add_argument(
'--redo-ocr',
@@ -358,7 +343,8 @@ Online documentation is located at:
help="Attempt to detect and remove the hidden OCR layer from files that "
"were previously OCRed with OCRmyPDF or another program. Apply OCR "
"to text found in raster images. Existing visible text objects will "
"not be changed. If there is no existing OCR, OCR will be added.",
"not be changed. If there is no existing OCR, OCR will be added. "
"Equivalent to --mode redo.",
)
ocrsettings.add_argument(
'--skip-big',
@@ -397,10 +383,30 @@ Online documentation is located at:
)
advanced.add_argument(
'--pdf-renderer',
choices=['auto', 'hocr', 'sandwich', 'hocrdebug'],
choices=['auto', 'hocr', 'sandwich', 'hocrdebug', 'fpdf2'],
default='auto',
help="Choose OCR PDF renderer - the default option is to let OCRmyPDF "
"choose. See documentation for discussion.",
help="Choose OCR PDF renderer. 'auto' (recommended) uses fpdf2, which "
"provides full international language support including RTL scripts, "
"proper text positioning, and invisible text that becomes visible when "
"selected. 'sandwich' renders text as a background layer. Legacy 'hocr' "
"and 'hocrdebug' options are deprecated and will use fpdf2.",
)
advanced.add_argument(
'--ocr-engine',
choices=['auto', 'tesseract', 'none'],
default='auto',
help="OCR engine to use. 'auto' (default) selects the best available engine. "
"'tesseract' uses Tesseract OCR. "
"'none' skips OCR entirely, useful for PDF/A conversion or image processing "
"without text recognition.",
)
advanced.add_argument(
'--rasterizer',
choices=['auto', 'ghostscript', 'pypdfium'],
default='auto',
help="Choose PDF page rasterizer. 'auto' prefers pypdfium when available, "
"falling back to Ghostscript. 'pypdfium' is faster but requires the "
"pypdfium2 package. 'ghostscript' uses the traditional Ghostscript rasterizer.",
)
advanced.add_argument(
'--rotate-pages-threshold',
@@ -465,3 +471,75 @@ plugins_only_parser.add_argument(
default=[],
help="Name of plugin to import.",
)
def namespace_to_options(ns) -> OcrOptions:
"""Convert argparse.Namespace to OcrOptions.
This function encapsulates CLI-specific knowledge of how command line
arguments map to our internal options model.
"""
# Extract known fields
known_fields = {}
extra_attrs = {}
# Legacy boolean flags that map to mode - handled by OcrOptions model validator
legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'}
for key, value in vars(ns).items():
if key in OcrOptions.model_fields:
known_fields[key] = value
elif key in legacy_mode_flags:
# Pass legacy flags to OcrOptions for conversion to mode
known_fields[key] = value
else:
extra_attrs[key] = value
# Handle special cases for hOCR API
if 'output_folder' in extra_attrs and 'output_file' not in known_fields:
known_fields['output_file'] = '/dev/null' # Placeholder
# Handle case where input_file is missing (e.g., in _hocr_to_ocr_pdf)
if 'work_folder' in extra_attrs and 'input_file' not in known_fields:
known_fields['input_file'] = '/dev/null' # Placeholder
instance = OcrOptions(**known_fields)
instance.extra_attrs = extra_attrs
return instance
def get_options_and_plugins(
args=None,
) -> tuple[OcrOptions, OcrmypdfPluginManager]:
"""Parse command line arguments and return OcrOptions and plugin manager.
This is the main entry point for CLI argument processing. It handles
plugin discovery, argument parsing, and conversion to our internal
options model.
Args:
args: Command line arguments. If None, uses sys.argv.
Returns:
Tuple of (OcrOptions, PluginManager)
"""
# Import here to avoid circular imports
from ocrmypdf.api import setup_plugin_infrastructure
# First pass: get plugins so we can register their options
pre_options, _unused = plugins_only_parser.parse_known_args(args=args)
# Set up plugin infrastructure with proper initialization
plugin_manager = setup_plugin_infrastructure(plugins=pre_options.plugins)
# Get parser and let plugins add their options
parser = get_parser()
plugin_manager.add_options(parser=parser)
# Parse all arguments
namespace = parser.parse_args(args=args)
# Convert to OcrOptions
options = namespace_to_options(namespace)
return options, plugin_manager
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Font management for OCRmyPDF PDF rendering.
This module provides font infrastructure for the fpdf2 PDF renderer. It includes:
- FontManager: Base class for font loading and glyph checking
- FontProvider: Protocol and implementations for font discovery
- MultiFontManager: Automatic font selection for multilingual documents
- SystemFontProvider: System font discovery
"""
from __future__ import annotations
from ocrmypdf.font.font_manager import FontManager
from ocrmypdf.font.font_provider import (
BuiltinFontProvider,
ChainedFontProvider,
FontProvider,
)
from ocrmypdf.font.multi_font_manager import MultiFontManager
from ocrmypdf.font.system_font_provider import SystemFontProvider
__all__ = [
"FontManager",
"FontProvider",
"BuiltinFontProvider",
"ChainedFontProvider",
"MultiFontManager",
"SystemFontProvider",
]
+115
View File
@@ -0,0 +1,115 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Base font management for PDF rendering.
This module provides the base FontManager class that handles font loading
and glyph checking using uharfbuzz.
"""
from __future__ import annotations
from pathlib import Path
import uharfbuzz as hb
class FontManager:
"""Manages font loading and glyph checking for PDF rendering.
This base class handles loading fonts with uharfbuzz for glyph checking
and text shaping. Renderer-specific subclasses should extend this to
add their own font objects.
Attributes:
font_path: Path to the font file
font_data: Raw font file bytes
font_index: Index within TTC collection (0 for single-font files)
hb_face: uharfbuzz Face object
hb_font: uharfbuzz Font object
"""
def __init__(self, font_path: Path, font_index: int = 0):
"""Initialize font manager.
Args:
font_path: Path to TrueType/OpenType font file
font_index: Index of font within a TTC collection (default 0).
For single-font files (.ttf, .otf), use 0.
"""
self.font_path = font_path
self.font_index = font_index
# Load font data
self.font_data = font_path.read_bytes()
# Load font with uharfbuzz for glyph checking and text measurement
# Note: uharfbuzz Face also supports font_index for TTC files
self.hb_face = hb.Face(self.font_data, font_index)
self.hb_font = hb.Font(self.hb_face)
def get_hb_font(self) -> hb.Font:
"""Get uharfbuzz Font object for text measurement.
Returns:
UHarfBuzz Font instance
"""
return self.hb_font
def has_glyph(self, codepoint: int) -> bool:
"""Check if font has a glyph for given codepoint.
Args:
codepoint: Unicode codepoint
Returns:
True if font has a real glyph (not .notdef)
"""
glyph_id = self.hb_font.get_nominal_glyph(codepoint)
return glyph_id is not None and glyph_id != 0
def get_font_metrics(self) -> tuple[float, float, float]:
"""Get normalized font metrics (ascent, descent, units_per_em).
Returns:
Tuple of (ascent, descent, units_per_em) where ascent and descent
are in font units. Ascent is positive (above baseline), descent
is typically negative (below baseline).
"""
extents = self.hb_font.get_font_extents('ltr')
units_per_em = self.hb_face.upem
return (extents.ascender, extents.descender, units_per_em)
def get_left_side_bearing(self, char: str, font_size: float) -> float:
"""Get the left side bearing of a character at a given font size.
The left side bearing (lsb) is the horizontal distance from the glyph
origin (x=0) to the leftmost pixel of the glyph. A positive lsb means
there's whitespace before the glyph starts.
Args:
char: Single character to get lsb for
font_size: Font size in points
Returns:
Left side bearing in points. Returns 0 if character not found.
"""
if not char:
return 0.0
codepoint = ord(char)
glyph_id = self.hb_font.get_nominal_glyph(codepoint)
if glyph_id is None or glyph_id == 0:
return 0.0
# Get glyph extents which include left/right bearing info
extents = self.hb_font.get_glyph_extents(glyph_id)
if extents is None:
return 0.0
# x_bearing is the left side bearing in font units
units_per_em = self.hb_face.upem
lsb_units = extents.x_bearing
lsb_pt = lsb_units * font_size / units_per_em
return lsb_pt
+189
View File
@@ -0,0 +1,189 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Font provider protocol and implementations for PDF rendering."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Protocol
from ocrmypdf.font.font_manager import FontManager
log = logging.getLogger(__name__)
class FontProvider(Protocol):
"""Protocol for providing fonts to MultiFontManager.
Implementations are responsible for knowing where fonts are located
and loading them. MultiFontManager asks for fonts by name and uses
them for glyph coverage checking.
"""
def get_font(self, font_name: str) -> FontManager | None:
"""Get a FontManager for the named font.
Args:
font_name: Logical font name (e.g., 'NotoSans-Regular')
Returns:
FontManager if font is available, None otherwise
"""
...
def get_available_fonts(self) -> list[str]:
"""Get list of available font names.
Returns:
List of font names that can be retrieved with get_font()
"""
...
def get_fallback_font(self) -> FontManager:
"""Get the glyphless fallback font.
This font must always be available and handles any codepoint.
Returns:
FontManager for the glyphless fallback font (Occulta.ttf)
"""
...
class BuiltinFontProvider:
"""Font provider using builtin fonts from ocrmypdf/data directory."""
# Mapping of logical font names to filenames
# Only Latin (NotoSans) and the glyphless fallback (Occulta.ttf) are bundled.
# All other scripts (Arabic, Devanagari, CJK, etc.) are discovered from
# system fonts by SystemFontProvider to reduce package size.
FONT_FILES = {
'NotoSans-Regular': 'NotoSans-Regular.ttf',
'Occulta': 'Occulta.ttf',
}
def __init__(self, font_dir: Path | None = None):
"""Initialize builtin font provider.
Args:
font_dir: Directory containing font files. If None, uses
the default ocrmypdf/data directory.
"""
if font_dir is None:
font_dir = Path(__file__).parent.parent / "data"
self.font_dir = font_dir
self._fonts: dict[str, FontManager] = {}
self._load_fonts()
def _load_fonts(self) -> None:
"""Load available fonts, logging warnings for missing ones."""
for font_name, font_file in self.FONT_FILES.items():
font_path = self.font_dir / font_file
if not font_path.exists():
if font_name == 'Occulta':
raise FileNotFoundError(
f"Required fallback font not found: {font_path}"
)
log.warning(
"Font %s not found at %s - OCR output quality for some "
"scripts may be affected",
font_name,
font_path,
)
continue
try:
self._fonts[font_name] = FontManager(font_path)
except Exception as e:
if font_name == 'Occulta':
raise ValueError(
f"Failed to load required fallback font {font_file}: {e}"
) from e
log.warning(
"Failed to load font %s: %s - OCR output quality may be affected",
font_name,
e,
)
def get_font(self, font_name: str) -> FontManager | None:
"""Get a FontManager for the named font."""
return self._fonts.get(font_name)
def get_available_fonts(self) -> list[str]:
"""Get list of available font names."""
return list(self._fonts.keys())
def get_fallback_font(self) -> FontManager:
"""Get the glyphless fallback font."""
return self._fonts['Occulta']
class ChainedFontProvider:
"""Font provider that tries multiple providers in order.
This allows combining builtin fonts with system fonts, trying
the builtin provider first and falling back to system fonts
for fonts not bundled with the package.
"""
def __init__(self, providers: list[FontProvider]):
"""Initialize chained font provider.
Args:
providers: List of font providers to try in order.
The first provider that returns a font wins.
"""
if not providers:
raise ValueError("At least one provider is required")
self.providers = providers
def get_font(self, font_name: str) -> FontManager | None:
"""Get a FontManager for the named font.
Tries each provider in order until one returns a font.
Args:
font_name: Logical font name (e.g., 'NotoSans-Regular')
Returns:
FontManager if any provider has the font, None otherwise
"""
for provider in self.providers:
if font := provider.get_font(font_name):
return font
return None
def get_available_fonts(self) -> list[str]:
"""Get list of available font names from all providers.
Returns:
Combined list of font names (deduplicated, order preserved)
"""
seen: set[str] = set()
result: list[str] = []
for provider in self.providers:
for name in provider.get_available_fonts():
if name not in seen:
seen.add(name)
result.append(name)
return result
def get_fallback_font(self) -> FontManager:
"""Get the glyphless fallback font.
Tries each provider until one provides a fallback font.
Returns:
FontManager for the fallback font
Raises:
RuntimeError: If no provider can provide a fallback font
"""
for provider in self.providers:
try:
return provider.get_fallback_font()
except (NotImplementedError, AttributeError, KeyError):
continue
raise RuntimeError("No fallback font available from any provider")
+334
View File
@@ -0,0 +1,334 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Multi-font management for PDF rendering.
Provides automatic font selection for multilingual documents based on
language hints and glyph coverage analysis.
"""
from __future__ import annotations
import logging
from pathlib import Path
from ocrmypdf.font.font_manager import FontManager
from ocrmypdf.font.font_provider import (
BuiltinFontProvider,
ChainedFontProvider,
FontProvider,
)
from ocrmypdf.font.system_font_provider import SystemFontProvider
log = logging.getLogger(__name__)
class MultiFontManager:
"""Manages multiple fonts with automatic selection and fallback.
This class orchestrates multiple FontManager instances to provide
word-level font selection for multilingual documents. It uses a hybrid
approach combining language hints from hOCR with glyph coverage analysis.
Font selection strategy:
1. Try language-preferred font (if language hint available)
2. Try fallback fonts in order by glyph coverage
3. Fall back to Occulta.ttf (glyphless fallback)
"""
# Language to font mapping
# Keys are ISO 639-2/3 codes or Tesseract language codes
LANGUAGE_FONT_MAP = {
# Arabic script
'ara': 'NotoSansArabic-Regular', # Arabic
'per': 'NotoSansArabic-Regular', # Persian (uses Arabic script)
'fas': 'NotoSansArabic-Regular', # Farsi (alternative code for Persian)
'urd': 'NotoSansArabic-Regular', # Urdu (uses Arabic script)
'pus': 'NotoSansArabic-Regular', # Pashto
'kur': 'NotoSansArabic-Regular', # Kurdish (Arabic script variant)
# Devanagari script
'hin': 'NotoSansDevanagari-Regular', # Hindi
'san': 'NotoSansDevanagari-Regular', # Sanskrit
'mar': 'NotoSansDevanagari-Regular', # Marathi
'nep': 'NotoSansDevanagari-Regular', # Nepali
'kok': 'NotoSansDevanagari-Regular', # Konkani
'bho': 'NotoSansDevanagari-Regular', # Bhojpuri
'mai': 'NotoSansDevanagari-Regular', # Maithili
# CJK
'chi': 'NotoSansCJK-Regular', # Chinese (generic)
'zho': 'NotoSansCJK-Regular', # Chinese (ISO 639-3)
'chi_sim': 'NotoSansCJK-Regular', # Chinese Simplified (Tesseract)
'chi_tra': 'NotoSansCJK-Regular', # Chinese Traditional (Tesseract)
'jpn': 'NotoSansCJK-Regular', # Japanese
'kor': 'NotoSansCJK-Regular', # Korean
# Thai
'tha': 'NotoSansThai-Regular', # Thai
# Hebrew
'heb': 'NotoSansHebrew-Regular', # Hebrew
'yid': 'NotoSansHebrew-Regular', # Yiddish (uses Hebrew script)
# Bengali script
'ben': 'NotoSansBengali-Regular', # Bengali
'asm': 'NotoSansBengali-Regular', # Assamese (uses Bengali script)
# Tamil
'tam': 'NotoSansTamil-Regular', # Tamil
# Gujarati
'guj': 'NotoSansGujarati-Regular', # Gujarati
# Telugu
'tel': 'NotoSansTelugu-Regular', # Telugu
# Kannada
'kan': 'NotoSansKannada-Regular', # Kannada
# Malayalam
'mal': 'NotoSansMalayalam-Regular', # Malayalam
# Myanmar (Burmese)
'mya': 'NotoSansMyanmar-Regular', # Myanmar
# Khmer (Cambodian)
'khm': 'NotoSansKhmer-Regular', # Khmer
# Lao
'lao': 'NotoSansLao-Regular', # Lao
# Georgian
'kat': 'NotoSansGeorgian-Regular', # Georgian
'geo': 'NotoSansGeorgian-Regular', # Georgian (alternative)
# Armenian
'hye': 'NotoSansArmenian-Regular', # Armenian
'arm': 'NotoSansArmenian-Regular', # Armenian (alternative)
# Ethiopic
'amh': 'NotoSansEthiopic-Regular', # Amharic
'tir': 'NotoSansEthiopic-Regular', # Tigrinya
# Sinhala
'sin': 'NotoSansSinhala-Regular', # Sinhala
# Gurmukhi (Punjabi)
'pan': 'NotoSansGurmukhi-Regular', # Punjabi
'pnb': 'NotoSansGurmukhi-Regular', # Western Punjabi
# Oriya
'ori': 'NotoSansOriya-Regular', # Oriya
'ory': 'NotoSansOriya-Regular', # Oriya (alternative)
# Tibetan
'bod': 'NotoSansTibetan-Regular', # Tibetan
'tib': 'NotoSansTibetan-Regular', # Tibetan (alternative)
}
# Ordered fallback chain for fonts (after language-preferred font)
# Order matters: most common scripts first for faster matching
FALLBACK_FONTS = [
'NotoSans-Regular', # Latin, Greek, Cyrillic
'NotoSansArabic-Regular',
'NotoSansDevanagari-Regular',
'NotoSansCJK-Regular',
'NotoSansThai-Regular',
'NotoSansHebrew-Regular',
'NotoSansBengali-Regular',
'NotoSansTamil-Regular',
'NotoSansGujarati-Regular',
'NotoSansTelugu-Regular',
'NotoSansKannada-Regular',
'NotoSansMalayalam-Regular',
'NotoSansMyanmar-Regular',
'NotoSansKhmer-Regular',
'NotoSansLao-Regular',
'NotoSansGeorgian-Regular',
'NotoSansArmenian-Regular',
'NotoSansEthiopic-Regular',
'NotoSansSinhala-Regular',
'NotoSansGurmukhi-Regular',
'NotoSansOriya-Regular',
'NotoSansTibetan-Regular',
]
def __init__(
self,
font_dir: Path | None = None,
*,
font_provider: FontProvider | None = None,
):
"""Initialize multi-font manager.
Args:
font_dir: Directory containing font files. If font_provider is
not specified, this is passed to BuiltinFontProvider.
font_provider: Provider for loading fonts. If None, uses a
ChainedFontProvider that tries builtin fonts first,
then searches system fonts.
"""
if font_provider is not None:
self.font_provider = font_provider
else:
# Use chained provider: try builtin fonts first, then system fonts
self.font_provider = ChainedFontProvider(
[
BuiltinFontProvider(font_dir),
SystemFontProvider(),
]
)
# Font selection cache: (word_text, language) -> font_name
self._selection_cache: dict[tuple[str, str | None], str] = {}
# Track whether we've warned about missing fonts (warn once per script)
self._warned_scripts: set[str] = set()
@property
def fonts(self) -> dict[str, FontManager]:
"""Get all loaded fonts (backward compatibility)."""
return self.get_all_fonts()
def _try_font(
self, font_name: str, word_text: str, cache_key: tuple[str, str | None]
) -> FontManager | None:
"""Try to use a font for the given word.
Args:
font_name: Name of font to try
word_text: Text content to check
cache_key: Cache key for storing successful result
Returns:
FontManager if font exists and has all glyphs, None otherwise
"""
font = self.font_provider.get_font(font_name)
if font is None:
return None
if self._has_all_glyphs(font, word_text):
self._selection_cache[cache_key] = font_name
return font
return None
def select_font_for_word(
self, word_text: str, line_language: str | None
) -> FontManager:
"""Select appropriate font for a word.
Uses a hybrid approach:
1. Language-based selection (if language hint available)
2. Ordered fallback through available fonts by glyph coverage
3. Final fallback to Occulta.ttf (glyphless)
Args:
word_text: The text content of the word
line_language: Language code from hOCR (e.g., 'ara', 'eng')
Returns:
FontManager instance to use for rendering this word
"""
cache_key = (word_text, line_language)
if cache_key in self._selection_cache:
cached_name = self._selection_cache[cache_key]
font = self.font_provider.get_font(cached_name)
if font:
return font
tried_fonts: set[str] = set()
# Phase 1: Try language-preferred font
if line_language and line_language in self.LANGUAGE_FONT_MAP:
preferred = self.LANGUAGE_FONT_MAP[line_language]
tried_fonts.add(preferred)
if result := self._try_font(preferred, word_text, cache_key):
return result
# Phase 2: Try fallback fonts in order
for font_name in self.FALLBACK_FONTS:
if font_name in tried_fonts:
continue
if result := self._try_font(font_name, word_text, cache_key):
return result
# Phase 3: Glyphless fallback (always succeeds)
# Warn if we're falling back for non-ASCII text (likely missing font)
self._warn_missing_font(word_text, line_language)
self._selection_cache[cache_key] = 'Occulta'
return self.font_provider.get_fallback_font()
def _warn_missing_font(self, word_text: str, line_language: str | None) -> None:
"""Warn user about missing font for non-Latin text.
Only warns once per language/script to avoid log spam.
"""
# Determine a key for deduplication (language or 'non-ascii')
warn_key = line_language if line_language else 'unknown'
# Only warn for non-ASCII text and only once per key
if warn_key in self._warned_scripts:
return
# Check if text contains non-ASCII characters
if not any(ord(c) > 127 for c in word_text):
return
self._warned_scripts.add(warn_key)
if line_language and line_language in self.LANGUAGE_FONT_MAP:
font_name = self.LANGUAGE_FONT_MAP[line_language]
log.warning(
"No font found with glyphs for '%s' text. "
"Install %s for better rendering. "
"See https://fonts.google.com/noto",
line_language,
font_name,
)
else:
log.warning(
"No font found with glyphs for some text. "
"Install Noto fonts for better rendering. "
"See https://fonts.google.com/noto"
)
def _has_all_glyphs(self, font: FontManager, text: str) -> bool:
"""Check if a font has glyphs for all characters in text.
Args:
font: FontManager instance to check
text: Text to verify coverage for
Returns:
True if font has real glyphs for all characters (not .notdef)
"""
if not text:
return True
hb_font = font.get_hb_font()
for char in text:
codepoint = ord(char)
glyph_id = hb_font.get_nominal_glyph(codepoint)
if glyph_id is None or glyph_id == 0: # 0 = .notdef glyph
return False
return True
def has_font(self, font_name: str) -> bool:
"""Check if a named font is available.
Args:
font_name: Name of font to check
Returns:
True if font is available
"""
return self.font_provider.get_font(font_name) is not None
def has_all_glyphs(self, font_name: str, text: str) -> bool:
"""Check if a named font has glyphs for all characters in text.
Args:
font_name: Name of font to check
text: Text to verify coverage for
Returns:
True if font has real glyphs for all characters (not .notdef)
"""
font = self.font_provider.get_font(font_name)
if font is None:
return False
return self._has_all_glyphs(font, text)
def get_all_fonts(self) -> dict[str, FontManager]:
"""Get all loaded font managers.
Returns:
Dictionary mapping font names to FontManager instances
"""
result = {}
for name in self.font_provider.get_available_fonts():
font = self.font_provider.get_font(name)
if font is not None:
result[name] = font
return result
+297
View File
@@ -0,0 +1,297 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""System font discovery for PDF rendering.
Provides lazy discovery of Noto fonts installed on the system across
Linux, macOS, and Windows platforms.
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from ocrmypdf.font.font_manager import FontManager
log = logging.getLogger(__name__)
class SystemFontProvider:
"""Discovers and provides system-installed Noto fonts with lazy scanning.
This provider searches standard system font directories for Noto fonts.
Scanning is performed lazily - only when a font is actually requested
and not found in the builtin fonts. Results are cached for the lifetime
of the provider instance.
"""
# System font directories by platform
SYSTEM_FONT_DIRS: dict[str, list[Path]] = {
'linux': [
Path('/usr/share/fonts'),
Path('/usr/local/share/fonts'),
Path.home() / '.fonts',
Path.home() / '.local/share/fonts',
],
'freebsd': [
Path('/usr/local/share/fonts'),
Path.home() / '.fonts',
],
'darwin': [
Path('/Library/Fonts'),
Path('/System/Library/Fonts'),
Path.home() / 'Library/Fonts',
],
# Windows is handled dynamically in _get_font_dirs()
}
# Noto font logical names → possible filenames (priority order)
# The first match found will be used
NOTO_FONT_PATTERNS: dict[str, list[str]] = {
'NotoSans-Regular': [
'NotoSans-Regular.ttf',
'NotoSans-Regular.otf',
],
'NotoSansArabic-Regular': [
'NotoSansArabic-Regular.ttf',
'NotoSansArabic-Regular.otf',
],
'NotoSansDevanagari-Regular': [
'NotoSansDevanagari-Regular.ttf',
'NotoSansDevanagari-Regular.otf',
],
'NotoSansCJK-Regular': [
# Language-specific variants (any will work for CJK)
'NotoSansCJKsc-Regular.otf', # Simplified Chinese
'NotoSansCJKtc-Regular.otf', # Traditional Chinese
'NotoSansCJKjp-Regular.otf', # Japanese
'NotoSansCJKkr-Regular.otf', # Korean
# TTC collections (common on Linux distros)
'NotoSansCJK-Regular.ttc',
'NotoSansCJKsc-Regular.ttc',
# Variable fonts
'NotoSansCJKsc-VF.otf',
],
'NotoSansThai-Regular': [
'NotoSansThai-Regular.ttf',
'NotoSansThai-Regular.otf',
],
'NotoSansHebrew-Regular': [
'NotoSansHebrew-Regular.ttf',
'NotoSansHebrew-Regular.otf',
],
'NotoSansBengali-Regular': [
'NotoSansBengali-Regular.ttf',
'NotoSansBengali-Regular.otf',
],
'NotoSansTamil-Regular': [
'NotoSansTamil-Regular.ttf',
'NotoSansTamil-Regular.otf',
],
'NotoSansGujarati-Regular': [
'NotoSansGujarati-Regular.ttf',
'NotoSansGujarati-Regular.otf',
],
'NotoSansTelugu-Regular': [
'NotoSansTelugu-Regular.ttf',
'NotoSansTelugu-Regular.otf',
],
'NotoSansKannada-Regular': [
'NotoSansKannada-Regular.ttf',
'NotoSansKannada-Regular.otf',
],
'NotoSansMalayalam-Regular': [
'NotoSansMalayalam-Regular.ttf',
'NotoSansMalayalam-Regular.otf',
],
'NotoSansMyanmar-Regular': [
'NotoSansMyanmar-Regular.ttf',
'NotoSansMyanmar-Regular.otf',
],
'NotoSansKhmer-Regular': [
'NotoSansKhmer-Regular.ttf',
'NotoSansKhmer-Regular.otf',
],
'NotoSansLao-Regular': [
'NotoSansLao-Regular.ttf',
'NotoSansLao-Regular.otf',
],
'NotoSansGeorgian-Regular': [
'NotoSansGeorgian-Regular.ttf',
'NotoSansGeorgian-Regular.otf',
],
'NotoSansArmenian-Regular': [
'NotoSansArmenian-Regular.ttf',
'NotoSansArmenian-Regular.otf',
],
'NotoSansEthiopic-Regular': [
'NotoSansEthiopic-Regular.ttf',
'NotoSansEthiopic-Regular.otf',
],
'NotoSansSinhala-Regular': [
'NotoSansSinhala-Regular.ttf',
'NotoSansSinhala-Regular.otf',
],
'NotoSansGurmukhi-Regular': [
'NotoSansGurmukhi-Regular.ttf',
'NotoSansGurmukhi-Regular.otf',
],
'NotoSansOriya-Regular': [
'NotoSansOriya-Regular.ttf',
'NotoSansOriya-Regular.otf',
],
'NotoSansTibetan-Regular': [
'NotoSansTibetan-Regular.ttf',
'NotoSansTibetan-Regular.otf',
],
}
def __init__(self) -> None:
"""Initialize system font provider with empty caches."""
# Cache: font_name -> FontManager (successfully loaded fonts)
self._font_cache: dict[str, FontManager] = {}
# Negative cache: font names we've searched for but not found
self._not_found: set[str] = set()
# Cached font directories (computed lazily)
self._font_dirs: list[Path] | None = None
def _get_platform(self) -> str:
"""Get the current platform identifier.
Returns:
Platform string: 'linux', 'darwin', 'windows', or 'freebsd'
"""
if sys.platform == 'win32':
return 'windows'
elif sys.platform == 'darwin':
return 'darwin'
elif 'freebsd' in sys.platform:
return 'freebsd'
else:
return 'linux'
def _get_font_dirs(self) -> list[Path]:
"""Get font directories for the current platform.
Returns:
List of paths to search for fonts (may include non-existent paths)
"""
if self._font_dirs is not None:
return self._font_dirs
platform = self._get_platform()
if platform == 'windows':
# Get Windows font directories from environment
windir = os.environ.get('WINDIR', r'C:\Windows')
self._font_dirs = [Path(windir) / 'Fonts']
# User-installed fonts (Windows 10+)
localappdata = os.environ.get('LOCALAPPDATA')
if localappdata:
self._font_dirs.append(
Path(localappdata) / 'Microsoft' / 'Windows' / 'Fonts'
)
else:
self._font_dirs = list(self.SYSTEM_FONT_DIRS.get(platform, []))
return self._font_dirs
def _find_font_file(self, font_name: str) -> Path | None:
"""Search system directories for a font file.
Args:
font_name: Logical font name (e.g., 'NotoSansCJK-Regular')
Returns:
Path to font file if found, None otherwise
"""
if font_name not in self.NOTO_FONT_PATTERNS:
return None
patterns = self.NOTO_FONT_PATTERNS[font_name]
for font_dir in self._get_font_dirs():
if not font_dir.exists():
continue
for pattern in patterns:
# Search recursively for the font file
try:
matches = list(font_dir.rglob(pattern))
if matches:
log.debug(
"Found system font %s at %s", font_name, matches[0]
)
return matches[0]
except PermissionError:
# Skip directories we can't read
continue
return None
def get_font(self, font_name: str) -> FontManager | None:
"""Get a FontManager for the named font (lazy loading).
This method implements lazy scanning: fonts are only searched for
when first requested. Results (both positive and negative) are
cached for subsequent calls.
Args:
font_name: Logical font name (e.g., 'NotoSansCJK-Regular')
Returns:
FontManager if font is found and loadable, None otherwise
"""
# Check positive cache first
if font_name in self._font_cache:
return self._font_cache[font_name]
# Check negative cache (already searched, not found)
if font_name in self._not_found:
return None
# Lazy scan for this specific font
font_path = self._find_font_file(font_name)
if font_path is not None:
try:
fm = FontManager(font_path)
self._font_cache[font_name] = fm
return fm
except Exception as e:
log.warning(
"Found font %s at %s but failed to load: %s",
font_name,
font_path,
e,
)
# Cache negative result
self._not_found.add(font_name)
return None
def get_available_fonts(self) -> list[str]:
"""Get list of font names this provider can potentially find.
Note: This returns all font names we know patterns for, not
necessarily fonts that are actually installed. Use get_font()
to check if a specific font is available.
Returns:
List of logical font names
"""
return list(self.NOTO_FONT_PATTERNS.keys())
def get_fallback_font(self) -> FontManager:
"""Get the glyphless fallback font.
Raises:
NotImplementedError: System provider doesn't provide fallback.
Use BuiltinFontProvider for the fallback font.
"""
raise NotImplementedError(
"SystemFontProvider does not provide a fallback font. "
"Use BuiltinFontProvider for Occulta.ttf fallback."
)
+21
View File
@@ -0,0 +1,21 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""fpdf2-based PDF renderer for OCR text layers.
This module provides the PDF renderer using fpdf2 for creating
searchable OCR text layers.
"""
from __future__ import annotations
from ocrmypdf.fpdf_renderer.renderer import (
DebugRenderOptions,
Fpdf2MultiPageRenderer,
Fpdf2PdfRenderer,
)
__all__ = [
"DebugRenderOptions",
"Fpdf2PdfRenderer",
"Fpdf2MultiPageRenderer",
]
+833
View File
@@ -0,0 +1,833 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""fpdf2-based PDF renderer for OCR text layers.
This module provides PDF rendering using fpdf2 for creating searchable
OCR text layers.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from itertools import pairwise
from math import atan, degrees
from pathlib import Path
from fpdf import FPDF
from fpdf.enums import TextMode
from pikepdf import Matrix, Rectangle
from ocrmypdf.font import FontManager, MultiFontManager
from ocrmypdf.models.ocr_element import OcrClass, OcrElement
log = logging.getLogger(__name__)
def transform_point(matrix: Matrix, x: float, y: float) -> tuple[float, float]:
"""Transform a point (x, y) by a matrix.
Args:
matrix: pikepdf Matrix to apply
x: X coordinate
y: Y coordinate
Returns:
Tuple of (transformed_x, transformed_y)
"""
# Use a degenerate rectangle to transform a single point
rect = Rectangle(x, y, x, y)
transformed = matrix.transform(rect)
return (transformed.llx, transformed.lly)
def transform_box(
matrix: Matrix, left: float, top: float, right: float, bottom: float
) -> tuple[float, float, float, float]:
"""Transform a bounding box by a matrix.
Args:
matrix: pikepdf Matrix to apply
left: Left edge of box
top: Top edge of box
right: Right edge of box
bottom: Bottom edge of box
Returns:
Tuple of (llx, lly, width, height) of the transformed box
"""
rect = Rectangle(left, top, right, bottom)
transformed = matrix.transform(rect)
return (
transformed.llx,
transformed.lly,
transformed.width,
transformed.height,
)
@dataclass
class DebugRenderOptions:
"""Options for debug visualization during rendering.
When enabled, draws colored lines/shapes to visualize OCR structure.
"""
render_baseline: bool = False # Magenta lines along baselines
render_line_bbox: bool = False # Blue rectangles around lines
render_word_bbox: bool = False # Green rectangles around words
class CoordinateTransform:
"""Manages coordinate transformations for fpdf2 rendering.
Handles conversion from OCR pixel coordinates (top-left origin) to
PDF points. fpdf2 uses top-left origin like hOCR, so no Y-flip needed.
"""
def __init__(self, dpi: float, page_width_px: float, page_height_px: float):
"""Initialize coordinate transform."""
self.dpi = dpi
self.page_width_px = page_width_px
self.page_height_px = page_height_px
@property
def page_width_pt(self) -> float:
"""Page width in PDF points."""
return self.page_width_px * 72.0 / self.dpi
@property
def page_height_pt(self) -> float:
"""Page height in PDF points."""
return self.page_height_px * 72.0 / self.dpi
def px_to_pt(self, value: float) -> float:
"""Convert pixels to PDF points."""
return value * 72.0 / self.dpi
def bbox_to_pt(self, bbox) -> tuple[float, float, float, float]:
"""Convert BoundingBox from pixels to points."""
return (
self.px_to_pt(bbox.left),
self.px_to_pt(bbox.top),
self.px_to_pt(bbox.right),
self.px_to_pt(bbox.bottom),
)
class Fpdf2PdfRenderer:
"""Renders OcrElement trees to PDF using fpdf2.
This class provides the core rendering logic for converting OCR output
into PDF text layers using fpdf2's text drawing capabilities.
"""
def __init__(
self,
page: OcrElement,
dpi: float,
multi_font_manager: MultiFontManager,
invisible_text: bool = True,
debug_render_options: DebugRenderOptions | None = None,
):
"""Initialize renderer.
Args:
page: Root OcrElement (must be ocr_page)
dpi: Source image DPI
multi_font_manager: MultiFontManager instance
invisible_text: If True, render text as invisible (text mode 3)
debug_render_options: Options for debug visualization
Raises:
ValueError: If page is not an ocr_page or lacks a bounding box
"""
if page.ocr_class != OcrClass.PAGE:
raise ValueError("Root element must be ocr_page")
if page.bbox is None:
raise ValueError("Page must have bounding box")
self.page = page
self.dpi = dpi
self.multi_font_manager = multi_font_manager
self.invisible_text = invisible_text
self.debug_options = debug_render_options or DebugRenderOptions()
# Setup coordinate transform
self.coord_transform = CoordinateTransform(
dpi=dpi,
page_width_px=page.bbox.width,
page_height_px=page.bbox.height,
)
# Registered fonts: font_path -> fpdf_family_name
self._registered_fonts: dict[str, str] = {}
def render(self, output_path: Path) -> None:
"""Render page to PDF file.
Args:
output_path: Output PDF file path
"""
# Create PDF with custom page size
pdf = FPDF(
unit="pt",
format=(
self.coord_transform.page_width_pt,
self.coord_transform.page_height_pt,
),
)
pdf.set_auto_page_break(auto=False)
# Enable text shaping for complex scripts
pdf.set_text_shaping(True)
# Disable cell margin to ensure precise text positioning
# fpdf2's cell() adds c_margin padding by default, which shifts text
pdf.c_margin = 0
# Set text mode for invisible text
if self.invisible_text:
pdf.text_rendering_mode = TextMode.INVISIBLE
else:
pdf.text_rendering_mode = TextMode.FILL
# Render content to PDF
self.render_to_pdf(pdf)
# Write PDF
pdf.output(str(output_path))
def render_to_pdf(self, pdf: FPDF) -> None:
"""Render page content to an existing FPDF instance.
This method adds a page and renders all content. Used by both
single-page rendering and multi-page rendering.
Args:
pdf: FPDF instance to render into
"""
# Add page with correct dimensions
pdf.add_page(
format=(
self.coord_transform.page_width_pt,
self.coord_transform.page_height_pt,
)
)
# Render all paragraphs
for para in self.page.paragraphs:
self._render_paragraph(pdf, para)
# If no paragraphs, render lines directly
if not self.page.paragraphs:
for line in self.page.lines:
self._render_line(pdf, line)
def _register_font(self, pdf: FPDF, font_manager: FontManager) -> str:
"""Register font with fpdf2 if not already registered.
Args:
pdf: FPDF instance
font_manager: FontManager containing the font
Returns:
Font family name to use with pdf.set_font()
"""
font_path_str = str(font_manager.font_path)
if font_path_str not in self._registered_fonts:
# Use the font filename stem as the family name
family_name = font_manager.font_path.stem
pdf.add_font(family=family_name, fname=font_path_str)
self._registered_fonts[font_path_str] = family_name
return self._registered_fonts[font_path_str]
def _render_paragraph(self, pdf: FPDF, para: OcrElement) -> None:
"""Render a paragraph element.
Args:
pdf: FPDF instance
para: Paragraph OCR element
"""
for line in para.children:
if line.ocr_class in OcrClass.LINE_TYPES:
self._render_line(pdf, line)
def _render_line(self, pdf: FPDF, line: OcrElement) -> None:
"""Render a line element with baseline support.
Strategy (following pikepdf reference implementation):
1. Create a baseline_matrix that transforms from hOCR coordinates to
a coordinate system aligned with the text baseline
2. For each word, transform its hOCR bbox using baseline_matrix.inverse()
to get its position in the baseline coordinate system
3. Render words along the baseline with horizontal scaling
Args:
pdf: FPDF instance
line: Line OCR element
"""
if line.bbox is None:
return
# Validate line bbox
if line.bbox.height <= 0:
log.error(
"line box is invalid so we cannot render it: box=%s text=%s",
line.bbox,
line.text if hasattr(line, 'text') else '',
)
return
# Convert line bbox to PDF points
line_left_pt = self.coord_transform.px_to_pt(line.bbox.left)
line_top_pt = self.coord_transform.px_to_pt(line.bbox.top)
line_right_pt = self.coord_transform.px_to_pt(line.bbox.right)
line_bottom_pt = self.coord_transform.px_to_pt(line.bbox.bottom)
# Note: line_width_pt and line_height_pt not needed since we compute
# dimensions in the un-rotated coordinate system via matrix transform
# Debug rendering: draw line bbox (in page coordinates)
if self.debug_options.render_line_bbox:
self._render_debug_line_bbox(
pdf, line_left_pt, line_top_pt, line_right_pt, line_bottom_pt
)
# Get textangle (rotation of the entire line)
textangle = line.textangle or 0.0
# 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.
# Start at top-left corner of line bbox, then rotate by -textangle
line_size_aabb_matrix = (
Matrix()
.translated(line_left_pt, line_top_pt)
.rotated(-textangle) # textangle is counter-clockwise per hOCR spec
)
# Get the line dimensions in the un-rotated coordinate system
# Transform line bbox corners to get the un-rotated dimensions
inv_line_matrix = line_size_aabb_matrix.inverse()
# Transform bottom-right corner to get line dimensions in rotated space
_, _, line_size_width, line_size_height = transform_box(
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
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
descent_norm = descent / units_per_em
# Baseline intercept based on font metrics
intercept_pt = (
-abs(descent_norm)
* line_size_height
/ (ascent_norm + abs(descent_norm))
)
slope_angle_deg = degrees(atan(slope)) if slope != 0.0 else 0.0
# Build baseline_matrix: transforms from page coords to baseline coords
# 1. Start with line_size_aabb_matrix (translates to line corner, rotates)
# 2. Translate down to bottom of un-rotated line (line_size_height)
# 3. Apply baseline intercept offset
# 4. Rotate by baseline slope
baseline_matrix = (
line_size_aabb_matrix.translated(
0, line_size_height
) # Move to bottom of line
.translated(0, intercept_pt) # Apply baseline intercept
.rotated(slope_angle_deg) # Rotate by baseline slope
)
# Calculate font size: height from baseline to top of line
font_size = line_size_height + intercept_pt
if font_size < 1.0:
font_size = line_size_height * 0.8
# Total rotation for rendering (textangle + slope)
total_rotation_deg = -textangle + slope_angle_deg
# Debug rendering: draw baseline
if self.debug_options.render_baseline:
# Baseline starts at origin in baseline coords, extends line width
baseline_start = transform_point(baseline_matrix, 0, 0)
baseline_end = transform_point(baseline_matrix, line_size_width, 0)
pdf.set_draw_color(255, 0, 255) # Magenta
pdf.set_line_width(0.75)
pdf.line(
baseline_start[0], baseline_start[1], baseline_end[0], baseline_end[1]
)
# Extract line language for font selection
line_language = line.language
# Get inverse of baseline_matrix for transforming word bboxes
inv_baseline_matrix = baseline_matrix.inverse()
# Collect words to render
words: list[OcrElement | None] = [
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,
)
def _render_word(
self,
pdf: FPDF,
word: OcrElement,
baseline_matrix: Matrix,
inv_baseline_matrix: Matrix,
font_size: float,
rotation_deg: float,
line_language: str | None,
) -> None:
"""Render a word using word bbox positioning.
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.
Args:
pdf: FPDF instance
word: Word OCR element
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
"""
if not word.text or word.bbox is None:
return
# Select appropriate font for this word
font_manager = self.multi_font_manager.select_font_for_word(
word.text, line_language
)
# Register font with fpdf2
font_family = self._register_font(pdf, font_manager)
# 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
# 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
)
# Use line-based font_size for consistent vertical sizing
word_font_size = font_size
# Set font
pdf.set_font(font_family, size=word_font_size)
# 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
else:
scale_x = 100
# Apply horizontal stretching
pdf.set_stretching(scale_x)
# 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)
# 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)
# Adjust x position to account for lsb (scaled by horizontal stretch)
adjusted_x = page_x - lsb_pt * (scale_x / 100)
# 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
# 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)
# Reset stretching
pdf.set_stretching(100)
def _is_cjk_only(self, text: str) -> bool:
"""Check if text contains only CJK characters.
CJK scripts don't use spaces between words, so we should not insert
spaces between adjacent CJK words.
Args:
text: Text to check
Returns:
True if text contains only CJK characters
"""
for char in text:
cp = ord(char)
# Check if character is in CJK ranges
if not (
0x4E00 <= cp <= 0x9FFF # CJK Unified Ideographs
or 0x3400 <= cp <= 0x4DBF # CJK Extension A
or 0x20000 <= cp <= 0x2A6DF # CJK Extension B
or 0x2A700 <= cp <= 0x2B73F # CJK Extension C
or 0x2B740 <= cp <= 0x2B81F # CJK Extension D
or 0x2B820 <= cp <= 0x2CEAF # CJK Extension E
or 0x2CEB0 <= cp <= 0x2EBEF # CJK Extension F
or 0x30000 <= cp <= 0x3134F # CJK Extension G
or 0x3040 <= cp <= 0x309F # Hiragana
or 0x30A0 <= cp <= 0x30FF # Katakana
or 0x31F0 <= cp <= 0x31FF # Katakana Phonetic Extensions
or 0xAC00 <= cp <= 0xD7AF # Hangul Syllables
or 0x1100 <= cp <= 0x11FF # Hangul Jamo
or 0x3130 <= cp <= 0x318F # Hangul Compatibility Jamo
or 0xA960 <= cp <= 0xA97F # Hangul Jamo Extended-A
or 0xD7B0 <= cp <= 0xD7FF # Hangul Jamo Extended-B
or 0x3000 <= cp <= 0x303F # CJK Symbols and Punctuation
or 0xFF00 <= cp <= 0xFFEF # Halfwidth and Fullwidth Forms
):
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,
left: float,
top: float,
right: float,
bottom: float,
) -> None:
"""Draw a blue box around the line bbox."""
pdf.set_draw_color(0, 0, 255) # Blue
pdf.set_line_width(0.5)
pdf.rect(left, top, right - left, bottom - top)
def _render_debug_baseline(
self,
pdf: FPDF,
x: float,
y: float,
width: float,
rotation_deg: float,
) -> None:
"""Draw a magenta line along the baseline."""
pdf.set_draw_color(255, 0, 255) # Magenta
pdf.set_line_width(0.75)
if abs(rotation_deg) > 0.1:
with pdf.rotation(rotation_deg, x=x, y=y):
pdf.line(x, y, x + width, y)
else:
pdf.line(x, y, x + width, y)
def _render_debug_word_bbox(
self,
pdf: FPDF,
left: float,
top: float,
right: float,
bottom: float,
) -> None:
"""Draw a green box around the word bbox."""
pdf.set_draw_color(0, 255, 0) # Green
pdf.set_line_width(0.3)
pdf.rect(left, top, right - left, bottom - top)
class Fpdf2MultiPageRenderer:
"""Renders multiple OcrElement pages into a single PDF.
This class handles multi-page documents by delegating to Fpdf2PdfRenderer
for each page while sharing a single FPDF instance and font registration.
"""
def __init__(
self,
pages_data: list[tuple[int, OcrElement, float]],
multi_font_manager: MultiFontManager,
invisible_text: bool = True,
debug_render_options: DebugRenderOptions | None = None,
):
"""Initialize multi-page renderer.
Args:
pages_data: List of (pageno, ocr_tree, dpi) tuples
multi_font_manager: Shared multi-font manager for all pages
invisible_text: Whether to render invisible text
debug_render_options: Options for debug visualization
"""
self.pages_data = pages_data
self.multi_font_manager = multi_font_manager
self.invisible_text = invisible_text
self.debug_options = debug_render_options or DebugRenderOptions()
def render(self, output_path: Path) -> None:
"""Render all pages to a single multi-page PDF.
Args:
output_path: Output PDF file path
"""
if not self.pages_data:
raise ValueError("No pages to render")
# Create PDF (page size will be set per-page)
pdf = FPDF(unit="pt")
pdf.set_auto_page_break(auto=False)
pdf.set_text_shaping(True)
# Disable cell margin to ensure precise text positioning
# fpdf2's cell() adds c_margin padding by default, which shifts text
pdf.c_margin = 0
# Set text mode for invisible text
if self.invisible_text:
pdf.text_rendering_mode = TextMode.INVISIBLE
else:
pdf.text_rendering_mode = TextMode.FILL
# Shared font registration across all pages
shared_registered_fonts: dict[str, str] = {}
# Render each page using Fpdf2PdfRenderer
for _pageno, page, dpi in self.pages_data:
if page.bbox is None:
continue
# Create a renderer for this page
page_renderer = Fpdf2PdfRenderer(
page=page,
dpi=dpi,
multi_font_manager=self.multi_font_manager,
invisible_text=self.invisible_text,
debug_render_options=self.debug_options,
)
# Share font registration to avoid re-registering fonts
page_renderer._registered_fonts = shared_registered_fonts
# Render page content to the shared PDF
page_renderer.render_to_pdf(pdf)
# Write PDF
pdf.output(str(output_path))
+2 -10
View File
@@ -25,7 +25,6 @@ from typing import (
import img2pdf
import pikepdf
from deprecation import deprecated
log = logging.getLogger(__name__)
@@ -136,11 +135,6 @@ class Resolution(Generic[T]):
return self._isclose(self.x, other.x) and self._isclose(self.y, other.y)
@deprecated(deprecated_in='15.4.0')
class NeverRaise(Exception):
"""An exception that is never raised."""
def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike) -> None:
"""Create a symbolic link at ``soft_link_name``, which references ``input_file``.
@@ -200,7 +194,7 @@ def is_iterable_notstr(thing: Any) -> bool:
def monotonic(seq: Sequence) -> bool:
"""Does this sequence increase monotonically?"""
return all(b > a for a, b in zip(seq, seq[1:]))
return all(b > a for a, b in zip(seq, seq[1:], strict=False))
def page_number(input_file: os.PathLike) -> int:
@@ -298,9 +292,7 @@ def check_pdf(input_file: Path) -> bool:
if linearize_msgs:
log.warning(linearize_msgs)
if success and not linearize_msgs:
return True
return False
return bool(success and not linearize_msgs)
def clamp(n: T, smallest: T, largest: T) -> T:
+39 -9
View File
@@ -1,18 +1,48 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-FileCopyrightText: 2023-2025 James R. Barlow
# SPDX-License-Identifier: MIT
"""Transform .hocr and page image to text PDF."""
"""Transform OCR output to text-only PDFs.
This package provides tools for:
1. Parsing OCR output (hOCR format) into generic OcrElement structures
2. Rendering OcrElement structures to searchable PDF text layers
The architecture separates parsing from rendering, allowing:
- Support for multiple OCR input formats (hOCR, ALTO, custom engines)
- Independent improvements to text rendering
- Reuse of the OcrElement data model for other purposes
Main components:
- OcrElement: Generic dataclass representing OCR output structure
- HocrParser: Parses hOCR files into OcrElement trees
- Fpdf2PdfRenderer: Renders OcrElement trees to PDF text layers (via fpdf2)
For PDF rendering, use the fpdf2_renderer module:
from ocrmypdf.fpdf_renderer import Fpdf2PdfRenderer, DebugRenderOptions
"""
from __future__ import annotations
from ocrmypdf.hocrtransform._hocr import (
DebugRenderOptions,
HocrTransform,
HocrTransformError,
from ocrmypdf.hocrtransform.hocr_parser import (
HocrParseError,
HocrParser,
)
from ocrmypdf.models.ocr_element import (
Baseline,
BoundingBox,
FontInfo,
OcrClass,
OcrElement,
)
__all__ = (
'HocrTransform',
'HocrTransformError',
'DebugRenderOptions',
# hOCR parsing
'HocrParser',
'HocrParseError',
# OCR element data model
'OcrElement',
'OcrClass',
'BoundingBox',
'Baseline',
'FontInfo',
)
+43 -9
View File
@@ -1,11 +1,15 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-FileCopyrightText: 2023-2025 James R. Barlow
# SPDX-License-Identifier: MIT
"""Simple CLI for testing HOCR."""
"""Simple CLI for testing HOCR to PDF conversion using fpdf2 renderer."""
from __future__ import annotations
import argparse
from pathlib import Path
from ocrmypdf.hocrtransform import HocrTransform
from ocrmypdf.font import MultiFontManager
from ocrmypdf.fpdf_renderer import DebugRenderOptions, Fpdf2PdfRenderer
from ocrmypdf.hocrtransform.hocr_parser import HocrParser
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Convert hocr file to PDF')
@@ -14,7 +18,7 @@ if __name__ == "__main__":
'--boundingboxes',
action="store_true",
default=False,
help='Show bounding boxes borders',
help='Show bounding boxes borders (debug mode)',
)
parser.add_argument(
'-r',
@@ -27,14 +31,44 @@ if __name__ == "__main__":
'-i',
'--image',
default=None,
help='Path to the image to be placed above the text',
help='Path to the image to be placed above the text (not yet supported)',
)
parser.add_argument('hocrfile', help='Path to the hocr file to be parsed')
parser.add_argument('outputfile', help='Path to the PDF file to be generated')
args = parser.parse_args()
hocr = HocrTransform(hocr_filename=args.hocrfile, dpi=args.resolution)
hocr.to_pdf(
out_filename=args.outputfile,
image_filename=args.image,
# Parse hOCR file
hocr_parser = HocrParser(args.hocrfile)
ocr_page = hocr_parser.parse()
# Use DPI from hOCR if available, otherwise use command-line resolution
dpi = ocr_page.dpi or args.resolution
# Setup debug render options if requested
debug_options = None
if args.boundingboxes:
debug_options = DebugRenderOptions(
render_line_bbox=True,
render_word_bbox=True,
render_baseline=True,
)
# Create multi-font manager with default font directory
font_dir = Path(__file__).parent.parent / "data"
multi_font_manager = MultiFontManager(font_dir)
# Render to PDF using fpdf2
renderer = Fpdf2PdfRenderer(
page=ocr_page,
dpi=dpi,
multi_font_manager=multi_font_manager,
invisible_text=not args.boundingboxes, # Visible text in debug mode
debug_render_options=debug_options,
)
renderer.render(Path(args.outputfile))
if args.image:
print(
f"Warning: Image overlay (--image {args.image}) is not yet supported "
"with the fpdf2 renderer."
)
-141
View File
@@ -1,141 +0,0 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
import logging
import unicodedata
import zlib
from importlib.resources import files as package_files
from pikepdf import (
Dictionary,
Name,
Pdf,
)
from pikepdf.canvas import Font
log = logging.getLogger(__name__)
class EncodableFont(Font):
def text_encode(self, text: str) -> bytes:
raise NotImplementedError()
class GlyphlessFont(EncodableFont):
CID_TO_GID_DATA = zlib.compress(b"\x00\x01" * 65536)
GLYPHLESS_FONT_NAME = 'pdf.ttf'
GLYPHLESS_FONT = (package_files('ocrmypdf.data') / GLYPHLESS_FONT_NAME).read_bytes()
CHAR_ASPECT = 2
def __init__(self):
pass
def text_width(self, text: str, fontsize: float) -> float:
"""Estimate the width of a text string when rendered with the given font."""
# NFKC: split ligatures, combine diacritics
return len(unicodedata.normalize("NFKC", text)) * (fontsize / self.CHAR_ASPECT)
def text_encode(self, text: str) -> bytes:
return text.encode('utf-16be')
def register(self, pdf: Pdf):
"""Register the glyphless font.
Create several data structures in the Pdf to describe the font. While it create
the data, a reference should be set in at least one page's /Resources dictionary
to retain the font in the output PDF and ensure it is usable on that page.
"""
PLACEHOLDER = Name.Placeholder
basefont = pdf.make_indirect(
Dictionary(
BaseFont=Name.GlyphLessFont,
DescendantFonts=[PLACEHOLDER],
Encoding=Name("/Identity-H"),
Subtype=Name.Type0,
ToUnicode=PLACEHOLDER,
Type=Name.Font,
)
)
cid_font_type2 = pdf.make_indirect(
Dictionary(
BaseFont=Name.GlyphLessFont,
CIDToGIDMap=PLACEHOLDER,
CIDSystemInfo=Dictionary(
Ordering="Identity",
Registry="Adobe",
Supplement=0,
),
FontDescriptor=PLACEHOLDER,
Subtype=Name.CIDFontType2,
Type=Name.Font,
DW=1000 // self.CHAR_ASPECT,
)
)
basefont.DescendantFonts = [cid_font_type2]
cid_font_type2.CIDToGIDMap = pdf.make_stream(
self.CID_TO_GID_DATA, Filter=Name.FlateDecode
)
basefont.ToUnicode = pdf.make_stream(
b"/CIDInit /ProcSet findresource begin\n"
b"12 dict begin\n"
b"begincmap\n"
b"/CIDSystemInfo\n"
b"<<\n"
b" /Registry (Adobe)\n"
b" /Ordering (UCS)\n"
b" /Supplement 0\n"
b">> def\n"
b"/CMapName /Adobe-Identify-UCS def\n"
b"/CMapType 2 def\n"
b"1 begincodespacerange\n"
b"<0000> <FFFF>\n"
b"endcodespacerange\n"
b"1 beginbfrange\n"
b"<0000> <FFFF> <0000>\n"
b"endbfrange\n"
b"endcmap\n"
b"CMapName currentdict /CMap defineresource pop\n"
b"end\n"
b"end\n"
)
font_descriptor = pdf.make_indirect(
Dictionary(
Ascent=1000,
CapHeight=1000,
Descent=-1,
Flags=5, # Fixed pitch and symbolic
FontBBox=[0, 0, 1000 // self.CHAR_ASPECT, 1000],
FontFile2=PLACEHOLDER,
FontName=Name.GlyphLessFont,
ItalicAngle=0,
StemV=80,
Type=Name.FontDescriptor,
)
)
font_descriptor.FontFile2 = pdf.make_stream(self.GLYPHLESS_FONT)
cid_font_type2.FontDescriptor = font_descriptor
return basefont
class Courier(EncodableFont):
"""Courier font."""
def text_width(self, text: str, fontsize: float) -> float:
"""Estimate the width of a text string when rendered with the given font."""
return len(text) * fontsize
def text_encode(self, text: str) -> bytes:
return text.encode('pdfdoc', errors='ignore')
def register(self, pdf: Pdf) -> Dictionary:
"""Register the font."""
return pdf.make_indirect(
Dictionary(
BaseFont=Name.Courier,
Type=Name.Font,
Subtype=Name.Type1,
)
)
-540
View File
@@ -1,540 +0,0 @@
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-FileCopyrightText: 2025 Odin Dahlström
# SPDX-License-Identifier: MIT
"""hOCR transform implementation."""
from __future__ import annotations
import logging
import os
import re
import unicodedata
from dataclasses import dataclass
from itertools import pairwise
from math import atan, pi
from pathlib import Path
from xml.etree import ElementTree
from pikepdf import Matrix, Name, Rectangle
from pikepdf.canvas import (
BLACK,
BLUE,
CYAN,
DARKGREEN,
GREEN,
MAGENTA,
RED,
Canvas,
Text,
TextDirection,
)
from ocrmypdf.hocrtransform._font import EncodableFont as Font
from ocrmypdf.hocrtransform._font import GlyphlessFont
log = logging.getLogger(__name__)
INCH = 72.0
Element = ElementTree.Element
@dataclass
class DebugRenderOptions:
"""A class for managing rendering options."""
render_paragraph_bbox: bool = False
render_baseline: bool = False
render_triangle: bool = False
render_line_bbox: bool = False
render_word_bbox: bool = False
render_space_bbox: bool = False
class HocrTransformError(Exception):
"""Error while applying hOCR transform."""
class HocrTransform:
"""A class for converting documents from the hOCR format.
For details of the hOCR format, see:
http://kba.github.io/hocr-spec/1.2/.
"""
box_pattern = re.compile(
r'''
bbox \s+
(\d+) \s+ # left: uint
(\d+) \s+ # top: uint
(\d+) \s+ # right: uint
(\d+) # bottom: uint
''',
re.VERBOSE,
)
baseline_pattern = re.compile(
r'''
baseline \s+
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
([\-\+]?\d+) # +/- int
''',
re.VERBOSE,
)
textangle_pattern = re.compile(
r'''
textangle \s+
([\-\+]?\d*\.?\d*) # +/- decimal float
''',
re.VERBOSE,
)
def __init__(
self,
*,
hocr_filename: str | Path,
dpi: float,
debug: bool = False,
fontname: Name = Name("/f-0-0"),
font: Font = GlyphlessFont(),
debug_render_options: DebugRenderOptions | None = None,
):
"""Initialize the HocrTransform object."""
if debug:
log.warning("Use debug_render_options instead", DeprecationWarning)
self.render_options = DebugRenderOptions(
render_baseline=debug,
render_triangle=debug,
render_line_bbox=False,
render_word_bbox=debug,
render_paragraph_bbox=False,
render_space_bbox=False,
)
else:
self.render_options = debug_render_options or DebugRenderOptions()
self.dpi = dpi
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
self._fontname = fontname
self._font = font
# if the hOCR file has a namespace, ElementTree requires its use to
# find elements
matches = re.match(r'({.*})html', self.hocr.getroot().tag)
self.xmlns = ''
if matches:
self.xmlns = matches.group(1)
for div in self.hocr.findall(self._child_xpath('div', 'ocr_page')):
coords = self.element_coordinates(div)
if not coords:
raise HocrTransformError("hocr file is missing page dimensions")
self.width = (coords.urx - coords.llx) / (self.dpi / INCH)
self.height = (coords.ury - coords.lly) / (self.dpi / INCH)
# Stop after first div that has page coordinates
break
def _get_element_text(self, element: Element) -> str:
"""Return the textual content of the element and its children."""
text = element.text if element.text is not None else ''
for child in element:
text += self._get_element_text(child)
text += element.tail if element.tail is not None else ''
return text
@classmethod
def element_coordinates(cls, element: Element) -> Rectangle | None:
"""Get coordinates of the bounding box around an element."""
matches = cls.box_pattern.search(element.attrib.get('title', ''))
if not matches:
return None
return Rectangle(
float(matches.group(1)), # llx = left
float(matches.group(2)), # lly = top
float(matches.group(3)), # urx = right
float(matches.group(4)), # ury = bottom
)
@classmethod
def baseline(cls, element: Element) -> tuple[float, float]:
"""Get baseline's slope and intercept."""
matches = cls.baseline_pattern.search(element.attrib.get('title', ''))
if not matches:
return (0.0, 0.0)
return float(matches.group(1)), int(matches.group(2))
@classmethod
def textangle(cls, element: Element) -> float:
"""Get text angle of an element."""
matches = cls.textangle_pattern.search(element.attrib.get('title', ''))
if not matches:
return 0.0
return float(matches.group(1))
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
xpath = f".//{self.xmlns}{html_tag}"
if html_class:
xpath += f"[@class='{html_class}']"
return xpath
@classmethod
def normalize_text(cls, s: str) -> str:
"""Normalize the given text using the NFKC normalization form."""
return unicodedata.normalize("NFKC", s)
def to_pdf(
self,
*,
out_filename: Path,
image_filename: Path | None = None,
invisible_text: bool = True,
) -> None:
"""Creates a PDF file with an image superimposed on top of the text.
Text is positioned according to the bounding box of the lines in
the hOCR file.
The image need not be identical to the image used to create the hOCR
file.
It can have a lower resolution, different color mode, etc.
Arguments:
out_filename: Path of PDF to write.
image_filename: Image to use for this file. If omitted, the OCR text
is shown.
invisible_text: If True, text is rendered invisible so that is
selectable but never drawn. If False, text is visible and may
be seen if the image is skipped or deleted in Acrobat.
"""
# create the PDF file
# page size in points (1/72 in.)
canvas = Canvas(page_size=(self.width, self.height))
canvas.add_font(self._fontname, self._font)
page_matrix = (
Matrix()
.translated(0, self.height)
.scaled(1, -1)
.scaled(INCH / self.dpi, INCH / self.dpi)
)
log.debug(page_matrix)
with canvas.do.save_state(cm=page_matrix):
self._debug_draw_paragraph_boxes(canvas)
found_lines = False
for par in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
for line in (
element
for element in par.iterfind(self._child_xpath('span'))
if 'class' in element.attrib
and element.attrib['class']
in {'ocr_header', 'ocr_line', 'ocr_textfloat', 'ocr_caption'}
):
found_lines = True
direction = self._get_text_direction(par)
inject_word_breaks = self._get_inject_word_breaks(par)
self._do_line(
canvas,
line,
"ocrx_word",
invisible_text,
direction,
inject_word_breaks,
)
if not found_lines:
# Tesseract did not report any lines (just words)
root = self.hocr.find(self._child_xpath('div', 'ocr_page'))
direction = self._get_text_direction(root)
self._do_line(
canvas,
root,
"ocrx_word",
invisible_text,
direction,
True,
)
# put the image on the page, scaled to fill the page
if image_filename is not None:
canvas.do.draw_image(
image_filename, 0, 0, width=self.width, height=self.height
)
# finish up the page and save it
canvas.to_pdf().save(out_filename)
def _get_text_direction(self, par):
"""Get the text direction of the paragraph.
Arabic, Hebrew, Persian, are right-to-left languages.
When the paragraph element is None, defaults to left-to-right.
"""
if par is None:
return TextDirection.LTR
return (
TextDirection.RTL
if par.attrib.get('dir', 'ltr') == 'rtl'
else TextDirection.LTR
)
def _get_inject_word_breaks(self, par):
"""Determine whether word breaks should be injected.
In Chinese, Japanese, and Korean, word breaks are not injected, because
words are usually one or two characters and separators are usually explicit.
In all other languages, we inject word breaks to help word segmentation.
"""
lang = par.attrib.get('lang', '')
log.debug(lang)
if lang in {'chi_sim', 'chi_tra', 'jpn', 'kor'}:
return False
return True
@classmethod
def polyval(cls, poly, x): # pragma: no cover
"""Calculate the value of a polynomial at a point."""
return x * poly[0] + poly[1]
def _do_line(
self,
canvas: Canvas,
line: Element | None,
elemclass: str,
invisible_text: bool,
text_direction: TextDirection,
inject_word_breaks: bool,
):
"""Render the text for a given line.
The canvas's coordinate system must be configured so that hOCR pixel
coordinates are mapped to PDF coordinates.
"""
if line is None:
return
# line_min_aabb (which is created from the "bbox" hOCR property) is so named
# because a Rectangle instance is always an AABB (it has no orientation).
# However, this means that for non-zero values of the "textangle" hOCR
# property, line_min_aabb is not the true bounding box of the hOCR line,
# but rather the minimum AABB that encloses the bounding box of the line.
# The true bounding box of the line must be seen as an OBB, due to the
# existance of the "textangle" hOCR property.
line_min_aabb = self.element_coordinates(line)
if not line_min_aabb:
return
if line_min_aabb.ury <= line_min_aabb.lly:
log.error(
"line box is invalid so we cannot render it: box=%s text=%s",
line_min_aabb,
self._get_element_text(line),
)
return
self._debug_draw_line_bbox(canvas, line_min_aabb)
# Even though line_min_aabb is not the true bounding box of the line,
# it is still possible to derive an AABB (Rectangle) from it that is
# the same size as the true bounding box of the line,
# if we use a coordinate system that is axis-aligned with respect to
# the rotation of the OBB (textangle).
# line_size_aabb_matrix is a transform matrix for such a coordinate
# system, and line_size_aabb is thus an AABB with the same
# size as the true bounding box of the line.
top_left_corner = (line_min_aabb.llx, line_min_aabb.lly)
line_size_aabb_matrix = (
Matrix()
.translated(*top_left_corner)
# Note: negative sign (textangle is counter-clockwise, see hOCR spec)
.rotated(-self.textangle(line))
)
line_size_aabb = line_size_aabb_matrix.inverse().transform(line_min_aabb)
slope, intercept = self.baseline(line)
if abs(slope) < 0.005:
slope = 0.0
slope_angle = atan(slope)
# Final PDF-perspective (bottom-left corner) transform matrix for the
# text baseline, which has an intercept and slope relative to the OBB.
# See "bbox", "textangle" and "baseline" in the hOCR spec for more details.
baseline_matrix = (
line_size_aabb_matrix
# Translate from hOCR perspective (top-left corner) to PDF perspective
# (bottom-left corner).
# Note: it would be incorrect to use line_min_aabb.height here because
# it is not the true height of the OBB of the line, if textangle != 0.
.translated(0, line_size_aabb.height)
.translated(0, intercept)
.rotated(slope_angle / pi * 180)
)
with canvas.do.save_state(cm=baseline_matrix):
text = Text(direction=text_direction)
fontsize = line_size_aabb.height + intercept
text.font(self._fontname, fontsize)
text.render_mode(3 if invisible_text else 0)
self._debug_draw_baseline(
canvas, baseline_matrix.inverse().transform(line_min_aabb), 0
)
canvas.do.fill_color(BLACK) # text in black
elements = line.findall(self._child_xpath('span', elemclass))
for elem, next_elem in pairwise(elements + [None]):
self._do_line_word(
canvas,
baseline_matrix,
text,
fontsize,
elem,
next_elem,
text_direction,
inject_word_breaks,
)
canvas.do.draw_text(text)
def _do_line_word(
self,
canvas: Canvas,
line_matrix: Matrix,
text: Text,
fontsize: float,
elem: Element | None,
next_elem: Element | None,
text_direction: TextDirection,
inject_word_breaks: bool,
):
"""Render the text for a single word."""
if elem is None:
return
elemtxt = self.normalize_text(self._get_element_text(elem).strip())
if elemtxt == '':
return
hocr_box = self.element_coordinates(elem)
if hocr_box is None:
return
box = line_matrix.inverse().transform(hocr_box)
font_width = self._font.text_width(elemtxt, fontsize)
# Debug sketches
self._debug_draw_word_triangle(canvas, box)
self._debug_draw_word_bbox(canvas, box)
# If this word is 0 units wide, our best bet seems to be to suppress this text
if text_direction == TextDirection.RTL:
log.info("RTL: %s", elemtxt)
if font_width > 0:
if text_direction == TextDirection.LTR:
text.text_transform(Matrix(1, 0, 0, -1, box.llx, 0))
elif text_direction == TextDirection.RTL:
text.text_transform(Matrix(-1, 0, 0, -1, box.llx + box.width, 0))
text.horiz_scale(100 * box.width / font_width)
text.show(self._font.text_encode(elemtxt))
# Get coordinates of the next word (if there is one)
hocr_next_box = (
self.element_coordinates(next_elem) if next_elem is not None else None
)
if hocr_next_box is None:
return
# Render a space between this word and the next word. The explicit space helps
# PDF viewers identify the word break, and horizontally scaling it to
# occupy the space the between the words helps the PDF viewer
# avoid combiningthewordstogether.
if not inject_word_breaks:
return
next_box = line_matrix.inverse().transform(hocr_next_box)
if text_direction == TextDirection.LTR:
space_box = Rectangle(box.urx, box.lly, next_box.llx, next_box.ury)
elif text_direction == TextDirection.RTL:
space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury)
self._debug_draw_space_bbox(canvas, space_box)
space_width = self._font.text_width(' ', fontsize)
if space_width > 0 and space_box.width > 0:
if text_direction == TextDirection.LTR:
text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0))
elif text_direction == TextDirection.RTL:
text.text_transform(
Matrix(-1, 0, 0, -1, space_box.llx + space_box.width, 0)
)
text.horiz_scale(100 * space_box.width / space_width)
text.show(self._font.text_encode(' '))
def _debug_draw_paragraph_boxes(self, canvas: Canvas, color=CYAN):
"""Draw boxes around paragraphs in the document."""
if not self.render_options.render_paragraph_bbox: # pragma: no cover
return
with canvas.do.save_state():
# draw box around paragraph
canvas.do.stroke_color(color).line_width(0.1)
for elem in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
elemtxt = self._get_element_text(elem).strip()
if len(elemtxt) == 0:
continue
ocr_par = self.element_coordinates(elem)
if ocr_par is None:
continue
canvas.do.rect(
ocr_par.llx, ocr_par.lly, ocr_par.width, ocr_par.height, fill=False
)
def _debug_draw_line_bbox(self, canvas: Canvas, line_box: Rectangle, color=BLUE):
"""Render the bounding box of a text line."""
if not self.render_options.render_line_bbox: # pragma: no cover
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(0.15).rect(
line_box.llx, line_box.lly, line_box.width, line_box.height, fill=False
)
def _debug_draw_word_triangle(
self, canvas: Canvas, box: Rectangle, color=RED, line_width=0.1
):
"""Render a triangle that conveys word height and drawing direction."""
if not self.render_options.render_triangle: # pragma: no cover
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(line_width).line(
box.llx, box.lly, box.urx, box.lly
).line(box.urx, box.lly, box.llx, box.ury).line(
box.llx, box.lly, box.llx, box.ury
)
def _debug_draw_word_bbox(
self, canvas: Canvas, box: Rectangle, color=GREEN, line_width=0.1
):
"""Render a box depicting the word."""
if not self.render_options.render_word_bbox: # pragma: no cover
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(line_width).rect(
box.llx, box.lly, box.width, box.height, fill=False
)
def _debug_draw_space_bbox(
self, canvas: Canvas, box: Rectangle, color=DARKGREEN, line_width=0.1
):
"""Render a box depicting the space between two words."""
if not self.render_options.render_space_bbox: # pragma: no cover
return
with canvas.do.save_state():
canvas.do.fill_color(color).line_width(line_width).rect(
box.llx, box.lly, box.width, box.height, fill=True
)
def _debug_draw_baseline(
self,
canvas: Canvas,
line_box: Rectangle,
baseline_lly,
color=MAGENTA,
line_width=0.25,
):
"""Render the text baseline."""
if not self.render_options.render_baseline:
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(line_width).line(
line_box.llx,
baseline_lly,
line_box.urx,
baseline_lly,
)
+521
View File
@@ -0,0 +1,521 @@
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
# SPDX-FileCopyrightText: 2023-2025 James R. Barlow
# SPDX-License-Identifier: MIT
"""Parser for hOCR format files.
This module provides functionality to parse hOCR files (HTML-based OCR format)
and convert them to the engine-agnostic OcrElement tree structure.
For details of the hOCR format, see:
http://kba.github.io/hocr-spec/1.2/
"""
from __future__ import annotations
import logging
import os
import re
import unicodedata
from pathlib import Path
from typing import Literal, cast
from xml.etree import ElementTree as ET
from ocrmypdf.models.ocr_element import (
Baseline,
BoundingBox,
FontInfo,
OcrClass,
OcrElement,
)
TextDirection = Literal["ltr", "rtl"]
log = logging.getLogger(__name__)
Element = ET.Element
class HocrParseError(Exception):
"""Error while parsing hOCR file."""
class HocrParser:
"""Parser for hOCR format files.
Converts hOCR XML/HTML files into OcrElement trees.
The hOCR format uses HTML with special class attributes (ocr_page, ocr_line,
ocrx_word, etc.) and a title attribute containing properties like bbox,
baseline, and confidence scores.
"""
# Regex patterns for parsing hOCR title attributes
_bbox_pattern = re.compile(
r'''
bbox \s+
(\d+) \s+ # left: uint
(\d+) \s+ # top: uint
(\d+) \s+ # right: uint
(\d+) # bottom: uint
''',
re.VERBOSE,
)
_baseline_pattern = re.compile(
r'''
baseline \s+
([\-\+]?\d*\.?\d*) \s+ # slope: +/- decimal float
([\-\+]?\d+) # intercept: +/- int
''',
re.VERBOSE,
)
_textangle_pattern = re.compile(
r'''
textangle \s+
([\-\+]?\d*\.?\d*) # angle: +/- decimal float
''',
re.VERBOSE,
)
_x_wconf_pattern = re.compile(
r'''
x_wconf \s+
(\d+) # confidence: uint (0-100)
''',
re.VERBOSE,
)
_x_fsize_pattern = re.compile(
r'''
x_fsize \s+
(\d*\.?\d+) # font size: float
''',
re.VERBOSE,
)
_x_font_pattern = re.compile(
r'''
x_font \s+
([^\s;]+) # font name: non-whitespace, non-semicolon string
''',
re.VERBOSE,
)
_ppageno_pattern = re.compile(
r'''
ppageno \s+
(\d+) # page number: uint
''',
re.VERBOSE,
)
_scan_res_pattern = re.compile(
r'''
scan_res \s+
(\d+) \s+ # x resolution
(\d+) # y resolution
''',
re.VERBOSE,
)
def __init__(self, hocr_file: str | Path):
"""Initialize the parser with an hOCR file.
Args:
hocr_file: Path to the hOCR file to parse
Raises:
HocrParseError: If the file cannot be parsed
"""
self._hocr_path = Path(hocr_file)
try:
self._tree = ET.parse(os.fspath(hocr_file))
except ET.ParseError as e:
raise HocrParseError(f"Failed to parse hOCR file: {e}") from e
# Detect XML namespace
root_tag = self._tree.getroot().tag
matches = re.match(r'({.*})html', root_tag)
self._xmlns = matches.group(1) if matches else ''
def parse(self) -> OcrElement:
"""Parse the hOCR file and return an OcrElement tree.
Returns:
The root OcrElement (ocr_page) containing the document structure
Raises:
HocrParseError: If no ocr_page element is found
"""
# Find the first ocr_page element
page_div = self._tree.find(self._xpath('div', 'ocr_page'))
if page_div is None:
raise HocrParseError("No ocr_page element found in hOCR file")
return self._parse_page(page_div)
def _xpath(self, html_tag: str, html_class: str | None = None) -> str:
"""Build an XPath expression for finding elements.
Args:
html_tag: HTML tag name (e.g., 'div', 'span', 'p')
html_class: Optional class attribute to match
Returns:
XPath expression string
"""
xpath = f".//{self._xmlns}{html_tag}"
if html_class:
xpath += f"[@class='{html_class}']"
return xpath
def _parse_page(self, page_elem: Element) -> OcrElement:
"""Parse an ocr_page element.
Args:
page_elem: The XML element with class="ocr_page"
Returns:
OcrElement representing the page
"""
title = page_elem.attrib.get('title', '')
bbox = self._parse_bbox(title)
if bbox is None:
raise HocrParseError("ocr_page missing bbox")
# Parse page-level properties
page_number = self._parse_ppageno(title)
dpi = self._parse_scan_res(title)
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=bbox,
page_number=page_number,
dpi=dpi,
)
# Parse child paragraphs
for par_elem in page_elem.iterfind(self._xpath('p', 'ocr_par')):
paragraph = self._parse_paragraph(par_elem)
if paragraph is not None:
page.children.append(paragraph)
# If no paragraphs found, check for words directly under page
# (some Tesseract output structures)
if not page.children:
for word_elem in page_elem.iterfind(self._xpath('span', 'ocrx_word')):
word = self._parse_word(word_elem)
if word is not None:
page.children.append(word)
return page
def _parse_paragraph(self, par_elem: Element) -> OcrElement | None:
"""Parse an ocr_par element.
Args:
par_elem: The XML element with class="ocr_par"
Returns:
OcrElement representing the paragraph, or None if empty
"""
title = par_elem.attrib.get('title', '')
bbox = self._parse_bbox(title)
# Get direction and language from attributes
dir_attr = par_elem.attrib.get('dir')
direction: TextDirection | None = (
cast(TextDirection, dir_attr) if dir_attr in ('ltr', 'rtl') else None
)
language = par_elem.attrib.get('lang')
paragraph = OcrElement(
ocr_class=OcrClass.PARAGRAPH,
bbox=bbox,
direction=direction,
language=language,
)
# Parse child lines
line_classes = {
'ocr_line',
'ocr_header',
'ocr_footer',
'ocr_caption',
'ocr_textfloat',
}
for span_elem in par_elem.iterfind(self._xpath('span')):
elem_class = span_elem.attrib.get('class', '')
if elem_class in line_classes:
line = self._parse_line(span_elem, elem_class, direction, language)
if line is not None:
paragraph.children.append(line)
# Return None if paragraph is empty
if not paragraph.children:
return None
return paragraph
def _parse_line(
self,
line_elem: Element,
ocr_class: str,
parent_direction: TextDirection | None,
parent_language: str | None,
) -> OcrElement | None:
"""Parse a line element (ocr_line, ocr_header, etc.).
Args:
line_elem: The XML element representing the line
ocr_class: The hOCR class of the line
parent_direction: Text direction inherited from parent
parent_language: Language inherited from parent
Returns:
OcrElement representing the line, or None if empty
"""
title = line_elem.attrib.get('title', '')
bbox = self._parse_bbox(title)
if bbox is None:
return None
baseline = self._parse_baseline(title)
textangle = self._parse_textangle(title)
# Inherit direction and language from parent if not specified
dir_attr = line_elem.attrib.get('dir')
if dir_attr in ('ltr', 'rtl'):
direction: TextDirection | None = cast(TextDirection, dir_attr)
else:
direction = parent_direction
language = line_elem.attrib.get('lang') or parent_language
line = OcrElement(
ocr_class=ocr_class,
bbox=bbox,
baseline=baseline,
textangle=textangle,
direction=direction,
language=language,
)
# Parse child words
for word_elem in line_elem.iterfind(self._xpath('span', 'ocrx_word')):
word = self._parse_word(word_elem)
if word is not None:
line.children.append(word)
# Return None if line has no words
if not line.children:
return None
return line
def _parse_word(self, word_elem: Element) -> OcrElement | None:
"""Parse an ocrx_word element.
Args:
word_elem: The XML element with class="ocrx_word"
Returns:
OcrElement representing the word, or None if empty
"""
title = word_elem.attrib.get('title', '')
bbox = self._parse_bbox(title)
# Get the text content
text = self._get_element_text(word_elem)
text = self._normalize_text(text)
if not text:
return None
# Parse confidence (x_wconf is 0-100, convert to 0.0-1.0)
confidence = self._parse_x_wconf(title)
if confidence is not None:
confidence = confidence / 100.0
# Parse font info
font = self._parse_font_info(title)
return OcrElement(
ocr_class=OcrClass.WORD,
bbox=bbox,
text=text,
confidence=confidence,
font=font,
)
def _get_element_text(self, element: Element) -> str:
"""Get the full text content of an element including children.
Args:
element: XML element
Returns:
Combined text content
"""
text = element.text if element.text is not None else ''
for child in element:
text += self._get_element_text(child)
text += element.tail if element.tail is not None else ''
return text
@staticmethod
def _normalize_text(text: str) -> str:
"""Normalize text using NFKC normalization.
This splits ligatures and combines diacritics.
Args:
text: Raw text
Returns:
Normalized text, stripped of leading/trailing whitespace
"""
return unicodedata.normalize("NFKC", text).strip()
def _parse_bbox(self, title: str) -> BoundingBox | None:
"""Parse a bbox from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
BoundingBox or None if not found
"""
match = self._bbox_pattern.search(title)
if not match:
return None
try:
return BoundingBox(
left=float(match.group(1)),
top=float(match.group(2)),
right=float(match.group(3)),
bottom=float(match.group(4)),
)
except ValueError:
return None
def _parse_baseline(self, title: str) -> Baseline | None:
"""Parse baseline from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
Baseline or None if not found
"""
match = self._baseline_pattern.search(title)
if not match:
return None
try:
return Baseline(
slope=float(match.group(1)) if match.group(1) else 0.0,
intercept=float(match.group(2)),
)
except ValueError:
return None
def _parse_textangle(self, title: str) -> float | None:
"""Parse textangle from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
Angle in degrees or None if not found
"""
match = self._textangle_pattern.search(title)
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
def _parse_x_wconf(self, title: str) -> float | None:
"""Parse word confidence from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
Confidence (0-100) or None if not found
"""
match = self._x_wconf_pattern.search(title)
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
def _parse_ppageno(self, title: str) -> int | None:
"""Parse physical page number from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
Page number or None if not found
"""
match = self._ppageno_pattern.search(title)
if not match:
return None
try:
return int(match.group(1))
except ValueError:
return None
def _parse_scan_res(self, title: str) -> float | None:
"""Parse scan resolution (DPI) from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
DPI (using first value if x and y differ) or None if not found
"""
match = self._scan_res_pattern.search(title)
if not match:
return None
try:
# Use the first (x) resolution value
return float(match.group(1))
except ValueError:
return None
def _parse_font_info(self, title: str) -> FontInfo | None:
"""Parse font information from an hOCR title attribute.
Args:
title: The title attribute value
Returns:
FontInfo or None if no font info found
"""
font_match = self._x_font_pattern.search(title)
size_match = self._x_fsize_pattern.search(title)
if not font_match and not size_match:
return None
return FontInfo(
name=font_match.group(1) if font_match else None,
size=float(size_match.group(1)) if size_match else None,
)
+4 -5
View File
@@ -60,11 +60,10 @@ def _calculate_downsample(
elif size[1] == 0:
size = min(size[0], max_size[0]), 1
if max_pixels is not None:
if size[0] * size[1] > max_pixels:
log.debug("Resizing image to fit image pixel limit")
pixels_factor = sqrt(max_pixels / (size[0] * size[1]))
size = floor(size[0] * pixels_factor), floor(size[1] * pixels_factor)
if max_pixels is not None and size[0] * size[1] > max_pixels:
log.debug("Resizing image to fit image pixel limit")
pixels_factor = sqrt(max_pixels / (size[0] * size[1]))
size = floor(size[0] * pixels_factor), floor(size[1] * pixels_factor)
if max_bytes is not None:
bpp = bytes_per_pixel
+1
View File
@@ -6,6 +6,7 @@
Derived from
https://www.loc.gov/standards/iso639-2/ascii_8bits.html
"""
from __future__ import annotations
from typing import NamedTuple
+6
View File
@@ -0,0 +1,6 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""OCRmyPDF models for plugin options and cross-cutting concerns."""
from __future__ import annotations
+267
View File
@@ -0,0 +1,267 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""OCR element dataclasses for representing OCR output structure.
This module provides a generic, engine-agnostic representation of OCR output.
The OcrElement dataclass can represent structural units from any OCR source
(hOCR, ALTO, custom engines, etc.) in a unified format suitable for rendering.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
@dataclass
class BoundingBox:
"""An axis-aligned bounding box in pixel coordinates.
Coordinates use top-left origin (standard for images and hOCR).
Attributes:
left: Left edge x-coordinate
top: Top edge y-coordinate
right: Right edge x-coordinate
bottom: Bottom edge y-coordinate
"""
left: float
top: float
right: float
bottom: float
@property
def width(self) -> float:
"""Width of the bounding box."""
return self.right - self.left
@property
def height(self) -> float:
"""Height of the bounding box."""
return self.bottom - self.top
def __post_init__(self):
"""Validate bounding box coordinates."""
if self.right < self.left:
raise ValueError(
f"Invalid bounding box: right ({self.right}) < left ({self.left})"
)
if self.bottom < self.top:
raise ValueError(
f"Invalid bounding box: bottom ({self.bottom}) < top ({self.top})"
)
@dataclass
class Baseline:
"""Text baseline information.
The baseline is represented as a linear equation: y = slope * x + intercept.
This describes the line along which text characters sit, relative to the
bottom-left corner of the line's bounding box.
In hOCR, the baseline is specified relative to the bottom of the line's bbox,
with the intercept being the vertical offset from the bottom and the slope
representing rotation (positive = ascending left-to-right).
Attributes:
slope: Slope of the baseline (rise over run)
intercept: Y-intercept of the baseline (vertical offset from bbox bottom)
"""
slope: float = 0.0
intercept: float = 0.0
@dataclass
class FontInfo:
"""Font information for text rendering.
Attributes:
name: Font family name (e.g., "Times New Roman")
size: Font size in points
bold: Whether the font is bold
italic: Whether the font is italic
monospace: Whether the font is monospace
serif: Whether the font is serif (vs sans-serif)
smallcaps: Whether the font uses small caps
underline: Whether the text is underlined
"""
name: str | None = None
size: float | None = None
bold: bool = False
italic: bool = False
monospace: bool = False
serif: bool = False
smallcaps: bool = False
underline: bool = False
@dataclass
class OcrElement:
"""A generic OCR element representing any structural unit of OCR output.
OcrElements form a tree structure where pages contain paragraphs, paragraphs
contain lines, lines contain words, etc. The specific hierarchy depends on
the OCR engine, but this dataclass can represent any of these levels.
The ocr_class field uses hOCR naming conventions (ocr_page, ocr_par, ocr_line,
ocrx_word, etc.) as a common vocabulary, but elements from other sources can
map to these classes.
Common hOCR classes:
- ocr_page: The root element for a page
- ocr_carea: A content/column area
- ocr_par: A paragraph
- ocr_line: A line of text
- ocr_header: A header line
- ocr_footer: A footer line
- ocr_caption: A caption line
- ocr_textfloat: A floating text element
- ocrx_word: A single word
Attributes:
ocr_class: The element type (e.g., "ocr_page", "ocr_line", "ocrx_word")
bbox: Axis-aligned bounding box in source pixel coordinates (top-left origin)
poly: Polygon vertices for oriented/non-rectangular bounds
text: Text content (primarily for leaf nodes like words)
confidence: OCR confidence score (0.0-1.0)
children: Child elements (hierarchical structure)
direction: Text direction ("ltr" or "rtl")
language: Language code (e.g., "eng", "deu", "chi_sim")
baseline: Text baseline information (slope and intercept)
textangle: Text rotation angle in degrees (counter-clockwise from horizontal)
font: Font information (name, size, style)
dpi: Image resolution in dots per inch (typically for page-level)
page_number: Physical page number (0-indexed)
logical_page_number: Logical page number (as printed on the page)
"""
ocr_class: str
# Bounding boxes
bbox: BoundingBox | None = None
poly: list[tuple[float, float]] | None = None
# Text content
text: str = ""
# Confidence (0.0-1.0)
confidence: float | None = None
# Children (hierarchical structure)
children: list[OcrElement] = field(default_factory=list)
# Text direction and language
direction: Literal["ltr", "rtl"] | None = None
language: str | None = None
# Baseline (for lines)
baseline: Baseline | None = None
# Rotation angle in degrees (counter-clockwise)
textangle: float | None = None
# Font information
font: FontInfo | None = None
# Page-level properties
dpi: float | None = None
page_number: int | None = None
logical_page_number: int | None = None
def iter_by_class(self, *ocr_classes: str) -> list[OcrElement]:
"""Iterate over all descendants matching the given class(es).
Args:
*ocr_classes: One or more ocr_class values to match
Returns:
List of all matching descendant elements (depth-first order)
"""
result = []
if self.ocr_class in ocr_classes:
result.append(self)
for child in self.children:
result.extend(child.iter_by_class(*ocr_classes))
return result
def find_by_class(self, *ocr_classes: str) -> OcrElement | None:
"""Find the first descendant matching the given class(es).
Args:
*ocr_classes: One or more ocr_class values to match
Returns:
The first matching element, or None if not found
"""
if self.ocr_class in ocr_classes:
return self
for child in self.children:
result = child.find_by_class(*ocr_classes)
if result is not None:
return result
return None
def get_text_recursive(self) -> str:
"""Get the combined text of this element and all descendants.
Returns:
Combined text content, with words separated by spaces
"""
if self.text:
return self.text
texts = [child.get_text_recursive() for child in self.children]
return " ".join(t for t in texts if t)
@property
def words(self) -> list[OcrElement]:
"""Get all word elements (ocrx_word) in this element's subtree."""
return self.iter_by_class("ocrx_word")
@property
def lines(self) -> list[OcrElement]:
"""Get all line elements in this element's subtree."""
return self.iter_by_class(
"ocr_line", "ocr_header", "ocr_footer", "ocr_caption", "ocr_textfloat"
)
@property
def paragraphs(self) -> list[OcrElement]:
"""Get all paragraph elements (ocr_par) in this element's subtree."""
return self.iter_by_class("ocr_par")
# Type alias for text direction
TextDirection = Literal["ltr", "rtl"]
# hOCR class constants for convenience
class OcrClass:
"""Constants for common OCR element classes."""
# Page-level
PAGE = "ocr_page"
CAREA = "ocr_carea"
# Block-level
PARAGRAPH = "ocr_par"
# Line-level
LINE = "ocr_line"
HEADER = "ocr_header"
FOOTER = "ocr_footer"
CAPTION = "ocr_caption"
TEXTFLOAT = "ocr_textfloat"
# Word-level
WORD = "ocrx_word"
# Character-level
CHAR = "ocrx_cinfo"
# Line types (for convenience)
LINE_TYPES = frozenset({LINE, HEADER, FOOTER, CAPTION, TEXTFLOAT})
+68 -111
View File
@@ -9,7 +9,6 @@ import logging
import sys
import tempfile
import threading
from collections import defaultdict
from collections.abc import Callable, Iterator, MutableSet, Sequence
from os import fspath
from pathlib import Path
@@ -19,6 +18,7 @@ from zlib import compress
import img2pdf
from packaging.version import Version
from pikepdf import (
Array,
Dictionary,
Name,
Object,
@@ -194,11 +194,9 @@ def extract_image_jbig2(
def _should_optimize_jpeg(options, filtdp):
if options.optimize >= 2:
return True
if options.optimize < 2 and ghostscript.version() >= Version('10.6.0'):
# Ghostscript 10.6.0+ introduced some sort of JPEG encoding issue.
# To resolve this, re-optimize the JPEG anyway.
return True
return False
# Ghostscript 10.6.0+ introduced some sort of JPEG encoding issue.
# To resolve this, re-optimize the JPEG anyway.
return options.optimize < 2 and ghostscript.version() >= Version('10.6.0')
def extract_image_generic(
@@ -245,11 +243,9 @@ def extract_image_generic(
not pim.indexed
and pim.colorspace == Name.ICCBased
and pim.bits_per_component == 1
and not options.jbig2_lossy
):
# We can losslessly optimize 1-bit images to CCITT or JBIG2 without
# paying any attention to the ICC profile, provided we're not doing
# lossy JBIG2
# paying any attention to the ICC profile
pim.as_pil_image().save(png_name(root, xref))
return XrefExt(xref, '.png')
@@ -372,116 +368,75 @@ def extract_images_generic(
return jpegs, pngs
def extract_images_jbig2(pdf: Pdf, root: Path, options) -> dict[int, list[XrefExt]]:
def extract_images_jbig2(pdf: Pdf, root: Path, options) -> list[XrefExt]:
"""Extract any bitonal image that we think we can improve as JBIG2."""
jbig2_groups = defaultdict(list)
for pageno, xref_ext in extract_images(pdf, root, options, extract_image_jbig2):
group = pageno // options.jbig2_page_group_size
jbig2_groups[group].append(xref_ext)
jbig2_images = []
for _pageno, xref_ext in extract_images(pdf, root, options, extract_image_jbig2):
jbig2_images.append(xref_ext)
log.debug(f"Optimizable images: JBIG2 groups: {len(jbig2_groups)}")
return jbig2_groups
log.debug(f"Optimizable images: JBIG2: {len(jbig2_images)}")
return jbig2_images
def _produce_jbig2_images(
jbig2_groups: dict[int, list[XrefExt]], root: Path, options, executor: Executor
jbig2_images: list[XrefExt], root: Path, options, executor: Executor
) -> None:
"""Produce JBIG2 images from their groups."""
"""Produce JBIG2 images using lossless single-image encoding."""
def jbig2_group_args(root: Path, groups: dict[int, list[XrefExt]]):
for group, xref_exts in groups.items():
prefix = f'group{group:08d}'
def jbig2_args():
for xref_ext in jbig2_images:
xref, ext = xref_ext
yield (
fspath(root), # =cwd
(img_name(root, xref, ext) for xref, ext in xref_exts), # =infiles
prefix, # =out_prefix
fspath(root),
img_name(root, xref, ext),
root / f'{xref:08d}.jbig2',
options.jbig2_threshold,
)
def jbig2_single_args(root: Path, groups: dict[int, list[XrefExt]]):
for group, xref_exts in groups.items():
prefix = f'group{group:08d}'
# Second loop is to ensure multiple images per page are unpacked
for n, xref_ext in enumerate(xref_exts):
xref, ext = xref_ext
yield (
fspath(root),
img_name(root, xref, ext),
root / f'{prefix}.{n:04d}',
options.jbig2_threshold,
)
if options.jbig2_page_group_size > 1:
jbig2_args = jbig2_group_args
jbig2_convert = jbig2enc.convert_group
else:
jbig2_args = jbig2_single_args
jbig2_convert = jbig2enc.convert_single
executor(
use_threads=True,
max_workers=options.jobs,
progress_kwargs=dict(
total=len(jbig2_groups),
total=len(jbig2_images),
desc="JBIG2",
unit='item',
unit='image',
disable=not options.progress_bar,
),
task=jbig2_convert,
task_arguments=jbig2_args(root, jbig2_groups),
task=jbig2enc.convert_single,
task_arguments=jbig2_args(),
)
def convert_to_jbig2(
pdf: Pdf,
jbig2_groups: dict[int, list[XrefExt]],
jbig2_images: list[XrefExt],
root: Path,
options,
executor: Executor,
) -> None:
"""Convert images to JBIG2 and insert into PDF.
When the JBIG2 page group size is > 1 we do several JBIG2 images at once
and build a symbol dictionary that will span several pages. Each JBIG2
image must reference to its symbol dictionary. If too many pages shared the
same dictionary JBIG2 encoding becomes more expensive and less efficient.
The default value of 10 was determined through testing. Currently this
must be lossy encoding since jbig2enc does not support refinement coding.
When the JBIG2 symbolic coder is not used, each JBIG2 stands on its own
and needs no dictionary. Currently this must be lossless JBIG2.
Each JBIG2 image is encoded independently using lossless compression.
No symbol dictionary (JBIG2Globals) is used.
"""
jbig2_globals_dict: Dictionary | None
_produce_jbig2_images(jbig2_images, root, options, executor)
_produce_jbig2_images(jbig2_groups, root, options, executor)
for group, xref_exts in jbig2_groups.items():
prefix = f'group{group:08d}'
jbig2_symfile = root / (prefix + '.sym')
if jbig2_symfile.exists():
jbig2_globals_data = jbig2_symfile.read_bytes()
jbig2_globals = Stream(pdf, jbig2_globals_data)
jbig2_globals_dict = Dictionary(JBIG2Globals=jbig2_globals)
elif options.jbig2_page_group_size == 1:
jbig2_globals_dict = None
else:
raise FileNotFoundError(jbig2_symfile)
for n, xref_ext in enumerate(xref_exts):
xref, _ = xref_ext
jbig2_im_file = root / (prefix + f'.{n:04d}')
jbig2_im_data = jbig2_im_file.read_bytes()
im_obj = pdf.get_object(xref, 0)
im_obj.write(
jbig2_im_data, filter=Name.JBIG2Decode, decode_parms=jbig2_globals_dict
)
for xref_ext in jbig2_images:
xref, _ = xref_ext
jbig2_im_file = root / f'{xref:08d}.jbig2'
jbig2_im_data = jbig2_im_file.read_bytes()
im_obj = pdf.get_object(xref, 0)
im_obj.write(jbig2_im_data, filter=Name.JBIG2Decode, decode_parms=None)
def _optimize_jpeg(
xref: Xref, in_jpg: Path, opt_jpg: Path, jpeg_quality: int
xref: Xref, in_jpg: Path, opt_jpg: Path, jpg_quality: int
) -> tuple[Xref, Path | None]:
with Image.open(in_jpg) as im:
im.save(opt_jpg, optimize=True, quality=jpeg_quality)
save_kwargs: dict[str, Any] = {'optimize': True}
if isinstance(jpg_quality, int) and 0 < jpg_quality <= 100:
save_kwargs['quality'] = jpg_quality
im.save(opt_jpg, **save_kwargs)
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
log.debug(f"xref {xref}, jpeg, made larger - skip")
@@ -499,7 +454,7 @@ def transcode_jpegs(
for xref in jpegs:
in_jpg = jpg_name(root, xref)
opt_jpg = in_jpg.with_suffix('.opt.jpg')
yield xref, in_jpg, opt_jpg, options.jpeg_quality
yield xref, in_jpg, opt_jpg, options.jpg_quality
def finish_jpeg(result: tuple[Xref, Path | None], pbar: ProgressBar):
xref, opt_jpg = result
@@ -524,6 +479,16 @@ def transcode_jpegs(
)
def _already_flate_encoded(image: Stream) -> bool:
"""Check if the image already has FlateDecode in its filter chain."""
filt = image.get(Name.Filter)
if filt is None:
return False
if isinstance(filt, Array):
return Name.FlateDecode in list(filt)
return filt == Name.FlateDecode
def _find_deflatable_jpeg(
*, pdf: Pdf, root: Path, image: Stream, xref: Xref, options
) -> XrefExt | None:
@@ -532,6 +497,10 @@ def _find_deflatable_jpeg(
return None
_pim, filtdp = result
# Skip if already FlateDecode compressed - would double-compress
if _already_flate_encoded(image):
return None
if (
filtdp[0] == Name.DCTDecode
and not filtdp[1]
@@ -712,12 +681,10 @@ def optimize(
safe_symlink(input_file, output_file)
return output_file
if options.jpeg_quality == 0:
options.jpeg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
if options.jpg_quality == 0:
options.jpg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
if options.png_quality == 0:
options.png_quality = DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
if options.jbig2_page_group_size == 0:
options.jbig2_page_group_size = 10 if options.jbig2_lossy else 1
with Pdf.open(input_file) as pdf:
root = output_file.parent / 'images'
@@ -731,8 +698,8 @@ def optimize(
# transcode_pngs(pdf, jpegs, jpg_name, root, options)
transcode_pngs(pdf, pngs, png_name, root, options, executor)
jbig2_groups = extract_images_jbig2(pdf, root, options)
convert_to_jbig2(pdf, jbig2_groups, root, options, executor)
jbig2_images = extract_images_jbig2(pdf, root, options)
convert_to_jbig2(pdf, jbig2_images, root, options, executor)
target_file = output_file.with_suffix('.opt.pdf')
pdf.remove_unreferenced_resources()
@@ -767,35 +734,25 @@ def main(infile, outfile, level, jobs=1):
from shutil import copy # pylint: disable=import-outside-toplevel
from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel
class OptimizeOptions:
"""Emulate ocrmypdf's options."""
def __init__(
self, input_file, jobs, optimize_, jpeg_quality, png_quality, jb2lossy
):
self.input_file = input_file
self.jobs = jobs
self.optimize = optimize_
self.jpeg_quality = jpeg_quality
self.png_quality = png_quality
self.jbig2_page_group_size = 0
self.jbig2_lossy = jb2lossy
self.jbig2_threshold = 0.85
self.quiet = True
self.progress_bar = False
from ocrmypdf._options import OcrOptions # pylint: disable=import-outside-toplevel
infile = Path(infile)
options = OptimizeOptions(
# Create OcrOptions with optimization-specific settings
options = OcrOptions(
input_file=infile,
output_file=outfile, # Required field
jobs=jobs,
optimize_=int(level),
jpeg_quality=0, # Use default
optimize=int(level),
jpg_quality=0, # Use default
png_quality=0,
jb2lossy=False,
jbig2_threshold=0.85,
quiet=True,
progress_bar=False,
)
with TemporaryDirectory() as tmpdir:
context = PdfContext(options, tmpdir, infile, None, None)
context = PdfContext(options, Path(tmpdir), infile, None, None)
tmpout = Path(tmpdir) / 'out.pdf'
optimize(
infile,
+117 -1
View File
@@ -1,16 +1,20 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Utilities for PDF/A production and confirmation with Ghostspcript."""
"""Utilities for PDF/A production and confirmation with Ghostscript."""
from __future__ import annotations
import base64
import logging
from collections.abc import Iterator
from importlib.resources import files as package_files
from pathlib import Path
import pikepdf
from pikepdf import Array, Dictionary, Name, Pdf, Stream
log = logging.getLogger(__name__)
SRGB_ICC_PROFILE_NAME = 'sRGB.icc'
@@ -131,3 +135,115 @@ def file_claims_pdfa(filename: Path):
pdfa_dict['output'] = 'pdfa'
pdfa_dict['conformance'] = conformance
return pdfa_dict
def _load_srgb_icc_profile() -> bytes:
"""Load the sRGB ICC profile from package data."""
return (package_files('ocrmypdf.data') / SRGB_ICC_PROFILE_NAME).read_bytes()
def _pdfa_part_conformance(output_type: str) -> tuple[str, str]:
"""Extract PDF/A part and conformance from output_type.
Args:
output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'
Returns:
Tuple of (part, conformance) e.g., ('2', 'B')
"""
mapping = {
'pdfa': ('2', 'B'),
'pdfa-1': ('1', 'B'),
'pdfa-2': ('2', 'B'),
'pdfa-3': ('3', 'B'),
}
return mapping.get(output_type, ('2', 'B'))
def add_pdfa_metadata(pdf: Pdf, part: str, conformance: str) -> None:
"""Add PDF/A XMP metadata declaration to a PDF.
Args:
pdf: An open pikepdf.Pdf object
part: PDF/A part number ('1', '2', or '3')
conformance: Conformance level ('A', 'B', or 'U')
"""
with pdf.open_metadata() as meta:
meta['pdfaid:part'] = part
meta['pdfaid:conformance'] = conformance
def add_srgb_output_intent(pdf: Pdf) -> None:
"""Add sRGB ICC profile as OutputIntent to PDF catalog.
This creates the required PDF/A OutputIntent structure with:
- An ICC profile stream containing sRGB profile
- An OutputIntent dictionary pointing to that profile
- Updates the Catalog's OutputIntents array
Args:
pdf: An open pikepdf.Pdf object
"""
icc_data = _load_srgb_icc_profile()
# Create ICC profile stream
icc_stream = Stream(pdf, icc_data)
icc_stream[Name.N] = 3 # RGB has 3 components
# Create OutputIntent dictionary
output_intent = Dictionary({
'/Type': Name.OutputIntent,
'/S': Name('/GTS_PDFA1'),
'/OutputConditionIdentifier': 'sRGB',
'/DestOutputProfile': icc_stream,
})
# Add to catalog's OutputIntents array
if Name.OutputIntents not in pdf.Root:
pdf.Root[Name.OutputIntents] = Array([])
# Check if sRGB OutputIntent already exists
for intent in pdf.Root.OutputIntents: # type: ignore[attr-defined]
if str(intent.get(Name.OutputConditionIdentifier)) == 'sRGB':
log.debug('sRGB OutputIntent already exists, skipping')
return
pdf.Root.OutputIntents.append(output_intent)
def speculative_pdfa_conversion(
input_file: Path,
output_file: Path,
output_type: str,
) -> Path:
"""Attempt to convert a PDF to PDF/A by adding required structures.
This function creates a copy of the input PDF and adds:
1. sRGB ICC profile as OutputIntent
2. XMP metadata declaring PDF/A conformance
This approach works for PDFs that are already mostly PDF/A compliant
but lack the formal declarations. It does NOT perform color conversion,
font embedding, or other transformations that Ghostscript does.
Args:
input_file: Path to input PDF
output_file: Path where output PDF should be written
output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'
Returns:
Path to the output file
Raises:
pikepdf.PdfError: If the PDF cannot be opened or modified
"""
part, conformance = _pdfa_part_conformance(output_type)
with Pdf.open(input_file) as pdf:
add_srgb_output_intent(pdf)
add_pdfa_metadata(pdf, part, conformance)
pdf.save(output_file)
log.debug('Speculative PDF/A conversion complete: %s', output_file)
return output_file
+3 -2
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PageInfo, PdfInfo
from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect
from ocrmypdf.pdfinfo.info import PageInfo, PdfInfo
__all__ = ["Colorspace", "Encoding", "PageInfo", "PdfInfo"]
__all__ = ["Colorspace", "Encoding", "FloatRect", "PageInfo", "PdfInfo"]
+231
View File
@@ -0,0 +1,231 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""PDF content stream interpretation."""
from __future__ import annotations
import re
from collections import defaultdict
from collections.abc import Mapping
from math import hypot, inf, isclose
from typing import NamedTuple
from warnings import warn
from pikepdf import Matrix, Object, PdfInlineImage, parse_content_stream
from ocrmypdf.exceptions import InputFileError
from ocrmypdf.helpers import Resolution
from ocrmypdf.pdfinfo._types import UNIT_SQUARE
class XobjectSettings(NamedTuple):
"""Info about an XObject found in a PDF."""
name: str
shorthand: tuple[float, float, float, float, float, float]
stack_depth: int
class InlineSettings(NamedTuple):
"""Info about an inline image found in a PDF."""
iimage: PdfInlineImage
shorthand: tuple[float, float, float, float, float, float]
stack_depth: int
class ContentsInfo(NamedTuple):
"""Info about various objects found in a PDF."""
xobject_settings: list[XobjectSettings]
inline_images: list[InlineSettings]
found_vector: bool
found_text: bool
name_index: Mapping[str, list[XobjectSettings]]
class TextboxInfo(NamedTuple):
"""Info about a text box found in a PDF."""
bbox: tuple[float, float, float, float]
is_visible: bool
is_corrupt: bool
class VectorMarker:
"""Sentinel indicating vector drawing operations were found on a page."""
class TextMarker:
"""Sentinel indicating text drawing operations were found on a page."""
def _is_unit_square(shorthand):
"""Check if the shorthand represents a unit square transformation."""
values = map(float, shorthand)
pairwise = zip(values, UNIT_SQUARE, strict=False)
return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise)
def _normalize_stack(graphobjs):
"""Convert runs of qQ's in the stack into single graphobjs."""
for operands, operator in graphobjs:
operator = str(operator)
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
for char in operator: # Split into individual
yield ([], char) # Yield individual
else:
yield (operands, operator)
def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
"""Interpret the PDF content stream.
The stack represents the state of the PDF graphics stack. We are only
interested in the current transformation matrix (CTM) so we only track
this object; a full implementation would need to track many other items.
The CTM is initialized to the mapping from user space to device space.
PDF units are 1/72". In a PDF viewer or printer this matrix is initialized
to the transformation to device space. For example if set to
(1/72, 0, 0, 1/72, 0, 0) then all units would be calculated in inches.
Images are always considered to be (0, 0) -> (1, 1). Before drawing an
image there should be a 'cm' that sets up an image coordinate system
where drawing from (0, 0) -> (1, 1) will draw on the desired area of the
page.
PDF units suit our needs so we initialize ctm to the identity matrix.
According to the PDF specification, the maximum stack depth is 32. Other
viewers tolerate some amount beyond this. We issue a warning if the
stack depth exceeds the spec limit and set a hard limit beyond this to
bound our memory requirements. If the stack underflows behavior is
undefined in the spec, but we just pretend nothing happened and leave the
CTM unchanged.
"""
stack = []
ctm = Matrix(initial_shorthand)
xobject_settings: list[XobjectSettings] = []
inline_images: list[InlineSettings] = []
name_index = defaultdict(lambda: [])
found_vector = False
found_text = False
vector_ops = set('S s f F f* B B* b b*'.split())
text_showing_ops = set("""TJ Tj " '""".split())
image_ops = set('BI ID EI q Q Do cm'.split())
operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops)
for n, graphobj in enumerate(
_normalize_stack(parse_content_stream(contentstream, operator_whitelist))
):
operands, operator = graphobj
if operator == 'q':
stack.append(ctm)
if len(stack) > 32: # See docstring
if len(stack) > 128:
raise RuntimeError(
f"PDF graphics stack overflowed hard limit at operator {n}"
)
warn("PDF graphics stack overflowed spec limit")
elif operator == 'Q':
try:
ctm = stack.pop()
except IndexError:
# Keeping the ctm the same seems to be the only sensible thing
# to do. Just pretend nothing happened, keep calm and carry on.
warn("PDF graphics stack underflowed - PDF may be malformed")
elif operator == 'cm':
try:
ctm = Matrix(operands) @ ctm
except ValueError as e:
raise InputFileError(
"PDF content stream is corrupt - this PDF is malformed. "
"Use a PDF editor that is capable of visually inspecting the PDF."
) from e
elif operator == 'Do':
image_name = operands[0]
settings = XobjectSettings(
name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack)
)
xobject_settings.append(settings)
name_index[str(image_name)].append(settings)
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
iimage = operands[0]
inline = InlineSettings(
iimage=iimage, shorthand=ctm.shorthand, stack_depth=len(stack)
)
inline_images.append(inline)
elif operator in vector_ops:
found_vector = True
elif operator in text_showing_ops:
found_text = True
return ContentsInfo(
xobject_settings=xobject_settings,
inline_images=inline_images,
found_vector=found_vector,
found_text=found_text,
name_index=name_index,
)
def _get_dpi(ctm_shorthand, image_size) -> Resolution:
"""Given the transformation matrix and image size, find the image DPI.
PDFs do not include image resolution information within image data.
Instead, the PDF page content stream describes the location where the
image will be rasterized, and the effective resolution is the ratio of the
pixel size to raster target size.
Normally a scanned PDF has the paper size set appropriately but this is
not guaranteed. The most common case is a cropped image will change the
page size (/CropBox) without altering the page content stream. That means
it is not sufficient to assume that the image fills the page, even though
that is the most common case.
A PDF image may be scaled (always), cropped, translated, rotated in place
to an arbitrary angle (rarely) and skewed. Only equal area mappings can
be expressed, that is, it is not necessary to consider distortions where
the effective DPI varies with position.
To determine the image scale, transform an offset axis vector v0 (0, 0),
width-axis vector v0 (1, 0), height-axis vector vh (0, 1) with the matrix,
which gives the dimensions of the image in PDF units. From there we can
compare to actual image dimensions. PDF uses
row vector * matrix_transposed unlike the traditional
matrix * column vector.
The offset, width and height vectors can be combined in a matrix and
multiplied by the transform matrix. Then we want to calculated
magnitude(width_vector - offset_vector)
and
magnitude(height_vector - offset_vector)
When the above is worked out algebraically, the effect of translation
cancels out, and the vector magnitudes become functions of the nonzero
transformation matrix indices. The results of the derivation are used
in this code.
pdfimages -list does calculate the DPI in some way that is not completely
naive, but it does not get the DPI of rotated images right, so cannot be
used anymore to validate this. Photoshop works, or using Acrobat to
rotate the image back to normal.
It does not matter if the image is partially cropped, or even out of the
/MediaBox.
"""
a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name
# Calculate the width and height of the image in PDF units
image_drawn = hypot(a, b), hypot(c, d)
def calc(drawn, pixels, inches_per_pt=72.0):
# The scale of the image is pixels per unit of default user space (1/72")
scale = pixels / drawn if drawn != 0 else inf
dpi = scale * inches_per_pt
return dpi
dpi_w, dpi_h = (calc(image_drawn[n], image_size[n]) for n in range(2))
return Resolution(dpi_w, dpi_h)
+380
View File
@@ -0,0 +1,380 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""PDF image analysis."""
from __future__ import annotations
import logging
from collections.abc import Iterator
from decimal import Decimal
from pikepdf import (
Dictionary,
Matrix,
Name,
Object,
Pdf,
PdfImage,
PdfInlineImage,
Stream,
UnsupportedImageTypeError,
)
from ocrmypdf.helpers import Resolution
from ocrmypdf.pdfinfo._contentstream import (
ContentsInfo,
TextMarker,
VectorMarker,
_get_dpi,
_interpret_contents,
_is_unit_square,
)
from ocrmypdf.pdfinfo._types import (
FRIENDLY_COLORSPACE,
FRIENDLY_COMP,
FRIENDLY_ENCODING,
UNIT_SQUARE,
Colorspace,
Encoding,
)
logger = logging.getLogger()
class ImageInfo:
"""Information about an image found in a PDF.
This gathers information from pikepdf and pdfminer.six, and is pickle-able
so that it can be passed to a worker process, unlike objects from those
libraries.
"""
DPI_PREC = Decimal('1.000')
_comp: int | None
_name: str
def __init__(
self,
*,
name='',
pdfimage: Object | None = None,
inline: PdfInlineImage | None = None,
shorthand=None,
):
"""Initialize an ImageInfo."""
self._name = str(name)
self._shorthand = shorthand
pim: PdfInlineImage | PdfImage
if inline is not None:
self._origin = 'inline'
pim = inline
elif pdfimage is not None and isinstance(pdfimage, Stream):
self._origin = 'xobject'
pim = PdfImage(pdfimage)
else:
raise ValueError("Either pdfimage or inline must be set")
self._width = pim.width
self._height = pim.height
if (smask := pim.obj.get(Name.SMask, None)) is not None and isinstance(
smask, Stream | Dictionary
):
# SMask is pretty much an alpha channel, but in PDF it's possible
# for channel to have different dimensions than the image
# itself. Some PDF writers use this to create a grayscale stencil
# mask. For our purposes, the effective size is the size of the
# larger component (image or smask).
self._width = max(smask.get(Name.Width, 0), self._width)
self._height = max(smask.get(Name.Height, 0), self._height)
if (mask := pim.obj.get(Name.Mask, None)) is not None and isinstance(
mask, Stream | Dictionary
):
# If the image has a /Mask entry, it has an explicit mask.
# /Mask can be a Stream or an Array. If it's a Stream,
# use its /Width and /Height if they are larger than the main
# image's.
self._width = max(mask.get(Name.Width, 0), self._width)
self._height = max(mask.get(Name.Height, 0), self._height)
# If /ImageMask is true, then this image is a stencil mask
# (Images that draw with this stencil mask will have a reference to
# it in their /Mask, but we don't actually need that information)
if pim.image_mask:
self._type = 'stencil'
else:
self._type = 'image'
self._bpc = int(pim.bits_per_component)
try:
self._enc = FRIENDLY_ENCODING.get(pim.filters[0])
except IndexError:
self._enc = None
try:
self._color = FRIENDLY_COLORSPACE.get(pim.colorspace or '')
except NotImplementedError:
self._color = None
if self._enc == Encoding.jpeg2000:
self._color = Colorspace.jpeg2000
self._comp = None
if self._color == Colorspace.icc and isinstance(pim, PdfImage):
self._comp = self._init_icc(pim)
else:
if isinstance(self._color, Colorspace):
self._comp = FRIENDLY_COMP.get(self._color)
# Bit of a hack... infer grayscale if component count is uncertain
# but encoding only supports monochrome.
if self._comp is None and self._enc in (Encoding.ccitt, Encoding.jbig2):
self._comp = FRIENDLY_COMP[Colorspace.gray]
def _init_icc(self, pim: PdfImage):
try:
icc = pim.icc
except UnsupportedImageTypeError as e:
logger.warning(
f"An image with a corrupt or unreadable ICC profile was found. "
f"Output PDF may not match the input PDF visually: {e}. {self}"
)
return None
# Check the ICC profile to determine actual colorspace
if icc is None or not hasattr(icc, 'profile'):
logger.warning(
f"An image with an ICC profile but no ICC profile data was found. "
f"The output PDF may not match the input PDF visually. {self}"
)
return None
try:
if icc.profile.xcolor_space == 'GRAY':
return 1
elif icc.profile.xcolor_space == 'CMYK':
return 4
else:
return 3
except AttributeError:
return None
@property
def name(self):
"""Name of the image as it appears in the PDF."""
return self._name
@property
def type_(self):
"""Type of image, either 'image' or 'stencil'."""
return self._type
@property
def width(self) -> int:
"""Width of the image in pixels."""
return self._width
@property
def height(self) -> int:
"""Height of the image in pixels."""
return self._height
@property
def bpc(self):
"""Bits per component."""
return self._bpc
@property
def color(self):
"""Colorspace of the image."""
return self._color if self._color is not None else '?'
@property
def comp(self):
"""Number of components/channels in the image."""
return self._comp if self._comp is not None else '?'
@property
def enc(self):
"""Encoding of the image."""
return self._enc if self._enc is not None else 'image'
@property
def renderable(self) -> bool:
"""Whether the image is renderable.
Some PDFs in the wild have invalid images that are not renderable,
due to unusual dimensions.
Stencil masks are not also not renderable, since they are not
drawn, but rather they control how rendering happens.
"""
return (
self.dpi.is_finite
and self.width >= 0
and self.height >= 0
and self.type_ != 'stencil'
)
@property
def dpi(self) -> Resolution:
"""Dots per inch of the image.
Calculated based on where and how the image is drawn in the PDF.
"""
return _get_dpi(self._shorthand, (self._width, self._height))
@property
def printed_area(self) -> float:
"""Physical area of the image in square inches."""
if not self.renderable:
return 0.0
return float((self.width / self.dpi.x) * (self.height / self.dpi.y))
def __repr__(self):
"""Return a string representation of the image."""
return (
f"<ImageInfo '{self.name}' {self.type_} {self.width}×{self.height} "
f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>"
)
def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
"""Find inline images in the contentstream."""
for n, inline in enumerate(contentsinfo.inline_images):
yield ImageInfo(
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
)
def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
"""Search for all XObject-based images in the container.
Usually the container is a page, but it could also be a Form XObject
that contains images. Filter out the Form XObjects which are dealt with
elsewhere.
Generate a sequence of tuples (image, xobj container), where container,
where xobj is the name of the object and image is the object itself,
since the object does not know its own name.
"""
if Name.Resources not in container:
return
resources = container[Name.Resources]
if Name.XObject not in resources:
return
for key, candidate in resources[Name.XObject].items():
if candidate is None or Name.Subtype not in candidate:
continue
if candidate[Name.Subtype] == Name.Image:
pdfimage = candidate
yield (pdfimage, key)
def _find_regular_images(
container: Object, contentsinfo: ContentsInfo
) -> Iterator[ImageInfo]:
"""Find images stored in the container's /Resources /XObject.
Usually the container is a page, but it could also be a Form XObject
that contains images.
Generates images with their DPI at time of drawing.
"""
for pdfimage, xobj in _image_xobjects(container):
if xobj not in contentsinfo.name_index:
continue
for draw in contentsinfo.name_index[xobj]:
if draw.stack_depth == 0 and _is_unit_square(draw.shorthand):
# At least one PDF in the wild (and test suite) draws an image
# when the graphics stack depth is 0, meaning that the image
# gets drawn into a square of 1x1 PDF units (or 1/72",
# or 0.35 mm). The equivalent DPI will be >100,000. Exclude
# these from our DPI calculation for the page.
continue
yield ImageInfo(name=draw.name, pdfimage=pdfimage, shorthand=draw.shorthand)
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
"""Find any images that are in Form XObjects in the container.
The container may be a page, or a parent Form XObject.
"""
if Name.Resources not in container:
return
resources = container[Name.Resources]
if Name.XObject not in resources:
return
xobjs = resources[Name.XObject].as_dict()
for xobj in xobjs:
candidate = xobjs[xobj]
if candidate is None or candidate.get(Name.Subtype) != Name.Form:
continue
form_xobject = candidate
for settings in contentsinfo.xobject_settings:
if settings.name != xobj:
continue
# Find images once for each time this Form XObject is drawn.
# This could be optimized to cache the multiple drawing events
# but in practice both Form XObjects and multiple drawing of the
# same object are both very rare.
ctm_shorthand = settings.shorthand
yield from _process_content_streams(
pdf=pdf, container=form_xobject, shorthand=ctm_shorthand
)
def _process_content_streams(
*, pdf: Pdf, container: Object, shorthand=None
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
"""Find all individual instances of images drawn in the container.
Usually the container is a page, but it may also be a Form XObject.
On a typical page images are stored inline or as regular images
in an XObject.
Form XObjects may include inline images, XObject images,
and recursively, other Form XObjects; and also vector graphic objects.
Every instance of an image being drawn somewhere is flattened and
treated as a unique image, since if the same image is drawn multiple times
on one page it may be drawn at differing resolutions, and our objective
is to find the resolution at which the page can be rastered without
downsampling.
"""
if container.get(Name.Type) == Name.Page and Name.Contents in container:
initial_shorthand = shorthand or UNIT_SQUARE
elif (
container.get(Name.Type) == Name.XObject
and container[Name.Subtype] == Name.Form
):
# Set the CTM to the state it was when the "Do" operator was
# encountered that is drawing this instance of the Form XObject
ctm = Matrix(shorthand) if shorthand else Matrix()
# A Form XObject may provide its own matrix to map form space into
# user space. Get this if one exists
form_shorthand = container.get(Name.Matrix, Matrix())
form_matrix = Matrix(form_shorthand)
# Concatenate form matrix with CTM to ensure CTM is correct for
# drawing this instance of the XObject
ctm = form_matrix @ ctm
initial_shorthand = ctm.shorthand
else:
return
contentsinfo = _interpret_contents(container, initial_shorthand)
if contentsinfo.found_vector:
yield VectorMarker()
if contentsinfo.found_text:
yield TextMarker()
yield from _find_inline_images(contentsinfo)
yield from _find_regular_images(container, contentsinfo)
yield from _find_form_xobject_images(pdf, container, contentsinfo)
+83
View File
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""PDF type definitions and constants."""
from __future__ import annotations
from enum import Enum, auto
class Colorspace(Enum):
"""Description of common image colorspaces in a PDF."""
# pylint: disable=invalid-name
gray = auto()
rgb = auto()
cmyk = auto()
lab = auto()
icc = auto()
index = auto()
sep = auto()
devn = auto()
pattern = auto()
jpeg2000 = auto()
class Encoding(Enum):
"""Description of common image encodings in a PDF."""
# pylint: disable=invalid-name
ccitt = auto()
jpeg = auto()
jpeg2000 = auto()
jbig2 = auto()
asciihex = auto()
ascii85 = auto()
lzw = auto()
flate = auto()
runlength = auto()
FloatRect = tuple[float, float, float, float]
FRIENDLY_COLORSPACE: dict[str, Colorspace] = {
'/DeviceGray': Colorspace.gray,
'/CalGray': Colorspace.gray,
'/DeviceRGB': Colorspace.rgb,
'/CalRGB': Colorspace.rgb,
'/DeviceCMYK': Colorspace.cmyk,
'/Lab': Colorspace.lab,
'/ICCBased': Colorspace.icc,
'/Indexed': Colorspace.index,
'/Separation': Colorspace.sep,
'/DeviceN': Colorspace.devn,
'/Pattern': Colorspace.pattern,
'/G': Colorspace.gray, # Abbreviations permitted in inline images
'/RGB': Colorspace.rgb,
'/CMYK': Colorspace.cmyk,
'/I': Colorspace.index,
}
FRIENDLY_ENCODING: dict[str, Encoding] = {
'/CCITTFaxDecode': Encoding.ccitt,
'/DCTDecode': Encoding.jpeg,
'/JPXDecode': Encoding.jpeg2000,
'/JBIG2Decode': Encoding.jbig2,
'/CCF': Encoding.ccitt, # Abbreviations permitted in inline images
'/DCT': Encoding.jpeg,
'/AHx': Encoding.asciihex,
'/A85': Encoding.ascii85,
'/LZW': Encoding.lzw,
'/Fl': Encoding.flate,
'/RL': Encoding.runlength,
}
FRIENDLY_COMP: dict[Colorspace, int] = {
Colorspace.gray: 1,
Colorspace.rgb: 3,
Colorspace.cmyk: 4,
Colorspace.lab: 3,
Colorspace.index: 1,
}
UNIT_SQUARE = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
+143
View File
@@ -0,0 +1,143 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""PDF page info worker process handling."""
from __future__ import annotations
import atexit
import logging
from collections.abc import Container, Sequence
from contextlib import contextmanager
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING
from pikepdf import Pdf
from ocrmypdf._concurrent import Executor
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.exceptions import InputFileError
from ocrmypdf.helpers import available_cpu_count, pikepdf_enable_mmap
if TYPE_CHECKING:
from ocrmypdf.pdfinfo.info import PageInfo
from ocrmypdf.pdfinfo.layout import PdfMinerState
logger = logging.getLogger()
worker_pdf = None # pylint: disable=invalid-name
def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel):
global worker_pdf # pylint: disable=global-statement,invalid-name
pikepdf_enable_mmap()
logging.getLogger('pdfminer').setLevel(pdfminer_loglevel)
# If the pdf is not opened, open a copy for our worker process to use
if pdf is None:
worker_pdf = Pdf.open(infile)
def on_process_close():
worker_pdf.close()
# Close when this process exits
atexit.register(on_process_close)
@contextmanager
def _pdf_pageinfo_sync_pdf(thread_pdf: Pdf | None, infile: Path):
if thread_pdf is not None:
yield thread_pdf
elif worker_pdf is not None:
yield worker_pdf
else:
with Pdf.open(infile) as pdf:
yield pdf
def _pdf_pageinfo_sync(
pageno: int,
thread_pdf: Pdf | None,
infile: Path,
check_pages: Container[int],
detailed_analysis: bool,
miner_state: PdfMinerState | None,
) -> PageInfo:
# Import here to avoid circular import - info.py imports this module,
# but PageInfo is defined in info.py
from ocrmypdf.pdfinfo.info import PageInfo
with _pdf_pageinfo_sync_pdf(thread_pdf, infile) as pdf:
return PageInfo(
pdf, pageno, infile, check_pages, detailed_analysis, miner_state
)
def _pdf_pageinfo_concurrent(
pdf,
executor: Executor,
max_workers: int,
use_threads: bool,
infile,
progbar,
check_pages,
detailed_analysis: bool = False,
miner_state: PdfMinerState | None = None,
) -> Sequence[PageInfo | None]:
pages: list[PageInfo | None] = [None] * len(pdf.pages)
def update_pageinfo(page: PageInfo, pbar: ProgressBar):
if not page:
raise InputFileError("Could read a page in the PDF")
pages[page.pageno] = page
pbar.update()
if max_workers is None:
max_workers = available_cpu_count()
total = len(pdf.pages)
n_workers = min(1 + len(pages) // 4, max_workers)
if n_workers == 1:
# If we decided on only one worker, there is no point in using
# a separate process.
use_threads = True
if use_threads and n_workers > 1:
# If we are using threads, there is no point in using more than one
# worker thread - they will just fight over the GIL.
n_workers = 1
# If we use a thread, we can pass the already-open Pdf for them to use
# If we use processes, we pass a None which tells the init function to open its
# own
initial_pdf = pdf if use_threads else None
contexts = (
(n, initial_pdf, infile, check_pages, detailed_analysis, miner_state)
for n in range(total)
)
assert n_workers == 1 if use_threads else n_workers >= 1, "Not multithreadable"
logger.debug(
f"Gathering info with {n_workers} "
+ ('thread' if use_threads else 'process')
+ " workers"
)
executor(
use_threads=use_threads,
max_workers=n_workers,
progress_kwargs=dict(
total=total, desc="Scanning contents", unit='page', disable=not progbar
),
worker_initializer=partial(
_pdf_pageinfo_sync_init,
initial_pdf,
infile,
logging.getLogger('pdfminer').level,
),
task=_pdf_pageinfo_sync,
task_arguments=contexts,
task_finished=update_pageinfo,
)
return pages
+18 -776
View File
@@ -6,677 +6,34 @@
from __future__ import annotations
import atexit
import logging
import re
import statistics
from collections import defaultdict
from collections.abc import Callable, Container, Iterable, Iterator, Mapping, Sequence
from contextlib import contextmanager, nullcontext
from collections.abc import Callable, Container, Iterable, Iterator
from contextlib import nullcontext
from decimal import Decimal
from enum import Enum, auto
from functools import partial
from math import hypot, inf, isclose
from os import PathLike
from pathlib import Path
from typing import NamedTuple
from warnings import warn
from pdfminer.layout import LTPage, LTTextBox
from pikepdf import (
Dictionary,
Matrix,
Name,
Object,
Page,
Pdf,
PdfImage,
PdfInlineImage,
Stream,
UnsupportedImageTypeError,
parse_content_stream,
)
from pikepdf import Name, Page, Pdf
from ocrmypdf._concurrent import Executor, SerialExecutor
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
from ocrmypdf.helpers import Resolution, available_cpu_count, pikepdf_enable_mmap
from ocrmypdf.exceptions import EncryptedPdfError
from ocrmypdf.helpers import Resolution
from ocrmypdf.pdfinfo._contentstream import TextboxInfo, TextMarker, VectorMarker
from ocrmypdf.pdfinfo._image import ImageInfo, _process_content_streams
from ocrmypdf.pdfinfo._types import FloatRect
from ocrmypdf.pdfinfo._worker import _pdf_pageinfo_concurrent
from ocrmypdf.pdfinfo.layout import (
LTStateAwareChar,
PdfMinerState,
get_page_analysis,
get_text_boxes,
)
logger = logging.getLogger()
class Colorspace(Enum):
"""Description of common image colorspaces in a PDF."""
# pylint: disable=invalid-name
gray = auto()
rgb = auto()
cmyk = auto()
lab = auto()
icc = auto()
index = auto()
sep = auto()
devn = auto()
pattern = auto()
jpeg2000 = auto()
class Encoding(Enum):
"""Description of common image encodings in a PDF."""
# pylint: disable=invalid-name
ccitt = auto()
jpeg = auto()
jpeg2000 = auto()
jbig2 = auto()
asciihex = auto()
ascii85 = auto()
lzw = auto()
flate = auto()
runlength = auto()
FloatRect = tuple[float, float, float, float]
FRIENDLY_COLORSPACE: dict[str, Colorspace] = {
'/DeviceGray': Colorspace.gray,
'/CalGray': Colorspace.gray,
'/DeviceRGB': Colorspace.rgb,
'/CalRGB': Colorspace.rgb,
'/DeviceCMYK': Colorspace.cmyk,
'/Lab': Colorspace.lab,
'/ICCBased': Colorspace.icc,
'/Indexed': Colorspace.index,
'/Separation': Colorspace.sep,
'/DeviceN': Colorspace.devn,
'/Pattern': Colorspace.pattern,
'/G': Colorspace.gray, # Abbreviations permitted in inline images
'/RGB': Colorspace.rgb,
'/CMYK': Colorspace.cmyk,
'/I': Colorspace.index,
}
FRIENDLY_ENCODING: dict[str, Encoding] = {
'/CCITTFaxDecode': Encoding.ccitt,
'/DCTDecode': Encoding.jpeg,
'/JPXDecode': Encoding.jpeg2000,
'/JBIG2Decode': Encoding.jbig2,
'/CCF': Encoding.ccitt, # Abbreviations permitted in inline images
'/DCT': Encoding.jpeg,
'/AHx': Encoding.asciihex,
'/A85': Encoding.ascii85,
'/LZW': Encoding.lzw,
'/Fl': Encoding.flate,
'/RL': Encoding.runlength,
}
FRIENDLY_COMP: dict[Colorspace, int] = {
Colorspace.gray: 1,
Colorspace.rgb: 3,
Colorspace.cmyk: 4,
Colorspace.lab: 3,
Colorspace.index: 1,
}
UNIT_SQUARE = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
def _is_unit_square(shorthand):
values = map(float, shorthand)
pairwise = zip(values, UNIT_SQUARE)
return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise)
class XobjectSettings(NamedTuple):
"""Info about an XObject found in a PDF."""
name: str
shorthand: tuple[float, float, float, float, float, float]
stack_depth: int
class InlineSettings(NamedTuple):
"""Info about an inline image found in a PDF."""
iimage: PdfInlineImage
shorthand: tuple[float, float, float, float, float, float]
stack_depth: int
class ContentsInfo(NamedTuple):
"""Info about various objects found in a PDF."""
xobject_settings: list[XobjectSettings]
inline_images: list[InlineSettings]
found_vector: bool
found_text: bool
name_index: Mapping[str, list[XobjectSettings]]
class TextboxInfo(NamedTuple):
"""Info about a text box found in a PDF."""
bbox: tuple[float, float, float, float]
is_visible: bool
is_corrupt: bool
class VectorMarker:
"""Sentinel indicating vector drawing operations were found on a page."""
class TextMarker:
"""Sentinel indicating text drawing operations were found on a page."""
def _normalize_stack(graphobjs):
"""Convert runs of qQ's in the stack into single graphobjs."""
for operands, operator in graphobjs:
operator = str(operator)
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
for char in operator: # Split into individual
yield ([], char) # Yield individual
else:
yield (operands, operator)
def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
"""Interpret the PDF content stream.
The stack represents the state of the PDF graphics stack. We are only
interested in the current transformation matrix (CTM) so we only track
this object; a full implementation would need to track many other items.
The CTM is initialized to the mapping from user space to device space.
PDF units are 1/72". In a PDF viewer or printer this matrix is initialized
to the transformation to device space. For example if set to
(1/72, 0, 0, 1/72, 0, 0) then all units would be calculated in inches.
Images are always considered to be (0, 0) -> (1, 1). Before drawing an
image there should be a 'cm' that sets up an image coordinate system
where drawing from (0, 0) -> (1, 1) will draw on the desired area of the
page.
PDF units suit our needs so we initialize ctm to the identity matrix.
According to the PDF specification, the maximum stack depth is 32. Other
viewers tolerate some amount beyond this. We issue a warning if the
stack depth exceeds the spec limit and set a hard limit beyond this to
bound our memory requirements. If the stack underflows behavior is
undefined in the spec, but we just pretend nothing happened and leave the
CTM unchanged.
"""
stack = []
ctm = Matrix(initial_shorthand)
xobject_settings: list[XobjectSettings] = []
inline_images: list[InlineSettings] = []
name_index = defaultdict(lambda: [])
found_vector = False
found_text = False
vector_ops = set('S s f F f* B B* b b*'.split())
text_showing_ops = set("""TJ Tj " '""".split())
image_ops = set('BI ID EI q Q Do cm'.split())
operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops)
for n, graphobj in enumerate(
_normalize_stack(parse_content_stream(contentstream, operator_whitelist))
):
operands, operator = graphobj
if operator == 'q':
stack.append(ctm)
if len(stack) > 32: # See docstring
if len(stack) > 128:
raise RuntimeError(
f"PDF graphics stack overflowed hard limit at operator {n}"
)
warn("PDF graphics stack overflowed spec limit")
elif operator == 'Q':
try:
ctm = stack.pop()
except IndexError:
# Keeping the ctm the same seems to be the only sensible thing
# to do. Just pretend nothing happened, keep calm and carry on.
warn("PDF graphics stack underflowed - PDF may be malformed")
elif operator == 'cm':
try:
ctm = Matrix(operands) @ ctm
except ValueError:
raise InputFileError(
"PDF content stream is corrupt - this PDF is malformed. "
"Use a PDF editor that is capable of visually inspecting the PDF."
)
elif operator == 'Do':
image_name = operands[0]
settings = XobjectSettings(
name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack)
)
xobject_settings.append(settings)
name_index[str(image_name)].append(settings)
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
iimage = operands[0]
inline = InlineSettings(
iimage=iimage, shorthand=ctm.shorthand, stack_depth=len(stack)
)
inline_images.append(inline)
elif operator in vector_ops:
found_vector = True
elif operator in text_showing_ops:
found_text = True
return ContentsInfo(
xobject_settings=xobject_settings,
inline_images=inline_images,
found_vector=found_vector,
found_text=found_text,
name_index=name_index,
)
def _get_dpi(ctm_shorthand, image_size) -> Resolution:
"""Given the transformation matrix and image size, find the image DPI.
PDFs do not include image resolution information within image data.
Instead, the PDF page content stream describes the location where the
image will be rasterized, and the effective resolution is the ratio of the
pixel size to raster target size.
Normally a scanned PDF has the paper size set appropriately but this is
not guaranteed. The most common case is a cropped image will change the
page size (/CropBox) without altering the page content stream. That means
it is not sufficient to assume that the image fills the page, even though
that is the most common case.
A PDF image may be scaled (always), cropped, translated, rotated in place
to an arbitrary angle (rarely) and skewed. Only equal area mappings can
be expressed, that is, it is not necessary to consider distortions where
the effective DPI varies with position.
To determine the image scale, transform an offset axis vector v0 (0, 0),
width-axis vector v0 (1, 0), height-axis vector vh (0, 1) with the matrix,
which gives the dimensions of the image in PDF units. From there we can
compare to actual image dimensions. PDF uses
row vector * matrix_transposed unlike the traditional
matrix * column vector.
The offset, width and height vectors can be combined in a matrix and
multiplied by the transform matrix. Then we want to calculated
magnitude(width_vector - offset_vector)
and
magnitude(height_vector - offset_vector)
When the above is worked out algebraically, the effect of translation
cancels out, and the vector magnitudes become functions of the nonzero
transformation matrix indices. The results of the derivation are used
in this code.
pdfimages -list does calculate the DPI in some way that is not completely
naive, but it does not get the DPI of rotated images right, so cannot be
used anymore to validate this. Photoshop works, or using Acrobat to
rotate the image back to normal.
It does not matter if the image is partially cropped, or even out of the
/MediaBox.
"""
a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name
# Calculate the width and height of the image in PDF units
image_drawn = hypot(a, b), hypot(c, d)
def calc(drawn, pixels, inches_per_pt=72.0):
# The scale of the image is pixels per unit of default user space (1/72")
scale = pixels / drawn if drawn != 0 else inf
dpi = scale * inches_per_pt
return dpi
dpi_w, dpi_h = (calc(image_drawn[n], image_size[n]) for n in range(2))
return Resolution(dpi_w, dpi_h)
class ImageInfo:
"""Information about an image found in a PDF.
This gathers information from pikepdf and pdfminer.six, and is pickle-able
so that it can be passed to a worker process, unlike objects from those
libraries.
"""
DPI_PREC = Decimal('1.000')
_comp: int | None
_name: str
def __init__(
self,
*,
name='',
pdfimage: Object | None = None,
inline: PdfInlineImage | None = None,
shorthand=None,
):
"""Initialize an ImageInfo."""
self._name = str(name)
self._shorthand = shorthand
pim: PdfInlineImage | PdfImage
if inline is not None:
self._origin = 'inline'
pim = inline
elif pdfimage is not None and isinstance(pdfimage, Stream):
self._origin = 'xobject'
pim = PdfImage(pdfimage)
else:
raise ValueError("Either pdfimage or inline must be set")
self._width = pim.width
self._height = pim.height
if (smask := pim.obj.get(Name.SMask, None)) is not None:
# SMask is pretty much an alpha channel, but in PDF it's possible
# for channel to have different dimensions than the image
# itself. Some PDF writers use this to create a grayscale stencil
# mask. For our purposes, the effective size is the size of the
# larger component (image or smask).
if isinstance(smask, Stream | Dictionary):
self._width = max(smask.get(Name.Width, 0), self._width)
self._height = max(smask.get(Name.Height, 0), self._height)
if (mask := pim.obj.get(Name.Mask, None)) is not None:
# If the image has a /Mask entry, it has an explicit mask.
# /Mask can be a Stream or an Array. If it's a Stream,
# use its /Width and /Height if they are larger than the main
# image's.
if isinstance(mask, Stream | Dictionary):
self._width = max(mask.get(Name.Width, 0), self._width)
self._height = max(mask.get(Name.Height, 0), self._height)
# If /ImageMask is true, then this image is a stencil mask
# (Images that draw with this stencil mask will have a reference to
# it in their /Mask, but we don't actually need that information)
if pim.image_mask:
self._type = 'stencil'
else:
self._type = 'image'
self._bpc = int(pim.bits_per_component)
try:
self._enc = FRIENDLY_ENCODING.get(pim.filters[0])
except IndexError:
self._enc = None
try:
self._color = FRIENDLY_COLORSPACE.get(pim.colorspace or '')
except NotImplementedError:
self._color = None
if self._enc == Encoding.jpeg2000:
self._color = Colorspace.jpeg2000
self._comp = None
if self._color == Colorspace.icc and isinstance(pim, PdfImage):
self._comp = self._init_icc(pim)
else:
if isinstance(self._color, Colorspace):
self._comp = FRIENDLY_COMP.get(self._color)
# Bit of a hack... infer grayscale if component count is uncertain
# but encoding only supports monochrome.
if self._comp is None and self._enc in (Encoding.ccitt, Encoding.jbig2):
self._comp = FRIENDLY_COMP[Colorspace.gray]
def _init_icc(self, pim: PdfImage):
try:
icc = pim.icc
except UnsupportedImageTypeError as e:
logger.warning(
f"An image with a corrupt or unreadable ICC profile was found. "
f"Output PDF may not match the input PDF visually: {e}. {self}"
)
return None
# Check the ICC profile to determine actual colorspace
if icc is None or not hasattr(icc, 'profile'):
logger.warning(
f"An image with an ICC profile but no ICC profile data was found. "
f"The output PDF may not match the input PDF visually. {self}"
)
return None
try:
if icc.profile.xcolor_space == 'GRAY':
return 1
elif icc.profile.xcolor_space == 'CMYK':
return 4
else:
return 3
except AttributeError:
return None
@property
def name(self):
"""Name of the image as it appears in the PDF."""
return self._name
@property
def type_(self):
"""Type of image, either 'image' or 'stencil'."""
return self._type
@property
def width(self) -> int:
"""Width of the image in pixels."""
return self._width
@property
def height(self) -> int:
"""Height of the image in pixels."""
return self._height
@property
def bpc(self):
"""Bits per component."""
return self._bpc
@property
def color(self):
"""Colorspace of the image."""
return self._color if self._color is not None else '?'
@property
def comp(self):
"""Number of components/channels in the image."""
return self._comp if self._comp is not None else '?'
@property
def enc(self):
"""Encoding of the image."""
return self._enc if self._enc is not None else 'image'
@property
def renderable(self) -> bool:
"""Whether the image is renderable.
Some PDFs in the wild have invalid images that are not renderable,
due to unusual dimensions.
Stencil masks are not also not renderable, since they are not
drawn, but rather they control how rendering happens.
"""
return (
self.dpi.is_finite
and self.width >= 0
and self.height >= 0
and self.type_ != 'stencil'
)
@property
def dpi(self) -> Resolution:
"""Dots per inch of the image.
Calculated based on where and how the image is drawn in the PDF.
"""
return _get_dpi(self._shorthand, (self._width, self._height))
@property
def printed_area(self) -> float:
"""Physical area of the image in square inches."""
if not self.renderable:
return 0.0
return float((self.width / self.dpi.x) * (self.height / self.dpi.y))
def __repr__(self):
"""Return a string representation of the image."""
return (
f"<ImageInfo '{self.name}' {self.type_} {self.width}×{self.height} "
f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>"
)
def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
"""Find inline images in the contentstream."""
for n, inline in enumerate(contentsinfo.inline_images):
yield ImageInfo(
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
)
def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
"""Search for all XObject-based images in the container.
Usually the container is a page, but it could also be a Form XObject
that contains images. Filter out the Form XObjects which are dealt with
elsewhere.
Generate a sequence of tuples (image, xobj container), where container,
where xobj is the name of the object and image is the object itself,
since the object does not know its own name.
"""
if Name.Resources not in container:
return
resources = container[Name.Resources]
if Name.XObject not in resources:
return
for key, candidate in resources[Name.XObject].items():
if candidate is None or Name.Subtype not in candidate:
continue
if candidate[Name.Subtype] == Name.Image:
pdfimage = candidate
yield (pdfimage, key)
def _find_regular_images(
container: Object, contentsinfo: ContentsInfo
) -> Iterator[ImageInfo]:
"""Find images stored in the container's /Resources /XObject.
Usually the container is a page, but it could also be a Form XObject
that contains images.
Generates images with their DPI at time of drawing.
"""
for pdfimage, xobj in _image_xobjects(container):
if xobj not in contentsinfo.name_index:
continue
for draw in contentsinfo.name_index[xobj]:
if draw.stack_depth == 0 and _is_unit_square(draw.shorthand):
# At least one PDF in the wild (and test suite) draws an image
# when the graphics stack depth is 0, meaning that the image
# gets drawn into a square of 1x1 PDF units (or 1/72",
# or 0.35 mm). The equivalent DPI will be >100,000. Exclude
# these from our DPI calculation for the page.
continue
yield ImageInfo(name=draw.name, pdfimage=pdfimage, shorthand=draw.shorthand)
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
"""Find any images that are in Form XObjects in the container.
The container may be a page, or a parent Form XObject.
"""
if Name.Resources not in container:
return
resources = container[Name.Resources]
if Name.XObject not in resources:
return
xobjs = resources[Name.XObject].as_dict()
for xobj in xobjs:
candidate = xobjs[xobj]
if candidate is None or candidate.get(Name.Subtype) != Name.Form:
continue
form_xobject = candidate
for settings in contentsinfo.xobject_settings:
if settings.name != xobj:
continue
# Find images once for each time this Form XObject is drawn.
# This could be optimized to cache the multiple drawing events
# but in practice both Form XObjects and multiple drawing of the
# same object are both very rare.
ctm_shorthand = settings.shorthand
yield from _process_content_streams(
pdf=pdf, container=form_xobject, shorthand=ctm_shorthand
)
def _process_content_streams(
*, pdf: Pdf, container: Object, shorthand=None
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
"""Find all individual instances of images drawn in the container.
Usually the container is a page, but it may also be a Form XObject.
On a typical page images are stored inline or as regular images
in an XObject.
Form XObjects may include inline images, XObject images,
and recursively, other Form XObjects; and also vector graphic objects.
Every instance of an image being drawn somewhere is flattened and
treated as a unique image, since if the same image is drawn multiple times
on one page it may be drawn at differing resolutions, and our objective
is to find the resolution at which the page can be rastered without
downsampling.
"""
if container.get(Name.Type) == Name.Page and Name.Contents in container:
initial_shorthand = shorthand or UNIT_SQUARE
elif (
container.get(Name.Type) == Name.XObject
and container[Name.Subtype] == Name.Form
):
# Set the CTM to the state it was when the "Do" operator was
# encountered that is drawing this instance of the Form XObject
ctm = Matrix(shorthand) if shorthand else Matrix()
# A Form XObject may provide its own matrix to map form space into
# user space. Get this if one exists
form_shorthand = container.get(Name.Matrix, Matrix())
form_matrix = Matrix(form_shorthand)
# Concatenate form matrix with CTM to ensure CTM is correct for
# drawing this instance of the XObject
ctm = form_matrix @ ctm
initial_shorthand = ctm.shorthand
else:
return
contentsinfo = _interpret_contents(container, initial_shorthand)
if contentsinfo.found_vector:
yield VectorMarker()
if contentsinfo.found_text:
yield TextMarker()
yield from _find_inline_images(contentsinfo)
yield from _find_regular_images(container, contentsinfo)
yield from _find_form_xobject_images(pdf, container, contentsinfo)
def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool:
"""Smarter text detection that ignores text in margins."""
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
@@ -723,120 +80,6 @@ def simplify_textboxes(
yield TextboxInfo(box.bbox, visible, corrupt)
worker_pdf = None # pylint: disable=invalid-name
def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel):
global worker_pdf # pylint: disable=global-statement,invalid-name
pikepdf_enable_mmap()
logging.getLogger('pdfminer').setLevel(pdfminer_loglevel)
# If the pdf is not opened, open a copy for our worker process to use
if pdf is None:
worker_pdf = Pdf.open(infile)
def on_process_close():
worker_pdf.close()
# Close when this process exits
atexit.register(on_process_close)
@contextmanager
def _pdf_pageinfo_sync_pdf(thread_pdf: Pdf | None, infile: Path):
if thread_pdf is not None:
yield thread_pdf
elif worker_pdf is not None:
yield worker_pdf
else:
with Pdf.open(infile) as pdf:
yield pdf
def _pdf_pageinfo_sync(
pageno: int,
thread_pdf: Pdf | None,
infile: Path,
check_pages: Container[int],
detailed_analysis: bool,
miner_state: PdfMinerState | None,
) -> PageInfo:
with _pdf_pageinfo_sync_pdf(thread_pdf, infile) as pdf:
return PageInfo(
pdf, pageno, infile, check_pages, detailed_analysis, miner_state
)
def _pdf_pageinfo_concurrent(
pdf,
executor: Executor,
max_workers: int,
use_threads: bool,
infile,
progbar,
check_pages,
detailed_analysis: bool = False,
miner_state: PdfMinerState | None = None,
) -> Sequence[PageInfo | None]:
pages: list[PageInfo | None] = [None] * len(pdf.pages)
def update_pageinfo(page: PageInfo, pbar: ProgressBar):
if not page:
raise InputFileError("Could read a page in the PDF")
pages[page.pageno] = page
pbar.update()
if max_workers is None:
max_workers = available_cpu_count()
total = len(pdf.pages)
n_workers = min(1 + len(pages) // 4, max_workers)
if n_workers == 1:
# If we decided on only one worker, there is no point in using
# a separate process.
use_threads = True
if use_threads and n_workers > 1:
# If we are using threads, there is no point in using more than one
# worker thread - they will just fight over the GIL.
n_workers = 1
# If we use a thread, we can pass the already-open Pdf for them to use
# If we use processes, we pass a None which tells the init function to open its
# own
initial_pdf = pdf if use_threads else None
contexts = (
(n, initial_pdf, infile, check_pages, detailed_analysis, miner_state)
for n in range(total)
)
assert n_workers == 1 if use_threads else n_workers >= 1, "Not multithreadable"
logger.debug(
f"Gathering info with {n_workers} "
+ ('thread' if use_threads else 'process')
+ " workers"
)
executor(
use_threads=use_threads,
max_workers=n_workers,
progress_kwargs=dict(
total=total, desc="Scanning contents", unit='page', disable=not progbar
),
worker_initializer=partial(
_pdf_pageinfo_sync_init,
initial_pdf,
infile,
logging.getLogger('pdfminer').level,
),
task=_pdf_pageinfo_sync,
task_arguments=contexts,
task_finished=update_pageinfo,
)
return pages
class PageResolutionProfile(NamedTuple):
"""Information about the resolutions of a page."""
@@ -1061,12 +304,10 @@ class PageInfo:
obj: TextboxInfo, want_visible: bool | None, want_corrupt: bool | None
) -> bool:
result = True
if want_visible is not None:
if obj.is_visible != want_visible:
result = False
if want_corrupt is not None:
if obj.is_corrupt != want_corrupt:
result = False
if want_visible is not None and obj.is_visible != want_visible:
result = False
if want_corrupt is not None and obj.is_corrupt != want_corrupt:
result = False
return result
if not self._textboxes:
@@ -1199,9 +440,10 @@ class PdfInfo:
)
self._needs_rendering = pdf.Root.get(Name.NeedsRendering, False)
if Name.AcroForm in pdf.Root:
if len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0:
self._has_acroform = True
elif Name.XFA in pdf.Root.AcroForm:
if (
len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0
or Name.XFA in pdf.Root.AcroForm
):
self._has_acroform = True
self._has_signature = bool(pdf.Root.AcroForm.get(Name.SigFlags, 0) & 1)
self._is_tagged = bool(
@@ -1209,7 +451,7 @@ class PdfInfo:
)
@property
def pages(self) -> Sequence[PageInfo | None]:
def pages(self) -> list[PageInfo | None]:
"""Return list of PageInfo objects, one per page in the PDF."""
return self._pages
+2 -2
View File
@@ -58,7 +58,7 @@ def pdfsimplefont__init__(
return
setattr(PDFSimpleFont, '__init__', pdfsimplefont__init__)
PDFSimpleFont.__init__ = pdfsimplefont__init__
# Patch pdfminer.six buffer size
# The parser doesn't properly handle keyword tokens are split across the end of the
@@ -363,7 +363,7 @@ class PdfMinerState:
except StopIteration:
raise InputFileError(
f"pdfminer did not find page {pageno} in the input file."
)
) from None
page = self.page_cache[pageno]
if not page:
raise InputFileError(
+121 -22
View File
@@ -6,15 +6,18 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from argparse import ArgumentParser, Namespace
from argparse import ArgumentParser
from collections.abc import Sequence, Set
from enum import StrEnum
from logging import Handler
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple
import pluggy
from pydantic import BaseModel
from ocrmypdf import Executor, PdfContext
from ocrmypdf._options import OcrOptions
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.helpers import Resolution
@@ -23,10 +26,23 @@ if TYPE_CHECKING:
# pylint: disable=ungrouped-imports
from ocrmypdf._jobcontext import PageContext
from ocrmypdf.hocrtransform import OcrElement
from ocrmypdf.pdfinfo import PdfInfo
# pylint: enable=ungrouped-imports
class GhostscriptRasterDevice(StrEnum):
"""Possible raster devices for Ghostscript."""
JPEGGRAY = 'jpeggray'
JPEGCOLOR = 'jpeg'
PNGMONO = 'pngmono'
PNGGRAY = 'pnggray'
PNG256 = 'png256'
PNG16M = 'png16m'
hookspec = pluggy.HookspecMarker('ocrmypdf')
# pylint: disable=unused-argument
@@ -34,7 +50,7 @@ hookspec = pluggy.HookspecMarker('ocrmypdf')
@hookspec(firstresult=True)
def get_logging_console() -> Handler:
def get_logging_console() -> Handler: # type: ignore[return-value]
"""Returns a custom logging handler.
Generally this is necessary when both logging output and a progress bar are both
@@ -87,7 +103,29 @@ def add_options(parser: ArgumentParser) -> None:
@hookspec
def check_options(options: Namespace) -> None:
def register_options() -> dict[str, type[BaseModel]]:
"""Return plugin's option models keyed by namespace.
This hook allows plugins to register their option models with the
plugin option registry. The returned dictionary should map namespace
strings to Pydantic model classes.
Returns:
Dictionary mapping namespace strings to BaseModel classes
Example:
@hookimpl
def register_options():
return {'tesseract': TesseractOptions}
Note:
This hook will be called from the main process during plugin
infrastructure setup, before child worker processes are forked.
"""
@hookspec
def check_options(options: OcrOptions) -> None:
"""Called to ask the plugin to check all of the options.
The plugin may check if options that it added are valid.
@@ -110,7 +148,7 @@ def check_options(options: Namespace) -> None:
@hookspec(firstresult=True)
def get_executor(progressbar_class: type[ProgressBar]) -> Executor:
def get_executor(progressbar_class: type[ProgressBar]) -> Executor: # type: ignore[return-value]
"""Called to obtain an object that manages parallel execution.
This may be used to replace OCRmyPDF's default parallel execution system
@@ -138,7 +176,7 @@ def get_executor(progressbar_class: type[ProgressBar]) -> Executor:
@hookspec(firstresult=True)
def get_progressbar_class() -> type[ProgressBar]:
def get_progressbar_class() -> type[ProgressBar]: # type: ignore[return-value]
"""Called to obtain a class that can be used to monitor progress.
OCRmyPDF will call this function when it wants to display a progress bar.
@@ -158,7 +196,7 @@ def get_progressbar_class() -> type[ProgressBar]:
@hookspec
def validate(pdfinfo: PdfInfo, options: Namespace) -> None:
def validate(pdfinfo: PdfInfo, options: OcrOptions) -> None:
"""Called to give a plugin an opportunity to review *options* and *pdfinfo*.
*options* contains the "work order" to process a particular file. *pdfinfo*
@@ -182,14 +220,16 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None:
def rasterize_pdf_page(
input_file: Path,
output_file: Path,
raster_device: str,
raster_device: GhostscriptRasterDevice,
raster_dpi: Resolution,
pageno: int,
page_dpi: Resolution | None,
rotation: int | None,
filter_vector: bool,
stop_on_soft_error: bool,
) -> Path:
options: OcrOptions | None,
use_cropbox: bool,
) -> Path: # type: ignore[return-value]
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units.
The image is sized to match the integer pixels dimensions implied by
@@ -212,6 +252,12 @@ def rasterize_pdf_page(
cannot proceed, it should always raise an exception, regardless of
this setting. One "soft error" would be a missing font that is
required to properly rasterize the PDF.
options: OCRmyPDF options. Plugins may use this to check settings like
``options.rasterizer`` to determine whether they should handle the
request or defer to another plugin. Introduced in version 17.0.
use_cropbox: If True, rasterize the page's CropBox instead of the
MediaBox. Default is False (use MediaBox) for consistency with
Ghostscript's default behavior.
Returns:
Path: output_file if successful
@@ -226,7 +272,7 @@ def rasterize_pdf_page(
@hookspec(firstresult=True)
def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image: # type: ignore[return-value]
"""Called to filter the image before it is sent to OCR.
This is the image that OCR sees, not what the user sees when they view the
@@ -261,7 +307,7 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
@hookspec(firstresult=True)
def filter_page_image(page: PageContext, image_filename: Path) -> Path:
def filter_page_image(page: PageContext, image_filename: Path) -> Path: # type: ignore[return-value]
"""Called to filter the whole page before it is inserted into the PDF.
A whole page image is only produced when preprocessing command line arguments
@@ -298,7 +344,7 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
@hookspec(firstresult=True)
def filter_pdf_page(page: PageContext, image_filename: Path, output_pdf: Path) -> Path:
def filter_pdf_page(page: PageContext, image_filename: Path, output_pdf: Path) -> Path: # type: ignore[return-value]
"""Called to convert a filtered whole page image into a PDF.
A whole page image is only produced when preprocessing command line arguments
@@ -368,7 +414,7 @@ class OcrEngine(ABC):
@staticmethod
@abstractmethod
def creator_tag(options: Namespace) -> str:
def creator_tag(options: OcrOptions) -> str:
"""Returns the creator tag to identify this software's role in creating the PDF.
This tag will be inserted in the XMP metadata and DocumentInfo dictionary
@@ -380,7 +426,7 @@ class OcrEngine(ABC):
"""
@abstractmethod
def __str__(self):
def __str__(self) -> str:
"""Returns name of OCR engine and version.
This is used when OCRmyPDF wants to mention the name of the OCR engine
@@ -389,7 +435,7 @@ class OcrEngine(ABC):
@staticmethod
@abstractmethod
def languages(options: Namespace) -> Set[str]:
def languages(options: OcrOptions) -> Set[str]:
"""Returns the set of all languages that are supported by the engine.
Languages are typically given in 3-letter ISO 3166-1 codes, but actually
@@ -398,18 +444,18 @@ class OcrEngine(ABC):
@staticmethod
@abstractmethod
def get_orientation(input_file: Path, options: Namespace) -> OrientationConfidence:
def get_orientation(input_file: Path, options: OcrOptions) -> OrientationConfidence:
"""Returns the orientation of the image."""
@staticmethod
def get_deskew(input_file: Path, options: Namespace) -> float:
def get_deskew(input_file: Path, options: OcrOptions) -> float:
"""Returns the deskew angle of the image, in degrees."""
return 0.0
@staticmethod
@abstractmethod
def generate_hocr(
input_file: Path, output_hocr: Path, output_text: Path, options: Namespace
input_file: Path, output_hocr: Path, output_text: Path, options: OcrOptions
) -> None:
"""Called to produce a hOCR file from a page image and sidecar text file.
@@ -432,7 +478,7 @@ class OcrEngine(ABC):
@staticmethod
@abstractmethod
def generate_pdf(
input_file: Path, output_pdf: Path, output_text: Path, options: Namespace
input_file: Path, output_pdf: Path, output_text: Path, options: OcrOptions
) -> None:
"""Called to produce a text only PDF from a page image.
@@ -452,14 +498,67 @@ class OcrEngine(ABC):
options: The command line options.
"""
@staticmethod
def supports_generate_ocr() -> bool:
"""Return True if this engine supports the generate_ocr() API.
The pipeline uses this to determine whether to call generate_ocr()
or fall back to generate_hocr().
Returns:
False by default. Engines implementing generate_ocr() should
override this to return True.
"""
return False
@staticmethod
def generate_ocr(
input_file: Path,
options: OcrOptions,
page_number: int = 0,
) -> tuple[OcrElement, str]:
"""Generate OCR results as an OcrElement tree.
This is the modern API for OCR engines. Engines implementing this method
can return structured OCR results directly without intermediate file formats.
This function executes in a worker thread or worker process. OCRmyPDF
automatically parallelizes OCR over pages. The OCR engine should not
introduce more parallelism.
Args:
input_file: A page image on which to perform OCR.
options: The command line options.
page_number: Zero-indexed page number (for multi-page context).
Returns:
A tuple of (OcrElement tree for the page, plain text content).
The OcrElement should have ocr_class=OcrClass.PAGE as its root.
Note:
This method is optional. Engines that don't implement it should
leave the default implementation, and the pipeline will fall back to
generate_hocr() or generate_pdf().
"""
raise NotImplementedError("This OcrEngine does not implement generate_ocr()")
@hookspec(firstresult=True)
def get_ocr_engine() -> OcrEngine:
def get_ocr_engine(options: OcrOptions | None) -> OcrEngine: # type: ignore[return-value]
"""Returns an OcrEngine to use for processing this file.
The OcrEngine may be instantiated multiple times, by both the main process
and child process.
When multiple OCR engine plugins are installed, plugins should check
``options.ocr_engine`` and return ``None`` if they are not the selected
engine. The hook caller will then try the next plugin.
Args:
options: The current OcrOptions, used to determine which engine
to select. May be None for backward compatibility with external
plugins.
Note:
This is a :ref:`firstresult hook<firstresult>`.
"""
@@ -475,7 +574,7 @@ def generate_pdfa(
pdfa_part: str,
progressbar_class: type[ProgressBar] | None,
stop_on_soft_error: bool,
) -> Path:
) -> Path: # type: ignore[return-value]
"""Generate a PDF/A.
This API strongly assumes a PDF/A generator with Ghostscript's semantics.
@@ -522,7 +621,7 @@ def optimize_pdf(
context: PdfContext,
executor: Executor,
linearize: bool,
) -> tuple[Path, Sequence[str]]:
) -> tuple[Path, Sequence[str]]: # type: ignore[return-value]
"""Optimize a PDF after image, OCR and metadata processing.
If the input_pdf is a PDF/A, the plugin should modify input_pdf in a way
@@ -559,7 +658,7 @@ def optimize_pdf(
@hookspec(firstresult=True)
def is_optimization_enabled(context: PdfContext) -> bool:
def is_optimization_enabled(context: PdfContext) -> bool: # type: ignore[return-value]
"""For a given PdfContext, OCRmyPDF asks the plugin if optimization is enabled.
An optimization plugin might be installed and active but could be disabled by
+1 -4
View File
@@ -40,8 +40,5 @@ class OcrQualityDictionary:
w != w.lower() and w.lower() in self.dictionary
):
matches += 1
if matches > 0:
hit_ratio = matches / len(text_words)
else:
hit_ratio = 0.0
hit_ratio = matches / len(text_words) if matches > 0 else 0.0
return hit_ratio
+6 -6
View File
@@ -23,13 +23,13 @@ from ocrmypdf.exceptions import MissingDependencyError
log = logging.getLogger(__name__)
Args = Sequence[Path | str]
OsEnviron = os._Environ # pylint: disable=protected-access
Environ = Mapping[str, str] | os._Environ # pylint: disable=protected-access
def run(
args: Args,
*,
env: OsEnviron | None = None,
env: Environ | None = None,
logs_errors_to_stdout: bool = False,
check: bool = False,
**kwargs,
@@ -81,7 +81,7 @@ def run_polling_stderr(
*,
callback: Callable[[str], None],
check: bool = False,
env: OsEnviron | None = None,
env: Environ | None = None,
**kwargs,
) -> CompletedProcess:
"""Run a process like ``ocrmypdf.subprocess.run``, and poll stderr.
@@ -116,8 +116,8 @@ def run_polling_stderr(
def _fix_process_args(
args: Args, env: OsEnviron | None, kwargs
) -> tuple[Args, OsEnviron, logging.Logger, bool]:
args: Args, env: Environ | None, kwargs
) -> tuple[Args, Environ, logging.Logger, bool]:
if not env:
env = os.environ
@@ -142,7 +142,7 @@ def get_version(
*,
version_arg: str = '--version',
regex=r'(\d+(\.\d+)*)',
env: OsEnviron | None = None,
env: Environ | None = None,
) -> str:
"""Get the version of the specified program.
@@ -5,170 +5,171 @@
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='tesseract 5.3.2' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
<meta name='ocr-system' content='tesseract 5.5.1' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_dir ocrp_lang ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.jcdzf4up/000001_ocr.png"; bbox 0 0 4000 2864; ppageno 0; scan_res 2400 2400'>
<div class='ocr_carea' id='block_1_1' title="bbox 251 146 2173 237">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 251 146 2173 237">
<span class='ocr_line' id='line_1_1' title="bbox 251 146 2173 237; baseline -0.006 5; x_size 99.418808; x_descenders 9.4188042; x_ascenders 32">
<span class='ocrx_word' id='word_1_1' title='bbox 251 154 274 176; x_wconf 89'>i</span>
<span class='ocrx_word' id='word_1_2' title='bbox 1080 146 1152 237; x_wconf 89'>a</span>
<span class='ocrx_word' id='word_1_3' title='bbox 1254 153 1406 235; x_wconf 92'>la</span>
<span class='ocrx_word' id='word_1_4' title='bbox 1500 153 2173 235; x_wconf 95'>Waterman</span>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.662_0rn0/000001_ocr.png"; bbox 0 0 4001 2864; ppageno 0; scan_res 2400 2400'>
<div class='ocr_carea' id='block_1_1' title="bbox 142 152 2174 236">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 142 152 2174 236">
<span class='ocr_line' id='line_1_1' title="bbox 142 152 2174 236; baseline 0.003 -11; x_size 109; x_descenders 30; x_ascenders 24">
<span class='ocrx_word' id='word_1_1' title='bbox 142 157 969 214; x_wconf 89'>Linzensoep</span>
<span class='ocrx_word' id='word_1_2' title='bbox 1080 177 1152 236; x_wconf 95'>a</span>
<span class='ocrx_word' id='word_1_3' title='bbox 1254 152 1406 234; x_wconf 94'>la</span>
<span class='ocrx_word' id='word_1_4' title='bbox 1500 152 2174 234; x_wconf 96'>Waterman</span>
</span>
</p>
</div>
<div class='ocr_separator' id='block_1_2' title="bbox 135 202 2180 295"></div>
<div class='ocr_carea' id='block_1_3' title="bbox 145 414 2929 1221">
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 145 414 1154 516">
<span class='ocr_line' id='line_1_2' title="bbox 145 414 1154 516; baseline -0.005 -4; x_size 122.38636; x_descenders 24.386362; x_ascenders 40">
<span class='ocrx_word' id='word_1_5' title='bbox 145 414 211 512; x_wconf 60'>4</span>
<span class='ocrx_word' id='word_1_6' title='bbox 318 453 551 516; x_wconf 93'>ons</span>
<span class='ocrx_word' id='word_1_7' title='bbox 660 430 1154 512; x_wconf 91'>linzen</span>
<div class='ocr_separator' id='block_1_2' title="bbox 136 209 2181 286"></div>
<div class='ocr_carea' id='block_1_3' title="bbox 145 413 2929 1220">
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 145 413 1154 515">
<span class='ocr_line' id='line_1_2' title="bbox 145 413 1154 515; baseline -0.005 -4; x_size 121.97968; x_descenders 23.979671; x_ascenders 40">
<span class='ocrx_word' id='word_1_5' title='bbox 145 413 211 511; x_wconf 49'>4</span>
<span class='ocrx_word' id='word_1_6' title='bbox 318 452 551 515; x_wconf 91'>ons</span>
<span class='ocrx_word' id='word_1_7' title='bbox 660 429 1154 511; x_wconf 84'>linzen</span>
</span>
</p>
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 146 568 1239 667">
<span class='ocr_line' id='line_1_3' title="bbox 146 568 1239 667; baseline -0.004 -16; x_size 99; x_descenders 17; x_ascenders 24">
<span class='ocrx_word' id='word_1_8' title='bbox 146 569 209 667; x_wconf 94'>3</span>
<span class='ocrx_word' id='word_1_9' title='bbox 323 568 729 652; x_wconf 83'>liter</span>
<span class='ocrx_word' id='word_1_10' title='bbox 821 569 1239 650; x_wconf 96'>water</span>
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 146 567 1239 666">
<span class='ocr_line' id='line_1_3' title="bbox 146 567 1239 666; baseline -0.004 -16; x_size 99; x_descenders 17; x_ascenders 24">
<span class='ocrx_word' id='word_1_8' title='bbox 146 568 209 666; x_wconf 94'>3</span>
<span class='ocrx_word' id='word_1_9' title='bbox 323 567 729 651; x_wconf 79'>liter</span>
<span class='ocrx_word' id='word_1_10' title='bbox 821 568 1239 649; x_wconf 95'>water</span>
</span>
</p>
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 148 705 645 804">
<span class='ocr_line' id='line_1_4' title="bbox 148 705 645 804; baseline -0.016 -14; x_size 103; x_descenders 19; x_ascenders 27">
<span class='ocrx_word' id='word_1_11' title='bbox 148 706 211 804; x_wconf 88'>3</span>
<span class='ocrx_word' id='word_1_12' title='bbox 311 705 645 789; x_wconf 52'>uien</span>
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 148 704 645 803">
<span class='ocr_line' id='line_1_4' title="bbox 148 704 645 803; baseline -0.016 -14; x_size 103; x_descenders 19; x_ascenders 27">
<span class='ocrx_word' id='word_1_11' title='bbox 148 705 211 803; x_wconf 87'>3</span>
<span class='ocrx_word' id='word_1_12' title='bbox 311 704 645 788; x_wconf 78'>uien</span>
</span>
</p>
<p class='ocr_par' id='par_1_5' lang='eng' title="bbox 147 832 1154 950">
<span class='ocr_line' id='line_1_5' title="bbox 147 832 1154 950; baseline -0.004 -27; x_size 118; x_descenders 28; x_ascenders 30">
<span class='ocrx_word' id='word_1_13' title='bbox 147 832 623 950; x_wconf 91'>bloem,</span>
<span class='ocrx_word' id='word_1_14' title='bbox 737 843 1154 924; x_wconf 91'>boter</span>
<p class='ocr_par' id='par_1_5' lang='eng' title="bbox 147 831 1154 949">
<span class='ocr_line' id='line_1_5' title="bbox 147 831 1154 949; baseline -0.004 -27; x_size 118; x_descenders 28; x_ascenders 30">
<span class='ocrx_word' id='word_1_13' title='bbox 147 831 623 949; x_wconf 91'>bloem,</span>
<span class='ocrx_word' id='word_1_14' title='bbox 737 842 1154 923; x_wconf 92'>boter</span>
</span>
</p>
<p class='ocr_par' id='par_1_6' lang='eng' title="bbox 148 979 1241 1087">
<span class='ocr_line' id='line_1_6' title="bbox 148 979 1241 1087; baseline -0.005 -21; x_size 107; x_descenders 24; x_ascenders 24">
<span class='ocrx_word' id='word_1_15' title='bbox 148 983 215 1066; x_wconf 88'>2</span>
<span class='ocrx_word' id='word_1_16' title='bbox 312 983 807 1087; x_wconf 88'>kopjes</span>
<span class='ocrx_word' id='word_1_17' title='bbox 905 979 1241 1062; x_wconf 92'>melk</span>
<p class='ocr_par' id='par_1_6' lang='eng' title="bbox 148 978 1241 1086">
<span class='ocr_line' id='line_1_6' title="bbox 148 978 1241 1086; baseline -0.005 -21; x_size 107; x_descenders 24; x_ascenders 24">
<span class='ocrx_word' id='word_1_15' title='bbox 148 982 215 1065; x_wconf 78'>2</span>
<span class='ocrx_word' id='word_1_16' title='bbox 312 982 807 1086; x_wconf 78'>kopjes</span>
<span class='ocrx_word' id='word_1_17' title='bbox 905 978 1241 1061; x_wconf 90'>melk</span>
</span>
</p>
<p class='ocr_par' id='par_1_7' lang='eng' title="bbox 154 1109 2929 1221">
<span class='ocr_line' id='line_1_7' title="bbox 154 1109 2929 1221; baseline -0.003 -21; x_size 112; x_descenders 28; x_ascenders 26">
<span class='ocrx_word' id='word_1_18' title='bbox 154 1117 791 1221; x_wconf 92'>laurier,</span>
<span class='ocrx_word' id='word_1_19' title='bbox 906 1111 1810 1219; x_wconf 90'>kruidnagel,</span>
<span class='ocrx_word' id='word_1_20' title='bbox 1927 1109 2490 1219; x_wconf 90'>kerrie,</span>
<span class='ocrx_word' id='word_1_21' title='bbox 2615 1110 2929 1195; x_wconf 91'>zout</span>
<p class='ocr_par' id='par_1_7' lang='eng' title="bbox 154 1108 2929 1220">
<span class='ocr_line' id='line_1_7' title="bbox 154 1108 2929 1220; baseline -0.003 -21; x_size 108; x_descenders 24; x_ascenders 26">
<span class='ocrx_word' id='word_1_18' title='bbox 154 1116 791 1220; x_wconf 90'>laurier,</span>
<span class='ocrx_word' id='word_1_19' title='bbox 906 1110 1810 1218; x_wconf 91'>kruidnagel,</span>
<span class='ocrx_word' id='word_1_20' title='bbox 1927 1108 2491 1218; x_wconf 87'>kerrie,</span>
<span class='ocrx_word' id='word_1_21' title='bbox 2616 1109 2929 1194; x_wconf 91'>zout</span>
</span>
</p>
</div>
<div class='ocr_carea' id='block_1_4' title="bbox 147 1383 3706 2731">
<p class='ocr_par' id='par_1_8' lang='eng' title="bbox 147 1383 3706 2731">
<span class='ocr_line' id='line_1_8' title="bbox 149 1383 3612 1474; baseline -0.003 -2; x_size 107.01524; x_descenders 25.01524; x_ascenders 21">
<span class='ocrx_word' id='word_1_22' title='bbox 149 1395 303 1474; x_wconf 93'>De</span>
<span class='ocrx_word' id='word_1_23' title='bbox 411 1390 902 1473; x_wconf 80'>linzgen</span>
<span class='ocrx_word' id='word_1_24' title='bbox 996 1409 1497 1470; x_wconf 89'>wassen</span>
<span class='ocrx_word' id='word_1_25' title='bbox 1595 1409 1752 1470; x_wconf 88'>en</span>
<span class='ocrx_word' id='word_1_26' title='bbox 1853 1385 2166 1467; x_wconf 75'>in-l</span>
<span class='ocrx_word' id='word_1_27' title='bbox 2275 1383 2684 1466; x_wconf 93'>liter</span>
<span class='ocrx_word' id='word_1_28' title='bbox 2775 1383 3278 1464; x_wconf 91'>kokend</span>
<span class='ocrx_word' id='word_1_29' title='bbox 3368 1401 3612 1462; x_wconf 90'>wa-</span>
<div class='ocr_carea' id='block_1_4' title="bbox 147 1382 3707 2731">
<p class='ocr_par' id='par_1_8' lang='eng' title="bbox 147 1382 3707 2731">
<span class='ocr_line' id='line_1_8' title="bbox 149 1382 3613 1474; baseline -0.003 -2; x_size 107.47087; x_descenders 24.470867; x_ascenders 23">
<span class='ocrx_word' id='word_1_22' title='bbox 149 1394 303 1474; x_wconf 93'>De</span>
<span class='ocrx_word' id='word_1_23' title='bbox 411 1389 902 1473; x_wconf 69'>linzen</span>
<span class='ocrx_word' id='word_1_24' title='bbox 997 1408 1497 1470; x_wconf 89'>wassen</span>
<span class='ocrx_word' id='word_1_25' title='bbox 1596 1408 1752 1469; x_wconf 92'>en</span>
<span class='ocrx_word' id='word_1_26' title='bbox 1853 1384 2167 1467; x_wconf 87'>in</span>
<span class='ocrx_word' id='word_1_27' title='bbox 2088 1378 2166 1494; x_wconf 53'>1</span>
<span class='ocrx_word' id='word_1_28' title='bbox 2276 1382 2685 1466; x_wconf 92'>liter</span>
<span class='ocrx_word' id='word_1_29' title='bbox 2776 1382 3278 1464; x_wconf 90'>kokend</span>
<span class='ocrx_word' id='word_1_30' title='bbox 3369 1401 3613 1462; x_wconf 88'>wa-</span>
</span>
<span class='ocr_line' id='line_1_9' title="bbox 157 1516 3520 1632; baseline -0.004 -21; x_size 110; x_descenders 25; x_ascenders 25">
<span class='ocrx_word' id='word_1_30' title='bbox 157 1531 394 1611; x_wconf 94'>ter</span>
<span class='ocrx_word' id='word_1_31' title='bbox 495 1527 558 1609; x_wconf 93'>1</span>
<span class='ocrx_word' id='word_1_32' title='bbox 658 1530 903 1632; x_wconf 84'>dag</span>
<span class='ocrx_word' id='word_1_33' title='bbox 1002 1527 1415 1609; x_wconf 90'>laten</span>
<span class='ocrx_word' id='word_1_34' title='bbox 1505 1525 1979 1611; x_wconf 58'>weken,</span>
<span class='ocrx_word' id='word_1_35' title='bbox 2103 1521 2167 1603; x_wconf 96'>2</span>
<span class='ocrx_word' id='word_1_36' title='bbox 2275 1518 2683 1603; x_wconf 83'>liter</span>
<span class='ocrx_word' id='word_1_37' title='bbox 2777 1519 3194 1601; x_wconf 96'>water</span>
<span class='ocrx_word' id='word_1_38' title='bbox 3286 1516 3520 1599; x_wconf 89'>bij</span>
<span class='ocr_line' id='line_1_9' title="bbox 157 1516 3521 1632; baseline -0.004 -21; x_size 109; x_descenders 25; x_ascenders 24">
<span class='ocrx_word' id='word_1_31' title='bbox 157 1531 394 1611; x_wconf 93'>ter</span>
<span class='ocrx_word' id='word_1_32' title='bbox 495 1527 558 1609; x_wconf 92'>1</span>
<span class='ocrx_word' id='word_1_33' title='bbox 658 1530 903 1632; x_wconf 84'>dag</span>
<span class='ocrx_word' id='word_1_34' title='bbox 1002 1527 1415 1609; x_wconf 90'>laten</span>
<span class='ocrx_word' id='word_1_35' title='bbox 1505 1525 1979 1611; x_wconf 87'>weken,</span>
<span class='ocrx_word' id='word_1_36' title='bbox 2104 1521 2168 1603; x_wconf 96'>2</span>
<span class='ocrx_word' id='word_1_37' title='bbox 2276 1518 2684 1603; x_wconf 85'>liter</span>
<span class='ocrx_word' id='word_1_38' title='bbox 2778 1519 3195 1601; x_wconf 96'>water</span>
<span class='ocrx_word' id='word_1_39' title='bbox 3287 1516 3521 1599; x_wconf 92'>bij</span>
</span>
<span class='ocr_line' id='line_1_10' title="bbox 152 1651 3616 1767; baseline -0.004 -21; x_size 110; x_descenders 27; x_ascenders 23">
<span class='ocrx_word' id='word_1_39' title='bbox 152 1668 302 1747; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_40' title='bbox 407 1662 905 1747; x_wconf 91'>linzen</span>
<span class='ocrx_word' id='word_1_41' title='bbox 996 1682 1559 1767; x_wconf 92'>voegen,</span>
<span class='ocrx_word' id='word_1_42' title='bbox 1680 1659 2176 1741; x_wconf 96'>zonder</span>
<span class='ocrx_word' id='word_1_43' title='bbox 2267 1655 2506 1737; x_wconf 96'>het</span>
<span class='ocrx_word' id='word_1_44' title='bbox 2606 1655 3023 1737; x_wconf 92'>water</span>
<span class='ocrx_word' id='word_1_45' title='bbox 3116 1651 3616 1735; x_wconf 91'>waarin</span>
<span class='ocr_line' id='line_1_10' title="bbox 152 1651 3617 1766; baseline -0.004 -20; x_size 108; x_descenders 25; x_ascenders 23">
<span class='ocrx_word' id='word_1_40' title='bbox 152 1668 302 1747; x_wconf 90'>de</span>
<span class='ocrx_word' id='word_1_41' title='bbox 407 1662 905 1747; x_wconf 90'>linzen</span>
<span class='ocrx_word' id='word_1_42' title='bbox 996 1682 1559 1766; x_wconf 89'>voegen,</span>
<span class='ocrx_word' id='word_1_43' title='bbox 1680 1659 2176 1741; x_wconf 96'>zonder</span>
<span class='ocrx_word' id='word_1_44' title='bbox 2268 1655 2507 1737; x_wconf 95'>het</span>
<span class='ocrx_word' id='word_1_45' title='bbox 2607 1655 3024 1737; x_wconf 93'>water</span>
<span class='ocrx_word' id='word_1_46' title='bbox 3117 1651 3617 1735; x_wconf 91'>waarin</span>
</span>
<span class='ocr_line' id='line_1_11' title="bbox 153 1782 3704 1905; baseline -0.004 -20; x_size 117; x_descenders 23; x_ascenders 34">
<span class='ocrx_word' id='word_1_46' title='bbox 153 1824 305 1885; x_wconf 91'>ze</span>
<span class='ocrx_word' id='word_1_47' title='bbox 407 1800 979 1905; x_wconf 85'>geweekt</span>
<span class='ocrx_word' id='word_1_48' title='bbox 1089 1797 1412 1900; x_wconf 96'>zijn</span>
<span class='ocrx_word' id='word_1_49' title='bbox 1510 1796 1672 1878; x_wconf 96'>af</span>
<span class='ocrx_word' id='word_1_50' title='bbox 1770 1782 1914 1876; x_wconf 93'>te</span>
<span class='ocrx_word' id='word_1_51' title='bbox 2019 1792 2576 1899; x_wconf 54'>gieten,</span>
<span class='ocrx_word' id='word_1_52' title='bbox 2690 1790 2850 1874; x_wconf 93'>De</span>
<span class='ocrx_word' id='word_1_53' title='bbox 2948 1791 3357 1872; x_wconf 89'>helft</span>
<span class='ocrx_word' id='word_1_54' title='bbox 3452 1811 3704 1873; x_wconf 96'>van</span>
<span class='ocr_line' id='line_1_11' title="bbox 153 1782 3705 1905; baseline -0.004 -20; x_size 116; x_descenders 22; x_ascenders 34">
<span class='ocrx_word' id='word_1_47' title='bbox 153 1824 305 1885; x_wconf 91'>ze</span>
<span class='ocrx_word' id='word_1_48' title='bbox 407 1800 979 1905; x_wconf 76'>geweekt</span>
<span class='ocrx_word' id='word_1_49' title='bbox 1090 1797 1412 1900; x_wconf 96'>zijn</span>
<span class='ocrx_word' id='word_1_50' title='bbox 1510 1796 1672 1878; x_wconf 96'>af</span>
<span class='ocrx_word' id='word_1_51' title='bbox 1770 1782 1915 1876; x_wconf 93'>te</span>
<span class='ocrx_word' id='word_1_52' title='bbox 2019 1792 2577 1899; x_wconf 56'>gieten,</span>
<span class='ocrx_word' id='word_1_53' title='bbox 2691 1790 2850 1874; x_wconf 91'>De</span>
<span class='ocrx_word' id='word_1_54' title='bbox 2949 1791 3358 1872; x_wconf 90'>helft</span>
<span class='ocrx_word' id='word_1_55' title='bbox 3453 1811 3705 1873; x_wconf 96'>van</span>
</span>
<span class='ocr_line' id='line_1_12' title="bbox 151 1928 3593 2035; baseline -0.003 -13; x_size 109; x_descenders 25; x_ascenders 25">
<span class='ocrx_word' id='word_1_55' title='bbox 151 1942 305 2024; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_56' title='bbox 403 1940 735 2021; x_wconf 89'>uien</span>
<span class='ocrx_word' id='word_1_57' title='bbox 829 1938 1330 2022; x_wconf 91'>bakken</span>
<span class='ocrx_word' id='word_1_58' title='bbox 1419 1939 1659 2018; x_wconf 92'>met</span>
<span class='ocrx_word' id='word_1_59' title='bbox 1770 1933 2346 2016; x_wconf 91'>laurier</span>
<span class='ocrx_word' id='word_1_60' title='bbox 2447 1953 2603 2014; x_wconf 91'>en</span>
<span class='ocrx_word' id='word_1_61' title='bbox 2691 1928 3593 2035; x_wconf 63'>Kruidnagel.</span>
<span class='ocr_line' id='line_1_12' title="bbox 151 1928 3594 2035; baseline -0.003 -13; x_size 109; x_descenders 25; x_ascenders 25">
<span class='ocrx_word' id='word_1_56' title='bbox 151 1942 305 2024; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_57' title='bbox 403 1940 735 2021; x_wconf 87'>uien</span>
<span class='ocrx_word' id='word_1_58' title='bbox 829 1938 1330 2022; x_wconf 91'>bakken</span>
<span class='ocrx_word' id='word_1_59' title='bbox 1420 1939 1659 2017; x_wconf 93'>met</span>
<span class='ocrx_word' id='word_1_60' title='bbox 1770 1933 2347 2016; x_wconf 89'>laurier</span>
<span class='ocrx_word' id='word_1_61' title='bbox 2448 1953 2604 2014; x_wconf 93'>en</span>
<span class='ocrx_word' id='word_1_62' title='bbox 2692 1928 3594 2035; x_wconf 89'>kruidnagel.</span>
</span>
<span class='ocr_line' id='line_1_13' title="bbox 151 2067 3451 2180; baseline -0.004 -21; x_size 108; x_descenders 25; x_ascenders 24">
<span class='ocrx_word' id='word_1_62' title='bbox 151 2076 473 2159; x_wconf 92'>Alle</span>
<span class='ocrx_word' id='word_1_63' title='bbox 569 2076 965 2180; x_wconf 88'>uien,</span>
<span class='ocrx_word' id='word_1_64' title='bbox 1079 2072 1578 2156; x_wconf 90'>kerrie</span>
<span class='ocrx_word' id='word_1_65' title='bbox 1685 2092 1837 2153; x_wconf 93'>en</span>
<span class='ocrx_word' id='word_1_66' title='bbox 1938 2072 2254 2153; x_wconf 81'>zgout</span>
<span class='ocrx_word' id='word_1_67' title='bbox 2355 2068 2593 2172; x_wconf 43'>bij</span>
<span class='ocrx_word' id='word_1_68' title='bbox 2696 2071 2850 2150; x_wconf 91'>de</span>
<span class='ocrx_word' id='word_1_69' title='bbox 2957 2067 3451 2150; x_wconf 85'>linzen</span>
<span class='ocr_line' id='line_1_13' title="bbox 151 2067 3452 2180; baseline -0.004 -21; x_size 108; x_descenders 25; x_ascenders 24">
<span class='ocrx_word' id='word_1_63' title='bbox 151 2076 473 2159; x_wconf 92'>Alle</span>
<span class='ocrx_word' id='word_1_64' title='bbox 569 2076 965 2180; x_wconf 90'>uien,</span>
<span class='ocrx_word' id='word_1_65' title='bbox 1079 2072 1578 2156; x_wconf 91'>kerrie</span>
<span class='ocrx_word' id='word_1_66' title='bbox 1686 2092 1837 2153; x_wconf 96'>en</span>
<span class='ocrx_word' id='word_1_67' title='bbox 1938 2072 2255 2153; x_wconf 0'>gout</span>
<span class='ocrx_word' id='word_1_68' title='bbox 2356 2068 2594 2172; x_wconf 52'>bijg</span>
<span class='ocrx_word' id='word_1_69' title='bbox 2697 2071 2851 2150; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_70' title='bbox 2958 2067 3452 2150; x_wconf 80'>linzen</span>
</span>
<span class='ocr_line' id='line_1_14' title="bbox 147 2205 3614 2318; baseline -0.003 -23; x_size 106; x_descenders 22; x_ascenders 25">
<span class='ocrx_word' id='word_1_70' title='bbox 147 2234 711 2318; x_wconf 88'>voegen,</span>
<span class='ocrx_word' id='word_1_71' title='bbox 826 2210 1234 2295; x_wconf 95'>Alles</span>
<span class='ocrx_word' id='word_1_72' title='bbox 1342 2232 1588 2295; x_wconf 95'>aan</span>
<span class='ocrx_word' id='word_1_73' title='bbox 1679 2212 1831 2291; x_wconf 96'>de</span>
<span class='ocrx_word' id='word_1_74' title='bbox 1928 2208 2263 2290; x_wconf 93'>kook</span>
<span class='ocrx_word' id='word_1_75' title='bbox 2355 2206 3000 2308; x_wconf 54'>brengen,</span>
<span class='ocrx_word' id='word_1_76' title='bbox 3117 2205 3367 2285; x_wconf 95'>Van</span>
<span class='ocrx_word' id='word_1_77' title='bbox 3462 2206 3614 2287; x_wconf 95'>de</span>
<span class='ocr_line' id='line_1_14' title="bbox 147 2205 3615 2318; baseline -0.003 -23; x_size 106; x_descenders 22; x_ascenders 25">
<span class='ocrx_word' id='word_1_71' title='bbox 147 2234 711 2318; x_wconf 89'>voegen,</span>
<span class='ocrx_word' id='word_1_72' title='bbox 826 2210 1234 2295; x_wconf 96'>Alles</span>
<span class='ocrx_word' id='word_1_73' title='bbox 1342 2232 1588 2295; x_wconf 96'>aan</span>
<span class='ocrx_word' id='word_1_74' title='bbox 1679 2212 1831 2291; x_wconf 95'>de</span>
<span class='ocrx_word' id='word_1_75' title='bbox 1928 2208 2264 2290; x_wconf 93'>kook</span>
<span class='ocrx_word' id='word_1_76' title='bbox 2356 2206 3001 2308; x_wconf 35'>brengen,.</span>
<span class='ocrx_word' id='word_1_77' title='bbox 3118 2205 3368 2285; x_wconf 96'>Van</span>
<span class='ocrx_word' id='word_1_78' title='bbox 3463 2206 3615 2287; x_wconf 96'>de</span>
</span>
<span class='ocr_line' id='line_1_15' title="bbox 152 2341 3706 2447; baseline -0.003 -18; x_size 107; x_descenders 24; x_ascenders 24">
<span class='ocrx_word' id='word_1_78' title='bbox 152 2352 565 2434; x_wconf 91'>bloem</span>
<span class='ocrx_word' id='word_1_79' title='bbox 655 2351 896 2431; x_wconf 92'>met</span>
<span class='ocrx_word' id='word_1_80' title='bbox 997 2349 1669 2431; x_wconf 90'>boter</span>
<span class='ocrx_word' id='word_1_81' title='bbox 1514 2337 1683 2455; x_wconf 91'>en</span>
<span class='ocrx_word' id='word_1_82' title='bbox 1757 2343 2095 2426; x_wconf 88'>melk</span>
<span class='ocrx_word' id='word_1_83' title='bbox 2193 2364 2432 2427; x_wconf 93'>een</span>
<span class='ocrx_word' id='word_1_84' title='bbox 2527 2341 2935 2447; x_wconf 90'>papje</span>
<span class='ocrx_word' id='word_1_85' title='bbox 3029 2341 3453 2422; x_wconf 96'>maken</span>
<span class='ocrx_word' id='word_1_86' title='bbox 3549 2362 3706 2422; x_wconf 95'>en</span>
<span class='ocr_line' id='line_1_15' title="bbox 152 2341 3707 2447; baseline -0.003 -18; x_size 107; x_descenders 24; x_ascenders 24">
<span class='ocrx_word' id='word_1_79' title='bbox 152 2352 565 2434; x_wconf 91'>bloem</span>
<span class='ocrx_word' id='word_1_80' title='bbox 655 2351 896 2431; x_wconf 92'>met</span>
<span class='ocrx_word' id='word_1_81' title='bbox 997 2349 1669 2431; x_wconf 91'>boter</span>
<span class='ocrx_word' id='word_1_82' title='bbox 1514 2337 1683 2455; x_wconf 91'>en</span>
<span class='ocrx_word' id='word_1_83' title='bbox 1757 2343 2096 2426; x_wconf 90'>melk</span>
<span class='ocrx_word' id='word_1_84' title='bbox 2194 2364 2433 2427; x_wconf 93'>een</span>
<span class='ocrx_word' id='word_1_85' title='bbox 2528 2341 2936 2447; x_wconf 90'>papje</span>
<span class='ocrx_word' id='word_1_86' title='bbox 3030 2341 3454 2422; x_wconf 96'>maken</span>
<span class='ocrx_word' id='word_1_87' title='bbox 3550 2362 3707 2422; x_wconf 95'>en</span>
</span>
<span class='ocr_line' id='line_1_16' title="bbox 149 2477 3619 2586; baseline -0.003 -16; x_size 107; x_descenders 24; x_ascenders 23">
<span class='ocrx_word' id='word_1_87' title='bbox 149 2489 650 2571; x_wconf 86'>verder</span>
<span class='ocrx_word' id='word_1_88' title='bbox 750 2486 1330 2570; x_wconf 90'>afmaken</span>
<span class='ocrx_word' id='word_1_89' title='bbox 1420 2485 1660 2567; x_wconf 96'>met</span>
<span class='ocrx_word' id='word_1_90' title='bbox 1765 2485 1917 2566; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_91' title='bbox 2025 2505 2409 2586; x_wconf 86'>soep,</span>
<span class='ocrx_word' id='word_1_92' title='bbox 2521 2479 2764 2564; x_wconf 96'>Als</span>
<span class='ocrx_word' id='word_1_93' title='bbox 2868 2480 3021 2561; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_94' title='bbox 3127 2477 3619 2562; x_wconf 91'>linzen</span>
<span class='ocr_line' id='line_1_16' title="bbox 149 2477 3620 2586; baseline -0.003 -16; x_size 106; x_descenders 23; x_ascenders 23">
<span class='ocrx_word' id='word_1_88' title='bbox 149 2489 650 2571; x_wconf 87'>verder</span>
<span class='ocrx_word' id='word_1_89' title='bbox 750 2486 1330 2570; x_wconf 91'>afmaken</span>
<span class='ocrx_word' id='word_1_90' title='bbox 1420 2485 1660 2567; x_wconf 95'>met</span>
<span class='ocrx_word' id='word_1_91' title='bbox 1765 2485 1918 2566; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_92' title='bbox 2025 2505 2410 2586; x_wconf 66'>soep,</span>
<span class='ocrx_word' id='word_1_93' title='bbox 2522 2479 2765 2564; x_wconf 95'>Als</span>
<span class='ocrx_word' id='word_1_94' title='bbox 2869 2480 3022 2561; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_95' title='bbox 3128 2477 3620 2562; x_wconf 85'>linzen</span>
</span>
<span class='ocr_line' id='line_1_17' title="bbox 155 2619 2412 2731; baseline -0.005 -20; x_size 98; x_descenders 15; x_ascenders 23">
<span class='ocrx_word' id='word_1_95' title='bbox 155 2647 481 2731; x_wconf 35'>gfgaar</span>
<span class='ocrx_word' id='word_1_96' title='bbox 581 2624 909 2728; x_wconf 87'>Zijn</span>
<span class='ocrx_word' id='word_1_97' title='bbox 1005 2623 1153 2707; x_wconf 95'>is</span>
<span class='ocrx_word' id='word_1_98' title='bbox 1255 2624 1409 2706; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_99' title='bbox 1522 2642 1840 2726; x_wconf 91'>soep</span>
<span class='ocrx_word' id='word_1_100' title='bbox 1929 2619 2412 2709; x_wconf 89'>klaar.</span>
<span class='ocr_line' id='line_1_17' title="bbox 155 2619 2413 2731; baseline -0.005 -20; x_size 108; x_descenders 25; x_ascenders 23">
<span class='ocrx_word' id='word_1_96' title='bbox 155 2647 481 2731; x_wconf 47'>gaar</span>
<span class='ocrx_word' id='word_1_97' title='bbox 581 2624 909 2728; x_wconf 88'>Zijn</span>
<span class='ocrx_word' id='word_1_98' title='bbox 1005 2623 1153 2707; x_wconf 95'>is</span>
<span class='ocrx_word' id='word_1_99' title='bbox 1255 2624 1409 2706; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_100' title='bbox 1522 2642 1840 2726; x_wconf 91'>soep</span>
<span class='ocrx_word' id='word_1_101' title='bbox 1929 2619 2413 2709; x_wconf 86'>klaar.</span>
</span>
</p>
</div>
@@ -1,4 +1,4 @@
i a la Waterman
Linzensoep a la Waterman
4 ons linzen
@@ -12,13 +12,13 @@ bloem, boter
laurier, kruidnagel, kerrie, zout
De linzgen wassen en in-l liter kokend wa-
De linzen wassen en in 1 liter kokend wa-
ter 1 dag laten weken, 2 liter water bij
de linzen voegen, zonder het water waarin
ze geweekt zijn af te gieten, De helft van
de uien bakken met laurier en Kruidnagel.
Alle uien, kerrie en zgout bij de linzen
voegen, Alles aan de kook brengen, Van de
de uien bakken met laurier en kruidnagel.
Alle uien, kerrie en gout bijg de linzen
voegen, Alles aan de kook brengen,. Van de
bloem met boter en melk een papje maken en
verder afmaken met de soep, Als de linzen
gfgaar Zijn is de soep klaar.
gaar Zijn is de soep klaar.

Some files were not shown because too many files have changed in this diff Show More