home · contact · privacy
Turn page ranges into proper class, move validation there.
authorPlom Heller <plom@plomlompom.com>
Sun, 1 Nov 2026 15:27:08 +0000 (16:27 +0100)
committerPlom Heller <plom@plomlompom.com>
Sun, 1 Nov 2026 15:27:08 +0000 (16:27 +0100)
src/bookmaker/misc.py

index 767c32f56affd4b7c4cb891130b02adeac40f163..7267ddd0f2d916c98cf34218aebec3b9a0f4140a 100755 (executable)
@@ -39,6 +39,61 @@ PAGE_ORDER_FOR_NUP4 = (3, 0, 7, 4, 1, 2, 5, 6)
 ROTATE_COMMAND = '/Rotate'
 
 
+_TOK_RANGE_START = 'start'
+_TOK_RANGE_END = 'end'
+
+
+class PageRange:
+    'Section of multi-page document.'
+    start = 0
+    end: int
+
+    def __init__(
+            self,
+            code: Optional[str],
+            end_max: int = -1
+            ) -> None:
+        if code:
+            prefix = 'page range string'
+            if '-' not in code:
+                raise MsgFail(f'{prefix} lacks "-": {code}')
+            tokens = code.split('-')
+            if len(tokens) > 2:
+                raise MsgFail(f'{prefix} has too many "-": {code}')
+            for i, token in enumerate(tokens):
+                if token == '':
+                    continue
+                if i == 0 and token == _TOK_RANGE_START:
+                    continue
+                if i == 1 and token == _TOK_RANGE_END:
+                    continue
+                try:
+                    int(token)
+                except ValueError as e:
+                    raise MsgFail(
+                        f'{prefix} carries value neither integer, nor '
+                        f'"{_TOK_RANGE_START}", nor "{_TOK_RANGE_END}": {code}'
+                        ) from e
+                if int(token) < 1:
+                    raise MsgFail(f'{prefix} carries page number <1: {code}')
+            str_start, str_end = code.split('-')
+            self.end = end_max
+            if str_end not in {'', _TOK_RANGE_END}:
+                self.end = int(str_end)
+                if self.end > end_max >= 0:
+                    raise MsgFail(
+                            f'{prefix} exceeds maximum {end_max}: {code}')
+            if str_start not in {'', _TOK_RANGE_START}:
+                self.start = int(str_start) - 1
+
+    def ranging(
+            self
+            ) -> range:
+        'Turn into range().'
+        assert self.end >= 0
+        return range(self.start, self.end)
+
+
 class Page:
     'Fuses PdfPage, PageCrop, and PdfTransformation.'
 
@@ -388,8 +443,10 @@ def parse_args(
                         help='output PDF file')
     parser.add_argument('-p',
                         '--page_range',
+                        type=PageRange,
                         action='append',
-                        help='page range, e.g. "2-9" or "3-end" or "start-14"')
+                        help=(f'page range, e.g. "2-9" or "3-{_TOK_RANGE_END}"'
+                              f'or "{_TOK_RANGE_START}-14"'))
     parser.add_argument('-c',
                         '--crops',
                         action='append',
@@ -437,45 +494,9 @@ def _validate_args_syntax(
         ) -> None:
     'Check command args against general syntax expectations.'
 
-    def validate_page_range(
-            pr_string: str
-            ) -> None:
-        prefix = 'page range string'
-        if '-' not in pr_string:
-            raise MsgFail(f'{prefix} lacks "-": {pr_string}')
-        tokens = pr_string.split('-')
-        if len(tokens) > 2:
-            raise MsgFail(f'{prefix} has too many "-": {pr_string}')
-        for i, token in enumerate(tokens):
-            if token == '':
-                continue
-            if i == 0 and token == 'start':
-                continue
-            if i == 1 and token == 'end':
-                continue
-            try:
-                int(token)
-            except ValueError as e:
-                raise MsgFail(f'{prefix} carries value neither integer, '
-                              f'nor "start", nor "end": {pr_string}') from e
-            if int(token) < 1:
-                raise MsgFail(f'{prefix} carries page number <1: {pr_string}')
-        start = -1
-        end = -1
-        try:
-            start = int(tokens[0])
-            end = int(tokens[1])
-        except ValueError:
-            pass
-        if start > end > 0:
-            raise MsgFail(
-                    f'{prefix} has higher start than end value: {pr_string}')
-
     try:
         if args.page_range:
             arg_char = 'p'
-            for p_string in args.page_range:
-                validate_page_range(p_string)
             if len(args.page_range) > len(args.input_file):
                 raise MsgFail(
                     'more --page_range arguments than --input_file arguments')
@@ -487,9 +508,8 @@ def _validate_args_syntax(
                     raise MsgFail(
                             f'cropping string has multiple ":": {c_string}')
                 page_range, crops_str = split_crops_string(c_string)
+                PageRange(page_range)
                 crops = crops_str.split(',')
-                if page_range:
-                    validate_page_range(page_range)
                 if len(crops) != 4:
                     raise MsgFail('cropping does not contain exactly three '
                                   f'",": {c_string}')
@@ -508,7 +528,7 @@ def _validate_args_syntax(
                 if r < 1:
                     raise MsgFail('value must not be <1: {r}')
     except MsgFail as e:
-        raise ArgFail(arg_char, e.msg)
+        raise ArgFail(arg_char, e.msg) from e
     try:
         float(args.print_margin)
     except ValueError as e:
@@ -529,28 +549,13 @@ def split_crops_string(
     return page_range, crops
 
 
-def parse_page_range(
-        range_string: Optional[str],
-        idx_after: int
-        ) -> tuple[int, int]:
-    'Based on actual pages size read range_string into range limit indices.'
-    idx_start = 0
-    if range_string:
-        start, end = range_string.split('-')
-        if not (len(start) == 0 or start == 'start'):
-            idx_start = int(start) - 1
-        if not (len(end) == 0 or end == 'end'):
-            idx_after = int(end)
-    return idx_start, idx_after
-
-
 class Book:
     'Collection of pages.'
 
     def __init__(
             self,
             input_filenames: list[str],
-            args_page_range: list[str],
+            args_page_range: list[PageRange],
             do_analyze: bool
             ) -> None:
         self.pages: list[Page] = []
@@ -569,11 +574,10 @@ class Book:
                     to_close.close()
                 raise ArgFail('i',
                               f'cannot interpret {path} as PDF file') from e
-            range_string = None
-            if args_page_range and len(args_page_range) > i:
-                range_string = args_page_range[i]
-            for old_page_num in range(*parse_page_range(range_string,
-                                                        len(reader.pages))):
+            for old_page_num in (
+                    args_page_range[i] if len(args_page_range) > i
+                    else PageRange(None, len(reader.pages))
+                                   ).ranging():
                 new_page_num += 1
                 page = (Page.new_blank() if old_page_num >= len(reader.pages)
                         else Page(reader.pages[old_page_num]))
@@ -598,11 +602,10 @@ class Book:
         len_ = len(self)
         if args_crops:
             for c_string in args_crops:
-                if (page_range := split_crops_string(c_string)[0])\
-                        and parse_page_range(page_range, len_)[1] > len_:
-                    raise ArgFail('c',
-                                  'page range goes beyond number of pages '
-                                  f'we\'re building: {page_range}')
+                try:
+                    PageRange(split_crops_string(c_string)[0], len_ - 1)
+                except MsgFail as e:
+                    raise ArgFail('c', e.msg) from e
         if args_rotate_page:
             for r in [r for r in args_rotate_page if r > len_]:
                 raise ArgFail('r',
@@ -677,17 +680,18 @@ class Book:
         'Calculate individual PageCrops from inputs.'
         if args_crops:
             for c_string in args_crops:
-                page_range, crops = split_crops_string(c_string)
-                idx_start, idx_after = parse_page_range(page_range, len(self))
+                range_code, crops = split_crops_string(c_string)
+                page_range = PageRange(range_code, len(self))
                 prefix = '-c, -t' if args_symmetry else '-c'
                 suffix = ((' (but alternating left and right crop '
                           'between even and odd pages)') if args_symmetry
                           else '')
                 page_crop = PageCrop(*(float(crop)
                                        for crop in crops.split(',')))
-                print(f'{prefix}: to pages {idx_start + 1}:{idx_after} '
-                      f'applying crop: {page_crop.format_in_cm}{suffix}')
-                for idx in range(idx_start, idx_after):
+                print(f'{prefix}: to pages {page_range.start + 1}:'
+                      f'{page_range.end} applying crop: '
+                      f'{page_crop.format_in_cm}{suffix}')
+                for idx in page_range.ranging():
                     self.pages[idx].crop.add(page_crop,
                                              args_symmetry and idx % 2)
         elif args_keep_mediabox: