- Reads the first page of an input PDF, optionally resizes it to a specified physical size, adds a configurable light gray border, and converts it into a “card” page while preserving aspect ratio.
- Computes the optimal tiling of the card on an A4 sheet (portrait or landscape) without further scaling, then generates a single tiled output page containing a grid of card copies.
- Provides flexible CLI options for source and card dimensions in millimeters, plus a
--no-resize-cardflag to bypass card scaling, all implemented purely with pypdf (no external PDF engines).
Last active
December 5, 2025 07:24
-
-
Save aont/7c3cf929b01804d93fddb645264fa5b5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import argparse | |
| from copy import copy | |
| from pypdf import PdfReader, PdfWriter, PageObject, Transformation | |
| from pypdf.generic import NameObject, DecodedStreamObject | |
| # A4 size (pt) | |
| A4_PORTRAIT = (595.276, 841.89) # width, height | |
| A4_LANDSCAPE = (841.89, 595.276) | |
| def mm_to_pt(mm: float) -> float: | |
| """Convert millimeters to PostScript points""" | |
| return mm / 25.4 * 72.0 | |
| def resize_page_to_size( | |
| page: PageObject, | |
| target_width_pt: float, | |
| target_height_pt: float, | |
| ) -> PageObject: | |
| """ | |
| Place an arbitrary page into a new page of size | |
| (target_width_pt x target_height_pt), keeping the original aspect ratio | |
| and fitting it inside the target page. | |
| """ | |
| orig_w = float(page.mediabox.width) | |
| orig_h = float(page.mediabox.height) | |
| new_page = PageObject.create_blank_page(width=target_width_pt, height=target_height_pt) | |
| scale_x = target_width_pt / orig_w | |
| scale_y = target_height_pt / orig_h | |
| scale = min(scale_x, scale_y) | |
| scaled_w = orig_w * scale | |
| scaled_h = orig_h * scale | |
| offset_x = (target_width_pt - scaled_w) / 2.0 | |
| offset_y = (target_height_pt - scaled_h) / 2.0 | |
| page_copy = copy(page) | |
| transform = ( | |
| Transformation() | |
| .scale(scale, scale) | |
| .translate(offset_x, offset_y) | |
| ) | |
| new_page.merge_transformed_page(page_copy, transform) | |
| return new_page | |
| def create_bordered_page( | |
| page: PageObject, | |
| border_gray: float = 0.8, | |
| border_width: float = 1.0, | |
| ) -> PageObject: | |
| """ | |
| Create a blank page of the same size as `page`, draw a light gray border | |
| around it, and then merge the original page content on top of it. | |
| """ | |
| x0 = float(page.mediabox.left) | |
| y0 = float(page.mediabox.bottom) | |
| x1 = float(page.mediabox.right) | |
| y1 = float(page.mediabox.top) | |
| width = x1 - x0 | |
| height = y1 - y0 | |
| margin = border_width / 2.0 | |
| r = g = b = border_gray | |
| rect_x = x0 + margin | |
| rect_y = y0 + margin | |
| rect_w = width - 2 * margin | |
| rect_h = height - 2 * margin | |
| content = f""" | |
| q | |
| {r} {g} {b} RG | |
| {border_width} w | |
| {rect_x} {rect_y} {rect_w} {rect_h} re | |
| S | |
| Q | |
| """.encode("ascii") | |
| bordered_base = PageObject.create_blank_page(width=width, height=height) | |
| stream = DecodedStreamObject() | |
| stream.set_data(content) | |
| bordered_base[NameObject("/Contents")] = stream | |
| bordered_base.merge_page(page) | |
| return bordered_base | |
| def create_card_page( | |
| bordered_page: PageObject, | |
| card_width_pt: float | None = None, | |
| card_height_pt: float | None = None, | |
| ) -> PageObject: | |
| """ | |
| Create a card_page from bordered_page. | |
| - Both card_width_pt and card_height_pt are None: | |
| → Do not scale, return a copy of bordered_page as-is. | |
| - Only one of card_width_pt or card_height_pt is specified: | |
| → Compute the other dimension from the aspect ratio of bordered_page, | |
| so that the page is scaled to match the specified dimension. | |
| - Both are specified: | |
| → Create a page of the specified size and place bordered_page inside it | |
| while preserving the aspect ratio. | |
| If necessary, adjust one of the values slightly so that the aspect | |
| ratio is exactly preserved. | |
| """ | |
| orig_w = float(bordered_page.mediabox.width) | |
| orig_h = float(bordered_page.mediabox.height) | |
| # Both None → no scaling | |
| if card_width_pt is None and card_height_pt is None: | |
| return copy(bordered_page) | |
| aspect = orig_w / orig_h | |
| # Only one side is None → compute the other from aspect ratio | |
| if card_width_pt is None and card_height_pt is not None: | |
| card_width_pt = card_height_pt * aspect | |
| elif card_height_pt is None and card_width_pt is not None: | |
| card_height_pt = card_width_pt / aspect | |
| else: | |
| # Both are specified | |
| assert card_width_pt is not None and card_height_pt is not None | |
| # Adjust one side if the given size is inconsistent with the aspect ratio | |
| if card_height_pt * aspect < card_width_pt: | |
| card_width_pt = card_height_pt * aspect | |
| else: | |
| card_height_pt = card_width_pt / aspect | |
| card_page = PageObject.create_blank_page(width=card_width_pt, height=card_height_pt) | |
| scale_x = card_width_pt / orig_w | |
| scale_y = card_height_pt / orig_h | |
| scale = min(scale_x, scale_y) | |
| scaled_w = orig_w * scale | |
| scaled_h = orig_h * scale | |
| offset_x = (card_width_pt - scaled_w) / 2.0 | |
| offset_y = (card_height_pt - scaled_h) / 2.0 | |
| bordered_copy = copy(bordered_page) | |
| transform = ( | |
| Transformation() | |
| .scale(scale, scale) | |
| .translate(offset_x, offset_y) | |
| ) | |
| card_page.merge_transformed_page(bordered_copy, transform) | |
| return card_page | |
| def compute_best_tiling_for_card(card_width: float, card_height: float): | |
| """ | |
| For both portrait and landscape A4 sizes, compute how many card_pages of | |
| size (card_width x card_height) can be tiled without scaling, and choose | |
| the orientation that allows the largest number of tiles. | |
| """ | |
| def best_for_size(page_w, page_h): | |
| cols = int(page_w // card_width) | |
| rows = int(page_h // card_height) | |
| if cols < 1 or rows < 1: | |
| return {"tiles": 0, "rows": 0, "cols": 0} | |
| tiles = rows * cols | |
| return {"tiles": tiles, "rows": rows, "cols": cols} | |
| best_portrait = best_for_size(*A4_PORTRAIT) | |
| best_landscape = best_for_size(*A4_LANDSCAPE) | |
| if best_landscape["tiles"] > best_portrait["tiles"]: | |
| orientation = "landscape" | |
| sheet_w, sheet_h = A4_LANDSCAPE | |
| best = best_landscape | |
| else: | |
| orientation = "portrait" | |
| sheet_w, sheet_h = A4_PORTRAIT | |
| best = best_portrait | |
| return orientation, sheet_w, sheet_h, best["rows"], best["cols"] | |
| def build_tiled_sheet(card_page: PageObject) -> PageObject: | |
| """ | |
| Create a tiled_page that arranges card_page instances on an A4 sheet_page | |
| in a grid without changing the size of card_page. | |
| """ | |
| card_w = float(card_page.mediabox.width) | |
| card_h = float(card_page.mediabox.height) | |
| ( | |
| orientation, | |
| sheet_w, | |
| sheet_h, | |
| rows, | |
| cols, | |
| ) = compute_best_tiling_for_card(card_w, card_h) | |
| sheet_page = PageObject.create_blank_page(width=sheet_w, height=sheet_h) | |
| if rows == 0 or cols == 0: | |
| rows, cols = 1, 1 | |
| total_tiles_w = card_w * cols | |
| total_tiles_h = card_h * rows | |
| offset_x0 = (sheet_w - total_tiles_w) / 2.0 | |
| offset_y0 = (sheet_h - total_tiles_h) / 2.0 | |
| for r in range(rows): | |
| for c in range(cols): | |
| x = offset_x0 + c * card_w | |
| y = offset_y0 + r * card_h | |
| tile = copy(card_page) | |
| transform = Transformation().translate(x, y) | |
| sheet_page.merge_transformed_page(tile, transform) | |
| return sheet_page | |
| def process_pdf( | |
| input_path: str, | |
| output_path: str, | |
| source_width_mm: float | None, | |
| source_height_mm: float | None, | |
| card_width_mm: float | None, | |
| card_height_mm: float | None, | |
| no_resize_card: bool, | |
| ) -> None: | |
| reader = PdfReader(input_path) | |
| if len(reader.pages) == 0: | |
| raise ValueError("Input PDF has no pages.") | |
| original_page = reader.pages[0] | |
| # --- source_page: optionally resize the input page --- | |
| if source_width_mm is not None and source_height_mm is not None: | |
| target_w_pt = mm_to_pt(source_width_mm) | |
| target_h_pt = mm_to_pt(source_height_mm) | |
| source_page = resize_page_to_size(original_page, target_w_pt, target_h_pt) | |
| else: | |
| source_page = original_page | |
| # --- bordered_page: page with border --- | |
| bordered_page = create_bordered_page(source_page) | |
| # --- card_page: card-sized page / or no-resize --- | |
| if no_resize_card: | |
| # If the flag is set, do not scale at all | |
| card_page = copy(bordered_page) | |
| else: | |
| # mm → pt (if not specified, keep as None) | |
| card_w_pt = mm_to_pt(card_width_mm) if card_width_mm is not None else None | |
| card_h_pt = mm_to_pt(card_height_mm) if card_height_mm is not None else None | |
| card_page = create_card_page(bordered_page, card_width_pt=card_w_pt, card_height_pt=card_h_pt) | |
| # --- tiled_page: tile card_page on an A4 sheet --- | |
| tiled_page = build_tiled_sheet(card_page) | |
| writer = PdfWriter() | |
| writer.add_page(tiled_page) | |
| with open(output_path, "wb") as f: | |
| writer.write(f) | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Read the first page of the input PDF (source_page), add a border " | |
| "(bordered_page), convert it to a card-sized page (card_page), and " | |
| "tile that on an A4 sheet (sheet_page) to produce a single output " | |
| "page (tiled_page). Uses pypdf only." | |
| ) | |
| ) | |
| parser.add_argument("input_pdf", help="Path to input PDF file") | |
| parser.add_argument("output_pdf", help="Path to output PDF file") | |
| # source_page size (mm) | |
| parser.add_argument( | |
| "--source-width-mm", | |
| type=float, | |
| default=None, | |
| help="Width of source_page in mm. If omitted, the original PDF size is used.", | |
| ) | |
| parser.add_argument( | |
| "--source-height-mm", | |
| type=float, | |
| default=None, | |
| help="Height of source_page in mm. If omitted, the original PDF size is used.", | |
| ) | |
| # card_page size (mm) — default is both None | |
| parser.add_argument( | |
| "--card-width-mm", | |
| type=float, | |
| default=None, | |
| help=( | |
| "Width of card_page in mm. If height is not specified, it is computed " | |
| "from the aspect ratio. If both are omitted, no scaling is performed." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--card-height-mm", | |
| type=float, | |
| default=None, | |
| help=( | |
| "Height of card_page in mm. If width is not specified, it is computed " | |
| "from the aspect ratio. If both are omitted, no scaling is performed." | |
| ), | |
| ) | |
| # no-resize option for card_page (takes precedence) | |
| parser.add_argument( | |
| "--no-resize-card", | |
| action="store_true", | |
| help=( | |
| "Do not resize bordered_page when creating card_page; " | |
| "use the original size as-is (card_* arguments are ignored)." | |
| ), | |
| ) | |
| args = parser.parse_args() | |
| if (args.source_width_mm is None) != (args.source_height_mm is None): | |
| parser.error("When specifying source_page size, both --source-width-mm and --source-height-mm must be given.") | |
| process_pdf( | |
| input_path=args.input_pdf, | |
| output_path=args.output_pdf, | |
| source_width_mm=args.source_width_mm, | |
| source_height_mm=args.source_height_mm, | |
| card_width_mm=args.card_width_mm, | |
| card_height_mm=args.card_height_mm, | |
| no_resize_card=args.no_resize_card, | |
| ) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment