業務需求:服務端動態生成可配置的 PDF 報表,包含樣式排版、中文字體、頁眉頁腳、業務圖表嵌入、自動分頁。
| 組件 | 角色 | 解決痛點 |
|---|---|---|
| iText 5 | PDF 生成核心 | 底層 PDF 操作、分頁、水印、加密 |
| FreeMarker | HTML 模板引擎 | 樣式排版、動態數據渲染、前端熟悉語法 |
| JFreeChart | 圖表生成 | 折線/柱狀/餅圖程序化繪製 |
| Flying Saucer (xhtmlrenderer) | HTML→PDF 轉換 | CSS 2.1 支援、中文斷行、複雜佈局 |
<dependencies>
<!-- iText 核心 -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itextpdf</artifactId>
<version>5.5.13.3</version>
</dependency>
<dependency>
<groupId>com.itextpdf.tool</groupId>
<artifactId>xmlworker</artifactId>
<version>5.5.13.3</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-asian</artifactId>
<version>5.2.0</version>
</dependency>
<!-- Flying Saucer HTML→PDF -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
<version>9.1.22</version>
</dependency>
<!-- FreeMarker -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version>
</dependency>
<!-- JFreeChart -->
<dependency>
<groupId>org.jfree</groupId>
<artifactId>jfreechart</artifactId>
<version>1.5.4</version>
</dependency>
</dependencies>
<!DOCTYPE html>
<html xmlns="https://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<style type="text/css">
@font-face {
font-family: 'pingfang';
src: url('file:///fonts/PingFangSC-Light.ttf');
}
body { font-family: 'pingfang', sans-serif; font-size: 12px; }
.page { page-break-after: always; } /* 關鍵:分頁控制 */
.center { text-align: center; }
.header { position: running(header); }
.footer { position: running(footer); }
@page {
@top-center { content: element(header); }
@bottom-center { content: element(footer); counter-increment: page; }
@bottom-right { content: "第 " counter(page) " 頁"; }
margin: 2cm;
}
</style>
</head>
<body>
<div class="header">${reportTitle} - 機密</div>
<div class="footer">生成時間: ${genTime}</div>
<!-- 第 1 頁 -->
<div class="page">
<h1 class="center">${templateName}</h1>
<p>iText 官網: <a href="${itextUrl}">${itextUrl}</a></p>
<p>FreeMarker 官網: <a href="${fmUrl}">${fmUrl}</a></p>
<h3>評分列表</h3>
<ul>
<#list scores as score>
<li>${score}</li>
</#list>
</ul>
</div>
<!-- 第 2 頁:圖表嵌入 -->
<div class="page">
<h2>外部圖片</h2>
<img src="${imageUrl}" width="270" height="129"/>
<h2>動態生成圖表</h2>
<img src="${chartUrl}" width="500" height="300"/>
</div>
</body>
</html>
public class FreeMarkerUtil {
public static String render(String templateName, Object dataModel) {
try {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setDefaultEncoding("UTF-8");
cfg.setClassForTemplateLoading(FreeMarkerUtil.class, "/templates");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
Template template = cfg.getTemplate(templateName);
StringWriter writer = new StringWriter();
template.process(dataModel, writer);
return writer.toString();
} catch (Exception e) {
throw new RuntimeException("模板渲染失敗: " + templateName, e);
}
}
}
@Service
public class ChartService {
public String generateLineChartBase64(List<Double> data, String title) {
try {
// 1. 建構數據集
DefaultCategoryDataset dataset = new DefaultCategoryDataset();
for (int i = 0; i < data.size(); i++) {
dataset.addValue(data.get(i), "數值", "點" + (i+1));
}
// 2. 創建圖表
JFreeChart chart = ChartFactory.createLineChart(
title, "時間", "數值", dataset,
PlotOrientation.VERTICAL, true, true, false
);
// 3. 中文顯示修正
chart.getTitle().setFont(new Font("PingFang SC", Font.BOLD, 16));
CategoryPlot plot = chart.getCategoryPlot();
plot.getDomainAxis().setLabelFont(new Font("PingFang SC", Font.PLAIN, 12));
plot.getRangeAxis().setLabelFont(new Font("PingFang SC", Font.PLAIN, 12));
plot.getDomainAxis().setTickLabelFont(new Font("PingFang SC", Font.PLAIN, 10));
// 4. 輸出為 Base64 Data URI
BufferedImage image = chart.createBufferedImage(600, 350);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", baos);
return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray());
} catch (Exception e) {
throw new RuntimeException("圖表生成失敗", e);
}
}
}
@Component
public class PdfGenerator {
@Autowired private ChartService chartService;
public String generate(String templateName, ReportData data, String outputPath) {
try {
// 1. 生成圖表 Base64
String chartBase64 = chartService.generateLineChartBase64(data.getScores(), "評分趨勢");
data.setChartUrl(chartBase64);
data.setGenTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
// 2. 渲染 HTML
String html = FreeMarkerUtil.render(templateName, data);
// 3. iText + Flying Saucer 轉換
File outputFile = new File(outputPath);
outputFile.getParentFile().mkdirs();
try (OutputStream os = new FileOutputStream(outputFile)) {
ITextRenderer renderer = new ITextRenderer();
// 關鍵:註冊中文字體
ITextFontResolver fontResolver = renderer.getFontResolver();
fontResolver.addFont("/fonts/PingFangSC-Light.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
fontResolver.addFont("/fonts/PingFangSC-Regular.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
renderer.setDocumentFromString(html);
renderer.layout();
renderer.createPDF(os);
}
return outputPath;
} catch (Exception e) {
throw new RuntimeException("PDF 生成失敗", e);
}
}
}
public class HeaderFooterEvent implements PdfPageEventHelper {
private final Font headerFont;
private final Font footerFont;
private final String headerText;
private final String footerText;
public HeaderFooterEvent(String headerText, String footerText, String fontPath) {
try {
BaseFont bf = BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
this.headerFont = new Font(bf, 10);
this.footerFont = new Font(bf, 8);
} catch (Exception e) {
throw new RuntimeException(e);
}
this.headerText = headerText;
this.footerText = footerText;
}
@Override
public void onEndPage(PdfWriter writer, Document document) {
PdfContentByte cb = writer.getDirectContent();
Rectangle page = document.getPageSize();
// 頁眉
ColumnText.showTextAligned(cb, Element.ALIGN_CENTER,
new Phrase(headerText, headerFont),
(page.getLeft() + page.getRight()) / 2, page.getTop() - 20, 0);
// 頁腳
String footer = footerText + " - " + writer.getPageNumber();
ColumnText.showTextAligned(cb, Element.ALIGN_CENTER,
new Phrase(footer, footerFont),
(page.getLeft() + page.getRight()) / 2, page.getBottom() + 20, 0);
}
}
使用方式:writer.setPageEvent(new HeaderFooterEvent("報表標題", "機密文檔", "/fonts/PingFangSC-Regular.ttf"));
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<excludes>
<exclude>**/*.ttf</exclude>
<exclude>**/*.otf</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<nonFilteredFileExtensions>
<nonFilteredFileExtension>ttf</nonFilteredFileExtension>
<nonFilteredFileExtension>otf</nonFilteredFileExtension>
</nonFilteredFileExtensions>
</configuration>
</plugin>
</plugins>
</build>
font-family: 'pingfang', sans-serif; 指定字體族IDENTITY_H + EMBEDDEDfile:/// 協議data:image/png;base64,... 直接嵌入,無需外部文件renderer.getSharedContext().setBaseURL().page { page-break-after: always; } 強制分頁@SpringBootTest
class PdfGeneratorTest {
@Autowired private PdfGenerator pdfGenerator;
@Test
void testGenerate() {
ReportData data = new ReportData();
data.setTemplateName("季度業績報表");
data.setItextUrl("https://itextpdf.com");
data.setFmUrl("https://freemarker.apache.org");
data.setImageUrl("https://example.com/logo.png");
data.setScores(Arrays.asList(85.5, 92.0, 78.5, 96.0, 88.5));
String path = pdfGenerator.generate("report.ftl", data, "/tmp/report.pdf");
assertTrue(new File(path).exists());
assertTrue(new File(path).length() > 1024);
}
}
src/main/java ├── com.example.pdf │ ├── component.PdfGenerator │ ├── component.HeaderFooterEvent │ ├── service.ChartService │ ├── util.FreeMarkerUtil │ └── model.ReportData src/main/resources ├── templates │ └── report.ftl ├── fonts │ ├── PingFangSC-Light.ttf │ └── PingFangSC-Regular.ttf └── application.yml
iText + FreeMarker + JFreeChart 組合優勢互補:
核心成功要素:字體註冊、Base64 圖片嵌入、CSS 分頁控制、Maven 非文本資源保護。
GitHub 完整示例:https://github.com/superad/pdf-kit