From: Plom Heller Date: Tue, 7 Apr 2026 21:58:05 +0000 (+0200) Subject: Re-organize all that pages_to_add and page_croppings passing-along into Page class. X-Git-Url: https://plomlompom.com/repos/%7B%7Bprefix%7D%7D/%22https:/validator.w3.org/template?a=commitdiff_plain;h=187fe8adfe1de599aab3ec8acef970748a6bea00;p=bookmaker Re-organize all that pages_to_add and page_croppings passing-along into Page class. --- diff --git a/bookmaker.py b/bookmaker.py index b361cc1..e3c0bca 100755 --- a/bookmaker.py +++ b/bookmaker.py @@ -12,7 +12,7 @@ from argparse import ( from io import BufferedReader, BytesIO from os.path import isfile from sys import exit as sys_exit -from typing import Optional +from typing import Optional, Self def handled_error_exit( @@ -55,16 +55,82 @@ 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.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)) + class PageCrop: 'Per-page crop instructions as sizes in point and cm, and A4-zoom factor.' def __init__( self, - left_cm: int = 0, - bottom_cm: int = 0, - right_cm: int = 0, - top_cm: int = 0 + 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 @@ -314,11 +380,10 @@ def split_crops_string( def parse_page_range( range_string: Optional[str], - pages: list[PdfPage] + idx_after: int ) -> tuple[int, int]: 'Based on actual pages size read range_string into range limit indices.' idx_start = 0 - idx_after = len(pages) if range_string: start, end = range_string.split('-') if not (len(start) == 0 or start == 'start'): @@ -331,9 +396,9 @@ def parse_page_range( def args_to_pagelist( args_input_file: list[str], args_page_range: list[str] - ) -> tuple[list[PdfPage], list[BufferedReader]]: + ) -> tuple[list[Page], list[BufferedReader]]: 'Follow args_input_file ranged by args_page_range into pages, open files.' - pages_to_add = [] + pages = [] opened_files = [] new_page_num = 0 for i, filename in enumerate(args_input_file): @@ -352,76 +417,67 @@ def args_to_pagelist( 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, - reader.pages)): + len(reader.pages))): new_page_num += 1 - if old_page_num >= len(reader.pages): - page = PdfPage.create_blank_page(width=A4_WIDTH, - height=A4_HEIGHT) - else: - page = reader.pages[old_page_num] - pages_to_add += [page] + pages += [Page.new_blank() if old_page_num >= len(reader.pages) + else Page(reader.pages[old_page_num])] print(f'-i, -p: read in {filename} page number {old_page_num+1} ' f'as new page {new_page_num}') - return pages_to_add, opened_files + return pages, opened_files def validate_ranges( args: ArgsNamespace, - pages_to_add: list[PdfPage] + len_pages: int ) -> None: - 'Check command args\' ranges fit into pages_to_add count.' + '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, - pages_to_add)[1] > len(pages_to_add): + 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 args.rotate_page: - if r > len(pages_to_add): - raise ArgFail('r', - 'page number beyond number of ' - f'pages we\'re building: {r}') + 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( args_rotate_page: list[int], - pages_to_add: list[PdfPage] + pages: list[Page] ) -> None: - 'For pages_to_add page numbered in args_rotate_page, rotate by 90°.' + 'For pages page numbered in args_rotate_page, rotate by 90°.' if args_rotate_page: for rotate_page in args_rotate_page: - page = pages_to_add[rotate_page - 1] - page.add_transformation(PdfTransformation().translate( - tx=-A4_WIDTH/2, ty=-A4_HEIGHT/2)) - page.add_transformation(PdfTransformation().rotate(-90)) - page.add_transformation(PdfTransformation().translate( - tx=A4_WIDTH/2, ty=A4_HEIGHT/2)) + 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) print(f'-r: rotating (by 90°) page {rotate_page}') def pad_pages_to_multiple_of_8( - pages_to_add: list[PdfPage] + pages: list[Page] ) -> None: - 'To pages_to_add add blank pages until its size is multiple of 8.' - mod_to_8 = len(pages_to_add) % 8 + '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: - old_len = len(pages_to_add) for _ in range(8 - mod_to_8): - pages_to_add += [PdfPage.create_blank_page(width=A4_WIDTH, - height=A4_HEIGHT)] + pages += [Page.new_blank()] print(f'-n: number of input pages {old_len} not required multiple ' - f'of 8, padded to {len(pages_to_add)}') + f'of 8, padded to {len(pages)}') def normalize_pages_to_a4( - pages_to_add: list[PdfPage] + pages: list[Page] ) -> None: - 'Zoom and adjust in pages_to_add .mediabox=.cropbox to A4, enact /Rotate.' - max_x = max(page.mediabox.right for page in pages_to_add) - max_y = max(page.mediabox.top for page in pages_to_add) + '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} if zooms[0] < zooms[1]: @@ -430,75 +486,64 @@ def normalize_pages_to_a4( else: zoom = zooms[1] offsets['tx'] = (A4_WIDTH - max_x * zoom) / 2 - for page in pages_to_add: - page.add_transformation(PdfTransformation().scale(zoom, zoom)) - page.add_transformation(PdfTransformation().translate(**offsets)) - if '/Rotate' in page: # TODO: preserve rotation, but in canvas? - rotation: int = page['/Rotate'] # type: ignore + for page in pages: + page.scale(zoom) + page.translate(**offsets) + if (rotation := page.rotation()): page.rotate(360 - rotation) - page.mediabox.left = 0 - page.mediabox.bottom = 0 - page.mediabox.top = A4_HEIGHT - page.mediabox.right = A4_WIDTH - page.cropbox = page.mediabox + page.set_box(0, 0, A4_HEIGHT, A4_WIDTH) -def collect_page_croppings(args_crops, - args_keep_mediabox, - args_symmetry, - pages_to_add): +def collect_page_croppings( + args_crops: str, + args_keep_mediabox: bool, + args_symmetry: str, + pages: list[Page] + ) -> None: 'Calculate individual PageCrops from inputs.' - page_croppings = [PageCrop()] * len(pages_to_add) 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, pages_to_add) + 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(*crops.split(',')) + 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 n_page in range(idx_start, idx_after): - page_croppings[n_page] = (page_crop.make_mirror() - if (args_symmetry and n_page % 2) - else page_crop) + for idx in range(idx_start, idx_after): + pages[idx].crop = (page_crop.make_mirror() + if (args_symmetry and idx % 2) + else page_crop) elif args_keep_mediabox: - for n_page, page in enumerate(pages_to_add): - page_croppings[n_page] = PageCrop( - page.mediabox.left / POINTS_PER_CM, - page.mediabox.bottom / POINTS_PER_CM, - (0.01 + A4_WIDTH - page.mediabox.right) / POINTS_PER_CM, - (0.01 + A4_HEIGHT - page.mediabox.top) / POINTS_PER_CM) - if args_symmetry and not n_page % 2: - page_croppings[n_page] = page_croppings[n_page].make_mirror() - return page_croppings + for page in pages: + page.crop = 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) + if args_symmetry and not idx % 2: + page.crop = page.crop.make_mirror() def build_single_pages_output( writer: PdfWriter, - pages_to_add: list[PdfPage], - page_croppings: list[PageCrop] + pages: list[Page], ) -> None: - 'On each of pages_to_add apply its page_croppings, then writer.add_page.' + '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_to_add): - page.add_transformation(PdfTransformation().translate( - tx=-page_croppings[i].left, ty=-page_croppings[i].bottom)) - page.add_transformation(PdfTransformation().scale( - page_croppings[i].zoom, page_croppings[i].zoom)) - page.mediabox.right\ - = page_croppings[i].remaining_width * page_croppings[i].zoom - page.mediabox.top\ - = page_croppings[i].remaining_height * page_croppings[i].zoom - writer.add_page(page) - print(f'built page number {i+1} (of {len(pages_to_add)})') + 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) + page.add_to_writer(writer) + print(f'built page number {i+1} (of {len(pages)})') def build_nup4_output( writer: PdfWriter, - pages_to_add: list[PdfPage], - page_croppings: list[PageCrop], + pages: list[Page], args_print_margin: int, args_analyze: str, ) -> None: @@ -508,39 +553,35 @@ def build_nup4_output( if args_analyze: print('-a: drawing page borders, spine limits') nup4_geometry = Nup4Geometry(args_print_margin) - pages_to_add, old_indices = resort_pages_for_nup4(pages_to_add) + resort_pages_for_nup4(pages) nup4_i = 0 page_count = 0 is_front_page = True - new_page: Optional[PdfPage] = None - for i, page in enumerate(pages_to_add): - new_page = new_page or PdfPage.create_blank_page(width=A4_WIDTH, - height=A4_HEIGHT) - nup4_inner_page_transform( - page, page_croppings[old_indices[i]], nup4_geometry, nup4_i) + new_page = Page.new_blank() + for page in pages: + nup4_inner_page_transform(page, nup4_geometry, nup4_i) nup4_outer_page_transform(page, nup4_geometry, nup4_i) new_page.merge_page(page) page_count += 1 - print(f'merged page number {page_count} (of {len(pages_to_add)})') + print(f'merged page number {page_count} (of {len(pages)})') nup4_i += 1 if nup4_i > 3: ornate_nup4(args_analyze, is_front_page, new_page, nup4_geometry) - writer.add_page(new_page) + new_page.add_to_writer(writer) nup4_i = 0 - new_page = None + new_page = Page.new_blank() is_front_page = not is_front_page def resort_pages_for_nup4( - pages_to_add: list[PdfPage] - ) -> tuple[list[PdfPage], list[int]]: - 'Adapt pages_to_add towards PAGE_ORDER_FOR_NUP4.' + pages: list[Page] + ) -> None: + 'Adapt pages towards PAGE_ORDER_FOR_NUP4.' new_page_order = [] - old_indices = [] - eight_pack: list[PdfPage] = [] + eight_pack: list[Page] = [] i = 0 n_eights = 0 - for page in pages_to_add: + for page in pages: if i == 0: eight_pack = [] eight_pack += [page] @@ -548,69 +589,56 @@ def resort_pages_for_nup4( if i == 8: i = 0 for n in PAGE_ORDER_FOR_NUP4: - old_indices += [8 * n_eights + n] new_page_order += [eight_pack[n]] n_eights += 1 - return new_page_order, old_indices + pages[:] = new_page_order[:] def nup4_inner_page_transform( - page: PdfPage, - crop: PageCrop, + page: Page, nup4_geometry: Nup4Geometry, nup4_i: int ) -> None: 'Apply to page crop instructions adequate to position in nup4 geometry.' - page.add_transformation(PdfTransformation().translate( - ty=A4_HEIGHT / crop.zoom - (A4_HEIGHT - crop.top))) - page.add_transformation(PdfTransformation().translate( - tx=((-crop.left) if nup4_i in {0, 2} - else A4_WIDTH / crop.zoom - (A4_WIDTH - crop.right)))) # in {1, 3} - page.add_transformation(PdfTransformation().scale( - crop.zoom * nup4_geometry.shrink_for_spine, - crop.zoom * nup4_geometry.shrink_for_spine)) + page.translate(ty=A4_HEIGHT / page.crop.zoom - (A4_HEIGHT - page.crop.top)) + page.translate(tx=( + (-page.crop.left) if nup4_i in {0, 2} else + A4_WIDTH / page.crop.zoom - (A4_WIDTH - page.crop.right))) # in {1, 3} + page.scale(page.crop.zoom * nup4_geometry.shrink_for_spine) if nup4_i in {2, 3}: - page.add_transformation(PdfTransformation().translate( - ty=-2*nup4_geometry.margin/nup4_geometry.shrink_for_margin)) + page.translate( + ty=-2 * nup4_geometry.margin / nup4_geometry.shrink_for_margin) def nup4_outer_page_transform( - page: PdfPage, + page: Page, nup4_geometry: Nup4Geometry, nup4_i: int ) -> None: 'Shrink and position page into nup4_geometry as per its position nup4_i.' - page.add_transformation(PdfTransformation().translate( - ty=(1-nup4_geometry.shrink_for_spine)*A4_HEIGHT)) + page.translate(ty=(1 - nup4_geometry.shrink_for_spine) * A4_HEIGHT) if nup4_i in {0, 1}: y_section = A4_HEIGHT - page.mediabox.bottom = A4_HALF_HEIGHT - page.mediabox.top = A4_HEIGHT + page.set_box(bottom=A4_HALF_HEIGHT, top=A4_HEIGHT) else: # nup4_in in {2, 3} y_section = 0 - page.mediabox.bottom = 0 - page.mediabox.top = A4_HALF_HEIGHT + page.set_box(bottom=0, top=A4_HALF_HEIGHT) x_section: float if nup4_i in {0, 2}: x_section = 0 - page.mediabox.left = 0 - page.mediabox.right = A4_HALF_WIDTH + page.set_box(left=0, right=A4_HALF_WIDTH) else: # nup4_in in {1, 3} - page.add_transformation(PdfTransformation().translate( - tx=(1-nup4_geometry.shrink_for_spine)*A4_WIDTH)) + page.translate(tx=(1 - nup4_geometry.shrink_for_spine) * A4_WIDTH) x_section = A4_WIDTH - page.mediabox.left = A4_HALF_WIDTH - page.mediabox.right = A4_WIDTH - page.add_transformation(PdfTransformation().translate(tx=x_section, - ty=y_section)) - page.add_transformation(PdfTransformation().scale(QUARTER_SCALE_FACTOR, - QUARTER_SCALE_FACTOR)) + page.set_box(left=A4_HALF_WIDTH, right=A4_WIDTH) + page.translate(tx=x_section, ty=y_section) + page.scale(QUARTER_SCALE_FACTOR) def ornate_nup4( args_analyze: str, is_front_page: bool, - new_page: PdfPage, + new_page: Page, nup4_geometry: Nup4Geometry, ) -> None: 'Apply nup4 line guides onto new_page.' @@ -627,13 +655,11 @@ def ornate_nup4( c.line(A4_WIDTH, A4_HEIGHT, A4_WIDTH, 0) c.save() new_pdf = PdfReader(packet) - new_page.merge_page(new_pdf.pages[0]) + new_page.merge_page(Page(new_pdf.pages[0])) printable_offset_x = nup4_geometry.margin printable_offset_y = nup4_geometry.margin * A4_HEIGHT / A4_WIDTH - new_page.add_transformation(PdfTransformation().scale( - nup4_geometry.shrink_for_margin, nup4_geometry.shrink_for_margin)) - new_page.add_transformation(PdfTransformation().translate( - tx=printable_offset_x, ty=printable_offset_y)) + new_page.scale(nup4_geometry.shrink_for_margin) + new_page.translate(tx=printable_offset_x, ty=printable_offset_y) if not (args_analyze or is_front_page): return x_left_spine_limit = A4_HALF_WIDTH * nup4_geometry.shrink_for_spine @@ -651,7 +677,7 @@ def ornate_nup4( draw_cut(c, x_right_spine_limit, -1) c.save() new_pdf = PdfReader(packet) - new_page.merge_page(new_pdf.pages[0]) + new_page.merge_page(Page(new_pdf.pages[0])) def draw_cut( @@ -677,27 +703,20 @@ def main( validate_args_syntax(args) if args.nup4 and not GOT_CANVAS: raise ArgFail('n', 'need reportlab.pdfgen.canvas installed for --nup4') - pages_to_add, opened_files = args_to_pagelist(args.input_file, - args.page_range) - validate_ranges(args, pages_to_add) - rotate_pages(args.rotate_page, pages_to_add) + pages, opened_files = args_to_pagelist(args.input_file, args.page_range) + validate_ranges(args, len(pages)) + rotate_pages(args.rotate_page, pages) if not args.keep_mediabox: - normalize_pages_to_a4(pages_to_add) + normalize_pages_to_a4(pages) if args.nup4: - pad_pages_to_multiple_of_8(pages_to_add) - page_croppings = collect_page_croppings(args.crops, - args.keep_mediabox, - args.symmetry, - pages_to_add) + pad_pages_to_multiple_of_8(pages) + collect_page_croppings(args.crops, args.keep_mediabox, args.symmetry, + pages) writer = PdfWriter() if args.nup4: - build_nup4_output(writer, - pages_to_add, - page_croppings, - args.print_margin, - args.analyze) + build_nup4_output(writer, pages, args.print_margin, args.analyze) else: - build_single_pages_output(writer, pages_to_add, page_croppings) + build_single_pages_output(writer, pages) for file in opened_files: file.close() with open(args.output_file, 'wb') as output_file: