Skip to content
Open
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
Binary file added .github/assets/before-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/social-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 82 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Smoke-tests the pipeline on the bundled sample scan and verifies the output is
# genuinely born-digital (extractable text, much smaller than the scan).
#
# The green check on this workflow is what the README's CI badge points at, so keep
# the filename stable once the badge references it.
#
# NOTE: every step below that touches the environment declares
# `shell: bash -el {0}`. GitHub's default Ubuntu shell is `bash --noprofile
# --norc`, which never sources the profile that `conda init` writes, so without
# it `tesseract` and `python` are "command not found" even though the env was
# created successfully. That is the action's documented requirement, not a
# quirk.

name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
smoke:
runs-on: ubuntu-latest
# See the note at the top: the login shell is what loads the conda env.
defaults:
run:
shell: bash -el {0}
steps:
- uses: actions/checkout@v4

# Miniforge, not Miniconda: it ships mamba and only ever points at
# conda-forge, so there is no `defaults` channel to ask about and no
# Anaconda ToS prompt to abort the run. That is the whole reason this step
# is not "setup-miniconda + a condarc".
- name: Setup Miniforge
uses: conda-incubator/setup-miniconda@v3
with:
miniforge-variant: Miniforge3
miniforge-version: latest
activate-environment: pdf-ocr
environment-file: environment.yml
use-mamba: true
auto-activate: false

- name: Tesseract is on PATH
run: tesseract --version

- name: CLI is importable and --help works
run: python -m pdfclean --help

- name: Convert page 1 of the bundled sample
run: python -m pdfclean assets/ -o output/ --max-pages 1 --overwrite

- name: Output exists
run: ls -la output/

- name: Output is born-digital (real extractable text, far smaller than the scan)
run: |
python - <<'PY'
import glob
import os

import pymupdf

scan_path = glob.glob("assets/*.pdf")[0]
out_paths = glob.glob("output/**/*.pdf", recursive=True)
assert out_paths, "no output PDF produced"

out = pymupdf.open(out_paths[0])
words = out[0].get_text().split()
print(f"words on page 1: {len(words)}")
print(f"sample: {' '.join(words[:25])}")

assert len(words) > 200, f"only {len(words)} words extracted — OCR looks broken"

# The point of the tool: the page photo is gone, so the output must be
# much smaller than the scan it came from.
ratio = os.path.getsize(out_paths[0]) / os.path.getsize(scan_path)
print(f"size ratio vs input: {ratio:.3f}")
assert ratio < 0.6, f"output is {ratio:.0%} of the scan — page image was probably kept"
PY
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Changelog

All notable changes to this project are documented here.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- `conda env create` no longer stalls for tens of minutes on macOS 13 Intel.
Recent `opencv-python-headless` releases publish no `macosx_13_0_x86_64`
wheel, so pip fell back to a source build that needs Xcode Command Line
Tools. The dependency is now bounded so every supported platform resolves
to a prebuilt wheel.
- The setup instructions for the Anaconda terms-of-service prompt now give a
command that actually works. The previously documented
`conda env create --override-channels` is not a valid flag for that
subcommand, and neither the `nodefaults` channel entry nor a `.condarc` in
the working directory prevents the prompt.

## [0.1.0] - 2026-06-24

### Added

- First release: `python -m pdfclean input/ -o output/` turns scanned,
image-only PDFs into born-digital ones — real selectable text, real figures,
no page photo.
- Per page: extract the embedded scan, clean it (deskew, background whitening,
denoise), OCR it with Tesseract, detect figures the OCR didn't claim as text,
then rebuild the page with reflowed text at a font size chosen to fill each
block's rectangle.
- Batch a folder of PDFs; the output tree mirrors the input tree.
- Optional `--engine vision` corrects OCR errors and detects italics using a
hosted multimodal model (Mistral by default, OpenRouter supported). Falls
back to the Tesseract text per page if a call fails, so a batch always
finishes.
- `--no-figures` for text-only output, `--max-pages` to test cheaply,
`--min-conf` and `--psm` to trade OCR quality against speed.
- `--api-key`, or the provider's env var, or a gitignored `.env.local`.

<!--
Writing entries:

Sections, in this order, omitting the empty ones:
Added / Changed / Deprecated / Removed / Fixed / Security

Write for the person who has to decide whether to upgrade. That means the effect,
then the reason — not the file that changed.

Good: "Partial style overrides no longer discard the defaults. Passing
`{ style: { width: '40px' } }` previously replaced the whole object,
leaving the marker with no border or background."
Bad: "Fixed merge bug in options handling."

Anything that breaks a caller belongs under Changed or Removed with the migration
spelled out, even when the version number already implies it.
-->
104 changes: 104 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Contributing

## Development environment

### Requirements

- Miniconda (or any conda/mamba distribution) — the project targets conda, not pip
- Tesseract comes from conda; no system-level install needed

### Setup

```bash
git clone https://github.com/killerwolf/pdfclean.git
cd pdfclean
conda env create -f environment.yml
conda activate pdf-ocr
```

Verify the toolchain end to end before changing anything:

```bash
python -m pdfclean assets/ -o output/ --max-pages 1 --overwrite
```

> **The Anaconda ToS prompt.** `environment.yml` asks for `conda-forge`, but conda
> still merges the `defaults` channels (repo.anaconda.com) from its own config, and
> those need their Terms of Service accepted. In a non-interactive shell that aborts
> with `CondaToSNonInteractiveError`. You have two options:
>
> **Accept the terms** — one-off, keeps `defaults` available:
>
> ```bash
> conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main
> conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r
> ```
>
> **Or skip those channels entirely**, which needs no ToS acceptance at all. Point
> conda at a config that replaces `defaults` rather than adding to it:
>
> ```bash
> printf 'channels:\n - conda-forge\ndefault_channels:\n - conda-forge\nchannel_priority: strict\n' > .condarc
> CONDARC=$PWD/.condarc conda env create -f environment.yml
> ```
>
> Two things that do *not* work, despite looking like they should: the `nodefaults`
> channel entry inside `environment.yml`, and a bare `.condarc` in the working
> directory. The ToS check runs before either is applied — only the `CONDARC`
> environment variable is honoured early enough. Note that `conda env create` does
> not accept `--override-channels` at all (that's `conda create`).

## Project structure

```
pdfclean/
├── pdfclean/
│ ├── cli.py # argument parsing, .env.local loading, batch loop
│ ├── pipeline.py # per-document orchestration
│ ├── clean.py # deskew, background whitening, denoise
│ ├── ocr.py # Tesseract + the Block → Paragraph → Line → Word tree
│ ├── figures.py # figure detection, block splitting around figures
│ ├── style.py # bold detection from stroke width
│ ├── vision.py # optional hosted vision OCR
│ └── reconstruct.py # rebuilds the page with real text + figures
├── assets/ # sample scanned PDF
└── environment.yml
```

## Making a change

1. Branch from `main`.
2. Make the change. The pipeline order in `pipeline.py` is the contract everything
else depends on — if you add a step, keep it there and update the README's
flow diagram to match.
3. Run a one-page conversion locally (command above); CI runs a real conversion
on the bundled sample, so a broken pipeline fails the build.
4. Add a `CHANGELOG.md` entry under `## [Unreleased]` if the change is user-visible.
5. Open a pull request describing what changed and why.

### Things that are easy to get wrong

- **Don't invent numbers.** The README quotes file sizes, word counts and OCR
confidence. If you change the pipeline, re-measure them on the bundled sample
and update the README rather than leaving stale figures.
- **Privacy.** `--engine vision` uploads page images to a third party. Keep it
opt-in, and never make it the default.
- **Base-14 fonts.** Output uses Times/Helvetica/Courier so the files stay tiny.
Text containing characters those fonts can't map renders as `?` — normalise
punctuation (see `_normalize_punct` in `reconstruct.py`) rather than embedding
a font.
- **Base64 blobs don't belong in the README.** Embedded base64 images don't render
on GitHub and bloat the file. Commit PNGs under `.github/assets/`.

## Releasing

1. Move the `## [Unreleased]` entries in `CHANGELOG.md` into a new version section
with today's date.
2. Bump `__version__` in `pdfclean/__init__.py`.
3. Commit, merge to `main`.
4. Tag and push: `git tag v<VERSION> && git push origin v<VERSION>`.
5. Create the GitHub Release from the tag.

## Code of conduct

Be decent to people. Report problems as a GitHub issue on the repo.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Hamdi LAADHARI

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading