跳到內容

透過壓縮與子集化縮減 PDF 檔案大小

你想要在不損失保真度的前提下,得到內容所允許的最小 PDF。NextPDF 提供兩個大小調節項,而且兩者都預設開啟:

  • 串流壓縮。 寫入器會把每個頁面內容串流與每個嵌入的字型程式都封裝成一個 FlateDecode(zlib)串流。NextPDF\Core\Configcompress 旗標承載這項設定。當你建構串流文件時,可透過 withCompress() wither 調整它。
  • 字型子集化。 當你嵌入一個 TrueType 或 CFF 字型時,寫入器會重建字型程式,使其只攜帶文件實際用到的字形,然後再以 FlateDecode 壓縮結果。這會自動發生——沒有旗標要設定,也沒有方法要呼叫。一個有 20,000 個字形的 CJK 字面,若只貢獻幾百個字形給某份文件,嵌入後的大小只占其磁碟上大小的一小部分。

先說清楚:NextPDF Core 並未提供影像重新取樣、影像品質旋鈕、物件串流開關或資源去重設定。現有的大小控制項就是上述那兩個。本範例的其餘部分會說明如何正確使用它們,以及每一項各自不會做什麼。

前置需求:一份 Core 安裝(composer require nextpdf/core:^3),以及用於子集化路徑、你有授權嵌入的字型檔。

Terminal window
composer require nextpdf/core:^3

一份 PDF 是一棵物件樹。最大的物件通常是內容串流(每個頁面的繪圖運算子)與字型程式(嵌入的字形外框)。兩者都是高度可壓縮的文字與二進位資料,因此最有效的單一大小調節項,就是以 FlateDecode 壓縮它們。FlateDecode 是 PDF 2.0 對 zlib 包裝的 DEFLATE 串流所用的名稱(ISO 32000-2:2020 §7.4.4),也是 NextPDF 輸出的過濾器。

寫入器透過 NextPDF\Writer\PinnedZlibCompressor 把 DEFLATE 壓縮等級固定在 9,也就是 RFC 1951 的最大值。等級 9 以一點額外的 CPU 換取最小的串流。固定等級也讓輸出維持確定性,因為 zlib 標頭會編碼這個等級;等級若飄移,就會改變位元組。你不需要挑選等級——引擎會把它固定下來,讓對同一份輸入的兩次執行產生位元組完全相同的串流。

第二個調節項是字型子集化。磁碟上的字型檔會攜帶該字體所定義的每一個字形,但一份印出「Invoice 2026」的文件只需要其中十幾個。NextPDF\Typography\FontSubsetter(用於 TrueType)與 NextPDF\Typography\CffSubsetter(用於 CFF / OpenType)會走訪文件實際繪製的碼位、resolve(解析)複合字形相依性,並只重建所需的字型表。它們會輸出一個有效的子集字型二進位,並帶有一個確定性的六字母子集前綴標籤(ISO 32000-2:2020 §9.9)。只要某個嵌入字型的已用字形集合是已知的,寫入器就會套用這項處理,然後以 FlateDecode 壓縮該子集。如果對某個特定字面進行子集化所省下的不到一成,子集器就會改為傳回原始字型程式,因為重建的開銷不值得換取那一點邊際效益。

重點是:要讓 PDF 維持精簡,靠的是讓壓縮保持開啟(也就是預設值),以及嵌入真正的字型檔(讓子集化有東西可以縮減),而不是靠調整一長串選項。

你唯一需要設定的大小旋鈕在組態物件上。

NextPDF\Core\Config 是一個不可變的 final readonly 值物件,並提供具型別的 wither 方法。相關的成員是:

  • compressbool,預設 true)——啟用 FlateDecode 壓縮。用 withCompress(bool $compress): self 調整它時,方法會傳回一個新的 Config,其中旗標已變更,其餘每個欄位都保留。

在建構時把一個 Config 附加到文件上:

  • NextPDF\Core\Document::createStandalone(?Config $config = null): self 會建構一份帶有臨時登錄表的文件,適用於 CLI 指令稿或短生命週期的行程,並套用你的 Config

有兩個成員會影響大小調節項可運作的素材,但兩者本身都不是壓縮控制項:

  • imageCacheBytesint,預設 52_428_800)會為記憶體內的影像快取設定上限,而 withImageCacheBytes(int $bytes): self 會變更它。這會限制一次建構期間的尖峰記憶體。它不會重新取樣、重新壓縮,也不會以其他方式縮小你嵌入的影像——它是記憶體上限,不是輸出大小控制項。
  • fontsDirectorystring)與 withFontsDirectory(string $dir): self 會設定字型檔的預設搜尋路徑,供子集化路徑使用。

字型相關工作透過文件上的排版介面進行:

  • setFont(string $family, string $style = '', float $size = 12.0): static 會選取一個字面。當字族解析為一個可嵌入的字型檔時,寫入器會記錄你繪製的碼位,以便在儲存時對該字面進行子集化。
  • addFontDirectory(string $directory): static 會註冊另一個用來搜尋字型檔的目錄。

輸出是標準的三件組:getPdfData(): string 傳回位元組,save(string $path): void 以原子方式寫入它們,而 output(?string $filename, OutputDestination $dest): string 處理 HTTP 傳遞。

子集化沒有公開方法,也沒有旗標。它是嵌入字型並繪製文字所衍生的行為——寫入器會替你驅動 FontSubsetter / CffSubsetter,就在 NextPDF\Writer\PdfFontWriter 內部。

這會建構一份明確啟用壓縮、並包含已嵌入且已子集化字型的文件,然後寫出位元組。它略去了錯誤處理以呈現呼叫形式;下方的正式環境範例則補上完整的防護。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Config;
use NextPDF\Core\Document;
// compress defaults to true; setting it explicitly documents intent.
$config = (new Config())->withCompress(true);
$doc = Document::createStandalone($config);
$doc->addFontDirectory(__DIR__ . '/fonts');
$doc->addPage();
// Selecting an embeddable face records the glyphs used, so the writer
// subsets this font automatically when the document is built.
$doc->setFont('LiberationSans', '', 12);
$doc->cell(0, 10, 'Invoice 2026 - subsetted, compressed output.', newLine: true);
$pdf = $doc->getPdfData();
file_put_contents(__DIR__ . '/small.pdf', $pdf);
printf("Wrote %d bytes.\n", strlen($pdf));

這是一支自成一體的程式。它會建構一份開啟壓縮的文件,從一個你控管的目錄嵌入字型,繪製文字讓子集器取得一組已用字形集合,並以原子方式寫出結果。它會捕捉建構與儲存路徑所拋出、最具體的 NextPDF 例外,然後為每一個例外附加脈絡後重新拋出,而不是吞掉它。把 NEXTPDF_FONT_DIR 指向一個放有 TrueType 或 CFF 字面、且你有授權嵌入的目錄;程式會在嵌入前先驗證該路徑。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Config;
use NextPDF\Core\Document;
use NextPDF\Exception\CompressionException;
use NextPDF\Exception\InvalidConfigException;
/**
* Resolve and validate the font directory from a server-controlled source.
*
* Reading the directory from the environment keeps the path off the request
* surface. The function rejects a missing or unreadable directory so the
* embedding path never runs against untrusted or absent input.
*/
function resolveFontDirectory(): string
{
$configured = getenv('NEXTPDF_FONT_DIR');
$dir = $configured !== false && $configured !== '' ? $configured : __DIR__ . '/fonts';
$real = realpath($dir);
if ($real === false || !is_dir($real) || !is_readable($real)) {
throw new RuntimeException(sprintf('Font directory "%s" is not a readable directory.', $dir));
}
return $real;
}
/**
* Build a compressed, font-subsetted document and return its bytes.
*
* @param non-empty-string $fontDirectory Validated directory of embeddable fonts.
*
* @return string Raw PDF bytes.
*/
function buildCompactPdf(string $fontDirectory): string
{
// compress is true by default; pin it so the intent is explicit and the
// streaming writer path honours it regardless of any wrapper defaults.
$config = (new Config())
->withCompress(true)
->withFontsDirectory($fontDirectory)
// Bound the image cache so a build cannot exhaust memory. This is a
// memory ceiling, not an output-size control.
->withImageCacheBytes(16 * 1024 * 1024);
$doc = Document::createStandalone($config);
$doc->addFontDirectory($fontDirectory);
$doc->addPage();
// Rendering with an embeddable face records the used codepoints, which the
// writer turns into a font subset at build time.
$doc->setFont('LiberationSans', '', 12);
$doc->cell(0, 10, 'Invoice 2026', newLine: true);
$doc->cell(0, 10, 'Compressed streams plus an automatic font subset.', newLine: true);
// getPdfData() triggers the build: page streams and the subset font program
// are FlateDecode-compressed before the bytes are returned.
return $doc->getPdfData();
}
try {
$fontDirectory = resolveFontDirectory();
$pdf = buildCompactPdf($fontDirectory);
} catch (CompressionException $e) {
// Raised if the zlib encoder hard-fails while compressing a stream.
throw new RuntimeException(
sprintf('Compression failed for a %s stream.', $e->getAlgorithm()),
previous: $e,
);
} catch (InvalidConfigException $e) {
// Raised by the output path for an invalid destination configuration.
throw new RuntimeException(
sprintf('Output configuration "%s" was rejected.', $e->getConfigKey()),
previous: $e,
);
}
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT');
$path = $out !== false && $out !== '' ? $out : __DIR__ . '/small.pdf';
if (file_put_contents($path, $pdf) === false) {
throw new RuntimeException(sprintf('Could not write PDF to "%s".', $path));
}
printf("Wrote %d bytes to %s.\n", strlen($pdf), $path);

預期的 STDOUT(位元組數取決於字型與該次建構):

Wrote <n> bytes to <path>.
  • 壓縮預設開啟。 一個全新的 Configcompress 設為 true。你幾乎不需要用到 withCompress()。只在想記錄意圖時才明確設定它;或是在除錯建構中需要讀取原始串流時,用它停用壓縮。
  • 關掉壓縮會讓檔案變大,而不是變小。 withCompress(false) 是用來檢視未壓縮串流的診斷輔助。它絕不是一種大小最佳化。上線時請保持壓縮開啟。
  • 子集化需要一個真正嵌入的字型。 Base14 標準字型(Helvetica、Times、Courier 及其相關字型)是以名稱參照的,在一般文件中不攜帶嵌入的字型程式,因此沒有東西可以子集化。子集化只會縮減你從字型檔嵌入的字面。
  • 子集化是自動且無聲的。 沒有旗標、沒有方法,也沒有確認訊息。如果你嵌入了一個字型並用它繪製了文字,寫入器就會對它做子集化。嵌入的字型程式會攜帶一個六字母的子集前綴標籤(例如 ABCDEF+LiberationSans),讓閱讀器能分辨子集與完整嵌入。
  • 省得太少就保留完整字型。 當子集化所省下的不到字型程式大小的一成時,子集器會傳回原始字型程式。這是一道刻意設下的下限:重建不值得換取那一點邊際效益。嵌入一個本來就很小的字面,或繪製了它幾乎所有的字形,都可能落入這種情況。
  • imageCacheBytes 不是影像大小旋鈕。 它限制的是記憶體,不是輸出位元組。NextPDF Core 會嵌入你給它的影像資料;沒有重新取樣、降取樣或重新編碼的步驟。如果你需要更小的影像,請在嵌入它們之前先調整大小並重新編碼。
  • 沒有物件串流或去重設定存在。 NextPDF Core 並未提供 PDF 2.0 物件串流或資源去重的開關。不用尋找這種開關——大小調節項就是串流壓縮與字型子集化。

等級 9 的壓縮是寫入一個串流時主要的 CPU 成本。它以幾個百分點的建構時間換取最小的輸出。成本與未壓縮位元組數成線性關係,因此頁面數與嵌入字型資料量就決定了這項預算。子集化會為每個嵌入字面額外增加一次處理:剖析字型的表目錄、解析已用字形閉包,並重建所需的字型表。對一個大型 CJK 字面而言,這是兩個調節項中成本較高的一個,但它每個字型只執行一次,而非每頁一次。那道省幅一成的下限之所以存在,部分原因就是要在不划算時,讓這次處理離開熱路徑。一份帶有單一嵌入子集的小型文件,能輕鬆落在 1500 ms 的牆鐘時間與 96 MB 的尖峰預算之內。請把 imageCacheBytes 設成你真正的上限,讓一個嵌入大量影像的建構在記憶體上快速失敗,而不是發生置換。

建構作業在行程內執行;沒有任何文件位元組離開主機,也不會發出任何網路呼叫。請把任何外部提供的字型或影像都當成不可信任的輸入:

  • 驗證字型目錄。 正式環境範例會從一個由伺服器控管的環境變數讀取字型路徑,並在嵌入前拒絕缺漏或不可讀的目錄。絕不要從請求欄位推導出字型路徑。
  • 只嵌入你有授權再散布的字型。 子集仍然是一個嵌入的字型程式。在你交付一份攜帶該字面的文件之前,請確認授權允許嵌入。
  • 格式錯誤的字型會拋出例外,而不是無聲地損毀。 一個剖析失敗的字型檔會拋出 NextPDF\Exception\FontParsingException,而一個硬性的 zlib 失敗會拋出 NextPDF\Exception\CompressionException。請捕捉最具體的例外並據以行動。絕不要把建構包進一個空的 catch 裡。
  • 絕不要把使用者輸入內插進輸出路徑。 範例會寫入一個固定路徑或一個由伺服器控管的旁路通道,並透過 save() 中的原子寫入器拒絕串流包裝器與 null 位元組。請從由伺服器控管的值推導輸出路徑,以避免路徑遍歷。
  • 文件中不放任何密鑰。 不要在一份你要回傳給用戶端的產生文件中嵌入憑證、權杖或內部識別碼。

本範例本身不提出任何規範性的標準主張。它所使用的機制是由 PDF 2.0 規格定義的:FlateDecode 串流壓縮(ISO 32000-2:2020 §7.4.4),以及帶有六字元子集前綴的字型子集命名(ISO 32000-2:2020 §9.9)。NextPDF 會將兩者都作為標準寫入路徑的一部分輸出;除了 compress 旗標之外,你不必再為它們做任何設定。本頁宣告的 structural 可重現性設定檔,反映出寫入器固定了 DEFLATE 等級,因此壓縮後的串流是確定性的,而文件層級的識別碼如果你未一併設定確定性設定,仍可能在不同執行之間變動。關於子集化背後的嵌入機制,請參閱下方連結的嵌入與子集化範例。