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:
Load the source file.
Create or load the watermark.
Position and transform the watermark.
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 ImageFor 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 PDFThe terminology changes slightly, but the architecture remains similar.
Installing the Required Python Libraries
For image processing, install Pillow:
pip install pillowFor PDF reading, writing, and page merging, install pypdf:
pip install pypdfFor generating PDF watermark pages, ReportLab is useful:
pip install reportlabA typical project might use:
pip install pillow pypdf reportlabYou 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 .venvActivate it on Windows:
.venv\Scripts\Activate.ps1Or on Linux and macOS:
source .venv/bin/activateThen install your dependencies.
A simple requirements.txt file could contain:
Pillow
pypdf
reportlabFor 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.4Those 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 = AlphaThe alpha value controls opacity.
Conceptually:
Alpha = 255 -> fully opaque
Alpha = 128 -> approximately half opaque
Alpha = 0 -> fully transparentPillow 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.pyThen:
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.pyThen:
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 = 100For a 900-pixel-wide image:
900 / 30 = 30This 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.pngwith 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 imageThen:
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 × 800and you force it into:
300 × 300it 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:
Create the watermark.
Rotate it.
Place it on the image.
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.jpegWe 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
CMYKA 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:
resultThis 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 * 1024You 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 ResponseHowever, 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 × 6000contains:
48,000,000 pixelsFor RGBA data, four bytes per pixel is a useful rough mental model:
48,000,000 × 4
≈ 192 MBThis 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 = 92Then:
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
ThumbnailProfileEach 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 reportlabThe architecture will be:
source.pdf
|
v
PdfReader
|
v
Read each page
|
+------> watermark.pdf
| |
| v
| PdfReader
| |
| v
+-------> merge_page()
|
v
PdfWriter
|
v
watermarked.pdfThe 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 pointsdepending 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 pageThe 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 bufferThis 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 bufferThe 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 bufferThis 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.pngThen 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 bufferThe 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_widthUsing 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 PDFThis 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 PageThe 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 = 90or:
mediabox = landscape
rotation = 90The page may appear visually rotated even though its underlying media box is stored differently.
A robust application should inspect:
rotation = page.rotationand 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 -> watermarkUse 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 = Noneand interpret None as all pages.
Supporting Human-Friendly Page Numbers
Users usually think in terms of:
Page 1
Page 2
Page 3while Python indexes:
0
1
2Convert 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:
CONFIDENTIALyou could use:
Prepared for John Smithor:
Licensed to Example CompanyFor 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
CONFIDENTIALThe 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 watermarkThen:
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 imageyou 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 110This 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.jpgor:
python watermark.py pdf input.pdf output.pdfAnd 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/pdfValidate input independently.
Depending on the application, you may want:
maximum upload size
maximum page count
allowed extension
allowed MIME type
temporary storage limits
timeout limitsAlso 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
/producerDepending 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 strongThese 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.formatafter opening the image.
Do not rely entirely on the filename extension.
An uploaded file called:
photo.jpgdoes 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=75The 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 -> JPEGquality may degrade over time.
A better architecture is:
Original source
|
v
Single watermark operation
|
v
Final outputDo 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 WatermarkedThe order of operations matters.
You can create the thumbnail first and then watermark it:
thumbnail = image.copy()
thumbnail.thumbnail(
(800, 800)
)
# Apply watermark to thumbnailThis 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 ResultsThis 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 imagesor:
500-page PDFProcessing 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 UserCommon 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
failedYour database model might conceptually contain:
id
source_file
output_file
status
error_message
created_at
completed_atThe UI could then show:
Watermarking...followed by:
CompletedThis 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.pyThe service module could contain:
class WatermarkService:
def watermark_image(...):
...
def watermark_pdf(...):
...The API layer then becomes responsible for:
authentication
validation
request parsing
response generationwhile the service handles:
file processing
watermark placement
savingThis 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) > 0For 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.heightbefore 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=Trueand 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:
CONFIDENTIALin 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-repeatedFor 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
↓
ExportThe 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 canvasThen 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 rangeDo 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 = TrueThen 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 pageDo not tightly couple those concepts.
Instead:
Business Layer
|
v
Watermark Configuration
|
v
Rendering Service
|
+----> Pillow
|
+----> ReportLab + pypdfThat 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.jsonFor 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.comThis is useful across:
development
staging
productionwhere 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
workingand 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
)
# processPillow'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 compressionPerformance 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:
CONFIDENTIALis easy with basic PDF fonts.
But if you need:
وثيقة سريةor:
Document confidentielor:
機密文書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 scriptsdepending 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 documentThat 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 watermarkrather than trying to understand every PDF object.
Using an Image Watermark Inside a PDF
Sometimes your watermark is prepared in Pillow as:
watermark.pngThen 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 mergingThat 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 ImagePDF Page
+
Watermark PDF Page
=
Merged PDF PageThe tools differ:
Pillow
-> pixel composition
ReportLab
-> create PDF watermark layers
pypdf
-> merge PDF pagesUnderstanding 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 NotImplementedErrorThen:
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_rotationThen 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 OutputNever 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.25Later the design changes to:
logo watermark, opacity 0.35If 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 Byou 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
↓
Returnuse:
Request
↓
Check cache
↓
Existing output?
├── yes -> return
└── no
↓
watermark
↓
save
↓
returnA cache key can include:
source file ID
watermark template ID
watermark version
watermark settingsThis lets you invalidate old outputs when branding changes.
Versioning Watermark Templates
A useful idea is:
template_version = 3When the template changes:
version 4Then a generated artifact can store:
source_id = 812
watermark_template = customer-preview
watermark_version = 4Now 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 succeededThis 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 errorFor example:
class WatermarkError(Exception):
pass
class UnsupportedFileError(
WatermarkError
):
pass
class WatermarkAssetError(
WatermarkError
):
passThen 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 excThis 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:
pytestThen:
Lint
↓
Unit Tests
↓
Integration Tests
↓
Build
↓
DeployYour 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.pdfYou 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 resourcesBefore 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 imageA background watermark may be completely hidden by that image.
A foreground watermark:
over=Truemay be more appropriate if visibility is required.
Text PDFs
A text-native PDF often works well with a subtle background watermark:
over=Falsebecause 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 × 595instead of:
595 × 842If 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 sizeA 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 overlayThis 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 = marginThis 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
+ statusFor PDFs:
source page
+ background watermark
+ logo
+ footer
+ confidential labelYou 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
UserThis 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.pyThis 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 <= 255Keep your public configuration format consistent. It is often easier to expose:
0.0 -> 1.0and 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=15which 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.0or 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.jpgYour system could automatically generate:
product-original.jpg
product-preview.jpg
product-thumbnail.jpgThe preview could receive:
YOUR STOREand 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 previewwith 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 PDFThe preview could contain:
PREVIEW ONLYon 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 creationUse post-processing when:
the PDF already existsUse Pillow when:
you are processing raster imagesUse pypdf plus ReportLab when:
you are processing existing PDFsThis 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.pdfThen 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 textwhen appropriate.
For logos:
optimized PNGor 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 identificationEach 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 removedThat 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.pyExample: 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
↓
saveFor PDFs:
pypdf
↓
read source
↓
inspect page size
↓
ReportLab creates matching watermark page
↓
merge watermark
↓
write outputThe 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
rotationinside 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 layerA PDF is:
document page
+
watermark pageOnce 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 overlayfor images, and:
ReportLab + pypdffor 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 logsAt 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.
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