home · contact · privacy
Add typing.
authorPlom Heller <plom@plomlompom.com>
Sun, 5 Apr 2026 21:48:44 +0000 (23:48 +0200)
committerPlom Heller <plom@plomlompom.com>
Sun, 5 Apr 2026 21:48:44 +0000 (23:48 +0200)
bookmaker.py

index 65da0a64d98ab30943182c188ca327cacf49b845..b60576de6bd8794b4e60f066dcf38acdd5c48fcf 100755 (executable)
@@ -8,9 +8,12 @@ import argparse
 import io
 import os
 import sys
+from typing import Any, Optional
 
 
-def handled_error_exit(msg):
+def handled_error_exit(
+        msg: str
+        ) -> None:
     'Print msg, then exit(1).'
     print(f'ERROR: {msg}')
     sys.exit(1)
@@ -41,7 +44,13 @@ PAGE_ORDER_FOR_NUP4 = (3, 0, 7, 4, 1, 2, 5, 6)
 class PageCrop:
     'Per-page crop instructions as sizes in point and cm, and A4-zoom factor.'
 
-    def __init__(self, left_cm=0, bottom_cm=0, right_cm=0, top_cm=0):
+    def __init__(
+            self,
+            left_cm: int = 0,
+            bottom_cm: int = 0,
+            right_cm: int = 0,
+            top_cm: int = 0
+            ) -> None:
         self.left_cm = left_cm
         self.bottom_cm = bottom_cm
         self.right_cm = right_cm
@@ -60,26 +69,36 @@ class PageCrop:
         else:
             self.zoom = max(zoom_horizontal, zoom_vertical)
 
-    def __str__(self):
+    def __str__(
+            self
+            ) -> str:
         return str(vars(self))
 
     @property
-    def format_in_cm(self):
+    def format_in_cm(
+            self
+            ) -> str:
         'Human-readable listing of crops in cm.'
         return (f'left {self.left_cm}cm, bottom {self.bottom_cm}cm, '
                 f'right {self.right_cm}cm, top {self.top_cm}cm')
 
     @property
-    def remaining_width(self):
+    def remaining_width(
+            self
+            ) -> float:
         "What's left of A4_WIDTH after applying width croppings."
         return A4_WIDTH - self.left - self.right
 
     @property
-    def remaining_height(self):
+    def remaining_height(
+            self
+            ) -> float:
         "What's left of A4_WIDTH after applying height croppings."
         return A4_HEIGHT - self.bottom - self.top
 
-    def make_mirror(self):
+    def make_mirror(
+            self
+            ) -> 'PageCrop':
         'Return PageCrop of swapped .left and .right.'
         return PageCrop(left_cm=self.right_cm,
                         bottom_cm=self.bottom_cm,
@@ -90,7 +109,10 @@ class PageCrop:
 class Nup4Geometry:
     'Nup4-specific attributes, i.e. outer-page margins, spine sizes.'
 
-    def __init__(self, margin_cm):
+    def __init__(
+            self,
+            margin_cm: int
+            ) -> None:
         self.margin = margin_cm * POINTS_PER_CM
         self.shrink_for_margin = (A4_WIDTH - 2 * self.margin)/A4_WIDTH
         # NB: We define spine size un-shrunk, but .shrink_for_spine is used
@@ -103,13 +125,20 @@ class Nup4Geometry:
 class ArgFail(Exception):
     'Collects relevant command character, followed by explanation.'
 
-    def __init__(self, arg_char, msg, *args, **kwargs):
+    def __init__(
+            self,
+            arg_char: str,
+            msg: str,
+            *args,
+            **kwargs
+            ) -> None:
         super().__init__(*args, **kwargs)
         self.arg_char = arg_char
         self.msg = msg
 
 
-def parse_args():
+def parse_args(
+        ) -> argparse.Namespace:
     'Collect command line arguments.'
     help_epilogue = ('See README.txt for detailed usage instructions, '
                      'command examples, etc.')
@@ -170,9 +199,15 @@ def parse_args():
     return parser.parse_args()
 
 
-def validate_args_syntax(args):
+def validate_args_syntax(
+        args: argparse.Namespace
+        ) -> None:
     'Check command args against general syntax expectations.'
-    def validate_page_range(pr_string, arg_char):
+
+    def validate_page_range(
+            pr_string: str,
+            arg_char: str
+            ) -> None:
         prefix = 'page range string'
         if '-' not in pr_string:
             raise ArgFail(arg_char, f'{prefix} lacks "-": {pr_string}')
@@ -220,8 +255,8 @@ def validate_args_syntax(args):
             if len(initial_split) > 2:
                 raise ArgFail('c',
                               f'cropping string has multiple ":": {c_string}')
-            page_range, crops = split_crops_string(c_string)
-            crops = crops.split(',')
+            page_range, crops_str = split_crops_string(c_string)
+            crops = crops_str.split(',')
             if page_range:
                 validate_page_range(page_range, 'c')
             if len(crops) != 4:
@@ -247,7 +282,9 @@ def validate_args_syntax(args):
         raise ArgFail('m', f'non-float value: {args.print_margin}')
 
 
-def split_crops_string(c_string):
+def split_crops_string(
+        c_string: str
+        ) -> tuple[Optional[str], str]:
     'If c_string contains ":" return before and after, else None and c_string.'
     initial_split = c_string.split(':', maxsplit=1)
     if len(initial_split) > 1:
@@ -259,7 +296,10 @@ def split_crops_string(c_string):
     return page_range, crops
 
 
-def parse_page_range(range_string, pages):
+def parse_page_range(
+        range_string: Optional[str],
+        pages: list[pypdf.PageObject]
+        ) -> tuple[int, int]:
     'Based on actual pages size read range_string into range limit indices.'
     idx_start = 0
     idx_after = len(pages)
@@ -272,7 +312,10 @@ def parse_page_range(range_string, pages):
     return idx_start, idx_after
 
 
-def args_to_pagelist(args_input_file, args_page_range):
+def args_to_pagelist(
+        args_input_file: list[str],
+        args_page_range: list[str]
+        ) -> tuple[list[pypdf.PageObject], list[io.BufferedReader]]:
     'Follow args_input_file ranged by args_page_range into pages, open files.'
     pages_to_add = []
     opened_files = []
@@ -305,7 +348,10 @@ def args_to_pagelist(args_input_file, args_page_range):
     return pages_to_add, opened_files
 
 
-def validate_ranges(args, pages_to_add):
+def validate_ranges(
+        args: argparse.Namespace,
+        pages_to_add: list[pypdf.PageObject]
+        ) -> None:
     'Check command args\' ranges fit into pages_to_add count.'
     if args.crops:
         for c_string in args.crops:
@@ -323,7 +369,10 @@ def validate_ranges(args, pages_to_add):
                               f'pages we\'re building: {r}')
 
 
-def rotate_pages(args_rotate_page, pages_to_add):
+def rotate_pages(
+        args_rotate_page: list[int],
+        pages_to_add: list[pypdf.PageObject]
+        ) -> None:
     'For pages_to_add page numbered in args_rotate_page, rotate by 90°.'
     if args_rotate_page:
         for rotate_page in args_rotate_page:
@@ -336,7 +385,9 @@ def rotate_pages(args_rotate_page, pages_to_add):
             print(f'-r: rotating (by 90°) page {rotate_page}')
 
 
-def pad_pages_to_multiple_of_8(pages_to_add):
+def pad_pages_to_multiple_of_8(
+        pages_to_add: list[pypdf.PageObject]
+        ) -> None:
     'To pages_to_add add blank pages until its size is multiple of 8.'
     mod_to_8 = len(pages_to_add) % 8
     if mod_to_8 > 0:
@@ -349,11 +400,14 @@ def pad_pages_to_multiple_of_8(pages_to_add):
               f'of 8, padded to {len(pages_to_add)}')
 
 
-def normalize_pages_to_a4(pages_to_add):
+def normalize_pages_to_a4(
+        pages_to_add: list[pypdf.PageObject]
+        ) -> None:
     'Adjust pages_to_add .mediabox=.cropbox to A4, enact /Rotate inside that.'
     for page in pages_to_add:
         if '/Rotate' in page:  # TODO: preserve rotation, but in canvas?
-            page.rotate(360 - page['/Rotate'])
+            rotation: int = page['/Rotate']  # type: ignore
+            page.rotate(360 - rotation)
         page.mediabox.left = 0
         page.mediabox.bottom = 0
         page.mediabox.top = A4_HEIGHT
@@ -394,7 +448,11 @@ def collect_page_croppings(args_crops,
     return page_croppings
 
 
-def build_single_pages_output(writer, pages_to_add, page_croppings):
+def build_single_pages_output(
+        writer: pypdf.PdfWriter,
+        pages_to_add: list[pypdf.PageObject],
+        page_croppings: list[PageCrop]
+        ) -> None:
     'On each of pages_to_add apply its page_croppings, then writer.add_page.'
     print('building 1-input-page-per-output-page book')
     odd_page = True   # TODO: removable?
@@ -412,12 +470,14 @@ def build_single_pages_output(writer, pages_to_add, page_croppings):
         print(f'built page number {i+1} (of {len(pages_to_add)})')
 
 
-def build_nup4_output(writer,
-                      pages_to_add,
-                      page_croppings,
-                      args_print_margin,
-                      args_analyze,
-                      canvas_class):
+def build_nup4_output(
+        writer: pypdf.PdfWriter,
+        pages_to_add: list[pypdf.PageObject],
+        page_croppings: list[PageCrop],
+        args_print_margin: int,
+        args_analyze: str,
+        canvas_class: Any
+        ) -> None:
     'Build nup4 pages from inputs.'
     print('-n: building 4-input-pages-per-output-page book')
     print(f'-m: applying printable-area margin of {args_print_margin}cm')
@@ -451,11 +511,13 @@ def build_nup4_output(writer,
             is_front_page = not is_front_page
 
 
-def resort_pages_for_nup4(pages_to_add):
+def resort_pages_for_nup4(
+        pages_to_add: list[pypdf.PageObject]
+        ) -> tuple[list[pypdf.PageObject], list[int]]:
     'Adapt pages_to_add towards PAGE_ORDER_FOR_NUP4.'
     new_page_order = []
     old_indices = []
-    eight_pack = []
+    eight_pack: list[pypdf.PageObject] = []
     i = 0
     n_eights = 0
     for page in pages_to_add:
@@ -472,7 +534,12 @@ def resort_pages_for_nup4(pages_to_add):
     return new_page_order, old_indices
 
 
-def nup4_inner_page_transform(page, crop, nup4_geometry, nup4_i):
+def nup4_inner_page_transform(
+        page: pypdf.PageObject,
+        crop: PageCrop,
+        nup4_geometry: Nup4Geometry,
+        nup4_i: int
+        ) -> None:
     'Apply to page crop instructions adequate to position in nup4 geometry.'
     page.add_transformation(pypdf.Transformation().translate(
         ty=A4_HEIGHT / crop.zoom - (A4_HEIGHT - crop.top)))
@@ -490,7 +557,11 @@ def nup4_inner_page_transform(page, crop, nup4_geometry, nup4_i):
             ty=-2*nup4_geometry.margin/nup4_geometry.shrink_for_margin))
 
 
-def nup4_outer_page_transform(page, nup4_geometry, nup4_i):
+def nup4_outer_page_transform(
+        page: pypdf.PageObject,
+        nup4_geometry: Nup4Geometry,
+        nup4_i: int
+        ) -> None:
     'Shrink and position page into nup4_geometry as per its position nup4_i.'
     page.add_transformation(pypdf.Transformation().translate(
         ty=(1-nup4_geometry.shrink_for_spine)*A4_HEIGHT))
@@ -502,6 +573,7 @@ def nup4_outer_page_transform(page, nup4_geometry, nup4_i):
         y_section = 0
         page.mediabox.bottom = 0
         page.mediabox.top = A4_HALF_HEIGHT
+    x_section: float
     if nup4_i in {0, 2}:
         x_section = 0
         page.mediabox.left = 0
@@ -518,11 +590,13 @@ def nup4_outer_page_transform(page, nup4_geometry, nup4_i):
                                                          QUARTER_SCALE_FACTOR))
 
 
-def ornate_nup4(args_analyze,
-                is_front_page,
-                new_page,
-                nup4_geometry,
-                canvas_class):
+def ornate_nup4(
+        args_analyze: str,
+        is_front_page: bool,
+        new_page: pypdf.PageObject,
+        nup4_geometry: Nup4Geometry,
+        canvas_class
+        ) -> None:
     'Apply nup4 line guides onto new_page.'
     if args_analyze:
         # borders
@@ -556,15 +630,19 @@ def ornate_nup4(args_analyze,
         c.line(x_right_spine_limit, A4_HEIGHT, x_right_spine_limit, 0)
     if is_front_page:
         c.setLineWidth(0.2)
-        draw_cut(c, x_left_spine_limit, (1))
-        draw_cut(c, x_right_spine_limit, (-1))
+        draw_cut(c, x_left_spine_limit, 1)
+        draw_cut(c, x_right_spine_limit, -1)
     if args_analyze or is_front_page:
         c.save()
         new_pdf = pypdf.PdfReader(packet)
         new_page.merge_page(new_pdf.pages[0])
 
 
-def draw_cut(canvas, x_spine_limit, direction):
+def draw_cut(
+        canvas: Any,
+        x_spine_limit: float,
+        direction: int
+        ) -> None:
     'Into canvas draw book binding cut guide at x_spine_limit into direction.'
     outer_start_x = x_spine_limit - 0.5 * CUT_WIDTH * direction
     inner_start_x = x_spine_limit + 0.5 * CUT_WIDTH * direction
@@ -575,7 +653,8 @@ def draw_cut(canvas, x_spine_limit, direction):
     canvas.line(x_spine_limit, middle_point_y, outer_start_x, A4_HALF_HEIGHT)
 
 
-def main():
+def main(
+        ) -> None:
     'Full program run to be wrapped into ArgFail catcher.'
     args = parse_args()
     validate_args_syntax(args)