为页面添加文字与图片水印或背景
要点摘要
标题为“要点摘要”的章节你想在每一页印上「DRAFT」或「CONFIDENTIAL」标记,或在内容后方放一个淡淡的标志。这则 recipe(范例)通过公开的文档接口,把两者都叠加到 NextPDF core 页面上:用 setAlpha() 控制透明度、用 startTransform() / rotate() / stopTransform() 制作对角印章、用 text() 绘制标记,并用 image() 放置位图背景。
水印与背景只差一个决定:绘制顺序。
- 背景:先绘制,再把页面内容写在它上面。标记位于文字后方。
- 叠加水印:先写页面内容,再把标记绘制在它上面。标记位于最上层。
NextPDF 会按调用顺序绘制内容,因此调用顺序就是图层顺序。它没有独立的「背景模式」。你需要通过选择绘制时机来决定图层。
先决条件:一个 core 安装(composer require nextpdf/core:^3),若要制作图片背景,磁盘上还需要一个可读取的位图文件(PNG、JPEG 或 WebP)。整个流程都在进程内执行,不需要 headless 浏览器,也不会发出网络调用。
composer require nextpdf/core:^3概念概览
标题为“概念概览”的章节你添加的每个标记,都是通过图形状态绘制出来的普通页面内容。公开接口中有三个部分会组合生成水印:
-
透明度。
setAlpha(float $alpha, BlendMode $mode = BlendMode::Normal)会为后续绘制的所有内容设置填充不透明度,范围从0.0(不可见)到1.0(不透明)。水印通常设置为0.1到0.3,这样下方内容仍然清晰可读。混合模式来自NextPDF\Graphics\BlendMode枚举。例如,BlendMode::Multiply会在标记与内容重叠处加深颜色。 -
旋转。 对角印章就是围绕一个枢轴点旋转的文字。
startTransform()会保存图形状态,rotate(float $angle, float $x, float $y)会让坐标系统围绕($x, $y)逆时针旋转,而stopTransform()会恢复之前保存的状态。把标记包在一个 transform 块中,可以避免旋转和 alpha 影响页面的其他部分。 -
标记本身。
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\Watermark 与 NextPDF\Graphics\WatermarkPosition。Watermark 是一个不可变的设置容器:包含文字、字体大小、角度、颜色、叠加标志位,以及像 WatermarkPosition::Diagonal 这样的位置默认值。它们用于描述水印参数。这则 recipe 使用上述会直接写入页面的方法来绘制标记,因此输出会直接进入页面内容流。
API 接口
标题为“API 接口”的章节下列所有方法都是 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()会把位图图像拉伸到你提供的width与height。若要做整页背景,请传入页面的宽度与高度;若要做角落标志,请传入它的原始尺寸。传入零或负值尺寸会引发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 块旋转、缩放与平移内容。
- 异常感知的错误处理:说明
ImageProcessingException、PageLayoutException与NextPdfException背后的 NextPDF 异常层级结构。