v17.11.0
macro-inc/macrov17.11.0Aug 28, 2026by github-actions[bot]
AI Summary
This release introduces significant performance improvements, including reduced memory usage and concurrent OCR processing within a single process, alongside major enhancements to the `watcher.py` helper that allow for configurable output structures and conflict policies.
Key Highlights
- Memory usage reduced by approximately one-third through downsampling and improved buffer management.
- The `watcher.py` helper gains new `--output-structure` (FLAT, YEAR_MONTH, HIERARCHY) and `--on-conflict` (SUFFIX, SKIP, OVERWRITE) settings.
- OCR jobs can now run concurrently within a single Python process to improve throughput.
- Filesystem sanitization for Windows/SMB compatibility replaces illegal characters and reserved DOS names.
Breaking Changes
- The default `--on-conflict` policy changed from `OVERWRITE` to `SUFFIX`.
- The `OCR_OUTPUT_DIRECTORY_YEAR_MONTH` setting is deprecated in favor of `OCR_OUTPUT_STRUCTURE`.
New Features
- New `--max-ocr-image-mpixels` flag to bound memory usage during OCR.
- Configurable output layout and conflict policy for the watcher tool.
- Plugin set reuse and concurrent job execution support.
Full Release Notes
**Enhancements**
- New `--max-ocr-image-mpixels` downsamples the image sent to OCR when a page
exceeds the given size, which bounds the largest consumer of memory. The
visible page is never downsampled, so output appearance is unaffected in every
mode; what it trades is OCR accuracy on very high resolution scans. A 34
megapixel page that peaks at 492 MB peaks at 325 MB under
`--max-ocr-image-mpixels 8`, and recognizes the same text. See the "Memory"
section of the performance documentation for how to size a memory limit.
- `watcher.py` (the `watcher` extra) gained a configurable output layout and
conflict policy. These are watcher-only changes; they do not affect the
`ocrmypdf` library API.
- New `OCR_OUTPUT_STRUCTURE` setting (`--output-structure`): `FLAT`
(default, all outputs directly in the destination directory),
`YEAR_MONTH` (`{destination}/{year}/{month}/{filename}`, same layout as
the old `OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1`), or `HIERARCHY`, which
mirrors the input directory tree under the destination, e.g.
`input/a/b/c.pdf` → `output/a/b/c.pdf`.
- New `OCR_ON_CONFLICT` setting (`--on-conflict`) controls what happens
when the intended destination file already exists: `SUFFIX` (default)
writes `name (1).pdf`, `name (2).pdf`, ... in the OS style; `SKIP` logs
and leaves the file unprocessed; `OVERWRITE` is the old behavior.
**Behavior change: the default is now `SUFFIX`, so existing output
files are no longer silently overwritten.**
- Both settings now apply equally to `OCR_OUTPUT_DIRECTORY` and to the
archive directory used by `OCR_ON_SUCCESS_ARCHIVE`; previously the
archive directory was always flat and silently overwrote on a name
collision.
- Output and archive filenames and directory components are sanitized
for filesystems more restrictive than the input side (e.g. an SMB
share): characters illegal on Windows/SMB (`<>:"/\|?*` and control
characters) are replaced with `_`, trailing dots/spaces are stripped,
and reserved DOS device names (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`9`,
`LPT1`-`9`) are prefixed with `_`.
- `OCR_OUTPUT_DIRECTORY_YEAR_MONTH` is now deprecated in favor of
`OCR_OUTPUT_STRUCTURE=YEAR_MONTH`. It is still honored and logs a
deprecation warning; if both are set, `OCR_OUTPUT_STRUCTURE` wins.
- See the "Watched folders with watcher.py" section of the batch
processing documentation for the full description, including a note
for SMB users about client-side directory/file-info caching delays.
**Performance**
- The image sent to OCR is no longer decoded and re-encoded when nothing needs
to change it. On a page with no pre-existing text to mask and no filtering
plugin -- the ordinary case for a scanned document -- the rasterized page
already *is* the OCR image, so it is linked rather than rewritten. Producing
it was costing 3.3 seconds and a full-size buffer on a 34 megapixel page, out
of about 15 seconds for the whole file. Building the mask is now deferred
until a text area actually needs blanking, since that step is what forced the
decode. The minimum Pillow version is raised to 12, because deciding whether
anything decoded the image reads an attribute whose shape settled in Pillow 11
(`pi-heif` already required Pillow 11.1, so the effective floor barely moves).
- Reduced peak memory on files with large images by about a third at default
settings. On a 34 megapixel page the process tree peaked at 752 MB and now
peaks at 492 MB. Two changes account for it: rasterizing a page no longer
allocates a third full-page buffer to correct PDFium's rounding of the
rendered size by a pixel or two, and freed heap memory is now returned to the
operating system before the OCR engine runs, instead of counting against our
resident set for as long as the engine is working. Rasterization is also
faster, since correcting the size no longer resamples the whole page.
- Several OCR jobs may now run concurrently in a single Python process. The API
previously held a lock for the whole duration of `ocrmypdf.ocr()`, so a second
call in another thread had to wait for the first to finish. Plugin state is
now guarded by a readers-writer lock: installing a plugin set takes it
exclusively, and a job holds it shared for its run. An in-flight job therefore
cannot have the plugin infrastructure it depends on replaced underneath it,
while jobs that are past installation proceed concurrently.
- A plugin set is now installed once per interpreter and reused, rather than
being reinstalled on every call. Previously each `ocrmypdf.ocr()` call
re-executed plugin modules given as file paths and rebound them in
`sys.modules`. Plugins must not rely on being re-executed for each job, and
must not store per-job state on the plugin manager, which concurrent jobs
requesting the same plugin set now share.
- Jobs requesting different plugin sets serialize against each other, since
installing the second set must wait for the first set's jobs to finish. Use
the same plugin set across concurrent jobs, or separate processes.
- Image optimization is substantially faster on documents with large images.
An image stored as `/FlateDecode` with a PNG predictor already holds exactly
what a PNG `IDAT` chunk holds, so it is now repackaged as a PNG directly
instead of being decoded to a bitmap and re-encoded. On a 6-page document
containing one 9000x9000 image, the optimization step went from 3.4s to 0.6s,
and to 0.04s together with the JPEG change below; total runtime went from
10.0s to 7.0s. Output is unchanged: the compressed
data is reused verbatim. Images that are not in a directly repackageable form
still take the previous path.
- During image optimization, we decoded all JPEGs, even if the code
path was an optimization setting with the decoded JPEG would be never be
re-encoded (below `--optimize 2`). We now decode only on code paths that
use the decoded JPEG. Output is unchanged.
- An uncompressed image (one with no `/Filter`) no longer produces a spurious
"could not be processed by the optimizer" warning. Such an image raised
`IndexError` internally, which the optimizer's best-effort handler caught and
reported as a warning; it is now recognized and skipped quietly.
- Removed an unreachable branch in the image optimizer that claimed to handle
1 bit per component images in an ICC-based colorspace. An earlier check sends
every 1 bpc image to the JBIG2 pass, which handles ICC-based images by
neutralizing the profile before extracting, so the branch could never run.
- Removed the process-wide lock that serialized worker pools across all
`Executor` instances. `Executor.pool_lock` is retained but no longer acquired,
and is deprecated; it will be removed in a future major release. The invariant
it protected - that only one progress bar renders on the shared console - is
now enforced by the progress bar, which disables itself if another bar already
owns the console.
- Note that N concurrent jobs each configured with `jobs=M` may now spawn up to
N*M workers, where previously they were serialized to M. Size `jobs`
accordingly.
- Known limitation of concurrent in-process jobs: they must use the same
`max_image_mpixels`. Pillow's decompression-bomb limit is interpreter-global
and the last job to set it wins. Use separate processes to run jobs with
differing configurations.
**Fixes**
- Fixed a latent use-after-free in the pypdfium2 rasterizer. `to_pil()` lets
Pillow alias PDFium's bitmap buffer for some formats -- grayscale renders
among them, which is every mono and grayscale page -- and the buffer was freed
immediately afterwards, leaving Pillow reading memory PDFium had released.
- Windows: OCRmyPDF no longer prints `[WinError 2] The system cannot find the
file specified` warnings while it searches for Ghostscript and Tesseract
([#1671](https://github.com/ocrmypdf/OCRmyPDF/discussions/1671)). These
messages came from probing registry keys that simply don't exist when the
programs were installed by a package manager such as Scoop, or not installed
at all. Since the search then continues elsewhere and usually succeeds, these
failures are normal, and are now logged at debug level, naming the location
that was searched. If a program genuinely cannot be found, OCRmyPDF still
reports that as an error.
- Windows: fixed a crash when the `PROGRAMFILES` environment variable pointed to
a folder that does not exist.
- `--mode strip` failed to remove OCR text layers that OCRmyPDF itself
produced ({issue}`1730`). OCRmyPDF grafts its text layer as a Form XObject
and stripping only examined the page content stream, so the invisible text
was never found. The same flaw made `--mode redo` stack a second text layer
on top of the old one instead of replacing it. Stripping now descends into
Form XObjects. Thanks @Anai-Guo ({issue}`1732`).
- Stripping now also resolves page `/Resources` inherited from an ancestor
`/Pages` node, rather than only looking at the page's own resources.
- A text layer that becomes empty after stripping is now removed from the page
instead of being left behind as a vestigial Form XObject husk.
- Setting `clean_final` on an existing options object in the Python API no
longer leaves `clean` unset. `--clean-final` implies `--clean`, but the rule
lived in a field validator that only ran while the options object was being
constructed, so assigning to the attribute afterwards silently skipped it.
- Validation errors for out-of-range or misspelled options now name the option
they are about, and their wording comes from Pydantic rather than being
hand-written, so it has changed slightly. For example, `--jobs 999` now
reports `--jobs: Input should be less than or equal to 256`. The set of
accepted values is unchanged.