--- /dev/null
+[submodule "plomlib"]
+ path = plomlib
+ url = https://plomlompom.com/repos/clone/plomlib
+++ /dev/null
-#!/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}')
--- /dev/null
+#!/usr/bin/sh
+./plomlib/sh/install.sh bookmaker
--- /dev/null
+Subproject commit 8692e6dd3c9f5803def5f42b570c5a5f8027f11e
+++ /dev/null
-pillow==12.2.0
-pypdf==6.9.2
-reportlab==4.4.10
-# for mypy only
-types-reportlab==4.4.10.20260402
--- /dev/null
+# 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[:]
--- /dev/null
+../../plomlib/py/setup.py
\ No newline at end of file
--- /dev/null
+pillow==12.2.0
+pypdf==6.9.2
+reportlab==4.4.10
+# for mypy only
+types-reportlab==4.4.10.20260402
--- /dev/null
+#!/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)