|
| 1 | +""" |
| 2 | +Reorder and two-up PDF pages for booklet printing. |
| 3 | +
|
| 4 | +If the number of pages is not a multiple of four, pages are |
| 5 | +added until it is a multiple of four. This includes a centerfold |
| 6 | +in the middle of the booklet and a single page on the inside |
| 7 | +back cover. The content of those pages are from the |
| 8 | +centerfold-file and blank-page-file files, if specified, otherwise |
| 9 | +they are blank pages. |
| 10 | +
|
| 11 | +Example: |
| 12 | + pdfly booklet input.pdf output.pdf |
| 13 | +
|
| 14 | +""" |
| 15 | + |
| 16 | +# Copyright (c) 2014, Steve Witham <[email protected]>. |
| 17 | +# All rights reserved. This software is available under a BSD license; |
| 18 | +# see https://github.com/py-pdf/pypdf/LICENSE |
| 19 | + |
| 20 | +import sys |
| 21 | +import traceback |
| 22 | +from pathlib import Path |
| 23 | +from typing import Generator, Optional, Tuple |
| 24 | + |
| 25 | +from pypdf import ( |
| 26 | + PageObject, |
| 27 | + PdfReader, |
| 28 | + PdfWriter, |
| 29 | +) |
| 30 | +from pypdf.generic import RectangleObject |
| 31 | + |
| 32 | + |
| 33 | +def main( |
| 34 | + filename: Path, |
| 35 | + output: Path, |
| 36 | + inside_cover_file: Optional[Path], |
| 37 | + centerfold_file: Optional[Path], |
| 38 | +) -> None: |
| 39 | + try: |
| 40 | + # Set up the streams |
| 41 | + reader = PdfReader(filename) |
| 42 | + pages = list(reader.pages) |
| 43 | + writer = PdfWriter() |
| 44 | + |
| 45 | + # Add blank pages to make the number of pages a multiple of 4 |
| 46 | + # If the user specified an inside-back-cover file, use it. |
| 47 | + blank_page = PageObject.create_blank_page( |
| 48 | + width=pages[0].mediabox.width, height=pages[0].mediabox.height |
| 49 | + ) |
| 50 | + if len(pages) % 2 == 1: |
| 51 | + if inside_cover_file: |
| 52 | + ic_reader_page = fetch_first_page(inside_cover_file) |
| 53 | + pages.insert(-1, ic_reader_page) |
| 54 | + else: |
| 55 | + pages.insert(-1, blank_page) |
| 56 | + if len(pages) % 4 == 2: |
| 57 | + pages.insert(len(pages) // 2, blank_page) |
| 58 | + pages.insert(len(pages) // 2, blank_page) |
| 59 | + requires_centerfold = True |
| 60 | + else: |
| 61 | + requires_centerfold = False |
| 62 | + |
| 63 | + # Reorder the pages and place two pages side by side (2-up) on each sheet |
| 64 | + for lhs, rhs in page_iter(len(pages)): |
| 65 | + pages[lhs].merge_translated_page( |
| 66 | + page2=pages[rhs], |
| 67 | + tx=pages[lhs].mediabox.width, |
| 68 | + ty=0, |
| 69 | + expand=True, |
| 70 | + over=True, |
| 71 | + ) |
| 72 | + writer.add_page(pages[lhs]) |
| 73 | + |
| 74 | + # If a centerfold was required, it is already |
| 75 | + # present as a pair of blank pages. If the user |
| 76 | + # specified a centerfold file, use it instead. |
| 77 | + if requires_centerfold and centerfold_file: |
| 78 | + centerfold_page = fetch_first_page(centerfold_file) |
| 79 | + last_page = writer.pages[-1] |
| 80 | + if centerfold_page.rotation != 0: |
| 81 | + centerfold_page.transfer_rotation_to_content() |
| 82 | + if requires_rotate(centerfold_page.mediabox, last_page.mediabox): |
| 83 | + centerfold_page = centerfold_page.rotate(270) |
| 84 | + if centerfold_page.rotation != 0: |
| 85 | + centerfold_page.transfer_rotation_to_content() |
| 86 | + last_page.merge_page(centerfold_page) |
| 87 | + |
| 88 | + # Everything looks good! Write the output file. |
| 89 | + with open(output, "wb") as output_fh: |
| 90 | + writer.write(output_fh) |
| 91 | + |
| 92 | + except Exception: |
| 93 | + print(traceback.format_exc(), file=sys.stderr) |
| 94 | + print(f"Error while reading {filename}", file=sys.stderr) |
| 95 | + sys.exit(1) |
| 96 | + |
| 97 | + |
| 98 | +def requires_rotate(a: RectangleObject, b: RectangleObject) -> bool: |
| 99 | + """ |
| 100 | + Return True if a and b are rotated relative to each other. |
| 101 | +
|
| 102 | + Args: |
| 103 | + a (RectangleObject): The first rectangle. |
| 104 | + b (RectangleObject): The second rectangle. |
| 105 | +
|
| 106 | + """ |
| 107 | + a_portrait = a.height > a.width |
| 108 | + b_portrait = b.height > b.width |
| 109 | + return a_portrait != b_portrait |
| 110 | + |
| 111 | + |
| 112 | +def fetch_first_page(filename: Path) -> PageObject: |
| 113 | + """ |
| 114 | + Fetch the first page of a PDF file. |
| 115 | +
|
| 116 | + Args: |
| 117 | + filename (Path): The path to the PDF file. |
| 118 | +
|
| 119 | + Returns: |
| 120 | + PageObject: The first page of the PDF file. |
| 121 | +
|
| 122 | + """ |
| 123 | + return PdfReader(filename).pages[0] |
| 124 | + |
| 125 | + |
| 126 | +# This function written with inspiration, assistance, and code |
| 127 | +# from claude.ai & Github Copilot |
| 128 | +def page_iter(num_pages: int) -> Generator[Tuple[int, int], None, None]: |
| 129 | + """ |
| 130 | + Generate pairs of page numbers for printing a booklet. |
| 131 | + This function assumes that the total number of pages is divisible by 4. |
| 132 | + It yields tuples of page numbers that should be printed on the same sheet |
| 133 | + of paper to create a booklet. |
| 134 | +
|
| 135 | + Args: |
| 136 | + num_pages (int): The total number of pages in the document. Must be divisible by 4. |
| 137 | +
|
| 138 | + Yields: |
| 139 | + Generator[Tuple[int, int], None, None]: Tuples containing pairs of page numbers. |
| 140 | + Each tuple represents the page numbers to be printed on one side of a sheet. |
| 141 | +
|
| 142 | + Raises: |
| 143 | + ValueError: If the number of pages is not divisible by 4. |
| 144 | +
|
| 145 | + """ |
| 146 | + if num_pages % 4 != 0: |
| 147 | + raise ValueError("Number of pages must be divisible by 4") |
| 148 | + |
| 149 | + for sheet in range(num_pages // 4): |
| 150 | + # Outside the fold |
| 151 | + last_page = num_pages - sheet * 2 - 1 |
| 152 | + first_page = sheet * 2 |
| 153 | + |
| 154 | + # Inside the fold |
| 155 | + second_page = sheet * 2 + 1 |
| 156 | + second_to_last_page = num_pages - sheet * 2 - 2 |
| 157 | + |
| 158 | + yield last_page, first_page |
| 159 | + yield second_page, second_to_last_page |
0 commit comments