pdf-tools
Tools for converting documents to PDF, merging PDFs, and adding text watermarks.
The Python package is pdf-toolchest; the installed command is pdf-tools.
Supported operations
| Operation | Inputs | Behavior |
|---|---|---|
| Convert | Images with .jpg, .jpeg, .png, .tiff, or .bmp extensions; Word .doc and .docx files |
Pillow validates images and PDF Oxide creates their PDFs. TIFF, BMP, and non-RGB images are normalized to PNG first. Word files retain LibreOffice, unoserver, and unoconvert. |
| Merge | PDFs | PDF Oxide merges PDFs. Non-PDF inputs are skipped with a warning. Optional bookmarks use File.bookmark_name, falling back to the filename. pypdf temporarily writes the outline entries. |
| Process | Supported conversion inputs and existing PDFs | Converts what it can, then merges the successful conversions and existing PDFs. |
| Watermark | Adds text with open-source ReportLab and pypdf. Rotation must be a multiple of 90 degrees. |
Installation
pip install pdf-toolchest
pdf-tools --help
Word conversion requires LibreOffice and working unoserver/unoconvert executables with UNO bindings. PDF Oxide 0.3.78's Python DOCX converter did not complete on valid test files, so the original Word converter remains in place.
pipx install unoserver --system-site-packages
CLI commands start and stop a listener for Word inputs. Direct Python calls need an existing listener or the unoserver_listener context manager.
CLI quick start
Output parent directories must already exist. Commands do not create them. In this example, out/ is created before conversion; the other outputs are written to the current directory.
# Convert one Word file beside its source
pdf-tools convert file-to-pdf draft.docx
# Convert immediate children of a folder into an existing output directory
mkdir -p out
pdf-tools convert folder-to-pdfs assets/ --output-dir out/
# Merge selected PDFs in the given order
pdf-tools merge pdf-files a.pdf b.pdf c.pdf -o merged.pdf
# Merge PDFs found in a folder
pdf-tools merge pdfs-in-folder scans/ -o merged.pdf
# Convert images and Word files, then merge the successful inputs
pdf-tools process convert-and-merge-pdfs image1.jpg doc1.docx doc2.docx -o final.pdf
# Add a red DRAFT watermark on every page
pdf-tools watermark add-text src.pdf stamped.pdf \
--text "DRAFT" --color "#FF0000" --font-size 72 --opacity 0.2 --rotation 90
Watermark rotation must be a multiple of 90 degrees (for example, 0, 90, 180, or 270). --rotation 45 fails model validation.
merge pdfs-in-folder uses the filesystem's directory enumeration order. Alphabetical order is not guaranteed; pass files explicitly to merge pdf-files in the desired sequence when order matters.
Batch conversion continues after an individual input fails. convert files-to-pdfs and convert folder-to-pdfs print a reason for each skipped file and a converted/skipped count. They exit successfully if at least one file converted, even when others were skipped; they exit with code 1 when none converted. A missing output directory makes each attempted conversion fail and appear in the skipped results.
process convert-and-merge-pdfs is best-effort: an input that cannot be converted is omitted, while successful conversions and existing PDFs may still be merged. The output can contain fewer documents than requested. The process CLI does not list omitted inputs. If every input fails, no merge is written. For all-or-nothing work, run conversion separately, inspect every result, and merge only after all required inputs succeeded.
Python API
The root package supports both module-level functions and the stateless
PDFTools() class. Every class method has the same parameters, return model,
exceptions, and filesystem effects as the function with the same name.
Calls are synchronous. Component imports such as
from pdf_tools.merge import merge_pdfs and service-level imports remain
supported. FileInput and FilesInput are also available from pdf_tools.
| Operation | Function and PDFTools method |
Result |
|---|---|---|
| Convert one file | convert_word_to_pdf, convert_image_to_pdf, convert_file_to_pdf |
File |
| Convert a batch | convert_files_to_pdfs, convert_folder_to_pdfs |
ConversionBatchResult |
| Merge | merge_pdfs, convert_and_merge_pdfs |
File |
| Watermark | add_text_watermark |
WatermarkResult |
| Extract | extract_pdf, extract_pdf_text, extract_pdf_images |
DocumentExtraction |
Accepted input types vary by operation:
| Operation | Accepted input types |
|---|---|
Single-file conversion (convert_file_to_pdf, convert_image_to_pdf, convert_word_to_pdf) |
File, str, or Path |
Batch conversion (convert_files_to_pdfs) |
Files or a sequence of File, str, or Path |
Folder conversion (convert_folder_to_pdfs) |
Directory as str or Path |
Merge (merge_pdfs) and process (convert_and_merge_pdfs) |
Files or a sequence of File, str, or Path |
Watermark (add_text_watermark) |
Source and destination as str or Path; options as WatermarkOptions |
The destination's parent directory must exist for single-file conversion, merging, and processing. Single-file conversion and merging raise FileNotFoundError for a missing output parent. Batch conversion records that error in ConversionBatchResult.skipped for each affected input. Processing raises FileNotFoundError at merge time if at least one input was retained; if none were retained, it raises ValueError instead.
For example, the two interfaces can convert, merge, watermark, and extract the same documents. These examples assume the input files exist and output parent directories have been created:
from pathlib import Path
from pdf_tools import (
PDFTools, ExtractionOptions, OcrMode, WatermarkOptions,
add_text_watermark, convert_file_to_pdf, extract_pdf,
extract_pdf_text, merge_pdfs,
)
Path("out").mkdir(exist_ok=True)
# Functional API
img_pdf = convert_file_to_pdf("diagram.png", output_path=Path("out"))
merged = merge_pdfs(["intro.pdf", img_pdf.path], output_path="bundle.pdf", set_bookmarks=True)
add_text_watermark(
src=merged.path,
dst="bundle_wm.pdf",
opts=WatermarkOptions(text="CONFIDENTIAL", font_size=36, all_pages=False),
)
text = extract_pdf_text(merged.path, ocr=OcrMode.NEVER)
pages = extract_pdf(merged.path, ExtractionOptions(pages=[1]))
# Class API: identical contracts and Pydantic result models
tools = PDFTools()
img_pdf_2 = tools.convert_file_to_pdf("diagram.png", output_path="out/diagram-2.pdf")
merged_2 = tools.merge_pdfs(["intro.pdf", img_pdf_2.path], "bundle-2.pdf")
tools.add_text_watermark(
src=merged_2.path,
dst="bundle-2-wm.pdf",
opts=WatermarkOptions(text="CONFIDENTIAL"),
)
text_2 = tools.extract_pdf_text(merged_2.path, ocr=OcrMode.NEVER)
pages_2 = tools.extract_pdf(merged_2.path, ExtractionOptions(pages=[1]))
Both APIs return the same Pydantic models and propagate the same service
exceptions, including UnsupportedFileTypeError, ExtractionError, and
OcrUnavailableError. Neither interface silently creates missing output
parent directories. OCR still requires the optional runtime and models
described under Set up OCR.
Batch conversion returns ConversionBatchResult: converted contains File models for successful PDFs; skipped contains each failed input path and its reason. It continues after a failed input and can return both lists populated.
from pathlib import Path
from pdf_tools import convert_files_to_pdfs
Path("out").mkdir(exist_ok=True)
result = convert_files_to_pdfs(["photo.jpg", "notes.txt"], output_dir="out")
print(result.converted)
print(result.skipped)
Direct Python calls involving Word files need a caller-managed listener.
Constructing PDFTools() does not start or probe LibreOffice or unoserver:
from pdf_tools import PDFTools, convert_and_merge_pdfs, unoserver_listener
with unoserver_listener():
convert_and_merge_pdfs(
files=["doc1.docx", "pic.jpg", "appendix.pdf"],
output_path="package.pdf",
)
converted = PDFTools().convert_word_to_pdf("document.docx")
Architecture
- Models: Pydantic v2 models describe inputs, options, and results.
- Services: Synchronous Python functions perform conversion, merging, processing, and watermarking.
- CLI: Typer commands parse arguments, print results, and manage CLI-specific resources.
- PDF backend: PDF Oxide creates image PDFs and merges PDFs. pypdf writes merge outline entries and places ReportLab text overlays for watermarks. The pinned PDF Oxide Python binding lacks equivalent styled watermark controls.
- External tools: LibreOffice with
unoserverandunoconvertperforms.docand.docxconversion during the DOCX compatibility gap.
unoserver_listener remains a functional context manager for existing callers.
CLI setup and resource behavior can differ from direct service-layer calls.
Development
git clone https://github.com/aswann45/pdf-tools.git
cd pdf-tools
poetry install --with dev
On Unix-like systems, scripts/lint is a Bash convenience wrapper. Run poetry run bash scripts/lint and poetry run pytest. The wrapper runs Mypy on pdf_tools and tests, Ruff lint on both directories with ERA001 and FIX002 ignored, and Ruff format checks on both directories.
On Windows, run the underlying commands in PowerShell. The Mypy configuration names .venv/bin/python, so override that Unix path with Poetry's environment interpreter:
$venvPython = poetry env info --executable
poetry run mypy --python-executable $venvPython pdf_tools
poetry run mypy --python-executable $venvPython tests
poetry run ruff check pdf_tools --ignore ERA001 --ignore FIX002
poetry run ruff check tests --ignore ERA001 --ignore FIX002
poetry run ruff format pdf_tools --check
poetry run ruff format tests --check
poetry run pytest
The formatting check is ruff format --check; scripts/format applies formatting on Unix-like systems. Word-conversion tests marked slow require LibreOffice and working uno bindings.
PDF extraction
Extraction returns one PageExtraction per selected page, with one-based page
numbers and native, ocr, or none text provenance. The original page text
is retained; result.text joins selected pages with form feeds (\f). Embedded
images stay attached to their source pages in DocumentExtraction.model_dump_json().
pdf-tools extract text report.pdf --pages "1-5,9" -o report.txt
pdf-tools extract images report.pdf --output-dir existing-images/
pdf-tools extract document report.pdf --output-dir existing-output/ --ocr auto
The image directory must already exist for extract images. For extract document, the output directory must exist; the command creates its images/
child when requested and writes extraction.json plus text.txt when text is
enabled. Existing outputs are protected unless --overwrite is given. The
installed PDF Oxide 0.3.78 Python binding returns extracted image bytes as
PNG and has no extract_images_to_files method, so JPEGs are saved as lossless
PNG rather than preserved as original JPEG bytes.
from pdf_tools import ExtractionOptions, OcrMode, extract_pdf
result = extract_pdf("report.pdf", ExtractionOptions(ocr=OcrMode.AUTO))
for page in result.pages:
print(page.page_number, page.text_source, page.text)
Set up OCR
Native text extraction needs no OCR setup. To recognize text in scanned pages,
install the optional ONNX Runtime dependency and provision three model files.
Installing the extra alone does not install the models. From a local
checkout, use python -m pip install -e ".[ocr]"; for a published release,
use python -m pip install "pdf-toolchest[ocr]".
On Linux or macOS, this example places the English models in a directory of your choice. The model URLs and dictionary setup come from PDF Oxide's Python OCR guide. Run these commands only when you want to download the models:
mkdir -p ocr-models
curl -fL https://huggingface.co/deepghs/paddleocr/resolve/main/det/ch_PP-OCRv4_det/model.onnx -o ocr-models/det.onnx
curl -fL https://huggingface.co/monkt/paddleocr-onnx/resolve/main/languages/english/rec.onnx -o ocr-models/rec.onnx
curl -fL https://huggingface.co/monkt/paddleocr-onnx/resolve/main/languages/english/dict.txt -o ocr-models/en_dict.txt
echo " " >> ocr-models/en_dict.txt
export PDF_OXIDE_MODEL_DIR="$(pwd)/ocr-models"
pdf-tools extract text scanned.pdf --ocr always -o scanned.txt
Keep PDF_OXIDE_MODEL_DIR set when running pdf-tools again. The directory
must contain files named exactly det.onnx, rec.onnx, and en_dict.txt.
You can instead place them in ~/.cache/pdf_oxide/models, the default used
by this package. On Windows, the equivalent PowerShell setup is:
$env:PDF_OXIDE_MODEL_DIR = Join-Path $HOME 'ocr-models'
New-Item -ItemType Directory -Force $env:PDF_OXIDE_MODEL_DIR | Out-Null
Invoke-WebRequest 'https://huggingface.co/deepghs/paddleocr/resolve/main/det/ch_PP-OCRv4_det/model.onnx' -OutFile (Join-Path $env:PDF_OXIDE_MODEL_DIR 'det.onnx')
Invoke-WebRequest 'https://huggingface.co/monkt/paddleocr-onnx/resolve/main/languages/english/rec.onnx' -OutFile (Join-Path $env:PDF_OXIDE_MODEL_DIR 'rec.onnx')
Invoke-WebRequest 'https://huggingface.co/monkt/paddleocr-onnx/resolve/main/languages/english/dict.txt' -OutFile (Join-Path $env:PDF_OXIDE_MODEL_DIR 'en_dict.txt')
Add-Content (Join-Path $env:PDF_OXIDE_MODEL_DIR 'en_dict.txt') ' '
pdf-tools extract text scanned.pdf --ocr always -o scanned.txt
The optional onnxruntime Python package normally supplies the shared
library on all three platforms; if it cannot be located, set
ORT_DYLIB_PATH to the full path of the ONNX Runtime shared-library file.
Use --ocr auto for mixed native/scanned PDFs or --ocr always to OCR every
selected page. Without the runtime or models, auto still returns native
text and records a warning for pages needing OCR; always fails with an
actionable error. Neither import nor routine extraction downloads models.
The fast test suite checks OCR routing with mocks. Real OCR requires the
optional runtime and model files, so its integration test is skipped unless
both are available. After setup, run
poetry run pytest tests/test_extract.py::test_real_ocr_when_provisioned to
verify OCR end to end on your machine.
Metadata
Release files for pdf-toolchest 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pdf_toolchest-0.5.0.tar.gz | 37.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pdf_toolchest-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 81.7 kB
Release files / pdf_toolchest-0.5.0.tar.gz
| Download URL | pdf_toolchest-0.5.0.tar.gz |
|---|---|
| Size | 37.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fbbbe5d24fef528b00d9384cbe1fc9c1372114be55e6582dc28d9fa4acba8c21
|
|
BLAKE2b-256 checksum How to use checksums |
4211686257181f13693c40548461dc8e6f8ed5b95c530b6b9bd4c6dc90ea1625
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency logRelease files / pdf_toolchest-0.5.0-py3-none-any.whl
| Download URL | pdf_toolchest-0.5.0-py3-none-any.whl |
|---|---|
| Size | 44.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f4354e0fee208f777476b41c623ea08ed31bd28f9b47a89f21cf846be9bc3ca1
|
|
BLAKE2b-256 checksum How to use checksums |
09e6204f2816bce8dcba108109bd8ec13cbbda6545d0cd712752fd38d42a6ce2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency log