home · contact · privacy
Do much more with -a.
authorPlom Heller <plom@plomlompom.com>
Fri, 10 Apr 2026 00:28:43 +0000 (02:28 +0200)
committerPlom Heller <plom@plomlompom.com>
Fri, 10 Apr 2026 00:28:43 +0000 (02:28 +0200)
bookmaker.py

index 6878b947a131f7b12e4725de37931a7f918a4805..cfdf5c56ef8e3c78285dfaaae795a24071be6185 100755 (executable)
@@ -61,13 +61,17 @@ class Page:
     'Fuses PdfPage, PageCrop, and PdfTransformation.'
 
     def __init__(
-            self, pypdf_page: PdfPage
+            self,
+            pypdf_page: PdfPage
             ) -> None:
         self._pypdf = pypdf_page
+        self.set_box()
         self.crop = PageCrop()
 
     @property
-    def box(self) -> dict[str, float]:
+    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')}
@@ -91,34 +95,84 @@ class Page:
         self._pypdf.cropbox = self._pypdf.mediabox
 
     @classmethod
-    def new_blank(cls) -> Self:
+    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:
+    def add_to_writer(
+            self,
+            writer: PdfWriter
+            ) -> None:
         'Add self to writer.'
         writer.add_page(self._pypdf)
 
-    def merge_page(self, page: Self) -> None:
+    def merge_page(
+            self,
+            page: Self
+            ) -> None:
         'Merge page .to self.'
         self._pypdf.merge_page(page._pypdf)
 
-    def rotation(self) -> Optional[int]:
+    def rotation(
+            self
+            ) -> Optional[int]:
         'Return PDF /Rotate instruction.'
         return self._pypdf.get(ROTATE_COMMAND, None)
 
-    def rotate(self, degrees: int) -> 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:
+    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:
+    def scale(
+            self,
+            zoom: float
+            ) -> None:
         'Wrap PdfTransformation.scale.'
         self._pypdf.add_transformation(PdfTransformation().scale(zoom, zoom))
 
+    def draw_lines(
+            self,
+            lines: tuple[tuple[float, float, float, float], ...],
+            rgb_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
+            line_width: float = 2.0,
+            ) -> None:
+        'Draw lines.'
+        min_x, min_y, max_x, max_y = self._pypdf.mediabox
+        packet = BytesIO()
+        c = Canvas(packet, pagesize=(max_x - min_x, max_y - min_y))
+        c.setLineWidth(line_width)
+        c.setStrokeColorRGB(*rgb_color)
+        for line in lines:
+            c.line(line[0], line[1], line[2], line[3])
+        c.save()
+        self._pypdf.merge_page(PdfReader(packet).pages[0])
+
+    def draw_borders(
+            self,
+            rgb_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
+            line_width: float = 4.0,
+            ) -> None:
+        'Draw border lines around mediabox.'
+        min_x, min_y, max_x, max_y = self._pypdf.mediabox
+        self.draw_lines(((min_x, min_y, min_x, max_y),   # left
+                         (min_x, min_y, max_x, min_y),   # bottom
+                         (max_x, min_y, max_x, max_y),   # right
+                         (min_x, max_y, max_x, max_y)),  # top
+                        rgb_color,
+                        line_width)
+
 
 class PageCrop:
     'Per-page crop instructions as sizes in point and cm, and A4-zoom factor.'
@@ -399,7 +453,8 @@ def parse_page_range(
 
 def args_to_pagelist(
         args_input_file: list[str],
-        args_page_range: list[str]
+        args_page_range: list[str],
+        arg_analyze: bool
         ) -> tuple[list[Page], list[BufferedReader]]:
     'Follow args_input_file ranged by args_page_range into pages, open files.'
     pages = []
@@ -423,8 +478,11 @@ def args_to_pagelist(
         for old_page_num in range(*parse_page_range(range_string,
                                                     len(reader.pages))):
             new_page_num += 1
-            pages += [Page.new_blank() if old_page_num >= len(reader.pages)
-                      else Page(reader.pages[old_page_num])]
+            page = (Page.new_blank() if old_page_num >= len(reader.pages)
+                    else Page(reader.pages[old_page_num]))
+            pages += [page]
+            if arg_analyze:
+                page.draw_borders((0.75, 0.0, 0.0))
             print(f'-i, -p: read in {filename} page number {old_page_num+1} '
                   f'as new page {new_page_num}')
     return pages, opened_files
@@ -450,8 +508,9 @@ def validate_ranges(
 
 
 def rotate_pages(
+        pages: list[Page],
         args_rotate_page: Optional[list[int]],
-        pages: list[Page]
+        arg_analyze: bool
         ) -> None:
     'For pages page numbered in args_rotate_page, rotate by 90°.'
     if args_rotate_page:
@@ -460,6 +519,8 @@ def rotate_pages(
             page.translate(tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2)
             page.rotate(-90)
             page.translate(tx=A4_WIDTH/2, ty=A4_HEIGHT/2)
+            if arg_analyze:
+                page.draw_borders((0.75, 0.0, 0.0))
             print(f'-r: rotating (by 90°) page {rotate_page}')
 
 
@@ -477,7 +538,8 @@ def pad_pages_to_multiple_of_8(
 
 
 def normalize_pages_to_a4(
-        pages: list[Page]
+        pages: list[Page],
+        arg_analyze: bool
         ) -> None:
     'Zoom and adjust to A4 in pages, enact /Rotate.'
     max_x = max(page.box['right'] for page in pages)
@@ -504,13 +566,15 @@ def normalize_pages_to_a4(
             page.rotate(360 - rotation)
         page.set_box(0, 0, A4_WIDTH, A4_HEIGHT)
         page.crop.add(zoom_crop)
+        if arg_analyze:
+            page.draw_borders((0.0, 0.75, 0.0))
 
 
 def collect_page_croppings(
+        pages: list[Page],
         args_crops: str,
         args_keep_mediabox: bool,
-        args_symmetry: str,
-        pages: list[Page]
+        args_symmetry: str
         ) -> None:
     'Calculate individual PageCrops from inputs.'
     if args_crops:
@@ -539,6 +603,7 @@ def collect_page_croppings(
 def build_single_pages_output(
         writer: PdfWriter,
         pages: list[Page],
+        arg_analyze: bool
         ) -> None:
     'On each of pages apply its page_croppings, then writer.add_page.'
     print('building 1-input-page-per-output-page book')
@@ -547,6 +612,8 @@ def build_single_pages_output(
         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)
+        if arg_analyze:
+            page.draw_borders((0.0, 0.0, 0.75))
         page.add_to_writer(writer)
         print(f'built page number {i+1} (of {len(pages)})')
 
@@ -555,12 +622,12 @@ def build_nup4_output(
         writer: PdfWriter,
         pages: list[Page],
         args_print_margin: int,
-        args_analyze: str,
+        arg_analyze: str,
         ) -> 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')
-    if args_analyze:
+    if arg_analyze:
         print('-a: drawing page borders, spine limits')
     nup4_geometry = Nup4Geometry(args_print_margin)
     resort_pages_for_nup4(pages)
@@ -576,7 +643,7 @@ def build_nup4_output(
         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)
+            ornate_nup4(arg_analyze, is_front_page, new_page, nup4_geometry)
             new_page.add_to_writer(writer)
             nup4_i = 0
             new_page = Page.new_blank()
@@ -646,52 +713,40 @@ def nup4_outer_page_transform(
 
 
 def ornate_nup4(
-        args_analyze: str,
+        arg_analyze: str,
         is_front_page: bool,
         new_page: Page,
         nup4_geometry: Nup4Geometry,
         ) -> None:
     'Apply nup4 line guides onto new_page.'
-    if args_analyze:
-        # borders
-        packet = BytesIO()
-        c = Canvas(packet, pagesize=A4)
-        c.setLineWidth(0.1)
-        c.line(0, A4_HEIGHT, A4_WIDTH, A4_HEIGHT)
-        c.line(0, A4_HEIGHT/2, A4_WIDTH, A4_HEIGHT/2)
-        c.line(0, 0, A4_WIDTH, 0)
-        c.line(0, A4_HEIGHT, 0, 0)
-        c.line(A4_WIDTH/2, A4_HEIGHT, A4_WIDTH/2, 0)
-        c.line(A4_WIDTH, A4_HEIGHT, A4_WIDTH, 0)
-        c.save()
-        new_pdf = PdfReader(packet)
-        new_page.merge_page(Page(new_pdf.pages[0]))
+    if arg_analyze:
+        new_page.draw_borders((0.5, 1.0, 1.0))
+    new_page.draw_lines(
+            ((0, new_page.box['top']/2,
+              new_page.box['right'], new_page.box['top']/2),
+             (new_page.box['right']/2, new_page.box['top'],
+              new_page.box['right']/2, new_page.box['bottom'])),
+            line_width=0.1)
     printable_offset_x = nup4_geometry.margin
     printable_offset_y = nup4_geometry.margin * A4_HEIGHT / A4_WIDTH
     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):
+    if not (arg_analyze or is_front_page):
         return
     x_left_spine_limit = A4_WIDTH/2 * nup4_geometry.shrink_for_spine
     x_right_spine_limit = A4_WIDTH - x_left_spine_limit
-    packet = BytesIO()
-    c = Canvas(packet, pagesize=A4)
-    if args_analyze:
-        # spine lines
-        c.setLineWidth(0.1)
-        c.line(x_left_spine_limit, A4_HEIGHT, x_left_spine_limit, 0)
-        c.line(x_right_spine_limit, A4_HEIGHT, x_right_spine_limit, 0)
+    if arg_analyze:
+        new_page.draw_lines(
+                ((x_left_spine_limit, A4_HEIGHT, x_left_spine_limit, 0),
+                 (x_right_spine_limit, A4_HEIGHT, x_right_spine_limit, 0)),
+                line_width=0.1, rgb_color=(1.0, 0.5, 1.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)
-    c.save()
-    new_pdf = PdfReader(packet)
-    new_page.merge_page(Page(new_pdf.pages[0]))
+        draw_cut(new_page, x_left_spine_limit, 1)
+        draw_cut(new_page, x_right_spine_limit, -1)
 
 
 def draw_cut(
-        canvas: 'Canvas',
+        page: Page,
         x_spine_limit: float,
         direction: int
         ) -> None:
@@ -701,9 +756,11 @@ def draw_cut(
     inner_start_x = x_spine_limit + directed_half_width
     middle_point_y = A4_HEIGHT/2 + MIDDLE_POINT_DEPTH * direction
     end_point_y = A4_HEIGHT/2 + CUT_DEPTH * direction
-    canvas.line(inner_start_x, A4_HEIGHT/2, x_spine_limit, end_point_y)
-    canvas.line(x_spine_limit, end_point_y, x_spine_limit, middle_point_y)
-    canvas.line(x_spine_limit, middle_point_y, outer_start_x, A4_HEIGHT/2)
+    page.draw_lines(
+            ((inner_start_x, A4_HEIGHT/2, x_spine_limit, end_point_y),
+             (x_spine_limit, end_point_y, x_spine_limit, middle_point_y),
+             (x_spine_limit, middle_point_y, outer_start_x, A4_HEIGHT/2)),
+            line_width=0.2)
 
 
 def main(
@@ -713,20 +770,21 @@ def main(
     validate_args_syntax(args)
     if args.nup4 and not GOT_CANVAS:
         raise ArgFail('n', 'need reportlab.pdfgen.canvas installed for --nup4')
-    pages, opened_files = args_to_pagelist(args.input_file, args.page_range)
+    pages, opened_files = args_to_pagelist(
+            args.input_file, args.page_range, args.analyze)
     validate_ranges(args, len(pages))
-    rotate_pages(args.rotate_page, pages)
+    rotate_pages(pages, args.rotate_page, args.analyze)
     if not args.keep_mediabox:
-        normalize_pages_to_a4(pages)
+        normalize_pages_to_a4(pages, args.analyze)
     if args.nup4:
         pad_pages_to_multiple_of_8(pages)
-    collect_page_croppings(args.crops, args.keep_mediabox, args.symmetry,
-                           pages)
+    collect_page_croppings(pages,
+                           args.crops, args.keep_mediabox, args.symmetry)
     writer = PdfWriter()
     if args.nup4:
         build_nup4_output(writer, pages, args.print_margin, args.analyze)
     else:
-        build_single_pages_output(writer, pages)
+        build_single_pages_output(writer, pages, args.analyze)
     for file in opened_files:
         file.close()
     with open(args.output_file, 'wb') as output_file: