mirror of
https://github.com/ocrmypdf/OCRmyPDF.git
synced 2026-07-30 15:08:10 -04:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
71
tests/test_strip.py
Normal 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'
|
||||
)
|
||||
Reference in New Issue
Block a user