Source code for pdfdol.bundle

"""Bundle several PDFs into one, with a contents page, per-document headers and bookmarks.

``concat_pdfs`` just joins documents. ``bundle_pdf_bytes`` keeps track of which
page each document starts on while joining, which is what makes these possible:

- ``toc``: a contents page (several pages if needed) whose lines link to the
  documents' first pages;
- ``doc_header``: a band naming the document, added *above* its first page, so
  no content is covered;
- ``outline``: one bookmark per document.

Rendering text uses ``reportlab``, imported lazily: without ``toc`` and
``doc_header``, only ``pypdf`` is used.

>>> import io, pypdf
>>> def blank(n, w=200, h=300):
...     writer = pypdf.PdfWriter()
...     for _ in range(n):
...         _ = writer.add_blank_page(width=w, height=h)
...     buf = io.BytesIO(); _ = writer.write(buf); return buf.getvalue()
>>> out = bundle_pdf_bytes({'a.pdf': blank(2), 'b.pdf': blank(1)}, outline=True)
>>> reader = pypdf.PdfReader(io.BytesIO(out))
>>> len(reader.pages), [o.title for o in reader.outline]
(3, ['a.pdf', 'b.pdf'])
"""

from __future__ import annotations

import io
from collections.abc import Callable, Iterable, Mapping
from typing import NamedTuple, Union

import pypdf
from pypdf.generic import (
    ArrayObject,
    DictionaryObject,
    FloatObject,
    NameObject,
    NumberObject,
    RectangleObject,
)

#: Page sizes in points (1/72 inch), portrait.
PAGE_SIZES = {
    "A3": (841.89, 1190.55),
    "A4": (595.28, 841.89),
    "A5": (419.53, 595.28),
    "letter": (612.0, 792.0),
    "legal": (612.0, 1008.0),
}

DFLT_MARGIN = 36
DFLT_HEADER_HEIGHT = 30
DFLT_TOC_TITLE = "Contents"
DFLT_FONT, DFLT_FONT_BOLD = "Helvetica", "Helvetica-Bold"

PageSize = Union[str, tuple]


[docs] class Entry(NamedTuple): """One document in a bundle. ``start_page`` is 1-based, in the final PDF.""" key: str start_page: int n_pages: int
#: A contents renderer maps ``(entries, title)`` to the contents PDF bytes, or to #: ``(pdf_bytes, link_rects)`` where ``link_rects[i]`` is ``(toc_page_index, (x0, y0, x1, y1))`` #: for entry ``i``, making that region a link to the entry's first page. TocRenderer = Callable[[list, str], Union[bytes, tuple]] def _require_reportlab(feature): try: import reportlab # noqa: F401 except ImportError as e: raise ImportError(f"{feature} needs reportlab: pip install reportlab") from e
[docs] def resolve_page_size(page_size: PageSize) -> tuple[float, float]: """Name (case-insensitive, see ``PAGE_SIZES``) or ``(w, h)`` points -> ``(w, h)``. >>> resolve_page_size('a4') (595.28, 841.89) >>> resolve_page_size((100, 200)) (100.0, 200.0) """ if isinstance(page_size, str): by_lower = {k.lower(): v for k, v in PAGE_SIZES.items()} try: return by_lower[page_size.lower()] except KeyError: raise ValueError( f"Unknown page size {page_size!r}; use one of {sorted(PAGE_SIZES)} or (w, h)" ) from None w, h = page_size return float(w), float(h)
[docs] def fit_pdf_to_page( pdf_bytes: bytes, page_size: PageSize = "A4", *, margin: float = DFLT_MARGIN, landscape_if_wider: bool = True, ) -> bytes: """Put each page of ``pdf_bytes`` on a ``page_size`` page, scaled to fit and centered. A page wider than tall gets a landscape page when ``landscape_if_wider``. >>> import io, pypdf >>> w = pypdf.PdfWriter(); _ = w.add_blank_page(width=1000, height=500) >>> buf = io.BytesIO(); _ = w.write(buf) >>> out = fit_pdf_to_page(buf.getvalue(), 'A4') >>> [round(float(v)) for v in pypdf.PdfReader(io.BytesIO(out)).pages[0].mediabox[2:]] [842, 595] """ pw0, ph0 = resolve_page_size(page_size) writer = pypdf.PdfWriter() for src in pypdf.PdfReader(io.BytesIO(pdf_bytes)).pages: src.transfer_rotation_to_content() sw, sh = float(src.mediabox.width), float(src.mediabox.height) pw, ph = ( (max(pw0, ph0), min(pw0, ph0)) if (landscape_if_wider and sw > sh) else ( min(pw0, ph0), max(pw0, ph0), ) ) scale = min((pw - 2 * margin) / sw, (ph - 2 * margin) / sh) dx = (pw - sw * scale) / 2 - float(src.mediabox.left) * scale dy = (ph - sh * scale) / 2 - float(src.mediabox.bottom) * scale page = writer.add_blank_page(width=pw, height=ph) page.merge_transformed_page( src, pypdf.Transformation().scale(scale).translate(dx, dy) ) out = io.BytesIO() writer.write(out) return out.getvalue()
[docs] def add_header_band( page, text: str, *, height: float = DFLT_HEADER_HEIGHT, margin: float = DFLT_MARGIN, font: str = DFLT_FONT_BOLD, font_size: float = 9, gray: float = 0.92, ) -> None: """Extend ``page`` upward by ``height`` and draw ``text`` in the new band (in place).""" _require_reportlab("Document headers") from reportlab.pdfgen import canvas page.transfer_rotation_to_content() box = page.mediabox left, bottom, right, top = ( float(v) for v in (box.left, box.bottom, box.right, box.top) ) buf = io.BytesIO() c = canvas.Canvas(buf, pagesize=(right - left, height)) c.setFillGray(gray) c.rect(0, 0, right - left, height, stroke=0, fill=1) c.setFillGray(0) c.setFont(font, font_size) c.drawString(margin / 2, height / 2 - font_size / 3, text) c.save() band = pypdf.PdfReader(io.BytesIO(buf.getvalue())).pages[0] page.merge_translated_page(band, left, top, expand=True) page.mediabox = RectangleObject([left, bottom, right, top + height]) page.cropbox = page.mediabox
[docs] def render_contents_pdf( entries: list, title: str = DFLT_TOC_TITLE, *, page_size: PageSize = "A4", margin: float = DFLT_MARGIN, line_height: float = 18, font: str = DFLT_FONT, title_font: str = DFLT_FONT_BOLD, font_size: float = 10, title_size: float = 16, ) -> tuple: """Default ``TocRenderer``: a title, then one line per entry with its page range at right. Returns ``(pdf_bytes, link_rects)``. """ _require_reportlab("A contents page") from reportlab.pdfgen import canvas pw, ph = resolve_page_size(page_size) left, right = margin * 1.5, pw - margin * 1.5 buf = io.BytesIO() c = canvas.Canvas(buf, pagesize=(pw, ph)) y = ph - 2 * margin c.setFont(title_font, title_size) c.drawString(left, y, title) y -= 2 * line_height page_idx, rects = 0, [] for i, (key, start, n_pages) in enumerate(entries, 1): if y < 2 * margin: c.showPage() page_idx += 1 y = ph - 2 * margin c.setFont(font, font_size) c.drawString(left, y, f"{i:>2}. {key}") last = start + n_pages - 1 c.drawRightString(right, y, f"p. {start}" + (f"–{last}" if n_pages > 1 else "")) rects.append((page_idx, (left, y - 4, right, y + font_size + 2))) y -= line_height c.save() return buf.getvalue(), rects
def _header_text_func(doc_header) -> Callable | None: if doc_header is None or doc_header is False: return None if doc_header is True: doc_header = "{key}" if isinstance(doc_header, str): template = doc_header return lambda entry: template.format( key=entry.key, start_page=entry.start_page, n_pages=entry.n_pages, ) return lambda entry: doc_header(entry.key) def _layout(toc_renderer, readers, keys, title): """Render the contents so that its own length is consistent with the start pages.""" toc_n = 1 for _ in range(10): entries, start = [], toc_n + 1 for key, reader in zip(keys, readers): entries.append(Entry(key, start, len(reader.pages))) start += len(reader.pages) rendered = toc_renderer(entries, title) toc_bytes, rects = rendered if isinstance(rendered, tuple) else (rendered, []) n = len(pypdf.PdfReader(io.BytesIO(toc_bytes)).pages) if n == toc_n: return entries, toc_bytes, rects toc_n = n raise RuntimeError("Contents page length did not converge") def _add_internal_link(writer, page_idx, rect, target_idx): """Link ``rect`` on page ``page_idx`` to page ``target_idx``. The destination is an indirect reference to the page, as viewers require (an integer page index is only valid for links to other files). """ annot = DictionaryObject( { NameObject("/Type"): NameObject("/Annot"), NameObject("/Subtype"): NameObject("/Link"), NameObject("/Rect"): ArrayObject(FloatObject(v) for v in rect), NameObject("/Border"): ArrayObject(NumberObject(0) for _ in range(3)), NameObject("/Dest"): ArrayObject( [writer.pages[target_idx].indirect_reference, NameObject("/Fit")] ), } ) page = writer.pages[page_idx] if "/Annots" not in page: page[NameObject("/Annots")] = ArrayObject() page["/Annots"].append(writer._add_object(annot))
[docs] def bundle_pdf_bytes( items: Mapping | Iterable, *, toc: bool | TocRenderer = False, doc_header: None | bool | str | Callable[[str], str] = None, outline: bool = False, title: str | None = None, toc_title: str = DFLT_TOC_TITLE, header_height: float = DFLT_HEADER_HEIGHT, ) -> bytes: """Join PDFs, optionally adding a contents page, document headers and bookmarks. :param items: ``{key: pdf_bytes}`` or an iterable of ``(key, pdf_bytes)``, in order. :param toc: ``True`` for the default contents page (``render_contents_pdf``), or a ``TocRenderer`` callable ``(entries, title) -> bytes | (bytes, link_rects)``. :param doc_header: Text of the band added above each document's first page. ``True`` -> the key; a format string using ``{key}``, ``{start_page}``, ``{n_pages}``; or a callable ``key -> str``. :param outline: Add one bookmark per document. :param title: PDF title metadata; also the contents page title if ``toc_title`` is left at its default. :param toc_title: Title of the contents page. :param header_height: Height in points of the header band. >>> import io, pypdf >>> w = pypdf.PdfWriter(); _ = w.add_blank_page(width=200, height=300) >>> buf = io.BytesIO(); _ = w.write(buf); one = buf.getvalue() >>> out = bundle_pdf_bytes({'a.pdf': one, 'b.pdf': one}, toc=True, doc_header='{key}') >>> reader = pypdf.PdfReader(io.BytesIO(out)) >>> len(reader.pages) # 1 contents page + 2 documents 3 >>> float(reader.pages[1].mediabox.height) # 300 + header band 330.0 """ pairs = list(items.items() if isinstance(items, Mapping) else items) keys = [str(k) for k, _ in pairs] readers = [pypdf.PdfReader(io.BytesIO(b)) for _, b in pairs] header_text = _header_text_func(doc_header) if toc is True: toc = render_contents_pdf if toc: entries, toc_bytes, rects = _layout( toc, readers, keys, title if title and toc_title == DFLT_TOC_TITLE else toc_title, ) toc_pages = pypdf.PdfReader(io.BytesIO(toc_bytes)).pages else: entries, start, toc_pages, rects = [], 1, [], [] for key, reader in zip(keys, readers): entries.append(Entry(key, start, len(reader.pages))) start += len(reader.pages) writer = pypdf.PdfWriter() for page in toc_pages: writer.add_page(page) for entry, reader in zip(entries, readers): for j, page in enumerate(reader.pages): if j == 0 and header_text is not None: add_header_band(page, header_text(entry), height=header_height) writer.add_page(page) if outline: writer.add_outline_item(entry.key, entry.start_page - 1) for (toc_idx, rect), entry in zip(rects, entries): _add_internal_link(writer, toc_idx, rect, entry.start_page - 1) if title: writer.add_metadata({"/Title": title}) out = io.BytesIO() writer.write(out) return out.getvalue()