Add a Text Watermark to Every PDF Page
Board papers must go out with CONFIDENTIAL – prepared for J. Patel across every page, so a leaked copy identifies its recipient. The watermark script works on the first test file, then a real pack exposes the gaps: on landscape pages the text runs off the edge, on pages stored with /Rotate 90 it appears sideways and in a corner, on A3 appendices it is tiny and off-centre, and on scanned pages it is solid black and hides the text it should sit behind.
Root Cause
A watermark is extra content drawn on each page, and three page properties decide where "the middle of the page, diagonally" actually is. Page sizes vary within one document — A4 body, A3 foldouts, Letter attachments from US colleagues — so fixed coordinates and a fixed font size only fit one of them. Page rotation (/Rotate) turns the displayed page, but content is drawn in the page's unrotated coordinate system, so a watermark placed using the visual width and height lands in the wrong place and at the wrong angle on rotated pages. And the page's origin is not always (0, 0): the visible area is the crop box, which may be offset within the media box. Transparency is a fourth issue: without an explicit fill opacity, text is fully opaque and obscures content, and some generation paths ignore opacity unless it is set through an extended graphics state.
Minimal Diagnostic
List each page's size, rotation and crop box offset. Any variation means a fixed-position watermark will misbehave on some pages.
# pip install pymupdf
from collections import Counter
from pathlib import Path
import pymupdf
SOURCE = Path("in/board-pack-2026-09.pdf")
def page_geometry(pdf_path: Path) -> None:
try:
doc = pymupdf.open(pdf_path)
except (pymupdf.FileDataError, RuntimeError) as exc:
raise SystemExit(f"cannot open {pdf_path}: {exc}")
with doc:
kinds = Counter()
for page in doc:
w_mm, h_mm = page.rect.width / 72 * 25.4, page.rect.height / 72 * 25.4
offset = (round(page.cropbox.x0), round(page.cropbox.y0))
kinds[(round(w_mm), round(h_mm), page.rotation, offset)] += 1
for (w, h, rot, off), n in kinds.most_common():
print(f"{n:>3} page(s): {w} x {h} mm displayed, /Rotate {rot}, cropbox origin {off}")
if __name__ == "__main__":
page_geometry(SOURCE)
38 page(s): 210 x 297 mm displayed, /Rotate 0, cropbox origin (0, 0)
6 page(s): 297 x 210 mm displayed, /Rotate 90, cropbox origin (0, 0)
4 page(s): 420 x 297 mm displayed, /Rotate 0, cropbox origin (0, 0)
2 page(s): 216 x 279 mm displayed, /Rotate 0, cropbox origin (18, 18)
Four different geometries in one pack. The watermark has to be computed per page.
Fix: Compute Position, Size and Angle per Page
PyMuPDF's insert_text with a morph transformation draws rotated text around a pivot point, and fill_opacity makes it translucent. Compute everything from each page's visible rectangle and rotation. Changed lines carry comments.
# pip install pymupdf
import math
from pathlib import Path
import pymupdf
SOURCE = Path("in/board-pack-2026-09.pdf")
DEST = Path("out/board-pack-2026-09-jpatel.pdf")
def watermark_page(page: pymupdf.Page, text: str, opacity: float = 0.15) -> None:
visible = page.rect # changed: displayed rect (rotation applied)
diagonal = math.hypot(visible.width, visible.height)
font = pymupdf.Font("helv")
fontsize = 0.70 * diagonal / max(font.text_length(text, fontsize=1), 1) # changed: scale to the page
angle = math.degrees(math.atan2(visible.height, visible.width)) # changed: corner to corner
centre = pymupdf.Point(visible.x0 + visible.width / 2, visible.y0 + visible.height / 2)
centre_unrotated = centre * page.derotation_matrix # changed: into drawing coordinates
start = pymupdf.Point(centre_unrotated.x - font.text_length(text, fontsize) / 2,
centre_unrotated.y + fontsize * 0.35)
matrix = pymupdf.Matrix(-angle - page.rotation) # changed: compensate page /Rotate
page.insert_text(
start, text,
fontsize=fontsize, fontname="helv",
color=(0.8, 0.1, 0.1),
fill_opacity=opacity, # changed: translucent
morph=(centre_unrotated, matrix), # changed: rotate around the centre
overlay=True,
)
def watermark_pdf(src: Path, dest: Path, text: str) -> int:
with pymupdf.open(src) as doc:
for page in doc:
watermark_page(page, text)
dest.parent.mkdir(parents=True, exist_ok=True)
doc.save(dest, garbage=3, deflate=True)
return doc.page_count
if __name__ == "__main__":
try:
n = watermark_pdf(SOURCE, DEST, "CONFIDENTIAL – prepared for J. Patel")
print(f"watermarked {n} pages")
except Exception as exc:
raise SystemExit(f"watermarking failed: {exc}")
Scaling the font so the text spans 70 percent of the diagonal gives a visually consistent watermark on A4, A3 and Letter alike. page.rect is the visible, rotation-adjusted page area; multiplying the centre by page.derotation_matrix converts it into the unrotated coordinate system that drawing commands use, and subtracting the page rotation from the text angle keeps the watermark running bottom-left to top-right as the reader sees it. Negative angles in PyMuPDF's Matrix rotate counter-clockwise in page coordinates, which with PDF's downward y-axis produces the familiar rising diagonal.
Variant Fix 1: pypdf with a Stamp Page
Without PyMuPDF, draw the watermark once per distinct page size with ReportLab and merge it onto each page with pypdf. Cache stamps by size so a 200-page A4 document creates one stamp, not 200:
# pip install "pypdf>=4.0" reportlab
import io
import math
from functools import lru_cache
from pathlib import Path
from pypdf import PdfReader, PdfWriter, Transformation
from reportlab.pdfgen import canvas
@lru_cache(maxsize=16)
def stamp_for(width: float, height: float, text: str, opacity: float = 0.15):
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(width, height))
diagonal = math.hypot(width, height)
size = 0.70 * diagonal / c.stringWidth(text, "Helvetica-Bold", 1)
c.setFont("Helvetica-Bold", size)
c.setFillColorRGB(0.8, 0.1, 0.1, alpha=opacity)
c.translate(width / 2, height / 2)
c.rotate(math.degrees(math.atan2(height, width)))
c.drawCentredString(0, -size * 0.35, text)
c.save()
buf.seek(0)
return PdfReader(buf).pages[0]
def watermark_pypdf(src: Path, dest: Path, text: str) -> None:
writer = PdfWriter(clone_from=PdfReader(src))
for page in writer.pages:
box = page.cropbox
w, h = float(box.width), float(box.height)
rotation = page.rotation % 360
if rotation in (90, 270):
w, h = h, w # draw in unrotated space
stamp = stamp_for(w, h, text)
page.merge_transformed_page(stamp, Transformation().translate(float(box.left), float(box.bottom)))
with dest.open("wb") as fh:
writer.write(fh)
For rotated pages, the stamp is drawn at the unrotated size; pypdf merges it in the page's own coordinates, and the viewer applies /Rotate to both content and stamp together. If the angle looks wrong on rotated pages in your documents, rotate the stamp by the negative page rotation before merging — the same compensation the PyMuPDF fix performs. The broader stamping patterns, including images and encryption, are in Watermarking and Securing PDFs.
Variant Fix 2: Per-Recipient Watermarks in Bulk
Personalised watermarks multiply output files. Generate them in parallel from one source, name outputs predictably, and add the recipient to the metadata for traceability:
# pip install pymupdf
import re
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import pymupdf
def personalised_copy(args: tuple[str, str, str]) -> str:
src, recipient, out_dir = args
safe = re.sub(r"[^\w\-]+", "-", recipient).strip("-").lower()
dest = Path(out_dir) / f"{Path(src).stem}-{safe}.pdf"
with pymupdf.open(src) as doc:
for page in doc:
watermark_page(page, f"CONFIDENTIAL – prepared for {recipient}")
meta = doc.metadata or {}
meta["keywords"] = f"recipient:{recipient}"
doc.set_metadata(meta)
doc.save(dest, garbage=3, deflate=True)
return str(dest)
def bulk(src: Path, recipients: list[str], out_dir: Path, workers: int = 4) -> list[str]:
out_dir.mkdir(parents=True, exist_ok=True)
jobs = [(str(src), r, str(out_dir)) for r in recipients]
with ProcessPoolExecutor(max_workers=workers) as pool:
return list(pool.map(personalised_copy, jobs))
A visible watermark deters casual forwarding but is easy to remove from a PDF with the right tools. Where leak tracing matters, combine it with an invisible per-recipient marker and restricted permissions, as in add password protection to PDF files.
Verification
Confirm the watermark exists on every page, sits near the visible centre regardless of size and rotation, and did not change the original text.
# pip install pymupdf
from pathlib import Path
import pymupdf
def verify_watermark(original: Path, stamped: Path, text: str, tolerance: float = 0.08) -> None:
with pymupdf.open(original) as a, pymupdf.open(stamped) as b:
pages = a.page_count
assert pages == b.page_count, "page count changed"
for pa, pb in zip(a, b):
hits = pb.search_for(text.split(" – ")[0]) # 'CONFIDENTIAL'
assert hits, f"page {pb.number + 1}: watermark text not found"
centre = pb.rect.width / 2, pb.rect.height / 2
hx, hy = (hits[0].x0 + hits[0].x1) / 2, (hits[0].y0 + hits[0].y1) / 2
assert abs(hx - centre[0]) < pb.rect.width * 0.35 and abs(hy - centre[1]) < pb.rect.height * 0.35, \
f"page {pb.number + 1}: watermark far from centre"
before = set(pa.get_text().split())
after = set(pb.get_text().split())
assert before <= after, f"page {pb.number + 1}: original words missing after stamping"
print(f"{stamped.name}: watermark on all {pages} pages, text preserved")
search_for returns the rectangle of the first word of the watermark, which on a diagonal line sits left of and below centre — hence the generous tolerance. The subset check proves stamping only added words. Render two pages of each geometry from the diagnostic to PNG and look at them once; placement bugs on rotated pages are obvious visually and subtle in numbers.
FAQ
Can readers remove the watermark? With editing tools, yes. Flatten the page content (rasterise) for stronger protection, at the cost of text selection and file size.
Why does the watermark print but not show on screen, or vice versa?
Optional content (layers) can be set to print-only or view-only. insert_text adds ordinary content visible in both; watermark annotations may not print by default.
Should the watermark go over or under the content? Over, with low opacity, is visible on every page including full-page images. Under content is hidden by opaque backgrounds, as explained in fix watermark hidden behind page content.
Does it break digital signatures? Yes. Any content change invalidates a signature. Watermark before signing.
Related
- Watermarking and Securing PDFs — images, stamps and permissions
- Fix Watermark Hidden Behind Page Content — layering and opaque backgrounds
- Rotate and Reorder PDF Pages with Python — how page rotation works
- Add Page Numbers and Headers to PDF Reports — stamping text at generation time instead
Part of Watermarking and Securing PDFs.