跳到內容

為頁面加入文字與影像浮水印或背景

當你想在每一頁印上「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\WatermarkPosition 這兩個值物件。Watermark 是不可變的設定容器:包含文字、字型大小、角度、顏色、疊加旗標,以及像 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 例外階層架構。