home · contact · privacy
Re-organize all that pages_to_add and page_croppings passing-along into Page class.
authorPlom Heller <plom@plomlompom.com>
Tue, 7 Apr 2026 21:58:05 +0000 (23:58 +0200)
committerPlom Heller <plom@plomlompom.com>
Tue, 7 Apr 2026 21:58:05 +0000 (23:58 +0200)
bookmaker.py

index b361cc1be192985a848d022617712884436f3043..e3c0bcada9bd0231ba6aefb4c3b4fe0ee0003200 100755 (executable)
@@ -12,7 +12,7 @@ from argparse import (
 from io import BufferedReader, BytesIO
 from os.path import isfile
 from sys import exit as sys_exit
-from typing import Optional
+from typing import Optional, Self
 
 
 def handled_error_exit(
@@ -55,16 +55,82 @@ INNER_SPINE_MARGIN_PER_PAGE = 1 * POINTS_PER_CM
 QUARTER_SCALE_FACTOR = 0.5
 PAGE_ORDER_FOR_NUP4 = (3, 0, 7, 4, 1, 2, 5, 6)
 
+# PDF specifics
+ROTATE_COMMAND = '/Rotate'
+
+
+class Page:
+    'Fuses PdfPage, PageCrop, and PdfTransformation.'
+
+    def __init__(
+            self, pypdf_page: PdfPage
+            ) -> None:
+        self._pypdf = pypdf_page
+        self.crop = PageCrop()
+
+    @property
+    def box(self) -> dict[str, float]:
+        'Return dict of mediabox measures.'
+        return {direction: getattr(self._pypdf.mediabox, direction)
+                for direction in ('left', 'bottom', 'right', 'top')}
+
+    def set_box(
+            self,
+            left: Optional[float] = None,
+            bottom: Optional[float] = None,
+            right: Optional[float] = None,
+            top: Optional[float] = None
+            ) -> None:
+        'Set mediabox (and cropbox) measures.'
+        if left is not None:
+            self._pypdf.mediabox.left = left
+        if bottom is not None:
+            self._pypdf.mediabox.bottom = bottom
+        if right is not None:
+            self._pypdf.mediabox.right = right
+        if top is not None:
+            self._pypdf.mediabox.top = top
+        self._pypdf.cropbox = self._pypdf.mediabox
+
+    @classmethod
+    def new_blank(cls) -> Self:
+        'Make blank page of A4 measures.'
+        return cls(PdfPage.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT))
+
+    def add_to_writer(self, writer: PdfWriter) -> None:
+        'Add self to writer.'
+        writer.add_page(self._pypdf)
+
+    def merge_page(self, page: Self) -> None:
+        'Merge page .to self.'
+        self._pypdf.merge_page(page._pypdf)
+
+    def rotation(self) -> Optional[int]:
+        'Return PDF /Rotate instruction.'
+        return self._pypdf.get(ROTATE_COMMAND, None)
+
+    def rotate(self, degrees: int) -> None:
+        'Wrap PdfTransformation.rotate.'
+        self._pypdf.add_transformation(PdfTransformation().rotate(degrees))
+
+    def translate(self, tx: float = 0.0, ty: float = 0.0) -> None:
+        'Wrap PdfTransformation.translate.'
+        self._pypdf.add_transformation(PdfTransformation().translate(tx, ty))
+
+    def scale(self, zoom: float) -> None:
+        'Wrap PdfTransformation.scale.'
+        self._pypdf.add_transformation(PdfTransformation().scale(zoom, zoom))
+
 
 class PageCrop:
     'Per-page crop instructions as sizes in point and cm, and A4-zoom factor.'
 
     def __init__(
             self,
-            left_cm: int = 0,
-            bottom_cm: int = 0,
-            right_cm: int = 0,
-            top_cm: int = 0
+            left_cm: float = 0,
+            bottom_cm: float = 0,
+            right_cm: float = 0,
+            top_cm: float = 0
             ) -> None:
         self.left_cm = left_cm
         self.bottom_cm = bottom_cm
@@ -314,11 +380,10 @@ def split_crops_string(
 
 def parse_page_range(
         range_string: Optional[str],
-        pages: list[PdfPage]
+        idx_after: int
         ) -> tuple[int, int]:
     'Based on actual pages size read range_string into range limit indices.'
     idx_start = 0
-    idx_after = len(pages)
     if range_string:
         start, end = range_string.split('-')
         if not (len(start) == 0 or start == 'start'):
@@ -331,9 +396,9 @@ def parse_page_range(
 def args_to_pagelist(
         args_input_file: list[str],
         args_page_range: list[str]
-        ) -> tuple[list[PdfPage], list[BufferedReader]]:
+        ) -> tuple[list[Page], list[BufferedReader]]:
     'Follow args_input_file ranged by args_page_range into pages, open files.'
-    pages_to_add = []
+    pages = []
     opened_files = []
     new_page_num = 0
     for i, filename in enumerate(args_input_file):
@@ -352,76 +417,67 @@ def args_to_pagelist(
         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,
-                                                    reader.pages)):
+                                                    len(reader.pages))):
             new_page_num += 1
-            if old_page_num >= len(reader.pages):
-                page = PdfPage.create_blank_page(width=A4_WIDTH,
-                                                 height=A4_HEIGHT)
-            else:
-                page = reader.pages[old_page_num]
-            pages_to_add += [page]
+            pages += [Page.new_blank() if old_page_num >= len(reader.pages)
+                      else Page(reader.pages[old_page_num])]
             print(f'-i, -p: read in {filename} page number {old_page_num+1} '
                   f'as new page {new_page_num}')
-    return pages_to_add, opened_files
+    return pages, opened_files
 
 
 def validate_ranges(
         args: ArgsNamespace,
-        pages_to_add: list[PdfPage]
+        len_pages: int
         ) -> None:
-    'Check command args\' ranges fit into pages_to_add count.'
+    'Check command args\' ranges fits into len_pages.'
     if args.crops:
         for c_string in args.crops:
             if (page_range := split_crops_string(c_string)[0])\
-                    and parse_page_range(page_range,
-                                         pages_to_add)[1] > len(pages_to_add):
+                    and parse_page_range(page_range, len_pages)[1] > len_pages:
                 raise ArgFail('c',
                               'page range goes beyond number of pages '
                               f'we\'re building: {page_range}')
     if args.rotate_page:
-        for r in args.rotate_page:
-            if r > len(pages_to_add):
-                raise ArgFail('r',
-                              'page number beyond number of '
-                              f'pages we\'re building: {r}')
+        for r in [r for r in args.rotate_page if r > len_pages]:
+            raise ArgFail('r',
+                          'page number beyond number of '
+                          f'pages we\'re building: {r}')
 
 
 def rotate_pages(
         args_rotate_page: list[int],
-        pages_to_add: list[PdfPage]
+        pages: list[Page]
         ) -> None:
-    'For pages_to_add page numbered in args_rotate_page, rotate by 90°.'
+    'For pages page numbered in args_rotate_page, rotate by 90°.'
     if args_rotate_page:
         for rotate_page in args_rotate_page:
-            page = pages_to_add[rotate_page - 1]
-            page.add_transformation(PdfTransformation().translate(
-                tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2))
-            page.add_transformation(PdfTransformation().rotate(-90))
-            page.add_transformation(PdfTransformation().translate(
-                tx=A4_WIDTH/2, ty=A4_HEIGHT/2))
+            page = pages[rotate_page - 1]
+            page.translate(tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2)
+            page.rotate(-90)
+            page.translate(tx=A4_WIDTH/2, ty=A4_HEIGHT/2)
             print(f'-r: rotating (by 90°) page {rotate_page}')
 
 
 def pad_pages_to_multiple_of_8(
-        pages_to_add: list[PdfPage]
+        pages: list[Page]
         ) -> None:
-    'To pages_to_add add blank pages until its size is multiple of 8.'
-    mod_to_8 = len(pages_to_add) % 8
+    'To pages add blank pages until its size is multiple of 8.'
+    old_len = len(pages)
+    mod_to_8 = old_len % 8
     if mod_to_8 > 0:
-        old_len = len(pages_to_add)
         for _ in range(8 - mod_to_8):
-            pages_to_add += [PdfPage.create_blank_page(width=A4_WIDTH,
-                                                       height=A4_HEIGHT)]
+            pages += [Page.new_blank()]
         print(f'-n: number of input pages {old_len} not required multiple '
-              f'of 8, padded to {len(pages_to_add)}')
+              f'of 8, padded to {len(pages)}')
 
 
 def normalize_pages_to_a4(
-        pages_to_add: list[PdfPage]
+        pages: list[Page]
         ) -> None:
-    'Zoom and adjust in pages_to_add .mediabox=.cropbox to A4, enact /Rotate.'
-    max_x = max(page.mediabox.right for page in pages_to_add)
-    max_y = max(page.mediabox.top for page in pages_to_add)
+    'Zoom and adjust to A4 in pages, enact /Rotate.'
+    max_x = max(page.box['right'] for page in pages)
+    max_y = max(page.box['top'] for page in pages)
     zooms = (A4_WIDTH / max_x, A4_HEIGHT / max_y)
     offsets = {'tx': 0.0, 'ty': 0.0}
     if zooms[0] < zooms[1]:
@@ -430,75 +486,64 @@ def normalize_pages_to_a4(
     else:
         zoom = zooms[1]
         offsets['tx'] = (A4_WIDTH - max_x * zoom) / 2
-    for page in pages_to_add:
-        page.add_transformation(PdfTransformation().scale(zoom, zoom))
-        page.add_transformation(PdfTransformation().translate(**offsets))
-        if '/Rotate' in page:  # TODO: preserve rotation, but in canvas?
-            rotation: int = page['/Rotate']  # type: ignore
+    for page in pages:
+        page.scale(zoom)
+        page.translate(**offsets)
+        if (rotation := page.rotation()):
             page.rotate(360 - rotation)
-        page.mediabox.left = 0
-        page.mediabox.bottom = 0
-        page.mediabox.top = A4_HEIGHT
-        page.mediabox.right = A4_WIDTH
-        page.cropbox = page.mediabox
+        page.set_box(0, 0, A4_HEIGHT, A4_WIDTH)
 
 
-def collect_page_croppings(args_crops,
-                           args_keep_mediabox,
-                           args_symmetry,
-                           pages_to_add):
+def collect_page_croppings(
+        args_crops: str,
+        args_keep_mediabox: bool,
+        args_symmetry: str,
+        pages: list[Page]
+        ) -> None:
     'Calculate individual PageCrops from inputs.'
-    page_croppings = [PageCrop()] * len(pages_to_add)
     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, pages_to_add)
+            idx_start, idx_after = parse_page_range(page_range, len(pages))
             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(*crops.split(','))
+            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 n_page in range(idx_start, idx_after):
-                page_croppings[n_page] = (page_crop.make_mirror()
-                                          if (args_symmetry and n_page % 2)
-                                          else page_crop)
+            for idx in range(idx_start, idx_after):
+                pages[idx].crop = (page_crop.make_mirror()
+                                   if (args_symmetry and idx % 2)
+                                   else page_crop)
     elif args_keep_mediabox:
-        for n_page, page in enumerate(pages_to_add):
-            page_croppings[n_page] = PageCrop(
-                    page.mediabox.left / POINTS_PER_CM,
-                    page.mediabox.bottom / POINTS_PER_CM,
-                    (0.01 + A4_WIDTH - page.mediabox.right) / POINTS_PER_CM,
-                    (0.01 + A4_HEIGHT - page.mediabox.top) / POINTS_PER_CM)
-            if args_symmetry and not n_page % 2:
-                page_croppings[n_page] = page_croppings[n_page].make_mirror()
-    return page_croppings
+        for page in pages:
+            page.crop = PageCrop(
+                    page.box['left'] / POINTS_PER_CM,
+                    page.box['bottom'] / POINTS_PER_CM,
+                    (0.01 + A4_WIDTH - page.box['right']) / POINTS_PER_CM,
+                    (0.01 + A4_HEIGHT - page.box['top']) / POINTS_PER_CM)
+            if args_symmetry and not idx % 2:
+                page.crop = page.crop.make_mirror()
 
 
 def build_single_pages_output(
         writer: PdfWriter,
-        pages_to_add: list[PdfPage],
-        page_croppings: list[PageCrop]
+        pages: list[Page],
         ) -> None:
-    'On each of pages_to_add apply its page_croppings, then writer.add_page.'
+    'On each of pages apply its page_croppings, then writer.add_page.'
     print('building 1-input-page-per-output-page book')
-    for i, page in enumerate(pages_to_add):
-        page.add_transformation(PdfTransformation().translate(
-            tx=-page_croppings[i].left, ty=-page_croppings[i].bottom))
-        page.add_transformation(PdfTransformation().scale(
-            page_croppings[i].zoom, page_croppings[i].zoom))
-        page.mediabox.right\
-            = page_croppings[i].remaining_width * page_croppings[i].zoom
-        page.mediabox.top\
-            = page_croppings[i].remaining_height * page_croppings[i].zoom
-        writer.add_page(page)
-        print(f'built page number {i+1} (of {len(pages_to_add)})')
+    for i, page in enumerate(pages):
+        page.translate(tx=-page.crop.left, ty=-page.crop.bottom)
+        page.scale(page.crop.zoom)
+        page.set_box(right=page.crop.remaining_width * page.crop.zoom)
+        page.set_box(top=page.crop.remaining_height * page.crop.zoom)
+        page.add_to_writer(writer)
+        print(f'built page number {i+1} (of {len(pages)})')
 
 
 def build_nup4_output(
         writer: PdfWriter,
-        pages_to_add: list[PdfPage],
-        page_croppings: list[PageCrop],
+        pages: list[Page],
         args_print_margin: int,
         args_analyze: str,
         ) -> None:
@@ -508,39 +553,35 @@ def build_nup4_output(
     if args_analyze:
         print('-a: drawing page borders, spine limits')
     nup4_geometry = Nup4Geometry(args_print_margin)
-    pages_to_add, old_indices = resort_pages_for_nup4(pages_to_add)
+    resort_pages_for_nup4(pages)
     nup4_i = 0
     page_count = 0
     is_front_page = True
-    new_page: Optional[PdfPage] = None
-    for i, page in enumerate(pages_to_add):
-        new_page = new_page or PdfPage.create_blank_page(width=A4_WIDTH,
-                                                         height=A4_HEIGHT)
-        nup4_inner_page_transform(
-                page, page_croppings[old_indices[i]], nup4_geometry, nup4_i)
+    new_page = Page.new_blank()
+    for page in pages:
+        nup4_inner_page_transform(page, nup4_geometry, nup4_i)
         nup4_outer_page_transform(page, nup4_geometry, nup4_i)
         new_page.merge_page(page)
         page_count += 1
-        print(f'merged page number {page_count} (of {len(pages_to_add)})')
+        print(f'merged page number {page_count} (of {len(pages)})')
         nup4_i += 1
         if nup4_i > 3:
             ornate_nup4(args_analyze, is_front_page, new_page, nup4_geometry)
-            writer.add_page(new_page)
+            new_page.add_to_writer(writer)
             nup4_i = 0
-            new_page = None
+            new_page = Page.new_blank()
             is_front_page = not is_front_page
 
 
 def resort_pages_for_nup4(
-        pages_to_add: list[PdfPage]
-        ) -> tuple[list[PdfPage], list[int]]:
-    'Adapt pages_to_add towards PAGE_ORDER_FOR_NUP4.'
+        pages: list[Page]
+        ) -> None:
+    'Adapt pages towards PAGE_ORDER_FOR_NUP4.'
     new_page_order = []
-    old_indices = []
-    eight_pack: list[PdfPage] = []
+    eight_pack: list[Page] = []
     i = 0
     n_eights = 0
-    for page in pages_to_add:
+    for page in pages:
         if i == 0:
             eight_pack = []
         eight_pack += [page]
@@ -548,69 +589,56 @@ def resort_pages_for_nup4(
         if i == 8:
             i = 0
             for n in PAGE_ORDER_FOR_NUP4:
-                old_indices += [8 * n_eights + n]
                 new_page_order += [eight_pack[n]]
             n_eights += 1
-    return new_page_order, old_indices
+    pages[:] = new_page_order[:]
 
 
 def nup4_inner_page_transform(
-        page: PdfPage,
-        crop: PageCrop,
+        page: Page,
         nup4_geometry: Nup4Geometry,
         nup4_i: int
         ) -> None:
     'Apply to page crop instructions adequate to position in nup4 geometry.'
-    page.add_transformation(PdfTransformation().translate(
-        ty=A4_HEIGHT / crop.zoom - (A4_HEIGHT - crop.top)))
-    page.add_transformation(PdfTransformation().translate(
-        tx=((-crop.left) if nup4_i in {0, 2}
-            else A4_WIDTH / crop.zoom - (A4_WIDTH - crop.right))))  # in {1, 3}
-    page.add_transformation(PdfTransformation().scale(
-        crop.zoom * nup4_geometry.shrink_for_spine,
-        crop.zoom * nup4_geometry.shrink_for_spine))
+    page.translate(ty=A4_HEIGHT / page.crop.zoom - (A4_HEIGHT - page.crop.top))
+    page.translate(tx=(
+        (-page.crop.left) if nup4_i in {0, 2} else
+        A4_WIDTH / page.crop.zoom - (A4_WIDTH - page.crop.right)))  # in {1, 3}
+    page.scale(page.crop.zoom * nup4_geometry.shrink_for_spine)
     if nup4_i in {2, 3}:
-        page.add_transformation(PdfTransformation().translate(
-            ty=-2*nup4_geometry.margin/nup4_geometry.shrink_for_margin))
+        page.translate(
+                ty=-2 * nup4_geometry.margin / nup4_geometry.shrink_for_margin)
 
 
 def nup4_outer_page_transform(
-        page: PdfPage,
+        page: Page,
         nup4_geometry: Nup4Geometry,
         nup4_i: int
         ) -> None:
     'Shrink and position page into nup4_geometry as per its position nup4_i.'
-    page.add_transformation(PdfTransformation().translate(
-        ty=(1-nup4_geometry.shrink_for_spine)*A4_HEIGHT))
+    page.translate(ty=(1 - nup4_geometry.shrink_for_spine) * A4_HEIGHT)
     if nup4_i in {0, 1}:
         y_section = A4_HEIGHT
-        page.mediabox.bottom = A4_HALF_HEIGHT
-        page.mediabox.top = A4_HEIGHT
+        page.set_box(bottom=A4_HALF_HEIGHT, top=A4_HEIGHT)
     else:  # nup4_in in {2, 3}
         y_section = 0
-        page.mediabox.bottom = 0
-        page.mediabox.top = A4_HALF_HEIGHT
+        page.set_box(bottom=0, top=A4_HALF_HEIGHT)
     x_section: float
     if nup4_i in {0, 2}:
         x_section = 0
-        page.mediabox.left = 0
-        page.mediabox.right = A4_HALF_WIDTH
+        page.set_box(left=0, right=A4_HALF_WIDTH)
     else:  # nup4_in in {1, 3}
-        page.add_transformation(PdfTransformation().translate(
-            tx=(1-nup4_geometry.shrink_for_spine)*A4_WIDTH))
+        page.translate(tx=(1 - nup4_geometry.shrink_for_spine) * A4_WIDTH)
         x_section = A4_WIDTH
-        page.mediabox.left = A4_HALF_WIDTH
-        page.mediabox.right = A4_WIDTH
-    page.add_transformation(PdfTransformation().translate(tx=x_section,
-                                                          ty=y_section))
-    page.add_transformation(PdfTransformation().scale(QUARTER_SCALE_FACTOR,
-                                                      QUARTER_SCALE_FACTOR))
+        page.set_box(left=A4_HALF_WIDTH, right=A4_WIDTH)
+    page.translate(tx=x_section, ty=y_section)
+    page.scale(QUARTER_SCALE_FACTOR)
 
 
 def ornate_nup4(
         args_analyze: str,
         is_front_page: bool,
-        new_page: PdfPage,
+        new_page: Page,
         nup4_geometry: Nup4Geometry,
         ) -> None:
     'Apply nup4 line guides onto new_page.'
@@ -627,13 +655,11 @@ def ornate_nup4(
         c.line(A4_WIDTH, A4_HEIGHT, A4_WIDTH, 0)
         c.save()
         new_pdf = PdfReader(packet)
-        new_page.merge_page(new_pdf.pages[0])
+        new_page.merge_page(Page(new_pdf.pages[0]))
     printable_offset_x = nup4_geometry.margin
     printable_offset_y = nup4_geometry.margin * A4_HEIGHT / A4_WIDTH
-    new_page.add_transformation(PdfTransformation().scale(
-        nup4_geometry.shrink_for_margin, nup4_geometry.shrink_for_margin))
-    new_page.add_transformation(PdfTransformation().translate(
-        tx=printable_offset_x, ty=printable_offset_y))
+    new_page.scale(nup4_geometry.shrink_for_margin)
+    new_page.translate(tx=printable_offset_x, ty=printable_offset_y)
     if not (args_analyze or is_front_page):
         return
     x_left_spine_limit = A4_HALF_WIDTH * nup4_geometry.shrink_for_spine
@@ -651,7 +677,7 @@ def ornate_nup4(
         draw_cut(c, x_right_spine_limit, -1)
     c.save()
     new_pdf = PdfReader(packet)
-    new_page.merge_page(new_pdf.pages[0])
+    new_page.merge_page(Page(new_pdf.pages[0]))
 
 
 def draw_cut(
@@ -677,27 +703,20 @@ def main(
     validate_args_syntax(args)
     if args.nup4 and not GOT_CANVAS:
         raise ArgFail('n', 'need reportlab.pdfgen.canvas installed for --nup4')
-    pages_to_add, opened_files = args_to_pagelist(args.input_file,
-                                                  args.page_range)
-    validate_ranges(args, pages_to_add)
-    rotate_pages(args.rotate_page, pages_to_add)
+    pages, opened_files = args_to_pagelist(args.input_file, args.page_range)
+    validate_ranges(args, len(pages))
+    rotate_pages(args.rotate_page, pages)
     if not args.keep_mediabox:
-        normalize_pages_to_a4(pages_to_add)
+        normalize_pages_to_a4(pages)
     if args.nup4:
-        pad_pages_to_multiple_of_8(pages_to_add)
-    page_croppings = collect_page_croppings(args.crops,
-                                            args.keep_mediabox,
-                                            args.symmetry,
-                                            pages_to_add)
+        pad_pages_to_multiple_of_8(pages)
+    collect_page_croppings(args.crops, args.keep_mediabox, args.symmetry,
+                           pages)
     writer = PdfWriter()
     if args.nup4:
-        build_nup4_output(writer,
-                          pages_to_add,
-                          page_croppings,
-                          args.print_margin,
-                          args.analyze)
+        build_nup4_output(writer, pages, args.print_margin, args.analyze)
     else:
-        build_single_pages_output(writer, pages_to_add, page_croppings)
+        build_single_pages_output(writer, pages)
     for file in opened_files:
         file.close()
     with open(args.output_file, 'wb') as output_file: