跳转到内容

为页面添加文字与图片水印或背景

你想在每一页印上「DRAFT」或「CONFIDENTIAL」标记,或在内容后方放一个淡淡的标志。这则 recipe(范例)通过公开的文档接口,把两者都叠加到 NextPDF core 页面上:用 setAlpha() 控制透明度、用 startTransform() / rotate() / stopTransform() 制作对角印章、用 text() 绘制标记,并用 image() 放置位图背景。

水印与背景只差一个决定:绘制顺序。

  • 背景:先绘制,再把页面内容写在它上面。标记位于文字后方。
  • 叠加水印:先写页面内容,再把标记绘制在它上面。标记位于最上层。

NextPDF 会按调用顺序绘制内容,因此调用顺序就是图层顺序。它没有独立的「背景模式」。你需要通过选择绘制时机来决定图层。

先决条件:一个 core 安装(composer require nextpdf/core:^3),若要制作图片背景,磁盘上还需要一个可读取的位图文件(PNG、JPEG 或 WebP)。整个流程都在进程内执行,不需要 headless 浏览器,也不会发出网络调用。

Terminal window
composer require nextpdf/core:^3

你添加的每个标记,都是通过图形状态绘制出来的普通页面内容。公开接口中有三个部分会组合生成水印:

  1. 透明度。 setAlpha(float $alpha, BlendMode $mode = BlendMode::Normal) 会为后续绘制的所有内容设置填充不透明度,范围从 0.0(不可见)到 1.0(不透明)。水印通常设置为 0.10.3,这样下方内容仍然清晰可读。混合模式来自 NextPDF\Graphics\BlendMode 枚举。例如,BlendMode::Multiply 会在标记与内容重叠处加深颜色。

  2. 旋转。 对角印章就是围绕一个枢轴点旋转的文字。startTransform() 会保存图形状态,rotate(float $angle, float $x, float $y) 会让坐标系统围绕 ($x, $y) 逆时针旋转,而 stopTransform() 会恢复之前保存的状态。把标记包在一个 transform 块中,可以避免旋转和 alpha 影响页面的其他部分。

  3. 标记本身。 text(float $x, float $y, string $text) 会以当前字体、颜色和 alpha,在绝对位置写出一个字符串。image(string $file, ?float $x, ?float $y, ?float $width, ?float $height) 会放置一张位图图像:这是图片水印或整页背景的基本构件。

由于 startTransform()stopTransform() 会把这项变更夹在中间,图形状态可以干净地恢复。setAlpha() 的值会持续生效,直到你再次设置为止。因此,如果后续内容必须完全不透明,就在绘制标记之后把不透明度重置为 1.0。下面示例采用的更稳妥做法,是把标记画在它自己的 transform 块内,并明确设置页面内容的 alpha。

这个软件包还提供值对象 NextPDF\Graphics\WatermarkNextPDF\Graphics\WatermarkPositionWatermark 是一个不可变的设置容器:包含文字、字体大小、角度、颜色、叠加标志位,以及像 WatermarkPosition::Diagonal 这样的位置默认值。它们用于描述水印参数。这则 recipe 使用上述会直接写入页面的方法来绘制标记,因此输出会直接进入页面内容流。

下列所有方法都是 NextPDF\Core\Document 上的 public 方法,并返回 static,因此可以链式调用。

  • setAlpha(float $alpha, BlendMode $mode = BlendMode::Normal): static:为后续内容设置填充不透明度(0.0-1.0)与混合模式。
  • startTransform(): static:保存图形状态(输出 q)。
  • rotate(float $angle, float $x = 0, float $y = 0): static:让坐标系统逆时针旋转 $angle 度,围绕枢轴 ($x, $y)
  • stopTransform(): static:恢复 startTransform() 所保存的状态(输出 Q),同时还原旋转与 alpha 变更。
  • setFont(string $family, string $style = '', float $size = 12.0): static:为标记选择字体。Base-14 字体家族 helvetica 始终可用,不需要字体文件。
  • setTextColor(int $r, int $g = -1, int $b = -1): static:以红、绿、蓝设置标记颜色(也可以用单一灰度值设置)。
  • text(float $x, float $y, string $text): static:在绝对位置写出标记。
  • image(string $file, ?float $x = null, ?float $y = null, ?float $width = null, ?float $height = null): static:放置一张位图图像,这是图片水印或整页背景的基础。
  • getPageWidth(): float / getPageHeight(): float:读取当前页面以点为单位的尺寸,让你可以把标记居中。

辅助类型位于 NextPDF\Graphics 之下:BlendMode 枚举、Color 值对象,以及 Watermark / WatermarkPosition 这组设置配对。

这段代码会生成一页内容,在内容上印一个淡淡的对角「DRAFT」印章,并保存文件。它省略了错误处理,以便呈现调用方式。下面的正式版示例会加上完整的防护措施。

<?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());

这个独立完整的程序会在生成的内容上印一个对角文字水印。接着,当通过 NEXTPDF_WATERMARK_IMAGE 环境变量提供图片路径时,它会把那张图片作为第二页上淡淡、居中的背景。它会在使用前验证图片路径,捕获最具体的 NextPDF 异常,并把结果写入服务器控制的路径。请把内存中的内容替换为你自己的内容,并把输出接入你的响应或存储层。

<?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);

预期的 STDOUT(字节大小取决于构建,以及你是否提供了图片):

Wrote <n>-byte PDF to <path>
  • 图层顺序就是调用顺序。 背景是在你的页面内容之前绘制的内容。叠加水印则是之后才绘制的内容。没有任何标志可以重排图层;要改变顺序,就移动调用位置。
  • Alpha 会持续到重置为止。 setAlpha() 会改变后续所有绘制内容的状态。你可以把标记夹在 startTransform() / stopTransform() 之间以恢复之前的 alpha,或在不透明内容之前调用 setAlpha(1.0)。正式版示例两种做法都用了。
  • 每个 transform 块都要成对。 每个 startTransform() 都需要一个对应的 stopTransform()。未成对的块会让旋转或 alpha 应用到后续内容,而缺少 stopTransform() 会导致图形状态不平衡,写入器会在输出时拒绝。
  • rotate() 以用户坐标为枢轴旋转。 枢轴 ($x, $y) 以用户单位表示,从页面左上角量起,与 text() 使用同一个坐标系统。若要做穿过中心的对角线,请使用页面中心(getPageWidth() / 2, getPageHeight() / 2)。
  • 旋转的文字需要手动补上宽度偏移。 text() 只放置字符串的起点,并不会替你居中。请从枢轴 X 减去估计文字宽度的一半左右,让旋转后的标记横跨中心,就像这个辅助函数所做的那样。
  • 图片会缩放到你传入的方框。 image() 会把位图图像拉伸到你提供的 widthheight。若要做整页背景,请传入页面的宽度与高度;若要做角落标志,请传入它的原始尺寸。传入零或负值尺寸会引发 PageLayoutException
  • image() 会拒绝 URL 与 NUL 字节。 scheme:// 路径或 $file 中的 NUL 字节,会在任何解码之前就引发 PageLayoutException。请只传入本地、已验证的路径。
  • 标记是可见内容。 以这种方式绘制的水印是真正的页面内容,而不是隐藏的注释。任何拿到文件的人都能读到它。它是一种视觉提示,而不是访问控制。

文字水印每页只是少数几个内容流运算符,增加的时间与内存可以忽略不计。图片水印或背景的成本,是把位图解码一次,再加上输出中嵌入的图片字节。跨多页重复使用同一张图片时,会通过图片缓存重用已解码的 XObject,因此解码成本只会支付一次。请在嵌入之前,先把背景图片缩放到它的显示方框大小。把一张 4000 px 的照片缩放进一页 letter 大小的页面,会存储读者永远看不到的字节。一个典型的单页文字水印,落在 500 ms 的时间与 32 MB 的峰值内存预算之内,仍有充足余量。图片背景的成本则随来源位图的解码后大小而定。

这条流程在进程内执行。没有任何文件字节会离开主机,也不会发出任何网络调用。请把任何源自你代码以外的图片路径,都当成不可信输入。

  • 在使用前验证图片路径。 请拒绝 NUL 字节,用 realpath() 规范化路径,并确认 is_file()is_readable(),再调用 image(),就像正式版示例所做的那样。这能阻挡路径遍历,并及早拒绝目录与失效链接。
  • 绝不要把请求字段插入路径。 请从服务器控制的值派生图片路径与输出路径,不要从请求参数取得。这能避免读取或写入预期目录以外的文件。
  • 把不可信图片当成恶意输入处理。 格式错误的位图会引发 ImageProcessingException,而不会损坏文件,而且加载器会限制图片尺寸,以抵御解压缩炸弹式输入。请捕获这个异常并拒绝该上传。不要盲目重试。
  • 水印不是机密存储区。 标记是可见内容。不要把凭证、令牌或内部标识符编码进你要返回给客户端的水印或背景里。

这则 recipe 本身不主张任何规范性标准。它组合了公开的 alpha、transform、text 与 image 基本组件。它们每一个都会输出标准的 PDF 内容流运算符。图形状态通过 q / Q 运算符隔离,这两个运算符正是 startTransform()stopTransform() 所输出的;透明度则通过 ExtGState 图形状态参数引入。由于输出在结构上是全新的,而不是字节稳定的,因此这个页面声明为 structural 可复现性配置文件。关于 transform 与图形状态接口在运算符层级的细节,请参阅 Graphics 模块参考一节。

  • Graphics 模块参考:这些方法背后完整的 path、transform、color 与图形状态接口。
  • 嵌入图片:加载、调整大小并放置位图图像,这是图片水印或背景的基本构件。
  • 渐变与透明度:深入说明 alpha 与混合模式接口,包含半透明填充。
  • 转换坐标空间:以成对的 transform 块旋转、缩放与平移内容。
  • 异常感知的错误处理:说明 ImageProcessingExceptionPageLayoutExceptionNextPdfException 背后的 NextPDF 异常层级结构。