人工文字空白、Tab 與長字詞換行
概述
4.0.0-alpha.30 新增 opt-in 的 ParagraphStyle.withTextFlow(TextFlow)。
alpha.29 的公開樣式無此配置:行首尾半形空白會略去,Tab 以半形空白處理,超長字詞直接使用目前行的剩餘寬度拆分;
Text + LineBreak + 空 Text 也不能要求額外末尾空行。既有公開 API 無法等價組合出下列能力。
新策略共用相同字型量測、flow 換行與分頁,適用 Paragraph、Heading、materialized Table cell 和
分節 Block 頁首尾。未設定 textFlow 的文件仍使用原本的 LineLayout,維持預設PDF bytes與版面。
快速開始
implementation("io.leandev.appfuse.webapp:appfuse-server:4.0.0-alpha.32")
implementation("org.apache.pdfbox:pdfbox:3.0.8")
import io.leandev.appfuse.measure.Length;
import io.leandev.appfuse.report.ReportDocument;
import io.leandev.appfuse.report.node.ParagraphStyle;
import io.leandev.appfuse.report.node.TextFlow;
var notesStyle = ParagraphStyle.inherit()
.withTextFlow(TextFlow.preWrap().withTabSize(4).withTrailingEmptyLine(true))
.withLineHeight(1.3)
.withSpaceBefore(Length.ofMillimeter(2))
.withSpaceAfter(Length.ofPoint(0))
.withRepeatSpaceBefore(true)
.withOrphans(1).withWidows(1);
var original = " LEADING\nTRAILING \nAB\tC\nTWO SPACES\n";
var report = ReportDocument.builder().paragraph(original, notesStyle).build();
文字直接傳入 Text/paragraph,不替換成 NBSP 或全形空白、不自行斷詞。
字級請與確認版面相同,例如文件 TextStyle.withSize(Length.ofPoint(10))。
空白與末尾空行
TextFlow.preWrap() 保留 U+0020 的行首、行尾及連續空白,將原始LF、CRLF或CR排成換行;連續換行產生空行。
Tab 不繪製成另一個文字字元,而是移動下一個glyph到Tab stop。半形空白仍以原字型的空格glyph繪製,
所以不可只用 PDFTextStripper 的正規化結果證明保留成功。
單一末尾 \n 在 CSS pre-wrap 只結束目前行,不再生成一行。
本次人工備註驗收選擇額外保留末尾空行高度,應使用 withTrailingEmptyLine(true);上述輸入占5行。
同樣適用 Text("X") + LineBreak + Text("")。此配置是明確增加的高度,不宣稱與未增加空行的HTML高度相同。
單純CSS末尾換行行高則使用預設false;"X\n\n" 在該模式占2行。
行尾空白在軟換行時依pre-wrap語意懸掛;強制換行或段落結尾前的空白若能放下,仍納入對齊寬度。 它們保留glyph與座標,不藉縮字或字元替換避免溢位。
正常斷點優先的 anywhere
TextFlow.normal() 使用折疊空白、overflow-wrap:anywhere 的正常斷點優先語意,適合個案資訊。
TextFlow.preWrap() 使用相同換行策略,但保留空白與原始換行。
若目前行已有可用的正常斷點,先在該處換行;整個單詞連完整一行也放不下時才在grapheme邊界拆分, 不在字詞前方還有正常斷點時立即塞入部分字詞。Inline字型或樣式切換不形成額外字詞邊界。 不插入連字號、不拆開組合字或emoji grapheme;單個grapheme大於整行會回報ERROR,避免裁切或縮字。
190/3mm欄寬、10pt Times/Kai標籤+Msjh值的合成姓名 陳 + 198個O + 林 - F,
首行為 個案姓名:陳,姓名共11行、再加篩檢編號與出生日期後左組共13行。
import io.leandev.appfuse.report.node.Cell;
import io.leandev.appfuse.report.node.Paragraph;
import io.leandev.appfuse.report.node.Text;
import io.leandev.appfuse.report.node.TextStyle;
import io.leandev.appfuse.report.node.Align;
import io.leandev.appfuse.report.node.VerticalAlign;
import java.util.List;
var infoStyle = ParagraphStyle.inherit().withTextFlow(TextFlow.normal())
.withText(TextStyle.inherit().withFontFamily("label").withSize(Length.ofPoint(10)))
.withLineHeight(1.15).withSpaceBefore(Length.ofPoint(0)).withSpaceAfter(Length.ofPoint(0));
var name = new Paragraph(null, List.of(
Text.of("個案姓名:"),
new Text("陳" + "O".repeat(198) + "林 - F", TextStyle.inherit().withFontFamily("data"))), infoStyle);
var cell = new Cell(List.of(name), Align.LEFT, VerticalAlign.TOP, 1);
將cell放進materialized Table,再把該Table放進 SectionPageRegion.blocks 即適用;
整段落及表格cell量測後的高度計入分節頁首預留,疾病表切換不另外生成頁首。
字型家族名稱須對應自己的 ReportFonts 登錄,例如label家族Times+Kai、data家族Msjh。
框架Table的預設文字為9pt;上例在cell段落明確指定10pt,完整範例則用 TableStyle.withText 設定10pt。
只設定文件的TextStyle為10pt,仍可能被Table預設覆寫,應一併設定cell段落或Table樣式。
Table有內距或框線時,內容欄寬應扣除相同的實體內距;不要只比較欄位權重。
配置選項
TextFlow 是不可變的完整配置;段落樣式的 textFlow 非null時整份覆寫繼承值。
| 配置 | 預設/語意 |
|---|---|
normal() | WhiteSpace.COLLAPSE、OverflowWrap.ANYWHERE、tabSize4、避頭/避尾true、無標點懸掛、無額外末尾空行 |
preWrap() | 同normal,但 WhiteSpace.PRE_WRAP |
withWhiteSpace(...) | 半形空白、Tab與原始換行保留或折疊;LineBreak在折疊模式仍強制換行 |
withOverflowWrap(...) | ANYWHERE 正常斷點不足時拆詞;NORMAL 不做緊急拆詞,普通正文可溢出,有界頁首超寬為ERROR |
withTabSize(int) | 非負數;以段落基底字型的半形空格advance乘倍數作為間距,0不移動;會清除固定tabStop |
withTabStop(Length) | 固定正值Tab stop間距,優先於tabSize;null恢復tabSize |
withAvoidLineStart(boolean) | 常見CJK句讀、關閉括號與小假名避頭 |
withAvoidLineEnd(boolean) | 常見開啟括號與開引號避尾 |
withHangingPunctuation(boolean) | 允許一個行尾避頭標點懸掛;分節有界頁首仍不允許超出欄寬 |
withTrailingEmptyLine(boolean) | true在末尾原始換行/LineBreak後另保留一行,影響後續區塊及分頁 |
ParagraphStyle.withRepeatSpaceBefore(true) | 在正文Paragraph頁頂與每個續頁片段重複spaceBefore;預設false保持既有段距行為 |
Tab stop從段落內容左緣開始,而非從Tab所在的Inline開始。若下一個stop距目前位置不到半個 0 字寬,
使用再下一個stop,對齊CSS的0.5ch規則。字型混用但希望固定物理Tab間距時使用 withTabStop。
ParagraphStyle 保留九參數建構器;新增的record component會影響反射、解構及equals/hashCode/toString。
預設標點避頭/避尾、無懸掛適合RPT-014;想放寬標點邊界可分別關閉避頭/避尾。
策略以JDK25正常斷點配合CJK標點規則及grapheme切分實作,不涵蓋任意CSS line-break 語系變體、
自動斷字、bidi或複雜文字shaping。本文承諾與測試範圍是所列拉丁/繁體中文資料及相同字型、尺寸的對照,
不是任意HTML/CSS importer。
每頁段落片段預留段後距
新增選填配置 ParagraphStyle.withRepeatSpaceAfter(true)。適用 flow 正文 Paragraph/Heading;
包含單頁段落、所有續頁片段及最後片段。先從目前頁可用高度扣除 spaceAfter 再計算可容納行數,
再套用既有 widow/orphan 規則;每個片段末端保留該距離,最後片段不重複套用。
var notes = ParagraphStyle.inherit().withLineHeight(1.3)
.withSpaceAfter(Length.ofMillimeter(2)).withRepeatSpaceAfter(true)
.withTextFlow(TextFlow.preWrap());
withRepeatSpaceAfter 自 4.0.0-alpha.32 提供。新欄位 null 繼承,預設 false;false 可明確覆寫上層 true。未啟用時保持 alpha.31 行數及段後距行為。
spaceAfter 沿用 Length 的實體單位、有限非負值驗證;2mm 約為 5.669pt。
若目前頁連一行加段後距都放不下,會移到新頁;新頁仍放不下則回報 report.layout.no-progress,
不截斷或無限產生空白頁。重複段前距可同時啟用,會一起計入容量。
此配置不更動文字或原始換行;空白與末尾空行仍由 TextFlow 控制。
表格儲存格及固定頁首尾區域的分頁由外層容器擁有,此配置不改變它們的容量策略。
保留 alpha.31 十一參數及原九參數建構器;新增 record component 仍影響反射、解構及值比較。
公開配置範例另見表格片段範例。
可執行範例與回歸
下載完整範例,來源為 appfuse-server/examples/text-flow/。
依README使用合法字型檔、JDK25及Gradle9.2.1產生PDF;選用的Chromium oracle只負責對照,不參與正式render。
測試包含原始空格glyph、Tab後字元座標、末尾空行造成後續區塊的位移、13行左組與完整頁首高度、
8,000 UTF-16字元的逐行/逐頁對照、重複續頁段距、materialized cell與Block頁首、跨案頁碼、
100案於128MB heap產生同一份PDF,以及既有8份PDF bytes相容性。
本次以Chromium 143.0.7499.4及相同Times/Kai/Msjh檔案實測:姓名11行/左組13行、備註422行;
含頁首A案11頁與B案1頁的逐頁分界一致,glyph水平差最多0.34mm、頁首高度與備註垂直差最多0.71mm。
沿用既有 RenderOptions.onPage 檢查1,000頁上限,超過即失敗且不交付部分PDF;本擴充不改全框架預設頁數限制。
RPT-014的正式adapter、全部疾病表內容及下游真人PDF接受仍由應用自己的RPT驗收承接。 API簽章見 Javadoc。