跳至主要内容

REP-01:報表 corpus 與 renderer spike

狀態:完成;選型已確認並記入 ADR-029。macOS 與 Linux headless(arm64、amd64)皆已重現;CI pipeline 尚未建立 spike 程式碼已移除:REP-03 正式 renderer 以同一組 corpus 通過後,依 FU-100 刪除 src/reportSpike 與 reportSpike Gradle 設定;corpus、PdfInspection、RasterGolden 已移入正式測試。下文的指令與路徑為歷史紀錄, 目前的驗證方式見 REP-03 契約設計(Linux 容器改用 scripts/report-linux.sh) 依據:ADR-029、報表產生開發計畫 REP-01 日期:2026-09-29

結論​

  1. Apache PDFBox 3.0.x 可作為 REP-03 的 PDF backend。 三份代表性報表在 PDFBox 上重現了頁數、逐頁文字、 metadata 與點陣基線;中文字型 subset 嵌入、向量圖表與大型明細都可行。
  2. 代價是排版由 AppFuse 自己負責。 PDFBox 只提供繪圖原語與字寬量測;後備字型、中英混排斷行、避頭尾、 孤行寡行、表格跨頁、重複表頭、群組續接、keep-together 與總頁數,都是 spike renderer 自行實作 (核心約 900 行)。REP-02/03 的工作量主要在這一層,不在 PDF 輸出。
  3. 字型只能用 TrueType 輪廓。 PDFBox 3.0.8 無法嵌入 CFF 輪廓的 OTF(Noto Sans TC 官方發行版即是); 可變字型只會用預設 instance。框架字型政策必須明訂「靜態 TTF」。
  4. SVG 圖表的圖內文字會變成向量外框,無法抽取、搜尋或讓螢幕報讀器讀取。Figure 必須帶 alt text 與 實體文字圖說;若產品需要可抽取的圖表文字,REP-04 要另外處理(見「發現的風險」)。
  5. 記憶體有界但不是常數:GC 後存活量基線約 67MB,之後每頁約增加 12KB;1,942 頁在 96MB heap 下完成。 PDFBox 必須在 save() 時一次寫出整份 PDF,無法真正逐頁串流輸出。

範圍與方法​

  • 實際動手驗證:只驗證 PDFBox(本次範圍決定)。其他候選只做文件評估,見「候選套件評估」。
  • 程式位置:appfuse-server/src/reportSpike/,是獨立的 Gradle source set:不相依 main、不進 jar、不在 check 內,也不影響公開 API 與 CHANGELOG。spike 的文件節點(Block)只為了驗證能力,不是 REP-02 的 ReportDocument 設計。
  • 環境:macOS 26.5.2+Temurin JDK 25.0.1;Linux 以 eclipse-temurin:25-jdk 容器(JDK 25.0.4.1)驗證 arm64 與 amd64(Rosetta 模擬)。共用 Gradle 9.2.1、PDFBox 3.0.8、pdfbox-graphics2d 3.0.5、Batik 1.19。
cd appfuse-server
./gradlew reportSpike # corpus、版面規則、SVG、決定性(39 項)
./gradlew reportSpike -PupdateGolden=true # 重新產生 golden;需人工檢視圖片後才提交
./gradlew reportSpikeBenchmark # 大型明細基準(預設 -Xmx256m)
./gradlew reportSpikeBenchmark -PspikeHeap=96m
scripts/report-spike-linux.sh linux/arm64 # Linux headless 容器;CI 常見的 x86_64 用 linux/amd64

Linux 腳本以唯讀掛載原始碼後複製進容器,不會把 Linux 建置產物寫回本機;輸出放在 build/linux-report-spike/{arch}/。

輸出 PDF、benchmark 表與 golden 差異圖位於 build/reports/report-spike/。

字型​

字型不進 git,由 downloadReportSpikeFonts 從固定 commit/版本路徑下載,驗證 SHA-256 後快取於 ~/.gradle/caches/appfuse-report-spike/fonts。

檔案來源授權
NotoSans-Regular.ttf、NotoSans-Bold.ttfnotofonts.github.io @ 28b15b4(hinted TTF)SIL OFL 1.1
NotoSansTC-Regular.ttf、NotoSansTC-Bold.ttfGoogle Fonts notosanstc/v39 靜態 TrueType instanceSIL OFL 1.1

授權全文(LICENSE-NotoSans.txt、OFL-NotoSansTC.txt)一併下載並驗證雜湊。拉丁字型是主字型, 中文字型是 fallback,藉此驗證逐字選擇字型的能力。

Corpus​

資料為固定檔案(src/reportSpike/resources/corpus/),產生時間固定為 2026-04-01 09:30 +08:00。

報表資料驗證重點基線
1. 多頁明細sales-detail.csv(367 筆、3 門市 × 3 月)門市/月份群組、月小計、門市合計、總計;每頁重複表頭;跨頁群組補「(續)」;群組標題不孤立於頁尾;頁首頁尾與「第 X 頁/共 Y 頁」15 頁;golden p1、p2、p15
2. 中文長文narrative.txt(四節、13 段)+程式產生的示意圖中英混排斷行與避頭尾;拉丁主字型+中文 fallback;圖片與圖說同頁;加框 keep-together 區塊;標題與後文同頁3 頁;golden 全頁
3. 統計圖表monthly-revenue.csv(12 月 × 3 門市)分組長條圖、折線圖、無資料狀態;向量 form 輸出;月報表格與全年合計2 頁;golden 全頁

Test harness​

元件做什麼與 backend 的關係
PdfInspection讀回 PDF:頁數、逐頁文字(依位置排序)、metadata、每頁字型(含 form 內)、是否全嵌入、image/form 數量以 PDFBox 讀取,可檢查任何 backend 的輸出
RasterGolden72 DPI 灰階渲染,與 golden PNG 比對;像素差 > 48 才算不同,不同像素 > 0.4% 即失敗;失敗時留下 actual 與 diff 圖同上
DeterminismTest同一輸入 render 兩次,逐頁文字與 50 DPI 點陣須完全相同驗證 ADR-029 不變量 3

已做負向檢查:把兩頁 golden 對調後比對會失敗,並正確產生 diff 圖。

能力驗證結果​

能力結果證據對 REP-02/03 的意涵
中文字型 subset 嵌入✅所有頁面字型皆為 ABCDEF+ subset 且嵌入;PDF 90–130KB7MB 字型不會讓每份 PDF 膨脹
CFF 輪廓 OTF❌True Type fonts using CFF outlines are not supported字型政策限定靜態 TTF;font registry 載入時就應拒絕 CFF
可變字型⚠️ 只用預設 instance字寬與靜態 Regular 不同不支援以 variation 選字重
後備字型(fallback)✅(renderer 自行實作)同一行切成 NotoSans/NotoSansTC 兩種 run,文字完整抽回PDFBox 沒有 fallback;font registry 要負責逐字選字型
缺字✅ MISSING_GLYPH花🌸店 輸出 花□店 並回報 U+1F338缺字須事先查 cmap,不能交給 PDFBox 處理
中英混排斷行、避頭尾✅(renderer 自行實作)句讀懸掛行尾、開括號移到下一行斷行器屬於 renderer 核心
不可斷長字串✅ FORCED_BREAKcorpus 初版訂單編號欄過窄時,這個 diagnostic 逐筆攔下了被強制斷開的訂單編號表格欄寬需要最小寬度或自動調整
段落跨頁、孤行寡行✅定點測試:頁尾容 4 行時 5 行段落切成 3+2;只容 1 行時整段下移—
標題與後文同頁✅定點測試—
keep-together✅/KEEP_TOGETHER_TOO_TALL放不下時整塊下移;超過整頁時回報後照常跨頁soft hint 的降級必須產生 diagnostic
表格跨頁、重複表頭、群組續接✅15 頁明細每頁都有表頭;「門市(續)」緊接表頭群組語意要進 IR,renderer 才能產生續接列
表格列高於整頁✅ TABLE_ROW_OVERFLOW80 行的儲存格被截斷並回報REP-03 要決定「列內分頁」或維持截斷+diagnostic
頁首頁尾、總頁數✅(兩階段)內容排完後再以 APPEND 模式回填每頁頁尾「共 Y 頁」需要全部頁面保留到結束(見記憶體)
點陣圖片✅LosslessFactory 嵌入;圖與圖說同頁—
SVG 向量圖表✅Batik GVT → PdfBoxGraphics2D → form XObject;統計報表 0 張點陣圖可在 PDF 中保留向量
圖內文字可抽取❌特性測試:圖表標題與圖例無法從 PDF 抽出見「發現的風險」第 2 點
點陣後備✅200 DPI;單張圖文件大小:向量 22.5KB、點陣 31.8KB—
SVG 安全政策✅拒絕 script、事件屬性、外部 href、外部 url()、foreignObject、style、DOCTYPE/XXE、過大尺寸與元素數;Batik 另以 no-load user agent 為第二道防線政策放在 Batik 之前
metadata✅title、author、subject、creator、producer、建立日期、語言 zh-TW—
決定性✅同機兩次輸出的文字與點陣完全相同;位元組只差 trailer /ID需要穩定 ETag 或內容雜湊時,renderer 要自行指定 /ID
跨平台一致性✅macOS、Linux arm64、Linux amd64 的輸出除 /ID 外位元組完全相同,golden 逐像素零差異目前的 golden 可直接作為 CI 基線
逐頁輸出⚠️內容串流可寫入暫存檔(createTempFileOnlyStreamCache),但最終 PDF 在 save() 時一次寫出見「記憶體與效能」
文字 shaping、bidi未驗證corpus 不含阿拉伯文、印度語系與組合字元列為風險;CJK/拉丁報表不依賴

記憶體與效能​

明細列以 iterator 逐列產生,不先展開成 List。「GC 後存活量」每 20ms 取樣各 heap pool 最近一次 GC 後的 用量,近似存活物件的峰值;不採用 pool 的 peak usage,因為它包含尚未回收的垃圾(曾量到大於 -Xmx 的值)。

列數頁數時間GC 後存活峰值PDF 大小
5,0001953.2 s67.8 MB0.6 MB
20,00077710.1 s70.5 MB2.3 MB
50,0001,94224.8 s89.8 MB5.6 MB
  • 以上為 -Xmx256m;-Xmx96m 下三種規模也都完成,數值相近。-Xmx64m 下 20,000 列時測試 JVM 耗盡記憶體。
  • 每頁約增加 12KB 存活物件:頁面物件樹會保留到 save(),這也是回填「共 Y 頁」的前提。
  • 約 67MB 的基線包含字型解析、Batik 與測試框架,本次沒有細分各自占比。
  • REP-03 要把「峰值記憶體隨頁數線性成長」寫進容量規劃,並以此 benchmark 作為 regression gate 的起點。

發現的風險​

  1. 量測與繪製不一致會讓內容靜默消失。 spike 中 9pt 表格的「可容納行數」因浮點誤差算成 0,整張表只剩 底色沒有文字,而 8.5pt 的表格剛好整除所以正常。REP-03 應該一次排版產生帶尺寸的 box tree,繪製時只讀 box,不重新計算;conformance 測試也要涵蓋多種字級。
  2. 圖表文字變外框。 Batik 以 glyph vector 繪製文字,PdfBoxGraphics2D 只能把它轉成外框。可行方向: (a) 接受外框,強制 alt text 與實體文字圖說;(b) chart adapter 把文字標籤留在 Figure 外、改用 PDF 文字繪製; (c) 在 SVG 子集中攔截 <text> 自行輸出。REP-04 需要選定其中之一。
  3. 窄欄中的單字孤行(例如「大同科技股份有限公/司」)不影響正確性,但影響品質;可在 REP-03 加入 行尾禁則的延伸規則。
  4. 字型下載來源。 Google Fonts 的靜態 instance 路徑帶版本號但不是 git commit;若來源下架,下載會因雜湊 不符或 404 而明確失敗。正式框架需要自己的字型散佈方式(例如內部 artifact)。
  5. 跨平台點陣差異(已排除)。 原本擔心 Linux 的 Java2D 反鋸齒或 FreeType 會讓 golden 失效;實測三個平台的 PDF 除 /ID 外位元組相同,連 Batik 經 AWT 取得的圖表文字外框也一致。0.4% 容差目前完全沒有用到,保留作為 JDK 或 PDFBox 升級時的緩衝。

候選套件評估​

版本為 2026-09 時 Maven Central 的最新 release。只有 PDFBox 做了實測,其餘為文件評估。

套件版本授權評估方式判斷
Apache PDFBox(+ fontbox、pdfbox-io)3.0.8(2026-07)Apache-2.0實測建議採用。JDK 25 實測可用;pdfbox 2.07MB+fontbox 1.65MB+pdfbox-io 0.05MB+commons-logging;型別可完全封裝在 renderer 內部
pdfbox-graphics2d3.0.5(2025-09)Apache-2.0實測建議採用於 Figure 向量輸出(0.09MB);有圖內文字外框的限制
Apache Batik1.19Apache-2.0實測appfuse-server 本來就相依;SVG 解析與繪製
Apache FOP2.11(2025-05)Apache-2.0文件評估成熟的 XSL-FO 分頁引擎,可作為內部 backend 讓 FO 只存在於 adapter 內。代價是整條 FO 管線與較大相依。若 REP-03 自製排版超出預期(複雜文字、進階表格),這是第一個重新評估的對象
OpenPDF3.0.5(2026-05)LGPL-2.1/MPL-2.0文件評估內建表格跨頁與重複表頭;授權可接受但比 Apache-2.0 多一層檢視;同樣沒有完整 shaping
JasperReports7.0.8(2026-08)LGPL-3.0文件評估ADR-029 方案 C:可作為未來 adapter,不作 canonical model
openhtmltopdf1.1.87(2026-09)LGPL-2.1文件評估以 HTML/CSS 為核心,ADR-029 方案 A 已排除
iText Core9.7.1(2026-07)AGPL-3.0/商業授權文件評估授權不適合框架散佈,排除

選型建議​

(2026-09-29 已由框架維護者確認採用,見 ADR-029 實作註記。)

REP-03 採用 PDFBox 3.0.x+AppFuse 自有排版層,Figure 向量輸出採用 pdfbox-graphics2d 與 Batik。所有 PDFBox 型別封裝在 renderer 內部,符合 ADR-029「公開 API 不出現 renderer vendor 型別」。本建議的前提是 FOP 未實測; 若 REP-03 發現自製排版的範圍明顯膨脹,應先以同一組 corpus 與 harness 實測 FOP,再決定是否更換。

REP-01 完成條件對照​

完成條件狀態
至少一個 backend 能在 CI 環境重現三份 corpus 的基線✅ Linux headless 容器(arm64、amd64)重現,與 macOS 位元組一致(除 /ID);repo 尚無 CI pipeline,接入時直接執行 scripts/report-spike-linux.sh 或 ./gradlew reportSpike
選型結果記錄為 ADR-029 的 implementation note 或另立技術選型 ADR✅ 2026-09-29 由框架維護者確認 PDFBox+自有排版層,記入 ADR-029 實作註記
所有基線字型具明確授權,CI 與正式環境可重現✅ 授權為 OFL 1.1、固定來源+SHA-256;正式環境的字型散佈方式待 REP-03 決定