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.

Page properties that move a watermark Page size varies between A4, A3 and Letter within one pack, so the font size and centre must scale with each page. Rotation of 90 degrees means content coordinates differ from the displayed page, so the watermark must be drawn in unrotated space with a derotated angle. A crop box offset moves the visible area, so the centre must come from the crop box not the media box. Opacity must be set explicitly or the text covers content. Page size varies A4, A3 and Letter in one pack: scale font and centre per page /Rotate 90 pages drawing space is unrotated: compensate the angle Crop box offset visible area starts at (18, 18): centre on the crop box Opacity default is opaque: set fill opacity explicitly

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.

Watermark geometry on one page The visible page rectangle has its centre marked. The watermark runs along the diagonal from bottom left to top right at the angle given by the page height over width. Its length is 70 percent of the diagonal, so it never touches the corners on any page size, and it is drawn with 15 percent opacity so content underneath remains readable. A4 page, watermark along the rising diagonal 1 1 Header content remains readable through 15% opacity 2 2 Centre pivot text length = 0.7 x diagonal 3 3 Body table content drawn before the stamp

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.

One pack, one watermarked copy per recipient The source board pack is processed once per recipient in a process pool. Each copy gets a diagonal watermark with the recipient's name, the recipient recorded in the PDF keywords, and a predictable file name. All copies land in a distribution folder together with a manifest listing recipient, file and page count. Board pack 50 pages, 4 sizes J. Patel copy name in watermark + keywords M. Chen copy name in watermark + keywords A. Novak copy name in watermark + keywords Distribution folder copies + manifest

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.

Part of Watermarking and Securing PDFs.

/html>