home · contact · privacy
Bookmaker: greatly refactor code.
[misc] / bookmaker.py
index e6c8d2aa7a0375452bcd305973f43e9e30ad408f..dc4b1cbc50c2b03af5f7d61a52dc3bdb4f82ed0e 100755 (executable)
 #!/usr/bin/env python3
-import pypdf 
+"""
+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.
+"""
+help_epilogue = """
+EXAMPLES:
+
+Concatenate two PDFs A.pdf and B.pdf to COMBINED.pdf:
+    bookmaker.py --input_file A.pdf --input_file B.pdf --output_file COMBINED.pdf
+
+Produce OUTPUT.pdf containing all pages of (inclusive) page number range 3-7 from INPUT.pdf:
+    bookmaker.py -i INPUT.pdf --page_range 3-7 -o OUTPUT.pdf
+
+Produce COMBINED.pdf from A.pdf's first 7 pages, B.pdf's pages except its first two, and all pages of C.pdf:
+    bookmaker.py -i A.pdf -p start-7 -i B.pdf -p 3-end -i C.pdf -o COMBINED.pdf
+
+Crop each page 5cm from the left, 10cm from the bottom, 2cm from the right, and 0cm from the top:
+    bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --crops "5,10,2,0"
+
+Include all pages from INPUT.pdf, but crop pages 10-20 by 5cm each from bottom and top:
+    bookmaker.py -i INPUT.pdf -c "10-20:0,5,0,5" -o OUTPUT.pdf
+
+Same crops for pages 10-20, but also crop all pages 30 and later by 3cm each from left and right:
+    bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -c "10-20:0,5,0,5" -c "30-end:3,0,3,0"
+
+Rotate by 90° pages 3, 5, 7; rotate page 7 once more by 90% (i.e. 180° in total):
+    bookmaker.py -i INPUT.pdf -o OUTPUT.pdf --rotate 3 -r 5 -r 7 -r 7
+
+Initially declare 5cm crop from the left and 1cm crop from right, but alternate direction between even and odd pages:
+    bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -c "5,0,1,0" -s
+
+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 --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 --nup4, but draw lines marking printable-region margins, page quarts, spine margins:
+    bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -n --analyze
+
+NOTES:
+
+For arguments like -p, page numbers are assumed to start with 1 (not 0, which is treated as an invalid page number value).
+
+The target page shape so far is assumed to be A4 in portrait orientation; bookmaker.py normalizes all pages to this format before applying crops, and removes any source PDF /Rotate commands (for their production of landscape orientations).
+
+For --nup4, the -c cropping instructions do not so much erase content outside the cropped area, but rather zoom into the page in a way that maximes the cropped area as much as possible into the available per-page area between printable-area margins and the borders to the other quartered pages.  If the zoomed cropped area does not fit in neatly into its per-page area, this will preserve additional page content.
+
+The --nup4 quartering puts pages into a specific order optimized for no-tumble duplex print-outs that can easily be folded and cut into pages of a small A6 book.  Each unit of 8 pages from the source PDF is mapped thus onto two subsequent pages (i.e. front and back of a printed A4 paper):
+
+ (front)      (back)
++-------+   +-------+
+| 4 | 1 |   | 2 | 3 |
+|-------|   |-------|
+| 8 | 5 |   | 6 | 7 |
++-------+   +-------+
+
+To facilitate this layout, --nup4 also pads the input PDF pages to a total number that is a multiple of 8, by adding empty pages if necessary.
+
+(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
-from reportlab.lib.pagesizes import A4
-a4_width, a4_height = A4
-
-parser = argparse.ArgumentParser(description="build print-ready book PDF")
-parser.add_argument("-i", "--input", dest="input_file", required=True, help="input PDF file")
-parser.add_argument("-o", "--output", dest="output_file", required=True, help="output PDF file")
-parser.add_argument("-p", "--pages", dest="page_range", help="page range, e.g., '3-end'")
-parser.add_argument("-c", "--crop", dest="crop_range", help="crops left, bottom, right, top – e.g., '10,10,10,10'")
-parser.add_argument("-n", "--nup4", dest="nup4", action='store_true', help="puts 4 input pages onto 1 output page")
-parser.add_argument("-a", "--analyze", dest="analyze", action="store_true", help="print lines identifying spine, page borders")
-parser.add_argument("-t", "--symmetry", dest="symmetry", action="store_true", help="alternate horizontal crops between odd and even pages")
-parser.add_argument("-s", "--second", dest="second", help="append second file as input to append")
-parser.add_argument("-r", "--rotate", dest="rotate", help="rotate page of number by 90°")
-args = parser.parse_args()
-
-with open(args.input_file, 'rb') as file:
-    reader = pypdf.PdfReader(file)
-
-    # determine page range
-    start_page = 0
-    end_page = len(reader.pages)
+import os
+import sys
+
+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
+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")
+    parser.add_argument("-o", "--output_file", required=True, help="output PDF file")
+    parser.add_argument("-p", "--page_range", action="append", help="page range, e.g., '2-9' or '3-end' or 'start-14'")
+    parser.add_argument("-c", "--crops", action="append", help="cm crops left, bottom, right, top – e.g., '10,10,10,10'; prefix with ':'-delimited page range to limit effect")
+    parser.add_argument("-r", "--rotate_page", type=int, action="append", help="rotate page of number by 90° (usable multiple times on same page!)")
+    parser.add_argument("-s", "--symmetry", action="store_true", help="alternate horizontal crops between odd and even pages")
+    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)")
+    return parser.parse_args()
+
+def validate_inputs_first_pass(args):
+    for filename in args.input_file:
+        if not os.path.isfile(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 HandledException(f"-i: cannot interpret {filename} as PDF file")
     if args.page_range:
-        start, end = args.page_range.split('-')
+        for p_string in args.page_range:
+            validate_page_range(p_string, "-p")
+        if len(args.page_range) > len(args.input_file):
+            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 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 HandledException(f"-c: cropping does not contain exactly three ',': {c_string}")
+            for crop in crops:
+                try:
+                    float(crop)
+                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 ValueError:
+                raise HandledException(f"-r: non-integer value: {r}")
+            if r < 1:
+                raise HandledException(f"-r: value must not be <1: {r}")
+    try:
+        float(args.print_margin)
+    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:
+        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}")
+
+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 
+            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 = []
-    for page_num in range(start_page, end_page):
-        page = reader.pages[page_num]
-        pages_to_add += [page]
-        print("read in page number", page_num+1)
-
-    # add pages of second PDF
-    if args.second:
-        file2 = open(args.second, 'rb')
-        reader2 = pypdf.PdfReader(file2)
-        page_num = 1
-        for page in reader2.pages:
+    opened_files = []
+    new_page_num = 0
+    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]
+        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 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("read second PDF's page number", page_num)
-            page_num += 1
+            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:
+            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 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 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:
+            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(f"-r: rotating (by 90°) page {rotate_page}")
 
-    # rotate page canvas
-    if args.rotate:
-        page = pages_to_add[int(args.rotate) - 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))
+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)}")
 
-    # normalize all pages to portrait A4
+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
-        page.mediabox.top = a4_height 
-        page.mediabox.right = a4_width
+        page.mediabox.top = A4_HEIGHT
+        page.mediabox.right = A4_WIDTH
         page.cropbox = page.mediabox
 
-    # determine page crop
-    crop_left, crop_bottom, crop_right, crop_top = 0, 0, 0, 0
-    if args.crop_range:
-        crop_left, crop_bottom, crop_right, crop_top = [float(x) for x in  args.crop_range.split(',')]
-    cropped_width  = a4_width - crop_left - crop_right
-    cropped_height = a4_height - crop_bottom - crop_top  
-    zoom = 1
-    if args.crop_range:
-        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):
-            print("Error: opposing zooms.")
-            exit(1)
-        elif zoom_horizontal + zoom_vertical > 2:
-            zoom = min(zoom_horizontal, zoom_vertical) 
-        else:
-            zoom = max(zoom_horizontal, zoom_vertical) 
+def collect_per_page_crops_and_zooms(args_crops, args_symmetry, pages_to_add):
+    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:
+            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
+            prefix = "-c, -t" if args_symmetry else "-c"
+            suffix = " (but alternating left and right crop between even and odd pages)" if args_symmetry else ""
+            print(f"{prefix}: to pages {start_page + 1} to {end_page} applying crops: left {crop_left_cm}cm, bottom {crop_bottom_cm}cm, right {crop_right_cm}cm, top {crop_top_cm}cm{suffix}")
+            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 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)
+            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)
+                else:
+                    crops_at_page[page_num] = (crop_left, crop_bottom, crop_right, crop_top)
+                zoom_at_page[page_num] = zoom
+    return crops_at_page, zoom_at_page
 
+def build_single_pages_output(writer, pages_to_add, crops_at_page, zoom_at_page):
+    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(f"built page number {i+1} (of {len(pages_to_add)})")
+
+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, crops, zoom, bonus_shrink_factor, printable_margin, printable_scale, nup4_position):
+    crop_left, crop_bottom, crop_right, crop_top = crops
+    page.add_transformation(pypdf.Transformation().translate(ty=(A4_HEIGHT / 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 / 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:
+        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, printable_scale, 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
+    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 = 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)
+
+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)
+    crops_at_page, zoom_at_page = collect_per_page_crops_and_zooms(args.crops, args.symmetry, pages_to_add)
     writer = pypdf.PdfWriter()
     if not args.nup4:
-        odd_page = True
-        for page in pages_to_add:
-            if args.symmetry and odd_page:
-                page.add_transformation(pypdf.Transformation().translate(tx=-crop_left, ty=-crop_bottom))
-            else:
-                page.add_transformation(pypdf.Transformation().translate(tx=-crop_right, ty=-crop_bottom))
-            page.add_transformation(pypdf.Transformation().scale(zoom, zoom))
-            page.mediabox.right = cropped_width * zoom
-            page.mediabox.top = cropped_height * zoom
-            writer.add_page(page)
-            odd_page = not odd_page
+        build_single_pages_output(writer, pages_to_add, crops_at_page, zoom_at_page)
     else:
-        n_pages_per_axis = 2
-        points_per_mm = 2.83465
-        printable_margin = 4.3 * points_per_mm
-        printable_scale = (a4_width - 2*printable_margin)/a4_width
-        spine_limit = 10 * points_per_mm
-        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
+        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
-        new_page_order = []
-        eight_pack = []
-        mod_to_8 = len(pages_to_add) % 8
-        if mod_to_8 > 0:
-            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]
-        i = 0
-        for page in pages_to_add:
-            if i == 0:
-                eight_pack = []
-            eight_pack += [page]
-            i += 1
-            if i == 8:
-                i = 0
-                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
+        pages_to_add, new_i_order = resort_pages_for_nup4(pages_to_add)
         i = 0
         page_count = 0
-        front_page = True
-        for page in new_page_order:
+        is_front_page = True
+        for j, page in enumerate(pages_to_add):
             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
-            page.add_transformation(pypdf.Transformation().translate(ty=(a4_height / zoom - (a4_height - crop_top))))
-            if i == 0 or i == 2:
-                if args.symmetry:
-                    page.add_transformation(pypdf.Transformation().translate(tx=-crop_right))
-                else:
-                    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 = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)
+            new_i = new_i_order[j]
+            nup4_inner_page_transform(page, crops_at_page[new_i], zoom_at_page[new_i], bonus_shrink_factor, printable_margin, printable_scale, i)
+            nup4_outer_page_transform(page, bonus_shrink_factor, i)
             new_page.merge_page(page)
             page_count += 1
-            print("merged page number", page_count)
+            print(f"merged page number {page_count} (of {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_depth = 19.5 * points_per_mm
-                    cut_width = 10.5 * points_per_mm
-                    middle_point_depth = 4 * points_per_mm 
-
-                    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)
-
-                    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()
-                    # packet.seek(0)
-                    new_pdf = pypdf.PdfReader(packet)
-                    new_page.merge_page(new_pdf.pages[0])
+                ornate_nup4(writer, args.analyze, is_front_page, new_page, printable_margin, printable_scale, bonus_shrink_factor, Canvas)
                 writer.add_page(new_page)
                 i = 0
-                front_page = not front_page 
-
+                is_front_page = not is_front_page
+    for file in opened_files:
+        file.close()
     with open(args.output_file, 'wb') as output_file:
         writer.write(output_file)
-    if args.second:
-        file2.close()
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except HandledException as e:
+        handled_error_exit(e)