Skip to content

Add text and image watermarks or backgrounds to pages

You can add a “DRAFT” or “CONFIDENTIAL” mark across each page, or place a faint logo behind the content. This recipe adds both to NextPDF core pages with the public document surface: setAlpha() for transparency, startTransform() / rotate() / stopTransform() for a diagonal stamp, text() for the mark, and image() for a raster background.

A watermark and a background differ in one choice: paint order.

  • Background: paint first, then write your page content over it. The mark sits behind the text.
  • Overlay watermark: write your page content first, then paint the mark over it. The mark sits on top.

NextPDF paints content in the order you call it, so your call order sets the layer order. There is no separate “background mode”. You choose the layer by choosing when to draw.

Prerequisites: a core install (composer require nextpdf/core:^3), and, for an image background, a readable raster file (PNG, JPEG, or WebP) on disk. The whole pipeline runs in process, without a headless browser or a network call.

Terminal window
composer require nextpdf/core:^3

Every mark you add is regular page content drawn through a graphics state. Three parts of the public surface work together to produce a watermark:

  1. Transparency. setAlpha(float $alpha, BlendMode $mode = BlendMode::Normal) sets the fill opacity for everything you draw afterward, from 0.0 (invisible) to 1.0 (opaque). A watermark usually works best at 0.1 to 0.3, so the content underneath stays readable. The blend mode comes from the NextPDF\Graphics\BlendMode enum. For example, BlendMode::Multiply darkens areas where the mark overlaps content.

  2. Rotation. A diagonal stamp is text rotated around a pivot point. startTransform() saves the graphics state, rotate(float $angle, float $x, float $y) turns the coordinate system counter-clockwise about ($x, $y), and stopTransform() restores the saved state. Wrapping the mark in a transform block keeps the rotation and alpha from affecting the rest of the page.

  3. The mark itself. text(float $x, float $y, string $text) writes a string at an absolute position in the current font, color, and alpha. image(string $file, ?float $x, ?float $y, ?float $width, ?float $height) places a raster image: the base for an image watermark or a full-page background.

The graphics state restores cleanly because startTransform() and stopTransform() bracket the change. The setAlpha() value persists until you set it again. If later content must be fully opaque, reset opacity to 1.0 after the mark. The safer pattern, shown below, draws the mark inside its own transform block and sets the page content’s alpha explicitly.

The package also includes the value objects NextPDF\Graphics\Watermark and NextPDF\Graphics\WatermarkPosition. Watermark is an immutable configuration holder for text, font size, angle, color, overlay flag, and position presets, such as WatermarkPosition::Diagonal. These objects model a watermark’s parameters. This recipe paints the mark with the page-committing methods above, so the output reaches the page content stream directly.

All methods below are public on NextPDF\Core\Document and return static, so you can chain them.

  • setAlpha(float $alpha, BlendMode $mode = BlendMode::Normal): static: set the fill opacity (0.0-1.0) and blend mode for later content.
  • startTransform(): static: save the graphics state (emits q).
  • rotate(float $angle, float $x = 0, float $y = 0): static: rotate the coordinate system $angle degrees counter-clockwise about pivot ($x, $y).
  • stopTransform(): static: restore the state saved by startTransform() (emits Q), undoing the rotation and alpha change together.
  • setFont(string $family, string $style = '', float $size = 12.0): static: select the font for the mark. The Base-14 family helvetica is always available and does not need a font file.
  • setTextColor(int $r, int $g = -1, int $b = -1): static: set the mark color in red, green, blue (or a single grayscale value).
  • text(float $x, float $y, string $text): static: write the mark at an absolute position.
  • image(string $file, ?float $x = null, ?float $y = null, ?float $width = null, ?float $height = null): static: place a raster image, the base for an image watermark or a full-page background.
  • getPageWidth(): float / getPageHeight(): float: read the current page size in points so you can center the mark.

Supporting types live under NextPDF\Graphics: the BlendMode enum, the Color value object, and the Watermark / WatermarkPosition configuration pair.

This sample writes one page, paints a faint diagonal “DRAFT” stamp over the content, and saves the file. It omits error handling to show the call shape. The production sample below adds the full guards.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();
$doc->addPage();
// Page content first, so the watermark lands on top of it.
$doc->setFont('helvetica', '', 12);
$doc->text(20.0, 40.0, 'Quarterly report: internal review copy.');
// Watermark second: a translucent, rotated stamp through the page center.
$pivotX = $doc->getPageWidth() / 2.0;
$pivotY = $doc->getPageHeight() / 2.0;
$doc->startTransform();
$doc->setAlpha(0.15);
$doc->setTextColor(150, 150, 150);
$doc->setFont('helvetica', 'B', 72);
$doc->rotate(45.0, $pivotX, $pivotY);
$doc->text($pivotX - 110.0, $pivotY, 'DRAFT');
$doc->stopTransform();
file_put_contents(__DIR__ . '/watermarked.pdf', $doc->getPdfData());

This self-contained program paints a diagonal text watermark over generated content. When you supply an image path through the NEXTPDF_WATERMARK_IMAGE environment variable, it places that image as a faint, centered background on a second page. It validates the image path before use, catches the most specific NextPDF exceptions, and writes the result to a server-controlled path. Replace the in-memory content with your own, then connect the output to your response or storage layer.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
use NextPDF\Exception\ImageProcessingException;
use NextPDF\Exception\NextPdfException;
use NextPDF\Exception\PageLayoutException;
/**
* Paint a translucent, rotated text stamp across the current page.
*
* The mark is bracketed in a transform block so the rotation and the alpha
* change are undone together and never leak into later content.
*
* @param non-empty-string $mark The watermark text (for example "CONFIDENTIAL")
*/
function paintTextWatermark(Document $doc, string $mark): void
{
$pivotX = $doc->getPageWidth() / 2.0;
$pivotY = $doc->getPageHeight() / 2.0;
// Estimate the mark width so the rotated text sits centered on the pivot.
// Helvetica averages ~0.5 em per glyph; half the width offsets the origin.
$fontSize = 64.0;
$halfWidth = (\strlen($mark) * $fontSize * 0.5) / 2.0;
$doc->startTransform();
$doc->setAlpha(0.12);
$doc->setTextColor(120, 120, 120);
$doc->setFont('helvetica', 'B', $fontSize);
$doc->rotate(45.0, $pivotX, $pivotY);
$doc->text($pivotX - $halfWidth, $pivotY, $mark);
$doc->stopTransform();
}
/**
* Place a raster image as a faint, full-page background behind later content.
*
* The image is drawn first and at low opacity; page content written after this
* call sits over it. The path is validated by the caller before it arrives.
*
* @param non-empty-string $imagePath A readable raster image (PNG, JPEG, WebP)
*
* @throws ImageProcessingException If the file is missing, unreadable, or corrupt.
* @throws PageLayoutException If the placement coordinates are rejected.
*/
function paintImageBackground(Document $doc, string $imagePath): void
{
$doc->startTransform();
$doc->setAlpha(0.08);
// Cover the full page: origin at the top-left, sized to the page box.
$doc->image(
file: $imagePath,
x: 0.0,
y: 0.0,
width: $doc->getPageWidth(),
height: $doc->getPageHeight(),
);
$doc->stopTransform();
}
$doc = Document::createStandalone();
$doc->setTitle('Watermark and background sample');
// Page 1: content first, then an overlay text watermark on top.
$doc->addPage();
$doc->setAlpha(1.0);
$doc->setTextColor(0, 0, 0);
$doc->setFont('helvetica', '', 12);
$doc->text(20.0, 40.0, 'Quarterly report: internal review copy.');
try {
paintTextWatermark($doc, 'CONFIDENTIAL');
} catch (PageLayoutException $e) {
// Raised if a coordinate or page state is rejected while placing the mark.
throw new RuntimeException(
sprintf('Watermark placement failed: %s', $e->getConstraint()),
previous: $e,
);
}
// Page 2: an optional image background, then content over it.
$imagePath = getenv('NEXTPDF_WATERMARK_IMAGE');
if ($imagePath !== false && $imagePath !== '') {
// Validate the path before touching the image loader: reject NUL bytes,
// require a real readable file, and resolve it to defeat path traversal.
if (str_contains($imagePath, "\0")) {
throw new RuntimeException('Image path must not contain NUL bytes.');
}
$resolved = realpath($imagePath);
if ($resolved === false || !is_file($resolved) || !is_readable($resolved)) {
throw new RuntimeException(
sprintf('Background image "%s" is not a readable file.', $imagePath),
);
}
$doc->addPage();
try {
paintImageBackground($doc, $resolved);
} catch (ImageProcessingException $e) {
// Raised when the file cannot be decoded as a supported raster format.
throw new RuntimeException(
sprintf(
'Background image rejected (%s, op "%s").',
$e->getFormat(),
$e->getOperation(),
),
previous: $e,
);
} catch (PageLayoutException $e) {
throw new RuntimeException(
sprintf('Background placement failed: %s', $e->getConstraint()),
previous: $e,
);
}
$doc->setAlpha(1.0);
$doc->setTextColor(0, 0, 0);
$doc->setFont('helvetica', '', 12);
$doc->text(20.0, 40.0, 'Page two over a faint background.');
}
try {
$pdf = $doc->getPdfData();
} catch (NextPdfException $e) {
// Base of the NextPDF exception hierarchy: any output-stage failure.
throw new RuntimeException(
sprintf('Document output failed: %s', $e->getMessage()),
previous: $e,
);
}
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT');
$path = $out !== false && $out !== '' ? $out : __DIR__ . '/watermarked.pdf';
if (file_put_contents($path, $pdf) === false) {
throw new RuntimeException(sprintf('Could not write PDF to "%s".', $path));
}
printf("Wrote %d-byte PDF to %s\n", strlen($pdf), $path);

Expected standard output (STDOUT) (the byte size depends on the build and on whether you supplied an image):

Wrote <n>-byte PDF to <path>
  • Layer order is call order. A background is content drawn before your page content. An overlay watermark is content drawn after it. No flag reorders the layers; move the call instead.
  • Alpha persists until reset. setAlpha() changes the state for everything drawn afterward. Either bracket the mark in startTransform() / stopTransform(), which restores the prior alpha, or call setAlpha(1.0) before opaque content. The production sample does both.
  • Balance every transform block. Each startTransform() needs a matching stopTransform(). An unbalanced block leaves the rotation or alpha applied to later content, and a missing stopTransform() creates a graphics-state imbalance that the writer rejects at output.
  • rotate() pivots in user coordinates. The pivot ($x, $y) is in user units measured from the page top-left, in the same frame as text(). For a through-center diagonal, use the page center (getPageWidth() / 2, getPageHeight() / 2).
  • Rotated text needs a manual width offset. text() places the string origin; it does not center for you. Subtract roughly half the estimated text width from the pivot X so the rotated mark straddles the center, as the helper does.
  • Images scale to the box you pass. image() stretches the raster to the width and height you give. For a full-page background, pass the page width and height; for a corner logo, pass its natural size. A zero or negative dimension raises PageLayoutException.
  • image() rejects URLs and NUL bytes. A scheme:// path or a NUL byte in $file raises PageLayoutException before any decode. Pass only a local, validated path.
  • The mark is visible content. A watermark drawn this way is real page content, not a hidden annotation. Anyone with the file can read it. It is a visual cue, not an access control.

A text watermark uses a handful of content-stream operators per page and adds negligible time or memory. An image watermark or background costs one raster decode plus the embedded image bytes in the output. Reusing the same image across pages reuses the decoded XObject through the image cache, so you pay the decode cost once. Size background images to their display box before embedding. A 4000 px photo scaled into a letter page stores bytes the reader never sees. A typical single-page text watermark stays well inside a 500 ms wall and 32 MB peak budget. An image background tracks the source raster’s decoded size.

The pipeline runs in process. No document bytes leave the host, and no network call is made. Treat any image path that originates outside your code as untrusted input.

  • Validate the image path before use. Reject NUL bytes, resolve the path with realpath(), and confirm is_file() and is_readable() before you call image(), exactly as the production sample does. This blocks path traversal and rejects directories and dangling links early.
  • Never interpolate a request field into a path. Derive the image path and the output path from server-controlled values, not from a request parameter. This keeps you from reading or writing files outside the intended directory.
  • Treat untrusted images as hostile input. A malformed raster raises ImageProcessingException rather than corrupting the document, and the loader caps image dimensions to resist decompression-bomb inputs. Catch the exception and reject the upload. Do not retry blindly.
  • A watermark is not a secret store. The mark is visible content. Do not encode credentials, tokens, or internal identifiers in a watermark or background that you return to a client.

This recipe makes no normative standards claim of its own. It composes the public alpha, transform, text, and image primitives. Each primitive emits standard PDF content-stream operators. The graphics state is isolated with the q / Q operators that startTransform() and stopTransform() emit, and transparency is carried through an ExtGState graphics-state parameter. The output is structurally fresh rather than byte-stable, so this page declares a structural reproducibility profile. For operator-level detail about the transform and graphics-state surface, see the Graphics module reference.