From: Christian Heller Date: Tue, 26 Sep 2023 13:51:03 +0000 (+0200) Subject: Initial commit of code previously stored in the "misc" repo. X-Git-Url: https://plomlompom.com/repos/%7B%7Bprefix%7D%7D/process?a=commitdiff_plain;h=635014d823a28b9dec2f38c2d992ed8888c67b7a;p=bookmaker Initial commit of code previously stored in the "misc" repo. --- 635014d823a28b9dec2f38c2d992ed8888c67b7a diff --git a/bookmaker.py b/bookmaker.py new file mode 100755 index 0000000..431587e --- /dev/null +++ b/bookmaker.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +""" +bookmaker.py is a helper for optimizing PDFs 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 = """ +OVERVIEW OF TARGET USAGE: + +By cropping with -c and studying the results, define the areas of the input PDF's pages you want visible. Then, with--nup4, map those areas onto 4 input pages per 1 output page, arranged in such a way that double-sided print-out of those output pages can be cut, folded, and bound (helped by addition of stencils for small incisions to carry rubber bands or the like) into a small A6 book. Each unit of 8 pages from the input PDF is mapped by --nup4 onto two pages representing two sides of a (no-tumble-duplex-printed) A4 paper: + + +-------=-------+ __________________ + (front) (back) | 4 | 1 = 2 | 3 | 4 /=|===|============ ++-------=-------+ ==> +-------=-------+ ===> _/|\_ v >=|===|============ +| 4 | 1 = 2 | 3 | / | \_ \=|===|============ +|-------=-------| +-------=-------+ 1-> | 2 | 3 | | \ / <- cut out! +| 8 | 5 = 6 | 7 | ==> | 8 | 5 = 6 | 7 | | _/ \_ | | \ | ++-------=-------+ +-------=-------+ |/ \| | \| (p. 5) + +To turn this paper into a small 8-pages book, first cut it into two A5 papers along its horizontal middle. Fold both A5's by their vertical middles, with pages 2-3 and 7-6 on the folds' insides. You now have two 4-page A6 "books" of pages 1-4 and pages 5-8. Fold both closed and (counter-intuitively) stack the second one on top of the first one (creating a temporary page order of 5,6,7,8,1,2,3,4). This reveals a small stencil on the top left of page 5 – cut it out, with all other pages folded and aligned under it, creating a small notch in the upper "inner" corner of all pages. Turn around the stack to find a mirror stencil on the bottom and repeat the cutting. Each page now has cuts on top and bottom of its inner margins into which a rubber band can be hooked, or through which a string may be looped and tied, to bind the page's inner margins into a kind of book spine. You may now swap the order of the 4-page books back into a proper final page order (of 1,2,3,4,5,6,7,8) and repeat the whole process for each further --nup4 output paper. + +COMMAND 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 only 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" --symmetry + +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 quarters, spine margins: + bookmaker.py -i INPUT.pdf -o OUTPUT.pdf -n --analyze + +FURTHER 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). +""" +import argparse +import io +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 + + +def parse_page_range(range_string, pages): + start_page = 0 + end_page = len(pages) + if range_string: + start, end = range_string.split('-') + if not (len(start) == 0 or start == "start"): + start_page = int(start) - 1 + if not (len(end) == 0 or end == "end"): + end_page = int(end) + return start_page, end_page + + +def read_inputs_to_pagelist(args_input_file, args_page_range): + pages_to_add = [] + opened_files = [] + new_page_num = 0 + for i, input_file in enumerate(args_input_file): + 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(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}") + + +def pad_pages_to_multiple_of_8(pages_to_add): + mod_to_8 = len(pages_to_add) % 8 + if mod_to_8 > 0: + old_len = len(pages_to_add) + for _ in range(8 - mod_to_8): + new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT) + pages_to_add += [new_page] + print(f"-n: number of input pages {old_len} not required multiple of 8, padded to {len(pages_to_add)}") + + +def normalize_pages_to_A4(pages_to_add): + for page in pages_to_add: + if "/Rotate" in page: # 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.cropbox = page.mediabox + + +def collect_per_page_crops_and_zooms(args_crops, args_symmetry, pages_to_add): + crop_at_page = [PageCrop()] * len(pages_to_add) + if args_crops: + for c_string in args_crops: + page_range, crops = split_crops_string(c_string) + start_page, end_page = parse_page_range(page_range, pages_to_add) + prefix = "-c, -t" if args_symmetry else "-c" + suffix = " (but alternating left and right crop between even and odd pages)" if args_symmetry else "" + page_crop = PageCrop(*[x for x in crops.split(',')]) + print(f"{prefix}: to pages {start_page + 1} to {end_page} applying crop: {page_crop.format_in_cm}{suffix}") + for page_num in range(start_page, end_page): + if args_symmetry and page_num % 2: + crop_at_page[page_num] = page_crop.give_mirror() + else: + crop_at_page[page_num] = page_crop + return crop_at_page + + +def build_single_pages_output(writer, pages_to_add, crop_at_page): + print("building 1-input-page-per-output-page book") + odd_page = True + for i, page in enumerate(pages_to_add): + page.add_transformation(pypdf.Transformation().translate(tx=-crop_at_page[i].left, ty=-crop_at_page[i].bottom)) + page.add_transformation(pypdf.Transformation().scale(crop_at_page[i].zoom, crop_at_page[i].zoom)) + page.mediabox.right = crop_at_page[i].remaining_width * crop_at_page[i].zoom + page.mediabox.top = crop_at_page[i].remaining_height * crop_at_page[i].zoom + writer.add_page(page) + odd_page = not odd_page + print(f"built page number {i+1} (of {len(pages_to_add)})") + + +def build_nup4_output(writer, pages_to_add, crop_at_page, args_print_margin, args_analyze, canvas_class): + print("-n: building 4-input-pages-per-output-page book") + print(f"-m: applying printable-area margin of {args_print_margin}cm") + if args_analyze: + print("-a: drawing page borders, spine limits") + nup4_geometry = Nup4Geometry(args_print_margin) + pages_to_add, new_i_order = resort_pages_for_nup4(pages_to_add) + nup4_i = 0 + page_count = 0 + is_front_page = True + for i, page in enumerate(pages_to_add): + if nup4_i == 0: + new_page = pypdf.PageObject.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT) + corrected_i = new_i_order[i] + nup4_inner_page_transform(page, crop_at_page[corrected_i], nup4_geometry, nup4_i) + nup4_outer_page_transform(page, nup4_geometry, nup4_i) + new_page.merge_page(page) + page_count += 1 + print(f"merged page number {page_count} (of {len(pages_to_add)})") + nup4_i += 1 + if nup4_i > 3: + ornate_nup4(writer, args_analyze, is_front_page, new_page, nup4_geometry, canvas_class) + writer.add_page(new_page) + nup4_i = 0 + is_front_page = not is_front_page + + +def resort_pages_for_nup4(pages_to_add): + new_page_order = [] + new_i_order = [] + eight_pack = [] + i = 0 + n_eights = 0 + for page in pages_to_add: + if i == 0: + eight_pack = [] + eight_pack += [page] + i += 1 + if i == 8: + i = 0 + for n in PAGE_ORDER_FOR_NUP4: + new_i_order += [8 * n_eights + n] + new_page_order += [eight_pack[n]] + n_eights += 1 + return new_page_order, new_i_order + + +def nup4_inner_page_transform(page, crop, nup4_geometry, nup4_i): + page.add_transformation(pypdf.Transformation().translate(ty=(A4_HEIGHT / crop.zoom - (A4_HEIGHT - crop.top)))) + if nup4_i == 0 or nup4_i == 2: + page.add_transformation(pypdf.Transformation().translate(tx=-crop.left)) + elif nup4_i == 1 or nup4_i == 3: + page.add_transformation(pypdf.Transformation().translate(tx=(A4_WIDTH / crop.zoom - (A4_WIDTH - crop.right)))) + page.add_transformation(pypdf.Transformation().scale(crop.zoom * nup4_geometry.shrink_for_spine, crop.zoom * nup4_geometry.shrink_for_spine)) + if nup4_i == 2 or nup4_i == 3: + page.add_transformation(pypdf.Transformation().translate(ty=-2*nup4_geometry.margin/nup4_geometry.shrink_for_margin)) + + +def nup4_outer_page_transform(page, nup4_geometry, nup4_i): + page.add_transformation(pypdf.Transformation().translate(ty=(1-nup4_geometry.shrink_for_spine)*A4_HEIGHT)) + if nup4_i == 0 or nup4_i == 1: + y_section = A4_HEIGHT + page.mediabox.bottom = A4_HALF_HEIGHT + page.mediabox.top = A4_HEIGHT + if nup4_i == 2 or nup4_i == 3: + y_section = 0 + page.mediabox.bottom = 0 + page.mediabox.top = A4_HALF_HEIGHT + if nup4_i == 0 or nup4_i == 2: + x_section = 0 + page.mediabox.left = 0 + page.mediabox.right = A4_HALF_WIDTH + if nup4_i == 1 or nup4_i == 3: + page.add_transformation(pypdf.Transformation().translate(tx=(1-nup4_geometry.shrink_for_spine)*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, nup4_geometry, 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 = nup4_geometry.margin + printable_offset_y = nup4_geometry.margin * A4_HEIGHT / A4_WIDTH + new_page.add_transformation(pypdf.Transformation().scale(nup4_geometry.shrink_for_margin, nup4_geometry.shrink_for_margin)) + new_page.add_transformation(pypdf.Transformation().translate(tx=printable_offset_x, ty=printable_offset_y)) + x_left_spine_limit = A4_HALF_WIDTH * nup4_geometry.shrink_for_spine + 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) + crop_at_page = collect_per_page_crops_and_zooms(args.crops, args.symmetry, pages_to_add) + writer = pypdf.PdfWriter() + if args.nup4: + build_nup4_output(writer, pages_to_add, crop_at_page, args.print_margin, args.analyze, Canvas) + else: + build_single_pages_output(writer, pages_to_add, crop_at_page) + for file in opened_files: + file.close() + with open(args.output_file, 'wb') as output_file: + writer.write(output_file) + + +if __name__ == "__main__": + try: + main() + except HandledException as e: + handled_error_exit(e)