Skip to content

Instantly share code, notes, and snippets.

@aont
Last active December 5, 2025 07:24
Show Gist options
  • Select an option

  • Save aont/7c3cf929b01804d93fddb645264fa5b5 to your computer and use it in GitHub Desktop.

Select an option

Save aont/7c3cf929b01804d93fddb645264fa5b5 to your computer and use it in GitHub Desktop.

PDF Card Tiling Tool Using pypdf

  • 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-card flag to bypass card scaling, all implemented purely with pypdf (no external PDF engines).
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