diff --git a/docs/cookbook.md b/docs/cookbook.md index 288fe75c..89525b11 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -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: diff --git a/docs/releasenotes/version17.md b/docs/releasenotes/version17.md index 92ad5b0f..2add64e7 100644 --- a/docs/releasenotes/version17.md +++ b/docs/releasenotes/version17.md @@ -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 diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index 497a632b..677f9682 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -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' diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index 2396052d..3e956135 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -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) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 0e53b1d6..161d77df 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -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: diff --git a/src/ocrmypdf/_validation.py b/src/ocrmypdf/_validation.py index a662ee69..c2e0f183 100644 --- a/src/ocrmypdf/_validation.py +++ b/src/ocrmypdf/_validation.py @@ -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) diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index 31756b4c..1cff9db6 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -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( diff --git a/tests/test_strip.py b/tests/test_strip.py new file mode 100644 index 00000000..057e07ef --- /dev/null +++ b/tests/test_strip.py @@ -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' + )