REP-02:ReportDocument 核心契約設計
狀態:已通過設計審閱(3 輪)與程式碼審閱(2 輪),2026-09-29 實作完成;本文列出的測試已實作 依據:ADR-029、報表產生開發計畫 REP-02、REP-01 spike 日期:2026-09-29
後續 opt-in 完整表頭、逐列樣式、內容末端頁摘要與每頁邏輯紀錄上限見流式版面擴充。 該擴充經三輪審閱,於2026-10-04隨 appfuse-server 4.0.0-alpha.23 發布;以下保留 alpha.22 的基線契約,新增 API 與相容性界線由擴充文件承接。
分節每頁頁首尾與逐節頁碼的opt-in擴充(alpha.28)見 分節頁首尾指南;本文的原v1固定區域與全文頁碼語意仍適用。
範圍
REP-02 定義與 renderer 無關的報表文件模型與建構方式:
| 納入 | 不納入(所屬工作包) |
|---|---|
ReportTemplate<T>、ReportContext、ReportDocument | ReportRenderer、字型登錄、排版與 PDF 輸出(REP-03) |
| 區塊與行內節點、樣式、量測單位 | ChartSpec -> Figure adapter、SVG 政策實作(REP-04) |
| 頁面設定、頁首頁尾、分頁與 keep hint | ReportArtifact、儲存、授權下載、/ID 與 ETag(REP-05) |
| 大型明細的列來源(row source)與其生命週期 | 非同步 job、進度、重試(應用擁有) |
| 驗證規則與 diagnostic 模型 | JSON/YAML 序列化 schema(只評估可行性,不承諾) |
| 公開 API 不含 vendor 型別的架構測試 | — |
REP-01 spike 的 Block 只為驗證 PDFBox 能力,不是本設計的起點;本文件以 spike 暴露的需求為輸入重新設計。
設計原則
- 資料綁定後的樹:
ReportDocument是「資料已套入、尚未分頁」的結果。IR 內只有值,沒有運算式、callback 或查詢; 唯一的例外是大型明細的列來源(見「列來源」)。 - 不可變:節點與樣式都是 record 或 final class,集合以
List.copyOf保存。同一個樣式物件可安全地在多處重用。 - 意圖而非座標:IR 表達段落、表格、群組、分頁意圖;絕對座標只存在
Figure的內容內(ADR-029 決策 2)。 - 不外露 vendor 型別:公開 API 只用 JDK 與 appfuse 自有型別。PDFBox、Batik、graphics2d 只能出現在
io.leandev.appfuse.report.internal..。 - 降級必須可見:soft hint 做不到時,renderer 回報 diagnostic;互相矛盾或無界的模型在建構時就被拒絕。
- 沿用框架既有 primitive:長度用
io.leandev.appfuse.measure.Length、紙張用Paper;不另造單位型別。
套件配置
| 套件 | 內容 | 公開性 |
|---|---|---|
io.leandev.appfuse.report | ReportTemplate、ReportContext、ReportDocument 與其 builder、ReportMetadata、PageSetup、Margins、PageRegion、GroupedRows、ReportDiagnostic、ReportValidationException | 公開 |
io.leandev.appfuse.report.node | 區塊與行內節點(sealed)、表格欄列、RowSource、RowCursor、樣式 record 與 DocumentStyles、ReportColor | 公開;不相依其他報表 package |
io.leandev.appfuse.report.internal | 驗證實作、之後的 renderer 與 vendor adapter | 內部(不觸發 CHANGELOG) |
REP-02 不新增任何第三方相依;PDFBox 等相依在 REP-03 才加入,且只能被 internal 使用。
RowSource/RowCursor 放在 report.node,因為 Table 節點需要引用它們;若放在 report,兩個公開 package 會互相依賴。
公開 package 以 @ExperimentalApi(io.leandev.appfuse.annotation)標示為實驗性。
核心型別
/// 把應用的查詢結果轉成報表文件;實作應無副作用、可重複呼叫
@FunctionalInterface
public interface ReportTemplate<T> {
ReportDocument create(T data, ReportContext context);
}
/// 產生報表時的環境;時間與時區一律取自 clock(業務時區,見 m-date-time)
public record ReportContext(Locale locale, Clock clock) {
public static ReportContext of(Locale locale, Clock clock) { ... }
public ZoneId zone() { return clock.getZone(); }
}
ReportDocument 是不可變的 final class,只能經 builder 建立:
public final class ReportDocument {
public static Builder builder() { ... }
public ReportMetadata metadata() { ... }
public PageSetup page() { ... }
public Optional<PageRegion> header() { ... }
public Optional<PageRegion> footer() { ... }
public List<Block> body() { ... }
}
public record ReportMetadata(String title, String author, String subject,
List<String> keywords, Locale language, Instant createdAt) { }
public record PageSetup(Length width, Length height, Margins margins) {
public static PageSetup of(Paper paper) { ... } // 預設邊界 20mm
public PageSetup landscape() { ... }
public PageSetup margins(Length top, Length right, Length bottom, Length left) { ... }
}
build() 執行建構時驗證;任何 ERROR 都丟出 ReportValidationException(帶完整 diagnostic 清單),
不會產生半成品文件。延遲開啟的列來源無法在建構時檢查,改由 renderer 在讀取時逐列驗證(見「驗證的兩個階段」)。
節點模型
區塊(Block,sealed)
| 節點 | 欄位 | 說明 |
|---|---|---|
Section | id、title(選填,供未來 PDF 書籤)、blocks、pageBreakBefore、summaryLastPage(選填,自 4.0.0-alpha.26) | 邏輯分節;不產生可見內容;最後內容頁摘要覆寫見流式版面擴充,由 4.0.0-alpha.26 提供 |
Heading | id、level(1–4)、inlines、style | 預設 keep-with-next |
Paragraph | id、inlines、style | 可跨頁;孤行寡行由 ParagraphStyle 控制 |
Table | id、columns、headerRows、rows、style、repeatHeader | 列可來自記憶體清單或 RowSource |
Image | id、data、width、height(選填,缺一則等比)、altText、caption | 點陣圖;與圖說同頁 |
Figure | id、content、width、height、altText(必填)、caption(必填) | 有界的局部座標圖形;內容由 REP-04 定義。圖內文字是向量外框(REP-01),因此替代文字與可抽取的文字圖說都必填。REP-04 改以自寫受限 SVG 子集繪製後,SVG 內文字已可抽取;點陣內容仍不可,兩者維持必填(見 REP-04 契約) |
KeepTogether | id、blocks、box(選填框線與底色) | 盡量不跨頁的區塊 |
PageBreak | — | 硬性換頁 |
Spacer | height | 垂直間距 |
id 皆為選填字串,用於 diagnostic 定位與結構測試;同一文件內不可重複。
行內(Inline,sealed)
| 節點 | 說明 | 可用位置 |
|---|---|---|
Text | 文字與選填的 TextStyle 覆寫 | 任何位置 |
LineBreak | 強制換行 | 任何位置 |
PageNumber | 目前頁碼 | 僅頁首頁尾 |
PageCount | 總頁數 | 僅頁首頁尾 |
頁首頁尾(PageRegion)有 left、center、right 三格,每格是行內節點清單,並可選擇是否畫分隔線。
使用 PageCount 表示 renderer 必須保留全部頁面直到結束(REP-01 實測:每頁約 12KB 存活記憶體)。
表格
public record Column(String id, List<Inline> title, float weight, Align align, Length minWidth) { }
public record TableRow(List<Cell> cells, RowRole role, List<String> groupPath,
boolean keepWithNext, boolean splittable) { }
public record Cell(List<Block> blocks, Align align, VerticalAlign verticalAlign, int colSpan) {
public static Cell of(String text) { ... } // 單一 Paragraph
public static Cell of(Inline... inlines) { ... } // 單一 Paragraph
}
public enum RowRole { DETAIL, GROUP_HEADER, GROUP_FOOTER, TOTAL }
groupPath表示列所屬的群組階層(例如["台北信義門市", "2026-01"])。renderer 依此在跨頁時補「續接列」, 並讓GROUP_HEADER不孤立於頁尾。續接列的文字格式由TableStyle.continuationLabel指定(預設"{group}(續)")。- 群組與彙總由樣板計算,IR 只記錄結果與角色(見「已裁決的決策」第 1 項)。框架提供非 IR 的輔助工具
GroupedRows,契約見下節。 splittable預設true:單列高於整頁時,renderer 在列內逐行分頁,而不是截斷(見「已裁決的決策」第 4 項)。- 儲存格內容是受限的區塊清單,只允許
Paragraph、Image、Figure、Spacer;其餘區塊(Table、Heading、KeepTogether、PageBreak、Section)在儲存格內為 ERROR。這讓 REP-04「Figure 可放在 table cell」的完成條件 不必再改公開 API。列內分頁時,段落可逐行分割,Image/Figure則不可分割、整個移到下一頁; 移頁後仍放不下時的處置見「不可分割內容的超頁處置」。 Cell.align是儲存格內段落的預設對齊,段落自身的ParagraphStyle.align優先。
GroupedRows:串流分組與彙總
GroupedRows 把已排序的領域資料串流轉成帶群組角色的 TableRow 串流。它是可重用的定義:先宣告群組鍵、
彙總器與各列的輸出方式,再對每次 render 的資料套用。
var rows = GroupedRows.<Sale>define(7); // 表格欄數:一格的列跨全部欄
var amount = rows.aggregate(Collectors.summingLong(Sale::amount)); // 事先註冊,取得有型別的 handle
var count = rows.aggregate(Collectors.counting());
rows.groupBy(Sale::store, store -> List.of("門市:" + store)) // 鍵需可比較,或另給 Comparator
.groupBy(Sale::month)
.detail(sale -> List.of(sale.orderNo(), money(sale.amount())))
.footer((group, totals) -> List.of(group.key() + " 小計(" + totals.get(count) + " 筆)", money(totals.get(amount))))
.grandTotal(totals -> List.of("總計", money(totals.get(amount))));
Stream<TableRow> tableRows = rows.apply(salesStream); // 每次 render 呼叫一次
| 規則 | 內容 |
|---|---|
| 彙總器事先註冊 | 彙總器是 JDK Collector,在 define() 階段註冊並取得 Aggregate<R> handle;每筆資料在讀到時立即累加。footer/grandTotal 只讀取彙總結果(totals.get(handle)),拿不到原始資料列 |
| 記憶體上限 | 同時存在的狀態只有「目前開啟的各層群組 × 已註冊彙總器的累加狀態」加上一筆預看資料,與列數無關;明細列讀一筆輸出一筆,不保留 |
| 彙總器的限制 | 上限的前提是 Collector 的累加狀態有界(例如加總、計數、最大最小)。會保留元素的 collector(toList()、groupingBy(...))會破壞上限;文件明確列為不支援的用法,框架不做執行期偵測 |
| 排序檢查 | 群組鍵以 Comparable 或指定的 Comparator 比較;同層鍵比前一個群組小時立即丟出 IllegalStateException(未排序的輸入會產生錯誤小計,不得靜默)。檢查只需保留前一個鍵 |
| 輸出角色 | 自動產生 GROUP_HEADER、DETAIL、GROUP_FOOTER、TOTAL 與對應的 groupPath,且保證符合列驗證規則 |
樣式
樣式分兩層:節點上攜帶的是覆寫值,排版時使用的是解析後的完整樣式。
| 覆寫 record | 欄位(皆可為 null=繼承) |
|---|---|
TextStyle | fontFamily(邏輯名稱)、size(Length)、weight(FontWeight.REGULAR/BOLD)、italic(Boolean)、color |
ParagraphStyle | text(TextStyle)、align、lineHeight(倍數,Double)、spaceBefore、spaceAfter、firstLineIndent、keepWithNext(Boolean)、orphans、widows(Integer) |
TableStyle | text、headerFill、groupFill、totalFill、rule、cellPadding、continuationLabel |
BoxStyle | border、fill、padding |
ReportColor | RGB 值;ReportColor.rgb(0x1f2328)(不可為 null 的值型別) |
- 「未指定」一律以
null表示,繼承上一層的值;Boolean.FALSE、FontWeight.REGULAR等是明確覆寫,會蓋掉上一層。 覆寫 record 以TextStyle.inherit()起始,搭配withWeight(...)、withItalic(false)等方法產生新物件。 覆寫 record 本身沒有預設值,避免「只想改粗體卻把字級也蓋回預設」。 - 完整樣式由
DocumentStyles提供:每個欄位都有值,框架基準為DocumentStyles.standard()。 文件以ReportDocument.builder().styles(s -> s.text(...).paragraph(...).heading(1, ...).table(...))在基準上修改; builder 驗證修改後每個欄位仍有值且合法。 - 合併順序(後者覆寫前者,只覆寫非
null欄位):- 內文:
DocumentStyles的段落預設 → 標題層級預設(僅Heading)→ 節點ParagraphStyle→ 行內Text的TextStyle - 表格:
DocumentStyles的表格預設 →Table.style→ 列角色預設(表頭、群組標題、合計預設粗體)→ 儲存格內段落的ParagraphStyle→ 行內TextStyle - 巢狀的
TextStyle(例如ParagraphStyle.text內)逐欄位合併,不整個替換。
- 內文:
- 解析演算法實作於
report.internal,但規則是公開契約;REP-02 以測試逐條鎖定(見「驗收方式」)。 fontFamily只是邏輯名稱(例如"sans"、"serif"或自訂名稱)。對應到哪些字型檔、fallback 順序,由 REP-03 renderer 的字型登錄決定;IR 不含字型檔路徑。
列來源(RowSource)
大型明細不能先展開成記憶體中的完整清單,因此 Table.rows 可接受列來源:
/// 開啟列游標;renderer 每次 render 呼叫一次,並負責關閉
@FunctionalInterface
public interface RowSource {
RowCursor open();
/// 記憶體清單,可重複開啟
static RowSource of(List<TableRow> rows) { ... }
/// 延遲取得串流:每次 open 才呼叫 supplier,游標關閉時關閉該串流
static RowSource lazy(Supplier<? extends Stream<TableRow>> rows) { ... }
/// 不開啟游標即可取得的全部列;只有記憶體來源有值,供建構時驗證使用
default Optional<List<TableRow>> materializedRows() { return Optional.empty(); }
}
/// 單次、只能往前讀的游標;close 不丟 checked exception
public interface RowCursor extends AutoCloseable {
boolean hasNext();
TableRow next();
@Override void close();
}
生命週期與所有權:
| 規則 | 內容 |
|---|---|
| 開啟前不持有資源 | RowSource 在 open() 之前不得持有任何需要釋放的資源。框架不提供接收已開啟 Stream/Iterator 的工廠;自訂實作也必須遵守。因此建構失敗、文件從未 render、或 render 在該表格之前就失敗時,沒有東西需要釋放 |
| 誰開啟 | renderer 在排到該表格時才呼叫 open(),不提前開啟 |
| 誰關閉 | renderer 在成功、失敗與取消時都必須關閉(try-with-resources);關閉後不得再讀 |
| 重複 render | 文件本身可重複 render;每次 render 都重新 open()。of 重讀同一份清單,lazy 重新呼叫 supplier(例如重新查詢) |
| 交易與連線 | 若資料來自 JPA/JDBC 串流,交易必須涵蓋整個 render;這是應用的協調責任,框架只在指南說明 |
| 量測限制 | 含 RowSource 的表格無法事先量測,因此不得放在 KeepTogether 內(驗證時拒絕) |
驗證與 diagnostic
public record ReportDiagnostic(Severity severity, String code, String message, String location) {
public enum Severity { INFO, WARNING, ERROR }
}
code以點分隔的命名空間表示來源,例如report.model.table.column-mismatch、report.layout.keep-together-too-tall、report.font.missing-glyph。location是節點路徑,例如body/2[table:orders]/row/1523;有id時一併顯示。- 建構時與讀取時的模型驗證產生
report.model.*;排版期間的降級由 renderer 產生report.layout.*、report.font.*、report.figure.*,並隨 render 結果回傳(REP-03/05 的 artifact 契約)。
驗證的兩個階段
| 階段 | 時機 | 範圍 | 失敗時 |
|---|---|---|---|
| 建構時 | build() | 文件結構、樣式、欄定義,以及 RowSource.of(List) 的全部列(資料已在記憶體,不必延後) | 丟出 ReportValidationException,不產生文件 |
| 讀取時 | renderer 從 RowCursor 取得每一列時 | 延遲來源(lazy 或自訂實作)的每一列,套用與建構時同一套列規則,唯一例外是不檢查節點 id 重複(見下);群組結構以串流方式檢查(只保留目前群組路徑,記憶體不隨列數成長) | 見下 |
讀取時驗證失敗的處置:
- renderer 立即停止讀取並關閉
RowCursor(與其他失敗相同的finally路徑)。關閉本身失敗時,關閉的例外附在驗證例外的 suppressed 中,不得蓋掉 diagnostic。 - 不產生任何產物:已寫出的暫存內容一律丟棄,不回傳部分 PDF。
- 丟出
ReportValidationException,diagnostic 的location指到該列(例如body/2[table:orders]/row/1523); 同一次 render 已收集的其他 diagnostic 一併附上。 - 列規則由
report.internal的單一驗證器實作,建構時與讀取時共用,避免兩處判斷不一致。GroupedRows產生的列依建構方式必然合法,但仍經過同一驗證。
硬性驗證規則(ERROR)
文件與節點(建構時):
| 規則 | 理由 |
|---|---|
PageNumber/PageCount 出現在頁首頁尾以外 | 內文不存在「目前頁」的穩定語意 |
| 紙張寬高 ≤ 0;任一邊界 < 0;扣除邊界後的可用寬或高 ≤ 0 | 沒有可排版的區域 |
任何 Length 或倍數不是有限值(NaN、無限大) | 無法計算 |
欄寬權重不是有限正值;minWidth < 0;全部欄的 minWidth 總和 > 可用寬度 | 欄寬無法分配 |
Image、Figure 的寬高 ≤ 0;Spacer.height < 0;字級 ≤ 0;行高倍數 ≤ 0;orphans/widows < 1 | 無意義的尺寸 |
KeepTogether 內含 PageBreak、pageBreakBefore 的 Section(含巢狀)、RowSource 表格或另一個 KeepTogether | 互相矛盾或無法量測 |
儲存格內出現 Paragraph、Image、Figure、Spacer 以外的區塊 | 儲存格內容模型的限制 |
Heading.level 不在 1–4 | 超出支援範圍 |
Image.altText、Figure.altText 空白;Figure.caption 缺少或不含非空白文字 | REP-01:圖內文字無法抽取;ADR-029 實作註記要求替代文字與實體文字圖說 |
DocumentStyles 修改後有欄位為 null 或不合法 | 完整樣式必須每欄有值 |
節點 id 重複(延遲列來源的列除外) | diagnostic 定位與測試會失準。延遲列在讀取時不檢查,因為需要記住每一列的 id,記憶體會隨列數成長;記憶體中的列在建構時照常檢查 |
表格列(建構時檢查記憶體中的列,讀取時檢查延遲來源的列):
| 規則 | 理由 |
|---|---|
任一儲存格 colSpan < 1 | 例如兩欄表的 [0, 2] 雖然總和正確,但無法排版 |
一列的 colSpan 總和 ≠ 欄數 | 版面無法定義 |
groupPath 深度與 GROUP_HEADER 巢狀不一致(例如未開啟上層群組就進入下層) | 續接列無法正確產生 |
| 儲存格內的區塊違反上表的文件與節點規則 | 與建構時一致;唯一例外是讀取時不檢查節點 id 重複 |
由 renderer 處理的 soft hint
keepWithNext、KeepTogether、orphans/widows、GROUP_HEADER 不孤立、repeatHeader。做不到時產生
WARNING,並照常輸出(例如 KeepTogether 高於整頁時跨頁)。
不可分割內容的超頁處置
Image 與 Figure(連同圖說)不可分割。它們先依可用寬度等比縮放;若移到新頁頂端後仍高於可用高度,依下表處置。
排版實作屬 REP-03(Image)與 REP-04(Figure),但規則與 diagnostic 在此定為契約。
可用高度的計算:
| 位置 | 可用高度 |
|---|---|
| 文件流中 | 頁面內容區高度(扣除邊界與頁首頁尾)−(所在 KeepTogether 框的上下留白) |
| 表格儲存格內 | 頁面內容區高度 − 每頁重複的表頭列 − 群組續接列 − 儲存格上下留白 − 同一儲存格內排在它之前、必須同頁的內容 |
| 情況 | 處置 | diagnostic |
|---|---|---|
| 圖形+圖說高於可用高度,但圖說本身放得下 | 圖形等比縮小到圖形+圖說恰好填滿可用高度;圖說不縮放 | WARNING report.layout.figure-scaled,含節點位置與縮放比例 |
| 連圖說都放不下(例如重複表頭已佔去大半頁) | 停止 render,不產生產物;與讀取時驗證失敗的處置相同 | ERROR report.layout.no-space,含位置、所需與可用高度 |
| 重複的表頭列本身高於頁面內容區 | 停止 render,不產生產物 | ERROR report.layout.header-too-tall |
縮放不設下限:任何縮放都會以 WARNING 揭露,由應用決定是否調整版面。
建構範例
以下為示意,對應 REP-01 的多頁明細報表:
// data.sales() 是 Supplier<Stream<Sale>>:render 排到表格時才執行查詢
// SALES_ROWS 是「GroupedRows:串流分組與彙總」一節定義的 GroupedRows<Sale>
class SalesDetailTemplate implements ReportTemplate<SalesReportData> {
@Override
public ReportDocument create(SalesReportData data, ReportContext ctx) {
var printed = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").format(ZonedDateTime.now(ctx.clock()));
return ReportDocument.builder()
.metadata(m -> m.title("門市銷售明細報表").author("營運部")
.language(ctx.locale()).createdAt(ctx.clock().instant()))
.page(PageSetup.of(Paper.A4))
.header(h -> h.left(Text.bold("門市銷售明細報表")).right(Text.of("列印時間 " + printed)).rule(true))
.footer(f -> f.right(Text.of("第 "), new PageNumber(), Text.of(" 頁/共 "), new PageCount(), Text.of(" 頁")))
.heading(1, "2026 年第一季門市銷售明細")
.paragraph("資料範圍:2026-01-01 至 2026-03-31;金額單位為新台幣元(含稅)。")
.table(t -> t.id("orders")
.column("orderNo", "訂單編號", 1.75f)
.column("amount", "金額", 0.95f, Align.RIGHT)
.repeatHeader(true)
.rows(RowSource.lazy(() -> SALES_ROWS.apply(data.sales().get()))))
.build();
}
}
三份 REP-01 報表都能以上述節點表達:
| 報表 | 使用的節點 |
|---|---|
| 多頁明細 | Heading、Paragraph、Table+RowSource+GroupedRows、頁首頁尾含 PageCount |
| 中文長文 | Heading、Paragraph、Image(含圖說)、KeepTogether(BoxStyle) |
| 統計圖表 | Figure(SVG 內容)、Table(TOTAL 列)、Heading |
驗收方式(對應計畫的完成條件)
| 完成條件 | 驗證方式 |
|---|---|
| 公開 API 無 PDF/chart vendor 型別 | ArchUnit:io.leandev.appfuse.report.. 中不在 ..internal.. 的類別,不得相依 org.apache.pdfbox..、org.apache.fontbox..、de.rototor..、org.apache.batik.. |
| builder 可建立三份 corpus 的完整文件樹 | 以 REP-01 的 corpus 資料建立三棵樹,做結構測試(節點種類、順序、列數、群組路徑、彙總值) |
| 文件與樣式不可變、樣式重用不產生共享狀態 | 測試修改來源集合後文件不變;同一樣式物件用於多節點後,修改衍生樣式不影響其他節點 |
| row source 不會被無意重複消費或遺漏關閉 | lazy 在 open() 前不呼叫 supplier;以測試用 renderer 模擬成功、失敗、中途取消與讀取時驗證失敗,驗證游標與串流皆關閉;建構失敗與未 render 的文件不開啟任何來源 |
| 矛盾或無界的模型被拒絕 | 逐條測試「硬性驗證規則」兩張表的每一條;列規則分別以記憶體列(建構時)與延遲來源(讀取時)各測一次,確認結果相同 |
| 讀取時驗證失敗不留下產物 | 測試用 renderer 在第 N 列遇到非法列時丟出例外、location 指向該列、沒有回傳任何輸出 |
| 樣式解析符合合併順序 | 逐條測試合併順序,包含「只覆寫粗體不影響字級與顏色」與「明確 false 覆寫上一層 true」 |
GroupedRows 記憶體有界、彙總正確 | 單一群組 100 萬筆、每筆帶約 10KB 資料的產生式串流(總量遠超測試 heap)能完整跑完,證明不保留原始列;與 REP-01 corpus 的小計、合計逐一比對;未排序輸入丟出例外 |
| 不可分割內容的超頁處置(交付 REP-03/04 實作與測試) | 以測試 renderer 驗證:①文件流中的 Figure 高於整頁 → 縮放並 figure-scaled;②儲存格內的 Figure 在扣除重複表頭後才放不下 → 縮放並以扣除後的可用高度計算;③重複表頭使圖說也放不下 → no-space 且無產物;④表頭列高於整頁 → header-too-tall |
已裁決的決策
以下 8 項於設計審閱第 3 輪(2026-09-29)由審閱者裁決,皆採作者方案。
| # | 議題 | 裁決 | 捨棄的方案與理由 |
|---|---|---|---|
| 1 | 群組與彙總放在哪一層 | 樣板計算,IR 只記錄列角色與 groupPath;框架提供 GroupedRows | IR 宣告分組鍵與彙總函式、由 renderer 計算:IR 會含函式,無法序列化,也更難做結構測試 |
| 2 | 頁面層級彙總(本頁小計、承上頁、轉下頁) | v1 不納入。產品確有需要時,再新增宣告式 PageAggregate(指定欄位與加總方式)由 renderer 計算 | 這類數字只有排版後才知道,樣板算不出來;在沒有實際需求前不擴大 v1 |
| 3 | RowSource 生命週期 | 開啟前不持有資源(of/lazy);renderer 每次 render 開啟並負責關閉;文件可重複 render | 把游標放進文件、只能 render 一次:較簡單,但失敗後無法重試,且未消費的資源沒有擁有者 |
| 4 | 單列高於整頁 | 預設列內逐行分頁;splittable=false 時仍放不下才截斷並回報 ERROR;不可分割的圖形依「不可分割內容的超頁處置」 | 一律截斷(REP-01 spike 的做法):等於遺失資料 |
| 5 | 實驗性 API 的標示 | 新增 @ExperimentalApi 註解,可被 ArchUnit 與 /upgrade-appfuse-server 辨識。它是框架層的新機制,細節與相關規則的更新見 follow-up FU-101 | 只在 package 文件與 CHANGELOG 註明:工具無法辨識;report.experimental 套件:轉為穩定時要改套件名,對下游是破壞性變更 |
| 6 | 顏色型別 | 自有 ReportColor | java.awt.Color:公開 API 會暗示 AWT 繪圖語意 |
| 7 | ReportContext 內容 | 只有 Locale 與 Clock;需要翻譯字串時,由應用把框架 I18n(或其他翻譯來源)注入樣板 | 把 I18n 放進 context:綁定特定翻譯機制,也讓不需翻譯的報表多一個依賴 |
| 8 | 頁首頁尾變化 | v1 全部頁面共用同一組頁首頁尾 | 首頁不同、奇偶頁不同:延後到有實際需求時 |
與 REP-01 spike 的差異
| spike | 本設計 | 原因 |
|---|---|---|
| 樣式是節點上的零散欄位(字級、粗體) | 獨立的樣式 record+文件預設值 | 重用與不可變 |
| 表格儲存格只有純文字 | Cell 含受限的區塊清單、對齊與跨欄 | 粗體合計、跨欄群組標題、儲存格內圖形 |
| 群組標題以單一儲存格表示跨欄 | colSpan | 語意明確,可驗證 |
| 頁首頁尾與頁碼格式寫死在 renderer | PageRegion+PageNumber/PageCount | 由樣板決定內容 |
列來源是 Iterable,關閉責任不明 | RowSource/RowCursor,renderer 負責關閉 | 大型明細與資源安全 |
| 超高列截斷 | 預設列內分頁 | 不遺失資料 |