Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.pdf binary
*.png binary
173 changes: 173 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
name: CI

on:
push:
branches: [main]
pull_request: {}
workflow_dispatch:
inputs:
jpdfium-ref:
description: JPDFium ref providing the safe jpdf_* core
default: feat/native-safe-domain
type: string

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
# Until the safe API lands on JPDFium main, build the core from its feature branch.
JPDFIUM_REF: ${{ github.event.inputs.jpdfium-ref || 'feat/native-safe-domain' }}

jobs:
unit:
name: Unit (no natives) / ${{ matrix.os }} / py${{ matrix.python }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python: ['3.10', '3.13']
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python }}
- run: python -m pip install "pytest>=8"
- name: Unit tests
env:
PYTHONPATH: src
run: python -m pytest tests/unit -q

native-linux:
name: Real native core / linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
with:
repository: Stirling-Tools/JPDFium
ref: ${{ env.JPDFIUM_REF }}
path: jpdfium
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- name: Install native build deps
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake ninja-build pkg-config \
libpcre2-dev libfreetype6-dev libharfbuzz-dev libicu-dev \
libqpdf-dev libpugixml-dev libunibreak-dev lld patchelf libgl1 poppler-utils icu-devtools

- name: Fetch pinned PDFium and verify its published digest
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG=$(grep -v '^#' jpdfium/native/pdfium.version | grep -v '^$' | head -n1)
ASSET=pdfium-linux-x64.tar.gz
gh release download "$TAG" -R Stirling-Tools/JPDFium -p "$ASSET" -D "$RUNNER_TEMP"
DIGEST=$(gh api "repos/Stirling-Tools/JPDFium/releases/tags/$TAG" \
--jq ".assets[] | select(.name == \"$ASSET\") | .digest")
echo "${DIGEST#sha256:} $RUNNER_TEMP/$ASSET" | sha256sum -c -
mkdir -p jpdfium/native/pdfium
tar -xzf "$RUNNER_TEMP/$ASSET" -C jpdfium/native/pdfium

# The optional OCR module: ONNX Runtime and the model profile, each pinned by digest.
- name: Fetch ONNX Runtime and the OCR profile (pinned)
run: |
bash jpdfium/native/ocr/fetch-onnxruntime.sh linux-x64
python jpdfium/native/ocr/tools/fetch_profile.py \
jpdfium/native/ocr/profiles/pp-ocrv6-small.profile "$RUNNER_TEMP/ocr-profile"
# The balanced profile pins the same files; the fetcher reuses them after its digest check.
cp -r "$RUNNER_TEMP/ocr-profile" "$RUNNER_TEMP/ocr-profile-balanced"
python jpdfium/native/ocr/tools/fetch_profile.py \
jpdfium/native/ocr/profiles/pp-ocrv6-small-balanced.profile \
"$RUNNER_TEMP/ocr-profile-balanced"

- name: Build the JPDFium core
run: bash jpdfium/native/build-real.sh

- name: Native core tests
env:
JPDFIUM_OCR_PROFILE: ${{ runner.temp }}/ocr-profile
run: ctest --test-dir jpdfium/native/build-real --output-on-failure

# One layout for tests and wheels: own sonames, with the OCR module beside the core.
- name: Stage the natives for Python
run: python tools/stage_natives.py jpdfium/native/dist/linux-x64 "$RUNNER_TEMP/natives"

- name: Build pyjpdfium
env:
JPDFIUM_NATIVE_DIR: ${{ runner.temp }}/natives
JPDFIUM_INCLUDE_DIR: ${{ github.workspace }}/jpdfium/native/bridge/include
run: |
python -m pip install "scikit-build-core>=0.11" "nanobind>=2.9" "pytest>=8"
python -m pip wheel --no-build-isolation --no-deps . -w "$RUNNER_TEMP/wheel"
python -m pip install --no-deps "$RUNNER_TEMP"/wheel/pyjpdfium-*.whl

- name: Python tests (unit + native)
env:
PYJPDFIUM_NATIVE_DIR: ${{ runner.temp }}/natives
PYJPDFIUM_OCR_PROFILE: ${{ runner.temp }}/ocr-profile
run: |
# Native tests skip when the extension is missing; fail loudly here instead.
python -c "import pyjpdfium; print(pyjpdfium.runtime_info())"
python -m pytest -q -rs

- name: Parity with OCRmyPDF and independent text extractors
env:
PYJPDFIUM_NATIVE_DIR: ${{ runner.temp }}/natives
run: |
python -m pip install --require-hashes --only-binary=:all: -r tests/parity/requirements.txt
export PYJPDFIUM_PARITY_SRC="$(python tests/parity/fetch_reference.py "$RUNNER_TEMP/parity")"
python -m pytest tests/parity -q -rs

- name: OCR parity with the official PaddleOCR pipeline
env:
PYJPDFIUM_NATIVE_DIR: ${{ runner.temp }}/natives
PYJPDFIUM_OCR_PROFILE: ${{ runner.temp }}/ocr-profile
PYJPDFIUM_OCR_REFERENCE: ${{ runner.temp }}/ocr-reference/bin/python
run: |
python -m venv "$RUNNER_TEMP/ocr-reference"
"$RUNNER_TEMP/ocr-reference/bin/pip" install --require-hashes --only-binary=:all: \
-r tests/parity/ocr-requirements.txt
python -m pytest tests/parity/test_ocr_parity.py -q -rs
PYJPDFIUM_OCR_PROFILE="$RUNNER_TEMP/ocr-profile-balanced" \
python -m pytest tests/parity/test_ocr_parity.py -q -rs

- name: OCRmyPDF end to end with the pyjpdfium plugin
env:
PYJPDFIUM_NATIVE_DIR: ${{ runner.temp }}/natives
PYJPDFIUM_OCR_PROFILE: ${{ runner.temp }}/ocr-profile
PYJPDFIUM_OCRMYPDF_PYTHON: ${{ runner.temp }}/ocrmypdf/bin/python
run: |
python -m venv "$RUNNER_TEMP/ocrmypdf"
"$RUNNER_TEMP/ocrmypdf/bin/pip" install --require-hashes --only-binary=:all: \
-r tests/ocrmypdf/requirements.txt
"$RUNNER_TEMP/ocrmypdf/bin/pip" install --no-deps "$RUNNER_TEMP"/wheel/pyjpdfium-*.whl
python -m pytest tests/ocrmypdf tests/native/test_cli.py -q -rs

# The release closure: JPDFium's bundler and checks, then a fresh Ubuntu with no network.
- name: Wheel on a clean offline machine
run: |
bash jpdfium/native/build-minimal-icu.sh
python tools/build_wheel.py jpdfium "$RUNNER_TEMP/wheelhouse"
python -m pip download --require-hashes --only-binary=:all: \
-r tests/ocrmypdf/requirements.txt -d "$RUNNER_TEMP/ocrmypdf-wheels"
printf 'FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv && rm -rf /var/lib/apt/lists/*\n' \
| docker build -t pyjpdfium-clean -
docker run --rm --network=none -v "$RUNNER_TEMP:/ci:ro" -v "$PWD:/src:ro" \
-e PYJPDFIUM_OCR_PROFILE=/ci/ocr-profile pyjpdfium-clean bash -c '
set -e
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install -q --no-index /ci/wheelhouse/pyjpdfium-*.whl
/tmp/venv/bin/pip install -q --no-index --require-hashes --only-binary=:all: \
--find-links /ci/ocrmypdf-wheels -r /src/tests/ocrmypdf/requirements.txt
cd /tmp && /tmp/venv/bin/python -I /src/tests/clean_machine/check.py
bash /src/tests/clean_machine/prune.sh /ci/wheelhouse/pyjpdfium-*.whl \
/ci/ocrmypdf-wheels /src/tests/ocrmypdf/requirements.txt \
/src/tests/clean_machine/check.py'
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
build/
dist/
*.egg-info/
__pycache__/
.pytest_cache/
*.so
*.pyd
*.dylib
src/pyjpdfium/_libs/
79 changes: 79 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
cmake_minimum_required(VERSION 3.20)
project(pyjpdfium LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# The extension links the one shared JPDFium core; it never compiles its own copy of the domain.
set(JPDFIUM_NATIVE_DIR "" CACHE PATH "Staged JPDFium natives (libjpdfium plus its runtime closure)")
set(JPDFIUM_INCLUDE_DIR "" CACHE PATH "Directory containing jpdfium_api.h")
option(PYJPDFIUM_BUNDLE_NATIVES "Copy the staged natives into the wheel under pyjpdfium/_libs" OFF)

if(NOT JPDFIUM_NATIVE_DIR)
message(FATAL_ERROR "Set JPDFIUM_NATIVE_DIR to a staged JPDFium native directory")
endif()
if(NOT JPDFIUM_INCLUDE_DIR)
set(JPDFIUM_INCLUDE_DIR "${JPDFIUM_NATIVE_DIR}/include")
endif()
if(NOT EXISTS "${JPDFIUM_INCLUDE_DIR}/jpdfium_api.h")
message(FATAL_ERROR "jpdfium_api.h not found in ${JPDFIUM_INCLUDE_DIR}; set JPDFIUM_INCLUDE_DIR")
endif()

find_library(JPDFIUM_LIBRARY NAMES jpdfium PATHS "${JPDFIUM_NATIVE_DIR}" NO_DEFAULT_PATH REQUIRED)
message(STATUS "JPDFium core: ${JPDFIUM_LIBRARY}")

find_package(Python 3.10 COMPONENTS Interpreter Development.Module REQUIRED)
execute_process(
COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
OUTPUT_STRIP_TRAILING_WHITESPACE
OUTPUT_VARIABLE nanobind_ROOT)
find_package(nanobind CONFIG REQUIRED)

nanobind_add_module(_native NB_STATIC src/native/pyjpdfium_native.cpp)
target_include_directories(_native PRIVATE "${JPDFIUM_INCLUDE_DIR}")
target_link_libraries(_native PRIVATE "${JPDFIUM_LIBRARY}")
if(MSVC)
target_compile_options(_native PRIVATE /W4 /WX)
else()
target_compile_options(_native PRIVATE -Wall -Wextra -Werror)
endif()

# Bundled wheels find the core next to the package; dev builds preload it from JPDFIUM_NATIVE_DIR.
if(APPLE)
set_target_properties(_native PROPERTIES INSTALL_RPATH "@loader_path/_libs")
elseif(UNIX)
set_target_properties(_native PROPERTIES INSTALL_RPATH "$ORIGIN/_libs")
endif()

install(TARGETS _native LIBRARY DESTINATION pyjpdfium)

# Optional OCR extension: built when the staged natives include libjpdfium_ocr.
set(JPDFIUM_OCR_INCLUDE_DIR "${JPDFIUM_INCLUDE_DIR}/../../ocr/include" CACHE PATH
"Directory containing jpdfium_ocr.h")
find_library(JPDFIUM_OCR_LIBRARY NAMES jpdfium_ocr PATHS "${JPDFIUM_NATIVE_DIR}" NO_DEFAULT_PATH)
if(JPDFIUM_OCR_LIBRARY AND EXISTS "${JPDFIUM_OCR_INCLUDE_DIR}/jpdfium_ocr.h")
message(STATUS "JPDFium OCR module: ${JPDFIUM_OCR_LIBRARY}")
nanobind_add_module(_ocr_native NB_STATIC src/native/pyjpdfium_ocr_native.cpp)
target_include_directories(_ocr_native PRIVATE "${JPDFIUM_INCLUDE_DIR}" "${JPDFIUM_OCR_INCLUDE_DIR}")
target_link_libraries(_ocr_native PRIVATE "${JPDFIUM_OCR_LIBRARY}" "${JPDFIUM_LIBRARY}")
if(MSVC)
target_compile_options(_ocr_native PRIVATE /W4 /WX)
else()
target_compile_options(_ocr_native PRIVATE -Wall -Wextra -Werror)
endif()
if(APPLE)
set_target_properties(_ocr_native PROPERTIES INSTALL_RPATH "@loader_path/_libs")
elseif(UNIX)
set_target_properties(_ocr_native PROPERTIES INSTALL_RPATH "$ORIGIN/_libs")
endif()
install(TARGETS _ocr_native LIBRARY DESTINATION pyjpdfium)
else()
message(STATUS "JPDFium OCR module not staged; pyjpdfium.ocr will be unavailable")
endif()

if(PYJPDFIUM_BUNDLE_NATIVES)
# The staged closure with its manifests and license texts (tools/build_wheel.py).
install(DIRECTORY "${JPDFIUM_NATIVE_DIR}/" DESTINATION pyjpdfium/_libs
FILES_MATCHING PATTERN "*.so*" PATTERN "*.dylib" PATTERN "*.dll"
PATTERN "*.json" PATTERN "licenses/*")
endif()
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# PyJPDFium

Python bindings to [JPDFium](https://github.com/Stirling-Tools/JPDFium)'s native PDF core
(PDFium). No JVM required.

PyJPDFium is a thin [nanobind](https://github.com/wjakob/nanobind) binding over JPDFium's safe
`jpdf_*` C API. The native core, not Python or the GIL, owns PDFium serialization, object
lifetimes and handle validation, so the same rules hold for the Java and Python bindings.

Status: early development. Covered so far: documents, pages, rendering, page text, transactional
PNG output, OCRmyPDF-compatible page rasterization, perspective rectification, invisible text
layers in any script and direction, OCR with PP-OCR models, a command line, and an OCRmyPDF
plugin that needs neither Tesseract nor Ghostscript.

```python
import pyjpdfium

with pyjpdfium.open_document("input.pdf") as document:
with document.page(0) as page:
page.render("page.png", dpi=(300, 300)) # atomic publish; failure leaves page.png as-is
with page.render(dpi=150, pixel_format="gray") as bitmap:
pixels = bitmap.view() # zero-copy, read-only, shape (h, w)
crop, geometry = bitmap.rectify([(40, 30), (300, 42), (296, 90), (36, 80)])

# OCRmyPDF's rasterize_pdf_page contract, natively (see JPDFium docs/design/RASTER.md).
pyjpdfium.rasterize_page("input.pdf", 0, "page.png", device="pnggray", raster_dpi=300)

# The text-only PDF of OCRmyPDF's generate_pdf hook (see JPDFium docs/design/TEXT_LAYER.md).
from pyjpdfium import TextLine, TextWord

line = TextLine((300, 300, 1500, 400), (TextWord("Hello", (300, 300, 800, 400)),
TextWord("world", (900, 300, 1500, 400))))
pyjpdfium.write_text_layer("text.pdf", [line], image_size=(2550, 3300), dpi=300)

# OCR, when the natives include the OCR module (see JPDFium docs/design/OCR.md).
with pyjpdfium.OcrEngine("pp-ocrv6-small-balanced/") as engine:
result = engine.recognize("page.png")
result.write_text_layer("text.pdf") # sized to the image, at the image's DPI
```

## Command line and OCRmyPDF

```bash
pyjpdfium info [input.pdf] # runtime identity and capabilities, or a PDF's pages
pyjpdfium render input.pdf page.png --page 1 --dpi 300
pyjpdfium ocr input.pdf --profile pp-ocrv6-small/ --layer text.pdf
pyjpdfium fonts # text layer fonts
pyjpdfium models pp-ocrv6-small/ # check a profile against its pinned digests
pyjpdfium doctor # check the installation; recognizes nothing

ocrmypdf --plugin pyjpdfium.ocrmypdf --output-type pdf input.pdf output.pdf
```

The plugin, what it refuses and why are in [docs/OCRMYPDF.md](docs/OCRMYPDF.md).

## Behaviour

- `import pyjpdfium` loads no native library. The core loads and starts on first use.
- Objects are safe to share across threads. All PDFium work is serialized natively, and the GIL
is released during native calls, so non-PDFium Python work keeps running.
- A closed object's handle can never be reused: use after close raises `StaleHandleError`.
- `close()` is idempotent. Closing a document with open pages raises `ResourceBusyError` and
leaves the document usable.
- Bitmaps are independent of their page and document, and a `view()` keeps its pixels alive even
after the bitmap is closed.
- One process uses one native core. JPDFium's Java binding is admitted by the same native
domain, so the two can share a process only when both load the same `libjpdfium` file; the
loader refuses a second copy.

## Building

PyJPDFium links a staged JPDFium native build; it never compiles its own copy of the core.

```bash
export JPDFIUM_NATIVE_DIR=/path/to/JPDFium/native/dist/linux-x64
export JPDFIUM_INCLUDE_DIR=/path/to/JPDFium/native/bridge/include
pip install --no-build-isolation .
PYJPDFIUM_NATIVE_DIR=$JPDFIUM_NATIVE_DIR pytest
```

`PYJPDFIUM_NATIVE_DIR` points the runtime at the staged natives. Wheels built with
`PYJPDFIUM_BUNDLE_NATIVES=ON` carry them in `pyjpdfium/_libs` instead.
`PYJPDFIUM_RENDERER` selects `auto` (default), `agg` or `skia`.

Native tests (`-m native`) need the real core and are skipped when the extension is not built.
Parity with OCRmyPDF's own rasterizer and sandwich graft is opt-in: install
`tests/parity/requirements.txt` (hash-pinned), set `PYJPDFIUM_PARITY_SRC` to the output of
`tests/parity/fetch_reference.py`, and run `pytest tests/parity`. OCR tests need
`PYJPDFIUM_OCR_PROFILE`; OCR parity also needs `PYJPDFIUM_OCR_REFERENCE`, a Python with
`tests/parity/ocr-requirements.txt` installed. The OCRmyPDF tests need
`PYJPDFIUM_OCRMYPDF_PYTHON`, a Python with `tests/ocrmypdf/requirements.txt` and pyjpdfium
installed, and natives staged by `tools/stage_natives.py`.

Self-contained wheels are built by `tools/build_wheel.py`
([docs/PACKAGING.md](docs/PACKAGING.md)).

See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design.
Loading
Loading