PDF & IMG · 5 min read · September 26, 2026

Add Watermark to Image and PDF Using Python

Learn how to add text and logo watermarks to images and PDFs using Python, Pillow, pypdf, and ReportLab with reusable code and production-ready techniques.

HA

Hassan Agmir

Author at Filenewer

Share:
Add Watermark to Image and PDF Using Python

Watermarking files looks like a small feature until you actually have to build it into a real application. At first, the requirement may sound simple: take an image or PDF, place a logo or a text such as “CONFIDENTIAL” on top of it, and save the result. In practice, however, a reliable watermarking system has to deal with transparency, different image formats, page dimensions, PDF coordinate systems, rotated pages, font rendering, image quality, file sizes, batch processing, user uploads, and the important question of whether the original file should remain untouched.

Python is particularly well suited to this kind of automation because it has mature libraries for image manipulation and PDF processing. For images, Pillow provides the building blocks needed to open, resize, transform, and composite images, including alpha-aware compositing for RGBA images. For PDFs, pypdf provides page-merging operations that allow one PDF page to be layered over or under another, with support for transformations such as translation, rotation, and scaling.

The interesting part is that the same basic idea appears in two different forms. With an image, you normally have pixels, so watermarking is essentially a composition operation: load the original image, create a watermark layer, position it, control its opacity, and combine the layers. With a PDF, you are often working with a structured document in which text, vector graphics, images, and page resources are represented separately. The safest general approach is therefore to create a separate watermark page or overlay, then merge it with each source page.

This article walks through the complete process. We will begin with the simplest watermarking examples, then progressively build reusable functions, support logos and text, add diagonal and repeated watermarks, process entire directories, create PDF watermark layers, preserve page dimensions, handle different page sizes, expose watermarking through an API, and discuss production concerns such as security, memory consumption, file cleanup, and testing.

The goal is not merely to show a few lines of code. The goal is to help you understand the architecture behind watermarking so that you can adapt the solution to a Flask application, Django project, FastAPI service, CLI tool, batch-processing script, document-management system, internal company utility, or SaaS product.

Why Add Watermarks with Python?

A watermark is generally used to associate a file with an owner, creator, company, website, department, workflow, or intended purpose. It can also communicate document status. A photo may contain a semi-transparent logo in the corner. A preview image may contain a diagonal “SAMPLE” message. A business document may contain “CONFIDENTIAL”, “INTERNAL USE ONLY”, or a department name. A PDF report may contain a company logo behind the content of every page.

The technical purpose of the watermark varies depending on the application. Sometimes it is primarily branding. Sometimes it is intended to discourage casual redistribution. Sometimes it communicates status. In other cases, it is part of a document workflow.

It is important to understand one practical limitation from the beginning: a visible watermark is not the same thing as strong access control or digital rights management. A watermark can make a document harder to reuse without attribution, but it does not cryptographically prevent copying. Someone can potentially crop an image, edit a PDF, cover the watermark, reconstruct a file, or use other techniques to remove it. For sensitive workflows, watermarking should therefore be treated as one layer of protection rather than the entire security strategy.

Python is useful because watermarking is easy to automate. Instead of manually opening files in a desktop editor, you can create a service that accepts an upload, validates it, applies a standard watermark, stores the result, and returns it to a user.

Imagine a website where photographers upload images. The website could automatically generate three versions:

  • the original private image

  • a public preview with a logo watermark

  • a smaller thumbnail with a stronger watermark

The same principle works for documents. A company could automatically generate customer previews of PDF reports, watermark every page with the customer name, and archive the original separately.

Once watermarking becomes part of a pipeline rather than a manual editing task, Python becomes especially powerful.

The Basic Watermarking Model

Most watermarking workflows can be understood as four layers of responsibility:

  1. Load the source file.

  2. Create or load the watermark.

  3. Position and transform the watermark.

  4. Save the final result.

For an image, this can be visualized as:

Original Image
      |
      v
Create Watermark Layer
      |
      v
Resize / Rotate / Position
      |
      v
Alpha Composite
      |
      v
Watermarked Image

For a PDF, the equivalent idea is:

Source PDF Page
      |
      +----------------------+
      |                      |
      v                      v
Read Page             Create Watermark PDF
                              |
                              v
                       Position / Rotate
                              |
                              v
                     Merge Watermark Layer
                              |
                              v
                       Watermarked Page
                              |
                              v
                       Output PDF

The terminology changes slightly, but the architecture remains similar.

Installing the Required Python Libraries

For image processing, install Pillow:

pip install pillow

For PDF reading, writing, and page merging, install pypdf:

pip install pypdf

For generating PDF watermark pages, ReportLab is useful:

pip install reportlab

A typical project might use:

pip install pillow pypdf reportlab

You can verify that everything is available with:

from PIL import Image
from pypdf import PdfReader, PdfWriter
from reportlab.pdfgen import canvas

print("All libraries are installed.")

For a real application, it is a good practice to isolate the dependencies inside a virtual environment:

python -m venv .venv

Activate it on Windows:

.venv\Scripts\Activate.ps1

Or on Linux and macOS:

source .venv/bin/activate

Then install your dependencies.

A simple requirements.txt file could contain:

Pillow
pypdf
reportlab

For production deployment, pin versions after testing your application rather than relying forever on floating versions:

Pillow==13.0.0
pypdf==6.19.0
reportlab==4.4.4

Those exact versions are examples for a controlled environment. Check the versions appropriate for your deployment target and test them together before deploying.

Understanding Transparency and Alpha Channels

Transparency is one of the most important concepts in watermarking.

Consider a black logo placed over a photograph. If the logo is completely opaque, it may be visually distracting. If the logo is partially transparent, the photograph can remain visible underneath.

The alpha channel represents this transparency.

A standard RGBA pixel has four components:

R = Red
G = Green
B = Blue
A = Alpha

The alpha value controls opacity.

Conceptually:

Alpha = 255  -> fully opaque
Alpha = 128  -> approximately half opaque
Alpha = 0    -> fully transparent

Pillow supports alpha-aware compositing through methods such as alpha_composite(). Its documentation specifies RGBA/LA images and requires matching image mode and size for the standalone Image.alpha_composite() function.

For watermarking, this is extremely useful because we can create a transparent layer the same size as the original image, draw the watermark on that layer, and then composite the two together.

The Simplest Text Watermark with Pillow

Let's start with a simple text watermark.

Create a file called:

watermark_image.py

Then:

from PIL import Image, ImageDraw, ImageFont

input_path = "input.jpg"
output_path = "output_watermarked.jpg"

image = Image.open(input_path).convert("RGBA")

overlay = Image.new("RGBA", image.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(overlay)

font = ImageFont.truetype("arial.ttf", 48)

text = "MY WEBSITE"

draw.text(
    (30, 30),
    text,
    font=font,
    fill=(255, 255, 255, 150),
)

result = Image.alpha_composite(image, overlay)

result.convert("RGB").save(output_path, "JPEG", quality=92)

print(f"Saved: {output_path}")

The important part is not the amount of code. It is the separation between the source image and the transparent overlay.

First:

image = Image.open(input_path).convert("RGBA")

We convert the source to RGBA so that it can participate cleanly in alpha compositing.

Next:

overlay = Image.new("RGBA", image.size, (255, 255, 255, 0))

The final value 0 represents a transparent background.

Then we draw text onto that transparent layer:

draw.text(
    (30, 30),
    text,
    font=font,
    fill=(255, 255, 255, 150),
)

The alpha value 150 makes the white text partially transparent.

Finally:

result = Image.alpha_composite(image, overlay)

The watermark layer is composited over the original.

Handling Fonts Correctly

A common problem in tutorials is using:

ImageFont.truetype("arial.ttf", 48)

and assuming that the font will always exist.

That is not true.

A font installed on Windows may not be installed on Linux. A font available on a developer machine may not exist inside a Docker container. A server image may contain only a few system fonts.

For a production project, keep a known font file inside your application or deployment image, subject to the font's licensing terms.

For example:

project/
├── fonts/
│   └── DejaVuSans.ttf
├── input/
├── output/
└── watermark.py

Then:

font = ImageFont.truetype(
    "fonts/DejaVuSans.ttf",
    48,
)

This makes the rendering much more predictable.

You should also keep the font size configurable:

font_size = 48

font = ImageFont.truetype(
    "fonts/DejaVuSans.ttf",
    font_size,
)

For dynamic watermarking systems, the font size should often depend on the image size.

A watermark that looks perfect on a 4000×3000 image may be almost invisible on a 640×480 thumbnail.

Creating a Reusable Text Watermark Function

Instead of writing the process directly in the global scope, create a function:

from pathlib import Path
from PIL import Image, ImageDraw, ImageFont


def add_text_watermark(
    input_path: str | Path,
    output_path: str | Path,
    text: str,
    font_path: str | Path,
    font_size: int = 48,
    opacity: int = 128,
    margin: int = 30,
) -> None:
    image = Image.open(input_path).convert("RGBA")

    overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(overlay)

    font = ImageFont.truetype(font_path, font_size)

    bbox = draw.textbbox((0, 0), text, font=font)

    text_width = bbox[2] - bbox[0]
    text_height = bbox[3] - bbox[1]

    x = image.width - text_width - margin
    y = image.height - text_height - margin

    draw.text(
        (x, y),
        text,
        font=font,
        fill=(255, 255, 255, opacity),
    )

    result = Image.alpha_composite(image, overlay)

    result.convert("RGB").save(
        output_path,
        "JPEG",
        quality=92,
    )

Now the function can be used like this:

add_text_watermark(
    input_path="input/photo.jpg",
    output_path="output/photo_watermarked.jpg",
    text="example.com",
    font_path="fonts/DejaVuSans.ttf",
    font_size=48,
    opacity=120,
)

This is already much more reusable.

Why the Bottom-Right Corner Is Common

The bottom-right corner is popular because it keeps the watermark visible while normally avoiding the center of the subject.

However, placement is not a technical rule. It is a design decision.

Possible locations include:

+--------------------------------+
| TOP LEFT          TOP RIGHT    |
|                                |
|                                |
|            CENTER              |
|                                |
|                                |
| BOTTOM LEFT     BOTTOM RIGHT   |
+--------------------------------+

You can make this a parameter:

position = "bottom-right"

Then calculate coordinates based on the chosen position.

Building a General Image Position Function

A cleaner approach is to separate positioning from rendering.

def calculate_position(
    image_size: tuple[int, int],
    watermark_size: tuple[int, int],
    position: str,
    margin: int = 30,
) -> tuple[int, int]:
    image_width, image_height = image_size
    watermark_width, watermark_height = watermark_size

    if position == "top-left":
        return margin, margin

    if position == "top-right":
        return (
            image_width - watermark_width - margin,
            margin,
        )

    if position == "bottom-left":
        return (
            margin,
            image_height - watermark_height - margin,
        )

    if position == "bottom-right":
        return (
            image_width - watermark_width - margin,
            image_height - watermark_height - margin,
        )

    if position == "center":
        return (
            (image_width - watermark_width) // 2,
            (image_height - watermark_height) // 2,
        )

    raise ValueError(
        f"Unsupported position: {position}"
    )

This lets the rest of the application stay simple.

For example:

position = calculate_position(
    image.size,
    (text_width, text_height),
    "bottom-right",
)

The advantage is architectural: if you later add more positions or alignment rules, you do not have to rewrite the watermark rendering code.

Automatically Choosing Font Size

A robust application should rarely use exactly the same font size for every image.

Instead, estimate the size from the image width.

For example:

font_size = max(24, image.width // 30)

For a 3000-pixel-wide image:

3000 / 30 = 100

For a 900-pixel-wide image:

900 / 30 = 30

This produces more consistent visual proportions.

A function makes this cleaner:

def calculate_font_size(
    image_width: int,
    ratio: int = 30,
    minimum: int = 24,
    maximum: int = 120,
) -> int:
    size = image_width // ratio

    return max(
        minimum,
        min(size, maximum),
    )

Then:

font_size = calculate_font_size(image.width)

This is not mathematically perfect for every aspect ratio or language, but it is a useful baseline.

Adding an Image Logo as a Watermark

Text is useful, but many real applications want to add a logo.

Suppose you have:

logo.png

with a transparent background.

A basic implementation is:

from PIL import Image

base = Image.open("input.jpg").convert("RGBA")
logo = Image.open("logo.png").convert("RGBA")

logo.thumbnail((300, 300))

x = base.width - logo.width - 30
y = base.height - logo.height - 30

base.alpha_composite(
    logo,
    dest=(x, y),
)

base.convert("RGB").save(
    "output.jpg",
    "JPEG",
    quality=92,
)

This approach is excellent when your logo already contains the desired transparency.

The important detail is:

.convert("RGBA")

Without an alpha-capable mode, transparent PNG behavior can become confusing.

Creating a Transparent Logo Watermark

Sometimes your logo is too strong.

You may want to reduce its opacity without editing the source file.

Use:

from PIL import Image

logo = Image.open("logo.png").convert("RGBA")

alpha = logo.getchannel("A")
alpha = alpha.point(lambda value: int(value * 0.35))

logo.putalpha(alpha)

The 0.35 factor means approximately 35% of the original alpha is retained.

You can turn this into a helper:

def set_opacity(image: Image.Image, opacity: float) -> Image.Image:
    image = image.convert("RGBA")

    if not 0.0 <= opacity <= 1.0:
        raise ValueError(
            "Opacity must be between 0.0 and 1.0"
        )

    alpha = image.getchannel("A")
    alpha = alpha.point(
        lambda value: int(value * opacity)
    )

    image.putalpha(alpha)

    return image

Then:

logo = Image.open("logo.png")
logo = set_opacity(logo, 0.35)

This gives your application a clean way to expose opacity as a user setting.

Resizing a Logo Without Distorting It

Never assume that you can simply assign arbitrary width and height values.

If the original logo is:

1600 × 800

and you force it into:

300 × 300

it will become distorted.

Instead, preserve its aspect ratio:

logo.thumbnail((300, 300))

This tells Pillow to fit the image inside the specified dimensions while maintaining its proportions.

For more advanced applications, you might calculate a size as a percentage of the base image:

max_width = int(base.width * 0.18)
max_height = int(base.height * 0.18)

logo.thumbnail((max_width, max_height))

This is often more suitable for batch processing because a logo occupies a consistent percentage of the image regardless of source resolution.

A Production-Friendly Logo Watermark Function

Let's combine these ideas:

from pathlib import Path
from PIL import Image


def add_logo_watermark(
    input_path: str | Path,
    logo_path: str | Path,
    output_path: str | Path,
    position: str = "bottom-right",
    opacity: float = 0.35,
    scale: float = 0.18,
    margin: int = 30,
) -> None:

    if not 0 < scale <= 1:
        raise ValueError(
            "Scale must be between 0 and 1."
        )

    if not 0 <= opacity <= 1:
        raise ValueError(
            "Opacity must be between 0 and 1."
        )

    base = Image.open(input_path).convert("RGBA")
    logo = Image.open(logo_path).convert("RGBA")

    max_width = max(
        1,
        int(base.width * scale),
    )

    max_height = max(
        1,
        int(base.height * scale),
    )

    logo.thumbnail(
        (max_width, max_height)
    )

    alpha = logo.getchannel("A")
    alpha = alpha.point(
        lambda value: int(value * opacity)
    )

    logo.putalpha(alpha)

    x, y = calculate_position(
        image_size=base.size,
        watermark_size=logo.size,
        position=position,
        margin=margin,
    )

    base.alpha_composite(
        logo,
        dest=(x, y),
    )

    base.convert("RGB").save(
        output_path,
        "JPEG",
        quality=92,
    )

Usage:

add_logo_watermark(
    input_path="input/photo.jpg",
    logo_path="assets/logo.png",
    output_path="output/photo.jpg",
    position="bottom-right",
    opacity=0.35,
    scale=0.18,
)

This pattern is suitable for many website backends.

Supporting PNG Output

JPEG is not always appropriate.

If you need to preserve transparency in the final result, save as PNG:

result.save(
    "output.png",
    "PNG",
)

If the source itself is PNG and the final image should preserve transparency:

result.save(
    output_path,
    format="PNG",
)

You can choose the output format based on your application.

For example:

suffix = Path(output_path).suffix.lower()

if suffix == ".png":
    result.save(output_path, "PNG")
elif suffix in {".jpg", ".jpeg"}:
    result.convert("RGB").save(
        output_path,
        "JPEG",
        quality=92,
    )
else:
    raise ValueError(
        f"Unsupported output format: {suffix}"
    )

Why PNG and JPEG Need Different Treatment

PNG commonly supports lossless compression and transparency. JPEG generally does not support an alpha channel.

Therefore, this:

result = Image.alpha_composite(
    image,
    overlay,
)

produces an RGBA result, but saving it directly as JPEG is inappropriate.

Convert it:

result.convert("RGB")

Then save:

result.convert("RGB").save(
    "output.jpg",
    quality=92,
)

This is a small detail, but it prevents one of the most common mistakes when following image-watermarking examples.

Adding a Text Watermark to the Center

A centered watermark is useful for preview images.

from PIL import Image, ImageDraw, ImageFont

image = Image.open("input.jpg").convert("RGBA")

overlay = Image.new(
    "RGBA",
    image.size,
    (0, 0, 0, 0),
)

draw = ImageDraw.Draw(overlay)

font = ImageFont.truetype(
    "fonts/DejaVuSans.ttf",
    80,
)

text = "PREVIEW"

bbox = draw.textbbox(
    (0, 0),
    text,
    font=font,
)

width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]

x = (image.width - width) // 2
y = (image.height - height) // 2

draw.text(
    (x, y),
    text,
    font=font,
    fill=(255, 255, 255, 100),
)

result = Image.alpha_composite(
    image,
    overlay,
)

result.convert("RGB").save(
    "preview.jpg",
    "JPEG",
    quality=90,
)

This produces a centrally positioned translucent watermark.

Creating a Diagonal Watermark

Diagonal watermarks are common for sample images because they are harder to remove by simply cropping a small corner.

The technique is straightforward:

  1. Create the watermark.

  2. Rotate it.

  3. Place it on the image.

  4. Composite the layers.

With Pillow:

from PIL import Image, ImageDraw, ImageFont

image = Image.open("input.jpg").convert("RGBA")

overlay = Image.new(
    "RGBA",
    image.size,
    (0, 0, 0, 0),
)

watermark = Image.new(
    "RGBA",
    (700, 180),
    (0, 0, 0, 0),
)

draw = ImageDraw.Draw(watermark)

font = ImageFont.truetype(
    "fonts/DejaVuSans.ttf",
    80,
)

draw.text(
    (40, 40),
    "SAMPLE",
    font=font,
    fill=(255, 255, 255, 110),
)

watermark = watermark.rotate(
    30,
    expand=True,
)

x = (image.width - watermark.width) // 2
y = (image.height - watermark.height) // 2

overlay.alpha_composite(
    watermark,
    dest=(x, y),
)

result = Image.alpha_composite(
    image,
    overlay,
)

result.convert("RGB").save(
    "diagonal_watermark.jpg",
    "JPEG",
    quality=90,
)

The exact angle is a design choice. A value such as 25 or 30 degrees usually produces a visually obvious diagonal effect without making the watermark completely dominant.

Repeating Watermarks Across an Image

A single corner watermark may not be enough for preview content.

You can build a tiled watermark pattern.

from PIL import Image, ImageDraw, ImageFont

image = Image.open("input.jpg").convert("RGBA")

overlay = Image.new(
    "RGBA",
    image.size,
    (0, 0, 0, 0),
)

tile = Image.new(
    "RGBA",
    (500, 180),
    (0, 0, 0, 0),
)

draw = ImageDraw.Draw(tile)

font = ImageFont.truetype(
    "fonts/DejaVuSans.ttf",
    45,
)

draw.text(
    (20, 60),
    "EXAMPLE.COM",
    font=font,
    fill=(255, 255, 255, 65),
)

tile = tile.rotate(
    25,
    expand=True,
)

for y in range(
    -tile.height,
    image.height,
    300,
):
    for x in range(
        -tile.width,
        image.width,
        450,
    ):
        overlay.alpha_composite(
            tile,
            dest=(x, y),
        )

result = Image.alpha_composite(
    image,
    overlay,
)

result.convert("RGB").save(
    "repeated_watermark.jpg",
    "JPEG",
    quality=90,
)

This produces a repeated diagonal watermark pattern.

Tiled watermarks are useful for digital proofs, preview documents, and stock-like image galleries where you want the watermark to remain visible even when a user crops the image.

Adding a Watermark with a Border or Shadow

Text can become difficult to read when placed over complicated backgrounds.

One solution is to draw a shadow or outline.

Pillow's text drawing supports stroke settings:

draw.text(
    (x, y),
    text,
    font=font,
    fill=(255, 255, 255, 130),
    stroke_width=3,
    stroke_fill=(0, 0, 0, 150),
)

The result has a dark outline around the light text.

This can be useful when the watermark needs to remain visible on both bright and dark parts of a photograph.

The important thing is moderation. A heavy outline may make the watermark look more like a graphic label than a subtle ownership marker.

Adding a Semi-Transparent Background to Text

Another technique is a translucent rectangle behind the text:

bbox = draw.textbbox(
    (0, 0),
    text,
    font=font,
)

padding = 15

draw.rounded_rectangle(
    (
        bbox[0] - padding,
        bbox[1] - padding,
        bbox[2] + padding,
        bbox[3] + padding,
    ),
    radius=10,
    fill=(0, 0, 0, 100),
)

draw.text(
    (0, 0),
    text,
    font=font,
    fill=(255, 255, 255, 220),
)

This is especially effective for photographs where readability is more important than absolute subtlety.

Batch Watermarking Images

The next practical step is processing a whole directory.

Suppose:

images/
├── photo1.jpg
├── photo2.jpg
├── photo3.png
└── photo4.jpeg

We can write:

from pathlib import Path

input_dir = Path("images")
output_dir = Path("watermarked")

output_dir.mkdir(
    parents=True,
    exist_ok=True,
)

supported = {
    ".jpg",
    ".jpeg",
    ".png",
    ".webp",
}

for source in input_dir.iterdir():
    if source.suffix.lower() not in supported:
        continue

    destination = (
        output_dir / source.name
    )

    add_logo_watermark(
        input_path=source,
        logo_path="assets/logo.png",
        output_path=destination,
        opacity=0.35,
        scale=0.18,
    )

    print(
        f"Processed: {source.name}"
    )

This simple loop can watermark hundreds or thousands of images.

Making Batch Processing Safer

For production usage, do not let one broken file stop the entire batch.

A safer pattern is:

for source in input_dir.iterdir():
    if source.suffix.lower() not in supported:
        continue

    try:
        destination = (
            output_dir / source.name
        )

        add_logo_watermark(
            input_path=source,
            logo_path="assets/logo.png",
            output_path=destination,
        )

        print(
            f"SUCCESS: {source.name}"
        )

    except Exception as exc:
        print(
            f"FAILED: {source.name}: {exc}"
        )

For a real service, replace print() with structured logging.

For example:

import logging

logger = logging.getLogger(__name__)

logger.info(
    "Watermarking file: %s",
    source,
)

and:

logger.exception(
    "Failed to watermark %s",
    source,
)

This makes errors much easier to investigate.

Handling Image Mode Correctly

Pillow can work with multiple image modes:

RGB
RGBA
L
LA
P
CMYK

A watermarking function should usually normalize the source to RGBA:

image = Image.open(
    input_path
).convert("RGBA")

This makes the compositing layer predictable.

Then, at the end, convert according to the output format.

For JPEG:

result.convert("RGB")

For PNG:

result

This normalization reduces surprises.

Dealing with EXIF Orientation

Photos taken by phones and cameras may contain orientation information in EXIF metadata rather than physically storing pixels in the orientation displayed by an application.

That can produce an annoying situation where the watermark appears in an unexpected place if the image is processed without normalization.

Pillow provides EXIF transpose support:

from PIL import Image, ImageOps

image = Image.open(
    input_path
)

image = ImageOps.exif_transpose(
    image
)

image = image.convert("RGBA")

This is a valuable step for real-world user uploads.

It is particularly important if your application receives photos directly from smartphones.

Preserving Metadata

Watermarking is not only about pixels. Images may contain metadata such as:

  • camera information

  • copyright information

  • GPS information

  • creation date

  • software information

  • color profiles

Whether you should preserve this information depends on your application.

For public uploads, you may intentionally remove sensitive metadata such as GPS coordinates.

For professional photography archives, you may want to retain copyright-related metadata.

Do not assume that preserving all metadata is always desirable.

Your application should make an explicit decision.

Security Considerations for Image Uploads

User-uploaded files should never be treated as trustworthy input.

A production system should:

  • validate the file type

  • limit file size

  • limit image dimensions where appropriate

  • generate safe output names

  • store uploads outside executable directories

  • avoid trusting client-provided extensions

  • reject unsupported formats

  • handle malformed files

  • clean temporary files

Pillow itself includes protection against extremely large image decompression attacks through its pixel limit mechanism. The library documentation describes MAX_IMAGE_PIXELS and the related decompression-bomb warning/error behavior.

That is useful, but it should not replace application-level upload limits.

For example:

MAX_FILE_SIZE = 10 * 1024 * 1024

You may also reject excessively large dimensions before performing expensive processing.

Using BytesIO Instead of Temporary Files

You do not always need to save an uploaded image to disk before processing it.

Python's io.BytesIO makes it possible to process an in-memory file-like object.

from io import BytesIO
from PIL import Image

def watermark_bytes(
    image_bytes: bytes,
    logo_path: str,
) -> bytes:

    base = Image.open(
        BytesIO(image_bytes)
    ).convert("RGBA")

    logo = Image.open(
        logo_path
    ).convert("RGBA")

    logo.thumbnail(
        (
            int(base.width * 0.18),
            int(base.height * 0.18),
        )
    )

    base.alpha_composite(
        logo,
        dest=(
            base.width - logo.width - 30,
            base.height - logo.height - 30,
        ),
    )

    output = BytesIO()

    base.convert("RGB").save(
        output,
        format="JPEG",
        quality=92,
    )

    return output.getvalue()

This is useful in web applications when you want:

HTTP Upload
    |
    v
BytesIO
    |
    v
Pillow
    |
    v
BytesIO
    |
    v
HTTP Response

However, in-memory processing consumes RAM, so you should not blindly use it for arbitrarily large files.

Processing Very Large Images

High-resolution photographs can consume a surprising amount of memory.

An image with dimensions:

8000 × 6000

contains:

48,000,000 pixels

For RGBA data, four bytes per pixel is a useful rough mental model:

48,000,000 × 4
≈ 192 MB

This is not necessarily the exact total process memory because Pillow and the operating system have other overhead, but it illustrates why a few large images can consume substantial RAM.

A production service should define reasonable limits and consider resizing output images if full-resolution watermarked originals are not required.

Creating a More Flexible Image Watermark Class

For larger projects, a class can organize configuration:

from dataclasses import dataclass


@dataclass
class WatermarkConfig:
    position: str = "bottom-right"
    opacity: float = 0.35
    scale: float = 0.18
    margin: int = 30
    jpeg_quality: int = 92

Then:

config = WatermarkConfig(
    position="bottom-right",
    opacity=0.3,
    scale=0.2,
)

Your watermark function can accept that configuration.

This becomes particularly useful if the application has multiple watermark profiles.

For example:

PreviewProfile
SocialMediaProfile
CustomerProfile
ConfidentialProfile
ThumbnailProfile

Each profile can use different size, position, opacity, and text.

Creating Multiple Watermark Profiles

A realistic application may need multiple strategies.

For example:

PREVIEW = WatermarkConfig(
    position="center",
    opacity=0.20,
    scale=0.30,
)

THUMBNAIL = WatermarkConfig(
    position="bottom-right",
    opacity=0.40,
    scale=0.15,
)

PUBLIC = WatermarkConfig(
    position="top-right",
    opacity=0.30,
    scale=0.18,
)

This moves your application away from hard-coded behavior and toward configurable document processing.

Working with PDF Watermarks

Now we reach the second major part of the problem: PDFs.

A PDF is not simply a collection of JPEGs.

A PDF page can contain:

  • text

  • vectors

  • embedded images

  • fonts

  • clipping paths

  • transparency

  • annotations

  • forms

  • page boxes

  • transformations

  • metadata

  • other PDF resources

For that reason, the general PDF watermark pattern is different from the image approach.

A common technique is to generate a separate one-page PDF that contains the watermark, then merge that page with each source page.

Current pypdf documentation exposes merge_page() for merging page content streams and documents the over parameter: with the default True, the merged content is drawn over the original, while False places it underneath. It also provides methods such as merge_transformed_page() for scaled, rotated, or translated overlays.

Installing pypdf and ReportLab

Install:

pip install pypdf reportlab

The architecture will be:

source.pdf
     |
     v
PdfReader
     |
     v
Read each page
     |
     +------> watermark.pdf
     |             |
     |             v
     |       PdfReader
     |             |
     |             v
     +-------> merge_page()
                   |
                   v
               PdfWriter
                   |
                   v
             watermarked.pdf

The Simplest PDF Text Watermark

Let's generate a one-page watermark PDF first.

from reportlab.pdfgen import canvas

watermark_path = "watermark.pdf"

c = canvas.Canvas(
    watermark_path,
    pagesize=(595, 842),
)

c.setFont(
    "Helvetica",
    48,
)

c.setFillGray(
    0.7
)

c.drawCentredString(
    297,
    421,
    "CONFIDENTIAL",
)

c.save()

The coordinates used by ReportLab are PDF points.

A standard A4 page is approximately:

595 × 842 points

depending on the exact definition and rounding.

This creates a watermark PDF.

Now merge it into an existing file.

from pypdf import PdfReader, PdfWriter

source = PdfReader(
    "input.pdf"
)

watermark = PdfReader(
    "watermark.pdf"
)

watermark_page = watermark.pages[0]

writer = PdfWriter()

for page in source.pages:
    page.merge_page(
        watermark_page,
        over=True,
    )
    writer.add_page(page)

with open(
    "output.pdf",
    "wb",
) as output_file:
    writer.write(output_file)

This puts the watermark above the page content.

Watermark Versus Stamp

This distinction matters.

A stamp is generally visual content placed on top.

A watermark may be intended to sit underneath the document content.

In pypdf, the same merging mechanism can be used for both. The over parameter controls the ordering. The pypdf documentation explicitly describes this model, with over=True for a foreground stamp and over=False for a background watermark.

For example:

page.merge_page(
    watermark_page,
    over=False,
)

This tells pypdf to place the watermark content underneath the existing page content.

Whether that is visually effective depends on the content of the source PDF. Dark backgrounds, opaque images, or full-page objects can still cover a background watermark.

Matching the Watermark Page to the Source Page

One of the biggest mistakes in PDF watermarking is creating a fixed A4 watermark and blindly applying it to every page.

What happens if the source PDF contains:

A4 page
A3 page
Letter page
Landscape page
Square page
Custom page

The watermark dimensions will not necessarily match.

A better strategy is to create an overlay for each page using that page's dimensions.

The basic idea:

page_width = float(page.mediabox.width)
page_height = float(page.mediabox.height)

Then create a ReportLab canvas using those dimensions.

Generating a Watermark PDF for a Specific Page

from io import BytesIO
from reportlab.pdfgen import canvas


def create_text_watermark_pdf(
    width: float,
    height: float,
    text: str,
) -> BytesIO:

    buffer = BytesIO()

    c = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    c.setFont(
        "Helvetica-Bold",
        45,
    )

    c.setFillGray(
        0.75
    )

    c.drawCentredString(
        width / 2,
        height / 2,
        text,
    )

    c.save()

    buffer.seek(0)

    return buffer

This function creates a watermark page matching the source page dimensions.

Now we can process every page independently.

Applying a Dynamic Watermark to Every PDF Page

from io import BytesIO

from pypdf import PdfReader, PdfWriter
from reportlab.pdfgen import canvas


def create_watermark_page(
    width: float,
    height: float,
    text: str,
) -> BytesIO:

    buffer = BytesIO()

    c = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    c.setFont(
        "Helvetica-Bold",
        45,
    )

    c.setFillGray(
        0.75
    )

    c.drawCentredString(
        width / 2,
        height / 2,
        text,
    )

    c.save()

    buffer.seek(0)

    return buffer


def watermark_pdf(
    input_path: str,
    output_path: str,
    text: str,
) -> None:

    reader = PdfReader(
        input_path
    )

    writer = PdfWriter()

    for page in reader.pages:
        width = float(
            page.mediabox.width
        )

        height = float(
            page.mediabox.height
        )

        watermark_buffer = (
            create_watermark_page(
                width,
                height,
                text,
            )
        )

        watermark_reader = PdfReader(
            watermark_buffer
        )

        watermark_page = (
            watermark_reader.pages[0]
        )

        page.merge_page(
            watermark_page,
            over=False,
        )

        writer.add_page(page)

    with open(
        output_path,
        "wb",
    ) as output:
        writer.write(output)

Use it:

watermark_pdf(
    "input.pdf",
    "output.pdf",
    "CONFIDENTIAL",
)

This is a much stronger foundation than a fixed A4 overlay.

Creating a Diagonal PDF Watermark

For a diagonal watermark, use ReportLab transformations.

The important sequence is:

c.saveState()
c.translate(...)
c.rotate(...)
c.drawCentredString(...)
c.restoreState()

Example:

from io import BytesIO

from reportlab.pdfgen import canvas


def create_diagonal_watermark(
    width: float,
    height: float,
    text: str,
) -> BytesIO:

    buffer = BytesIO()

    c = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    c.saveState()

    c.translate(
        width / 2,
        height / 2,
    )

    c.rotate(35)

    c.setFillGray(
        0.8
    )

    c.setFont(
        "Helvetica-Bold",
        54,
    )

    c.drawCentredString(
        0,
        0,
        text,
    )

    c.restoreState()

    c.save()

    buffer.seek(0)

    return buffer

The watermark is now rotated around the center of the page.

Repeating Text Across a PDF Page

For stronger preview-style watermarking, repeat the text.

from io import BytesIO
from reportlab.pdfgen import canvas


def create_repeated_pdf_watermark(
    width: float,
    height: float,
    text: str,
) -> BytesIO:

    buffer = BytesIO()

    c = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    c.setFillGray(
        0.88
    )

    c.setFont(
        "Helvetica-Bold",
        30,
    )

    for y in range(
        -50,
        int(height) + 200,
        180,
    ):
        for x in range(
            -150,
            int(width) + 300,
            260,
        ):

            c.saveState()

            c.translate(
                x,
                y,
            )

            c.rotate(30)

            c.drawString(
                0,
                0,
                text,
            )

            c.restoreState()

    c.save()

    buffer.seek(0)

    return buffer

This produces a tiled watermark pattern across the page.

Placing a Company Logo into a PDF

You can also use a logo as the watermark.

Suppose:

assets/company-logo.png

Then ReportLab can place it into the watermark page.

from io import BytesIO

from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader


def create_logo_watermark_page(
    width: float,
    height: float,
    logo_path: str,
) -> BytesIO:

    buffer = BytesIO()

    c = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    logo = ImageReader(
        logo_path
    )

    logo_width = width * 0.25
    logo_height = logo_width

    x = (
        width - logo_width
    ) / 2

    y = (
        height - logo_height
    ) / 2

    c.drawImage(
        logo,
        x,
        y,
        width=logo_width,
        height=logo_height,
        mask="auto",
    )

    c.save()

    buffer.seek(0)

    return buffer

The exact height should ideally preserve the image's aspect ratio. If the logo is not square, calculate the proportions instead of assuming:

logo_width = width * 0.25
logo_height = logo_width * original_height / original_width

Using Pillow to Prepare a Transparent PDF Logo

When you need precise control over opacity, preparing the logo with Pillow before embedding it into the PDF is often convenient.

For example:

from PIL import Image

logo = Image.open(
    "assets/logo.png"
).convert("RGBA")

alpha = logo.getchannel("A")

alpha = alpha.point(
    lambda value: int(value * 0.25)
)

logo.putalpha(alpha)

logo.save(
    "assets/logo_transparent.png"
)

Then ReportLab can use:

c.drawImage(
    "assets/logo_transparent.png",
    x,
    y,
    width=logo_width,
    height=logo_height,
    mask="auto",
)

This has the advantage of moving the alpha manipulation into a part of the workflow where Pillow gives you direct pixel-level control.

Watermarking Existing PDFs Without Rasterizing Them

One major advantage of using pypdf for watermarking is that you can merge PDF content rather than converting the entire document into images.

That distinction matters.

A poor workflow might look like:

PDF
 ↓
Render every page to image
 ↓
Watermark every image
 ↓
Convert every image back to PDF

This may introduce several problems:

  • text may no longer be selectable

  • file sizes may increase

  • vector graphics may be rasterized

  • quality can be reduced

  • OCR may become necessary

  • forms and document structure can be lost

The overlay approach is preferable for many workflows:

Original PDF Page
       +
Watermark PDF Page
       |
       v
Merged PDF Page

The page contents remain PDF content.

Understanding PDF Coordinate Systems

Another common source of confusion is coordinates.

In many PDF-generation workflows, coordinates are measured from the lower-left corner.

For example:

c.drawString(
    50,
    50,
    "Hello",
)

places text roughly near the lower-left region.

This differs from Pillow's typical image coordinate system, where (0, 0) is at the upper-left.

Image:

(0,0)
  +----------------------+
  |                      |
  |                      |
  |                      |
  +----------------------+

PDF canvas:

  +----------------------+
  |                      |
  |                      |
  |                      |
  +----------------------+
  (0,0)

The exact coordinate orientation is an important implementation detail when translating watermark positioning logic between images and PDFs.

Handling Rotated PDF Pages

PDF rotation can make watermark positioning more complicated.

A page may have:

mediabox = portrait
rotation = 90

or:

mediabox = landscape
rotation = 90

The page may appear visually rotated even though its underlying media box is stored differently.

A robust application should inspect:

rotation = page.rotation

and test watermark placement against PDFs containing rotated pages.

For example:

print(
    "Rotation:",
    page.rotation,
)

Depending on the requirements, you may need to account for the page's transformation before deciding where to place the watermark.

This is one reason why PDF watermarking should be tested with a realistic sample set rather than only a single A4 document.

Using pypdf Transformations

pypdf supports transformed page merging.

For example:

from pypdf import Transformation

transformation = (
    Transformation()
    .scale(0.5)
    .rotate(30)
    .translate(100, 200)
)

page.merge_transformed_page(
    watermark_page,
    transformation,
    over=True,
)

The current pypdf documentation describes merge_transformed_page() as a method for applying a transformation matrix while merging another page, and documents related operations for translation, scaling, and rotation.

This is especially useful when you already have a watermark PDF and want to reuse it with different transformations.

Reusing a Single Watermark Page

Creating a separate watermark PDF for every source page gives maximum control, but it can be more work and can make the code less efficient.

If every page has the same dimensions, you can create one watermark page and reuse it.

For example:

watermark_reader = PdfReader(
    "watermark.pdf"
)

watermark_page = watermark_reader.pages[0]

source_reader = PdfReader(
    "input.pdf"
)

writer = PdfWriter()

for page in source_reader.pages:
    page.merge_page(
        watermark_page,
        over=False,
    )

    writer.add_page(page)

This is simple and effective for uniformly sized documents.

For mixed page sizes, generate matching overlays as needed.

Building a Complete PDF Watermark Utility

Let's create a reusable module.

from __future__ import annotations

from io import BytesIO
from pathlib import Path

from pypdf import PdfReader, PdfWriter
from reportlab.pdfgen import canvas


def create_text_watermark(
    width: float,
    height: float,
    text: str,
    font_name: str = "Helvetica-Bold",
    font_size: float = 42,
    gray: float = 0.8,
) -> BytesIO:

    buffer = BytesIO()

    pdf = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    pdf.setFillGray(gray)

    pdf.setFont(
        font_name,
        font_size,
    )

    pdf.drawCentredString(
        width / 2,
        height / 2,
        text,
    )

    pdf.save()

    buffer.seek(0)

    return buffer


def watermark_pdf(
    input_path: str | Path,
    output_path: str | Path,
    text: str,
    over: bool = False,
) -> None:

    reader = PdfReader(
        str(input_path)
    )

    writer = PdfWriter()

    for page in reader.pages:

        width = float(
            page.mediabox.width
        )

        height = float(
            page.mediabox.height
        )

        buffer = create_text_watermark(
            width=width,
            height=height,
            text=text,
        )

        watermark_reader = PdfReader(
            buffer
        )

        watermark_page = (
            watermark_reader.pages[0]
        )

        page.merge_page(
            watermark_page,
            over=over,
        )

        writer.add_page(page)

    with open(
        output_path,
        "wb",
    ) as output:
        writer.write(output)

Usage:

watermark_pdf(
    "input.pdf",
    "output.pdf",
    "CONFIDENTIAL",
    over=False,
)

This utility can serve as the foundation for a larger document-processing system.

Watermarking Only Selected PDF Pages

Sometimes you do not want every page watermarked.

For example:

Page 1 -> watermark
Page 2 -> watermark
Page 3 -> no watermark
Page 4 -> watermark

Use page indices:

pages_to_watermark = {
    0,
    1,
    3,
}

Then:

for index, page in enumerate(reader.pages):

    if index in pages_to_watermark:
        # Apply watermark
        pass

    writer.add_page(page)

A function could accept:

pages: set[int] | None = None

and interpret None as all pages.

Supporting Human-Friendly Page Numbers

Users usually think in terms of:

Page 1
Page 2
Page 3

while Python indexes:

0
1
2

Convert explicitly:

page_numbers = {
    1,
    3,
    5,
}

page_indexes = {
    number - 1
    for number in page_numbers
}

Then watermark based on page_indexes.

Always validate the range:

total_pages = len(reader.pages)

for page_number in page_numbers:
    if not 1 <= page_number <= total_pages:
        raise ValueError(
            f"Invalid page number: {page_number}"
        )

This creates much friendlier application behavior.

Adding a User Name to the Watermark

Dynamic watermarking is one of the most useful patterns in real applications.

Instead of:

CONFIDENTIAL

you could use:

Prepared for John Smith

or:

Licensed to Example Company

For instance:

watermark_text = (
    f"Licensed to {customer_name}"
)

Then pass it to your PDF generator.

This is common in preview portals, reports, training material, client dashboards, and document-distribution systems.

The same technique works for image watermarks:

text = f"{customer_name} • example.com"

Combining a Logo and Text in an Image Watermark

You can create a composite watermark:

[LOGO] Example Company
             CONFIDENTIAL

The architecture might be:

watermark = Image.new(
    "RGBA",
    (800, 180),
    (0, 0, 0, 0),
)

Paste the logo:

watermark.alpha_composite(
    logo,
    dest=(20, 20),
)

Then draw text:

draw = ImageDraw.Draw(
    watermark
)

draw.text(
    (220, 50),
    "Example Company",
    font=font,
    fill=(255, 255, 255, 160),
)

Then:

watermark = watermark.rotate(
    15,
    expand=True,
)

Finally:

base.alpha_composite(
    watermark,
    dest=(x, y),
)

This allows you to build a single reusable visual watermark asset dynamically.

Creating a Watermark Factory

A useful architectural improvement is to separate watermark creation from watermark application.

For example:

def build_image_watermark(
    text: str,
    font_path: str,
    font_size: int,
    opacity: int,
) -> Image.Image:

    watermark = Image.new(
        "RGBA",
        (1000, 200),
        (0, 0, 0, 0),
    )

    draw = ImageDraw.Draw(
        watermark
    )

    font = ImageFont.truetype(
        font_path,
        font_size,
    )

    draw.text(
        (40, 50),
        text,
        font=font,
        fill=(255, 255, 255, opacity),
    )

    return watermark

Then:

watermark = build_image_watermark(
    text="example.com",
    font_path="fonts/DejaVuSans.ttf",
    font_size=60,
    opacity=100,
)

This separation makes testing easier.

Testing the Watermark Generator Independently

You can test the watermark itself before applying it to real files.

watermark = build_image_watermark(
    text="TEST",
    font_path="fonts/DejaVuSans.ttf",
    font_size=60,
    opacity=120,
)

watermark.save(
    "debug_watermark.png"
)

This is a surprisingly useful workflow during development.

Instead of repeatedly wondering whether the problem is:

font
position
opacity
compositing
source image

you can inspect the watermark asset independently.

Building a Command-Line Watermark Tool

Once the core logic works, a command-line interface makes it convenient.

Python's argparse module is enough:

import argparse


parser = argparse.ArgumentParser(
    description="Add a watermark to an image."
)

parser.add_argument(
    "input",
)

parser.add_argument(
    "output",
)

parser.add_argument(
    "--text",
    required=True,
)

parser.add_argument(
    "--font",
    default="fonts/DejaVuSans.ttf",
)

parser.add_argument(
    "--size",
    type=int,
    default=48,
)

parser.add_argument(
    "--opacity",
    type=int,
    default=120,
)

args = parser.parse_args()

Then:

add_text_watermark(
    input_path=args.input,
    output_path=args.output,
    text=args.text,
    font_path=args.font,
    font_size=args.size,
    opacity=args.opacity,
)

You can execute:

python watermark.py \
    input.jpg \
    output.jpg \
    --text "example.com" \
    --size 60 \
    --opacity 110

This turns a script into a reusable command-line utility.

Creating a Unified Watermark CLI

You can also support both images and PDFs:

python watermark.py image input.jpg output.jpg

or:

python watermark.py pdf input.pdf output.pdf

And parse:

parser.add_argument(
    "type",
    choices=[
        "image",
        "pdf",
    ],
)

Then dispatch:

if args.type == "image":
    watermark_image(...)
else:
    watermark_pdf(...)

This is a good example of how a few reusable functions can evolve into a real tool.

Using Watermarking in a Django Application

A Django application may receive an uploaded image through:

request.FILES["image"]

You can save the upload, process it, and return the result.

A simplified example:

from django.http import FileResponse
from django.views import View
from django.core.files.storage import default_storage

from pathlib import Path
import tempfile


class WatermarkImageView(View):

    def post(self, request):
        uploaded = request.FILES["image"]

        with tempfile.NamedTemporaryFile(
            suffix=Path(
                uploaded.name
            ).suffix,
            delete=False,
        ) as temp:
            for chunk in uploaded.chunks():
                temp.write(chunk)

            source_path = temp.name

        output_path = (
            source_path + "_watermarked.jpg"
        )

        add_logo_watermark(
            input_path=source_path,
            logo_path="assets/logo.png",
            output_path=output_path,
        )

        return FileResponse(
            open(
                output_path,
                "rb",
            ),
            as_attachment=True,
            filename="watermarked.jpg",
        )

In a production Django application, you should go further by validating uploads, managing temporary files carefully, enforcing size limits, and integrating with your project's storage backend rather than leaving temporary artifacts behind.

Using Watermarking in FastAPI

FastAPI is also well suited to this workflow.

A simplified example:

from fastapi import FastAPI, UploadFile, File
from fastapi.responses import FileResponse

import shutil
import tempfile

app = FastAPI()


@app.post("/watermark/image")
async def watermark_image(
    file: UploadFile = File(...)
):
    input_file = tempfile.NamedTemporaryFile(
        suffix=".jpg",
        delete=False,
    )

    with input_file:
        shutil.copyfileobj(
            file.file,
            input_file,
        )

    output_path = (
        input_file.name + "_watermarked.jpg"
    )

    add_logo_watermark(
        input_path=input_file.name,
        logo_path="assets/logo.png",
        output_path=output_path,
    )

    return FileResponse(
        output_path,
        media_type="image/jpeg",
        filename="watermarked.jpg",
    )

Again, this is a simplified example rather than a complete production upload service.

Handling Uploaded PDF Files in Web Applications

PDF uploads should receive the same attention as image uploads.

Do not assume this is safe:

PdfReader(uploaded_file)

just because the client says:

Content-Type: application/pdf

Validate input independently.

Depending on the application, you may want:

maximum upload size
maximum page count
allowed extension
allowed MIME type
temporary storage limits
timeout limits

Also remember that a PDF may be structurally valid but still require significant resources to process.

Preventing Path Traversal

Never use an uploaded filename directly as a filesystem path.

This is dangerous:

output_path = (
    "uploads/" + uploaded.filename
)

Instead generate your own filename:

import uuid

filename = (
    f"{uuid.uuid4().hex}_watermarked.pdf"
)

This avoids collisions and prevents users from controlling your filesystem path.

Generating Safe Output Names

For example:

from pathlib import Path
import uuid


def generate_output_name(
    original_name: str,
    suffix: str | None = None,
) -> str:

    extension = (
        Path(original_name)
        .suffix
        .lower()
    )

    if suffix is None:
        suffix = extension

    return (
        f"{uuid.uuid4().hex}"
        f"{suffix}"
    )

A production application should ensure that the suffix is one of the formats it actually supports.

Preserving PDF Metadata

When processing PDFs, metadata can matter.

A document may contain:

/title
/author
/subject
/creator
/producer

Depending on your pypdf workflow, you can explicitly set metadata on the output writer.

For example:

writer.add_metadata({
    "/Title": "Watermarked Document",
    "/Author": "Example Company",
})

This can make document management easier.

You may also choose to preserve the original metadata rather than replacing it. The correct behavior depends on your application's requirements.

What About Password-Protected PDFs?

Some PDFs are encrypted.

Before processing, inspect:

reader = PdfReader(
    "protected.pdf"
)

if reader.is_encrypted:
    print("PDF is encrypted")

Your application then needs an explicit policy.

For example, it may reject encrypted files:

if reader.is_encrypted:
    raise ValueError(
        "Encrypted PDFs are not supported."
    )

Or it may request a password and attempt decryption.

The important point is not to silently assume that every PDF is open and editable.

PDFs with Forms and Annotations

Modern PDFs can contain interactive elements.

A watermarking operation may interact with:

  • annotations

  • AcroForm fields

  • links

  • page resources

  • JavaScript

  • signatures

Your application should test representative documents.

This is particularly important in enterprise environments.

A simple text-heavy PDF may behave perfectly while a signed financial form behaves differently.

Digital Signatures and Watermarking

A digital signature typically depends on the document's exact content.

Changing the document by adding a watermark can invalidate an existing signature.

Therefore, if a user uploads a signed PDF and your application modifies it, you should treat that as a significant document-state change.

Do not market a watermarked output as preserving the original digital signature unless you have specifically implemented and validated that workflow.

Watermarking and Accessibility

A watermark can affect accessibility, especially if it adds visual noise over text.

For documents intended for accessibility, consider:

  • contrast

  • readability

  • placement

  • screen-reader behavior

  • text selection

  • document structure

A background watermark should not make important text unreadable.

For example, a very dark watermark across a paragraph can seriously reduce usability.

Choosing the Right Watermark Opacity

Opacity is one of the most subjective parameters.

A useful conceptual range might be:

0.10 -> very subtle
0.20 -> subtle
0.30 -> clearly visible
0.40 -> strong
0.50 -> very strong

These are design guidelines rather than universal rules.

For photography, 0.20–0.35 is often visually comfortable.

For a “CONFIDENTIAL” document, the best result may be a light gray background watermark rather than a highly transparent colored logo.

Always test on representative files.

Designing for Bright and Dark Images

A white watermark may disappear on white backgrounds.

A black watermark may disappear on dark backgrounds.

One strategy is to use a thin contrasting outline:

draw.text(
    (x, y),
    text,
    font=font,
    fill=(255, 255, 255, 120),
    stroke_width=2,
    stroke_fill=(0, 0, 0, 100),
)

Another approach is a semi-transparent black or white background.

In some professional systems, the watermark color is selectable:

watermark_color = (
    255,
    255,
    255,
    110,
)

or:

watermark_color = (
    0,
    0,
    0,
    110,
)

Supporting Custom Watermark Colors

Create a parameter:

color: tuple[
    int,
    int,
    int,
    int
]

Then:

draw.text(
    position,
    text,
    font=font,
    fill=color,
)

For example:

color=(
    255,
    255,
    255,
    120,
)

means semi-transparent white.

This makes your watermark service much more flexible.

Watermarking Multiple Image Formats

Pillow supports many common formats, but your application should define which formats it accepts.

For example:

SUPPORTED_IMAGE_FORMATS = {
    "JPEG",
    "PNG",
    "WEBP",
}

You can inspect:

image.format

after opening the image.

Do not rely entirely on the filename extension.

An uploaded file called:

photo.jpg

does not prove that it actually contains JPEG data.

Converting WebP to JPEG

A web application might accept WebP input but return JPEG output.

image = Image.open(
    "input.webp"
).convert("RGBA")

# Apply watermark...

image.convert("RGB").save(
    "output.jpg",
    "JPEG",
    quality=90,
)

This is useful when your downstream systems expect JPEG.

Using a High-Quality JPEG Setting

The quality parameter is a tradeoff.

For example:

image.save(
    "output.jpg",
    quality=95,
)

will generally produce a larger file than:

quality=75

The right setting depends on your use case.

For public website previews, 85 or 90 may be more than sufficient.

For archival photography workflows, you may prefer a higher setting.

Always measure both visual quality and file size.

Avoiding Repeated JPEG Re-Encoding

JPEG is lossy.

If you repeatedly perform:

JPEG -> JPEG -> JPEG -> JPEG

quality may degrade over time.

A better architecture is:

Original source
      |
      v
Single watermark operation
      |
      v
Final output

Do not continually reopen and save the same JPEG every time an application request occurs.

Store the processed result and reuse it when possible.

Creating Watermarked Thumbnails

A common application architecture is:

Original Image
      |
      +----> Thumbnail
      |
      +----> Web Preview
      |
      +----> Full-size Watermarked

The order of operations matters.

You can create the thumbnail first and then watermark it:

thumbnail = image.copy()

thumbnail.thumbnail(
    (800, 800)
)

# Apply watermark to thumbnail

This reduces the amount of work compared with watermarking a huge original before resizing.

However, if you need a high-resolution watermarked master plus a thumbnail, process the original once for the master and create the thumbnail from the master or from a controlled intermediate representation.

Creating a Watermarked Preview Pipeline

A practical pipeline could be:

Upload
  |
  v
Validate
  |
  v
Open Original
  |
  v
Normalize Orientation
  |
  v
Create Watermark
  |
  v
Composite
  |
  v
Save Master
  |
  +------> Resize Thumbnail
  |
  v
Store Results

This separation makes the system easier to maintain.

Asynchronous Watermarking

Watermarking large batches does not always belong inside an HTTP request.

Imagine a user uploads:

300 images

or:

500-page PDF

Processing everything before returning a response can cause request timeouts.

A better architecture may use a background worker:

User Upload
     |
     v
Application
     |
     v
Queue Job
     |
     v
Worker
     |
     +----> Watermark
     |
     +----> Save Result
     |
     v
Job Complete
     |
     v
Notify User

Common Python ecosystems include Celery, RQ, Dramatiq, and task systems built directly into cloud environments.

The important architectural decision is not the specific queue. It is separating long-running file processing from short HTTP request cycles when necessary.

Watermarking with Celery

A simple Celery task could look like:

from celery import shared_task


@shared_task
def watermark_image_task(
    input_path: str,
    output_path: str,
) -> None:

    add_logo_watermark(
        input_path=input_path,
        logo_path="assets/logo.png",
        output_path=output_path,
    )

Then enqueue:

watermark_image_task.delay(
    "uploads/photo.jpg",
    "processed/photo.jpg",
)

A production implementation would store file references and job status in a database rather than relying only on hard-coded paths.

Tracking Processing Status

For batch or asynchronous workflows, store a status such as:

pending
processing
completed
failed

Your database model might conceptually contain:

id
source_file
output_file
status
error_message
created_at
completed_at

The UI could then show:

Watermarking...

followed by:

Completed

This is much better than making users stare at a spinning HTTP request for several minutes.

Building a Watermark Service Layer

In a larger Django or FastAPI application, keep watermark logic outside the route or view.

For example:

app/
├── api/
├── models/
├── services/
│   └── watermark.py
├── workers/
└── settings.py

The service module could contain:

class WatermarkService:

    def watermark_image(...):
        ...

    def watermark_pdf(...):
        ...

The API layer then becomes responsible for:

authentication
validation
request parsing
response generation

while the service handles:

file processing
watermark placement
saving

This separation makes testing and reuse easier.

Logging Watermark Operations

A production application should log useful information.

For example:

logger.info(
    "Watermark started",
    extra={
        "input": str(input_path),
        "output": str(output_path),
    },
)

Then on completion:

logger.info(
    "Watermark completed",
    extra={
        "output": str(output_path),
    },
)

For failures:

logger.exception(
    "Watermark failed",
    extra={
        "input": str(input_path),
    },
)

Avoid putting sensitive document contents or confidential customer information into logs.

Testing Image Watermarks

Image tests should verify more than “a file exists.”

You can use Pillow to inspect the output:

from PIL import Image


def test_watermark_output():
    add_logo_watermark(
        "input.jpg",
        "logo.png",
        "output.jpg",
    )

    result = Image.open(
        "output.jpg"
    )

    assert result.width > 0
    assert result.height > 0
    assert result.mode in {
        "RGB",
        "RGBA",
    }

For stronger tests, compare selected pixel regions or use image-difference techniques.

Testing PDF Watermarks

For PDF tests:

from pypdf import PdfReader


def test_pdf_watermark():
    watermark_pdf(
        "input.pdf",
        "output.pdf",
        "TEST",
    )

    reader = PdfReader(
        "output.pdf"
    )

    assert len(reader.pages) > 0

For a stronger test, check page dimensions before and after:

source = PdfReader(
    "input.pdf"
)

result = PdfReader(
    "output.pdf"
)

assert len(source.pages) == len(
    result.pages
)

for original, watermarked in zip(
    source.pages,
    result.pages,
):
    assert (
        original.mediabox.width
        == watermarked.mediabox.width
    )

    assert (
        original.mediabox.height
        == watermarked.mediabox.height
    )

This helps ensure your watermarking function is not accidentally changing document geometry.

Common Mistake: Drawing Directly on the Source Image

A beginner may try:

draw = ImageDraw.Draw(image)

draw.text(
    ...
)

This works, but it mixes the watermark directly into the image.

Using a transparent overlay is usually cleaner:

overlay = Image.new(
    "RGBA",
    image.size,
    (0, 0, 0, 0),
)

Then:

draw = ImageDraw.Draw(
    overlay
)

and:

result = Image.alpha_composite(
    image,
    overlay,
)

The layer-based model is easier to extend with:

  • logos

  • text

  • borders

  • repeated marks

  • shadows

  • rotated content

  • multiple watermark elements

Common Mistake: Forgetting RGBA

This:

image = Image.open(
    "input.jpg"
)

might produce an RGB image.

Then trying to use alpha-aware operations can create confusing behavior.

Prefer:

image = Image.open(
    "input.jpg"
).convert("RGBA")

for compositing workflows.

Common Mistake: Saving RGBA as JPEG

This can lead to an error because JPEG does not support the alpha channel in the same way RGBA PNG does.

Use:

result.convert("RGB").save(
    "output.jpg",
    "JPEG",
)

Common Mistake: Fixed Watermark Dimensions

A watermark that is 300 pixels wide may look correct on one file and tiny on another.

Instead use relative sizing:

max_width = int(
    image.width * 0.18
)

This gives a watermark size that scales with the source.

Common Mistake: Fixed PDF Page Size

A fixed A4 watermark is not correct for every PDF.

Always consider:

page.mediabox.width
page.mediabox.height

before creating a page-specific overlay.

Common Mistake: Using the Same Mutable PDF Page Incorrectly

When reusing watermark page objects across operations, be mindful of transformations and mutations.

A clean pattern is to generate a new overlay page when its dimensions or transformation requirements differ.

Keep your code explicit rather than depending on complicated stateful reuse.

Common Mistake: Watermarking Below Opaque Content

You may correctly call:

page.merge_page(
    watermark_page,
    over=False,
)

but then see almost no watermark.

That can happen because the source page itself contains an opaque background object.

A background watermark only works visually when the source content allows it to show through.

If visibility is more important than background placement, use:

over=True

and make the watermark sufficiently subtle.

When to Use a Background Watermark

Use a background watermark when:

  • the document content should remain visually dominant

  • the watermark is primarily a status marker

  • text should appear behind normal content

  • the document design has sufficient white space

For example:

CONFIDENTIAL

in large pale gray letters can work very well behind a report.

When to Use a Foreground Stamp

Use a foreground stamp when:

  • the watermark must always be visible

  • the file is clearly a preview

  • the user must know that the document is not final

  • the branding should remain obvious

The key pypdf operation is:

page.merge_page(
    watermark_page,
    over=True,
)

Creating a Professional Confidential PDF Watermark

A good confidential watermark often uses:

large
light gray
diagonal
centered
repeated or semi-repeated

For example:

c.setFillGray(
    0.85
)

c.setFont(
    "Helvetica-Bold",
    50,
)

c.saveState()

c.translate(
    width / 2,
    height / 2,
)

c.rotate(35)

c.drawCentredString(
    0,
    0,
    "CONFIDENTIAL",
)

c.restoreState()

A light gray value can be less distracting than black text.

Adding Dynamic Dates

A watermark can contain a generated date:

from datetime import datetime

text = (
    "CONFIDENTIAL - "
    + datetime.now().strftime(
        "%Y-%m-%d"
    )
)

Or:

text = (
    f"Preview generated "
    f"{datetime.now():%Y-%m-%d}"
)

For an international application, use a timezone-aware datetime strategy rather than relying on the server's local timezone implicitly.

Adding User IDs or Document IDs

A watermark can also communicate an internal identifier:

text = (
    f"Customer #{customer_id} "
    f"Document #{document_id}"
)

This can make it easier to identify the source of a leaked document.

However, avoid exposing sensitive internal identifiers unnecessarily. A public-facing watermark should contain only the information that the user actually needs to see.

Combining Watermarking with Resizing

For images:

image.thumbnail(
    (1600, 1600)
)

Then apply the watermark.

Or:

Original
   ↓
Resize
   ↓
Watermark
   ↓
Export

The correct order depends on the desired relationship between watermark size and image size.

If the watermark should scale with the final exported dimensions, resize first.

Image Watermarking Pipeline with a Final Width

Here is a complete function:

from pathlib import Path

from PIL import Image, ImageDraw, ImageFont, ImageOps


def watermark_and_resize(
    input_path: str | Path,
    output_path: str | Path,
    text: str,
    font_path: str | Path,
    max_width: int = 1600,
    opacity: int = 120,
) -> None:

    image = Image.open(
        input_path
    )

    image = ImageOps.exif_transpose(
        image
    )

    image.thumbnail(
        (max_width, max_width)
    )

    image = image.convert("RGBA")

    overlay = Image.new(
        "RGBA",
        image.size,
        (0, 0, 0, 0),
    )

    draw = ImageDraw.Draw(
        overlay
    )

    font_size = max(
        24,
        image.width // 30,
    )

    font = ImageFont.truetype(
        font_path,
        font_size,
    )

    bbox = draw.textbbox(
        (0, 0),
        text,
        font=font,
    )

    text_width = (
        bbox[2] - bbox[0]
    )

    text_height = (
        bbox[3] - bbox[1]
    )

    margin = 30

    x = (
        image.width
        - text_width
        - margin
    )

    y = (
        image.height
        - text_height
        - margin
    )

    draw.text(
        (x, y),
        text,
        font=font,
        fill=(
            255,
            255,
            255,
            opacity,
        ),
        stroke_width=2,
        stroke_fill=(
            0,
            0,
            0,
            opacity // 2,
        ),
    )

    result = Image.alpha_composite(
        image,
        overlay,
    )

    result.convert("RGB").save(
        output_path,
        "JPEG",
        quality=90,
        optimize=True,
    )

This is the kind of function that can be directly adapted to a production media pipeline.

Adding a Logo and Text Together

A more advanced watermark can contain both.

def build_brand_watermark(
    logo_path: str,
    text: str,
    font_path: str,
) -> Image.Image:

    logo = Image.open(
        logo_path
    ).convert("RGBA")

    logo.thumbnail(
        (120, 120)
    )

    canvas = Image.new(
        "RGBA",
        (700, 150),
        (0, 0, 0, 0),
    )

    canvas.alpha_composite(
        logo,
        dest=(20, 15),
    )

    draw = ImageDraw.Draw(
        canvas
    )

    font = ImageFont.truetype(
        font_path,
        42,
    )

    draw.text(
        (170, 45),
        text,
        font=font,
        fill=(255, 255, 255, 125),
    )

    return canvas

Then position it exactly like any other watermark.

Creating an API Contract for a Watermark Service

A watermark API might accept:

{
  "type": "image",
  "watermark_type": "logo",
  "position": "bottom-right",
  "opacity": 0.35
}

For PDF:

{
  "type": "pdf",
  "watermark_type": "text",
  "text": "CONFIDENTIAL",
  "position": "diagonal",
  "opacity": 0.20
}

The backend then maps this configuration to a service function.

A good API should validate:

watermark type
position
opacity
scale
supported file type
file size
page range

Do not blindly pass arbitrary values from the client into image or PDF processing functions.

Designing a Watermark Configuration Object

A unified configuration can look like:

from dataclasses import dataclass


@dataclass
class WatermarkOptions:
    text: str | None = None
    logo_path: str | None = None

    position: str = "bottom-right"

    opacity: float = 0.35
    scale: float = 0.18

    rotation: float = 0.0

    margin: int = 30

    over: bool = True

Then both image and PDF logic can share conceptual settings, even though the implementation underneath is different.

Separating Business Rules from Rendering

This is one of the most important lessons when turning a script into a real application.

Business rule:

Customer preview files must contain a watermark.

Rendering implementation:

draw text
rotate 30 degrees
set opacity
merge page

Do not tightly couple those concepts.

Instead:

Business Layer
     |
     v
Watermark Configuration
     |
     v
Rendering Service
     |
     +----> Pillow
     |
     +----> ReportLab + pypdf

That design makes it much easier to change the watermark design later.

Handling Watermark Templates

Large systems may store templates:

templates/
├── preview.json
├── confidential.json
├── customer-preview.json
└── internal.json

For example:

{
  "position": "center",
  "opacity": 0.2,
  "rotation": 35,
  "text": "PREVIEW"
}

Then the service reads the configuration and renders it.

This allows administrators to change branding without rewriting application code.

Using Environment Variables for Branding

Instead of hard-coding:

text = "example.com"

use:

import os

watermark_text = os.getenv(
    "WATERMARK_TEXT",
    "example.com",
)

Then your deployment can define:

WATERMARK_TEXT=example.com

This is useful across:

development
staging
production

where branding may differ.

Cleaning Temporary Files

Temporary files should not accumulate forever.

For Python, use tempfile:

import tempfile
from pathlib import Path


with tempfile.TemporaryDirectory() as temp_dir:
    path = Path(temp_dir)

    input_file = (
        path / "input.pdf"
    )

    output_file = (
        path / "output.pdf"
    )

    # Process files here.

When the context exits, the temporary directory is removed automatically.

This is often cleaner than creating arbitrary files in:

/tmp
uploads
working

and hoping a separate cleanup job eventually removes them.

Avoiding Memory Leaks in Batch Processing

When processing many images, make sure objects do not remain referenced unnecessarily.

A simple batch operation:

for source in files:
    image = Image.open(source)

    # process

    image.close()

Using a context manager is cleaner:

with Image.open(source) as image:
    image = ImageOps.exif_transpose(
        image
    )

    # process

Pillow's image objects can rely on underlying file handles, so explicit lifecycle management is a good habit for large batches.

Parallelizing Large Image Batches

For very large batches, parallel processing can improve throughput.

Python's multiprocessing tools can help because image processing can involve CPU work.

For example:

from concurrent.futures import ProcessPoolExecutor


def process_one(path: str) -> str:
    output = (
        path.replace(
            ".jpg",
            "_watermarked.jpg",
        )
    )

    add_logo_watermark(
        path,
        "logo.png",
        output,
    )

    return output


files = [
    "a.jpg",
    "b.jpg",
    "c.jpg",
]

with ProcessPoolExecutor(
    max_workers=4
) as executor:

    results = list(
        executor.map(
            process_one,
            files,
        )
    )

Do not automatically choose a huge number of workers. Image processing can be memory-intensive, so four workers may be much safer than sixteen depending on file sizes and server resources.

Measuring Processing Time

Add timing around expensive operations:

from time import perf_counter


start = perf_counter()

add_logo_watermark(
    "input.jpg",
    "logo.png",
    "output.jpg",
)

elapsed = (
    perf_counter() - start
)

print(
    f"Completed in {elapsed:.3f}s"
)

This can help you discover whether your bottleneck is:

file I/O
image decoding
resizing
watermark generation
PDF merging
output compression

Performance optimization becomes much easier when you measure instead of guessing.

Watermarking in Docker

A Docker image may not contain the font you expect.

For example:

COPY fonts/DejaVuSans.ttf /app/fonts/

Then your Python code can use:

FONT_PATH = (
    "/app/fonts/DejaVuSans.ttf"
)

This ensures that the rendering environment is predictable.

For ReportLab, built-in fonts such as Helvetica are convenient because you do not need to manage an external font file for simple Latin text.

For multilingual watermarking, however, you may need to register a suitable Unicode-capable font.

Unicode and Multilingual Watermarks

A watermark such as:

CONFIDENTIAL

is easy with basic PDF fonts.

But if you need:

وثيقة سرية

or:

Document confidentiel

or:

機密文書

font support becomes much more important.

For Pillow, choose a font capable of rendering the required characters.

For PDF generation with ReportLab, use an appropriate TrueType/OpenType font and register it.

The general idea is:

from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont

pdfmetrics.registerFont(
    TTFont(
        "CustomFont",
        "fonts/DejaVuSans.ttf",
    )
)

Then:

c.setFont(
    "CustomFont",
    42,
)

For complex scripts such as Arabic, font support alone may not solve all shaping and bidirectional-layout requirements in every PDF-rendering scenario. Test actual output visually, not merely the absence of exceptions.

Why Font Testing Matters

A watermark function may execute successfully but render empty boxes instead of characters if the selected font lacks glyphs.

Therefore, test:

Latin
Arabic
French accents
emoji only if actually needed
Cyrillic
Asian scripts

depending on your product.

Do not assume a font that renders English will render every language correctly.

OCR Is Usually Not Necessary for Watermarking

One might think that to watermark a PDF behind its text, the program must understand the text.

Usually it does not.

The overlay approach is geometry-based.

You simply create another PDF content layer and merge it.

This is much simpler than:

read all text
understand document structure
rebuild document

That is one of the main reasons PDF overlays are such a useful technique.

What If You Want the Watermark Behind Only Text?

That is more complicated.

A PDF page might contain text and images in arbitrary positions. Placing one transparent watermark beneath all content means the watermark will appear behind images too, potentially becoming invisible.

If the requirement is more sophisticated, you may need document-specific analysis or a controlled PDF-generation pipeline.

For most applications, use:

whole-page watermark

rather than trying to understand every PDF object.

Using an Image Watermark Inside a PDF

Sometimes your watermark is prepared in Pillow as:

watermark.png

Then ReportLab can place it into the overlay:

c.drawImage(
    "watermark.png",
    x,
    y,
    width=logo_width,
    height=logo_height,
    mask="auto",
)

This combines the strengths of both libraries:

Pillow
=
excellent pixel-level watermark creation

ReportLab
=
easy PDF overlay generation

pypdf
=
PDF page merging

That division of responsibility is clean and practical.

A Complete End-to-End PDF Logo Watermark

Here is a complete example:

from io import BytesIO
from pathlib import Path

from pypdf import PdfReader, PdfWriter
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas


def create_logo_overlay(
    width: float,
    height: float,
    logo_path: str | Path,
) -> BytesIO:

    buffer = BytesIO()

    c = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    logo = ImageReader(
        str(logo_path)
    )

    logo_width = width * 0.22
    logo_height = height * 0.12

    x = (
        width - logo_width
    ) / 2

    y = (
        height - logo_height
    ) / 2

    c.drawImage(
        logo,
        x,
        y,
        width=logo_width,
        height=logo_height,
        preserveAspectRatio=True,
        anchor="c",
        mask="auto",
    )

    c.save()

    buffer.seek(0)

    return buffer


def watermark_pdf_with_logo(
    input_path: str | Path,
    output_path: str | Path,
    logo_path: str | Path,
) -> None:

    reader = PdfReader(
        str(input_path)
    )

    writer = PdfWriter()

    for page in reader.pages:

        width = float(
            page.mediabox.width
        )

        height = float(
            page.mediabox.height
        )

        overlay_buffer = (
            create_logo_overlay(
                width,
                height,
                logo_path,
            )
        )

        overlay_reader = PdfReader(
            overlay_buffer
        )

        overlay_page = (
            overlay_reader.pages[0]
        )

        page.merge_page(
            overlay_page,
            over=False,
        )

        writer.add_page(page)

    with open(
        output_path,
        "wb",
    ) as output:
        writer.write(output)

One design improvement would be to use the logo's actual aspect ratio rather than a fixed height. That can be added with Pillow by reading its dimensions before building the PDF overlay.

A Complete End-to-End Image Logo Watermark

For comparison, here is a reusable implementation:

from pathlib import Path

from PIL import Image, ImageOps


def watermark_image(
    input_path: str | Path,
    output_path: str | Path,
    logo_path: str | Path,
    opacity: float = 0.30,
    scale: float = 0.18,
    margin: int = 30,
) -> None:

    if not 0 <= opacity <= 1:
        raise ValueError(
            "opacity must be between 0 and 1"
        )

    if not 0 < scale <= 1:
        raise ValueError(
            "scale must be between 0 and 1"
        )

    with Image.open(
        input_path
    ) as source:

        source = ImageOps.exif_transpose(
            source
        )

        base = source.convert(
            "RGBA"
        )

    with Image.open(
        logo_path
    ) as source_logo:

        logo = source_logo.convert(
            "RGBA"
        )

    logo.thumbnail(
        (
            int(base.width * scale),
            int(base.height * scale),
        )
    )

    alpha = logo.getchannel(
        "A"
    )

    alpha = alpha.point(
        lambda value:
        int(value * opacity)
    )

    logo.putalpha(
        alpha
    )

    x = (
        base.width
        - logo.width
        - margin
    )

    y = (
        base.height
        - logo.height
        - margin
    )

    base.alpha_composite(
        logo,
        dest=(x, y),
    )

    output = Path(
        output_path
    )

    if output.suffix.lower() == ".png":

        base.save(
            output,
            "PNG",
        )

    else:

        base.convert(
            "RGB"
        ).save(
            output,
            "JPEG",
            quality=92,
            optimize=True,
        )

This version also handles EXIF orientation and supports either PNG or JPEG output based on the destination extension.

A Unified Mental Model for Image and PDF Watermarking

At this stage, the two workflows can be summarized as:

Image

Image
 +
Transparent Layer
 =
Composited Image

PDF

PDF Page
 +
Watermark PDF Page
 =
Merged PDF Page

The tools differ:

Pillow
  -> pixel composition

ReportLab
  -> create PDF watermark layers

pypdf
  -> merge PDF pages

Understanding this division makes the entire subject much easier.

Improving the Architecture Further

For a serious application, consider creating interfaces based on the file type.

For example:

class Watermarker:
    def apply(...):
        raise NotImplementedError

Then:

class ImageWatermarker(
    Watermarker
):
    def apply(...):
        ...


class PdfWatermarker(
    Watermarker
):
    def apply(...):
        ...

A service can choose the appropriate implementation:

def get_watermarker(
    file_type: str,
) -> Watermarker:

    if file_type == "image":
        return ImageWatermarker()

    if file_type == "pdf":
        return PdfWatermarker()

    raise ValueError(
        "Unsupported file type"
    )

This can be valuable when a document-management platform grows beyond one small script.

Adding Watermark Presets

You can define:

PRESETS = {
    "preview": {
        "opacity": 0.20,
        "position": "center",
        "rotation": 30,
    },
    "confidential": {
        "opacity": 0.15,
        "position": "center",
        "rotation": 35,
    },
    "branding": {
        "opacity": 0.30,
        "position": "bottom-right",
        "rotation": 0,
    },
}

Then:

options = PRESETS[
    "preview"
]

This is convenient for APIs and administrative dashboards.

Storing Watermark Settings in a Database

In a SaaS application, watermark configuration might belong to the tenant or organization.

For example:

organization
    |
    +-- watermark_enabled
    +-- watermark_text
    +-- watermark_logo
    +-- watermark_opacity
    +-- watermark_position
    +-- watermark_rotation

Then each organization can have different branding.

The important architectural rule is to keep the settings separate from the rendering implementation.

Multi-Tenant Watermarking

For a multi-tenant system:

Request
   |
   v
Identify Tenant
   |
   v
Load Tenant Watermark Settings
   |
   v
Process File
   |
   v
Store Tenant Output

Never load a watermark based solely on a user-controlled identifier.

Resolve it from authenticated tenant context.

This prevents one customer from accidentally receiving another customer's branding.

Storing Original and Watermarked Files Separately

A good storage structure might be:

media/
├── originals/
│   ├── 2026/
│   └── ...
└── watermarked/
    ├── 2026/
    └── ...

The original should generally remain separate from derived output.

This has several advantages:

  • regeneration is possible

  • watermark settings can change

  • quality can be preserved

  • users can download originals only when authorized

  • corrupted output does not destroy the source

Regenerating Watermarks

Imagine you initially generate:

logo watermark, opacity 0.25

Later the design changes to:

logo watermark, opacity 0.35

If you kept only the already-watermarked version, you cannot reliably recreate the original.

If you retained the source file:

original
   |
   +----> watermark version A
   |
   +----> watermark version B

you can regenerate the output.

This is one of the strongest arguments for storing derived files separately.

Caching Watermarked Outputs

Suppose the same PDF is requested repeatedly.

Instead of processing every time:

Request
 ↓
Watermark PDF
 ↓
Return

use:

Request
 ↓
Check cache
 ↓
Existing output?
 ├── yes -> return
 └── no
       ↓
    watermark
       ↓
      save
       ↓
      return

A cache key can include:

source file ID
watermark template ID
watermark version
watermark settings

This lets you invalidate old outputs when branding changes.

Versioning Watermark Templates

A useful idea is:

template_version = 3

When the template changes:

version 4

Then a generated artifact can store:

source_id = 812
watermark_template = customer-preview
watermark_version = 4

Now your system knows exactly how the file was generated.

Auditing Document Generation

In enterprise workflows, record:

who requested the operation
when it happened
which source file was used
which watermark template was used
what output was generated
whether it succeeded

This creates a clear audit trail.

You do not need to expose those details in the visible watermark itself.

Handling Failures Gracefully

A robust watermarking system should distinguish between:

Invalid input
Unsupported format
Oversized file
Encrypted PDF
Missing watermark asset
Font failure
Write failure
Unexpected processing error

For example:

class WatermarkError(Exception):
    pass


class UnsupportedFileError(
    WatermarkError
):
    pass


class WatermarkAssetError(
    WatermarkError
):
    pass

Then your application can return useful errors instead of one generic “something went wrong.”

Example Error Handling

try:
    watermark_image(
        input_path,
        output_path,
        logo_path,
    )

except FileNotFoundError as exc:
    raise WatermarkAssetError(
        "Required watermark asset "
        "was not found."
    ) from exc

except OSError as exc:
    raise WatermarkError(
        "The file could not be processed."
    ) from exc

This is especially helpful in API services where exceptions should be translated into appropriate HTTP responses.

Adding Watermarking to a CI/CD Pipeline

If your watermark logic is part of a production service, test it automatically in CI.

A pipeline might run:

pytest

Then:

Lint
  ↓
Unit Tests
  ↓
Integration Tests
  ↓
Build
  ↓
Deploy

Your integration tests should include real sample files.

Do not rely exclusively on synthetic one-page PDFs.

Test PDFs You Should Keep

A useful test suite may contain:

a4_portrait.pdf
a4_landscape.pdf
letter.pdf
mixed_sizes.pdf
rotated_pages.pdf
image_heavy.pdf
text_heavy.pdf
forms.pdf
encrypted.pdf

You do not need hundreds of files to get value from this approach. A carefully selected set of representative documents can reveal many compatibility issues.

Visual Regression Testing

Some watermark errors are difficult to catch through metadata alone.

For example, the code can succeed but the watermark might:

  • be off the page

  • be too small

  • be upside down

  • cover important text

  • disappear behind an opaque layer

For critical applications, visual regression testing can help.

You can generate outputs and compare them against approved reference images.

The exact implementation depends on your testing stack.

Performance Strategy for PDFs

Large PDFs can be expensive to process.

Consider:

file size
page count
embedded image size
number of resources

Before deciding that “PDF watermarking is fast,” benchmark it against realistic documents.

A 5-page text-only PDF and a 500-page scanned PDF are very different workloads.

Scanned PDFs

A scanned PDF might consist largely of full-page images.

In that case:

PDF page
  =
large image

A background watermark may be completely hidden by that image.

A foreground watermark:

over=True

may be more appropriate if visibility is required.

Text PDFs

A text-native PDF often works well with a subtle background watermark:

over=False

because the watermark can remain visible in white or empty regions while staying behind the text.

Again, this is a design decision, not a strict technical rule.

Watermarking Landscape Documents

For landscape documents, the page dimensions may be:

842 × 595

instead of:

595 × 842

If your overlay is created dynamically:

width = float(
    page.mediabox.width
)

height = float(
    page.mediabox.height
)

the watermark can be centered correctly.

This is one of the biggest advantages of page-specific overlays.

Watermarking Mixed-Size PDFs

A PDF can contain pages such as:

Page 1 -> A4 portrait
Page 2 -> A4 landscape
Page 3 -> Letter portrait
Page 4 -> custom size

A robust loop should calculate the overlay dimensions for every page instead of assuming the first page represents the entire document.

That leads to:

for page in reader.pages:

    width = float(
        page.mediabox.width
    )

    height = float(
        page.mediabox.height
    )

    overlay = create_watermark_page(
        width,
        height,
        text,
    )

    # merge overlay

This small design decision significantly improves reliability.

Adding a Bottom-Right PDF Watermark

A bottom-right watermark can be generated from page dimensions:

margin = 40

x = width - 180 - margin
y = margin

c.drawString(
    x,
    y,
    "example.com",
)

A better implementation measures the actual text width:

from reportlab.pdfbase.pdfmetrics import stringWidth


text_width = stringWidth(
    text,
    "Helvetica",
    24,
)

x = (
    width
    - text_width
    - margin
)

y = margin

This ensures that the right edge respects the margin.

Centering PDF Text Precisely

Use:

c.drawCentredString(
    width / 2,
    height / 2,
    text,
)

For horizontal centering this is convenient.

For more complicated multi-line text, calculate line heights and render each line separately.

Multi-Line PDF Watermarks

For example:

lines = [
    "CONFIDENTIAL",
    "Example Company",
    "Customer Preview",
]

Then:

line_height = 30

start_y = (
    height / 2
    + line_height
)

for index, line in enumerate(lines):

    y = (
        start_y
        - index * line_height
    )

    c.drawCentredString(
        width / 2,
        y,
        line,
    )

This can create a more professional watermark block.

Adding a Watermark Border

A document may use a light border rather than a full-page watermark.

margin = 25

c.setStrokeGray(
    0.85
)

c.setLineWidth(
    2
)

c.rect(
    margin,
    margin,
    width - 2 * margin,
    height - 2 * margin,
)

Then merge that overlay onto the source page.

This is useful for “preview” documents and branded reports.

Combining Border, Logo, and Text

A sophisticated PDF watermark page can contain:

+--------------------------------------+
|                                      |
|          [Logo] Example             |
|                                      |
|            CONFIDENTIAL             |
|                                      |
|                                      |
|                                      |
+--------------------------------------+

Because the watermark is simply another PDF page, you can use the normal ReportLab drawing primitives to create it.

This is often easier to maintain than trying to modify the source PDF's individual objects.

Why Layer-Based Design Scales Well

Once you understand the layer model, many features become straightforward.

For images:

base
 + logo
 + text
 + badge
 + border
 + status

For PDFs:

source page
 + background watermark
 + logo
 + footer
 + confidential label

You can build each element independently and then compose them.

That is much cleaner than writing one giant function that knows every visual detail.

Production Architecture Example

A production document-processing service might look like this:

                    +------------------+
                    |    Web Client    |
                    +--------+---------+
                             |
                             v
                    +------------------+
                    |    API Server    |
                    +--------+---------+
                             |
                    Validate Upload
                             |
                             v
                    +------------------+
                    |  Storage / DB    |
                    +--------+---------+
                             |
                             v
                    +------------------+
                    |      Queue       |
                    +--------+---------+
                             |
                             v
                    +------------------+
                    | Watermark Worker |
                    +--------+---------+
                             |
                 +-----------+-----------+
                 |                       |
                 v                       v
             Pillow                  pypdf
                 |                       |
                 v                       v
            Image Output            PDF Output
                 |                       |
                 +-----------+-----------+
                             |
                             v
                         Storage
                             |
                             v
                           User

This architecture is scalable because the API does not have to perform every expensive operation synchronously.

A Simple Project Structure

A clean project might look like:

watermark_service/
│
├── app/
│   ├── __init__.py
│   ├── image_watermark.py
│   ├── pdf_watermark.py
│   ├── config.py
│   └── validators.py
│
├── assets/
│   ├── logo.png
│   └── fonts/
│       └── DejaVuSans.ttf
│
├── tests/
│   ├── test_image_watermark.py
│   └── test_pdf_watermark.py
│
├── input/
├── output/
├── requirements.txt
└── main.py

This is a reasonable starting point for a standalone utility.

Example image_watermark.py

from pathlib import Path

from PIL import Image, ImageOps


def add_logo_watermark(
    input_path: str | Path,
    logo_path: str | Path,
    output_path: str | Path,
    opacity: float = 0.3,
    scale: float = 0.18,
    margin: int = 30,
) -> None:

    with Image.open(input_path) as image:

        image = ImageOps.exif_transpose(
            image
        )

        base = image.convert(
            "RGBA"
        )

    with Image.open(logo_path) as logo_file:

        logo = logo_file.convert(
            "RGBA"
        )

    logo.thumbnail(
        (
            int(base.width * scale),
            int(base.height * scale),
        )
    )

    alpha = logo.getchannel(
        "A"
    )

    alpha = alpha.point(
        lambda value:
        int(value * opacity)
    )

    logo.putalpha(
        alpha
    )

    x = (
        base.width
        - logo.width
        - margin
    )

    y = (
        base.height
        - logo.height
        - margin
    )

    base.alpha_composite(
        logo,
        dest=(x, y),
    )

    output = Path(
        output_path
    )

    base.convert(
        "RGB"
    ).save(
        output,
        "JPEG",
        quality=92,
        optimize=True,
    )

Example pdf_watermark.py

from io import BytesIO
from pathlib import Path

from pypdf import PdfReader, PdfWriter
from reportlab.pdfgen import canvas


def build_overlay(
    width: float,
    height: float,
    text: str,
) -> BytesIO:

    buffer = BytesIO()

    c = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    c.saveState()

    c.translate(
        width / 2,
        height / 2,
    )

    c.rotate(35)

    c.setFillGray(
        0.82
    )

    c.setFont(
        "Helvetica-Bold",
        48,
    )

    c.drawCentredString(
        0,
        0,
        text,
    )

    c.restoreState()

    c.save()

    buffer.seek(0)

    return buffer


def add_pdf_watermark(
    input_path: str | Path,
    output_path: str | Path,
    text: str,
) -> None:

    reader = PdfReader(
        str(input_path)
    )

    writer = PdfWriter()

    for page in reader.pages:

        width = float(
            page.mediabox.width
        )

        height = float(
            page.mediabox.height
        )

        buffer = build_overlay(
            width,
            height,
            text,
        )

        overlay_reader = PdfReader(
            buffer
        )

        overlay_page = (
            overlay_reader.pages[0]
        )

        page.merge_page(
            overlay_page,
            over=False,
        )

        writer.add_page(
            page
        )

    with open(
        output_path,
        "wb",
    ) as output:
        writer.write(
            output
        )

This separation makes the project easier to maintain.

Adding Type Hints Everywhere

Type hints make utility code clearer:

def calculate_position(
    image_size: tuple[int, int],
    watermark_size: tuple[int, int],
    position: str,
    margin: int,
) -> tuple[int, int]:
    ...

For larger systems, type hints also help IDEs, linters, and reviewers understand how your code should be used.

Validating Opacity

Do not accept arbitrary values:

opacity = float(
    request.form["opacity"]
)

without validating them.

Use:

if not 0.0 <= opacity <= 1.0:
    raise ValueError(
        "Opacity must be between 0 and 1."
    )

For image alpha values:

0 <= opacity <= 255

Keep your public configuration format consistent. It is often easier to expose:

0.0 -> 1.0

and convert internally.

Validating Scale

Similarly:

if not 0 < scale <= 1:
    raise ValueError(
        "Scale must be greater than 0 "
        "and no greater than 1."
    )

This prevents accidental values such as:

scale=15

which could create unexpectedly large watermark assets.

Validating Position

Use an allow-list:

VALID_POSITIONS = {
    "top-left",
    "top-right",
    "bottom-left",
    "bottom-right",
    "center",
}

Then:

if position not in VALID_POSITIONS:
    raise ValueError(
        "Invalid watermark position."
    )

Never assume the frontend will always send valid values.

Protecting Against Huge Watermark Requests

A user might submit:

font_size = 100000
scale = 1.0

or another extreme value.

Set reasonable limits:

if font_size > 500:
    raise ValueError(
        "Font size is too large."
    )

Likewise:

if scale > 0.8:
    raise ValueError(
        "Watermark scale is too large."
    )

These limits should reflect the actual needs of your application.

Watermarking for E-Commerce Product Images

A practical use case is an online store.

Suppose a seller uploads:

product.jpg

Your system could automatically generate:

product-original.jpg
product-preview.jpg
product-thumbnail.jpg

The preview could receive:

YOUR STORE

and the thumbnail could use a smaller logo.

This can be done with the same Pillow functions developed earlier.

Watermarking for Photography Platforms

A photography platform could generate:

Original
Large preview
Medium preview
Thumbnail
Social preview

with different watermark settings.

For example:

large = WatermarkConfig(
    opacity=0.25,
    scale=0.15,
)

thumbnail = WatermarkConfig(
    opacity=0.40,
    scale=0.12,
)

Because watermarking is automated, the photographer does not need to manually edit every image.

Watermarking for Document Preview Systems

A document platform may have:

original PDF
preview PDF

The preview could contain:

PREVIEW ONLY

on every page.

The original remains available only to authorized users.

This is a clear example where watermarking and authorization complement one another.

Watermarking Invoices and Reports

Businesses often generate PDFs from application data.

Instead of creating the document and watermarking it afterward, you can sometimes include the watermark at generation time.

For example, a ReportLab-generated report can include a watermark layer from the start.

That can be preferable when you control the document-generation pipeline.

If the PDF comes from an external source, post-processing with pypdf is more practical.

Choosing Between Generation-Time and Post-Processing

Use generation-time watermarking when:

you control PDF creation

Use post-processing when:

the PDF already exists

Use Pillow when:

you are processing raster images

Use pypdf plus ReportLab when:

you are processing existing PDFs

This distinction helps avoid unnecessary conversions.

Can Python Watermark PDFs Without ReportLab?

Yes, depending on what you need.

pypdf can merge an existing watermark PDF:

watermark_page = (
    PdfReader(
        "watermark.pdf"
    ).pages[0]
)

page.merge_page(
    watermark_page
)

The reason ReportLab is useful is simply that it gives you a convenient way to generate the watermark PDF programmatically.

You could create the watermark using another PDF library or use a pre-built PDF template.

Using a Pre-Built Watermark PDF

This is excellent for complex designs.

Suppose a designer creates:

customer_watermark.pdf

Then your Python code only needs to merge it.

watermark = PdfReader(
    "customer_watermark.pdf"
)

watermark_page = (
    watermark.pages[0]
)

for page in reader.pages:
    page.merge_page(
        watermark_page,
        over=False,
    )

This can be much easier than reproducing an elaborate graphic design through drawing primitives.

Scaling a Pre-Built Watermark

If your source pages have different sizes, use transformation methods to resize the watermark. pypdf's current page API documents transformed merging as the mechanism for combining a page while applying scaling, rotation, or translation.

Conceptually:

from pypdf import Transformation

transform = (
    Transformation()
    .scale(0.5)
    .translate(
        100,
        100,
    )
)

page.merge_transformed_page(
    watermark_page,
    transform,
    over=False,
)

Always test the transformation visually because coordinate systems and page rotations can influence the final placement.

PDF Watermarking and File Size

Adding a watermark can affect output size because the watermark introduces new content and resources.

A text watermark is usually compact.

A large raster watermark may be much larger.

Therefore, prefer:

vector text

when appropriate.

For logos:

optimized PNG

or a suitable PDF/vector asset can reduce unnecessary overhead.

Optimizing Logo Assets

If your logo contains a huge transparent canvas around a tiny logo, crop it first.

A simple Pillow operation:

bbox = logo.getbbox()

if bbox:
    logo = logo.crop(
        bbox
    )

This reduces unnecessary transparent pixels and makes positioning more predictable.

Removing Empty Transparent Space

This small optimization can make a surprising visual difference.

Imagine:

+-----------------------------+
|                             |
|         logo                |
|                             |
+-----------------------------+

The actual logo may occupy only 20% of the canvas.

After cropping:

+-----------+
|   logo    |
+-----------+

Then thumbnail() and positioning work based on the visible content rather than a giant transparent rectangle.

Human-Friendly Watermark Design

A watermark should communicate a message without destroying the original content.

There is a temptation to make the watermark extremely strong because it feels safer. But a watermark that dominates the document can make the content difficult to read and can create an unpleasant user experience.

A more useful question is:

What information should remain visible after the watermark is applied?

For example:

Brand watermark
=
subtle identity

CONFIDENTIAL watermark
=
status communication

PREVIEW watermark
=
usage restriction message

Customer-specific watermark
=
distribution identification

Each goal can justify a different visual treatment.

A Practical Watermark Design Checklist

Before launching your system, test:

□ watermark is clearly visible
□ important content remains readable
□ watermark stays inside page boundaries
□ logo is not distorted
□ watermark scales with different file sizes
□ image orientation is correct
□ PDF page sizes remain unchanged
□ portrait and landscape pages work
□ multiple page sizes work
□ output files open normally
□ unsupported files fail safely
□ temporary files are removed

That checklist is more valuable than simply testing one successful example.

Example: Image Watermark Script

Here is a compact standalone script:

from pathlib import Path

from PIL import Image, ImageOps, ImageDraw, ImageFont


INPUT = Path("input.jpg")
OUTPUT = Path("watermarked.jpg")
FONT = Path("fonts/DejaVuSans.ttf")

TEXT = "example.com"
OPACITY = 120
MARGIN = 30


def main() -> None:

    with Image.open(INPUT) as source:

        source = ImageOps.exif_transpose(
            source
        )

        image = source.convert(
            "RGBA"
        )

    overlay = Image.new(
        "RGBA",
        image.size,
        (0, 0, 0, 0),
    )

    draw = ImageDraw.Draw(
        overlay
    )

    font_size = max(
        24,
        image.width // 30,
    )

    font = ImageFont.truetype(
        FONT,
        font_size,
    )

    bbox = draw.textbbox(
        (0, 0),
        TEXT,
        font=font,
    )

    text_width = (
        bbox[2] - bbox[0]
    )

    text_height = (
        bbox[3] - bbox[1]
    )

    x = (
        image.width
        - text_width
        - MARGIN
    )

    y = (
        image.height
        - text_height
        - MARGIN
    )

    draw.text(
        (x, y),
        TEXT,
        font=font,
        fill=(
            255,
            255,
            255,
            OPACITY,
        ),
        stroke_width=2,
        stroke_fill=(
            0,
            0,
            0,
            OPACITY // 2,
        ),
    )

    result = Image.alpha_composite(
        image,
        overlay,
    )

    result.convert(
        "RGB"
    ).save(
        OUTPUT,
        "JPEG",
        quality=92,
        optimize=True,
    )

    print(
        f"Created: {OUTPUT}"
    )


if __name__ == "__main__":
    main()

Run it:

python watermark_image.py

Example: PDF Watermark Script

And here is a compact standalone PDF version:

from io import BytesIO
from pathlib import Path

from pypdf import PdfReader, PdfWriter
from reportlab.pdfgen import canvas


INPUT = Path("input.pdf")
OUTPUT = Path("watermarked.pdf")
TEXT = "CONFIDENTIAL"


def create_overlay(
    width: float,
    height: float,
) -> BytesIO:

    buffer = BytesIO()

    c = canvas.Canvas(
        buffer,
        pagesize=(width, height),
    )

    c.saveState()

    c.translate(
        width / 2,
        height / 2,
    )

    c.rotate(35)

    c.setFillGray(
        0.82
    )

    c.setFont(
        "Helvetica-Bold",
        48,
    )

    c.drawCentredString(
        0,
        0,
        TEXT,
    )

    c.restoreState()

    c.save()

    buffer.seek(0)

    return buffer


def main() -> None:

    reader = PdfReader(
        INPUT
    )

    writer = PdfWriter()

    for page in reader.pages:

        width = float(
            page.mediabox.width
        )

        height = float(
            page.mediabox.height
        )

        overlay_buffer = (
            create_overlay(
                width,
                height,
            )
        )

        overlay_reader = PdfReader(
            overlay_buffer
        )

        overlay_page = (
            overlay_reader.pages[0]
        )

        page.merge_page(
            overlay_page,
            over=False,
        )

        writer.add_page(
            page
        )

    with OUTPUT.open(
        "wb"
    ) as file:

        writer.write(
            file
        )

    print(
        f"Created: {OUTPUT}"
    )


if __name__ == "__main__":
    main()

This script is enough to demonstrate the central PDF approach without hiding the important mechanics.

Putting Everything Together

At this point, we have built a complete toolkit around a simple idea.

For images:

Pillow
 ↓
open
 ↓
normalize orientation
 ↓
convert RGBA
 ↓
create transparent overlay
 ↓
draw logo/text
 ↓
position
 ↓
alpha composite
 ↓
save

For PDFs:

pypdf
 ↓
read source
 ↓
inspect page size
 ↓
ReportLab creates matching watermark page
 ↓
merge watermark
 ↓
write output

The real power comes from combining those building blocks into reusable services.

Recommended Production Strategy

A robust implementation usually follows these principles:

Keep the original

Never overwrite the source file unless there is a very specific reason to do so.

Normalize input

For images, use EXIF-aware orientation handling and convert to a predictable mode before compositing.

Make watermark settings configurable

Do not hard-code:

opacity
position
scale
text
font
rotation

inside every function.

Match PDF page dimensions

Inspect each page rather than assuming A4.

Validate uploads

Treat every user-supplied file as untrusted input.

Separate processing from HTTP

Large workloads belong in background workers.

Test real files

Especially for PDFs, test rotated pages, mixed dimensions, scanned documents, forms, and encrypted files.

Keep the design subtle enough to remain useful

A watermark should solve a business problem without making the underlying content unnecessarily difficult to consume.

Final Thoughts

Adding a watermark to an image or PDF with Python is a perfect example of a feature that begins as a tiny script and can eventually become a complete document-processing subsystem.

At the beginning, the code may look like this:

draw.text(...)

or:

page.merge_page(...)

But reliable production software requires much more thought.

For images, the key ideas are transparency, RGBA processing, resizing, positioning, EXIF orientation, format handling, and output quality. Pillow's alpha compositing capabilities make a transparent overlay approach practical and predictable.

For PDFs, the essential idea is to create a watermark layer and merge it with each page. pypdf's page-merging API provides the core mechanism, including the ability to control whether the watermark is placed over or under the existing content and to apply transformations when necessary.

The most important architectural decision is to think in layers.

An image is:

base layer
+
watermark layer

A PDF is:

document page
+
watermark page

Once you think about watermarking this way, more advanced features become much easier to design. A logo can become a layer. Text can become a layer. A status label can become a layer. A customer-specific identifier can become a layer. A repeated pattern can become a layer. A decorative border can become a layer.

That flexibility is what makes Python such a good choice for automated watermarking.

There is also a practical human lesson behind all of this. When you build a watermarking feature, it is easy to focus entirely on making the watermark impossible to ignore. But the person opening the resulting file still needs to read the document or enjoy the image. A watermark that technically works but ruins the visual experience is not really a successful feature. The strongest implementations balance identity, communication, usability, and performance.

Start with the simplest implementation that solves the actual problem:

Pillow + transparent overlay

for images, and:

ReportLab + pypdf

for PDFs.

Then grow the design as the requirements become clearer.

Once the core functions are reliable, you can add:

batch processing
REST APIs
Django integration
FastAPI integration
Celery workers
multi-tenant branding
watermark presets
template versioning
caching
storage integration
audit logs

At that point, you are no longer just adding a watermark. You are building a reusable file-processing pipeline.

And that is one of the things Python does particularly well: taking a task that would be repetitive and error-prone when performed manually and turning it into a deterministic process that can run hundreds, thousands, or even millions of times with the same rules.

HA

Hassan Agmir

Author · Filenewer

Writing about file tools and automation at Filenewer.

Try It Free

Process your files right now

No account needed · Fast & secure · 100% free

Browse All Tools