Restoring DOCX to PDF Layout Compatibility with OpenPDF
把 Java 系統的 PDF 函式庫從 iText 2.1.7 換成 OpenPDF,編譯成功只代表 API 可以接上,不代表 DOCX 轉出的 PDF 版面沒有變化。
這次處理的是一份 Word 套版報表 PC1010.docx。更換 converter 後,原本一頁的申請表變成兩頁,標題往上撞到表格框線,醫療機構與下一行文字也疊在一起。
最後沒有修改 DOCX,也沒有強制壓縮資料列高度,而是在專案內補回 XDocReport OpenPDF converter 遺漏的固定行距處理,並以正式範本建立可長期保留的版面回歸測試。
Maven 相依只保留 OpenPDF converter
不能只刪除直接宣告的 iText,因為 XDocReport 的彙總套件或 DOCX converter 仍可能間接帶入 iText converter。
PhyChkLib 明確排除 iText converter,改用 OpenPDF converter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId>fr.opensagres.xdocreport</groupId>
<artifactId>fr.opensagres.xdocreport.converter.docx.xwpf</artifactId>
<version>2.1.0</version>
<exclusions>
<exclusion>
<groupId>fr.opensagres.xdocreport</groupId>
<artifactId>fr.opensagres.poi.xwpf.converter.pdf</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>fr.opensagres.xdocreport</groupId>
<artifactId>fr.opensagres.poi.xwpf.converter.pdf.openpdf</artifactId>
<version>2.1.0</version>
</dependency>
原本直接宣告的 iText 必須移除:
1
2
3
4
5
6
<!-- 移除這項相依 -->
<dependency>
<groupId>com.lowagie</groupId>
<artifactId>itext</artifactId>
<version>2.1.7</version>
</dependency>
檢查實際相依樹:
1
2
mvn dependency:tree \
-Dincludes=fr.opensagres.xdocreport:*,com.lowagie:*,com.github.librepdf:*
最後應看到 OpenPDF converter 與 OpenPDF:
1
2
3
fr.opensagres.xdocreport:fr.opensagres.poi.xwpf.converter.pdf.openpdf:2.1.0
\- fr.opensagres.xdocreport:fr.opensagres.xdocreport.openpdf.extension:2.1.0
\- com.github.librepdf:openpdf:2.0.2
OpenPDF 為了 API 相容性,仍使用 com.lowagie.* Java package。因此原始碼出現 com.lowagie.text.Font,不代表 Maven 仍依賴 com.lowagie:itext;應以 artifact 相依樹判斷。
先重現問題,不急著修改範本
以正式 PC1010.docx 套入代表性資料,再交給未修正的 OpenPDF converter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PdfOptions options = PdfOptions.create().fontProvider(
(familyName, encoding, size, style, color) -> {
try {
BaseFont baseFont = BaseFont.createFont(
chineseFontPath,
BaseFont.IDENTITY_H,
BaseFont.EMBEDDED
);
Font font = new Font(baseFont, size, style, color);
font.setFamily(familyName);
return font;
} catch (DocumentException | IOException exception) {
throw new IllegalStateException(exception);
}
}
);
try (XWPFDocument document = new XWPFDocument(docxInputStream)) {
PdfConverter.getInstance().convert(document, pdfOutputStream, options);
}
結果可以穩定重現:
- 舊 iText 成品:一頁。
- 原始 OpenPDF converter:兩頁。
- 標題向上移約 14.5pt。
- 醫療機構與收據提示只相隔約 10.6pt,文字發生重疊。
檢查 DOCX 內容
DOCX 是 ZIP 格式,可以先解開並檢查 word/document.xml:
1
2
3
4
5
mkdir -p /tmp/pc1010-docx
unzip PC1010.docx -d /tmp/pc1010-docx
rg -n 'trHeight|hRule|lastRenderedPageBreak|cantSplit|spacing|gridSpan' \
/tmp/pc1010-docx/word/document.xml
這份範本是有效的 OOXML,但排版高度依賴 Microsoft Word:
- 11 個
w:trHeight沒有明列w:hRule。 - 含有一個
w:lastRenderedPageBreak。 - 多個資料列使用
w:cantSplit。 - 多數段落沒有明列固定行距,而是依賴樣式繼承。
- 標題使用置中,加上較特殊的左縮排、凸排、段前與段後設定。
lastRenderedPageBreak 是 Word 上次計算分頁時留下的位置,不等於使用者設定的強制分頁,所以不能看到這個元素便直接刪除。
用受控實驗逐一排除
每次只改一項條件,並使用同一份已套版 DOCX、同一個字型及同一個 OpenPDF converter 匯出。
| 實驗 | 頁數 | 標題與行距 |
|---|---|---|
| 原始 OpenPDF converter | 2 | 標題撞框、文字重疊 |
移除 lastRenderedPageBreak | 2 | 沒有改善 |
移除所有 cantSplit | 2 | 沒有改善 |
所有缺省 hRule 明寫成 atLeast | 2 | 沒有改善 |
所有資料列改成 hRule="exact" | 1 | 文字仍重疊,且有裁切風險 |
PdfPCell.setUseAscender(true) | 2 | 沒有改善 |
PdfPTable.setSplitLate(false) | 2 | 沒有改善 |
PdfPTable.setSplitRows(false) | 2 | 沒有改善 |
| 每個 DOCX 段落明寫固定行距 | 1 | 不再重疊,但位置未完全符合 iText 基準 |
| converter 補回固定行距 | 1 | 重要文字位置符合 iText 基準 |
把 hRule 設為 exact 雖然能把內容壓回一頁,卻沒有修正文字重疊,而且動態資料一旦變長便可能被裁切。這只是改變表格高度限制,不是修正文字排版。
明寫 hRule="atLeast" 的語意比省略更清楚,但實測仍是兩頁,因此也不能取代 converter 修正。
比對 XDocReport 兩套 mapper
檢查 XDocReport 2.1.0 的 iText mapper 與 OpenPDF mapper,可以看到關鍵差異。
iText mapper 在處理每個 run 時,同時調整倍數行距及固定行距:
1
2
pdfParagraph.adjustMultipliedLeading(currentRunFontAscii);
pdfParagraph.adjustLeading(currentRunFontAscii);
OpenPDF mapper 只保留倍數行距:
1
pdfParagraph.adjustMultipliedLeading(currentRunFontAscii);
OpenPDF 版 StylableParagraph 甚至沒有對應的 adjustLeading(Font) 方法。
這也說明為什麼只在 DOCX 沒有明列行距的段落發生明顯差異:OpenPDF mapper 沒有像舊 iText mapper 一樣,以目前字型大小設定段落固定行距。
沒有可補上的公開參數
XDocReport 2.1.0 的 PdfOptions 只有以下幾類設定:
- 字型編碼。
- 字型提供者。
PdfWriter設定回呼。
它沒有開放下列設定:
- 段落 fixed leading。
PdfPCell.setUseAscender()。PdfPTable.setSplitLate()。PdfPTable.setSplitRows()。- 自訂
PdfMapperfactory。
IPdfWriterConfiguration 只能取得 PdfWriter,無法接觸 converter 建立的段落、儲存格或表格。因此這不是應用程式少傳一個既有參數。
在專案內建立相容 converter
不必複製整份第三方 PdfMapper。建立一個專案自有 converter,沿用 XDocReport 的轉換流程,只覆寫 run 處理並補上固定行距:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
package PhyChkLib.util;
import com.lowagie.text.Font;
import fr.opensagres.poi.xwpf.converter.core.AbstractXWPFConverter;
import fr.opensagres.poi.xwpf.converter.core.IXWPFConverter;
import fr.opensagres.poi.xwpf.converter.core.XWPFConverterException;
import fr.opensagres.poi.xwpf.converter.pdf.PdfOptions;
import fr.opensagres.poi.xwpf.converter.pdf.internal.PdfMapper;
import fr.opensagres.poi.xwpf.converter.pdf.internal.elements.StylableParagraph;
import fr.opensagres.xdocreport.openpdf.extension.IITextContainer;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Writer;
/**
* 使用 OpenPDF,並保留舊 iText converter 固定行距行為的 DOCX converter。
*/
final class CompatibleOpenPdfConverter
extends AbstractXWPFConverter<PdfOptions> {
private static final IXWPFConverter<PdfOptions> INSTANCE =
new CompatibleOpenPdfConverter();
/**
* 取得不保存單次轉檔狀態的共用 converter。
*
* @return OpenPDF 相容 converter
*/
static IXWPFConverter<PdfOptions> getInstance() {
return INSTANCE;
}
/**
* 執行 DOCX 轉 PDF,並保留總頁數欄位所需的第二次轉換流程。
*
* @param document DOCX 文件
* @param out PDF 輸出
* @param writer 此 converter 不使用的字元輸出
* @param options PDF 選項
* @throws XWPFConverterException 轉換失敗時拋出
* @throws IOException PDF 輸出失敗時拋出
*/
@Override
protected void doConvert(
XWPFDocument document,
OutputStream out,
Writer writer,
PdfOptions options
) throws XWPFConverterException, IOException {
try {
ByteArrayOutputStream firstPass = new ByteArrayOutputStream();
CompatiblePdfMapper mapper = new CompatiblePdfMapper(
document,
firstPass,
options,
null
);
mapper.start();
if (mapper.useTotalPageField()) {
mapper = new CompatiblePdfMapper(
document,
out,
options,
mapper.getPageCount()
);
mapper.start();
} else {
out.write(firstPass.toByteArray());
}
} catch (Exception exception) {
throw new XWPFConverterException(exception);
}
}
/**
* 在 OpenPDF mapper 完成文字處理後,補上固定行距。
*/
private static final class CompatiblePdfMapper extends PdfMapper {
/**
* 建立單次轉檔 mapper。
*
* @param document DOCX 文件
* @param out PDF 輸出
* @param options PDF 選項
* @param expectedPageCount 第二次轉換使用的預期頁數
* @throws Exception mapper 初始化失敗時拋出
*/
private CompatiblePdfMapper(
XWPFDocument document,
OutputStream out,
PdfOptions options,
Integer expectedPageCount
) throws Exception {
super(document, out, options, expectedPageCount);
}
/**
* 依目前 run 的有效字型大小設定段落固定行距。
*
* @param docxRun DOCX 文字 run
* @param pageNumber 是否為頁碼欄位
* @param url 超連結網址
* @param pdfParagraphContainer PDF 段落容器
* @throws Exception 文字轉換失敗時拋出
*/
@Override
protected void visitRun(
XWPFRun docxRun,
boolean pageNumber,
String url,
IITextContainer pdfParagraphContainer
) throws Exception {
super.visitRun(docxRun, pageNumber, url, pdfParagraphContainer);
Float fontSize = stylesDocument.getFontSize(docxRun);
if (fontSize != null && fontSize > Font.UNDEFINED) {
((StylableParagraph) pdfParagraphContainer)
.setLeading(fontSize);
}
}
}
}
這個類別保留 XDocReport 原有的兩階段總頁數處理,只在明確缺少的行距行為上補充,不需要維護一整份第三方 mapper 複本。
正式匯出流程明確選用修正版
不要再透過 registry 自動選擇 converter,否則 classpath 中 converter 組合一變,可能又選回未修正實作。
PdfReport 保留原有字型提供者,並明確建立 XWPFDocument、呼叫相容 converter。以下是專案實際使用的核心程式碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
PdfOptions pdfOptions = PdfOptions.create().fontProvider(
(familyName, encoding, size, style, color) -> {
Font result = null;
try {
BaseFont baseFont = BaseFont.createFont(
chnFontPath,
BaseFont.IDENTITY_H,
BaseFont.EMBEDDED
);
Font chineseFont = new Font(baseFont, size, style, color);
if (familyName != null) {
chineseFont.setFamily(familyName);
}
result = chineseFont;
} catch (DocumentException | IOException exception) {
System.out.println("轉 PDF 失敗: " + exception.getMessage());
}
return result;
}
);
try (XWPFDocument document = new XWPFDocument(getDocStream())) {
CompatibleOpenPdfConverter.getInstance()
.convert(document, result, pdfOptions);
}
PhyChkMgr 不需要知道 converter 細節。PC1010 與 PC1020 原本都呼叫 PdfReport.createReport(),因此會共同使用相同修正。
回歸測試必須從正式範本開始
只測一份事先產生的 PDF,無法涵蓋 Word 範本、XDocReport 套版資料與 converter 的組合。測試應從正式 PC1010.docx 開始:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
* 驗證正式 PC1010 範本套版後維持單頁,且重要文字位置正常。
*/
class Pc1010PdfLayoutTest {
private static final Path PDF_OUTPUT_PATH = Path.of(
"target",
"test-output",
"PC1010-OpenPDF.pdf"
);
private static final Path DOCX_OUTPUT_PATH = Path.of(
"target",
"test-output",
"PC1010-merged.docx"
);
/**
* 從正式範本完成套版、PDF 轉換、頁數及位置驗證。
*
* @throws IOException DOCX 或 PDF 無法處理時拋出
*/
@Test
void shouldKeepPc1010OnOnePage() throws IOException {
ByteArrayOutputStream docx = new DocReport()
.createPctp1Report(createRecord());
assertNotNull(docx, "PC1010 套版不應失敗");
writeTestArtifact(DOCX_OUTPUT_PATH, docx.toByteArray());
ByteArrayOutputStream pdf = new PdfReport().createReport(
new ByteArrayInputStream(docx.toByteArray())
);
assertNotNull(pdf, "PC1010 轉 PDF 不應失敗");
writeTestArtifact(PDF_OUTPUT_PATH, pdf.toByteArray());
PdfReader reader = new PdfReader(pdf.toByteArray());
try {
assertEquals(
1,
reader.getNumberOfPages(),
"PC1010 必須維持單頁"
);
String pageText = new PdfTextExtractor(reader)
.getTextFromPage(1);
assertTrue(pageText.contains(
"國立臺灣師範大學健康檢查補助費申請表"
));
assertTrue(pageText.contains("富霖診所"));
assertTrue(pageText.contains(
"檢附醫療機構健檢費用收據"
));
} finally {
reader.close();
}
assertImportantTextPositions(pdf.toByteArray());
}
}
測試把成品留在 target/test-output。它們不進版本控制,但每次更新 PDF 套件後都能重新產生,供人工審查。
不只測頁數,也測文字座標
一頁不代表排版正確。hRule="exact" 的實驗就是一頁,但文字仍然撞框及重疊。
加入 PDFBox 3 作為測試相依:
1
2
3
4
5
6
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.8</version>
<scope>test</scope>
</dependency>
收集 PDF 文字的頁面上緣座標:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 收集每段文字第一次出現時的 PDF 頁面上緣座標。
*/
private static final class TextPositionCollector
extends PDFTextStripper {
private final Map<String, Float> topPositions = new HashMap<>();
/**
* 依頁面位置排序,避免 PDF 內容流順序影響結果。
*
* @throws IOException PDFTextStripper 初始化失敗時拋出
*/
private TextPositionCollector() throws IOException {
setSortByPosition(true);
}
/**
* 保留每段文字第一次出現的位置。
*
* @param text 擷取文字
* @param textPositions 各字元位置
*/
@Override
protected void writeString(
String text,
List<TextPosition> textPositions
) {
if (!textPositions.isEmpty()) {
TextPosition first = textPositions.get(0);
topPositions.putIfAbsent(
text,
first.getYDirAdj() - first.getHeightDir()
);
}
}
/**
* 取得包含指定文字的第一個座標。
*
* @param text 要尋找的文字
* @return 文字上緣座標
*/
private float requireTopContaining(String text) {
Float position = topPositions.entrySet().stream()
.filter(entry -> entry.getKey().contains(text))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
assertNotNull(position, "找不到 PDF 文字位置: " + text);
return position;
}
}
再以舊 iText 成品的座標建立合理容許範圍:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private void assertImportantTextPositions(byte[] pdfBytes)
throws IOException {
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
TextPositionCollector collector = new TextPositionCollector();
collector.getText(document);
float titleY = collector.requireTopContaining(
"國立臺灣師範大學健康檢查補助費申請表"
);
float hospitalY = collector.requireTopContaining("富霖診所");
float receiptY = collector.requireTopContaining(
"檢附醫療機構健檢費用收據"
);
assertTrue(
titleY >= 54f && titleY <= 58f,
"標題垂直位置異常: " + titleY
);
assertTrue(
hospitalY >= 276f && hospitalY <= 280f,
"醫療機構垂直位置異常: " + hospitalY
);
assertTrue(
receiptY - hospitalY >= 18f,
"醫療機構與收據提示行距不足: "
+ (receiptY - hospitalY)
);
}
}
這三項驗證分別攔截:
- 內容被推到第二頁。
- 標題撞到上方框線。
- 醫療機構與收據提示重疊。
執行測試與安裝新版函式庫
先執行 PDF 專用測試:
1
mvn -Dtest=PdfReportTest,Pc1010PdfLayoutTest test
再執行完整測試:
1
mvn test
這次結果為:
1
2
Tests run: 108, Failures: 0, Errors: 0, Skipped: 50
BUILD SUCCESS
最後將 PhyChkLib 1.2 安裝到本機 Maven repository:
1
2
cd ~/workspace/PhyChkLib
mvn clean install
PhyChkMgr 重新載入 Maven 專案後,即可使用新版 phychklib-1.2.jar 實際匯出 PC1010 與 PC1020。
只執行 IDE Build 或 mvn package 不一定會更新另一個獨立 Maven 專案使用的本機 artifact;此情況應使用 mvn install。
最後決策
這次不修改 PC1010.docx,理由如下:
hRule="atLeast"實測不能修正分頁或重疊。hRule="exact"會改變資料列語意,且可能裁切動態內容。- 在每個 DOCX 段落補固定行距會增加範本維護成本,其他報表仍可能遇到相同問題。
- converter 補回的正是舊 iText mapper 已存在、OpenPDF mapper 遺漏的行為。
- 修正版成品的重要文字位置與舊 iText 基準一致。
移除 iText 的完成條件因此不只是「Maven 相依樹沒有 iText」,而是:相依已替換、正式報表可匯出、版面符合基準、測試能攔截頁數與座標退步,並保留可供人工審查的成品。
References
- XDocReport source: https://github.com/opensagres/xdocreport
- XDocReport iText leading fix: https://github.com/opensagres/xdocreport/commit/2bc026a78faa7e210f023840e0a46e9ec244c9e2
- OpenPDF leading and
setUseAscender: https://github.com/LibrePDF/OpenPDF/discussions/1175 - Microsoft
trHeightnotes: https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/3b8129c1-9aac-4611-a50e-247d13a382b2 - Microsoft
lastRenderedPageBreaknotes: https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/e0d0fc07-96bb-4248-a832-dd0e8e42f001 - Apache PDFBox 3.0: https://pdfbox.apache.org/3.0/getting-started.html