-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
-
-parser = argparse.ArgumentParser(description="build print-ready book PDF")
-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("-t", "--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("-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("-s", "--spine", type=float, default=1, help="on --nup4, cm width of margin hidden in spine (default 1)")
-args = parser.parse_args()
-
-
-# select pages from input files
+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
+INNER_SPINE_MARGIN_PER_PAGE = 1 * POINTS_PER_CM
+QUARTER_SCALE_FACTOR = 0.5
+PAGE_ORDER_FOR_NUP4 = (3,0,7,4,1,2,5,6)
+
+
+class PageCrop:
+
+ def __init__(self, left_cm=0, bottom_cm=0, right_cm=0, top_cm=0):
+ self.left_cm = left_cm
+ self.bottom_cm = bottom_cm
+ self.right_cm = right_cm
+ self.top_cm = top_cm
+ self.left = float(self.left_cm) * POINTS_PER_CM
+ self.bottom = float(self.bottom_cm) * POINTS_PER_CM
+ self.right = float(self.right_cm) * POINTS_PER_CM
+ self.top = float(self.top_cm) * POINTS_PER_CM
+ zoom_horizontal = A4_WIDTH / (A4_WIDTH - self.left - self.right)
+ zoom_vertical = A4_HEIGHT / (A4_HEIGHT - self.bottom - self.top)
+ if (zoom_horizontal > 1 and zoom_vertical < 1) or (zoom_horizontal < 1 and zoom_vertical > 1):
+ raise HandledException("-c: crops would create opposing zoom directions")
+ elif zoom_horizontal + zoom_vertical > 2:
+ self.zoom = min(zoom_horizontal, zoom_vertical)
+ else:
+ self.zoom = max(zoom_horizontal, zoom_vertical)
+
+ def __str__(self):
+ return str(vars(self))
+
+ @property
+ def format_in_cm(self):
+ return f"left {self.left_cm}cm, bottom {self.bottom_cm}cm, right {self.right_cm}cm, top {self.top_cm}cm"
+
+ @property
+ def remaining_width(self):
+ return A4_WIDTH - self.left - self.right
+
+ @property
+ def remaining_height(self):
+ return A4_HEIGHT - self.bottom - self.top
+
+ def give_mirror(self):
+ return PageCrop(left_cm=self.right_cm, bottom_cm=self.bottom_cm, right_cm=self.left_cm, top_cm=self.top_cm)
+
+
+class Nup4Geometry:
+
+ def __init__(self, margin_cm):
+ self.margin = margin_cm * POINTS_PER_CM
+ self.shrink_for_margin = (A4_WIDTH - 2 * self.margin)/A4_WIDTH
+ # NB: We define spine size un-shrunk, but .shrink_for_spine is used with values shrunk for the margin, which we undo here.
+ spine_part_of_page = (INNER_SPINE_MARGIN_PER_PAGE / A4_HALF_WIDTH) / self.shrink_for_margin
+ self.shrink_for_spine = 1 - spine_part_of_page
+
+
+class HandledException(Exception):
+ pass
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description=__doc__, epilog=help_epilogue, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("-i", "--input_file", action="append", required=True, help="input PDF file")
+ 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:
+ 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
+
+