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.
This commit is contained in:
James R. Barlow
2026-05-26 12:18:09 -07:00
parent e4b0c04be4
commit 9748208e68
5 changed files with 127 additions and 13 deletions
+15 -5
View File
@@ -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
+44 -5
View File
@@ -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)
+7 -2
View File
@@ -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,
+2 -1
View File
@@ -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(
+59
View File
@@ -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