Combining multiple images into a single PDF is a common task for digitizing scanned documents, building catalogs, or archiving photos. Python provides three tools with different approaches for this purpose.
## Tool comparison
| Library | Quality | Loss | Layout control | Speed | Install |
|---|---|---|---|---|---|
| **Pillow** | Good | Yes (recompresses) | Basic | Fast | `pip install pillow` |
| **img2pdf** | **Lossless** | **No** | Limited | **Very fast** | `pip install img2pdf` |
| **ReportLab** | Configurable | Yes | **Full** | Medium | `pip install reportlab` |
## Pillow — simple and quick
```python
from PIL import Image
from pathlib import Path
def images_to_pdf_pillow(image_paths, output_pdf, quality=85):
"""Combine images into a PDF using Pillow."""
images = []
for path in image_paths:
img = Image.open(path)
if img.mode != 'RGB':
img = img.convert('RGB')
images.append(img)
if not images:
raise ValueError("No images found")
images[0].save(
output_pdf,
format='PDF',
save_all=True,
append_images=images[1:],
resolution=150, # output DPI
quality=quality,
)
print(f"PDF created: {output_pdf} ({len(images)} pages)")
# Basic usage
paths = sorted(Path('scans/').glob('*.jpg'))
images_to_pdf_pillow(paths, 'document.pdf')
# Custom order
ordered_paths = ['page_01.jpg', 'page_02.jpg', 'page_03.jpg']
images_to_pdf_pillow(ordered_paths, 'document_ordered.pdf')
```
## img2pdf — lossless quality (recommended for scans)
img2pdf inserts images into the PDF without re-encoding them. A JPEG stays as JPEG inside the PDF — zero additional quality loss:
```python
import img2pdf
from pathlib import Path
# Basic lossless conversion
with open('scans.pdf', 'wb') as f:
f.write(img2pdf.convert(['page1.jpg', 'page2.jpg', 'page3.jpg']))
# From a directory (sorted by name)
directory = Path('scans/')
paths = sorted(directory.glob('*.jpg'))
with open('scans_complete.pdf', 'wb') as f:
f.write(img2pdf.convert([str(p) for p in paths]))
# With A4 page size (in mm)
layout = img2pdf.get_layout_fun(img2pdf.mm_to_pt(210), img2pdf.mm_to_pt(297))
with open('scans_a4.pdf', 'wb') as f:
f.write(img2pdf.convert([str(p) for p in paths], layout_fun=layout))
print(f"Lossless PDF created with {len(paths)} pages")
```
## ReportLab — full layout control
ReportLab lets you position images with millimeter precision, add text, headers, footers, and watermarks:
```python
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Image as RLImage, Spacer
from reportlab.lib.units import cm
from pathlib import Path
def create_pdf_with_layout(image_paths, output_pdf):
"""Create a PDF with controlled layout."""
doc = SimpleDocTemplate(
output_pdf,
pagesize=A4,
topMargin=2*cm, bottomMargin=2*cm,
leftMargin=1.5*cm, rightMargin=1.5*cm,
)
page_width = A4[0] - 3*cm
elements = []
for path in image_paths:
img = RLImage(str(path), width=page_width)
img.hAlign = 'CENTER'
elements.append(img)
elements.append(Spacer(1, 0.5*cm))
doc.build(elements)
print(f"PDF created: {output_pdf}")
paths = sorted(Path('images/').glob('*.jpg'))
create_pdf_with_layout(paths, 'catalog.pdf')
```
## ReportLab — PDF with header, footer and page numbers
```python
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from pathlib import Path
def pdf_with_header(image_paths, output_path, title='Scanned Document'):
width, height = A4
c = canvas.Canvas(str(output_path), pagesize=A4)
margin = 40
for page_num, img_path in enumerate(image_paths, 1):
# Header
c.setFont('Helvetica-Bold', 12)
c.drawString(margin, height - 30, title)
# Footer with page number
c.setFont('Helvetica', 9)
c.drawString(margin, 20, f'Page {page_num} of {len(image_paths)}')
# Center image on page
img_width = width - 2 * margin
img_height = height - 80
reader = ImageReader(str(img_path))
orig_w, orig_h = reader.getSize()
scale = min(img_width / orig_w, img_height / orig_h)
final_w = orig_w * scale
final_h = orig_h * scale
x = (width - final_w) / 2
y = (height - final_h) / 2 - 10
c.drawImage(str(img_path), x, y, width=final_w, height=final_h)
if page_num < len(image_paths):
c.showPage()
c.save()
print(f"PDF with full layout: {output_path} ({len(image_paths)} pages)")
paths = sorted(Path('scans/').glob('*.jpg'))
pdf_with_header(list(paths), 'full_report.pdf', '2024 Audit Report')
```
## Handle mixed image formats
```python
from PIL import Image
from pathlib import Path
import img2pdf, tempfile, os
def mixed_images_to_pdf(directory, output_pdf):
"""Convert JPG+PNG+WEBP to lossless PDF (converts PNG/WEBP to temp JPG)."""
directory = Path(directory)
temp_files = []
jpg_paths = []
try:
for file in sorted(directory.iterdir()):
ext = file.suffix.lower()
if ext in ('.jpg', '.jpeg'):
jpg_paths.append(str(file))
elif ext in ('.png', '.webp', '.bmp', '.tiff', '.tif'):
# Convert to temporary JPG
tmp = tempfile.NamedTemporaryFile(suffix='.jpg', delete=False)
temp_files.append(tmp.name)
img = Image.open(file).convert('RGB')
img.save(tmp.name, 'JPEG', quality=95)
jpg_paths.append(tmp.name)
with open(output_pdf, 'wb') as f:
f.write(img2pdf.convert(jpg_paths))
print(f"PDF created: {output_pdf} ({len(jpg_paths)} pages)")
finally:
for tmp in temp_files:
os.unlink(tmp)
mixed_images_to_pdf('documents/', 'result.pdf')
```
## When to use each tool
- **img2pdf**: scanned documents where preserving original quality is critical. Fastest and lossless.
- **Pillow**: simple conversion when file size matters and slight recompression is acceptable.
- **ReportLab**: catalogs, reports, or any PDF where you need layout control, overlaid text, headers, and footers.
Guide