跳到內容
getnextpdf.com

分割 PDF 並擷取頁面範圍

你有一個 PDF,卻需要好幾個。本範例運用 Core 的分割介面 NextPDF\Document\PdfSplitter,把單一文件雕琢成多個檔案。你把來源以原始 PDF 位元組字串傳入,並描述你想要哪些頁面。分割器會透過物件圖剖析來源、把每個所請求範圍的可達物件複製到一份全新且重新編號的文件中(具備自己的頁面樹與交叉參照表),並交還在符合規範的閱讀器中可載入、結構完整的 PDF。

這是 合併範例 的反向操作:合併把多份文件組成一份,分割把一份文件拆解成多份。同一個介面涵蓋你最常需要的三項任務:

  • 依範圍分割——為你指名的每個頁面範圍各產生一份輸出文件。
  • 每 N 頁分割——把一個長檔案切成固定大小的片段。
  • 擷取範圍——把單一連續的頁面範圍抽取成一份文件。

分割在處理程序內執行,不需要無頭瀏覽器,也不需網路呼叫。你需要已安裝 Core (composer require nextpdf/core:^3)以及一個可讀取的 PDF。

Terminal window
composer require nextpdf/core:^3

PDF 透過一棵以 /Pages 節點為根的頁面樹來定位其頁面,並透過其交叉參照資料(一張表或一個串流)抵達每個間接物件。你無法靠切割位元組來擷取頁面:單一頁面會參照存放在檔案他處的共用字型、影像與資源字典,而交叉參照位移也會不再有效。

PdfSplitter 負責實際工作。對每個範圍,它會從所請求的頁面物件走訪物件圖、蒐集可達物件的閉包、把那些物件重新編號到一個全新的位址空間、重建一份單一頁面樹的文件,並依 PDF 2.0 結構(ISO 32000-2:2020,交叉參照表 §7.5.4,頁面樹 §7.7.3)發出一張真正的交叉參照表。每份輸出都是一份自包含的文件,而非片段。

頁碼以 1 為起始且為含括。一個範圍是一個 NextPDF\Document\PageRange 值物件:new PageRange(2, 5) 代表第 2 到第 5 頁。建構子會驗證它自己的不變式——它會藉由拋出 NextPDF\Exception\PageLayoutException,拒絕起始小於 1 或結束早於起始的情形——因此不可能的範圍會在建構時失敗,而不是在分割器深處才失敗。PageRange::parse()PageRange::all() 在規格不正確或頁數總計非正時,會拋出相同的 PageLayoutException

new NextPDF\Document\PdfSplitter() 揭露三個方法。它們都把來源當作原始 PDF 位元組字串,而非路徑。

  • split(string $pdfData, array $ranges, int $maxBytes = 100_000_000, int $maxRanges = 1000): SplitResult 依序為 $ranges 中的每個 PageRange 各產生一份輸出文件。兩個界限參數分別限制輸入大小與範圍數量。
  • splitEvery(string $pdfData, int $pagesPerSegment): SplitResult 把文件切成每段 $pagesPerSegment 頁的固定大小片段;最後一段保留餘數。
  • extractPages(string $pdfData, PageRange $range): SplitDocument 擷取單一範圍,並直接回傳那一份文件。

split()splitEvery() 回傳一個 NextPDF\Document\SplitResult,一個 readonly 物件,攜帶 $documents(片段清單)、$totalPages (來源中的頁數)與 $sourceSize。它提供 count()document(int $index) (以零起始的索引取出某個片段)與 totalOutputSize()

每個片段,以及 extractPages() 的回傳值,都是一個 NextPDF\Document\SplitDocument:一個 readonly 物件,揭露 $pdfData(片段位元組)、 $range$pageCount$sizeBytesisValid() 輔助方法。 isValid() 是一項狹義的 %PDF-標頭健全性檢查——當片段位元組以 %PDF 開頭時回傳 true——而非文件結構或規範驗證;它確認分割器產出了一個 PDF,並非確認該檔案完全符合規範。

你可以直接以 new PageRange($start, $end) 建立一個 PageRange,或以 PageRange::parse('1-3,5,7-10') 剖析一個人類可讀的規格,它會回傳一個 list<PageRange>,可直接交給 split()PageRange::all($totalPages) 回傳涵蓋整份文件的單一範圍。

本範例讀取一個檔案並把它分割成兩份文件:第 1 到第 3 頁,以及第 4 到第 6 頁。它略去錯誤處理以展示呼叫形態;下方的正式版範例則加上完整的防護。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Document\PageRange;
use NextPDF\Document\PdfSplitter;
$splitter = new PdfSplitter();
$result = $splitter->split(
file_get_contents(__DIR__ . '/report.pdf'),
[
new PageRange(1, 3),
new PageRange(4, 6),
],
);
foreach ($result->documents as $i => $segment) {
file_put_contents(__DIR__ . sprintf('/part-%d.pdf', $i + 1), $segment->pdfData);
}
printf("Split %d-page source into %d document(s).\n", $result->totalPages, $result->count());

這個自包含的程式會在記憶體中建立一份小型的多頁文件,因此無須外部檔案即可執行。它示範全部三種操作——依範圍分割、每 N 頁分割,以及擷取單一範圍。它驗證並寫出依範圍切出的片段與擷取出的尾段,並把依大小切出的結果以計數方式回報,因此你可以在不撰寫三個近乎相同的寫入迴圈的情況下看到每種呼叫形態。它捕捉分割介面所拋出的例外,並為每個例外附帶情境後重新拋出,而非把它吞掉。請把這個記憶體內來源替換成你自己的 file_get_contents() 讀取或物件儲存擷取。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use InvalidArgumentException;
use NextPDF\Core\Document;
use NextPDF\Document\Merge\UnsupportedSourceDocumentException;
use NextPDF\Document\PageRange;
use NextPDF\Document\PdfSplitter;
use NextPDF\Document\SplitDocument;
use NextPDF\Exception\PageLayoutException;
/**
* Build a tiny labelled multi-page PDF so the program is self-contained.
*
* In your own code, replace this with a read of the PDF you want to split,
* for example file_get_contents($path).
*/
function buildSample(int $pages): string
{
$doc = Document::createStandalone();
$doc->setTitle('Split sample');
for ($page = 1; $page <= $pages; $page++) {
$doc->addPage();
$doc->setFont('helvetica', '', 12);
$doc->cell(0, 10, sprintf('Source page %d', $page), newLine: true);
}
return $doc->getPdfData();
}
$source = buildSample(7);
$splitter = new PdfSplitter();
try {
// 1. Split into named ranges: one output per PageRange, in order.
$byRange = $splitter->split(
$source,
PageRange::parse('1-3,4-6'),
maxBytes: 50_000_000,
maxRanges: 100,
);
// 2. Split every 2 pages: segments of [1-2], [3-4], [5-6], [7] (remainder).
$bySize = $splitter->splitEvery($source, 2);
// 3. Extract a single range as one document.
$tail = $splitter->extractPages($source, new PageRange(7, 7));
} catch (InvalidArgumentException $e) {
// Raised on an oversized input, an empty range list, or too many ranges.
throw new RuntimeException('Split rejected its input: ' . $e->getMessage(), previous: $e);
} catch (PageLayoutException $e) {
// Raised when a range exceeds the source page count, and also by the
// PageRange constructor / PageRange::parse() on an invalid or malformed range.
throw new RuntimeException(
sprintf('Range out of bounds (page %d): %s', $e->getPageNumber(), $e->getConstraint()),
previous: $e,
);
} catch (UnsupportedSourceDocumentException $e) {
// Raised fail-closed on an encrypted, signed, or form-bearing source.
throw new RuntimeException('Source cannot be split: ' . $e->getMessage(), previous: $e);
}
printf(
"Source has %d page(s). By-range produced %d doc(s); by-size produced %d doc(s).\n",
$byRange->totalPages,
$byRange->count(),
$bySize->count(),
);
foreach ($byRange->documents as $i => $segment) {
emitSegment(sprintf('range-%d', $i + 1), $segment);
}
emitSegment('tail', $tail);
/**
* Validate a segment and write it to the cookbook side-channel directory,
* or to the script directory by default.
*/
function emitSegment(string $name, SplitDocument $segment): void
{
if (!$segment->isValid()) {
throw new RuntimeException(sprintf('Segment "%s" failed its %%PDF header check.', $name));
}
$dir = getenv('NEXTPDF_COOKBOOK_OUTPUT');
$dir = $dir !== false && $dir !== '' ? $dir : __DIR__;
$path = sprintf('%s/%s.pdf', rtrim($dir, '/'), $name);
if (file_put_contents($path, $segment->pdfData) === false) {
throw new RuntimeException(sprintf('Could not write segment to "%s".', $path));
}
printf("Wrote %s: pages %d-%d, %d bytes.\n", $name, $segment->range->start, $segment->range->end, $segment->sizeBytes);
}

預期的標準輸出(位元組大小依建置而定):

Source has 7 page(s). By-range produced 2 doc(s); by-size produced 4 doc(s).
Wrote range-1: pages 1-3, <n> bytes.
Wrote range-2: pages 4-6, <n> bytes.
Wrote tail: pages 7-7, <n> bytes.
  • 來源是位元組,不是路徑。 每個方法都接受原始 PDF 字串。請先以 file_get_contents() 讀取檔案,或從物件儲存拉取位元組。傳入路徑會讓來源剖析失敗。
  • 頁碼以 1 為起始且為含括。 new PageRange(1, 3) 涵蓋第 1、2、3 頁——共三頁。起始小於 1 或結束早於起始,會由 PageRange 建構子本身拋出 PageLayoutException
  • 超出末頁的範圍是錯誤,不是夾限。 如果某個範圍的結束超過來源頁數, split() 會拋出 PageLayoutException;它絕不會默默把範圍修剪到最後一頁。如果你的範圍由呼叫端提供,請先檢視頁數。
  • splitEvery() 保留餘數。 最後一段保留剩餘的任何頁面,因此一份 7 頁文件每 2 頁分割會產生四段:三段各 2 頁,一段 1 頁。$pagesPerSegment 必須至少為 1,否則你會得到一個 InvalidArgumentException
  • 空的範圍清單會被拒絕。$ranges === [] 呼叫 split() 會拋出 InvalidArgumentException。請在呼叫前至少建立一個範圍。
  • 界限會拋出而非截斷。 超過 maxBytesmaxRanges 會拋出 InvalidArgumentException。分割器絕不會部分處理一個過大的輸入,因此請依你的工作負載調整兩個界限。
  • 加密、已簽署與帶表單的來源會以 fail-closed 方式處理。 加密的來源(沒有金鑰便無法複製)、數位簽署的來源(重新分頁會使簽章位元組範圍失效),或攜帶互動式表單的來源(某個欄位的 widget 可能落在被丟棄的頁面上而成為孤兒),都會拋出 UnsupportedSourceDocumentException。分割器寧可拒絕,也不發出損壞或已遭破壞的文件。分割表單文件是本版的已知限制。
  • UnsupportedSourceDocumentException 位於 Merge 命名空間下。 它的完整名稱是 NextPDF\Document\Merge\UnsupportedSourceDocumentException。 在分割頁面上出現那個 Merge 路徑並非複製貼上錯誤:它是合併與分割介面在某個來源無法被安全複製時,雙方共用的單一來源文件拒絕例外。請從那個命名空間匯入它。
  • 輸出在結構上是全新的,並非位元組穩定。 每個片段都是一份具備自己目錄、頁面樹與 trailer 的新文件。對相同輸入跑兩次在結構上相等,但不保證逐位元組相同——因此採用 structural 可重現性 profile。

分割的成本與所有範圍中被複製的頁數成線性。剖析來源並複製每個範圍的物件閉包(而非分割器自身的記帳)主導了工作量。來源以字串形式保存在記憶體中,而每個片段的位元組會保留到你寫出為止,因此尖峰記憶體會追蹤來源大小加上你所產生最大範圍的大小。maxBytes 防護會把這個尖峰中的來源那一側維持有界。對於大量管線,請把 maxBytesmaxRanges 設成你工作負載所需的最小值,讓不正確或過大的輸入快速失敗,而非耗盡記憶體。

分割在處理程序內執行;沒有任何文件位元組離開主機,也不進行網路呼叫。請把每個來源 PDF 都視為不受信任的輸入:

  • 保持界限緊湊。 maxBytesmaxRanges 是你抵禦阻斷服務輸入的第一道防線。對任何接受上傳的介面,請把它們設為你真正的上限,而非寬鬆的預設值。
  • 在分割前先分流。 加密或已簽署的來源會以 fail-closed 方式處理,但你可以更早偵測這些狀況。請先讓不受信任的輸入通過 Core 檢視器。請見 剖析並檢視 PDF,了解在較重的處理前標記加密、簽章與風險標記的有界掃描。
  • 絕不把使用者輸入內插進路徑。 本範例寫入一個固定目錄或 cookbook 側通道。請從伺服器控制的值衍生輸出路徑與片段名稱,絕不要從請求欄位衍生,以避免路徑穿越。
  • 輸出中不含祕密。 請勿把片段檔案寫到某個位置,或以某個名稱,而向不應看到內部識別碼的用戶端揭露它們。

本範例本身不作任何規範性標準主張。它透過 Core 分割介面拆解一份文件,並以 SplitDocument::isValid()%PDF-標頭檢查對每個片段做健全性檢查——這是一項分割器發出了 PDF 的存在性檢查,而非規範或文件結構驗證。PdfSplitter 為每個片段重建的頁面樹與交叉參照結構,正是 /modules/core/document/ 參考中所描述的 PDF 2.0 結構(ISO 32000-2:2020,交叉參照表 §7.5.4,頁面樹 §7.7.3)。若要對任何輸入或輸出文件做結構性讀取(包含版本、頁數、加密與簽章旗標),請使用 剖析並檢視 PDF 中所記載的 Core 檢視器。