feat: add --mode strip to remove the OCR text layer without rasterizing

Adds a processing mode that removes the invisible (render mode 3) OCR text
layer in place. Unlike `--ocr-engine none --force-ocr`, it does not
rasterize the page, so images and visible content are preserved unchanged
and the output is smaller rather than larger. Options that require
rasterization or OCR (--deskew, --clean, --sidecar, etc.) are rejected.

Only invisible text is removed; text drawn as visible glyphs under an
opaque image (some OCR engines, and OCRmyPDF v2.2 and earlier) cannot be
removed this way, as documented.

Closes #1435.
This commit is contained in:
James R. Barlow
2026-06-11 12:34:03 -07:00
parent 8a8d515933
commit 0d4c3bcdcf
8 changed files with 152 additions and 11 deletions

View File

@@ -249,19 +249,34 @@ case. Use `--tesseract-non-ocr-timeout` to control the timeout for
non-OCR operations, if needed.
:::
### Remove all text or OCR from my PDF
### Remove the OCR text layer from my PDF
This is getting ridiculous, but OCRmyPDF can complete strip all textual
information from a PDF and reconstruct it as a \"bag of images\" PDF.
To remove the invisible OCR text layer while keeping the original pages
exactly as they are -- no rasterizing, no change to images or visible
content, and a smaller output file -- use `--mode strip`:
```bash
ocrmypdf --mode strip input.pdf output.pdf
```
Why would you want to do this? Perhaps you have a PDF where OCR failed to
produce useful results and you simply want to get rid of it.
`--mode strip` removes only text drawn as *invisible* (PDF text render
mode 3), which is how OCRmyPDF and most OCR tools add a searchable layer
over a scanned page. Some OCR products -- and OCRmyPDF v2.2 and earlier --
instead draw *visible* text and paint an opaque image on top of it. That
text is part of the visible page, so `--mode strip` cannot remove it
without altering the page's appearance.
To strip *all* text, including such visible text, rasterize the whole page
into a \"bag of images\" PDF instead (this rebuilds every page as an image,
so the file usually grows and vector content is lost):
```bash
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
produce useful results, and just want to get rid of all OCR information.
This command also removes OCR generated by third party tools.
### Optimize images without performing OCR
You can also optimize all images without performing any OCR:

View File

@@ -80,6 +80,13 @@
interpreted "lots of diacritics - possibly poor OCR" hint, but now also emits
Tesseract's raw message at debug verbosity (``-v 1``) so the original wording
is available for diagnosis. {issue}`1566`
- Added ``--mode strip``, which removes the invisible OCR text layer from a PDF
in place. Unlike ``--ocr-engine none --force-ocr``, it does not rasterize the
page, so images and visible content are preserved unchanged and the output is
smaller rather than larger. Only text drawn as invisible (PDF text render mode
3) is removed; some OCR engines -- and OCRmyPDF v2.2 and earlier -- express
text as visible glyphs covered by an opaque image, and that text cannot be
removed this way. {issue}`1435`
## v17.5.0

View File

@@ -342,6 +342,14 @@ class OcrGrafter:
ocr_tree: OCR tree for fpdf2 renderer.
autorotate_correction: Orientation correction in degrees (0, 90, 180, 270).
"""
if self.context.options.mode == ProcessingMode.strip_text:
# Strip mode: remove the invisible OCR text layer in place without
# rasterizing or grafting anything. Honor --pages if specified.
options = self.context.options
if not options.pages or pageno in options.pages:
strip_invisible_text(self.pdf_base, self.pdf_base.pages[pageno])
return
if ocr_output and ocr_tree:
raise ValueError(
'Cannot specify both ocr_output and ocr_tree for fpdf2 renderer'

View File

@@ -43,12 +43,16 @@ class ProcessingMode(StrEnum):
- ``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
- ``strip``: Remove the invisible OCR text layer in place; do not OCR
"""
default = 'default'
force = 'force'
skip = 'skip'
redo = 'redo'
# User-facing value is '--mode strip'; the member is named strip_text to
# avoid shadowing str.strip on this str-based enum.
strip_text = 'strip'
class TaggedPdfMode(StrEnum):
@@ -79,8 +83,7 @@ def _resolve_page_token(token: str, total_pages: int | None) -> int:
if token.lower() == 'end':
if total_pages is None:
raise BadArgsError(
"'end' was used in --pages but the total page count is not yet "
"known"
"'end' was used in --pages but the total page count is not yet known"
)
return total_pages
return int(token)

View File

@@ -334,6 +334,11 @@ def is_ocr_required(page_context: PageContext) -> bool:
pageinfo = page_context.pageinfo
options = page_context.options
if options.mode == ProcessingMode.strip_text:
# Strip mode removes the OCR text layer in place; it never rasterizes
# or runs OCR. The stripping happens in OcrGrafter.graft_page.
return False
ocr_required = True
if options.pages and pageinfo.pageno not in options.pages:

View File

@@ -17,7 +17,7 @@ import pikepdf
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._exec import unpaper
from ocrmypdf._options import OcrOptions
from ocrmypdf._options import OcrOptions, ProcessingMode
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf.exceptions import (
BadArgsError,
@@ -118,8 +118,36 @@ def check_options_preprocessing(options: OcrOptions) -> None:
)
def check_options_strip(options: OcrOptions) -> None:
"""Reject options that cannot apply in strip mode.
``--mode strip`` removes the OCR text layer in place without rasterizing or
running OCR, so image-processing and OCR-output options have no effect.
"""
if options.mode != ProcessingMode.strip_text:
return
incompatible = {
'--deskew': options.deskew,
'--clean': options.clean,
'--clean-final': options.clean_final,
'--remove-background': options.remove_background,
'--rotate-pages': options.rotate_pages,
'--oversample': options.oversample,
'--remove-vectors': options.remove_vectors,
'--sidecar': options.sidecar,
}
used = sorted(name for name, value in incompatible.items() if value)
if used:
raise BadArgsError(
"--mode strip removes the OCR text layer without rasterizing or "
"running OCR, so these options have no effect and are not allowed: "
f"{', '.join(used)}"
)
def _check_plugin_invariant_options(options: OcrOptions) -> None:
check_platform()
check_options_strip(options)
check_options_sidecar(options)
check_options_preprocessing(options)

View File

@@ -327,7 +327,11 @@ Online documentation is located at:
"'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).",
"'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr). "
"'strip' removes the invisible OCR text layer without rasterizing or "
"running OCR, producing a smaller file; only text drawn as invisible "
"(render mode 3) is removed, so text from some OCR engines cannot be "
"removed this way.",
)
# Legacy flags for backward compatibility - these set the mode internally
ocrsettings.add_argument(

71
tests/test_strip.py Normal file
View File

@@ -0,0 +1,71 @@
# SPDX-FileCopyrightText: 2026 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Tests for --mode strip (remove the OCR text layer in place)."""
from __future__ import annotations
import pikepdf
import pytest
from ocrmypdf.exceptions import BadArgsError
from ocrmypdf.pdfinfo import PdfInfo
from .conftest import check_ocrmypdf, run_ocrmypdf_api
def _image_raw_bytes(pdf_path):
"""Return raw (still-compressed) stream bytes of each image on page 1."""
out = []
with pikepdf.open(pdf_path) as pdf:
resources = pdf.pages[0].get('/Resources', {})
for _name, xobj in resources.get('/XObject', {}).items():
if xobj.get('/Subtype') == pikepdf.Name.Image:
out.append(bytes(xobj.read_raw_bytes()))
return out
def test_mode_strip_removes_ocr_layer(resources, outpdf):
"""--mode strip removes the invisible OCR layer without rasterizing.
The page image is preserved byte-for-byte and the output is no larger than
the input.
"""
input_pdf = resources / 'graph_ocred.pdf'
assert PdfInfo(input_pdf, detailed_analysis=True)[0].has_text
out = check_ocrmypdf(
input_pdf, outpdf, '--mode', 'strip', '--output-type', 'pdf', '--optimize', '0'
)
info = PdfInfo(out, detailed_analysis=True)
assert len(info) == 1, "page count must be unchanged"
assert not info[0].has_text, "OCR text layer should be removed"
assert _image_raw_bytes(out) == _image_raw_bytes(input_pdf), (
"page image must be preserved byte-for-byte (no rasterization)"
)
assert out.stat().st_size <= input_pdf.stat().st_size, (
"removing the text layer must not grow the file"
)
def test_mode_strip_preserves_visible_text(resources, outpdf):
"""--mode strip leaves visible/born-digital text untouched (render mode != 3).
type3_font_nomapping.pdf is born-digital text with no images (the #1608
case): its visible text must survive strip, which only removes invisible
OCR text.
"""
input_pdf = resources / 'type3_font_nomapping.pdf'
out = check_ocrmypdf(
input_pdf, outpdf, '--mode', 'strip', '--output-type', 'pdf', '--optimize', '0'
)
assert PdfInfo(out, detailed_analysis=True)[0].has_text
def test_mode_strip_rejects_image_processing_options(resources, no_outpdf):
"""Options requiring rasterization/OCR are rejected in strip mode."""
with pytest.raises(BadArgsError, match=r'--deskew'):
run_ocrmypdf_api(
resources / 'graph_ocred.pdf', no_outpdf, '--mode', 'strip', '--deskew'
)