home · contact · privacy
Minor language improvements.
[misc] / bookmaker.py
index 646877a7a15c8cd71a2f02ce134def7dac237a2a..d23c20da85b8e25bfcdbcf914a6c5b40881e08bc 100755 (executable)
@@ -1,19 +1,31 @@
 #!/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.
+"""
 import argparse
 import io
 import os
-from reportlab.lib.pagesizes import A4
-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
-
-desc = """bookmaker.py is a helper for optimizing PDFs of books for the production of small self-printed, self-bound physical books  To this goal it offers various PDF manipulation options potentially that can also be used indepéndently and for other purposes.
-"""
-epilogue = """
+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:
 
 Concatenate two PDFs A.pdf and B.pdf to COMBINED.pdf:
@@ -69,21 +81,7 @@ To facilitate this layout, --nup4 also pads the input PDF pages to a total numbe
 (To turn this 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 it 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.)
 """
 
-# parser = argparse.ArgumentParser(description="build print-ready book PDF")
-parser = argparse.ArgumentParser(description=desc, epilog=epilogue, formatter_class=argparse.RawDescriptionHelpFormatter)
-parser._optionals.title = "OPTIONS"
-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)")
-args = parser.parse_args()
-
-# some basic input validation
+# 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:
@@ -113,51 +111,17 @@ def validate_page_range(p_string, err_msg_prefix):
         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))
-for filename in args.input_file:
-    if not os.path.isfile(filename):
-        raise ValueError("-i: %s is not a file" % filename)
-    try:
-        with open(filename, 'rb') as file:
-            pypdf.PdfReader(file)
-    except pypdf.errors.PdfStreamError:
-        raise ValueError("-i: cannot interpret %s as PDF file" % filename)
-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")
-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)
-        if len(initial_split) > 1:
-            validate_page_range(initial_split[0], "-c")
-            crops = initial_split[1].split(",")
-
-        else:
-            crops = initial_split[0].split(",")
-        if len(crops) != 4:
-            raise ValueError("-c: cropping should contain three ',': %s" % c_string)
-        for crop in crops:
-            try:
-                float(crop)
-            except:
-                raise ValueError("-c: non-number crop in %s" % c_string)
-if args.rotate_page:
-    for r in args.rotate_page:
-        try:
-            int(r)
-        except:
-            raise ValueError("-r: non-integer value: %s" % r)
-        if r < 1:
-            raise ValueError("-r: value must not be <1: %s" % r)
-try:
-    float(args.print_margin)
-except:
-    raise ValueError("-m: non-float value: %s" % arg.print_margin)
 
-# 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)
@@ -168,270 +132,334 @@ def parse_page_range(range_string, pages):
         if not (len(end) == 0 or end == "end"):
             end_page = int(end)
     return start_page, end_page
-pages_to_add = []
-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 ValueError("-p: page range goes beyond pages of input file: %s" % 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))
-
-# we can do some more input validations now that we know how many pages output should have
-if args.crops:
-    for c_string in args.crops:
-        initial_split = c_string.split(':')
-        if len(initial_split) > 1:
-            start, end = parse_page_range(initial_split[0], pages_to_add)
-            if end > len(pages_to_add):
-                 raise ValueError("-c: page range goes beyond number of pages we're building: %s" % initial_split[0])
-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)
-
-# rotate page canvas
-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)
-
-# 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
-for page in pages_to_add:
-    if "/Rotate" in page:
-        page.rotate(360 - page["/Rotate"])
-    page.mediabox.left = 0
-    page.mediabox.bottom = 0
-    page.mediabox.top = a4_height
-    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 crops in args.crops:
-        initial_split = crops.split(':')
-        if len(initial_split) > 1:
-            page_range = initial_split[0]
-            crops = initial_split[1]
-        else:
-            page_range = None
-            crops = initial_split[0]
-        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)
-        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
-
-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)))
-
-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
+
+def parse_args():
+    parser = argparse.ArgumentParser(description=__doc__, epilog=help_epilogue, formatter_class=argparse.RawDescriptionHelpFormatter)
+    parser._optionals.title = "OPTIONS"
+    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)")
+    args = parser.parse_args()
+
+    # some basic input validation
+    for filename in args.input_file:
+        if not os.path.isfile(filename):
+            raise ValueError("-i: %s is not a file" % filename)
+        try:
+            with open(filename, 'rb') as file:
+                pypdf.PdfReader(file)
+        except pypdf.errors.PdfStreamError:
+            raise ValueError("-i: cannot interpret %s as PDF file" % filename)
+    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")
+    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)
+            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)
+            for crop in crops:
+                try:
+                    float(crop)
+                except:
+                    raise ValueError("-c: non-number crop in %s" % c_string)
+    if args.rotate_page:
+        for r in args.rotate_page:
+            try:
+                int(r)
+            except:
+                raise ValueError("-r: non-integer value: %s" % r)
+            if r < 1:
+                raise ValueError("-r: value must not be <1: %s" % r)
+    try:
+        float(args.print_margin)
+    except:
+        raise ValueError("-m: non-float value: %s" % arg.print_margin)
+
+    return args
+
+def main():
+    args = parse_args()
+
+    # select pages from input files
+    pages_to_add = []
+    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 ValueError("-p: page range goes beyond pages of input file: %s" % 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))
+
+    # we can do some more input validations now that we know how many pages output should have
+    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)
+    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)
+
+    # rotate page canvas (as opposed to using PDF's /Rotate command)
+    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)
+
+    # 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
     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)
-
-                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()
-                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
-for file in opened_files:
-    file.close()
-with open(args.output_file, 'wb') as output_file:
-    writer.write(output_file)
+        if "/Rotate" in page:
+            page.rotate(360 - page["/Rotate"])
+        page.mediabox.left = 0
+        page.mediabox.bottom = 0
+        page.mediabox.top = A4_HEIGHT
+        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:
+            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)
+            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
+
+    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)))
+
+    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
+    for file in opened_files:
+        file.close()
+    with open(args.output_file, 'wb') as output_file:
+        writer.write(output_file)
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except ValueError as e:
+        fail_with_msg(e)