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:
+15
-5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user