跳至主要内容

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 /IDReportArtifact、儲存、下載與 ETag(REP-05)
排版:斷行、分頁、段落、標題、表格(跨頁、重複表頭、群組續接、列內分頁)、圖片、keep hint、頁首頁尾與頁碼SVG Figure 的實際繪製與安全政策(REP-04,見 FU-105)
字型嵌入、中英 fallback、缺字處理文字 shaping、bidi、直排
PDF metadata、頁數、diagnosticsPDF/A、tagged PDF、書籤(outline)、加密與簽章
列來源的開啟與關閉、讀取時驗證、失敗不留產物非同步 job、重試、保存期限(應用擁有)
corpus 基線、golden、記憶體 regression gate選頁 subset 與列印(REP-08)
完成後依 FU-100 移除 REP-01 spike 程式碼—

設計原則​

  1. 先排版、後繪製,量測只做一次。 排版引擎產生每頁的定位 box(文字行、矩形、線、圖片),繪製器只讀 box、不重新量測。 這是 REP-01 spike「量測與繪製不一致讓整列文字消失」的直接對策。
  2. 逐頁輸出。 一頁排完即繪製並釋放該頁的 box;大型表格的列讀一筆排一筆,不展開整張表。 唯一保留到最後的是 PDF 的頁面物件(回填「共 Y 頁」所需,REP-01 實測每頁約 12KB)。
  3. 失敗不留產物。 完整 PDF 先序列化到 renderer 自己的暫存檔,之後才開始寫入 out;在第一個位元組寫入前發生的任何 ERROR、取消或例外,都不會寫出任何位元組。開始寫出後的失敗見「輸出語意」。
  4. 降級必須可見。 soft hint 做不到、字型缺字、圖片縮放等一律產生 diagnostic,隨結果回傳;不得靜默。
  5. vendor 型別只在 report.internal.pdf。 公開 API 不出現 PDFBox/fontbox 型別;既有 ArchUnit 規則已涵蓋。
  6. 決定性。 相同文件、字型與 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 為界:

  1. 產生階段:排版、繪製,並把完整 PDF 序列化到 renderer 自己的暫存檔。所有列來源在此階段讀完並關閉。
  2. 寫出階段:把暫存檔依序複製到 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 的頁首頁尾延到全部頁面完成後回填
SectionpageBreakBefore 且不在頁首時換頁;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)
圖說圖片下方置中,與圖片同頁
FigureREP-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-familyWARNING使用未登錄的字型家族,改用預設家族
report.font.face-substitutedINFO缺少粗體或斜體字面,以其他字面代替
report.font.missing-glyphWARNING所有 fallback 都缺字,以 □ 替代
report.layout.forced-breakWARNING不可斷的長字串被強制斷開
report.layout.keep-with-next-brokenWARNING無法與下一個區塊或下一列同頁
report.layout.widow-orphan-brokenWARNING無法同時滿足孤行寡行設定,在可放的最多行處分割
report.layout.keep-together-too-tallWARNING區塊高於整頁,照常跨頁
report.layout.region-truncatedWARNING頁首頁尾的某一行超出該格寬度
report.layout.figure-scaledWARNING圖片或 Figure 等比縮小以放入頁面
report.layout.row-truncatedERROR不可分割的列高於整頁
report.layout.no-spaceERROR重複表頭等佔用後,連圖說都放不下
report.layout.header-too-tallERROR重複表頭本身高於頁面內容區
report.layout.page-regions-too-tallERROR頁首頁尾佔去整個內容區
report.layout.no-progressERROR換頁後仍放不下任何最小單位(見「前進保證」)
report.image.unsupportedERROR圖片格式不支援或無法解碼
report.figure.not-renderedWARNINGREP-04 之前 Figure 以替代文字框呈現(依「已裁決的決策」第 2 項);REP-04 起不再產生
report.resource.cleanup-failedWARNING成功寫出後刪除暫存檔失敗;輸出完整有效

同一代碼、同一 location 只回報一次;missing-glyph 以 code point 去重。

相依與封裝​

  • PDFBox(org.apache.pdfbox:pdfbox 3.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 輪)由審閱者裁決,皆採作者方案。

#議題裁決捨棄的方案與理由
1PDFBox 相依範圍compileOnly,使用報表的應用自行加入 org.apache.pdfbox:pdfbox 3.0.x;PdfReportRenderer.create 在缺少時丟出指引明確的例外。與 S3、Azure、SFTP SDK 一致。程式碼審閱時下限提高至已驗證的 3.0.8,於執行期檢查(見「相依與封裝」)implementation:所有下游都會帶入約 3.8MB,未使用報表的應用也一樣
2REP-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:失去作者要求的強調
6PDF 書籤(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,並加入每列一張不同圖片的案例
4PDFBox 版本下限提高至已驗證的 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 寬鬆,客戶、品項欄位較常換行
記憶體 gatereportRenderMemoryTest(-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 規則