From: Plom Heller Date: Tue, 29 Sep 2026 12:28:23 +0000 (+0200) Subject: Structure like my other installables. X-Git-Url: https://plomlompom.com/repos/booking/%7B%7B%20web_path%20%7D%7D/static/%7B%7Bprefix%7D%7D/ledger2?a=commitdiff_plain;p=bookmaker Structure like my other installables. --- diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..42cf7f3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "plomlib"] + path = plomlib + url = https://plomlompom.com/repos/clone/plomlib diff --git a/bookmaker.py b/bookmaker.py deleted file mode 100755 index e43d754..0000000 --- a/bookmaker.py +++ /dev/null @@ -1,804 +0,0 @@ -#!/usr/bin/env python3 -""" -bookmaker.py helps optimizing PDFs for the production of small self-printed, -self-bound physical books; towards this goal offers various PDF manipulation -options that may also be used indepéndently and for other purposes. -""" -from argparse import ( - ArgumentParser, - Namespace as ArgsNamespace, - RawDescriptionHelpFormatter - ) -from io import BufferedReader, BytesIO -from os.path import isfile -from sys import exit as sys_exit -from typing import Optional, Self - - -def handled_error_exit( - msg: str - ) -> None: - 'Print msg, then exit(1).' - print(f'ERROR: {msg}') - sys_exit(1) - - -try: - from pypdf import ( - PageObject as PdfPage, - PdfReader, - PdfWriter, - Transformation as PdfTransformation - ) - from pypdf.errors import PdfStreamError -except ImportError: - handled_error_exit('Can\'t run at all without pypdf installed.') -try: - from reportlab.pdfgen.canvas import Canvas - GOT_CANVAS = True -except ImportError: - GOT_CANVAS = False - -# 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 -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) - -# PDF specifics -ROTATE_COMMAND = '/Rotate' - - -class Page: - 'Fuses PdfPage, PageCrop, and PdfTransformation.' - - def __init__( - self, - pypdf_page: PdfPage - ) -> None: - self._pypdf = pypdf_page - self.set_box() - self.crop = PageCrop() - - @property - def box( - self - ) -> dict[str, float]: - 'Return dict of mediabox measures.' - return {direction: getattr(self._pypdf.mediabox, direction) - for direction in ('left', 'bottom', 'right', 'top')} - - def set_box( - self, - left: Optional[float] = None, - bottom: Optional[float] = None, - right: Optional[float] = None, - top: Optional[float] = None - ) -> None: - 'Set mediabox (and cropbox) measures.' - if left is not None: - self._pypdf.mediabox.left = left - if bottom is not None: - self._pypdf.mediabox.bottom = bottom - if right is not None: - self._pypdf.mediabox.right = right - if top is not None: - self._pypdf.mediabox.top = top - self._pypdf.cropbox = self._pypdf.mediabox - - @classmethod - def new_blank( - cls - ) -> Self: - 'Make blank page of A4 measures.' - return cls(PdfPage.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)) - - def add_to_writer( - self, - writer: PdfWriter - ) -> None: - 'Add self to writer.' - writer.add_page(self._pypdf) - - def merge_page( - self, - page: Self - ) -> None: - 'Merge page .to self.' - self._pypdf.merge_page(page._pypdf) - - def rotation( - self - ) -> Optional[int]: - 'Return PDF /Rotate instruction.' - return self._pypdf.get(ROTATE_COMMAND, None) - - def rotate(self, - degrees: int - ) -> None: - 'Wrap PdfTransformation.rotate.' - self._pypdf.add_transformation(PdfTransformation().rotate(degrees)) - - def translate( - self, - tx: float = 0.0, - ty: float = 0.0 - ) -> None: - 'Wrap PdfTransformation.translate.' - self._pypdf.add_transformation(PdfTransformation().translate(tx, ty)) - - def scale( - self, - zoom: float - ) -> None: - 'Wrap PdfTransformation.scale.' - self._pypdf.add_transformation(PdfTransformation().scale(zoom, zoom)) - - def draw_lines( - self, - lines: tuple[tuple[float, float, float, float], ...], - rgb_color: tuple[float, float, float] = (0.0, 0.0, 0.0), - line_width: float = 2.0, - ) -> None: - 'Draw lines.' - min_x, min_y, max_x, max_y = self._pypdf.mediabox - packet = BytesIO() - c = Canvas(packet, pagesize=(max_x - min_x, max_y - min_y)) - c.setLineWidth(line_width) - c.setStrokeColorRGB(*rgb_color) - for line in lines: - c.line(line[0], line[1], line[2], line[3]) - c.save() - self._pypdf.merge_page(PdfReader(packet).pages[0]) - - def draw_orth_lines( - self, - lines: tuple[tuple[bool, float], ...], - rgb_color: tuple[float, float, float] = (0.0, 0.0, 0.0), - line_width: float = 2.0, - ) -> None: - 'Draw horizontal or vertical lines.' - min_x, min_y, max_x, max_y = self._pypdf.mediabox - full_lines: list[tuple[float, float, float, float]] = [] - for is_horizontal, position in lines: - if is_horizontal: - x0, x1 = min_x, max_x - y0 = y1 = position - else: - y0, y1 = min_y, max_y - x0 = x1 = position - full_lines += [(x0, y0, x1, y1)] - self.draw_lines(tuple(full_lines), rgb_color, line_width) - - def draw_borders( - self, - rgb_color: tuple[float, float, float] = (0.0, 0.0, 0.0), - line_width: float = 4.0, - ) -> None: - 'Draw border lines around mediabox.' - min_x, min_y, max_x, max_y = self._pypdf.mediabox - self.draw_orth_lines( - ((False, min_x), (True, min_y), (False, max_x), (True, max_y)), - rgb_color, - line_width) - - def nup4_transforms( - self, - nup4_geometry: 'Nup4Geometry', - nup4_i: int - ) -> None: - 'Perform inner, outer page transforms to position in nup4_geometry.' - # Apply crop instructions adequate to position in nup4 geometry. - self.translate( - tx=((-self.crop.left) if nup4_i in {0, 2} - else # in {1, 3} - A4_WIDTH / self.crop.zoom - (A4_WIDTH - self.crop.right)), - ty=A4_HEIGHT / self.crop.zoom - (A4_HEIGHT - self.crop.top)) - self.scale(self.crop.zoom * nup4_geometry.shrink_for_spine) - if nup4_i in {2, 3}: - self.translate( - ty=-2 * nup4_geometry.margin / nup4_geometry.shrink_for_margin) - # Shrink and position into nup4_geometry as per its position nup4_i. - self.translate(ty=(1 - nup4_geometry.shrink_for_spine) * A4_HEIGHT) - if nup4_i in {0, 1}: - y_section = A4_HEIGHT - self.set_box(bottom=A4_HEIGHT/2, top=A4_HEIGHT) - else: # nup4_in in {2, 3} - y_section = 0 - self.set_box(bottom=0, top=A4_HEIGHT/2) - x_section: float - if nup4_i in {0, 2}: - x_section = 0 - self.set_box(left=0, right=A4_WIDTH/2) - else: # nup4_in in {1, 3} - self.translate(tx=(1 - nup4_geometry.shrink_for_spine) * A4_WIDTH) - x_section = A4_WIDTH - self.set_box(left=A4_WIDTH/2, right=A4_WIDTH) - self.translate(tx=x_section, ty=y_section) - self.scale(QUARTER_SCALE_FACTOR) - - def ornate_nup4( - self, - is_front_page: bool, - nup4_geometry: 'Nup4Geometry', - analyzing: bool - ) -> None: - 'Apply nup4 line guides.' - - def draw_book_binding_cut_guide( - x_spine_limit: float, - direction: int - ) -> None: - directed_half_width = 0.5 * CUT_WIDTH * direction - outer_start_x = x_spine_limit - directed_half_width - inner_start_x = x_spine_limit + directed_half_width - middle_point_y = A4_HEIGHT/2 + MIDDLE_POINT_DEPTH * direction - end_point_y = A4_HEIGHT/2 + CUT_DEPTH * direction - self.draw_lines(((inner_start_x, A4_HEIGHT/2, - x_spine_limit, end_point_y), - (x_spine_limit, end_point_y, - x_spine_limit, middle_point_y), - (x_spine_limit, middle_point_y, - outer_start_x, A4_HEIGHT/2)), - line_width=0.2) - - if analyzing: - self.draw_borders((0.5, 1.0, 1.0)) - self.draw_orth_lines(((True, self.box['top']/2), - (False, self.box['right']/2)), - line_width=0.1) - printable_offset_x = nup4_geometry.margin - printable_offset_y = nup4_geometry.margin * A4_HEIGHT / A4_WIDTH - self.scale(nup4_geometry.shrink_for_margin) - self.translate(tx=printable_offset_x, ty=printable_offset_y) - if not (analyzing or is_front_page): - return - x_left_spine_limit = A4_WIDTH/2 * nup4_geometry.shrink_for_spine - x_right_spine_limit = A4_WIDTH - x_left_spine_limit - if analyzing: - self.draw_orth_lines(((False, x_left_spine_limit,), - (False, x_right_spine_limit)), - line_width=0.1, - rgb_color=(1.0, 0.5, 1.0)) - if is_front_page: - draw_book_binding_cut_guide(x_left_spine_limit, 1) - draw_book_binding_cut_guide(x_right_spine_limit, -1) - - -class PageCrop: - 'Per-page crop instructions as sizes in point and cm, and A4-zoom factor.' - - def __init__( - self, - left_cm: float = 0, - bottom_cm: float = 0, - right_cm: float = 0, - top_cm: float = 0 - ) -> None: - self.left_cm = left_cm - self.bottom_cm = bottom_cm - self.right_cm = right_cm - self.top_cm = top_cm - self._calc_measures() - - def _calc_measures(self) -> None: - 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 > zoom_vertical\ - or zoom_horizontal < 1 < zoom_vertical: - raise ArgFail('c', 'crops would create opposing zoom directions') - if zoom_horizontal + zoom_vertical > 2: - self.zoom = min(zoom_horizontal, zoom_vertical) - else: - self.zoom = max(zoom_horizontal, zoom_vertical) - - def __str__( - self - ) -> str: - return str(vars(self)) - - @property - def format_in_cm( - self - ) -> str: - 'Human-readable listing of crops in cm.' - return (f'left {self.left_cm}cm, bottom {self.bottom_cm}cm, ' - f'right {self.right_cm}cm, top {self.top_cm}cm') - - @property - def remaining_width( - self - ) -> float: - "What's left of A4_WIDTH after applying width croppings." - return A4_WIDTH - self.left - self.right - - @property - def remaining_height( - self - ) -> float: - "What's left of A4_WIDTH after applying height croppings." - return A4_HEIGHT - self.bottom - self.top - - def add( - self, - to_add: Self, - mirror=False - ) -> None: - 'Add other crop to own measures, mirror left and right if demanded.' - self.left_cm += to_add.right_cm if mirror else to_add.left_cm - self.bottom_cm += to_add.bottom_cm - self.right_cm += to_add.left_cm if mirror else to_add.right_cm - self.top_cm += to_add.top_cm - self._calc_measures() - - -class Nup4Geometry: - 'Nup4-specific attributes, i.e. outer-page margins, spine sizes.' - - def __init__( - self, - margin_cm: int - ) -> None: - 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_WIDTH/2)) - / self.shrink_for_margin) - self.shrink_for_spine = 1 - spine_part_of_page - - -class ArgFail(Exception): - 'Collects relevant command character, followed by explanation.' - - def __init__( - self, - arg_char: str, - msg: str, - *args, - **kwargs - ) -> None: - super().__init__(*args, **kwargs) - self.arg_char = arg_char - self.msg = msg - - -def parse_args( - ) -> ArgsNamespace: - 'Collect command line arguments.' - help_epilogue = ('See README.txt for detailed usage instructions, ' - 'command examples, etc.') - parser = ArgumentParser( - description=__doc__, - epilog=help_epilogue, - formatter_class=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('-k', - '--keep_mediabox', - action='store_true', - help='keep non-A4 page shapes') - 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_args_syntax( - args: ArgsNamespace - ) -> None: - 'Check command args against general syntax expectations.' - - def validate_page_range( - pr_string: str, - arg_char: str - ) -> None: - prefix = 'page range string' - if '-' not in pr_string: - raise ArgFail(arg_char, f'{prefix} lacks "-": {pr_string}') - tokens = pr_string.split('-') - if len(tokens) > 2: - raise ArgFail(arg_char, f'{prefix} has too many "-": {pr_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 as e: - raise ArgFail(arg_char, - f'{prefix} carries value neither integer, ' - f'nor "start", nor "end": {pr_string}') from e - if int(token) < 1: - raise ArgFail(arg_char, - f'{prefix} carries page number <1: {pr_string}') - start = -1 - end = -1 - try: - start = int(tokens[0]) - end = int(tokens[1]) - except ValueError: - pass - if start > end > 0: - raise ArgFail( - arg_char, - f'{prefix} has higher start than end value: {pr_string}') - - 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 ArgFail( - '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 ArgFail('c', - f'cropping string has multiple ":": {c_string}') - page_range, crops_str = split_crops_string(c_string) - crops = crops_str.split(',') - if page_range: - validate_page_range(page_range, 'c') - if len(crops) != 4: - raise ArgFail( - 'c', - f'cropping does not contain exactly three ",": {c_string}') - for crop in crops: - try: - float(crop) - except ValueError as e: - raise ArgFail('c', - f'non-number crop in: {c_string}') from e - if args.rotate_page: - for r in args.rotate_page: - try: - int(r) - except ValueError as e: - raise ArgFail('r', f'non-integer value: {r}') from e - if r < 1: - raise ArgFail('r', 'value must not be <1: {r}') - try: - float(args.print_margin) - except ValueError as e: - raise ArgFail('m', f'non-float value: {args.print_margin}') from e - - -def split_crops_string( - c_string: str - ) -> tuple[Optional[str], str]: - 'If c_string contains ":" return before and after, else None and c_string.' - initial_split = c_string.split(':', maxsplit=1) - 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: Optional[str], - idx_after: int - ) -> tuple[int, int]: - 'Based on actual pages size read range_string into range limit indices.' - idx_start = 0 - if range_string: - start, end = range_string.split('-') - if not (len(start) == 0 or start == 'start'): - idx_start = int(start) - 1 - if not (len(end) == 0 or end == 'end'): - idx_after = int(end) - return idx_start, idx_after - - -def args_to_pagelist( - args_input_file: list[str], - args_page_range: list[str], - arg_analyze: bool - ) -> tuple[list[Page], list[BufferedReader]]: - 'Follow args_input_file ranged by args_page_range into pages, open files.' - pages = [] - opened_files = [] - new_page_num = 0 - for i, filename in enumerate(args_input_file): - if not isfile(filename): - raise ArgFail('i', f'{filename} is not a file') - file = open(filename, 'rb') - opened_files += [file] - try: - reader = PdfReader(file) - except PdfStreamError as e: - for to_close in opened_files: - to_close.close() - raise ArgFail('i', - f'cannot interpret {filename} as PDF file') from e - range_string = None - if args_page_range and len(args_page_range) > i: - range_string = args_page_range[i] - for old_page_num in range(*parse_page_range(range_string, - len(reader.pages))): - new_page_num += 1 - page = (Page.new_blank() if old_page_num >= len(reader.pages) - else Page(reader.pages[old_page_num])) - pages += [page] - if arg_analyze: - page.draw_borders((0.75, 0.0, 0.0)) - print(f'-i, -p: read in {filename} page number {old_page_num+1} ' - f'as new page {new_page_num}') - return pages, opened_files - - -def validate_ranges( - args: ArgsNamespace, - len_pages: int - ) -> None: - 'Check command args\' ranges fits into len_pages.' - if args.crops: - for c_string in args.crops: - if (page_range := split_crops_string(c_string)[0])\ - and parse_page_range(page_range, len_pages)[1] > len_pages: - raise ArgFail('c', - 'page range goes beyond number of pages ' - f'we\'re building: {page_range}') - if args.rotate_page: - for r in [r for r in args.rotate_page if r > len_pages]: - raise ArgFail('r', - 'page number beyond number of ' - f'pages we\'re building: {r}') - - -def rotate_pages( - pages: list[Page], - args_rotate_page: Optional[list[int]], - arg_analyze: bool - ) -> None: - 'For pages page numbered in args_rotate_page, rotate by 90°.' - if args_rotate_page: - for rotate_page in args_rotate_page: - page = pages[rotate_page - 1] - page.translate(tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2) - page.rotate(-90) - page.translate(tx=A4_WIDTH/2, ty=A4_HEIGHT/2) - if arg_analyze: - page.draw_borders((0.75, 0.0, 0.0)) - print(f'-r: rotating (by 90°) page {rotate_page}') - - -def pad_pages_to_multiple_of_8( - pages: list[Page] - ) -> None: - 'To pages add blank pages until its size is multiple of 8.' - old_len = len(pages) - mod_to_8 = old_len % 8 - if mod_to_8 > 0: - for _ in range(8 - mod_to_8): - pages += [Page.new_blank()] - print(f'-n: number of input pages {old_len} not required multiple ' - f'of 8, padded to {len(pages)}') - - -def normalize_pages_to_a4( - pages: list[Page], - arg_analyze: bool - ) -> None: - 'Zoom and adjust to A4 in pages, enact /Rotate.' - max_x = max(page.box['right'] for page in pages) - max_y = max(page.box['top'] for page in pages) - zooms = (A4_WIDTH / max_x, A4_HEIGHT / max_y) - offsets = {'tx': 0.0, 'ty': 0.0} - zoom_by_x = zooms[0] < zooms[1] - if zoom_by_x: - zoom = zooms[0] - offsets['ty'] = (A4_HEIGHT - max_y * zoom) / 2 - else: - zoom = zooms[1] - offsets['tx'] = (A4_WIDTH - max_x * zoom) / 2 - zoom_offset = 0.01 + offsets['tx'] / POINTS_PER_CM - zoom_crop = PageCrop( - left_cm=0 if zoom_by_x else zoom_offset, - right_cm=0 if zoom_by_x else zoom_offset, - bottom_cm=zoom_offset if zoom_by_x else 0, - top_cm=zoom_offset if zoom_by_x else 0) - for page in pages: - page.scale(zoom) - page.translate(**offsets) - if (rotation := page.rotation()): - page.rotate(360 - rotation) - page.set_box(0, 0, A4_WIDTH, A4_HEIGHT) - page.crop.add(zoom_crop) - if arg_analyze: - page.draw_borders((0.0, 0.75, 0.0)) - - -def collect_page_croppings( - pages: list[Page], - args_crops: str, - args_keep_mediabox: bool, - args_symmetry: str - ) -> None: - 'Calculate individual PageCrops from inputs.' - if args_crops: - for c_string in args_crops: - page_range, crops = split_crops_string(c_string) - idx_start, idx_after = parse_page_range(page_range, len(pages)) - 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(*(float(crop) for crop in crops.split(','))) - print(f'{prefix}: to pages {idx_start + 1}:{idx_after} ' - f'applying crop: {page_crop.format_in_cm}{suffix}') - for idx in range(idx_start, idx_after): - pages[idx].crop.add(page_crop, args_symmetry and idx % 2) - elif args_keep_mediabox: - for page in pages: - page.crop.add( - PageCrop( - page.box['left'] / POINTS_PER_CM, - page.box['bottom'] / POINTS_PER_CM, - (0.01 + A4_WIDTH - page.box['right']) / POINTS_PER_CM, - (0.01 + A4_HEIGHT - page.box['top']) / POINTS_PER_CM), - args_symmetry and not idx % 2) - - -def build_single_pages_output( - writer: PdfWriter, - pages: list[Page], - arg_analyze: bool - ) -> None: - 'On each of pages apply its page_croppings, then writer.add_page.' - print('building 1-input-page-per-output-page book') - for i, page in enumerate(pages): - page.translate(tx=-page.crop.left, ty=-page.crop.bottom) - page.scale(page.crop.zoom) - page.set_box(right=page.crop.remaining_width * page.crop.zoom) - page.set_box(top=page.crop.remaining_height * page.crop.zoom) - if arg_analyze: - page.draw_borders((0.0, 0.0, 0.75)) - page.add_to_writer(writer) - print(f'built page number {i+1} (of {len(pages)})') - - -def build_nup4_output( - writer: PdfWriter, - pages: list[Page], - args_print_margin: int, - arg_analyze: bool, - ) -> None: - 'Build nup4 pages from inputs.' - print('-n: building 4-input-pages-per-output-page book') - print(f'-m: applying printable-area margin of {args_print_margin}cm') - if arg_analyze: - print('-a: drawing page borders, spine limits') - nup4_geometry = Nup4Geometry(args_print_margin) - resort_pages_for_nup4(pages) - nup4_i = 0 - page_count = 0 - is_front_page = True - new_page = Page.new_blank() - for page in pages: - page.nup4_transforms(nup4_geometry, nup4_i) - new_page.merge_page(page) - page_count += 1 - print(f'merged page number {page_count} (of {len(pages)})') - nup4_i += 1 - if nup4_i > 3: - new_page.ornate_nup4(is_front_page, nup4_geometry, arg_analyze) - new_page.add_to_writer(writer) - nup4_i = 0 - new_page = Page.new_blank() - is_front_page = not is_front_page - - -def resort_pages_for_nup4( - pages: list[Page] - ) -> None: - 'Adapt pages towards PAGE_ORDER_FOR_NUP4.' - new_page_order = [] - eight_pack: list[Page] = [] - i = 0 - n_eights = 0 - for page in pages: - if i == 0: - eight_pack = [] - eight_pack += [page] - i += 1 - if i == 8: - i = 0 - for n in PAGE_ORDER_FOR_NUP4: - new_page_order += [eight_pack[n]] - n_eights += 1 - pages[:] = new_page_order[:] - - -def main( - ) -> None: - 'Full program run to be wrapped into ArgFail catcher.' - args = parse_args() - validate_args_syntax(args) - if args.nup4 and not GOT_CANVAS: - raise ArgFail('n', 'need reportlab.pdfgen.canvas installed for --nup4') - pages, opened_files = args_to_pagelist( - args.input_file, args.page_range, args.analyze) - validate_ranges(args, len(pages)) - rotate_pages(pages, args.rotate_page, args.analyze) - if not args.keep_mediabox: - normalize_pages_to_a4(pages, args.analyze) - if args.nup4: - pad_pages_to_multiple_of_8(pages) - collect_page_croppings(pages, - args.crops, args.keep_mediabox, args.symmetry) - writer = PdfWriter() - if args.nup4: - build_nup4_output(writer, pages, args.print_margin, args.analyze) - else: - build_single_pages_output(writer, pages, args.analyze) - 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 ArgFail as e: - handled_error_exit(f'-{e.arg_char}: {e.msg}') diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..976e3f4 --- /dev/null +++ b/install.sh @@ -0,0 +1,2 @@ +#!/usr/bin/sh +./plomlib/sh/install.sh bookmaker diff --git a/plomlib b/plomlib new file mode 160000 index 0000000..8692e6d --- /dev/null +++ b/plomlib @@ -0,0 +1 @@ +Subproject commit 8692e6dd3c9f5803def5f42b570c5a5f8027f11e diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8f71fb9..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -pillow==12.2.0 -pypdf==6.9.2 -reportlab==4.4.10 -# for mypy only -types-reportlab==4.4.10.20260402 diff --git a/src/bookmaker/misc.py b/src/bookmaker/misc.py new file mode 100755 index 0000000..9fa1921 --- /dev/null +++ b/src/bookmaker/misc.py @@ -0,0 +1,751 @@ +# standard libs +from argparse import ( + ArgumentParser, + Namespace as ArgsNamespace, + RawDescriptionHelpFormatter + ) +from io import BufferedReader, BytesIO +from os.path import isfile +from typing import Optional, Self +# non-standard libs +from pypdf import ( + PageObject as PdfPage, + PdfReader, + PdfWriter, + Transformation as PdfTransformation + ) +from pypdf.errors import PdfStreamError +try: + from reportlab.pdfgen.canvas import Canvas + GOT_CANVAS = True +except ImportError: + GOT_CANVAS = False + +# 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 +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) + +# PDF specifics +ROTATE_COMMAND = '/Rotate' + + +class Page: + 'Fuses PdfPage, PageCrop, and PdfTransformation.' + + def __init__( + self, + pypdf_page: PdfPage + ) -> None: + self._pypdf = pypdf_page + self.set_box() + self.crop = PageCrop() + + @property + def box( + self + ) -> dict[str, float]: + 'Return dict of mediabox measures.' + return {direction: getattr(self._pypdf.mediabox, direction) + for direction in ('left', 'bottom', 'right', 'top')} + + def set_box( + self, + left: Optional[float] = None, + bottom: Optional[float] = None, + right: Optional[float] = None, + top: Optional[float] = None + ) -> None: + 'Set mediabox (and cropbox) measures.' + if left is not None: + self._pypdf.mediabox.left = left + if bottom is not None: + self._pypdf.mediabox.bottom = bottom + if right is not None: + self._pypdf.mediabox.right = right + if top is not None: + self._pypdf.mediabox.top = top + self._pypdf.cropbox = self._pypdf.mediabox + + @classmethod + def new_blank( + cls + ) -> Self: + 'Make blank page of A4 measures.' + return cls(PdfPage.create_blank_page(width=A4_WIDTH, height=A4_HEIGHT)) + + def add_to_writer( + self, + writer: PdfWriter + ) -> None: + 'Add self to writer.' + writer.add_page(self._pypdf) + + def merge_page( + self, + page: Self + ) -> None: + 'Merge page .to self.' + self._pypdf.merge_page(page._pypdf) + + def rotation( + self + ) -> Optional[int]: + 'Return PDF /Rotate instruction.' + return self._pypdf.get(ROTATE_COMMAND, None) + + def rotate(self, + degrees: int + ) -> None: + 'Wrap PdfTransformation.rotate.' + self._pypdf.add_transformation(PdfTransformation().rotate(degrees)) + + def translate( + self, + tx: float = 0.0, + ty: float = 0.0 + ) -> None: + 'Wrap PdfTransformation.translate.' + self._pypdf.add_transformation(PdfTransformation().translate(tx, ty)) + + def scale( + self, + zoom: float + ) -> None: + 'Wrap PdfTransformation.scale.' + self._pypdf.add_transformation(PdfTransformation().scale(zoom, zoom)) + + def draw_lines( + self, + lines: tuple[tuple[float, float, float, float], ...], + rgb_color: tuple[float, float, float] = (0.0, 0.0, 0.0), + line_width: float = 2.0, + ) -> None: + 'Draw lines.' + min_x, min_y, max_x, max_y = self._pypdf.mediabox + packet = BytesIO() + c = Canvas(packet, pagesize=(max_x - min_x, max_y - min_y)) + c.setLineWidth(line_width) + c.setStrokeColorRGB(*rgb_color) + for line in lines: + c.line(line[0], line[1], line[2], line[3]) + c.save() + self._pypdf.merge_page(PdfReader(packet).pages[0]) + + def draw_orth_lines( + self, + lines: tuple[tuple[bool, float], ...], + rgb_color: tuple[float, float, float] = (0.0, 0.0, 0.0), + line_width: float = 2.0, + ) -> None: + 'Draw horizontal or vertical lines.' + min_x, min_y, max_x, max_y = self._pypdf.mediabox + full_lines: list[tuple[float, float, float, float]] = [] + for is_horizontal, position in lines: + if is_horizontal: + x0, x1 = min_x, max_x + y0 = y1 = position + else: + y0, y1 = min_y, max_y + x0 = x1 = position + full_lines += [(x0, y0, x1, y1)] + self.draw_lines(tuple(full_lines), rgb_color, line_width) + + def draw_borders( + self, + rgb_color: tuple[float, float, float] = (0.0, 0.0, 0.0), + line_width: float = 4.0, + ) -> None: + 'Draw border lines around mediabox.' + min_x, min_y, max_x, max_y = self._pypdf.mediabox + self.draw_orth_lines( + ((False, min_x), (True, min_y), (False, max_x), (True, max_y)), + rgb_color, + line_width) + + def nup4_transforms( + self, + nup4_geometry: 'Nup4Geometry', + nup4_i: int + ) -> None: + 'Perform inner, outer page transforms to position in nup4_geometry.' + # Apply crop instructions adequate to position in nup4 geometry. + self.translate( + tx=((-self.crop.left) if nup4_i in {0, 2} + else # in {1, 3} + A4_WIDTH / self.crop.zoom - (A4_WIDTH - self.crop.right)), + ty=A4_HEIGHT / self.crop.zoom - (A4_HEIGHT - self.crop.top)) + self.scale(self.crop.zoom * nup4_geometry.shrink_for_spine) + if nup4_i in {2, 3}: + self.translate( + ty=-2 * nup4_geometry.margin / nup4_geometry.shrink_for_margin) + # Shrink and position into nup4_geometry as per its position nup4_i. + self.translate(ty=(1 - nup4_geometry.shrink_for_spine) * A4_HEIGHT) + if nup4_i in {0, 1}: + y_section = A4_HEIGHT + self.set_box(bottom=A4_HEIGHT/2, top=A4_HEIGHT) + else: # nup4_in in {2, 3} + y_section = 0 + self.set_box(bottom=0, top=A4_HEIGHT/2) + x_section: float + if nup4_i in {0, 2}: + x_section = 0 + self.set_box(left=0, right=A4_WIDTH/2) + else: # nup4_in in {1, 3} + self.translate(tx=(1 - nup4_geometry.shrink_for_spine) * A4_WIDTH) + x_section = A4_WIDTH + self.set_box(left=A4_WIDTH/2, right=A4_WIDTH) + self.translate(tx=x_section, ty=y_section) + self.scale(QUARTER_SCALE_FACTOR) + + def ornate_nup4( + self, + is_front_page: bool, + nup4_geometry: 'Nup4Geometry', + analyzing: bool + ) -> None: + 'Apply nup4 line guides.' + + def draw_book_binding_cut_guide( + x_spine_limit: float, + direction: int + ) -> None: + directed_half_width = 0.5 * CUT_WIDTH * direction + outer_start_x = x_spine_limit - directed_half_width + inner_start_x = x_spine_limit + directed_half_width + middle_point_y = A4_HEIGHT/2 + MIDDLE_POINT_DEPTH * direction + end_point_y = A4_HEIGHT/2 + CUT_DEPTH * direction + self.draw_lines(((inner_start_x, A4_HEIGHT/2, + x_spine_limit, end_point_y), + (x_spine_limit, end_point_y, + x_spine_limit, middle_point_y), + (x_spine_limit, middle_point_y, + outer_start_x, A4_HEIGHT/2)), + line_width=0.2) + + if analyzing: + self.draw_borders((0.5, 1.0, 1.0)) + self.draw_orth_lines(((True, self.box['top']/2), + (False, self.box['right']/2)), + line_width=0.1) + printable_offset_x = nup4_geometry.margin + printable_offset_y = nup4_geometry.margin * A4_HEIGHT / A4_WIDTH + self.scale(nup4_geometry.shrink_for_margin) + self.translate(tx=printable_offset_x, ty=printable_offset_y) + if not (analyzing or is_front_page): + return + x_left_spine_limit = A4_WIDTH/2 * nup4_geometry.shrink_for_spine + x_right_spine_limit = A4_WIDTH - x_left_spine_limit + if analyzing: + self.draw_orth_lines(((False, x_left_spine_limit,), + (False, x_right_spine_limit)), + line_width=0.1, + rgb_color=(1.0, 0.5, 1.0)) + if is_front_page: + draw_book_binding_cut_guide(x_left_spine_limit, 1) + draw_book_binding_cut_guide(x_right_spine_limit, -1) + + +class PageCrop: + 'Per-page crop instructions as sizes in point and cm, and A4-zoom factor.' + + def __init__( + self, + left_cm: float = 0, + bottom_cm: float = 0, + right_cm: float = 0, + top_cm: float = 0 + ) -> None: + self.left_cm = left_cm + self.bottom_cm = bottom_cm + self.right_cm = right_cm + self.top_cm = top_cm + self._calc_measures() + + def _calc_measures(self) -> None: + 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 > zoom_vertical\ + or zoom_horizontal < 1 < zoom_vertical: + raise ArgFail('c', 'crops would create opposing zoom directions') + if zoom_horizontal + zoom_vertical > 2: + self.zoom = min(zoom_horizontal, zoom_vertical) + else: + self.zoom = max(zoom_horizontal, zoom_vertical) + + def __str__( + self + ) -> str: + return str(vars(self)) + + @property + def format_in_cm( + self + ) -> str: + 'Human-readable listing of crops in cm.' + return (f'left {self.left_cm}cm, bottom {self.bottom_cm}cm, ' + f'right {self.right_cm}cm, top {self.top_cm}cm') + + @property + def remaining_width( + self + ) -> float: + "What's left of A4_WIDTH after applying width croppings." + return A4_WIDTH - self.left - self.right + + @property + def remaining_height( + self + ) -> float: + "What's left of A4_WIDTH after applying height croppings." + return A4_HEIGHT - self.bottom - self.top + + def add( + self, + to_add: Self, + mirror=False + ) -> None: + 'Add other crop to own measures, mirror left and right if demanded.' + self.left_cm += to_add.right_cm if mirror else to_add.left_cm + self.bottom_cm += to_add.bottom_cm + self.right_cm += to_add.left_cm if mirror else to_add.right_cm + self.top_cm += to_add.top_cm + self._calc_measures() + + +class Nup4Geometry: + 'Nup4-specific attributes, i.e. outer-page margins, spine sizes.' + + def __init__( + self, + margin_cm: int + ) -> None: + 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_WIDTH/2)) + / self.shrink_for_margin) + self.shrink_for_spine = 1 - spine_part_of_page + + +class ArgFail(Exception): + 'Collects relevant command character, followed by explanation.' + + def __init__( + self, + arg_char: str, + msg: str, + *args, + **kwargs + ) -> None: + super().__init__(*args, **kwargs) + self.arg_char = arg_char + self.msg = msg + + +def parse_args( + ) -> ArgsNamespace: + 'Collect command line arguments.' + help_epilogue = ('See README.txt for detailed usage instructions, ' + 'command examples, etc.') + parser = ArgumentParser( + description=__doc__, + epilog=help_epilogue, + formatter_class=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('-k', + '--keep_mediabox', + action='store_true', + help='keep non-A4 page shapes') + 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_args_syntax( + args: ArgsNamespace + ) -> None: + 'Check command args against general syntax expectations.' + + def validate_page_range( + pr_string: str, + arg_char: str + ) -> None: + prefix = 'page range string' + if '-' not in pr_string: + raise ArgFail(arg_char, f'{prefix} lacks "-": {pr_string}') + tokens = pr_string.split('-') + if len(tokens) > 2: + raise ArgFail(arg_char, f'{prefix} has too many "-": {pr_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 as e: + raise ArgFail(arg_char, + f'{prefix} carries value neither integer, ' + f'nor "start", nor "end": {pr_string}') from e + if int(token) < 1: + raise ArgFail(arg_char, + f'{prefix} carries page number <1: {pr_string}') + start = -1 + end = -1 + try: + start = int(tokens[0]) + end = int(tokens[1]) + except ValueError: + pass + if start > end > 0: + raise ArgFail( + arg_char, + f'{prefix} has higher start than end value: {pr_string}') + + 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 ArgFail( + '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 ArgFail('c', + f'cropping string has multiple ":": {c_string}') + page_range, crops_str = split_crops_string(c_string) + crops = crops_str.split(',') + if page_range: + validate_page_range(page_range, 'c') + if len(crops) != 4: + raise ArgFail( + 'c', + f'cropping does not contain exactly three ",": {c_string}') + for crop in crops: + try: + float(crop) + except ValueError as e: + raise ArgFail('c', + f'non-number crop in: {c_string}') from e + if args.rotate_page: + for r in args.rotate_page: + try: + int(r) + except ValueError as e: + raise ArgFail('r', f'non-integer value: {r}') from e + if r < 1: + raise ArgFail('r', 'value must not be <1: {r}') + try: + float(args.print_margin) + except ValueError as e: + raise ArgFail('m', f'non-float value: {args.print_margin}') from e + + +def split_crops_string( + c_string: str + ) -> tuple[Optional[str], str]: + 'If c_string contains ":" return before and after, else None and c_string.' + initial_split = c_string.split(':', maxsplit=1) + 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: Optional[str], + idx_after: int + ) -> tuple[int, int]: + 'Based on actual pages size read range_string into range limit indices.' + idx_start = 0 + if range_string: + start, end = range_string.split('-') + if not (len(start) == 0 or start == 'start'): + idx_start = int(start) - 1 + if not (len(end) == 0 or end == 'end'): + idx_after = int(end) + return idx_start, idx_after + + +def args_to_pagelist( + args_input_file: list[str], + args_page_range: list[str], + arg_analyze: bool + ) -> tuple[list[Page], list[BufferedReader]]: + 'Follow args_input_file ranged by args_page_range into pages, open files.' + pages = [] + opened_files = [] + new_page_num = 0 + for i, filename in enumerate(args_input_file): + if not isfile(filename): + raise ArgFail('i', f'{filename} is not a file') + file = open(filename, 'rb') + opened_files += [file] + try: + reader = PdfReader(file) + except PdfStreamError as e: + for to_close in opened_files: + to_close.close() + raise ArgFail('i', + f'cannot interpret {filename} as PDF file') from e + range_string = None + if args_page_range and len(args_page_range) > i: + range_string = args_page_range[i] + for old_page_num in range(*parse_page_range(range_string, + len(reader.pages))): + new_page_num += 1 + page = (Page.new_blank() if old_page_num >= len(reader.pages) + else Page(reader.pages[old_page_num])) + pages += [page] + if arg_analyze: + page.draw_borders((0.75, 0.0, 0.0)) + print(f'-i, -p: read in {filename} page number {old_page_num+1} ' + f'as new page {new_page_num}') + return pages, opened_files + + +def validate_ranges( + args: ArgsNamespace, + len_pages: int + ) -> None: + 'Check command args\' ranges fits into len_pages.' + if args.crops: + for c_string in args.crops: + if (page_range := split_crops_string(c_string)[0])\ + and parse_page_range(page_range, len_pages)[1] > len_pages: + raise ArgFail('c', + 'page range goes beyond number of pages ' + f'we\'re building: {page_range}') + if args.rotate_page: + for r in [r for r in args.rotate_page if r > len_pages]: + raise ArgFail('r', + 'page number beyond number of ' + f'pages we\'re building: {r}') + + +def rotate_pages( + pages: list[Page], + args_rotate_page: Optional[list[int]], + arg_analyze: bool + ) -> None: + 'For pages page numbered in args_rotate_page, rotate by 90°.' + if args_rotate_page: + for rotate_page in args_rotate_page: + page = pages[rotate_page - 1] + page.translate(tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2) + page.rotate(-90) + page.translate(tx=A4_WIDTH/2, ty=A4_HEIGHT/2) + if arg_analyze: + page.draw_borders((0.75, 0.0, 0.0)) + print(f'-r: rotating (by 90°) page {rotate_page}') + + +def pad_pages_to_multiple_of_8( + pages: list[Page] + ) -> None: + 'To pages add blank pages until its size is multiple of 8.' + old_len = len(pages) + mod_to_8 = old_len % 8 + if mod_to_8 > 0: + for _ in range(8 - mod_to_8): + pages += [Page.new_blank()] + print(f'-n: number of input pages {old_len} not required multiple ' + f'of 8, padded to {len(pages)}') + + +def normalize_pages_to_a4( + pages: list[Page], + arg_analyze: bool + ) -> None: + 'Zoom and adjust to A4 in pages, enact /Rotate.' + max_x = max(page.box['right'] for page in pages) + max_y = max(page.box['top'] for page in pages) + zooms = (A4_WIDTH / max_x, A4_HEIGHT / max_y) + offsets = {'tx': 0.0, 'ty': 0.0} + zoom_by_x = zooms[0] < zooms[1] + if zoom_by_x: + zoom = zooms[0] + offsets['ty'] = (A4_HEIGHT - max_y * zoom) / 2 + else: + zoom = zooms[1] + offsets['tx'] = (A4_WIDTH - max_x * zoom) / 2 + zoom_offset = 0.01 + offsets['tx'] / POINTS_PER_CM + zoom_crop = PageCrop( + left_cm=0 if zoom_by_x else zoom_offset, + right_cm=0 if zoom_by_x else zoom_offset, + bottom_cm=zoom_offset if zoom_by_x else 0, + top_cm=zoom_offset if zoom_by_x else 0) + for page in pages: + page.scale(zoom) + page.translate(**offsets) + if (rotation := page.rotation()): + page.rotate(360 - rotation) + page.set_box(0, 0, A4_WIDTH, A4_HEIGHT) + page.crop.add(zoom_crop) + if arg_analyze: + page.draw_borders((0.0, 0.75, 0.0)) + + +def collect_page_croppings( + pages: list[Page], + args_crops: str, + args_keep_mediabox: bool, + args_symmetry: str + ) -> None: + 'Calculate individual PageCrops from inputs.' + if args_crops: + for c_string in args_crops: + page_range, crops = split_crops_string(c_string) + idx_start, idx_after = parse_page_range(page_range, len(pages)) + 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(*(float(crop) for crop in crops.split(','))) + print(f'{prefix}: to pages {idx_start + 1}:{idx_after} ' + f'applying crop: {page_crop.format_in_cm}{suffix}') + for idx in range(idx_start, idx_after): + pages[idx].crop.add(page_crop, args_symmetry and idx % 2) + elif args_keep_mediabox: + for page in pages: + page.crop.add( + PageCrop( + page.box['left'] / POINTS_PER_CM, + page.box['bottom'] / POINTS_PER_CM, + (0.01 + A4_WIDTH - page.box['right']) / POINTS_PER_CM, + (0.01 + A4_HEIGHT - page.box['top']) / POINTS_PER_CM), + args_symmetry and not idx % 2) + + +def build_single_pages_output( + writer: PdfWriter, + pages: list[Page], + arg_analyze: bool + ) -> None: + 'On each of pages apply its page_croppings, then writer.add_page.' + print('building 1-input-page-per-output-page book') + for i, page in enumerate(pages): + page.translate(tx=-page.crop.left, ty=-page.crop.bottom) + page.scale(page.crop.zoom) + page.set_box(right=page.crop.remaining_width * page.crop.zoom) + page.set_box(top=page.crop.remaining_height * page.crop.zoom) + if arg_analyze: + page.draw_borders((0.0, 0.0, 0.75)) + page.add_to_writer(writer) + print(f'built page number {i+1} (of {len(pages)})') + + +def build_nup4_output( + writer: PdfWriter, + pages: list[Page], + args_print_margin: int, + arg_analyze: bool, + ) -> None: + 'Build nup4 pages from inputs.' + print('-n: building 4-input-pages-per-output-page book') + print(f'-m: applying printable-area margin of {args_print_margin}cm') + if arg_analyze: + print('-a: drawing page borders, spine limits') + nup4_geometry = Nup4Geometry(args_print_margin) + resort_pages_for_nup4(pages) + nup4_i = 0 + page_count = 0 + is_front_page = True + new_page = Page.new_blank() + for page in pages: + page.nup4_transforms(nup4_geometry, nup4_i) + new_page.merge_page(page) + page_count += 1 + print(f'merged page number {page_count} (of {len(pages)})') + nup4_i += 1 + if nup4_i > 3: + new_page.ornate_nup4(is_front_page, nup4_geometry, arg_analyze) + new_page.add_to_writer(writer) + nup4_i = 0 + new_page = Page.new_blank() + is_front_page = not is_front_page + + +def resort_pages_for_nup4( + pages: list[Page] + ) -> None: + 'Adapt pages towards PAGE_ORDER_FOR_NUP4.' + new_page_order = [] + eight_pack: list[Page] = [] + i = 0 + n_eights = 0 + for page in pages: + if i == 0: + eight_pack = [] + eight_pack += [page] + i += 1 + if i == 8: + i = 0 + for n in PAGE_ORDER_FOR_NUP4: + new_page_order += [eight_pack[n]] + n_eights += 1 + pages[:] = new_page_order[:] diff --git a/src/plomlib/setup.py b/src/plomlib/setup.py new file mode 120000 index 0000000..4a26f80 --- /dev/null +++ b/src/plomlib/setup.py @@ -0,0 +1 @@ +../../plomlib/py/setup.py \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..8f71fb9 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,5 @@ +pillow==12.2.0 +pypdf==6.9.2 +reportlab==4.4.10 +# for mypy only +types-reportlab==4.4.10.20260402 diff --git a/src/run.py b/src/run.py new file mode 100755 index 0000000..37fd4c4 --- /dev/null +++ b/src/run.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +bookmaker.py helps optimizing PDFs for the production of small self-printed, +self-bound physical books; towards this goal offers various PDF manipulation +options that may also be used indepéndently and for other purposes. +""" + +# standard libs +from sys import exit as sys_exit +# ourselves +from plomlib.setup import dependency_hint +try: + from bookmaker.misc import ( + ArgFail, + GOT_CANVAS, + PdfWriter, + args_to_pagelist, + build_nup4_output, + build_single_pages_output, + collect_page_croppings, + normalize_pages_to_a4, + pad_pages_to_multiple_of_8, + parse_args, + rotate_pages, + validate_args_syntax, + validate_ranges, + ) +except ModuleNotFoundError as e: + dependency_hint(e) + + +def main( + ) -> None: + 'Full program run to be wrapped into ArgFail catcher.' + args = parse_args() + validate_args_syntax(args) + if args.nup4 and not GOT_CANVAS: + raise ArgFail('n', 'need reportlab.pdfgen.canvas installed for --nup4') + + pages, opened_files = args_to_pagelist( + args.input_file, args.page_range, args.analyze) + validate_ranges(args, len(pages)) + + rotate_pages(pages, args.rotate_page, args.analyze) + if not args.keep_mediabox: + normalize_pages_to_a4(pages, args.analyze) + if args.nup4: + pad_pages_to_multiple_of_8(pages) + collect_page_croppings(pages, + args.crops, args.keep_mediabox, args.symmetry) + writer = PdfWriter() + if args.nup4: + build_nup4_output(writer, pages, args.print_margin, args.analyze) + else: + build_single_pages_output(writer, pages, args.analyze) + + 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 ArgFail as e: + print(f'ERROR: -{e.arg_char}: {e.msg}') + sys_exit(1)