跳至主要内容

REP-08:選頁 subset 與瀏覽器列印契約設計

狀態:已通過設計審閱(3 輪,2026-09-30)與程式碼審閱(2 輪,2026-09-30);已實作。實作細節見「實作註記」,程式碼審閱的裁決見「程式碼審閱裁決」 依據:ADR-029(第 5 點:選頁列印由後端從已產生的原始 PDF 建立 subset PDF,前端載入後呼叫瀏覽器列印;不預填原生 page range、不提供 silent print)、報表產生開發計畫 REP-08、REP-05 ReportArtifact 與交付、REP-07 大型 PDF 按需預覽、appfuse-web ReportViewer README(REP-P0 的選頁與 adapter 接縫) 日期:2026-09-30

範圍​

納入不納入(所屬工作包)
appfuse-server:頁面選擇值型別 ReportPages(語法、正規化、上限、錯誤)參考伺服器的報表端點與授權接線(FU-103)
appfuse-server:PdfReportSubsets 從已保存的報表擷取頁面,產生暫存 ReportArtifact;outline、連結、表單、metadata 與頁碼標籤的保留規則subset 的保存、重用與過期清理(本設計不保存 subset)
appfuse-web:createReportPrintAdapter(取得 subset、隱藏 iframe 列印、取消、錯誤分類)與 formatReportPages預填原生列印對話框的 page range、選擇印表機、silent/kiosk printing
appfuse-web:列印生命週期(就緒後只呼叫一次 print()、清理、失敗回復)與 READMEReportViewer 的選頁 UI(REP-P0 已完成,只補說明文字)
驗收:server 單元與 MockMvc 測試、web Vitest 與 Playwright(print stub)conformance、benchmark 與發布(REP-09)

已確定的方向​

以下四項已由框架維護者於 2026-09-30 決定,本文據此設計:

  1. 每次請求即時產生並串流。 subset 由已保存的原始報表即時擷取成暫存 ReportArtifact,經 ReportResponses.inline 寫出(Cache-Control: no-store)後刪除;不保存 subset、不需要過期清理。「subset 過期」即原始報表已過期或被刪除。
  2. 選頁以 GET query 傳遞:pages=1-3,5,8。符合 ReportResponses 只接受 GET/HEAD,URL 可直接作為 iframe 或下載來源。 雙端各自提供解析與格式化,語法與上限一致。
  3. 框架提供前端 adapter 工廠 createReportPrintAdapter:經 HttpClient 取得 subset、沿用 REP-07 的隱藏 iframe 列印; 應用只提供 subset URL 的組成規則。
  4. 保守的保留規則:保留頁面外觀、外部 URI 連結與文件資訊;移除 outline、指向其他頁的內部連結與互動表單(先 flatten, 外觀留在頁面上);加上頁碼標籤讓檢視器顯示原報表頁碼。

設計原則​

  1. 授權在應用。 subset 沿用原始報表的授權:應用端點先以 ADR-031 的分類檢查使用者能否讀取該報表與其資料範圍, 再呼叫框架;fileId 不是授權憑證(REP-05)。框架只保證「分區不符即找不到」。
  2. physical page 是唯一的頁碼語意。 選頁、URL、subset 內容與頁碼標籤一律以原始 PDF 的 physical page(1 起算)表示, 與 PdfViewer 的頁碼顯示一致。
  3. 不保存、不共享。 subset 只存在於一次請求的暫存檔,回應 no-store;同一選頁再次列印即重新擷取。
  4. 不使用 popup。 列印一律在頁面內的隱藏 iframe 進行,沒有會被封鎖的新視窗,也不需要取得 window handle。
  5. 只呼叫一次 print(),且在文件就緒之後。 就緒訊號為 iframe 的 load 事件;同一次列印最多呼叫一次,取消後不呼叫。

頁面選擇語法​

語法​

pages = part *( "," part )
part = page / page "-" page
page = 1*9DIGIT ; 1 起算,不接受 0 與前導零
  • 不接受空白、正負號、空的 part(1,,3)與反向範圍(5-3)。
  • 範圍與頁碼可重疊、可亂序;解析後正規化為遞增、去重、合併相鄰的形式(5,1-3,2 → 1-3,5)。 subset 的頁序即正規化後的遞增順序,不支援重排或重複列印同一頁。
  • 字串長度上限 4096 字元(遠低於 Tomcat 預設 8 KiB 的請求行上限);頁碼必須不大於原始 PDF 的頁數。
  • 「全部」不以語法表示:前端對全部頁面直接列印原始來源,不請求 subset(見「前端 adapter」)。

資源界線​

字串上限不等於展開成本上限:1-999999999 只有 11 字元,卻代表近十億頁。因此:

  • 解析與正規化只處理區間,成本與 part 數成正比(4096 字元內最多約 2048 個 part),不展開成頁碼。
  • ReportPages 不提供把全部頁碼展開成陣列的方法;逐頁走訪只在 requireWithin(pageCount) 通過之後進行, 走訪次數因此不超過原始 PDF 的頁數。
  • 選取頁數以 long 回報(區間長度的總和),不會溢位。

使用者輸入與 wire 語法​

兩者刻意分開:

使用者輸入(REP-P0 選頁對話框)wire 語法(pages query)
解析者parseReportPageList/parseReportPageRange(既有,不變)server 的 ReportPages.parse
寬鬆程度寬鬆:接受 1, 3、1 - 3、01 等空白與前導零嚴格:本節文法,拒絕空白與前導零
產出結構化 ReportPageSelection(頁碼已驗證不超過頁數)ReportPages

前端以 formatReportPages(selection) 把結構化選擇格式化成 wire 語法的正規形式(遞增、合併相鄰區間、無空白); 它不解析使用者字串,所以不宣稱兩個解析者等價。格式化結果超過 4096 字元時(例如大型文件逐頁隔頁選取), adapter 在送出前以 report.invalid-selection 失敗,訊息說明分段過多。

錯誤​

情況代碼HTTP(經 StandardRestExceptionHandler)
空字串、語法錯誤、前導零、超過 9 位數report.pages.invalid-format400
反向範圍report.pages.invalid-range400
超過 4096 字元report.pages.too-long400
頁碼大於原始 PDF 的頁數report.pages.out-of-range400

ReportPageSelectionException 繼承 InvalidDataException,由標準例外處理轉成 400,code 放在 problem detail。 前端送出的是 formatReportPages 產生的正規形式、頁碼已在選頁時驗證,400 只在格式化實作有誤、原始 PDF 頁數與前端 所知不同,或 URL 被竄改時出現。

Server API​

ReportPages(io.leandev.appfuse.report)​

public final class ReportPages {
public static final int MAX_LENGTH = 4096;

/// 解析 query 字串;語法錯誤、反向範圍或超長時丟出 ReportPageSelectionException
public static ReportPages parse(String spec);

/// 由頁碼建立(1 起算);正規化同 parse
public static ReportPages of(int... pages);

/// 確認所有頁碼不大於 pageCount;否則丟出 report.pages.out-of-range(只比較最後一個區間的終點)
public ReportPages requireWithin(int pageCount);

/// 正規化的區間(遞增、不重疊、不相鄰)
public List<PageRange> ranges();

/// 逐頁走訪(遞增);未先以 requireWithin 確認時丟出 IllegalStateException
public void forEachPage(IntConsumer action);

/// 選取的頁數(區間長度總和)
public long size();

/// 正規化的字串形式(例如 "1-3,5"),可作為 query 值
@Override public String toString();
}

PageRange 為 record PageRange(int from, int to)(1 <= from <= to)。ReportPages 為值型別,equals/hashCode 以正規化的區間比較(不含已確認的頁數);requireWithin 回傳記住已確認頁數的新實例,forEachPage 只允許在該實例上呼叫。 不依賴 PDFBox,可在 controller 參數綁定時先驗證語法。

PdfReportSubsets(io.leandev.appfuse.report)​

public final class PdfReportSubsets {
/// 從已保存的 PDF 擷取頁面,回傳暫存 ReportArtifact(呼叫者負責關閉,或交給 ReportResponses)
///
/// @throws NotFoundException 分區內沒有此 fileId(包含已過期刪除)
/// @throws ReportPageSelectionException 頁碼超出原始 PDF 的頁數
/// @throws IllegalStateException classpath 上沒有 PDFBox、原始檔已加密、不是可解析的 PDF,
/// 或含無法保留外觀的表單(見「表單」)
public static ReportArtifact extract(FileStorage storage, String partition, String fileId, ReportPages pages);
}

PDFBox 與 PdfReportRenderer 相同為選用相依,進入時先 requirePdfBox()。實作放在 internal.pdf:

  1. 以 storage.getMetadata(partition, fileId) 取得檔名;不存在 → NotFoundException。
  2. getInputStream 複製到暫存檔(renderer 的 TempStorage),以 PDFBox 的暫存檔快取載入,不把整份 PDF 讀入記憶體。
  3. pages.requireWithin(document.getNumberOfPages())。
  4. 套用「保留規則」後,依序把選取的頁匯入新文件,存到另一個暫存檔;關閉來源文件並刪除來源暫存檔。
  5. 回傳以該暫存檔承載的 ReportArtifact(REP-05 的 TempFileArtifact,關閉後刪除)。任何一步失敗都刪除已建立的暫存檔。

產物的屬性:

屬性值
contentTypeapplication/pdf
filename{原檔名去副檔名}-pages-{正規化字串,"," 換成 "_"}.pdf;超過 120 字元時為 {原檔名去副檔名}-pages.pdf
pageCount選取的頁數
createdAt、metadata取自原始 PDF 的文件資訊(subset 是同一份文件的節錄,不是新文件)
diagnostics空

應用端點(範例)​

@RequestMapping(path = "/reports/{id}/print", method = {RequestMethod.GET, RequestMethod.HEAD})
void print(@PathVariable UUID id, @RequestParam String pages,
HttpServletRequest request, HttpServletResponse response) throws IOException {
var report = reports.findReadable(id); // 應用:不存在或無權讀取 → 404(不揭露是否存在)
if (report.isExpired(clock)) { // 應用:已知已過保存期限 → 410
throw new ResponseStatusException(HttpStatus.GONE);
}
var selection = ReportPages.parse(pages);
ReportResponses.inline(
PdfReportSubsets.extract(fileStorage, report.partition(), report.fileId(), selection),
request, response);
}

HEAD 仍會擷取(ReportResponses 需要 Content-Length);前端 adapter 不送 HEAD。

404 與 410 的分工:框架只知道檔案在不在分區內,找不到一律 NotFoundException(404);報表是否「過期」只有應用知道 (保存期限由應用以 StoredReport.storedAt 計算,REP-05)。應用對已知過期、且使用者仍有權讀取的報表回 410, 其餘不存在或無權讀取回 404,前端據此區分「重新產生」與「無法取得」。

保留規則​

原始 PDF 可能不是框架 renderer 產生的(應用也可能保存其他 PDF),規則對任意 PDF 一致。原則是只保留靜態外觀與 外部網址:凡是會在檢視器中執行、開啟、播放或攜帶其他檔案的內容一律移除,無法可靠保留外觀的表單則拒絕擷取。

文件與頁面​

項目處理理由
頁面內容、資源、頁面尺寸、旋轉、裁切框保留列印結果與預覽一致
Outline(書籤)不複製目標可能不在 subset
文件層級的 JavaScript、OpenAction、/AA、嵌入檔、Named destinations、結構樹(tags)不複製新文件只含頁面
頁面的 /AA 與 /StructParents移除前者是 action;後者指向未複製的結構樹
文件資訊(Title、Author、Subject、Keywords、Creator、Producer、CreationDate)複製subset 是同一份文件的節錄
XMP metadata不複製可能描述頁數等與 subset 不符的內容
頁碼標籤(/PageLabels)以原 physical page 重新設定:每段連續頁一個十進位標籤區段,起始值為原頁碼檢視器顯示「第 5 頁」而非「第 1 頁」;原 PDF 自有的標籤不沿用
加密的原始 PDF不支援,IllegalStateException框架 renderer 不加密;需要時由應用先解密保存

Annotation 允許清單​

逐頁處理 /Annots,不在允許清單內的 subtype 一律移除(包含未知 subtype):

Subtype處理
Text、FreeText、Line、Square、Circle、Polygon、PolyLine、Highlight、Underline、Squiggly、StrikeOut、Caret、Ink、Stamp、Watermark保留外觀;移除 /A、/AA
Popup其 /Parent 被保留時保留,否則移除
Link只在 action 為 URI 且網址 scheme 為 http、https、mailto 時保留,並重建:新的 Link 只帶 /Rect、/QuadPoints、/Border//BS、/C 與一個以 PDActionURI 新建的 action(/Type /Action、/S /URI 與從來源複製的 /URI 值;不帶 /Next、/IsMap 等其他欄位);/Dest、GoTo、Launch、JavaScript、其他 scheme(例如 javascript:、file:)一律移除該 annotation
Widget由「表單」處理:flatten 後不應殘留;殘留者移除
FileAttachment、Sound、Movie、Screen、RichMedia、3D、PrinterMark、TrapNet、Redact移除(附件、多媒體、印刷控制或未套用的遮蔽)

URI action 可經 /Next 串接其他 action,因此不沿用原 action 物件,而是只取 /URI 字串、以 PDActionURI 重建; /S /URI 是辨識 action 類型的必要欄位(缺少時 PDFBox 的 PDActionFactory 與檢視器都無法辨識),必須保留。

表單​

情況處理
動態 XFA(xfaIsDynamic())拒絕:IllegalStateException(report.subset.unsupported-form)。內容在 XFA template 中,頁面與 AcroForm 無法代表
靜態 XFA(同時有 AcroForm 欄位)移除 XFA,以 AcroForm 欄位與其外觀為準
一般 AcroForm以 flatten(fields, true) 在擷取前 flatten:先為欄位重新產生外觀(涵蓋 NeedAppearances 為 true 或缺少外觀的文字、選單欄位),再把外觀寫入頁面內容,移除欄位與 widget
選取頁上的欄位有值,但沒有也無法產生外觀(例如缺少狀態外觀的核取方塊、選項按鈕)拒絕:IllegalStateException(report.subset.form-appearance-missing),不產出遺失欄位內容的 subset
簽章欄位依其外觀 flatten;subset 不是原簽署文件,簽章不再有效(README 說明)

拒絕屬於伺服器端的內容問題(應用保存了框架無法可靠處理的 PDF),以 500 呈現並記錄;前端顯示一般錯誤並可改用下載原始檔。

前端 adapter​

API(@appfuse/appfuse-web/components)​

/** 以 subset PDF 列印的 adapter 設定 */
export interface ReportPrintAdapterOptions {
/** 應用的 HTTP client(帶認證的 apiClient) */
readonly httpClient: HttpClient
/** 組成 subset URL;pages 為 formatReportPages 的結果(例如 "1-3,5"),只含數字、`-` 與 `,`,可直接放入 query */
readonly subsetUrl: (request: Readonly<ReportPrintRequest>, pages: string) => string
}

export function createReportPrintAdapter(options: ReportPrintAdapterOptions): ReportViewerAdapter

/** 正規化的 query 值;`all` 回傳 null(列印原始來源) */
export function formatReportPages(selection: ReportPageSelection): string | null

/** adapter 可辨識的失敗 */
export class ReportPrintError extends Error {
readonly code: 'report.unavailable' | 'report.expired' | 'report.invalid-selection' | 'report.print-unavailable'
}

列印流程​

步驟all其他選頁
取得內容artifact.source:API URL 經 HttpClient 完整下載;blob:/data:/File 直接使用格式化結果超過 4096 字元即以 report.invalid-selection 失敗、不送出;否則 subsetUrl(request, formatReportPages(selection)) 經 HttpClient(responseType: 'blob'、帶 signal)
就緒建立隱藏 iframe 載入 blob URL,load 事件為就緒訊號同左
列印再次確認 signal.aborted 為 false 後呼叫一次 contentWindow.print();同一個 iframe 以旗標保證不重複呼叫同左
清理afterprint 或 60 秒後移除 iframe、釋放 blob URL同左
沒有內建 PDF 檢視器(navigator.pdfViewerEnabled === false)改為下載檔案(REP-07 行為),adapter 正常完成同左

REP-07 的 pdf-actions.printPdf 擴充 signal 參數與單次呼叫保證,供 PdfViewer 的列印按鈕與本 adapter 共用。 外部(跨來源、非 API)URL 的 all 列印不開新分頁:fetch 失敗即以 report.print-unavailable 失敗,由使用者改用下載。

取消與錯誤​

情況行為
使用者按「Cancel Print」或元件卸載(signal abort)中止進行中的請求;已建立的 iframe 立即移除;不呼叫 print();adapter 以 abort reason 結束(ReportViewer 已忽略 abort 後的結果)
回應 410ReportPrintError('report.expired'),訊息 This report has expired. Generate it again to continue.(沿用 REP-P0 的文字)
回應 404ReportPrintError('report.unavailable'),訊息 This report is not available, or you do not have access to it.;不暗示可重新產生
回應 403原樣拋出(授權拒絕),ReportViewer 以 getApiErrorMessage 顯示伺服器訊息
回應 400,或格式化結果超過 4096 字元ReportPrintError('report.invalid-selection'),訊息分別為 The selected pages are not valid for this report. 與 Too many separate page ranges are selected. Select fewer ranges and try again.
401由 HttpClient 的 refresh 流程處理;refresh 失敗依應用既有的登出流程,adapter 原樣拋出
iframe 30 秒內未載入ReportPrintError('report.print-unavailable'),訊息 The browser could not open the report for printing. Download the report instead.
其他錯誤原樣拋出;ReportViewer 以 getApiErrorMessage 顯示伺服器訊息

ReportViewer 在選頁對話框內顯示錯誤、保留使用者的選擇,可直接重試。應用可在 onPrintError 判斷 error instanceof ReportPrintError && error.code === 'report.expired',把 state 改為 expired 以顯示「Generate Again」; report.unavailable 不應改為 expired(使用者可能無權讀取,重新產生沒有意義)。

使用者在原生對話框按取消無法由網頁偵測(瀏覽器照樣觸發 afterprint);adapter 已完成,使用者可再次列印。

頁碼的可辨識性​

  • 選頁對話框沿用 REP-P0 的說明(「Select the physical PDF pages to print」),並補一句:頁碼指 PDF 的頁序,與 PdfViewer 工具列顯示的頁碼相同。
  • subset 以原頁碼設定頁碼標籤,瀏覽器 PDF 檢視器與預覽中顯示的頁碼即原報表的頁碼;框架 renderer 印在頁首頁尾的 頁碼本來就是原報表的頁碼,擷取後不變。
  • 原 PDF 若自帶與 physical page 不同的頁碼標籤(非框架 renderer 產生),選頁仍以 physical page 表示;README 說明此限制。

相容性​

變更影響
server 新增 ReportPages、ReportPageSelectionException、PdfReportSubsets新增 API(實驗性,同 REP-02 起的報表 API)
web 新增 createReportPrintAdapter、formatReportPages、ReportPrintError、ReportPrintAdapterOptions新增 export
pdf-actions.printPdf 支援 signal、保證單次 print()內部;PdfViewer 列印行為不變
ReportViewer 選頁說明文字補一句頁碼說明

CHANGELOG:appfuse-server Added(ReportPages、PdfReportSubsets);appfuse-web Added(adapter 工廠與相關型別)。

驗收方式(對應計畫的完成條件)​

完成條件驗證方式
subset 的 physical page 順序與選擇完全一致JUnit:以框架 renderer 產生 20 頁、每頁印有 Page n 的 PDF,擷取 1-3,5,20、20、2,1(正規化為 1-2)等,逐頁以 PDFTextStripper 取文字比對;頁碼標籤等於原頁碼
保留規則:文件與 annotationJUnit:測試中以 PDFBox 建立含 outline、文件 JavaScript、OpenAction、嵌入檔、XMP、頁面 /AA 與下列 annotation 的 PDF:https 連結(其 action 以 /Next 串接 JavaScript)、javascript: 與 file: 連結、GoTo 內部連結、Highlight(帶 /A)、FileAttachment、Screen、RichMedia、未知 subtype。subset 重新載入後只保留重建後的 https 連結:PDActionFactory 將其 action 辨識為 PDActionURI、URI 與來源相同、字典只有 /Type、/S、/URI(沒有 /Next、/IsMap)與去除 action 的 Highlight,其餘皆不存在;文件資訊保留
保留規則:表單JUnit:NeedAppearances 為 true、文字欄位缺少外觀的 PDF 擷取後,欄位值出現在頁面文字中且沒有 AcroForm;以 PDFRenderer 72 dpi 點陣化原頁(含 widget)與 subset 頁比對,平均像素差低於門檻;動態 XFA 與「有值但缺少狀態外觀的核取方塊」皆被拒絕
無權限或過期 artifact 無法擷取JUnit:不存在的 fileId 與其他分區的 fileId 皆為 NotFoundException;MockMvc(測試用 controller 模擬應用授權與保存期限):不可讀的報表 404、已過期 410、無效選頁 400、成功回 inline 與 no-store;應用授權本身由 FU-103 的參考伺服器驗證
暫存檔JUnit(自訂 TempStorage):成功與每一步失敗後都沒有殘留暫存檔
語法與資源界線JUnit:語法表(合法、正規化、前導零、空白、空 part、反向、超長、9 位數上限);1-999999999 解析後 size() 正確、requireWithin(20) 立即為 out-of-range,且不配置與頁數成比例的記憶體;未確認即 forEachPage 丟出例外。Vitest:formatReportPages 對各種結構化選擇的輸出皆符合 wire 文法(以與伺服器相同的正規表示式檢查)且為正規形式;逐頁隔頁選取的大型選擇超過 4096 字元時 adapter 不送出請求。既有的寬鬆使用者輸入(01、1 - 3)維持由 REP-P0 的解析者接受
print 只觸發一次、觸發前文件已就緒Playwright(channel: 'chromium',具 PDF 檢視器):init script 以 stub 取代 iframe 的 print,記錄呼叫次數與當下 iframe 的 document.readyState;選頁列印後呼叫恰為 1 次且為 complete;請求帶正確的 pages
取消Vitest:subset 請求進行中 abort → 請求被取消、不建立 iframe;iframe 載入中 abort → 移除 iframe、不呼叫 print()
載入失敗、過期與無檢視器Vitest:410 → report.expired、404 → report.unavailable、403 原樣拋出、400 → report.invalid-selection、載入逾時 → report.print-unavailable;Playwright:route 回 410 時對話框顯示過期訊息、回 404 時顯示無法取得的訊息,且皆可再次操作;預設 headless shell(無檢視器)改為下載
文件聲明README(ReportViewer)與本文件:Web 無法預填原生列印對話框的 page range,也不提供 silent print

已裁決的決策​

以下 4 項於設計審閱(2026-09-30,3 輪)由審閱者裁決,皆採作者方案。

#議題裁決捨棄的方案
1all 是否也經 subset 端點直接列印原始來源(位元組相同,省去 server 擷取)一律走 subset 端點(行為單一,但多一次完整擷取)
2頁碼標籤以原 physical page 重設,讓檢視器顯示原報表頁碼不設標籤,檢視器顯示 1…k
3內部連結移除目標在 subset 內者改指新頁(較完整,實作與測試成本高)
4選頁字串上限4096 字元;另以區間表示限制展開成本(R1-01)不設上限,交給伺服器請求行限制(錯誤不可預期)

設計審閱中補充的規則​

輪次議題結果
R1-01字串上限無法限制範圍展開(1-999999999)「資源界線」:只以區間解析與正規化;移除 toArray(),改 ranges() 與只能在 requireWithin 之後呼叫的 forEachPage;size() 為 long
R1-02/Next 串接 action、附件與多媒體 annotation「Annotation 允許清單」:不在清單者移除;Link 只保留 http/https/mailto 並重建 action
R1-03無參數 flatten() 不產生外觀、動態 XFA 不支援「表單」:動態 XFA 拒絕、靜態 XFA 以 AcroForm 為準、flatten(fields, true)、有值但無外觀者拒絕
R1-04既有 UI 解析較寬鬆,與 wire 語法不同「使用者輸入與 wire 語法」:分開定義,formatReportPages 只格式化結構化選擇
R1-05404 一律視為過期會誤導無權讀取的使用者410 → report.expired、404 → report.unavailable、403 原樣拋出;應用對已知過期回 410
R2-01只含 /URI 的 action 無法辨識以 PDActionURI 重建,保留 /Type、/S /URI;驗收重新載入後確認類型與 URI

程式碼審閱裁決​

程式碼審閱(2026-09-30,2 輪)的意見與裁決:

#議題裁決
R1-01列印 iframe 的 print() 丟出例外時 Promise 永不結束,無法回報與重試修正:捕捉 focus()/print() 的例外,立即清理並以 PrintFrameError 拒絕(adapter 轉為 report.print-unavailable)
R1-02缺外觀的判定只看外觀是否存在;BBox 為零的有值核取方塊被成功擷取成空白修正:取出實際使用的一般外觀串流,要求其 BBox 與 widget /Rect 都有面積,否則拒絕
R1-03of() 可建立超過 9 位數的頁碼;走訪到 Integer.MAX_VALUE 時溢位並持續循環修正:公開常數 ReportPages.MAX_PAGE(999999999),of() 與 PageRange 一致拒絕;走訪到終點即停止
1錯誤代碼的位置接受:getCode() 供 Java,problem detail 不帶 code,前端只依 HTTP 狀態分類
2原 PDF 無 CreationDate 時的 createdAt接受:以擷取時間代替
3繼承自頁面樹的共用 resources接受記為限制;須明示 subset 不提供未選取頁資料的保密隔離,授權以整份報表為單位(README、PdfReportSubsets Javadoc 與實作註記已說明)

實作註記​

項目內容
錯誤代碼的位置設計寫「code 放在 problem detail」;標準的 ApplicationExceptionMapper 沒有通用的 code 屬性,為此修改錯誤模組會讓它依賴報表套件。改為 ReportPageSelectionException.getCode() 供 Java 呼叫端使用,problem detail 仍帶英文訊息模板(i18n);前端 adapter 只依 HTTP 400 分類,不受影響
PageRange以巢狀 record ReportPages.PageRange 實作
文件語言文件資訊之外,一併複製目錄的 /Lang(與框架 renderer 寫入的 metadata 一致)
建立時間原 PDF 沒有 CreationDate 時,產物的 createdAt 以擷取時間代替;metadata().createdAt() 維持 null
annotation 與頁面欄位(允許清單)保留的標示 annotation、Popup 與匯入的頁面都只帶入欄位允許清單內的欄位(頁面:幾何、/Resources、/Contents、/Group、/UserUnit、/Tabs、/Annots;標示:外觀與註解內容;Popup:外觀與開關狀態),不改動原文件。初版以「移除清單」實作(/AA、/StructParents、/B、/Metadata、/IRT 等),資安審查指出 /AF(附件)、/PieceInfo(私有資料)、/DPart、/PresSteps 與非標準欄位會被保留,可能攜帶附件或經參照把原文件其他頁帶入 subset,改為允許清單並補測試。保留的標示若指向未保留的 Popup 則移除 /Popup。測試以 subset 中 Page、Filespec、EmbeddedFile 物件數驗證
繼承的 resourcesPDFBox 的 importPage 不帶入繼承自頁面樹的 resources,擷取時以解析後的 resources 明確設定。若原 PDF 把一份共用 /Resources 放在頁面樹節點上,其中只在未選取頁使用的圖片等 XObject 也會隨之寫入 subset(不顯示,但可從檔案取出);框架 renderer 的每頁 resources 各自獨立,不受影響。裁剪未使用的資源需解析頁面內容,本工作包不做。因此 subset 是列印用的節錄,不提供未選取頁資料的保密隔離,授權以整份報表為單位(README 與 PdfReportSubsets Javadoc 同步說明;程式碼審閱第 1 輪裁決)
表單以 getAcroForm(null) 讀取(不套用預設 fixup,避免 PDFBox 自行建立欄位與外觀);refreshAppearances() → 檢查選取頁的缺外觀欄位 → flatten(fields, false),與設計的 flatten(fields, true) 等效,且檢查在 flatten 前進行
無法解析與加密Loader 的 InvalidPasswordException 視為加密(report.subset.encrypted),其他解析失敗為 report.subset.invalid-pdf;都以 IllegalStateException 丟出並刪除暫存檔
列印共用REP-07 的 pdf-actions 拆出 printBlob(blob, filename, signal) 與 fetchPdfBlob(..., signal),PdfViewer 的列印按鈕與本 adapter 共用;iframe 可能觸發多次 load,以旗標只處理第一次;載入逾時與 frame 不可用為 PrintFrameError,adapter 轉為 report.print-unavailable
Storybook 示範.storybook/large-pdf.ts 新增 createPdfPages(只含指定原頁碼、內文保留原頁碼的 PDF)與 respondWithSubset,dev middleware 提供 /range-demo/large-report/print?pages=…;ReportViewer 新增 ServerPrint story(410 時切換為 expired)
大型 PDF 的 Playwright 等待13 個使用 30 MB 素材的測試以預設 worker 數並行時,首頁偶爾超過預設 5 秒等待(單獨或 4 workers 皆通過)。support/pdf-range.ts 新增 PAGE_VISIBLE(15 秒)用於等待頁面文字;契約以傳輸量斷言,放寬的是等待而非斷言
MockMvc 測試StandardRestExceptionHandler 為抽象類別,應用以 @RestControllerAdvice 子類啟用;測試比照建立子類
測試JUnit:ReportPagesTest(38,含語法表、資源界線與 MAX_PAGE)、PdfReportSubsetsTest(17:外觀 BBox 為零或缺少的核取方塊拒絕、有面積時 flatten 成功、頁序與頁碼標籤、檔名、其他分區與不存在、無法解析、加密、annotation 允許清單與重建連結、頁面與 annotation 的欄位允許清單、Popup 跟隨 parent、表單的外觀產生與點陣比對、動態 XFA、缺外觀核取方塊、HTTP 端點的 200/404/410/400)、PdfBoxAbsentTest(沒有 PDFBox 時的選頁與擷取)。Vitest:printBlob(7,含 print() 例外後重試)、adapter 與 formatReportPages(19)。Playwright(tests-storybook):channel: 'chromium' 下連續範圍、指定頁碼、全部頁面各只呼叫一次 print() 且當下文件 readyState 為 complete,410 顯示過期狀態、404 在對話框顯示無法取得並可重試;預設 headless shell 改為下載

相關文檔​