REP-01:報表 corpus 與 renderer spike
狀態:完成;選型已確認並記入 ADR-029。macOS 與 Linux headless(arm64、amd64)皆已重現;CI pipeline 尚未建立 spike 程式碼已移除:REP-03 正式 renderer 以同一組 corpus 通過後,依 FU-100 刪除
src/reportSpike與reportSpikeGradle 設定;corpus、PdfInspection、RasterGolden已移入正式測試。下文的指令與路徑為歷史紀錄, 目前的驗證方式見 REP-03 契約設計(Linux 容器改用scripts/report-linux.sh) 依據:ADR-029、報表產生開發計畫 REP-01 日期:2026-09-29
結論
- Apache PDFBox 3.0.x 可作為 REP-03 的 PDF backend。 三份代表性報表在 PDFBox 上重現了頁數、逐頁文字、 metadata 與點陣基線;中文字型 subset 嵌入、向量圖表與大型明細都可行。
- 代價是排版由 AppFuse 自己負責。 PDFBox 只提供繪圖原語與字寬量測;後備字型、中英混排斷行、避頭尾、 孤行寡行、表格跨頁、重複表頭、群組續接、keep-together 與總頁數,都是 spike renderer 自行實作 (核心約 900 行)。REP-02/03 的工作量主要在這一層,不在 PDF 輸出。
- 字型只能用 TrueType 輪廓。 PDFBox 3.0.8 無法嵌入 CFF 輪廓的 OTF(Noto Sans TC 官方發行版即是); 可變字型只會用預設 instance。框架字型政策必須明訂「靜態 TTF」。
- SVG 圖表的圖內文字會變成向量外框,無法抽取、搜尋或讓螢幕報讀器讀取。
Figure必須帶 alt text 與 實體文字圖說;若產品需要可抽取的圖表文字,REP-04 要另外處理(見「發現的風險」)。 - 記憶體有界但不是常數: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.ttf | notofonts.github.io @ 28b15b4(hinted TTF) | SIL OFL 1.1 |
NotoSansTC-Regular.ttf、NotoSansTC-Bold.ttf | Google Fonts notosanstc/v39 靜態 TrueType instance | SIL 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 的輸出 |
RasterGolden | 72 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–130KB | 7MB 字型不會讓每份 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_BREAK | corpus 初版訂單編號欄過窄時,這個 diagnostic 逐筆攔下了被強制斷開的訂單編號 | 表格欄寬需要最小寬度或自動調整 |
| 段落跨頁、孤行寡行 | ✅ | 定點測試:頁尾容 4 行時 5 行段落切成 3+2;只容 1 行時整段下移 | — |
| 標題與後文同頁 | ✅ | 定點測試 | — |
| keep-together | ✅/KEEP_TOGETHER_TOO_TALL | 放不下時整塊下移;超過整頁時回報後照常跨頁 | soft hint 的降級必須產生 diagnostic |
| 表格跨頁、重複表頭、群組續接 | ✅ | 15 頁明細每頁都有表頭;「門市(續)」緊接表頭 | 群組語意要進 IR,renderer 才能產生續接列 |
| 表格列高於整頁 | ✅ TABLE_ROW_OVERFLOW | 80 行的儲存格被截斷並回報 | 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,000 | 195 | 3.2 s | 67.8 MB | 0.6 MB |
| 20,000 | 777 | 10.1 s | 70.5 MB | 2.3 MB |
| 50,000 | 1,942 | 24.8 s | 89.8 MB | 5.6 MB |
- 以上為
-Xmx256m;-Xmx96m下三種規模也都完成,數值相近。-Xmx64m下 20,000 列時測試 JVM 耗盡記憶體。 - 每頁約增加 12KB 存活物件:頁面物件樹會保留到
save(),這也是回填「共 Y 頁」的前提。 - 約 67MB 的基線包含字型解析、Batik 與測試框架,本次沒有細分各自占比。
- REP-03 要把「峰值記憶體隨頁數線性成長」寫進容量規劃,並以此 benchmark 作為 regression gate 的起點。
發現的風險
- 量測與繪製不一致會讓內容靜默消失。 spike 中 9pt 表格的「可容納行數」因浮點誤差算成 0,整張表只剩 底色沒有文字,而 8.5pt 的表格剛好整除所以正常。REP-03 應該一次排版產生帶尺寸的 box tree,繪製時只讀 box,不重新計算;conformance 測試也要涵蓋多種字級。
- 圖表文字變外框。 Batik 以 glyph vector 繪製文字,
PdfBoxGraphics2D只能把它轉成外框。可行方向: (a) 接受外框,強制 alt text 與實體文字圖說;(b) chart adapter 把文字標籤留在 Figure 外、改用 PDF 文字繪製; (c) 在 SVG 子集中攔截<text>自行輸出。REP-04 需要選定其中之一。 - 窄欄中的單字孤行(例如「大同科技股份有限公/司」)不影響正確性,但影響品質;可在 REP-03 加入 行尾禁則的延伸規則。
- 字型下載來源。 Google Fonts 的靜態 instance 路徑帶版本號但不是 git commit;若來源下架,下載會因雜湊 不符或 404 而明確失敗。正式框架需要自己的字型散佈方式(例如內部 artifact)。
- 跨平台點陣差異(已排除)。 原本擔心 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-graphics2d | 3.0.5(2025-09) | Apache-2.0 | 實測 | 建議採用於 Figure 向量輸出(0.09MB);有圖內文字外框的限制 |
| Apache Batik | 1.19 | Apache-2.0 | 實測 | appfuse-server 本來就相依;SVG 解析與繪製 |
| Apache FOP | 2.11(2025-05) | Apache-2.0 | 文件評估 | 成熟的 XSL-FO 分頁引擎,可作為內部 backend 讓 FO 只存在於 adapter 內。代價是整條 FO 管線與較大相依。若 REP-03 自製排版超出預期(複雜文字、進階表格),這是第一個重新評估的對象 |
| OpenPDF | 3.0.5(2026-05) | LGPL-2.1/MPL-2.0 | 文件評估 | 內建表格跨頁與重複表頭;授權可接受但比 Apache-2.0 多一層檢視;同樣沒有完整 shaping |
| JasperReports | 7.0.8(2026-08) | LGPL-3.0 | 文件評估 | ADR-029 方案 C:可作為未來 adapter,不作 canonical model |
| openhtmltopdf | 1.1.87(2026-09) | LGPL-2.1 | 文件評估 | 以 HTML/CSS 為核心,ADR-029 方案 A 已排除 |
| iText Core | 9.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 決定 |