home · contact · privacy
Bookmaker: much refactoring.
[misc] / bookmaker.py
index 0de7f19b3e2eb69491e5148c4a6f3b90dd30a674..d111cf18f4530379e5d75581534c89fb801568e6 100755 (executable)
@@ -1,30 +1,7 @@
 #!/usr/bin/env python3
 """
-bookmaker.py is a helper for optimizing PDFs of books for the production of small self-printed, self-bound physical books  Towards this goal it offers various PDF manipulation options that may also be used indepéndently and for other purposes.
+bookmaker.py is a helper for optimizing PDFs of books for the production of small self-printed, self-bound physical books.  Towards this goal it offers various PDF manipulation options that may also be used indepéndently and for other purposes.
 """
-import argparse
-import io
-import os
-import sys
-def fail_with_msg(msg):
-    print("ERROR:", msg)
-    sys.exit(1)
-try:
-    import pypdf
-except ImportError:
-    fail_with_msg("Can't run without pypdf installed.")
-try:
-    from reportlab.lib.pagesizes import A4
-except ImportError:
-    fail_with_msg("Can't run without reportlab installed.")
-
-# some constants
-A4_WIDTH, A4_HEIGHT = A4
-POINTS_PER_CM = 10 * 72 / 25.4
-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
 help_epilogue = """
 EXAMPLES:
 
@@ -55,10 +32,10 @@ Initially declare 5cm crop from the left and 1cm crop from right, but alternate
 Quarter each OUTPUT.pdf page to carry 4 pages from INPUT.pdf, draw stencils into inner margins for cuts to carry binding strings:
     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --nup4
 
-Same as --nup4, but define a printable-region margin of 1.3cm to limit the space for the INPUT.pdf pages in OUTPUT.pdf page quarters:
+Same --nup4, but define a printable-region margin of 1.3cm to limit the space for the INPUT.pdf pages in OUTPUT.pdf page quarters:
     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -n --print_margin 1.3
 
-Same as -n, but draw lines marking printable-region margins, page quarts, spine margins:
+Same --nup4, but draw lines marking printable-region margins, page quarts, spine margins:
     bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -n --analyze
 
 NOTES:
@@ -82,58 +59,87 @@ To facilitate this layout, --nup4 also pads the input PDF pages to a total numbe
 
 (To turn above double-sided example page into a tiny 8-page book:  Cut the paper in two on its horizontal middle line.  Fold the two halves by their vertical middle lines, with pages 3-2 and 7-6 on the folds' insides.  This creates two 4-page books of pages 1-4 and pages 5-8.  Fold them both closed and (counter-intuitively) put the book of pages 5-8 on top of the other one (creating a temporary page order of 5,6,7,8,1,2,3,4).  A binding cut stencil should be visible on the top left of this stack – cut it out (with all pages folded together) to add the same inner-margin upper cut to each page.  Turn around your 8-pages stack to find the mirror image of aforementioned stencil on the stack's back's bottom, and cut that out too.  Each page now has binding cuts on top and bottom of its inner margins.  Swap the order of both books (back to the final page order of 1,2,3,4,5,6,7,8), and you now have an 8-pages book that can be "bound" in its binding cuts through a rubber band or the like.  Repeat with the next 8-pages double-page, et cetera.  (Actually, with just 8 pages, the paper may curl under the pressure of a rubber band – but go up to 32 pages or so, and the result will become quite stable.)
 """
+import argparse
+import io
+import os
+import sys
+from collections import namedtuple
+
+def handled_error_exit(msg):
+    print(f"ERROR: {msg}")
+    sys.exit(1)
+
+try:
+    import pypdf
+except ImportError:
+    handled_error_exit("Can't run at all without pypdf installed.")
+
+# some general paper geometry constants
+POINTS_PER_CM = 10 * 72 / 25.4
+A4_WIDTH = 21 * POINTS_PER_CM
+A4_HEIGHT = 29.7 * POINTS_PER_CM
+A4 = (A4_WIDTH, A4_HEIGHT)
+
+# constants specifically for --nup4
+A4_HALF_WIDTH = A4_WIDTH / 2
+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
+QUARTER_SCALE_FACTOR = 0.5
+PAGE_ORDER_FOR_NUP4 = (3,0,7,4,1,2,5,6)
 
 # some helpers
-def validate_page_range(p_string, err_msg_prefix):
-    err_msg = "%s: invalid page range string: %s" % (err_msg_prefix, p_string)
-    if '-' not in p_string:
-        raise ValueError("%s: page range string lacks '-': %s" % (err_msg_prefix, p_string))
-    tokens = p_string.split("-")
-    if len(tokens) > 2:
-        raise ValueError("%s: page range string has too many '-': %s" % (err_msg_prefix, p_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:
-            raise ValueError("%s: page range string carries values that are neither integer, nor 'start', nor 'end': %s" % (err_msg_prefix, p_string))
-        if int(token) < 1:
-            raise ValueError("%s: page range string may not carry page numbers <1: %s" % (err_msg_prefix, p_string))
-    start = -1
-    end = -1
-    try:
-        start = int(tokens[0])
-        end = int(tokens[1])
-    except:
-        pass
-    if start > 0 and end > 0 and start > end:
-        raise ValueError("%s: page range starts higher than it ends: %s" % (err_msg_prefix, p_string))
+class PageCrop:
 
-def split_crops_string(c_string):
-    initial_split = c_string.split(':')
-    if len(initial_split) > 1:
-        page_range = initial_split[0]
-        crops = initial_split[1]
-    else:
-        page_range = None
-        crops = initial_split[0]
-    return page_range, crops
+    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 PrintableMargin:
+
+    def __init__(self, size_cm):
+        self.size = size_cm * POINTS_PER_CM
+        self.zoom = (A4_WIDTH - 2 * self.size)/A4_WIDTH
+
+
+class HandledException(Exception):
+    pass
 
-def parse_page_range(range_string, pages):
-    start_page = 0
-    end_page = len(pages)
-    if range_string:
-        start, end = range_string.split('-')
-        if not (len(start) == 0 or start == "start"):
-            start_page = int(start) - 1
-        if not (len(end) == 0 or end == "end"):
-            end_page = int(end)
-    return start_page, end_page
 
 def parse_args():
     parser = argparse.ArgumentParser(description=__doc__, epilog=help_epilogue, formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -146,110 +152,166 @@ def parse_args():
     parser.add_argument("-n", "--nup4", action='store_true', help="puts 4 input pages onto 1 output page, adds binding cut stencil")
     parser.add_argument("-a", "--analyze", action="store_true", help="in --nup4, print lines identifying spine, page borders")
     parser.add_argument("-m", "--print_margin", type=float, default=0.43, help="print margin for --nup4 in cm (default 0.43)")
-    args = parser.parse_args()
+    return parser.parse_args()
+
 
-    # some basic input validation
+def validate_inputs_first_pass(args):
     for filename in args.input_file:
         if not os.path.isfile(filename):
-            raise ValueError("-i: %s is not a file" % filename)
+            raise HandledException(f"-i: {filename} is not a file")
         try:
             with open(filename, 'rb') as file:
                 pypdf.PdfReader(file)
         except pypdf.errors.PdfStreamError:
-            raise ValueError("-i: cannot interpret %s as PDF file" % filename)
+            raise HandledException(f"-i: cannot interpret {filename} as PDF file")
     if args.page_range:
         for p_string in args.page_range:
             validate_page_range(p_string, "-p")
         if len(args.page_range) > len(args.input_file):
-            raise ValueError("more -p arguments than -i arguments")
+            raise HandledException("-p: more --page_range arguments than --input_file arguments")
     if args.crops:
         for c_string in args.crops:
             initial_split = c_string.split(':')
             if len(initial_split) > 2:
-                raise ValueError("-c: cropping string has multiple ':': %s" % c_string)
+                raise HandledException(f"-c: cropping string has multiple ':': {c_string}")
             page_range, crops = split_crops_string(c_string)
             crops = crops.split(",")
             if page_range:
                 validate_page_range(page_range, "-c")
             if len(crops) != 4:
-                raise ValueError("-c: cropping should contain three ',': %s" % c_string)
+                raise HandledException(f"-c: cropping does not contain exactly three ',': {c_string}")
             for crop in crops:
                 try:
                     float(crop)
-                except:
-                    raise ValueError("-c: non-number crop in %s" % c_string)
+                except ValueError:
+                    raise HandledException(f"-c: non-number crop in: {c_string}")
     if args.rotate_page:
         for r in args.rotate_page:
             try:
                 int(r)
-            except:
-                raise ValueError("-r: non-integer value: %s" % r)
+            except ValueError:
+                raise HandledException(f"-r: non-integer value: {r}")
             if r < 1:
-                raise ValueError("-r: value must not be <1: %s" % r)
+                raise HandledException(f"-r: value must not be <1: {r}")
     try:
         float(args.print_margin)
-    except:
-        raise ValueError("-m: non-float value: %s" % arg.print_margin)
+    except ValueError:
+        raise HandledException(f"-m: non-float value: {arg.print_margin}")
 
-    return args
 
-def main():
-    args = parse_args()
+def validate_page_range(p_string, err_msg_prefix):
+    prefix = f"{err_msg_prefix}: page range string"
+    if '-' not in p_string:
+        raise HandledException(f"{prefix} lacks '-': {p_string}")
+    tokens = p_string.split("-")
+    if len(tokens) > 2:
+        raise HandledException(f"{prefix} has too many '-': {p_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:
+            raise HandledException(f"{prefix} carries value neither integer, nor 'start', nor 'end': {p_string}")
+        if int(token) < 1:
+            raise HandledException(f"{prefix} carries page number <1: {p_string}")
+    start = -1
+    end = -1
+    try:
+        start = int(tokens[0])
+        end = int(tokens[1])
+    except ValueError:
+        pass
+    if start > 0 and end > 0 and start > end:
+        raise HandledException(f"{prefix} has higher start than end value: {p_string}")
+
 
-    # select pages from input files
+def split_crops_string(c_string):
+    initial_split = c_string.split(':')
+    if len(initial_split) > 1:
+        page_range = initial_split[0]
+        crops = initial_split[1]
+    else:
+        page_range = None
+        crops = initial_split[0]
+    return page_range, crops
+
+
+def parse_page_range(range_string, pages):
+    start_page = 0
+    end_page = len(pages)
+    if range_string:
+        start, end = range_string.split('-')
+        if not (len(start) == 0 or start == "start"):
+            start_page = int(start) - 1
+        if not (len(end) == 0 or end == "end"):
+            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 = []
     new_page_num = 0
-    for i, input_file in enumerate(args.input_file):
+    for i, input_file in enumerate(args_input_file):
         file = open(input_file, 'rb')
         opened_files += [file]
         reader = pypdf.PdfReader(file)
         range_string = None
-        if args.page_range and len(args.page_range) > i:
-            range_string = args.page_range[i]
+        if args_page_range and len(args_page_range) > i:
+            range_string = args_page_range[i]
         start_page, end_page = parse_page_range(range_string, reader.pages)
         if end_page > len(reader.pages):  # no need to test start_page cause start_page > end_page is checked above
-            raise ValueError("-p: page range goes beyond pages of input file: %s" % range_string)
+            raise HandledException(f"-p: page range goes beyond pages of input file: {range_string}")
         for old_page_num in range(start_page, end_page):
             new_page_num += 1
             page = reader.pages[old_page_num]
             pages_to_add += [page]
-            print("-i, -p: read in %s page number %d as new page %d" % (input_file, old_page_num+1, new_page_num))
+            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
 
-    # we can do some more input validations now that we know how many pages output should have
+
+def validate_inputs_second_pass(args, pages_to_add):
     if args.crops:
         for c_string in args.crops:
             page_range, _= split_crops_string(c_string)
             if page_range:
                 start, end = parse_page_range(page_range, pages_to_add)
                 if end > len(pages_to_add):
-                     raise ValueError("-c: page range goes beyond number of pages we're building: %s" % page_range)
+                     raise HandledException(f"-c: page range goes beyond number of pages we're building: {page_range}")
     if args.rotate_page:
         for r in args.rotate_page:
             if r > len(pages_to_add):
-                 raise ValueError("-r: page number beyond number of pages we're building: %d" % r)
+                 raise HandledException(f"-r: page number beyond number of pages we're building: {r}")
 
-    # rotate page canvas (as opposed to using PDF's /Rotate command)
-    if args.rotate_page:
-        for rotate_page in args.rotate_page:
+
+def rotate_pages(args_rotate_page, pages_to_add):
+    if args_rotate_page:
+        for rotate_page in args_rotate_page:
             page = pages_to_add[rotate_page - 1]
             page.add_transformation(pypdf.Transformation().translate(tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2))
             page.add_transformation(pypdf.Transformation().rotate(-90))
             page.add_transformation(pypdf.Transformation().translate(tx=A4_WIDTH/2, ty=A4_HEIGHT/2))
-            print("-r: rotating (by 90°) page", rotate_page)
+            print(f"-r: rotating (by 90°) page {rotate_page}")
 
-    # if necessary, pad pages to multiple of 8
-    if args.nup4:
-        mod_to_8 = len(pages_to_add) % 8
-        if mod_to_8 > 0:
-            print("-n: number of input pages %d not multiple of 8, padding to that" % len(pages_to_add))
-            for _ in range(8 - mod_to_8):
-                new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
-                pages_to_add += [new_page]
-
-    # normalize all pages to portrait A4
+
+def pad_pages_to_multiple_of_8(pages_to_add):
+    mod_to_8 = len(pages_to_add) % 8
+    if mod_to_8 > 0:
+        old_len = len(pages_to_add)
+        for _ in range(8 - mod_to_8):
+            new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
+            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:
+        if "/Rotate" in page:  # TODO: preserve rotation, but in canvas?
             page.rotate(360 - page["/Rotate"])
         page.mediabox.left = 0
         page.mediabox.bottom = 0
@@ -257,202 +319,189 @@ def main():
         page.mediabox.right = A4_WIDTH
         page.cropbox = page.mediabox
 
-    # determine page crops, zooms, crop symmetry
-    crops_at_page = [(0,0,0,0)]*len(pages_to_add)
-    zoom_at_page = [1]*len(pages_to_add)
-    if args.crops:
-        for c_string in args.crops:
+
+def collect_per_page_crops_and_zooms(args_crops, args_symmetry, pages_to_add):
+    crop_at_page = [PageCrop()] * 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)
-            crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm = [float(x) for x in  crops.split(',')]
-            crop_left = crop_left_cm * POINTS_PER_CM
-            crop_bottom = crop_bottom_cm * POINTS_PER_CM
-            crop_right = crop_right_cm * POINTS_PER_CM
-            crop_top = crop_top_cm * POINTS_PER_CM
-            if args.symmetry:
-                print("-c, -t: to pages %d to %d applying crops: left %.2fcm, bottom %.2fcm, right %.2fcm, top %.2fcm (but alternating left and right crop between even and odd pages)" % (start_page + 1, end_page, crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm))
-            else:
-                print("-c: to pages %d to %d applying crops: left %.2fcm, bottom %.2fcm, right %.2fcm, top %.2fcm" % (start_page + 1, end_page, crop_left_cm, crop_bottom_cm, crop_right_cm, crop_top_cm))
-            cropped_width  = A4_WIDTH - crop_left - crop_right
-            cropped_height = A4_HEIGHT - crop_bottom - crop_top
-            zoom = 1
-            zoom_horizontal = A4_WIDTH / (A4_WIDTH - crop_left - crop_right)
-            zoom_vertical = A4_HEIGHT / (A4_HEIGHT - crop_bottom - crop_top)
-            if (zoom_horizontal > 1 and zoom_vertical < 1) or (zoom_horizontal < 1 and zoom_vertical > 1):
-                raise ValueError("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)
+            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(*[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:
-                    crops_at_page[page_num] = (crop_right, crop_bottom, crop_left, crop_top)
+                if args_symmetry and page_num % 2:
+                    crop_at_page[page_num] = page_crop.give_mirror()
                 else:
-                    crops_at_page[page_num] = (crop_left, crop_bottom, crop_right, crop_top)
-                zoom_at_page[page_num] = zoom
+                    crop_at_page[page_num] = page_crop
+    return crop_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):
+        page.add_transformation(pypdf.Transformation().translate(tx=-crop_at_page[i].left, ty=-crop_at_page[i].bottom))
+        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, 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 = PrintableMargin(args_print_margin)
+    spine_part_of_page = (SPINE_LIMIT / A4_HALF_WIDTH) / printable_margin.zoom
+    bonus_shrink_factor = 1 - spine_part_of_page
+    pages_to_add, new_i_order = resort_pages_for_nup4(pages_to_add)
+    nup4_position = 0
+    page_count = 0
+    is_front_page = True
+    for i, page in enumerate(pages_to_add):
+        if nup4_position == 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], bonus_shrink_factor, printable_margin, nup4_position)
+        nup4_outer_page_transform(page, bonus_shrink_factor, nup4_position)
+        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, bonus_shrink_factor, canvas_class)
+            writer.add_page(new_page)
+            nup4_position = 0
+            is_front_page = not is_front_page
+
+
+def resort_pages_for_nup4(pages_to_add):
+    new_page_order = []
+    new_i_order = []
+    eight_pack = []
+    i = 0
+    n_eights = 0
+    for page in pages_to_add:
+        if i == 0:
+            eight_pack = []
+        eight_pack += [page]
+        i += 1
+        if i == 8:
+            i = 0
+            for n in PAGE_ORDER_FOR_NUP4:
+                new_i_order += [8 * n_eights + n]
+                new_page_order += [eight_pack[n]]
+            n_eights += 1
+    return new_page_order, new_i_order
+
+
+def nup4_inner_page_transform(page, crop, bonus_shrink_factor, printable_margin, nup4_position):
+    page.add_transformation(pypdf.Transformation().translate(ty=(A4_HEIGHT / crop.zoom - (A4_HEIGHT - crop.top))))
+    if nup4_position == 0 or nup4_position == 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 / crop.zoom - (A4_WIDTH - crop.right))))
+    page.add_transformation(pypdf.Transformation().scale(crop.zoom * bonus_shrink_factor, crop.zoom * bonus_shrink_factor))
+    if nup4_position == 2 or nup4_position == 3:
+        page.add_transformation(pypdf.Transformation().translate(ty=-2*printable_margin.size/printable_margin.zoom))
+
+
+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:
+        y_section = A4_HEIGHT
+        page.mediabox.bottom = A4_HALF_HEIGHT
+        page.mediabox.top    = A4_HEIGHT
+    if nup4_position == 2 or nup4_position == 3:
+        y_section = 0
+        page.mediabox.bottom = 0
+        page.mediabox.top  =  A4_HALF_HEIGHT
+    if nup4_position == 0 or nup4_position == 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))
+        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, bonus_shrink_factor, canvas_class):
+    if args_analyze:
+        # borders
+        packet = io.BytesIO()
+        c = canvas_class(packet, pagesize=A4)
+        c.setLineWidth(0.1)
+        c.line(0, A4_HEIGHT, A4_WIDTH, A4_HEIGHT)
+        c.line(0, A4_HALF_HEIGHT, A4_WIDTH, A4_HALF_HEIGHT)
+        c.line(0, 0, A4_WIDTH, 0)
+        c.line(0, A4_HEIGHT, 0, 0)
+        c.line(A4_HALF_WIDTH, A4_HEIGHT, A4_HALF_WIDTH, 0)
+        c.line(A4_WIDTH, A4_HEIGHT, A4_WIDTH, 0)
+        c.save()
+        new_pdf = pypdf.PdfReader(packet)
+        new_page.merge_page(new_pdf.pages[0])
+    printable_offset_x = printable_margin.size
+    printable_offset_y = printable_margin.size * A4_HEIGHT / A4_WIDTH
+    new_page.add_transformation(pypdf.Transformation().scale(printable_margin.zoom, printable_margin.zoom))
+    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_right_spine_limit = A4_WIDTH - x_left_spine_limit
+    if args_analyze or is_front_page:
+        packet = io.BytesIO()
+        c = canvas_class(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 is_front_page:
+        c.setLineWidth(0.2)
+        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):
+    outer_start_x = x_spine_limit - 0.5 * CUT_WIDTH * direction
+    inner_start_x = x_spine_limit + 0.5 * CUT_WIDTH * direction
+    middle_point_y =  A4_HALF_HEIGHT + MIDDLE_POINT_DEPTH * direction
+    end_point_y =  A4_HALF_HEIGHT + CUT_DEPTH * direction
+    canvas.line(inner_start_x, A4_HALF_HEIGHT, 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_HALF_HEIGHT)
 
-    writer = pypdf.PdfWriter()
-    if not args.nup4:
-        # single-page output
-        print("building 1-input-page-per-output-page book")
-        odd_page = True
-        for i, page in enumerate(pages_to_add):
-            crop_left, crop_bottom, crop_right, crop_top = crops_at_page[i]
-            zoom = zoom_at_page[i]
-            page.add_transformation(pypdf.Transformation().translate(tx=-crop_left, ty=-crop_bottom))
-            page.add_transformation(pypdf.Transformation().scale(zoom, zoom))
-            cropped_width  = A4_WIDTH - crop_left - crop_right
-            cropped_height = A4_HEIGHT - crop_bottom - crop_top
-            page.mediabox.right = cropped_width * zoom
-            page.mediabox.top = cropped_height * zoom
-            writer.add_page(page)
-            odd_page = not odd_page
-            print("built page number %d (of %d)" % (i+1, len(pages_to_add)))
 
+def main():
+    args = parse_args()
+    validate_inputs_first_pass(args)
+    if args.nup4:
+        try:
+            from reportlab.pdfgen.canvas import Canvas
+        except ImportError:
+            raise HandledException("-n: need reportlab.pdfgen.canvas installed for --nup4")
+    pages_to_add, opened_files = read_inputs_to_pagelist(args.input_file, args.page_range)
+    validate_inputs_second_pass(args, pages_to_add)
+    rotate_pages(args.rotate_page, pages_to_add)
+    if args.nup4:
+        pad_pages_to_multiple_of_8(pages_to_add)
+    normalize_pages_to_A4(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, args.print_margin, args.analyze, Canvas)
     else:
-        print("-n: building 4-input-pages-per-output-page book")
-        print("-m: applying printable-area margin of %.2fcm" % args.print_margin)
-        if args.analyze:
-            print("-a: drawing page borders, spine limits")
-        n_pages_per_axis = 2
-        printable_margin = args.print_margin * POINTS_PER_CM
-        printable_scale = (A4_WIDTH - 2*printable_margin)/A4_WIDTH
-        half_width = A4_WIDTH / n_pages_per_axis
-        half_height = A4_HEIGHT / n_pages_per_axis
-        section_scale_factor = 1 / n_pages_per_axis
-        spine_part_of_page = (SPINE_LIMIT / half_width) / printable_scale
-        bonus_shrink_factor = 1 - spine_part_of_page
-        new_page_order = []
-        new_i_order = []
-        eight_pack = []
-        i = 0
-        n_eights = 0
-        for page in pages_to_add:
-            if i == 0:
-                eight_pack = []
-            eight_pack += [page]
-            i += 1
-            if i == 8:
-                i = 0
-                new_i_order += [8 * n_eights + 3,
-                                8 * n_eights + 0,
-                                8 * n_eights + 7,
-                                8 * n_eights + 4,
-                                8 * n_eights + 1,
-                                8 * n_eights + 2,
-                                8 * n_eights + 5,
-                                8 * n_eights + 6]
-                n_eights += 1
-                new_page_order += [eight_pack[3]]  # page front, upper left
-                new_page_order += [eight_pack[0]]  # page front, upper right
-                new_page_order += [eight_pack[7]]  # page front, lower left
-                new_page_order += [eight_pack[4]]  # page front, lower right
-                new_page_order += [eight_pack[1]]  # page back, upper left
-                new_page_order += [eight_pack[2]]  # page back, upper right
-                new_page_order += [eight_pack[5]]  # page back, lower left
-                new_page_order += [eight_pack[6]]  # page back, lower right
-        i = 0
-        page_count = 0
-        front_page = True
-        for j, page in enumerate(new_page_order):
-            if i == 0:
-                new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
-
-            # in-section transformations: align pages on top, left-hand pages to left, right-hand to right
-            new_i = new_i_order[j]
-            crop_left, crop_bottom, crop_right, crop_top = crops_at_page[new_i]
-            zoom = zoom_at_page[new_i]
-            page.add_transformation(pypdf.Transformation().translate(ty=(A4_HEIGHT / zoom - (A4_HEIGHT - crop_top))))
-            if i == 0 or i == 2:
-                page.add_transformation(pypdf.Transformation().translate(tx=-crop_left))
-            elif i == 1 or i == 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 i == 2 or i == 3:
-                page.add_transformation(pypdf.Transformation().translate(ty=-2*printable_margin/printable_scale))
-
-            # outer section transformations
-            page.add_transformation(pypdf.Transformation().translate(ty=(1-bonus_shrink_factor)*A4_HEIGHT))
-            if i == 0 or i == 1:
-                y_section = A4_HEIGHT
-                page.mediabox.bottom = half_height
-                page.mediabox.top    = A4_HEIGHT
-            if i == 2 or i == 3:
-                y_section = 0
-                page.mediabox.bottom = 0
-                page.mediabox.top  =   half_height
-            if i == 0 or i == 2:
-                x_section = 0
-                page.mediabox.left   = 0
-                page.mediabox.right  = half_width
-            if i == 1 or i == 3:
-                page.add_transformation(pypdf.Transformation().translate(tx=(1-bonus_shrink_factor)*A4_WIDTH))
-                x_section = A4_WIDTH
-                page.mediabox.left   = 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(section_scale_factor, section_scale_factor))
-            new_page.merge_page(page)
-            page_count += 1
-            print("merged page number %d (of %d)" % (page_count, len(pages_to_add)))
-            i += 1
-            if i > 3:
-                from reportlab.pdfgen import canvas
-                if args.analyze:
-                    # borders
-                    packet = io.BytesIO()
-                    c = canvas.Canvas(packet, pagesize=A4)
-                    c.setLineWidth(0.1)
-                    c.line(0, A4_HEIGHT, A4_WIDTH, A4_HEIGHT)
-                    c.line(0, half_height, A4_WIDTH, half_height)
-                    c.line(0, 0, A4_WIDTH, 0)
-                    c.line(0, A4_HEIGHT, 0, 0)
-                    c.line(half_width, A4_HEIGHT, half_width, 0)
-                    c.line(A4_WIDTH, A4_HEIGHT, A4_WIDTH, 0)
-                    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))
-                new_page.add_transformation(pypdf.Transformation().translate(tx=printable_offset_x, ty=printable_offset_y))
-                x_left_SPINE_LIMIT = half_width * bonus_shrink_factor
-                x_right_SPINE_LIMIT = A4_WIDTH - x_left_SPINE_LIMIT
-                if args.analyze or front_page:
-                    packet = io.BytesIO()
-                    c = canvas.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 front_page:
-                    c.setLineWidth(0.2)
-                    # cut upper left
-                    start_up_left_left_x = x_left_SPINE_LIMIT - 0.5 * CUT_WIDTH
-                    start_up_left_right_x = x_left_SPINE_LIMIT + 0.5 * CUT_WIDTH
-                    middle_point_up_left_y = half_height + MIDDLE_POINT_DEPTH
-                    end_point_up_left_y = half_height + CUT_DEPTH
-                    c.line(start_up_left_right_x, half_height, x_left_SPINE_LIMIT, end_point_up_left_y)
-                    c.line(x_left_SPINE_LIMIT, end_point_up_left_y, x_left_SPINE_LIMIT, middle_point_up_left_y)
-                    c.line(x_left_SPINE_LIMIT, middle_point_up_left_y, start_up_left_left_x, half_height)
-                    # cut lower right
-                    start_down_right_left_x = x_right_SPINE_LIMIT - 0.5 * CUT_WIDTH
-                    start_down_right_right_x = x_right_SPINE_LIMIT + 0.5 * CUT_WIDTH
-                    middle_point_down_right_y = half_height - MIDDLE_POINT_DEPTH
-                    end_point_down_right_y = half_height - CUT_DEPTH
-                    c.line(start_down_right_left_x, half_height, x_right_SPINE_LIMIT, end_point_down_right_y)
-                    c.line(x_right_SPINE_LIMIT, end_point_down_right_y, x_right_SPINE_LIMIT, middle_point_down_right_y)
-                    c.line(x_right_SPINE_LIMIT, middle_point_down_right_y, start_down_right_right_x, half_height)
-                if args.analyze or front_page:
-                    c.save()
-                    new_pdf = pypdf.PdfReader(packet)
-                    new_page.merge_page(new_pdf.pages[0])
-                writer.add_page(new_page)
-                i = 0
-                front_page = not front_page
-
-    # write and close
+        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:
@@ -462,5 +511,5 @@ def main():
 if __name__ == "__main__":
     try:
         main()
-    except ValueError as e:
-        fail_with_msg(e)
+    except HandledException as e:
+        handled_error_exit(e)