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(
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
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'):
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):
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]:
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:
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]
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.'
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
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(
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: