home · contact · privacy
Structure like my other installables.
authorPlom Heller <plom@plomlompom.com>
Tue, 29 Sep 2026 12:28:23 +0000 (14:28 +0200)
committerPlom Heller <plom@plomlompom.com>
Tue, 29 Sep 2026 12:28:23 +0000 (14:28 +0200)
.gitmodules [new file with mode: 0644]
bookmaker.py [deleted file]
install.sh [new file with mode: 0755]
plomlib [new submodule]
requirements.txt [deleted file]
src/bookmaker/misc.py [new file with mode: 0755]
src/plomlib/setup.py [new symlink]
src/requirements.txt [new file with mode: 0644]
src/run.py [new file with mode: 0755]

diff --git a/.gitmodules b/.gitmodules
new file mode 100644 (file)
index 0000000..42cf7f3
--- /dev/null
@@ -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 (executable)
index e43d754..0000000
+++ /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 (executable)
index 0000000..976e3f4
--- /dev/null
@@ -0,0 +1,2 @@
+#!/usr/bin/sh
+./plomlib/sh/install.sh bookmaker
diff --git a/plomlib b/plomlib
new file mode 160000 (submodule)
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 (file)
index 8f71fb9..0000000
+++ /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 (executable)
index 0000000..9fa1921
--- /dev/null
@@ -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 (symlink)
index 0000000..4a26f80
--- /dev/null
@@ -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 (file)
index 0000000..8f71fb9
--- /dev/null
@@ -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 (executable)
index 0000000..37fd4c4
--- /dev/null
@@ -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)