Everyday Automation Recipes

Chapter 28

Recipes for Photos, PDFs and Files

Page 380 of the printed book · 8 sections

Phones take thousands of photos. Downloads folders fill up with files called document (3).pdf. Somebody has to print forty certificates for a course, each with a different name on it. These jobs are dull, repetitive and easy to get slightly wrong by hand, which makes them perfect for Python.

This chapter has six recipes. Four work with photos, using a library called Pillow, the standard way to work with images in Python. The other two work with PDFs, using the reportlab and pypdf libraries you met in Chapter 17. As in the last chapter, each recipe explains the problem, shows the plan as a flowchart where there are decisions in it, and ends with ideas for making it your own.

Install Pillow first, in your virtual environment (Chapter 23):

Terminal
pip install pillow

The package is called pillow, but in your code you write from PIL import Image. PIL was the name of the older library that Pillow grew out of, and the name stuck.

Practice photos

You may not want to experiment on your real photo library, and you shouldn’t, at least not until a recipe has proved itself. This script makes eight practice photos in a folder called photos, each as big as a phone photo, with a “date taken” recorded inside it just as a camera would. It also adds a text file, so the recipes have something to skip:

Python
1# make_sample_photos.py - practice photos, with "date taken" info
2import random
3from datetime import datetime, timedelta
4from pathlib import Path
5
6from PIL import Image, ImageDraw, ExifTags
7
8random.seed(28)
9HERE = Path(__file__).parent
10FOLDER = HERE / "photos"
11FOLDER.mkdir(exist_ok=True)
12
13start = datetime(2026, 3, 14, 9, 30)
14for i in range(1, 9):
15    # a big, colourful picture, like a phone photo
16    img = Image.new(
17        "RGB",
18        (4000, 3000),
19        (
20            random.randint(40, 200),
21            random.randint(40, 200),
22            random.randint(40, 200),
23        ),
24    )
25    draw = ImageDraw.Draw(img)
26    for _ in range(25):
27        x, y = random.randint(0, 3600), random.randint(0, 2600)
28        size = random.randint(100, 600)
29        colour = tuple(random.randint(0, 255) for _ in range(3))
30        draw.ellipse([x, y, x + size, y + size], fill=colour)
31    taken = start + timedelta(
32        hours=random.randint(0, 72), minutes=random.randint(0, 59)
33    )
34    exif = Image.Exif()
35    exif.get_ifd(ExifTags.IFD.Exif)[
36        ExifTags.Base.DateTimeOriginal
37    ] = taken.strftime("%Y:%m:%d %H:%M:%S")
38    name = f"IMG_{4100 + i}.jpg" if i % 3 else f"DSC0{i:03d}.JPG"
39    img.save(FOLDER / name, quality=95, exif=exif)
40
41(FOLDER / "notes.txt").write_text(
42    "not a photo\n", encoding="utf-8"
43)
44print(f"Made 8 practice photos in {FOLDER.name}/")

That “date taken” lives in the photo’s EXIF information. EXIF is a small block of details that cameras and phones tuck inside every photo: when it was taken, which way up the camera was held, sometimes even where. You can’t see it in the picture, but programs can read it.

#Recipe 28.1: Resize a folder of photos

The job: You want to email thirty holiday photos to a relative, and each one is 4 MB. Or a website wants images no bigger than 2000 pixels. Opening each photo in an editor, resizing it and saving it takes an evening. This recipe takes a few seconds, and it never touches the originals: the smaller copies go into a separate folder.

Resizing: skip anything that isn’t a photo, and save smaller copies
Resizing: skip anything that isn’t a photo, and save smaller copies
Python
1# resize_photos.py - shrink a folder of photos for email or the web
2from pathlib import Path
3
4from PIL import Image, ImageOps, UnidentifiedImageError
5
6HERE = Path(__file__).parent
7SOURCE = HERE / "photos"
8TARGET = HERE / "resized"
9MAX_SIZE = 1600  # longest side, in pixels
10QUALITY = (
11    85  # JPEG quality, 1-95; 85 looks the same, much smaller
12)
13PHOTO_TYPES = {".jpg", ".jpeg", ".png", ".webp"}
14
15
16def resize_one(path, target):
17    with Image.open(path) as img:
18        img = ImageOps.exif_transpose(
19            img
20        )  # turn sideways phone photos
21        img.thumbnail((MAX_SIZE, MAX_SIZE))  # keeps the shape
22        img = img.convert("RGB")  # JPEG has no transparency
23        img.save(target, "JPEG", quality=QUALITY, optimize=True)
24
25
26def main():
27    TARGET.mkdir(exist_ok=True)
28    before = after = count = 0
29    for path in sorted(SOURCE.iterdir()):
30        if path.suffix.lower() not in PHOTO_TYPES:
31            continue
32        target = TARGET / (path.stem + ".jpg")
33        try:
34            resize_one(path, target)
35        except (UnidentifiedImageError, OSError) as error:
36            print(f"  skipped {path.name}: {error}")
37            continue
38        before += path.stat().st_size
39        after += target.stat().st_size
40        count += 1
41    if count:
42        print(f"Resized {count} photo(s) into {TARGET.name}/")
43        print(
44            f"{before / 1e6:.1f} MB -> {after / 1e6:.1f} MB "
45            f"({100 - after / before * 100:.0f}% smaller)"
46        )
47    else:
48        print(f"No photos found in {SOURCE.name}/")
49
50
51if __name__ == "__main__":
52    main()
Output
Resized 8 photo(s) into resized/
2.7 MB -> 0.4 MB (87% smaller)

How it works: Figure 28.1 shows the loop. For each photo, Image.open() opens it, and three lines do the real work:

  • ImageOps.exif_transpose(img) turns the picture the right way up. Phones often save photos sideways and record “rotate this” in the EXIF information instead. Without this line, some of your resized photos would come out lying on their side, which is the classic bug in every photo script.
  • img.thumbnail((MAX_SIZE, MAX_SIZE)) shrinks the photo so that its longest side is at most 1600 pixels, keeping its shape. Despite the name, it isn’t just for thumbnails.
  • img.save(..., quality=85, optimize=True) saves it as a JPEG. A quality of 85 looks the same as the original to almost everyone, and takes far less space.

Anything that Pillow can’t open, such as a damaged file, raises an error that’s caught and reported, so one bad file never stops the rest.

Make it yours:

  • Change MAX_SIZE to 800 for email, or 2000 for a website.
  • Make square thumbnails for a gallery with ImageOps.fit(img, (300, 300)), which crops to fill the square.
  • Convert PNG screenshots to JPEG to save space, or the other way round when you need transparency.

#Recipe 28.2: Rename photos by the date they were taken

The job: Photos from a phone are called IMG_4101.jpg; photos from a camera are called DSC0003.JPG. Put them in one folder and they sort into a meaningless jumble. Name them after the moment they were taken, like 2026-03-14_093000.jpg, and they sort themselves into the order things happened, across every device:

Python
1# rename_by_date.py - name photos after the moment they were taken
2import sys
3from datetime import datetime
4from pathlib import Path
5
6from PIL import Image, ExifTags, UnidentifiedImageError
7
8HERE = Path(__file__).parent
9FOLDER = HERE / "photos"
10PHOTO_TYPES = {".jpg", ".jpeg", ".png", ".heic", ".webp"}
11
12
13def date_taken(path):
14    """The camera's 'date taken', or else the file's modified
15    time."""
16    try:
17        with Image.open(path) as img:
18            exif = img.getexif().get_ifd(ExifTags.IFD.Exif)
19            text = exif.get(ExifTags.Base.DateTimeOriginal)
20        if text:
21            return (
22                datetime.strptime(text, "%Y:%m:%d %H:%M:%S"),
23                "camera",
24            )
25    except (UnidentifiedImageError, OSError, ValueError):
26        pass
27    return (
28        datetime.fromtimestamp(path.stat().st_mtime),
29        "file date",
30    )
31
32
33def free_name(path, taken):
34    """Return path, or path with _2, _3... if that name is taken."""
35    candidate, n = path, 2
36    while candidate.exists() or candidate in taken:
37        candidate = path.with_name(
38            f"{path.stem}_{n}{path.suffix}"
39        )
40        n += 1
41    return candidate
42
43
44def make_plan(folder):
45    plan, taken = [], set()
46    for path in sorted(folder.iterdir()):
47        if path.suffix.lower() not in PHOTO_TYPES:
48            continue
49        when, source = date_taken(path)
50        new = (
51            folder
52            / f"{when:%Y-%m-%d_%H%M%S}{path.suffix.lower()}"
53        )
54        if new.name == path.name:
55            continue  # already done
56        new = free_name(new, taken)
57        taken.add(new)
58        plan.append((path, new, source))
59    return plan
60
61
62def main():
63    plan = make_plan(FOLDER)
64    if not plan:
65        print("Nothing to rename.")
66        return
67    for old, new, source in plan:
68        print(f"  {old.name:<14} -> {new.name}   ({source})")
69    if "--apply" not in sys.argv:
70        print(
71            f"Dry run: {len(plan)} photo(s) would be renamed. "
72            "Run again with --apply."
73        )
74        return
75    for old, new, _ in plan:
76        old.rename(new)
77    print(f"Renamed {len(plan)} photo(s).")
78
79
80if __name__ == "__main__":
81    main()
Output
  DSC0003.JPG    -> 2026-03-16_055000.jpg   (camera)
  DSC0006.JPG    -> 2026-03-15_144600.jpg   (camera)
  IMG_4101.jpg   -> 2026-03-16_124500.jpg   (camera)
  ...
Dry run: 8 photo(s) would be renamed. Run again with --apply.

How it works: The heart of the recipe is date_taken(), shown in Figure 28.2. It opens the photo, reads its EXIF information, and looks for the DateTimeOriginal entry. Cameras write that date in an odd format, 2026:03:14 09:30:00, with colons in the date, so strptime is given that exact pattern to read it. If there’s no date taken (a screenshot, an image from the web, a file that isn’t really a photo), the function falls back to the file’s modified time and says so, so you can see which names to trust.

Where the date comes from: the camera if possible, the file if not
Where the date comes from: the camera if possible, the file if not

The rest is the safe-renaming pattern from Chapter 14: build the full plan first, show it, and only rename with --apply. Two photos taken in the same second, which happens with burst shots, would want the same name, so free_name() adds _2, _3 and so on. Notice that it checks names already planned as well as files already on disk, the lesson from Hannah’s two cameras in Chapter 14.

Make it yours:

  • Sort the photos into folders by month, like 2026-03/, by making the plan’s destination folder / f"{when:%Y-%m}" / name.
  • Add a short word to every name, such as 2026-03-14_093000_lisbon.jpg, by asking for it with input().
  • Keep an undo log, as the organizer in Chapter 14 does.

#Recipe 28.3: Add a watermark to your photos

The job: Hannah, the photographer from Chapter 14, sends clients a preview of their photos before they choose which ones to buy. She wants her name in the corner of every preview, faint enough not to spoil the picture but there all the same:

Python
1# watermark.py - put your name in the corner of every photo
2from pathlib import Path
3
4from PIL import Image, ImageDraw, ImageFont, ImageOps
5
6HERE = Path(__file__).parent
7SOURCE = HERE / "resized"  # watermark the smaller copies
8TARGET = HERE / "watermarked"
9TEXT = "© Hannah Lee Photography"
10OPACITY = 140  # 0 = invisible, 255 = solid
11
12
13def add_watermark(img, text):
14    img = ImageOps.exif_transpose(img).convert("RGBA")
15    layer = Image.new(
16        "RGBA", img.size, (0, 0, 0, 0)
17    )  # transparent
18    draw = ImageDraw.Draw(layer)
19    size = max(16, img.width // 30)  # scale with photo
20    font = ImageFont.load_default(size=size)
21    left, top, right, bottom = draw.textbbox(
22        (0, 0), text, font=font
23    )
24    margin = size
25    x = img.width - (right - left) - margin
26    y = img.height - (bottom - top) - margin
27    draw.text(
28        (x + 2, y + 2), text, font=font, fill=(0, 0, 0, OPACITY)
29    )
30    draw.text(
31        (x, y), text, font=font, fill=(255, 255, 255, OPACITY)
32    )
33    return Image.alpha_composite(img, layer).convert("RGB")
34
35
36def main():
37    if not SOURCE.exists():
38        print(
39            f"No {SOURCE.name}/ folder. Run resize_photos.py first,"
40        )
41        print("or change SOURCE to your own folder of photos.")
42        return
43    TARGET.mkdir(exist_ok=True)
44    count = 0
45    for path in sorted(SOURCE.glob("*.jpg")):
46        with Image.open(path) as img:
47            marked = add_watermark(img, TEXT)
48        marked.save(TARGET / path.name, quality=90)
49        count += 1
50    print(f"Watermarked {count} photo(s) into {TARGET.name}/")
51
52
53if __name__ == "__main__":
54    main()

How it works: Drawing straight onto the photo would make solid, heavy text. To make it see-through, the recipe draws on a separate, completely transparent layer the same size as the photo, using colours with a fourth number, the alpha, which controls how opaque they are: 0 is invisible and 255 is solid. Image.alpha_composite() then lays the text layer over the photo. The text is drawn twice, once in black shifted two pixels and once in white, so it shows up on both light and dark photos.

The font size is worked out from the photo’s width, img.width // 30, so the watermark looks the same size on a small preview and a big print. draw.textbbox() measures how wide and tall the text will be, which is how the recipe tucks it neatly into the bottom-right corner.

A faint watermark in the corner of a practice photo
A faint watermark in the corner of a practice photo

Make it yours:

  • Use a logo instead of text: open a PNG with a transparent background and paste it with img.paste(logo, (x, y), logo).
  • Put the watermark across the middle at a larger size for previews that mustn’t be used at all.
  • Use a nicer font with ImageFont.truetype("path/to/font.ttf", size). Your computer’s fonts are in C:\Windows\Fonts on Windows and /Library/Fonts on a Mac.

#Recipe 28.4: Certificates for everyone on a list

The job: A community college runs short courses, and every student gets a certificate at the end. Typing each name into a Word document and printing it, forty times a term, is how mistakes like misspelled names creep in. This recipe reads the class list and makes one PDF certificate per person, plus a single file containing all of them for printing.

The class list, attendees.csv:

Output
name,course,date
Ana Silva,Introduction to Python,2026-11-20
Raj Patel,Introduction to Python,2026-11-20
Mei Lin,Introduction to Python,2026-11-20
Tomás Ortega,Introduction to Python,2026-11-20
Python
1# certificates.py - a PDF certificate for everyone on a list
2import csv
3import unicodedata
4from datetime import date
5from pathlib import Path
6
7from pypdf import PdfWriter
8from reportlab.lib.pagesizes import A4, landscape
9from reportlab.pdfgen import canvas
10
11HERE = Path(__file__).parent
12ATTENDEES = HERE / "attendees.csv"
13OUTPUT = HERE / "certificates"
14WIDTH, HEIGHT = landscape(A4)
15ORGANISER = "Riverside Community Learning"
16
17
18def make_certificate(path, name, course, when):
19    c = canvas.Canvas(str(path), pagesize=(WIDTH, HEIGHT))
20    c.setLineWidth(3)
21    c.rect(30, 30, WIDTH - 60, HEIGHT - 60)  # a simple border
22    c.setLineWidth(1)
23    c.rect(40, 40, WIDTH - 80, HEIGHT - 80)
24    centre = WIDTH / 2
25    c.setFont("Helvetica", 20)
26    c.drawCentredString(
27        centre, HEIGHT - 150, "Certificate of Completion"
28    )
29    c.setFont("Helvetica", 14)
30    c.drawCentredString(
31        centre, HEIGHT - 210, "This is to certify that"
32    )
33    c.setFont("Helvetica-Bold", 36)
34    c.drawCentredString(centre, HEIGHT - 270, name)
35    c.setFont("Helvetica", 14)
36    c.drawCentredString(
37        centre, HEIGHT - 320, "has successfully completed"
38    )
39    c.setFont("Helvetica-Bold", 20)
40    c.drawCentredString(centre, HEIGHT - 355, course)
41    c.setFont("Helvetica", 12)
42    c.drawCentredString(
43        centre, 110, f"{ORGANISER}, {when:%d %B %Y}"
44    )
45    c.save()
46
47
48def safe_file_name(name):
49    """'Tomás Ortega' -> 'tomas_ortega', safe on any computer."""
50    plain = unicodedata.normalize("NFKD", name)
51    plain = plain.encode("ascii", "ignore").decode()  # á -> a
52    keep = "".join(
53        ch if ch.isalnum() else "_" for ch in plain.lower()
54    )
55    return keep.strip("_")
56
57
58def main():
59    OUTPUT.mkdir(exist_ok=True)
60    made = []
61    with open(ATTENDEES, newline="", encoding="utf-8") as file:
62        for row in csv.DictReader(file):
63            when = date.fromisoformat(row["date"])
64            path = (
65                OUTPUT
66                / f"certificate_{safe_file_name(row['name'])}.pdf"
67            )
68            make_certificate(
69                path, row["name"], row["course"], when
70            )
71            made.append(path)
72            print(f"  {path.name}")
73
74    everyone = PdfWriter()  # one file for printing
75    for path in made:
76        everyone.append(path)
77    everyone.write(OUTPUT / "all_certificates.pdf")
78    print(
79        f"Made {len(made)} certificate(s),"
80        " plus all_certificates.pdf"
81    )
82
83
84if __name__ == "__main__":
85    main()
One of the certificates the recipe makes
One of the certificates the recipe makes

How it works: reportlab draws on a page as if it were graph paper, with the point (0, 0) at the bottom-left corner, so text near the top of the page has a large y value. landscape(A4) turns the page sideways, rect() draws the double border, and drawCentredString() centres each line on the page, which is what makes a certificate look formal. Then pypdf’s PdfWriter from Chapter 17 joins all the certificates into one file for printing.

Notice safe_file_name(). Names with accents, like Tomás, are perfectly fine inside the PDF, but they can cause trouble in file names on some systems and when files are emailed or zipped. So the file name uses a plain version, certificate_tomas_ortega.pdf, while the certificate itself keeps the accent. unicodedata.normalize("NFKD", ...) splits “á” into “a” plus an accent mark, and encode("ascii", "ignore") drops the mark.

Make it yours:

  • Add a logo with c.drawImage("logo.png", x, y, width, height).
  • Email each person their own certificate, by combining this with the mail merge from Recipe 27.3.
  • Add a unique certificate number to each one, so they can be checked later.

#Recipe 28.5: Find duplicate files

The job: After years of copying photos from phones, backing up the backup, and downloading the same attachment twice, the same file often exists three or four times under different names. This recipe finds files whose contents are identical, whatever they’re called and wherever they are in a folder:

Python
1# find_duplicates.py - find identical files, whatever their names
2import hashlib
3import shutil
4import sys
5from pathlib import Path
6
7HERE = Path(__file__).parent
8FOLDER = HERE / "photos"
9DUPLICATES = HERE / "duplicates"
10
11
12def fingerprint(path):
13    """A SHA-256 hash of the file's contents, read in chunks."""
14    digest = hashlib.sha256()
15    with open(path, "rb") as file:
16        for chunk in iter(lambda: file.read(1024 * 1024), b""):
17            digest.update(chunk)
18    return digest.hexdigest()
19
20
21def find_groups(folder):
22    """Return lists of paths whose contents are exactly the same."""
23    by_size = {}
24    for path in folder.rglob("*"):
25        if path.is_file():
26            by_size.setdefault(path.stat().st_size, []).append(
27                path
28            )
29    groups = []
30    for same_size in by_size.values():
31        if len(same_size) < 2:
32            continue  # a unique size can't be a duplicate
33        by_hash = {}
34        for path in same_size:
35            by_hash.setdefault(fingerprint(path), []).append(path)
36        groups += [g for g in by_hash.values() if len(g) > 1]
37    return groups
38
39
40def main():
41    groups = find_groups(FOLDER)
42    if not groups:
43        print("No duplicates found.")
44        return
45    extra = 0
46    for group in groups:
47        group.sort(
48            key=lambda p: p.stat().st_mtime
49        )  # oldest first
50        keep, *copies = group
51        print(f"Keeping {keep.relative_to(FOLDER)}")
52        for copy in copies:
53            print(f"  duplicate: {copy.relative_to(FOLDER)}")
54            extra += copy.stat().st_size
55    print(f"{extra / 1e6:.1f} MB could be freed.")
56
57    if "--move" in sys.argv:
58        DUPLICATES.mkdir(exist_ok=True)
59        for group in groups:
60            for copy in group[1:]:
61                target = DUPLICATES / copy.name
62                n = 2
63                while target.exists():
64                    target = (
65                        DUPLICATES
66                        / f"{copy.stem}_{n}{copy.suffix}"
67                    )
68                    n += 1
69                shutil.move(str(copy), str(target))
70        print(
71            f"Moved the duplicates into {DUPLICATES.name}/. "
72            "Check them, then delete the folder yourself."
73        )
74    else:
75        print(
76            "Nothing moved. Run with --move to move the"
77            " copies aside."
78        )
79
80
81if __name__ == "__main__":
82    main()
Output
Keeping IMG_4101.jpg
  duplicate: IMG_4101 (copy).jpg
Keeping IMG_4102.jpg
  duplicate: old/holiday.jpg
0.7 MB could be freed.
Nothing moved. Run with --move to move the copies aside.

How it works: Comparing every file with every other file, byte by byte, would take forever on a big folder. The recipe uses two tricks, shown in Figure 28.5. First, files of different sizes can’t possibly be the same, so it groups files by size, which is instant, and ignores any file whose size is unique. Only files that share a size are read at all.

Duplicates: compare sizes first, then fingerprints
Duplicates: compare sizes first, then fingerprints

Second, for those, it works out a fingerprint with hashlib.sha256(). A hash function reads a file and produces a short string of 64 characters that depends on every byte in it. Change a single byte and the fingerprint changes completely; two files with the same fingerprint are, for all practical purposes, identical. The file is read in chunks of one megabyte, with iter(lambda: file.read(...), b""), so even a huge video never has to fit in memory at once.

For each group, the oldest file is kept and the rest are listed. With --move, the copies are moved to a duplicates folder rather than deleted, so that you can check them before deleting them yourself. A program that deletes your files on its own is a program that will one day delete the wrong one.

Make it yours:

  • Point FOLDER at your whole Pictures folder (it may take a few minutes the first time).
  • Write the results to a CSV file with columns for the group, the kept file and each duplicate, so you can review them in a spreadsheet.
  • Only look at photos and videos, by checking path.suffix before adding a file to the size groups.

#Recipe 28.6: Rename downloaded PDFs by what’s inside them

The job: Invoices arrive as document.pdf, document (1).pdf and scan_0001.pdf. Finding the one for Omar’s October job means opening them one by one. This recipe reads each PDF, finds the invoice number, the date and the client, and renames the file to say so: 2026-10-09_INV-1043_Omar-Haddad.pdf.

This script makes four practice PDFs with typical download names, written slightly differently from each other, as real invoices are:

Python
1# make_sample_downloads.py - PDFs with typical download names
2from pathlib import Path
3
4from reportlab.lib.pagesizes import A4
5from reportlab.pdfgen import canvas
6
7HERE = Path(__file__).parent
8FOLDER = HERE / "downloads"
9FOLDER.mkdir(exist_ok=True)
10
11DOCS = [
12    (
13        "document.pdf",
14        [
15            "Invoice INV-1042",
16            "Date: 2026-10-03",
17            "Client: Lin Chen",
18            "Total: $880.00",
19        ],
20    ),
21    (
22        "document (1).pdf",
23        [
24            "TAX INVOICE  inv-1043",
25            "Issued 09/10/2026",
26            "Client: Omar Haddad",
27            "Amount due $1,200.00",
28        ],
29    ),
30    (
31        "scan_0001.pdf",
32        [
33            "Invoice number: INV-1044",
34            "Date: 2026-10-15",
35            "Client: Priya Nair",
36            "Total: $100.50",
37        ],
38    ),
39    (
40        "receipt.pdf",
41        [
42            "Thank you for shopping with us!",
43            "No invoice " "number on this one.",
44        ],
45    ),
46]
47
48for name, lines in DOCS:
49    c = canvas.Canvas(str(FOLDER / name), pagesize=A4)
50    y = 780
51    for line in lines:
52        c.drawString(72, y, line)
53        y -= 22
54    c.save()
55print(f"Made {len(DOCS)} PDFs in {FOLDER.name}/")
Renaming PDFs: read, search, and only rename when the facts are found
Renaming PDFs: read, search, and only rename when the facts are found
Python
1# rename_pdfs.py - give downloaded PDFs names that say what they are
2import re
3import sys
4from datetime import datetime
5from pathlib import Path
6
7from pypdf import PdfReader
8
9HERE = Path(__file__).parent
10FOLDER = HERE / "downloads"
11
12INVOICE = re.compile(r"\bINV-(\d+)\b", re.IGNORECASE)
13ISO_DATE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
14AU_DATE = re.compile(r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b")
15CLIENT = re.compile(r"Client:\s*(.+)")
16
17
18def first_page_text(path):
19    try:
20        return PdfReader(path).pages[0].extract_text() or ""
21    except Exception:
22        return ""
23
24
25def find_date(text):
26    m = ISO_DATE.search(text)
27    if m:
28        return datetime(*map(int, m.groups())).date()
29    m = AU_DATE.search(text)
30    if m:
31        day, month, year = map(int, m.groups())
32        return datetime(year, month, day).date()
33    return None
34
35
36def new_name(text):
37    """A name like 2026-10-03_INV-1042_Lin-Chen.pdf, or None."""
38    invoice = INVOICE.search(text)
39    when = find_date(text)
40    if not invoice or not when:
41        return None
42    parts = [when.isoformat(), f"INV-{invoice.group(1)}"]
43    client = CLIENT.search(text)
44    if client:
45        parts.append(re.sub(r"\W+", "-", client.group(1).strip()))
46    return "_".join(parts) + ".pdf"
47
48
49def main():
50    plan, unknown, planned = [], [], set()
51    for path in sorted(FOLDER.glob("*.pdf")):
52        name = new_name(first_page_text(path))
53        if name is None:
54            unknown.append(path.name)
55        elif name == path.name:
56            continue  # already named
57        elif (FOLDER / name).exists() or name in planned:
58            print(
59                f"  {path.name}: {name} already exists, so skipped"
60            )
61        else:
62            plan.append((path, FOLDER / name))
63            planned.add(name)
64    for old, new in plan:
65        print(f"  {old.name:<18} -> {new.name}")
66    for name in unknown:
67        print(
68            f"  {name:<18}    (no number or date found: left alone)"
69        )
70    if "--apply" in sys.argv:
71        for old, new in plan:
72            old.rename(new)
73        print(f"Renamed {len(plan)} file(s).")
74    else:
75        print("Dry run. Run again with --apply to rename.")
76
77
78if __name__ == "__main__":
79    main()
Output
  document (1).pdf   -> 2026-10-09_INV-1043_Omar-Haddad.pdf
  document.pdf       -> 2026-10-03_INV-1042_Lin-Chen.pdf
  scan_0001.pdf      -> 2026-10-15_INV-1044_Priya-Nair.pdf
  receipt.pdf           (no number or date found: left alone)
Dry run. Run again with --apply to rename.

How it works: This recipe brings together Chapter 17’s PDF reading and Chapter 16’s regular expressions, as Figure 28.6 shows. first_page_text() extracts the text of page one. Then four patterns search it. INVOICE finds INV-1043 whether it’s written in capitals or not, thanks to re.IGNORECASE. There are two date patterns, because one supplier writes 2026-10-03 and another writes 09/10/2026, and find_date() tries both, turning either into a proper date. CLIENT picks up whatever follows “Client:”.

The new name puts the date first, so the files sort by date, then the invoice number, then the client’s name with any spaces or odd characters replaced by hyphens. A PDF where the number or the date can’t be found, like the shop receipt, is left alone and reported, rather than given a half-guessed name. Two PDFs that would end up with the same name are caught too, so nothing is ever renamed over another file.

Make it yours:

  • Move each renamed file into a folder per client, or per month.
  • Add a pattern for your own suppliers’ layouts, and try each pattern in turn, as Ruth did in Chapter 17.
  • Write a CSV index of every invoice found, with its date, number, client and total, as you rename.

#On the Job

How it shows up at work: Every organisation handles images and documents in bulk. Marketing teams resize product photos for websites. Estate agents watermark property photos. Schools and training companies produce certificates every term. Accounts teams rename and file hundreds of PDFs a month. In technical jobs, the same skills appear in preparing image datasets for machine learning, generating PDF reports from data, and cleaning up shared drives that have grown out of control.

What employers expect: Never change originals: work on copies, write to a separate folder, or rename only after a dry run. Handle the files that aren’t what you expect, because there’s always one. Know that image and PDF processing can be slow, so test on a small folder before a big one. And think about privacy: photos can contain the location they were taken in, and invoices contain people’s details.

Interview questions you might meet:

  • “How would you find duplicate files in a folder of 100,000 files?” Group by size first, then compare hashes only within groups of the same size. Explain why that’s much faster than comparing everything.
  • “What is a hash?” A short fingerprint calculated from data. The same data always gives the same hash; any change gives a completely different one. Hashes are used to check files haven’t been changed, to find duplicates, and (with special slow hash functions) to store passwords.
  • “Why do some photos come out sideways after resizing?” Phones often store the picture sideways and record the rotation in the EXIF information. You need to apply that rotation, with ImageOps.exif_transpose() in Pillow, before resizing.
  • “What’s EXIF, and is there any privacy concern?” Information stored inside a photo, such as the camera, the date and sometimes the GPS location. Before publishing photos, it’s good practice to remove it.

Portfolio task: Combine three recipes into one photo tool with subcommands, using argparse from Chapter 24: photos rename, photos resize, and photos dupes. Include make_sample_photos.py so anyone can try it safely, and show before-and-after folder listings in the README.

Professional habit: For any batch job on files, keep a log of exactly what was changed: old name, new name, time. The day someone asks “what happened to my file?”, the log is the answer.

#Exercises

Solutions are in solutions/chapter_28 in the book’s GitHub repository. Each one makes its own practice files first, so it’s safe to run.

Warm-up

Exercise 28.1. Open a photo with Pillow and print its width, height, format (such as JPEG) and mode (such as RGB). Then print its size in megapixels.

Exercise 28.2. Make a black-and-white copy of a photo with img.convert("L") and save it with _bw added to the name.

Exercise 28.3. Print the date taken for every photo in the photos folder, using date_taken() from Recipe 28.2, sorted from oldest to newest.

Practice

Exercise 28.4. Make square 300 by 300 thumbnails of every photo, using ImageOps.fit(), and save them in a thumbnails folder.

Exercise 28.5. Make a contact sheet: a single image showing small versions of every photo in a grid, four across, with the file name under each. (Create a large blank image with Image.new() and paste() each thumbnail into it.)

Exercise 28.6. Change certificates.py so that names longer than 20 characters are printed in a smaller font, so they never run off the page.

Exercise 28.7. Write a script that strips all EXIF information from copies of your photos before you share them online. (Saving a copy without passing exif= leaves it out.) Check the result by reading the EXIF of a copy.

Exercise 28.8. Make find_duplicates.py write its results to duplicates.csv, with one row per duplicate: the file to keep, the duplicate, and its size in KB.

Challenge

Exercise 28.9. Write photos_by_month.py, which copies (not moves) every photo into folders named by the year and month it was taken, like 2026-03/, using date_taken(). It should never overwrite a file, and should report how many photos went into each folder.

Exercise 28.10. Make name badges for an event: read attendees.csv and make one A4 PDF with eight badges per page (two across, four down), each showing a name in large letters and the course underneath. Start a new page when one is full.

Exercise 28.11. Extend rename_pdfs.py so that, as well as renaming, it writes invoices.csv with the date, invoice number, client and total of every invoice it found. Add a pattern for the total, like \$([\d,]+\.\d{2}).