From 9748208e6854ae9bf7fd872421fc0aa4411fe816 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 26 May 2026 12:18:09 -0700 Subject: [PATCH] Support 'end' alias for last page in --pages Closes #1615. The token 'end' (case-insensitive) is now accepted as an alias for the document's last page, e.g. --pages 3-end. Resolution is deferred until the page count is known from the input PDF. --- docs/cookbook.md | 20 +++++++--- src/ocrmypdf/_options.py | 49 ++++++++++++++++++++++--- src/ocrmypdf/_pipelines/_common.py | 9 ++++- src/ocrmypdf/cli.py | 3 +- tests/test_page_numbers.py | 59 ++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 13 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index 673e4d4b..288fe75c 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -343,12 +343,22 @@ Hyphens denote a range of pages and commas separate page numbers. If you prefer to use spaces, quote all of the page numbers: `--pages '2, 3, 5, 7'`. +The token `end` (case-insensitive) is an alias for the last page in the +document. For example, `--pages 3-end` OCRs from page 3 through the +final page, and `--pages end` OCRs only the last page: + +```bash +ocrmypdf --pages 3-end input.pdf output.pdf +ocrmypdf --pages end input.pdf output.pdf +``` + OCRmyPDF will warn if your list of page numbers contains duplicates or -overlapping pages. OCRmyPDF does not currently account for document page -numbers, such as an introduction section of a book that uses Roman -numerals. It simply counts the number of virtual pieces of paper since -the start. If your list of pages is out of numerical order, OCRmyPDF -will sort it for you. +overlapping pages. (Repeated page numbers are de-duplicated automatically, +since the underlying set of pages is what matters.) OCRmyPDF does not +currently account for document page numbers, such as an introduction +section of a book that uses Roman numerals. It simply counts the number +of virtual pieces of paper since the start. If your list of pages is out +of numerical order, OCRmyPDF will sort it for you. Regardless of the argument to `--pages`, OCRmyPDF will optimize all pages/images in the file and convert it to PDF/A, unless you disable diff --git a/src/ocrmypdf/_options.py b/src/ocrmypdf/_options.py index 489790aa..2396052d 100644 --- a/src/ocrmypdf/_options.py +++ b/src/ocrmypdf/_options.py @@ -65,8 +65,34 @@ class TaggedPdfMode(StrEnum): ignore = 'ignore' -def _pages_from_ranges(ranges: str) -> set[int]: - """Convert page range string to set of page numbers.""" +def _has_end_alias(ranges: str) -> bool: + """Return True if the page range string uses the ``end`` alias.""" + return 'end' in ranges.lower() + + +def _resolve_page_token(token: str, total_pages: int | None) -> int: + """Convert a single page-number token to a 1-based integer. + + The literal ``end`` (case-insensitive) is resolved to ``total_pages``. If + ``total_pages`` is None, an error is raised. + """ + 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" + ) + return total_pages + return int(token) + + +def _pages_from_ranges(ranges: str, total_pages: int | None = None) -> set[int]: + """Convert page range string to set of 0-based page numbers. + + The token ``end`` (case-insensitive) is an alias for the last page of the + document. It is resolved using ``total_pages``; if ``end`` appears in the + string and ``total_pages`` is None, a :class:`BadArgsError` is raised. + """ pages: list[int] = [] page_groups = ranges.replace(' ', '').split(',') for group in page_groups: @@ -75,10 +101,15 @@ def _pages_from_ranges(ranges: str) -> set[int]: try: start, end = group.split('-') except ValueError: - pages.append(int(group) - 1) + try: + pages.append(_resolve_page_token(group, total_pages) - 1) + except ValueError: + raise BadArgsError(f"invalid page number '{group}'") from None else: try: - new_pages = list(range(int(start) - 1, int(end))) + start_n = _resolve_page_token(start, total_pages) + end_n = _resolve_page_token(end, total_pages) + new_pages = list(range(start_n - 1, end_n)) if not new_pages: raise BadArgsError( f"invalid page subrange '{start}-{end}'" @@ -332,11 +363,19 @@ class OcrOptions(BaseModel): @field_validator('pages') @classmethod def validate_pages_format(cls, v): - """Convert page ranges string to set of page numbers.""" + """Convert page ranges string to set of page numbers. + + If the string uses the ``end`` alias, the original string is preserved + so that resolution can happen later, once the document's page count is + known. + """ if v is None: return v if isinstance(v, set): return v # Already processed + if _has_end_alias(v): + # Defer resolution until total page count is known + return v # Convert string ranges to set of page numbers return _pages_from_ranges(v) diff --git a/src/ocrmypdf/_pipelines/_common.py b/src/ocrmypdf/_pipelines/_common.py index 802c6f42..464e2ffb 100644 --- a/src/ocrmypdf/_pipelines/_common.py +++ b/src/ocrmypdf/_pipelines/_common.py @@ -343,12 +343,17 @@ def setup_pipeline( def do_get_pdfinfo(pdf_path: Path, executor: Executor, options) -> PdfInfo: - # Handle pages field - it might be a string that needs conversion + # Handle pages field - it might be a string that needs conversion. + # A string indicates the ``end`` alias was used and resolution was + # deferred; we resolve it now using the document's actual page count. check_pages = options.pages if isinstance(check_pages, str): from ocrmypdf._options import _pages_from_ranges - check_pages = _pages_from_ranges(check_pages) + with Pdf.open(pdf_path) as pdf: + total_pages = len(pdf.pages) + check_pages = _pages_from_ranges(check_pages, total_pages=total_pages) + options.pages = check_pages return get_pdfinfo( pdf_path, diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index a4bbf2ff..c544fd01 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -387,7 +387,8 @@ Online documentation is located at: type=str, help=( "Limit OCR to the specified pages (ranges or comma separated), " - "skipping others" + "skipping others. The token 'end' is an alias for the last page, " + "so e.g. '3-end' OCRs from page 3 to the last page." ), ) advanced.add_argument( diff --git a/tests/test_page_numbers.py b/tests/test_page_numbers.py index 79b1acf0..065459ef 100644 --- a/tests/test_page_numbers.py +++ b/tests/test_page_numbers.py @@ -42,6 +42,35 @@ def test_pages(pages, result): assert _pages_from_ranges(pages) == result +@pytest.mark.parametrize( + 'pages, total_pages, result', + [ + ['end', 10, {9}], + ['END', 10, {9}], + ['1-end', 3, {0, 1, 2}], + ['3-end', 5, {2, 3, 4}], + ['end-end', 7, {6}], + ['1,end', 4, {0, 3}], + ['2-4,end', 10, {1, 2, 3, 9}], + ['end,end,end', 5, {4}], + ['end-1', 5, BadArgsError], # empty range when end > 1 + ], +) +def test_pages_end_alias(pages, total_pages, result): + if isinstance(result, type): + with pytest.raises(result): + _pages_from_ranges(pages, total_pages=total_pages) + else: + assert _pages_from_ranges(pages, total_pages=total_pages) == result + + +def test_end_alias_requires_total_pages(): + with pytest.raises(BadArgsError, match="total page count"): + _pages_from_ranges('1-end') + with pytest.raises(BadArgsError, match="total page count"): + _pages_from_ranges('end') + + def test_nonmonotonic_warning(caplog): pages = _pages_from_ranges('1, 3, 2') assert pages == {0, 1, 2} @@ -61,3 +90,33 @@ def test_limited_pages(multipage, outpdf): assert not pi.pages[0].has_text assert pi.pages[4].has_text assert pi.pages[5].has_text + + +def test_limited_pages_end_alias(multipage, outpdf): + # multipage has 6 pages; 5-end == pages 5..6 + ocrmypdf.ocr( + multipage, + outpdf, + pages='5-end', + optimize=0, + output_type='pdf', + plugins=['tests/plugins/tesseract_cache.py'], + ) + pi = PdfInfo(outpdf) + assert not pi.pages[0].has_text + assert pi.pages[4].has_text + assert pi.pages[5].has_text + + +def test_pages_end_alone(multipage, outpdf): + ocrmypdf.ocr( + multipage, + outpdf, + pages='end', + optimize=0, + output_type='pdf', + plugins=['tests/plugins/tesseract_cache.py'], + ) + pi = PdfInfo(outpdf) + assert not pi.pages[0].has_text + assert pi.pages[5].has_text