跳至主要内容

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、ReportDocumentReportRenderer、字型登錄、排版與 PDF 輸出(REP-03)
區塊與行內節點、樣式、量測單位ChartSpec -> Figure adapter、SVG 政策實作(REP-04)
頁面設定、頁首頁尾、分頁與 keep hintReportArtifact、儲存、授權下載、/ID 與 ETag(REP-05)
大型明細的列來源(row source)與其生命週期非同步 job、進度、重試(應用擁有)
驗證規則與 diagnostic 模型JSON/YAML 序列化 schema(只評估可行性,不承諾)
公開 API 不含 vendor 型別的架構測試—

REP-01 spike 的 Block 只為驗證 PDFBox 能力,不是本設計的起點;本文件以 spike 暴露的需求為輸入重新設計。

設計原則​

  1. 資料綁定後的樹:ReportDocument 是「資料已套入、尚未分頁」的結果。IR 內只有值,沒有運算式、callback 或查詢; 唯一的例外是大型明細的列來源(見「列來源」)。
  2. 不可變:節點與樣式都是 record 或 final class,集合以 List.copyOf 保存。同一個樣式物件可安全地在多處重用。
  3. 意圖而非座標:IR 表達段落、表格、群組、分頁意圖;絕對座標只存在 Figure 的內容內(ADR-029 決策 2)。
  4. 不外露 vendor 型別:公開 API 只用 JDK 與 appfuse 自有型別。PDFBox、Batik、graphics2d 只能出現在 io.leandev.appfuse.report.internal..。
  5. 降級必須可見:soft hint 做不到時,renderer 回報 diagnostic;互相矛盾或無界的模型在建構時就被拒絕。
  6. 沿用框架既有 primitive:長度用 io.leandev.appfuse.measure.Length、紙張用 Paper;不另造單位型別。

套件配置​

套件內容公開性
io.leandev.appfuse.reportReportTemplate、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)​

節點欄位說明
Sectionid、title(選填,供未來 PDF 書籤)、blocks、pageBreakBefore、summaryLastPage(選填,自 4.0.0-alpha.26)邏輯分節;不產生可見內容;最後內容頁摘要覆寫見流式版面擴充,由 4.0.0-alpha.26 提供
Headingid、level(1–4)、inlines、style預設 keep-with-next
Paragraphid、inlines、style可跨頁;孤行寡行由 ParagraphStyle 控制
Tableid、columns、headerRows、rows、style、repeatHeader列可來自記憶體清單或 RowSource
Imageid、data、width、height(選填,缺一則等比)、altText、caption點陣圖;與圖說同頁
Figureid、content、width、height、altText(必填)、caption(必填)有界的局部座標圖形;內容由 REP-04 定義。圖內文字是向量外框(REP-01),因此替代文字與可抽取的文字圖說都必填。REP-04 改以自寫受限 SVG 子集繪製後,SVG 內文字已可抽取;點陣內容仍不可,兩者維持必填(見 REP-04 契約)
KeepTogetherid、blocks、box(選填框線與底色)盡量不跨頁的區塊
PageBreak—硬性換頁
Spacerheight垂直間距

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=繼承)
TextStylefontFamily(邏輯名稱)、size(Length)、weight(FontWeight.REGULAR/BOLD)、italic(Boolean)、color
ParagraphStyletext(TextStyle)、align、lineHeight(倍數,Double)、spaceBefore、spaceAfter、firstLineIndent、keepWithNext(Boolean)、orphans、widows(Integer)
TableStyletext、headerFill、groupFill、totalFill、rule、cellPadding、continuationLabel
BoxStyleborder、fill、padding
ReportColorRGB 值;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 重複(見下);群組結構以串流方式檢查(只保留目前群組路徑,記憶體不隨列數成長)見下

讀取時驗證失敗的處置:

  1. renderer 立即停止讀取並關閉 RowCursor(與其他失敗相同的 finally 路徑)。關閉本身失敗時,關閉的例外附在驗證例外的 suppressed 中,不得蓋掉 diagnostic。
  2. 不產生任何產物:已寫出的暫存內容一律丟棄,不回傳部分 PDF。
  3. 丟出 ReportValidationException,diagnostic 的 location 指到該列(例如 body/2[table:orders]/row/1523); 同一次 render 已收集的其他 diagnostic 一併附上。
  4. 列規則由 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;框架提供 GroupedRowsIR 宣告分組鍵與彙總函式、由 renderer 計算:IR 會含函式,無法序列化,也更難做結構測試
2頁面層級彙總(本頁小計、承上頁、轉下頁)v1 不納入。產品確有需要時,再新增宣告式 PageAggregate(指定欄位與加總方式)由 renderer 計算這類數字只有排版後才知道,樣板算不出來;在沒有實際需求前不擴大 v1
3RowSource 生命週期開啟前不持有資源(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顏色型別自有 ReportColorjava.awt.Color:公開 API 會暗示 AWT 繪圖語意
7ReportContext 內容只有 Locale 與 Clock;需要翻譯字串時,由應用把框架 I18n(或其他翻譯來源)注入樣板把 I18n 放進 context:綁定特定翻譯機制,也讓不需翻譯的報表多一個依賴
8頁首頁尾變化v1 全部頁面共用同一組頁首頁尾首頁不同、奇偶頁不同:延後到有實際需求時

與 REP-01 spike 的差異​

spike本設計原因
樣式是節點上的零散欄位(字級、粗體)獨立的樣式 record+文件預設值重用與不可變
表格儲存格只有純文字Cell 含受限的區塊清單、對齊與跨欄粗體合計、跨欄群組標題、儲存格內圖形
群組標題以單一儲存格表示跨欄colSpan語意明確,可驗證
頁首頁尾與頁碼格式寫死在 rendererPageRegion+PageNumber/PageCount由樣板決定內容
列來源是 Iterable,關閉責任不明RowSource/RowCursor,renderer 負責關閉大型明細與資源安全
超高列截斷預設列內分頁不遺失資料