home · contact · privacy
Bookmaker: improve error handling.
[misc] / bookmaker.py
index d9bf685c0967a492d941b15e25bcd9af6a720751..8d0f0c4e7a0a7e467ae41f3c63c81b00d9d2e04f 100755 (executable)
 #!/usr/bin/env python3
-import pypdf
 import argparse
 import io
-from reportlab.lib.pagesizes import A4
+import os
+def fail_with_msg(msg):
+    print("ERROR:", msg)
+    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.")
+
 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 = """
+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 from on 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 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:
+    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:
+    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).
+
+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 |   | 3 | 2 |
+|-------|   |-------|
+| 8 | 5 |   | 7 | 6 |
++-------+   +-------+
+
+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.
 
-parser = argparse.ArgumentParser(description="build print-ready book PDF")
+(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=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., '3-end'")
-parser.add_argument("-c", "--crop_range", 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("-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("-r", "--rotate", dest="rotate", type=int, action="append", help="rotate page of number by 90° (usable multiple times on same page!)")
-parser.add_argument("-n", "--nup4", action='store_true', help="puts 4 input pages onto 1 output page")
+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", "--margin", type=float, default=0.43, help="print margin for --nup4 in cm (default 0.43)")
+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
+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:
+        fail_with_msg("%s: page range string lacks '-': %s" % (err_msg_prefix, p_string))
+    tokens = p_string.split("-")
+    if len(tokens) > 2:
+        fail_with_msg("%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:
+            fail_with_msg("%s: page range string carries values that are neither integer, nor 'start', nor 'end': %s" % (err_msg_prefix, p_string))
+        if int(token) < 1:
+            fail_with_msg("%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:
+        fail_with_msg("%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):
+        fail_with_msg("-i: %s is not a file" % filename)
+    try:
+        with open(filename, 'rb') as file:
+            pypdf.PdfReader(file)
+    except pypdf.errors.PdfStreamError:
+        fail_with_msg("-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):
+        fail_with_msg("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:
+            fail_with_msg("-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:
+            fail_with_msg("-c: cropping should contain three ',': %s" % c_string)
+        for crop in crops:
+            try:
+                float(crop)
+            except:
+                fail_with_msg("-c: non-number crop in %s" % c_string)
+if args.rotate_page:
+    for r in args.rotate_page:
+        try:
+            int(r)
+        except:
+            fail_with_msg("-r: non-integer value: %s" % r)
+        if r < 1:
+            fail_with_msg("-r: value must not be <1: %s" % r)
+try:
+    float(args.print_margin)
+except:
+    fail_with_msg("-m: non-float value: %s" % arg.print_margin)
 
 # select pages from input files
 def parse_page_range(range_string, pages):
@@ -34,6 +177,7 @@ 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
@@ -45,12 +189,36 @@ for i, input_file in enumerate(args.input_file):
     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
+        fail_with_msg("-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):
+                 fail_with_msg("-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):
+             fail_with_msg("-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
@@ -60,15 +228,6 @@ if args.nup4:
             new_page = pypdf.PageObject.create_blank_page(width=a4_width, height=a4_height)
             pages_to_add += [new_page]
 
-# rotate page canvas
-if args.rotate:
-    for rotate in args.rotate:
-        page = pages_to_add[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))
-        print("-r: rotating (by 90°) page", rotate)
-
 # normalize all pages to portrait A4
 for page in pages_to_add:
     if "/Rotate" in page:
@@ -82,43 +241,42 @@ for page in pages_to_add:
 # 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.crop_range:
-  for crop_range in args.crop_range:
-      initial_split = crop_range.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):
-          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)
-      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
+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):
+            fail_with_msg("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:
@@ -140,11 +298,11 @@ if not args.nup4:
 
 else:
     print("-n: building 4-input-pages-per-output-page book")
-    print("-m: applying printable-area margin of %.2fcm" % args.margin)
+    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.margin * points_per_cm
+    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