home · contact · privacy
Bookmaker: more refactoring.
[misc] / bookmaker.py
index 33351329855601e48cd0c16321b5f2cf537a2bd9..2dd96532e7f8876e3f7218b88a99a6a27e75e86f 100755 (executable)
@@ -86,16 +86,64 @@ A4_HALF_HEIGHT = A4_HEIGHT / 2
 CUT_DEPTH = 1.95 * POINTS_PER_CM
 CUT_WIDTH = 1.05 * POINTS_PER_CM
 MIDDLE_POINT_DEPTH = 0.4 * POINTS_PER_CM
-SPINE_LIMIT = 1 * POINTS_PER_CM
+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)
 
-# some helpers
-PageCrop = namedtuple("PageCrop", ["left", "bottom", "right", "top"], defaults=[0,0,0,0])
+
+class PageCrop:
+
+    def __init__(self, left_cm=0, bottom_cm=0, right_cm=0, top_cm=0):
+        self.left_cm = left_cm
+        self.bottom_cm = bottom_cm
+        self.right_cm = right_cm
+        self.top_cm = top_cm
+        self.left = float(self.left_cm) * POINTS_PER_CM
+        self.bottom = float(self.bottom_cm) * POINTS_PER_CM
+        self.right = float(self.right_cm) * POINTS_PER_CM
+        self.top = float(self.top_cm) * POINTS_PER_CM
+        zoom_horizontal = A4_WIDTH / (A4_WIDTH - self.left - self.right)
+        zoom_vertical = A4_HEIGHT / (A4_HEIGHT - self.bottom - self.top)
+        if (zoom_horizontal > 1 and zoom_vertical < 1) or (zoom_horizontal < 1 and zoom_vertical > 1):
+            raise HandledException("-c: crops would create opposing zoom directions")
+        elif zoom_horizontal + zoom_vertical > 2:
+            self.zoom = min(zoom_horizontal, zoom_vertical)
+        else:
+            self.zoom = max(zoom_horizontal, zoom_vertical)
+
+    def __str__(self):
+        return str(vars(self))
+
+    @property
+    def format_in_cm(self):
+        return f"left {self.left_cm}cm, bottom {self.bottom_cm}cm, right {self.right_cm}cm, top {self.top_cm}cm"
+
+    @property
+    def remaining_width(self):
+        return A4_WIDTH - self.left - self.right
+
+    @property
+    def remaining_height(self):
+        return A4_HEIGHT - self.bottom - self.top
+
+    def give_mirror(self):
+        return PageCrop(left_cm=self.right_cm, bottom_cm=self.bottom_cm, right_cm=self.left_cm, top_cm=self.top_cm)
+
+
+class Nup4Geometry:
+
+    def __init__(self, margin_cm):
+        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 with values shrunk for the margin, which we undo here.
+        spine_part_of_page = (INNER_SPINE_MARGIN_PER_PAGE / A4_HALF_WIDTH) / self.shrink_for_margin
+        self.shrink_for_spine = 1 - spine_part_of_page
+
 
 class HandledException(Exception):
     pass
 
+
 def parse_args():
     parser = argparse.ArgumentParser(description=__doc__, epilog=help_epilogue, formatter_class=argparse.RawDescriptionHelpFormatter)
     parser.add_argument("-i", "--input_file", action="append", required=True, help="input PDF file")
@@ -109,6 +157,7 @@ def parse_args():
     parser.add_argument("-m", "--print_margin", type=float, default=0.43, help="print margin for --nup4 in cm (default 0.43)")
     return parser.parse_args()
 
+
 def validate_inputs_first_pass(args):
     for filename in args.input_file:
         if not os.path.isfile(filename):
@@ -152,6 +201,7 @@ def validate_inputs_first_pass(args):
     except ValueError:
         raise HandledException(f"-m: non-float value: {arg.print_margin}")
 
+
 def validate_page_range(p_string, err_msg_prefix):
     prefix = f"{err_msg_prefix}: page range string"
     if '-' not in p_string:
@@ -182,6 +232,7 @@ def validate_page_range(p_string, err_msg_prefix):
     if start > 0 and end > 0 and start > end:
         raise HandledException(f"{prefix} has higher start than end value: {p_string}")
 
+
 def split_crops_string(c_string):
     initial_split = c_string.split(':')
     if len(initial_split) > 1:
@@ -192,6 +243,7 @@ def split_crops_string(c_string):
         crops = initial_split[0]
     return page_range, crops
 
+
 def parse_page_range(range_string, pages):
     start_page = 0
     end_page = len(pages)
@@ -203,6 +255,7 @@ def parse_page_range(range_string, pages):
             end_page = int(end)
     return start_page, end_page
 
+
 def read_inputs_to_pagelist(args_input_file, args_page_range):
     pages_to_add = []
     opened_files = []
@@ -224,6 +277,7 @@ def read_inputs_to_pagelist(args_input_file, args_page_range):
             print(f"-i, -p: read in {input_file} page number {old_page_num+1} as new page {new_page_num}")
     return pages_to_add, opened_files
 
+
 def validate_inputs_second_pass(args, pages_to_add):
     if args.crops:
         for c_string in args.crops:
@@ -237,6 +291,7 @@ def validate_inputs_second_pass(args, pages_to_add):
             if r > len(pages_to_add):
                  raise HandledException(f"-r: page number beyond number of pages we're building: {r}")
 
+
 def rotate_pages(args_rotate_page, pages_to_add):
     if args_rotate_page:
         for rotate_page in args_rotate_page:
@@ -246,6 +301,7 @@ def rotate_pages(args_rotate_page, pages_to_add):
             page.add_transformation(pypdf.Transformation().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):
     mod_to_8 = len(pages_to_add) % 8
     if mod_to_8 > 0:
@@ -255,6 +311,7 @@ def pad_pages_to_multiple_of_8(pages_to_add):
             pages_to_add += [new_page]
         print(f"-n: number of input pages {old_len} not required multiple of 8, padded to {len(pages_to_add)}")
 
+
 def normalize_pages_to_A4(pages_to_add):
     for page in pages_to_add:
         if "/Rotate" in page:  # TODO: preserve rotation, but in canvas?
@@ -265,82 +322,65 @@ def normalize_pages_to_A4(pages_to_add):
         page.mediabox.right = A4_WIDTH
         page.cropbox = page.mediabox
 
+
 def collect_per_page_crops_and_zooms(args_crops, args_symmetry, pages_to_add):
     crop_at_page = [PageCrop()] * len(pages_to_add)
-    zoom_at_page = [1]*len(pages_to_add)
     if args_crops:
         for c_string in args_crops:
             page_range, crops = split_crops_string(c_string)
             start_page, end_page = parse_page_range(page_range, pages_to_add)
             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_cm = PageCrop(*[x for x in crops.split(',')])
-            page_crop = PageCrop(*[float(x) * POINTS_PER_CM for x in page_crop_cm])
-            crop_listing = ", ".join([f"{key} {val}cm" for key, val in page_crop_cm._asdict().items()])
-            print(f"{prefix}: to pages {start_page + 1} to {end_page} applying crop: {crop_listing}{suffix}")
-            cropped_width  = A4_WIDTH - page_crop.left - page_crop.right
-            cropped_height = A4_HEIGHT - page_crop.bottom - page_crop.top
-            zoom = 1
-            zoom_horizontal = A4_WIDTH / (A4_WIDTH - page_crop.left - page_crop.right)
-            zoom_vertical = A4_HEIGHT / (A4_HEIGHT - page_crop.bottom - page_crop.top)
-            if (zoom_horizontal > 1 and zoom_vertical < 1) or (zoom_horizontal < 1 and zoom_vertical > 1):
-                raise HandledException("-c: crops would create opposing zoom directions")
-            elif zoom_horizontal + zoom_vertical > 2:
-                zoom = min(zoom_horizontal, zoom_vertical)
-            else:
-                zoom = max(zoom_horizontal, zoom_vertical)
+            page_crop = PageCrop(*[x for x in crops.split(',')])
+            print(f"{prefix}: to pages {start_page + 1} to {end_page} applying crop: {page_crop.format_in_cm}{suffix}")
             for page_num in range(start_page, end_page):
                 if args_symmetry and page_num % 2:
-                    crop_at_page[page_num] = PageCrop(left=page_crop.right, right=page_crop.left, bottom=page_crop.bottom, top=page_crop.top)
+                    crop_at_page[page_num] = page_crop.give_mirror()
                 else:
                     crop_at_page[page_num] = page_crop
-                zoom_at_page[page_num] = zoom
-    return crop_at_page, zoom_at_page
+    return crop_at_page
 
-def build_single_pages_output(writer, pages_to_add, crop_at_page, zoom_at_page):
+
+def build_single_pages_output(writer, pages_to_add, crop_at_page):
     print("building 1-input-page-per-output-page book")
     odd_page = True
     for i, page in enumerate(pages_to_add):
-        zoom = zoom_at_page[i]
         page.add_transformation(pypdf.Transformation().translate(tx=-crop_at_page[i].left, ty=-crop_at_page[i].bottom))
-        page.add_transformation(pypdf.Transformation().scale(zoom, zoom))
-        cropped_width  = A4_WIDTH - crop_at_page[i].left - crop_at_page[i].right
-        cropped_height = A4_HEIGHT - crop_at_page[i].bottom - crop_at_page[i].top
-        page.mediabox.right = cropped_width * zoom
-        page.mediabox.top = cropped_height * zoom
+        page.add_transformation(pypdf.Transformation().scale(crop_at_page[i].zoom, crop_at_page[i].zoom))
+        page.mediabox.right = crop_at_page[i].remaining_width * crop_at_page[i].zoom
+        page.mediabox.top = crop_at_page[i].remaining_height * crop_at_page[i].zoom
         writer.add_page(page)
         odd_page = not odd_page
         print(f"built page number {i+1} (of {len(pages_to_add)})")
 
-def build_nup4_output(writer, pages_to_add, crop_at_page, zoom_at_page, args_print_margin, args_analyze, canvas_class):
+
+def build_nup4_output(writer, pages_to_add, crop_at_page, args_print_margin, args_analyze, canvas_class):
     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:
         print("-a: drawing page borders, spine limits")
-    printable_margin = args_print_margin * POINTS_PER_CM
-    printable_scale = (A4_WIDTH - 2 * printable_margin)/A4_WIDTH
-    spine_part_of_page = (SPINE_LIMIT / A4_HALF_WIDTH) / printable_scale
-    bonus_shrink_factor = 1 - spine_part_of_page
+    nup4_geometry = Nup4Geometry(args_print_margin)
     pages_to_add, new_i_order = resort_pages_for_nup4(pages_to_add)
-    nup4_position = 0
+    nup4_i = 0
     page_count = 0
     is_front_page = True
     for i, page in enumerate(pages_to_add):
-        if nup4_position == 0:
+        if nup4_i == 0:
             new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
         corrected_i = new_i_order[i]
-        nup4_inner_page_transform(page, crop_at_page[corrected_i], zoom_at_page[corrected_i], bonus_shrink_factor, printable_margin, printable_scale, nup4_position)
-        nup4_outer_page_transform(page, bonus_shrink_factor, nup4_position)
+        nup4_inner_page_transform(page, crop_at_page[corrected_i], 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)})")
-        nup4_position += 1
-        if nup4_position > 3:
-            ornate_nup4(writer, args_analyze, is_front_page, new_page, printable_margin, printable_scale, bonus_shrink_factor, canvas_class)
+        nup4_i += 1
+        if nup4_i > 3:
+            ornate_nup4(writer, args_analyze, is_front_page, new_page, nup4_geometry, canvas_class)
             writer.add_page(new_page)
-            nup4_position = 0
+            nup4_i = 0
             is_front_page = not is_front_page
 
+
 def resort_pages_for_nup4(pages_to_add):
     new_page_order = []
     new_i_order = []
@@ -360,39 +400,42 @@ def resort_pages_for_nup4(pages_to_add):
             n_eights += 1
     return new_page_order, new_i_order
 
-def nup4_inner_page_transform(page, crop, zoom, bonus_shrink_factor, printable_margin, printable_scale, nup4_position):
-    page.add_transformation(pypdf.Transformation().translate(ty=(A4_HEIGHT / zoom - (A4_HEIGHT - crop.top))))
-    if nup4_position == 0 or nup4_position == 2:
+
+def nup4_inner_page_transform(page, crop, nup4_geometry, nup4_i):
+    page.add_transformation(pypdf.Transformation().translate(ty=(A4_HEIGHT / crop.zoom - (A4_HEIGHT - crop.top))))
+    if nup4_i == 0 or nup4_i == 2:
         page.add_transformation(pypdf.Transformation().translate(tx=-crop.left))
-    elif nup4_position == 1 or nup4_position == 3:
-        page.add_transformation(pypdf.Transformation().translate(tx=(A4_WIDTH / zoom - (A4_WIDTH - crop.right))))
-    page.add_transformation(pypdf.Transformation().scale(zoom * bonus_shrink_factor, zoom * bonus_shrink_factor))
-    if nup4_position == 2 or nup4_position == 3:
-        page.add_transformation(pypdf.Transformation().translate(ty=-2*printable_margin/printable_scale))
-
-def nup4_outer_page_transform(page, bonus_shrink_factor, nup4_position):
-    page.add_transformation(pypdf.Transformation().translate(ty=(1-bonus_shrink_factor)*A4_HEIGHT))
-    if nup4_position == 0 or nup4_position == 1:
+    elif nup4_i == 1 or nup4_i == 3:
+        page.add_transformation(pypdf.Transformation().translate(tx=(A4_WIDTH / crop.zoom - (A4_WIDTH - crop.right))))
+    page.add_transformation(pypdf.Transformation().scale(crop.zoom * nup4_geometry.shrink_for_spine, crop.zoom * nup4_geometry.shrink_for_spine))
+    if nup4_i == 2 or nup4_i == 3:
+        page.add_transformation(pypdf.Transformation().translate(ty=-2*nup4_geometry.margin/nup4_geometry.shrink_for_margin))
+
+
+def nup4_outer_page_transform(page, nup4_geometry, nup4_i):
+    page.add_transformation(pypdf.Transformation().translate(ty=(1-nup4_geometry.shrink_for_spine)*A4_HEIGHT))
+    if nup4_i == 0 or nup4_i == 1:
         y_section = A4_HEIGHT
         page.mediabox.bottom = A4_HALF_HEIGHT
         page.mediabox.top    = A4_HEIGHT
-    if nup4_position == 2 or nup4_position == 3:
+    if nup4_i == 2 or nup4_i == 3:
         y_section = 0
         page.mediabox.bottom = 0
         page.mediabox.top  =  A4_HALF_HEIGHT
-    if nup4_position == 0 or nup4_position == 2:
+    if nup4_i == 0 or nup4_i == 2:
         x_section = 0
         page.mediabox.left   = 0
         page.mediabox.right  = A4_HALF_WIDTH
-    if nup4_position == 1 or nup4_position == 3:
-        page.add_transformation(pypdf.Transformation().translate(tx=(1-bonus_shrink_factor)*A4_WIDTH))
+    if nup4_i == 1 or nup4_i == 3:
+        page.add_transformation(pypdf.Transformation().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(pypdf.Transformation().translate(tx=x_section, ty=y_section))
     page.add_transformation(pypdf.Transformation().scale(QUARTER_SCALE_FACTOR, QUARTER_SCALE_FACTOR))
 
-def ornate_nup4(writer, args_analyze, is_front_page, new_page, printable_margin, printable_scale, bonus_shrink_factor, canvas_class):
+
+def ornate_nup4(writer, args_analyze, is_front_page, new_page, nup4_geometry, canvas_class):
     if args_analyze:
         # borders
         packet = io.BytesIO()
@@ -407,11 +450,11 @@ def ornate_nup4(writer, args_analyze, is_front_page, new_page, printable_margin,
         c.save()
         new_pdf = pypdf.PdfReader(packet)
         new_page.merge_page(new_pdf.pages[0])
-    printable_offset_x = printable_margin
-    printable_offset_y = printable_margin * A4_HEIGHT / A4_WIDTH
-    new_page.add_transformation(pypdf.Transformation().scale(printable_scale, printable_scale))
+    printable_offset_x = nup4_geometry.margin
+    printable_offset_y = nup4_geometry.margin * A4_HEIGHT / A4_WIDTH
+    new_page.add_transformation(pypdf.Transformation().scale(nup4_geometry.shrink_for_margin, nup4_geometry.shrink_for_margin))
     new_page.add_transformation(pypdf.Transformation().translate(tx=printable_offset_x, ty=printable_offset_y))
-    x_left_spine_limit = A4_HALF_WIDTH * bonus_shrink_factor
+    x_left_spine_limit = A4_HALF_WIDTH * nup4_geometry.shrink_for_spine
     x_right_spine_limit = A4_WIDTH - x_left_spine_limit
     if args_analyze or is_front_page:
         packet = io.BytesIO()
@@ -430,6 +473,7 @@ def ornate_nup4(writer, args_analyze, is_front_page, new_page, printable_margin,
         new_pdf = pypdf.PdfReader(packet)
         new_page.merge_page(new_pdf.pages[0])
 
+
 def draw_cut(canvas, x_spine_limit, direction):
     outer_start_x = x_spine_limit - 0.5 * CUT_WIDTH * direction
     inner_start_x = x_spine_limit + 0.5 * CUT_WIDTH * direction
@@ -439,6 +483,7 @@ def draw_cut(canvas, x_spine_limit, direction):
     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_HALF_HEIGHT)
 
+
 def main():
     args = parse_args()
     validate_inputs_first_pass(args)
@@ -453,12 +498,12 @@ def main():
     if args.nup4:
         pad_pages_to_multiple_of_8(pages_to_add)
     normalize_pages_to_A4(pages_to_add)
-    crop_at_page, zoom_at_page = collect_per_page_crops_and_zooms(args.crops, args.symmetry, pages_to_add)
+    crop_at_page = collect_per_page_crops_and_zooms(args.crops, args.symmetry, pages_to_add)
     writer = pypdf.PdfWriter()
     if args.nup4:
-        build_nup4_output(writer, pages_to_add, crop_at_page, zoom_at_page, args.print_margin, args.analyze, Canvas)
+        build_nup4_output(writer, pages_to_add, crop_at_page, args.print_margin, args.analyze, Canvas)
     else:
-        build_single_pages_output(writer, pages_to_add, crop_at_page, zoom_at_page)
+        build_single_pages_output(writer, pages_to_add, crop_at_page)
     for file in opened_files:
         file.close()
     with open(args.output_file, 'wb') as output_file: