REP-03:PDF renderer 基線契約設計
狀態:已通過設計審閱(3 輪)與程式碼審閱(3 輪),2026-09-29;已實作。實作期間確定的細節見「實作註記」,程式碼審閱的裁決見「程式碼審閱裁決」 依據:ADR-029(含 REP-01、REP-02 實作註記)、報表產生開發計畫 REP-03、REP-02 契約、REP-01 spike 日期:2026-09-29
後續 opt-in 排版設定見流式版面擴充,經三輪審閱,於2026-10-04隨 appfuse-server 4.0.0-alpha.23 發布。 它擴充指定表頭/逐列樣式、每頁內容末端摘要及邏輯紀錄上限;以下固定 PageRegion 的既有行為保留。
流式 PDF 可重現性修正經 20261004-report-reproducibility 第二輪審閱通過並完成 close,於 2026-10-04 隨 appfuse-server 4.0.0-alpha.24 發布。
既有報表的有效接受與 close 不受此框架修正撤銷;正式發布並升級後,完整 bytes 改變的首批 PDF 仍須取得新的真人接受。
分節每頁頁首尾與逐節頁碼的opt-in擴充(alpha.28)見 分節頁首尾指南;本文的原v1固定區域與全文頁碼語意仍適用。
範圍
| 納入 | 不納入(所屬工作包) |
|---|---|
公開的 renderer 進入點、字型登錄、render 選項與結果、流式 PDF /ID | ReportArtifact、儲存、下載與 ETag(REP-05) |
| 排版:斷行、分頁、段落、標題、表格(跨頁、重複表頭、群組續接、列內分頁)、圖片、keep hint、頁首頁尾與頁碼 | SVG Figure 的實際繪製與安全政策(REP-04,見 FU-105) |
| 字型嵌入、中英 fallback、缺字處理 | 文字 shaping、bidi、直排 |
| PDF metadata、頁數、diagnostics | PDF/A、tagged PDF、書籤(outline)、加密與簽章 |
| 列來源的開啟與關閉、讀取時驗證、失敗不留產物 | 非同步 job、重試、保存期限(應用擁有) |
| corpus 基線、golden、記憶體 regression gate | 選頁 subset 與列印(REP-08) |
| 完成後依 FU-100 移除 REP-01 spike 程式碼 | — |
設計原則
- 先排版、後繪製,量測只做一次。 排版引擎產生每頁的定位 box(文字行、矩形、線、圖片),繪製器只讀 box、不重新量測。 這是 REP-01 spike「量測與繪製不一致讓整列文字消失」的直接對策。
- 逐頁輸出。 一頁排完即繪製並釋放該頁的 box;大型表格的列讀一筆排一筆,不展開整張表。 唯一保留到最後的是 PDF 的頁面物件(回填「共 Y 頁」所需,REP-01 實測每頁約 12KB)。
- 失敗不留產物。 完整 PDF 先序列化到 renderer 自己的暫存檔,之後才開始寫入
out;在第一個位元組寫入前發生的任何 ERROR、取消或例外,都不會寫出任何位元組。開始寫出後的失敗見「輸出語意」。 - 降級必須可見。 soft hint 做不到、字型缺字、圖片縮放等一律產生 diagnostic,隨結果回傳;不得靜默。
- vendor 型別只在
report.internal.pdf。 公開 API 不出現 PDFBox/fontbox 型別;既有 ArchUnit 規則已涵蓋。 - 決定性。 相同文件、字型與 renderer/PDFBox 環境產生相同的頁數、文字與版面。流式 renderer 以固定零值
/ID先序列化, 以 8 KiB 緩衝串流計算完整內容 SHA-256,再用該摘要作為兩個/ID重新序列化;排版與資料只讀一次。 完整位元組層級的決定性另有前提:PDF 內的時間必須固定,即ReportMetadata.createdAt有值,或RenderOptions給固定的Clock。 未固定時 metadata 的建立時間取自系統時鐘,兩次 render 的位元組會不同。 此修正於 4.0.0-alpha.24 發布,首次升級會改變 PDF bytes;不追溯重寫舊輸出。增加一次暫存檔讀取與一次序列化, 不將整份 PDF 載入 heap,內容 hash 期間及交付前仍檢查取消。早期 REP-01/跨平台結果中的/ID例外保留為歷史, 不宣稱本次已重新驗證所有平台;固定表單及選頁 subset 不在此可重現性宣稱範圍。
公開 API
放在 io.leandev.appfuse.report(沿用 @ExperimentalApi):
/// 可重用、thread-safe;同一個 renderer 可同時處理多份文件
public interface ReportRenderer {
RenderResult render(ReportDocument document, OutputStream out, RenderOptions options);
}
/// PDF renderer 的建立入口;PDFBox 型別不外露
public final class PdfReportRenderer {
public static ReportRenderer create(ReportFonts fonts) { ... }
}
/// render 成功的結果;只含 INFO/WARNING(ERROR 一律以例外傳出)
public record RenderResult(int pageCount, long bytesWritten, List<ReportDiagnostic> diagnostics) { }
/// 單次 render 的選項
public record RenderOptions(BooleanSupplier cancelRequested, Clock clock) {
public static RenderOptions defaults() { ... } // 不可取消、系統時鐘
public RenderOptions cancelWhen(BooleanSupplier cancelRequested) { ... }
public RenderOptions clock(Clock clock) { ... } // metadata 未給 createdAt 時使用
}
/// 取消時丟出;已開啟的列來源與暫存資源都已關閉,沒有寫出任何位元組
public class ReportRenderCancelledException extends RuntimeException { ... }
字型登錄
ReportFonts fonts = ReportFonts.builder()
.family("sans", f -> f
.regular(FontSource.file(latinRegular), FontSource.file(cjkRegular)) // 依序為 fallback 鏈
.bold(FontSource.file(latinBold), FontSource.file(cjkBold)))
.family("serif", f -> f.regular(FontSource.classpath("/fonts/NotoSerifTC-Regular.ttf")))
.defaultFamily("sans")
.build();
| 規則 | 內容 |
|---|---|
| 字型來源 | FontSource.file(Path)、FontSource.classpath(String)、FontSource.bytes(String name, Supplier<byte[]>)(name 用於錯誤訊息);登錄時只記錄來源 |
| 建構時檢查 | build() 讀取每個字型的表頭:必須是 TrueType 輪廓(REP-01:PDFBox 無法嵌入 CFF 輪廓的 OTF);可變字型拒絕(PDFBox 只用預設 instance)。不合格者丟出 IllegalArgumentException,訊息指出檔案與原因 |
| 字面 | 每個家族至少要有 regular;缺 bold 時以 regular 代替並回報 report.font.face-substituted(INFO);斜體見「已裁決的決策」第 5 項 |
| fallback | 同一字面的來源依序嘗試;逐字選擇第一個含該字元的字型 |
| 未知家族 | 文件使用未登錄的 fontFamily 時改用預設家族,回報 report.font.unknown-family(WARNING)。框架基準樣式(DocumentStyles.standard())使用家族名稱 sans,因此預設家族應以 sans 登錄,否則每份文件都會回報此 WARNING |
| 缺字 | 所有 fallback 都沒有的字元以 □(U+25A1)替代,回報 report.font.missing-glyph(WARNING,每個 code point 一次);預設家族必須含 □,否則 build() 失敗 |
| 嵌入 | 一律 subset 嵌入 |
| 執行緒 | ReportFonts 不可變、可在多執行緒共用;每次 render 各自解析字型(見「已裁決的決策」第 7 項) |
輸出語意
render 分兩個階段,以第一個位元組寫入 out 為界:
- 產生階段:排版、繪製,並把完整 PDF 序列化到 renderer 自己的暫存檔。所有列來源在此階段讀完並關閉。
- 寫出階段:把暫存檔依序複製到
out(不關閉out),完成後刪除暫存檔。
| 情況 | 行為 |
|---|---|
| 成功 | 回傳 RenderResult |
| 產生階段的任何失敗:ERROR diagnostic、列來源開啟/讀取/關閉失敗、字型或 PDF 序列化失敗、執行期例外 | 丟出對應例外(ERROR 為 ReportValidationException,含完整 diagnostic);out 未被寫入任何位元組 |
產生階段中取消(cancelRequested 為真或執行緒被中斷) | 丟出 ReportRenderCancelledException;out 未被寫入 |
寫出階段 out 丟出例外(IOException 包成 UncheckedIOException,執行期例外原樣傳出) | 例外傳出;out 可能已有部分內容,呼叫者必須丟棄(REP-05 的 artifact 以暫存檔承接) |
| 寫出階段開始後才到達的取消 | 不再響應:寫出只是複製已完成的檔案,時間有界;完成後正常回傳 |
| 清理失敗(關閉游標、刪除暫存檔、釋放字型) | 已有主例外時,清理失敗以 addSuppressed 附上,不得取代主例外;成功路徑上的暫存檔刪除失敗不影響已完整寫出的結果,改在 RenderResult 回報 report.resource.cleanup-failed(WARNING) |
取消在產生階段的每頁結束、每 256 列表格列、序列化前,以及序列化完成後、開始複製到 out 之前檢查;最後一次檢查通過後才進入寫出階段,此後不再響應取消。
排版引擎
架構(report.internal 之下)
ReportDocument
│ StyleResolver(REP-02)
▼
LayoutEngine ── TextMeasurer(字寬、行高;PDF 實作包 PDFBox 字型)
│ 產生 PageLayout:定位 box 清單 + 需回填的頁首頁尾
▼
PdfPageWriter ── 繪製 box;頁面完成後釋放 box
│
▼
PdfDocumentWriter ── 回填 PageCount、寫 metadata、序列化到 renderer 暫存檔
│ (產生階段結束;最後一次取消檢查)
▼
OutputCopier ── 複製暫存檔到 out、刪除暫存檔(寫出階段)
LayoutEngine只依賴TextMeasurer介面,不依賴 PDFBox;單元測試以固定字寬的測試量測器驗證分頁規則,不需要字型檔。- box 是不可變 record,座標以 pt 為單位、原點在頁面左上;轉成 PDF 座標只在繪製器中做一次。
版面與頁面
| 項目 | 規則 |
|---|---|
| 內容區 | 紙張扣除邊界;頁首頁尾放在邊界內,佔用的高度由其內容量測決定,並從內容區扣除 |
| 頁首頁尾 | 左、中、右三格各自排版;LineBreak 產生多行,不自動換行,單行超出該格寬度時截斷並回報 report.layout.region-truncated(WARNING)。每一格逐行量測高度(該行最大字級 × 行高倍數)後加總,區域高度取三格中最高者,因此一行大字與兩行小字都能正確容納;rule 畫在頁首下方、頁尾上方 |
| 頁首頁尾過高 | 頁首+頁尾高度使內容區高度 ≤ 0,或容不下預設段落樣式的一行時,回報 report.layout.page-regions-too-tall(ERROR)並停止 |
| 頁碼 | PageNumber 在該頁完成時繪製;含 PageCount 的頁首頁尾延到全部頁面完成後回填 |
| Section | pageBreakBefore 且不在頁首時換頁;title 在 REP-03 不輸出(書籤延後) |
段落與標題
| 項目 | 規則 |
|---|---|
| 斷行 | CJK 字元之間可斷、拉丁字詞以空白分界;避頭(,。、;:!?)」』 等)以懸掛處理,避尾((「『 等)移到下一行;不可斷的長字串強制斷開並回報 report.layout.forced-break(WARNING) |
LineBreak | 強制換行 |
| 對齊 | LEFT、CENTER、RIGHT;JUSTIFY 對段落最後一行以外的行,將多餘寬度平均分配到可斷點(CJK 字間、拉丁空白) |
| 行高 | 字級 × lineHeight;同一行混用字級時取最大值 |
| 孤行寡行 | 段落跨頁時,頁尾至少留 orphans 行、頁首至少 widows 行。依序處理:① 整段放得下 → 排在目前頁;② 目前頁有合法切點(目前頁可放 ≥ orphans 行,且剩餘 ≥ widows 行;取可放的最多行)→ 在此分割;③ 目前頁沒有合法切點 → 移到新頁(不回報),在新頁頂端重新套用本規則;④ 在頁首仍沒有合法切點(段落行數少於 orphans + widows 且高於整頁,或整頁容量少於 orphans/widows 行)→ 在可放的最多行處分割並回報 report.layout.widow-orphan-broken(WARNING)。例:目前頁只剩 1 行、新頁可放 10 行、段落 20 行、orphans = widows = 2 → 走 ③ 移到新頁後在第 10 行分割,無 WARNING |
keepWithNext | 與下一個區塊的前 orphans 行(或第一列)同頁;做不到時回報 report.layout.keep-with-next-broken(WARNING) |
| 段距 | spaceBefore 在頁首時省略;spaceAfter 照常。表格、圖片/Figure 與 KeepTogether 之後留預設段落樣式的 spaceAfter |
| 全形空白 | U+3000 視同 CJK 字元保留寬度,不併成半形空白、也不在行首行尾省略 |
表格
| 項目 | 規則 |
|---|---|
| 欄寬 | 先滿足 minWidth,剩餘寬度依 weight 比例分配 |
| 表頭 | 欄標題列與 headerRows;repeatHeader 時每頁重複 |
| 群組續接 | 跨頁時若下一列的 groupPath 非空,在表頭後補續接列,文字為 continuationLabel 代入最深一層群組名稱;下一列是群組標題時,它開啟的是新群組,因此續接其上層群組(沒有上層則不補) |
keepWithNext | 群組標題列(或任何設定 keepWithNext 的列)與下一列同頁;連續設定的列(多層群組標題)整串與其後第一個未設定的列同頁,為此沿該串列預看,預看量以一頁高度為上限。合計高於新頁可用高度時照常分頁,回報 report.layout.keep-with-next-broken(WARNING),與區塊的 keepWithNext 相同 |
| 列內分頁 | 目前頁放不下、但新頁(扣除重複表頭與續接列)放得下的列,整列移到下一頁;只有高於新頁的 splittable 列才逐行分割,各儲存格的段落逐行續排;Image/Figure 不可分割,整個移到下一頁 |
| 不可分割的列放不下 | splittable=false 且高於整頁時截斷並回報 report.layout.row-truncated(ERROR,停止 render) |
| 列來源 | 以 ValidatingRowCursor 開啟,try-with-resources 關閉;只預看一列 |
| 樣式 | 依 REP-02 StyleResolver 的表格合併順序;列角色決定底色(表頭、群組、小計與總計) |
圖片與 Figure
| 項目 | 規則 |
|---|---|
| 格式 | image/png、image/jpeg;其他格式或無法解碼時回報 report.image.unsupported(ERROR) |
| 尺寸 | 依 width/height 等比;皆省略時以 72 DPI 的原始尺寸,超過可用寬度則縮小 |
| 超頁 | 依 REP-02「不可分割內容的超頁處置」:等比縮小並回報 report.layout.figure-scaled(WARNING);圖說放不下時 report.layout.no-space(ERROR) |
| 圖說 | 圖片下方置中,與圖片同頁 |
Figure | REP-04 之前依「已裁決的決策」第 2 項:同尺寸框、置中替代文字、照常輸出圖說,回報 report.figure.not-rendered(WARNING)。REP-04 起改為實際繪製,見 REP-04 契約 |
前進保證
規則只針對自動換頁:引擎因為目前頁放不下而換頁後,同一份待排內容必須在新頁至少放下一個最小單位——段落的一行、
表格列的一行文字(列內分頁時)、一張不可分割的圖片或 Figure(可縮放)。若新頁頂端扣除頁首頁尾、重複表頭與續接列後仍放不下,
回報 report.layout.no-progress(ERROR)並停止,location 指向該節點。例如:重複表頭與續接列佔去整頁只剩不到一行、段落字級乘上行高超過內容區高度。
引擎不會為同一份待排內容反覆自動換頁。
明示的分頁與間距本身就是進展,不受此規則限制:
| 節點 | 行為 |
|---|---|
PageBreak | 一律結束目前頁,即使目前頁沒有內容;連續的 PageBreak 會依作者意圖產生空白頁 |
Section.pageBreakBefore | 目前頁已有內容時才換頁(同「版面與頁面」表),不會產生空白頁 |
Spacer | 佔用垂直空間;高於目前頁剩餘空間時在頁尾截止、不延續到下一頁,也不觸發自動換頁後的前進檢查 |
既有的 no-space(圖說放不下)、header-too-tall(表頭本身過高)、row-truncated(不可分割列過高)是此規則的特例,保留較具體的代碼。 |
KeepTogether
以同一個排版引擎在不寫出的情況下量測整個區塊(內容已由 REP-02 驗證為可量測):放得下就排在目前頁;放不下但小於整頁就換頁;高於整頁則回報
report.layout.keep-together-too-tall(WARNING)並照常跨頁。box 樣式畫在區塊外框。
Diagnostic 代碼
| 代碼 | 嚴重度 | 說明 |
|---|---|---|
report.font.unknown-family | WARNING | 使用未登錄的字型家族,改用預設家族 |
report.font.face-substituted | INFO | 缺少粗體或斜體字面,以其他字面代替 |
report.font.missing-glyph | WARNING | 所有 fallback 都缺字,以 □ 替代 |
report.layout.forced-break | WARNING | 不可斷的長字串被強制斷開 |
report.layout.keep-with-next-broken | WARNING | 無法與下一個區塊或下一列同頁 |
report.layout.widow-orphan-broken | WARNING | 無法同時滿足孤行寡行設定,在可放的最多行處分割 |
report.layout.keep-together-too-tall | WARNING | 區塊高於整頁,照常跨頁 |
report.layout.region-truncated | WARNING | 頁首頁尾的某一行超出該格寬度 |
report.layout.figure-scaled | WARNING | 圖片或 Figure 等比縮小以放入頁面 |
report.layout.row-truncated | ERROR | 不可分割的列高於整頁 |
report.layout.no-space | ERROR | 重複表頭等佔用後,連圖說都放不下 |
report.layout.header-too-tall | ERROR | 重複表頭本身高於頁面內容區 |
report.layout.page-regions-too-tall | ERROR | 頁首頁尾佔去整個內容區 |
report.layout.no-progress | ERROR | 換頁後仍放不下任何最小單位(見「前進保證」) |
report.image.unsupported | ERROR | 圖片格式不支援或無法解碼 |
report.figure.not-rendered | WARNING | REP-04 之前 Figure 以替代文字框呈現(依「已裁決的決策」第 2 項);REP-04 起不再產生 |
report.resource.cleanup-failed | WARNING | 成功寫出後刪除暫存檔失敗;輸出完整有效 |
同一代碼、同一 location 只回報一次;missing-glyph 以 code point 去重。
相依與封裝
- PDFBox(
org.apache.pdfbox:pdfbox3.0.8 以上、4.0 以前,含 fontbox、pdfbox-io)只被io.leandev.appfuse.report.internal.pdf使用。 - 相依範圍依「已裁決的決策」第 1 項採
compileOnly,與 S3、Azure、SFTP SDK 相同:未使用報表的應用不需載入約 3.8MB 的 PDFBox; 使用者自行加入相依。compileOnly的版本範圍不會傳給下游,因此下限 3.0.8(REP-03 驗證的版本;scratch 暫存檔以擁有者權限建立)由執行期檢查:ReportFonts.Builder#build()與PdfReportRenderer.create在 PDFBox 不存在或版本不在[3.0.8,4.0.0)時丟出IllegalStateException,訊息指出需要的座標與版本範圍; 無法取得版本資訊(例如重新打包的 jar)時不阻擋。
驗收方式(對應計畫的完成條件)
| 完成條件 | 驗證方式 |
|---|---|
| 三份 corpus 的頁數、文字內容與 visual golden 通過 | REP-01 的 PdfInspection/RasterGolden 移入正式測試;以 REP-02 的 corpus 文件樹 render,斷言頁數、逐頁文字、metadata、字型全嵌入且為 subset、點陣 golden(72 DPI 灰階) |
| 跨平台一致 | 以固定 createdAt/Clock 的 corpus,在 Linux headless 容器(arm64、amd64)執行;輸出除 /ID 外位元組相同 |
| 決定性 | 固定時間的文件 render 兩次,完整位元組(含 /ID)相同;內容或製表時間改變時 /ID 與完整位元組均改變;另測未固定時間時頁數與逐頁文字仍相同 |
| 排版規則 | LayoutEngine 以測試量測器逐條測試:斷行與避頭尾、JUSTIFY、孤行寡行(四個步驟,含「頁尾只剩 1 行、長段落移到新頁後正常分割」案例)、keepWithNext(區塊與表格列)、KeepTogether、表格跨頁與重複表頭、群組續接、列內分頁、不可分割列、圖片縮放與 no-space、header-too-tall、多行與混合字級的頁首頁尾(高度逐行加總)與 page-regions-too-tall |
| 前進保證 | 重複表頭與續接列只剩不到一行、字級高於整頁等情境回報 no-progress;測試設時間上限並斷言頁數有界,防止反覆換空頁。另測連續 PageBreak 產生對應數量的空白頁、Section.pageBreakBefore 在頁首不換頁、超高 Spacer 在頁尾截止,三者皆不回報 no-progress |
| 峰值記憶體有 benchmark 與 regression gate | 獨立 JVM(-Xmx128m)render 50,000 列明細(約 2,000 頁)必須完成;同時記錄時間與 GC 後存活峰值供趨勢比較(時間不設門檻,因機器差異大) |
| 降級產生 diagnostic 而非靜默 | 逐一觸發上表每個代碼,斷言嚴重度、代碼與 location |
| 資源在成功、失敗、取消時都關閉 | 列來源探針(REP-02 測試手法)搭配失敗與取消;暫存檔目錄在每次 render 後為空;產生階段的 ERROR、例外與取消時 out 為零位元組 |
| 寫出前取消 | 已序列化草稿的摘要階段取消,或序列化鉤子觸發最後檢查:丟出 ReportRenderCancelledException,out 為零位元組,列來源關閉且暫存目錄為空 |
| 寫出階段語意 | out 在寫到一半時丟出 IOException 與執行期例外:例外傳出、已寫位元組大於零且暫存檔已刪除;以內部暫存檔介面模擬刪除失敗:成功路徑回報 cleanup-failed,失敗路徑的清理失敗成為 suppressed |
| 公開 API 無 vendor 型別 | 既有 ArchUnit 規則;另加「report.internal.pdf 以外不得相依 PDFBox」與「排版引擎不相依 PDF 實作與 AWT」 |
compileOnly 的實際行為 | Gradle reportWithoutPdfBoxTest 以移除 pdfbox/fontbox 的 classpath 執行:模型照常可用,ReportFonts.Builder#build() 與 PdfReportRenderer.create 丟出指引明確的 IllegalStateException |
| FU-100 | 正式 renderer 通過同一組 corpus 後,刪除 src/reportSpike、reportSpike Gradle 設定與 spike 的 corpus 副本;Linux 容器驗證改用 scripts/report-linux.sh |
已裁決的決策
以下 7 項於設計審閱(2026-09-29,3 輪)由審閱者裁決,皆採作者方案。
| # | 議題 | 裁決 | 捨棄的方案與理由 |
|---|---|---|---|
| 1 | PDFBox 相依範圍 | compileOnly,使用報表的應用自行加入 org.apache.pdfbox:pdfbox 3.0.x;PdfReportRenderer.create 在缺少時丟出指引明確的例外。與 S3、Azure、SFTP SDK 一致。程式碼審閱時下限提高至已驗證的 3.0.8,於執行期檢查(見「相依與封裝」) | implementation:所有下游都會帶入約 3.8MB,未使用報表的應用也一樣 |
| 2 | REP-04 之前的 Figure | 畫出同尺寸的框並置中顯示替代文字、照常輸出圖說,回報 report.figure.not-rendered(WARNING) | 回報 ERROR 拒絕 render:含圖表的報表在 REP-04 前完全無法產生 |
| 3 | 取消機制 | RenderOptions.cancelWhen(BooleanSupplier)+執行緒中斷;檢查點與寫出邊界見「輸出語意」 | 另立取消抽象:框架目前沒有可沿用者,REP-03 不擴大範圍 |
| 4 | 測試字型的取得 | 沿用 REP-01 的下載任務(固定來源+SHA-256、快取於 Gradle user home),一般 test 相依它;第一次執行需網路 | 獨立 task:容易被略過,golden 失去 regression 作用 |
| 5 | 斜體 | 家族沒有斜體字面時以 12° 傾斜合成,回報 report.font.face-substituted(INFO) | 直立輸出並回報 WARNING:失去作者要求的強調 |
| 6 | PDF 書籤(outline) | REP-03 不做;有需求時由 Section.title 與標題產生 | — |
| 7 | 字型解析是否跨 render 共享 | 每次 render 各自解析;benchmark 顯示成本過高時再評估共享唯讀字型資料 | 共享解析結果:PDFBox 字型物件非 thread-safe,需額外同步設計 |
程式碼審閱裁決
程式碼審閱(2026-09-29,3 輪)修正 5 項 major 意見(圖片快取保留原始位元組、零高度列使預看無界與取消延遲、 群組內圖片未扣續接列高度、keepWithNext 漏算段後距、不重複表頭時首列無法移頁;皆見「實作註記」),並裁決以下待決點:
| # | 議題 | 裁決 |
|---|---|---|
| 1 | 預設家族名稱 sans | 維持:文件使用未登錄的家族一律回報 report.font.unknown-family(WARNING);以「預設家族應以 sans 登錄」的說明處理,不讓基準樣式的家族名稱特殊化 |
| 2 | 明細末頁只有續接列與合計列 | 接受現狀;不在 REP-03 增加契約外的版面規則(例如群組尾列與前一列同頁) |
| 3 | 記憶體 gate 餘裕 | 修正圖片快取後維持 -Xmx128m 的 regression gate,並加入每列一張不同圖片的案例 |
| 4 | PDFBox 版本下限 | 提高至已驗證的 3.0.8;compileOnly 的範圍不會傳給下游,改由執行期檢查(見「相依與封裝」) |
實作註記
實作期間確定、補進上文規則的細節,以及驗收時的量測結果。
| 項目 | 內容 |
|---|---|
| 多層群組標題 | corpus 明細的門市標題與月份標題都設 keepWithNext;只預看一列時門市標題會留在頁尾(第 12 頁)。改為沿 keepWithNext 串列預看,預看量以一頁高度為上限,記憶體仍與列數無關 |
| 全形空白 | 斷行曾把 U+3000 併成半形空白,圖說「圖 1 …」失去全形空白;改為視同 CJK 字元 |
| 區塊間距 | 表格、KeepTogether 之後原本沒有間距,下一個區塊緊貼其外框;改與圖片一致,留預設段落的 spaceAfter |
| 中斷 | 字型以可中斷的檔案通道讀取,執行緒中斷會使讀取失敗(ClosedByInterruptException)。render 開始前先檢查取消;產生階段中斷造成的 IO 失敗視為取消,丟出 ReportRenderCancelledException(原失敗為 cause) |
| 圖片記憶體 | 圖片快取以 ImageData 的 identity 為弱參照 key:串流表格放置後捨棄的列,其圖片位元組即可回收;解碼後的點陣另外只保留到嵌入為止。仍被文件引用的同一圖片只嵌入一次。記憶體 gate 另含每列一張不同圖片的 2,000 列案例(約 55MB 圖片資料,-Xmx128m) |
| keepWithNext 預看上限 | 預看除一頁高度外另有 256 列上限,零高度或極小高度的列不會讓預看無界;取消以實際讀取的列數每 256 列檢查一次,預看期間也能及時停止 |
| 儲存格內圖形的可用高度 | 圖形不可分割,以新頁上該列可用的高度(扣除重複表頭、續接列與內距)為上限等比縮小;群組內的列一律預留續接列的高度 |
| 不重複表頭的首頁 | 「只有表頭與續接列的頁面換頁也不會更大」的前進保證以容量判斷:repeatHeader=false 時首張表格頁含表頭、後續頁不含,放不下的列仍會移到較大的後續頁 |
| keepWithNext 的段後距 | 區塊自身的同頁判斷包含段後距,避免段後距把後文擠到次頁而標題留在前頁 |
| 明細頁數 | corpus 明細 19 頁(spike 為 15 頁):正式 renderer 的儲存格內距與換行較 spike 寬鬆,客戶、品項欄位較常換行 |
| 記憶體 gate | reportRenderMemoryTest(-Xmx128m):50,000 列、2,248 頁、約 29 秒完成。GC 後存活量在 render 前即約 87MB(測試 JVM 與字型),50,000 列時約 110MB,每頁約 11KB,與 REP-01 的每頁 12KB 一致;可用 -PreportRows、-PreportHeap 做基準比較 |
| 跨平台 | scripts/report-linux.sh 在 eclipse-temurin:25-jdk(JDK 25.0.4.1)的 arm64 與 amd64(Rosetta 模擬)皆通過;三份 corpus 與 macOS 輸出除 /ID 外位元組相同。記憶體 gate 在兩平台 GC 後存活峰值約 114MB,amd64 因模擬耗時約 247 秒 |
| 測試 | LayoutEngineTest(固定字寬量測器,排版規則與前進保證)、PdfCorpusTest(三份 corpus 的頁數、文字、metadata、subset 字型與 8 張點陣 golden)、PdfDeterminismTest、PdfRenderLifecycleTest、PdfDiagnosticsTest、ReportFontsTest、PdfImagesTest(圖片快取可回收)、PdfSupportTest(版本下限)、PdfBoxAbsentTest、PdfRenderMemoryTest 與 ArchUnit 規則 |