DEV Community

vast cow
vast cow

Posted on

PDF Page Combining Tool

Overview

This tool combines multiple pages from a PDF into a grid layout on each output page. It is useful when you want to place several PDF pages together, such as arranging four pages into a 2 × 2 layout.

The tool can be used to reduce the number of pages, prepare handouts, create overview sheets, or make compact versions of PDF documents.

Main Purpose

The main purpose of this tool is to create an n-up PDF.

“N-up” means placing multiple pages onto one sheet. For example, a 2 × 2 layout places four original PDF pages on one output page.

The tool can:

  • Select all pages or only specific pages from the input PDF
  • Arrange pages in rows and columns
  • Create larger output pages without shrinking the original pages
  • Shrink pages to fit into the original page size
  • Add margins around the output page
  • Add gaps between the arranged pages

How It Works

The tool reads an input PDF, selects the requested pages, and places them into a grid. Each group of pages becomes one output page.

For example, if the layout is set to 2 columns and 2 rows, the tool places up to four source pages on each output page.

Pages are placed from left to right and from top to bottom.

Selecting Pages

You can choose which pages to include by using a page range.

If no page range is given, the tool uses all pages in the PDF.

Examples:

Page Range Meaning
1-4 Use pages 1 through 4
1,3,5-8 Use pages 1, 3, and 5 through 8
2- Use page 2 through the last page
-10 Use page 1 through page 10
empty Use all pages

Page numbers are written in the normal 1-based style, so page 1 means the first page of the PDF.

Basic Usage

Run the tool from the command line by giving an input PDF and an output PDF:

python nup_pdf.py input.pdf output.pdf
Enter fullscreen mode Exit fullscreen mode

By default, this creates a 2 × 2 layout using all pages.

Choosing Rows and Columns

You can change the layout with --cols and --rows.

For example, this creates a 3 × 2 layout:

python nup_pdf.py input.pdf output.pdf --cols 3 --rows 2
Enter fullscreen mode Exit fullscreen mode

This places up to six original pages on each output page.

Using a Page Range

To use only selected pages, add --range.

python nup_pdf.py input.pdf output.pdf --range "1-4"
Enter fullscreen mode Exit fullscreen mode

This uses only pages 1 through 4.

You can also combine separate pages and ranges:

python nup_pdf.py input.pdf output.pdf --range "1,3,5-8"
Enter fullscreen mode Exit fullscreen mode

Keeping the Original Page Size

By default, the tool does not shrink the source pages. Instead, it creates a larger output page large enough to hold the full grid.

For example, if four A4 pages are arranged in a 2 × 2 layout, the output page will be roughly A2-sized.

This is useful when you want to preserve the original page size and avoid reducing text or image quality.

Scaling Pages to Fit

Use --scale-to-fit when you want the output page to stay the same size as the first page of the input PDF.

python nup_pdf.py input.pdf output.pdf --scale-to-fit
Enter fullscreen mode Exit fullscreen mode

In this mode, each source page is reduced so that it fits inside its grid cell.

This is closer to ordinary printer-style n-up output, where multiple pages are placed on one sheet.

Adding Margins and Gaps

You can add an outer margin with --margin and spacing between grid cells with --gap.

python nup_pdf.py input.pdf output.pdf --margin 20 --gap 10
Enter fullscreen mode Exit fullscreen mode

Both values are measured in PDF points.

One point is 1/72 inch.

Margins are added around the outside of the output page. Gaps are added between the placed pages.

Example Commands

Create a 2 × 2 PDF using all pages

python nup_pdf.py input.pdf output.pdf
Enter fullscreen mode Exit fullscreen mode

Create a 2 × 2 PDF using only pages 1 to 8

python nup_pdf.py input.pdf output.pdf --range "1-8"
Enter fullscreen mode Exit fullscreen mode

Create a 3 × 2 layout

python nup_pdf.py input.pdf output.pdf --cols 3 --rows 2
Enter fullscreen mode Exit fullscreen mode

Create a printer-style compact version

python nup_pdf.py input.pdf output.pdf --scale-to-fit
Enter fullscreen mode Exit fullscreen mode

Add margin and spacing

python nup_pdf.py input.pdf output.pdf --margin 24 --gap 12
Enter fullscreen mode Exit fullscreen mode

When to Use This Tool

This tool is helpful when you need to:

  • Make handouts from slide PDFs
  • Combine several document pages into one overview page
  • Reduce the number of printed sheets
  • Create compact review materials
  • Arrange selected PDF pages into a clean grid

Summary

This PDF page combining tool creates a new PDF by arranging selected pages from an existing PDF into a grid. It supports flexible page selection, custom rows and columns, optional scaling, margins, and gaps.

Use the default mode when you want to preserve original page sizes. Use --scale-to-fit when you want several pages to fit onto a page of the original size.

from __future__ import annotations

from pathlib import Path

from pypdf import PdfReader, PdfWriter, PageObject, Transformation


def parse_page_range(page_range: str | None, total_pages: int) -> list[int]:
    """
    Convert a 1-based page range string to a list of 0-based page indices.

    If page_range is None or empty, all pages are selected.

    Examples
    --------
    "1-4"     -> [0, 1, 2, 3]
    "1,3,5-7" -> [0, 2, 4, 5, 6]
    "2-"      -> from page 2 to the last page
    "-5"      -> from page 1 to page 5
    "" or None -> all pages
    """
    if page_range is None or not page_range.strip():
        return list(range(total_pages))

    pages: list[int] = []

    for part in page_range.split(","):
        part = part.strip()
        if not part:
            continue

        if "-" in part:
            start_s, end_s = part.split("-", 1)

            start = int(start_s) if start_s else 1
            end = int(end_s) if end_s else total_pages

            if start < 1 or end > total_pages or start > end:
                raise ValueError(f"Invalid page range: {part}")

            pages.extend(range(start - 1, end))
        else:
            p = int(part)
            if p < 1 or p > total_pages:
                raise ValueError(f"Page does not exist: {p}")
            pages.append(p - 1)

    if not pages:
        return list(range(total_pages))

    return pages


def nup_pdf(
    input_pdf: str | Path,
    output_pdf: str | Path,
    page_range: str | None = None,
    cols: int = 2,
    rows: int = 2,
    *,
    scale_to_fit: bool = False,
    margin: float = 0,
    gap: float = 0,
) -> None:
    """
    Combine selected PDF pages into a cols x rows grid on each output page.

    Parameters
    ----------
    input_pdf:
        Input PDF file.
    output_pdf:
        Output PDF file.
    page_range:
        Page range to use, expressed with 1-based page numbers.
        If omitted, all pages are used.
        Examples: "1-4", "1,3,5-8", "2-", "-10"
    cols:
        Number of pages in the horizontal direction. Use 2 for a 2x2 layout.
    rows:
        Number of pages in the vertical direction. Use 2 for a 2x2 layout.
    scale_to_fit:
        False:
            Preserve the original page size and create a larger output page.
            For example, a 2x2 layout of A4 pages is roughly A2-sized.
        True:
            Keep the output page size equal to the first input page and shrink
            each source page into its grid cell. This is closer to ordinary
            n-up printing behavior.
    margin:
        Outer margin of the output page, in PDF points.
        1 point = 1/72 inch.
    gap:
        Gap between cells, in PDF points.
    """
    if cols <= 0 or rows <= 0:
        raise ValueError("cols and rows must be 1 or greater.")

    reader = PdfReader(str(input_pdf))
    writer = PdfWriter()

    selected_indices = parse_page_range(page_range, len(reader.pages))
    if not selected_indices:
        raise ValueError("No pages were selected.")

    pages_per_sheet = cols * rows

    # Reference page size.
    first_page = reader.pages[selected_indices[0]]
    base_width = float(first_page.mediabox.width)
    base_height = float(first_page.mediabox.height)

    if scale_to_fit:
        # Keep the output size equal to one original PDF page.
        out_width = base_width
        out_height = base_height

        cell_width = (out_width - 2 * margin - gap * (cols - 1)) / cols
        cell_height = (out_height - 2 * margin - gap * (rows - 1)) / rows
    else:
        # Do not shrink pages; create a larger page for the full grid.
        cell_width = base_width
        cell_height = base_height

        out_width = cols * cell_width + gap * (cols - 1) + 2 * margin
        out_height = rows * cell_height + gap * (rows - 1) + 2 * margin

    for group_start in range(0, len(selected_indices), pages_per_sheet):
        group = selected_indices[group_start : group_start + pages_per_sheet]

        output_page = PageObject.create_blank_page(
            width=out_width,
            height=out_height,
        )

        for slot, page_index in enumerate(group):
            src_page = reader.pages[page_index]

            src_width = float(src_page.mediabox.width)
            src_height = float(src_page.mediabox.height)

            col = slot % cols
            row = slot // cols

            # The PDF coordinate system starts at the lower-left corner.
            # Flip the y-position calculation so row=0 is placed on the top row.
            x0 = margin + col * (cell_width + gap)
            y0 = margin + (rows - 1 - row) * (cell_height + gap)

            if scale_to_fit:
                scale = min(cell_width / src_width, cell_height / src_height)
            else:
                scale = 1.0

            placed_width = src_width * scale
            placed_height = src_height * scale

            # Center the source page within the cell.
            tx = x0 + (cell_width - placed_width) / 2
            ty = y0 + (cell_height - placed_height) / 2

            # Also support PDFs whose mediabox lower-left corner is not (0, 0).
            src_left = float(src_page.mediabox.left)
            src_bottom = float(src_page.mediabox.bottom)

            transform = (
                Transformation()
                .translate(tx=-src_left, ty=-src_bottom)
                .scale(scale, scale)
                .translate(tx=tx, ty=ty)
            )

            output_page.merge_transformed_page(
                src_page,
                transform,
                over=True,
            )

        writer.add_page(output_page)

    with open(output_pdf, "wb") as f:
        writer.write(f)


import argparse


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Combine PDF pages into an n-up grid layout."
    )
    parser.add_argument("input_pdf")
    parser.add_argument("output_pdf")
    parser.add_argument(
        "--range",
        dest="page_range",
        default=None,
        help="1-based page range to use, such as '1-4', '1,3,5-8', '2-', or '-10'. If omitted, all pages are used.",
    )
    parser.add_argument("--cols", type=int, default=2)
    parser.add_argument("--rows", type=int, default=2)
    parser.add_argument("--scale-to-fit", action="store_true")
    parser.add_argument("--margin", type=float, default=0)
    parser.add_argument("--gap", type=float, default=0)

    args = parser.parse_args()

    nup_pdf(
        input_pdf=args.input_pdf,
        output_pdf=args.output_pdf,
        page_range=args.page_range,
        cols=args.cols,
        rows=args.rows,
        scale_to_fit=args.scale_to_fit,
        margin=args.margin,
        gap=args.gap,
    )


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Top comments (0)