PDF 生成最佳實踐:Builder 模式的優雅 API

商業價值:統一的 PDF 生成讓「出貨單、撿貨單一鍵產生」,直接支撐 導讀篇提到「處理速度提升 10 倍」——從每單 3 分鐘縮短到批次 1 秒。

前言:電商系統的 PDF 需求

OMS 系統需要產生各種 PDF 文件:

文件類型 用途 特殊需求
出貨標籤 貼在包裹上 條碼、收件人資訊
訂單明細 放在包裹內 商品清單、金額
撿貨單 倉庫作業 商品位置、數量
對帳單 商戶結算 表格、統計
挑戰:傳統 PDF 產生程式碼冗長、難以維護

解決方案:Builder 模式

使用範例(先看效果)

// 產生出貨單 PDF
byte[] pdf = PdfBuilder.create()
.title(“出貨單”)
.separator()
.text(“訂單編號:ORD-2024-001234”)
.text(“出貨日期:2024-03-18”)
.newLine()
.subtitle(“商品明細”)
.table(orderItems)
.newLine()
.subtitle(“收件資訊”)
.text(“收件人:王小明”)
.text(“電話:0912-345-678”)
.text(“地址:台北市信義區信義路五段7號”)
.newPage()
.image(barcodeBytes)
.build();
效果:程式碼像文件結構一樣直覺,易讀易維護

PdfBuilder 實作

核心結構

public class PdfBuilder {

private List<Element> elements = new ArrayList<>();
private FontConfig fontConfig;

// 工廠方法
public static PdfBuilder create() {
return new PdfBuilder(FontConfig.defaultConfig());
}

public static PdfBuilder create(FontConfig config) {
return new PdfBuilder(config);
}

private PdfBuilder(FontConfig config) {
this.fontConfig = config;
}
}

文字方法

/**
* 標題(最大字體)
*/

public PdfBuilder title(String text) {
elements.add(new TextElement(text, fontConfig.getTitleFont()));
return this; // 回傳 this 支援鏈式呼叫
}

/**
* 副標題
*/

public PdfBuilder subtitle(String text) {
elements.add(new TextElement(text, fontConfig.getSubtitleFont()));
return this;
}

/**
* 一般文字
*/

public PdfBuilder text(String text) {
elements.add(new TextElement(text, fontConfig.getBodyFont()));
return this;
}

/**
* 粗體文字
*/

public PdfBuilder boldText(String text) {
elements.add(new TextElement(text, fontConfig.getBoldFont()));
return this;
}

版面控制

/**
* 換行
*/

public PdfBuilder newLine() {
elements.add(new NewLineElement());
return this;
}

/**
* 分隔線
*/

public PdfBuilder separator() {
elements.add(new SeparatorElement());
return this;
}

/**
* 換頁
*/

public PdfBuilder newPage() {
elements.add(new NewPageElement());
return this;
}

表格支援

/**
* 新增表格
* @param data 二維資料,第一列為標題
*/

public PdfBuilder table(List<List<String>> data) {
elements.add(new TableElement(data, TableStyle.BORDERED));
return this;
}

/**
* 新增表格(指定樣式)
*/

public PdfBuilder table(List<List<String>> data, TableStyle style) {
elements.add(new TableElement(data, style));
return this;
}

圖片與條碼

/**
* 新增圖片
*/

public PdfBuilder image(byte[] imageBytes) {
elements.add(new ImageElement(imageBytes));
return this;
}

/**
* 新增條碼(自動產生)
*/

public PdfBuilder barcode(String content) {
byte[] barcodeImage = BarcodeGenerator.generate(content);
elements.add(new ImageElement(barcodeImage));
return this;
}


輸出 PDF

/**
* 產生 PDF byte 陣列
*/

public byte[] build() {
ByteArrayOutputStream output = new ByteArrayOutputStream();
Document document = new Document(PageSize.A4);

try {
PdfWriter.getInstance(document, output);
document.open();

for (Element element : elements) {
if (element instanceof NewPageElement) {
document.newPage();
} else {
document.add(element.render());
}
}

document.close();

} catch (DocumentException e) {
throw new PdfGenerationException(“PDF 產生失敗”, e);
}

return output.toByteArray();
}

/**
* 直接寫入檔案
*/

public void toFile(String filename) {
byte[] pdf = build();
try (FileOutputStream fos = new FileOutputStream(filename)) {
fos.write(pdf);
} catch (IOException e) {
throw new PdfGenerationException(“檔案寫入失敗”, e);
}
}


中文字型支援

public class FontConfig {

private static final String FONT_PATH = “/fonts/NotoSansTC-Regular.ttf”;
private BaseFont baseFont;

public static FontConfig defaultConfig() {
return new FontConfig();
}

private FontConfig() {
try {
// 載入支援中文的字型
baseFont = BaseFont.createFont(
FONT_PATH,
BaseFont.IDENTITY_H, // Unicode 支援
BaseFont.EMBEDDED // 嵌入字型
);
} catch (Exception e) {
throw new RuntimeException(“字型載入失敗”, e);
}
}

public Font getTitleFont() {
return new Font(baseFont, 24, Font.BOLD);
}

public Font getSubtitleFont() {
return new Font(baseFont, 18, Font.BOLD);
}

public Font getBodyFont() {
return new Font(baseFont, 12, Font.NORMAL);
}

public Font getBoldFont() {
return new Font(baseFont, 12, Font.BOLD);
}
}


完整範例:出貨單

public byte[] generateShippingLabel(Order order) {
// 準備商品明細表格
List<List<String>> items = new ArrayList<>();
items.add(List.of(“商品”, “數量”, “單價”, “小計”));

for (OrderItem item : order.getItems()) {
items.add(List.of(
item.getName(),
String.valueOf(item.getQuantity()),
formatPrice(item.getPrice()),
formatPrice(item.getSubtotal())
));
}

items.add(List.of(“合計”, “”, “”, formatPrice(order.getTotal())));

// 產生 PDF
return PdfBuilder.create()
// 標題區
.title(“出貨單”)
.separator()
.text(“訂單編號:” + order.getOrderId())
.text(“出貨日期:” + LocalDate.now())
.newLine()

// 條碼
.barcode(order.getOrderId())
.newLine()

// 商品明細
.subtitle(“商品明細”)
.table(items)
.newLine()

// 收件資訊
.subtitle(“收件資訊”)
.text(“收件人:” + order.getReceiverName())
.text(“電話:” + order.getReceiverPhone())
.text(“地址:” + order.getReceiverAddress())

.build();
}


總結

設計 效果
Builder 模式 鏈式呼叫,程式碼簡潔
流式 API 像寫 HTML 一樣直覺
字型抽象 換字型只改一處
元件化 表格、圖片、條碼都是元件

為什麼不用其他方案?

方案 優點 缺點 結論
HTML 轉 PDF 會 HTML 就會用 排版難控制、分頁問題 簡單報表可用
Word 範本 業務人員能改 需要額外軟體、格式問題 不推薦
JasperReports 功能強大 學習曲線陡、設計器難用 複雜報表可考慮
iText + Builder 程式碼控制、可測試 要寫程式碼 工程師友好

實戰踩坑

坑 1:中文字型問題

預設字型不支援中文,產出的 PDF 全是方框。要嵌入支援中文的字型(如 Noto Sans TC)。第一次載入字型要 2-3 秒,後來改成應用程式啟動時預載。

坑 2:出貨單格式每平台不同

蝦皮、Momo、Yahoo 的出貨單長得不一樣。最初想做成一模一樣,後來發現不可能(平台會檢查格式)。解法:只統一「我們自己的出貨單」,平台的標籤用平台 API 下載。

坑 3:大量 PDF 記憶體爆炸

一次產生 500 張出貨單,記憶體直接爆。解法:改成串流處理,產一張輸出一張,不要全部放在記憶體。


系列導航

◀ 上一篇
HTTP 客戶端
📚 返回目錄 下一篇 ▶
JSON 序列化

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *