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
ReportViewerREADME(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()、清理、失敗回復)與 README | ReportViewer 的選頁 UI(REP-P0 已完成,只補說明文字) |
| 驗收:server 單元與 MockMvc 測試、web Vitest 與 Playwright(print stub) | conformance、benchmark 與發布(REP-09) |
已確定的方向
以下四項已由框架維護者於 2026-09-30 決定,本文據此設計:
- 每次請求即時產生並串流。 subset 由已保存的原始報表即時擷取成暫存
ReportArtifact,經ReportResponses.inline寫出(Cache-Control: no-store)後刪除;不保存 subset、不需要過期清理。「subset 過期」即原始報表已過期或被刪除。 - 選頁以 GET query 傳遞:
pages=1-3,5,8。符合ReportResponses只接受GET/HEAD,URL 可直接作為 iframe 或下載來源。 雙端各自提供解析與格式化,語法與上限一致。 - 框架提供前端 adapter 工廠
createReportPrintAdapter:經HttpClient取得 subset、沿用 REP-07 的隱藏 iframe 列印; 應用只提供 subset URL 的組成規則。 - 保守的保留規則:保留頁面外觀、外部 URI 連結與文件資訊;移除 outline、指向其他頁的內部連結與互動表單(先 flatten, 外觀留在頁面上);加上頁碼標籤讓檢視器顯示原報表頁碼。
設計原則
- 授權在應用。 subset 沿用原始報表的授權:應用端點先以 ADR-031 的分類檢查使用者能否讀取該報表與其資料範圍,
再呼叫框架;
fileId不是授權憑證(REP-05)。框架只保證「分區不符即找不到」。 - physical page 是唯一的頁碼語意。 選頁、URL、subset 內容與頁碼標籤一律以原始 PDF 的 physical page(1 起算)表示,
與
PdfViewer的頁碼顯示一致。 - 不保存、不共享。 subset 只存在於一次請求的暫存檔,回應
no-store;同一選頁再次列印即重新擷取。 - 不使用 popup。 列印一律在頁面內的隱藏 iframe 進行,沒有會被封鎖的新視窗,也不需要取得 window handle。
- 只呼叫一次
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-format | 400 |
| 反向範圍 | report.pages.invalid-range | 400 |
| 超過 4096 字元 | report.pages.too-long | 400 |
| 頁碼大於原始 PDF 的頁數 | report.pages.out-of-range | 400 |
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:
- 以
storage.getMetadata(partition, fileId)取得檔名;不存在 →NotFoundException。 getInputStream複製到暫存檔(renderer 的TempStorage),以 PDFBox 的暫存檔快取載入,不把整份 PDF 讀入記憶體。pages.requireWithin(document.getNumberOfPages())。- 套用「保留規則」後,依序把選取的頁匯入新文件,存到另一個暫存檔;關閉來源文件並刪除來源暫存檔。
- 回傳以該暫存檔承載的
ReportArtifact(REP-05 的TempFileArtifact,關閉後刪除)。任何一步失敗都刪除已建立的暫存檔。
產物的屬性:
| 屬性 | 值 |
|---|---|
contentType | application/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 後的結果) |
回應 410 | ReportPrintError('report.expired'),訊息 This report has expired. Generate it again to continue.(沿用 REP-P0 的文字) |
回應 404 | ReportPrintError('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 取文字比對;頁碼標籤等於原頁碼 |
| 保留規則:文件與 annotation | JUnit:測試中以 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 輪)由審閱者裁決,皆採作者方案。
| # | 議題 | 裁決 | 捨棄的方案 |
|---|---|---|---|
| 1 | all 是否也經 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-05 | 404 一律視為過期會誤導無權讀取的使用者 | 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-03 | of() 可建立超過 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 物件數驗證 |
| 繼承的 resources | PDFBox 的 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 改為下載 |
相關文檔
- REP-05 ReportArtifact 與交付契約設計:
ReportResponses、暫存 ownership、授權責任 - REP-07 大型 PDF 按需預覽設計:隱藏 iframe 列印與無檢視器時的下載
- 報表產生開發計畫:REP-08 工作內容與完成條件