Fix PDF font loading for project overview reports

This commit is contained in:
wkc
2026-05-08 10:51:42 +08:00
parent 37e17ac903
commit 3ef45bc398
13 changed files with 416 additions and 41 deletions

View File

@@ -14,9 +14,11 @@ import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.file.FileUtils;
import jakarta.servlet.http.HttpServletResponse;
import java.awt.Color;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.text.DecimalFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -32,6 +34,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
@@ -43,6 +46,11 @@ public class CcdiProjectOverviewReportPdfExporter {
private static final String CONTENT_TYPE = "application/pdf";
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("#,##0.00");
private final String pdfFontPath;
public CcdiProjectOverviewReportPdfExporter(@Value("${ccdi.report.pdf-font-path:}") String pdfFontPath) {
this.pdfFontPath = pdfFontPath;
}
public void export(HttpServletResponse response, CcdiProjectOverviewReportVO report) throws IOException {
response.setContentType(CONTENT_TYPE);
@@ -51,9 +59,8 @@ public class CcdiProjectOverviewReportPdfExporter {
safeFileName(report.getProject().getProjectName()) + "_初核结果报告.pdf"
);
try (PDDocument document = new PDDocument()) {
PDType0Font font = loadChineseFont(document);
PdfPageWriter writer = new PdfPageWriter(document, font);
try (PDDocument document = new PDDocument(); LoadedChineseFont loadedFont = loadChineseFont(document)) {
PdfPageWriter writer = new PdfPageWriter(document, loadedFont.font());
writer.newPage();
writeCover(writer, report);
writeUploadSubjects(writer, report.getUploadSubjects());
@@ -204,44 +211,113 @@ public class CcdiProjectOverviewReportPdfExporter {
);
}
private PDType0Font loadChineseFont(PDDocument document) throws IOException {
List<String> candidates = List.of(
"C:/Windows/Fonts/NotoSansSC-VF.ttf",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsunb.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttf",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"
);
for (String path : candidates) {
File file = new File(path);
if (!file.exists() || !file.isFile()) {
continue;
}
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".ttf")) {
return PDType0Font.load(document, file);
}
if (lowerPath.endsWith(".ttc")) {
PDType0Font font = loadFirstCollectionFont(document, file);
if (font != null) {
return font;
}
}
private LoadedChineseFont loadChineseFont(PDDocument document) throws IOException {
if (pdfFontPath == null || pdfFontPath.isBlank()) {
throw new ServiceException("未配置PDF中文字体路径无法导出PDF报告");
}
throw new ServiceException("未找到可用中文字体无法导出PDF报告");
String path = pdfFontPath.trim();
File file = new File(path);
if (!file.exists() || !file.isFile()) {
throw new ServiceException("配置的PDF中文字体路径不可用" + path);
}
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".ttf")) {
return new LoadedChineseFont(PDType0Font.load(document, file), null);
}
if (lowerPath.endsWith(".ttc")) {
LoadedChineseFont loadedFont = loadPreferredCollectionFont(document, file);
if (loadedFont != null) {
return loadedFont;
}
throw new ServiceException("配置的PDF中文字体不支持导出" + path);
}
if (lowerPath.endsWith(".otf")) {
return new LoadedChineseFont(loadOpenTypeFont(document, file), null);
}
throw new ServiceException("配置的PDF中文字体格式不支持" + path);
}
private PDType0Font loadFirstCollectionFont(PDDocument document, File file) throws IOException {
private PDType0Font loadOpenTypeFont(PDDocument document, File file) throws IOException {
try (var inputStream = Files.newInputStream(file.toPath())) {
return PDType0Font.load(document, inputStream, false);
}
}
private LoadedChineseFont loadPreferredCollectionFont(PDDocument document, File file) throws IOException {
LoadedChineseFont font = loadCollectionFontByName(document, file);
return font == null ? loadFirstCollectionFont(document, file) : font;
}
private LoadedChineseFont loadCollectionFontByName(PDDocument document, File file) throws IOException {
TrueTypeCollection collection = new TrueTypeCollection(file);
AtomicReference<PDType0Font> font = new AtomicReference<>();
try (TrueTypeCollection collection = new TrueTypeCollection(file)) {
try {
collection.processAllFonts(typeFont -> {
if (font.get() == null) {
if (font.get() == null && supportsTrueTypeSubset(typeFont)
&& isSimplifiedChineseFont(typeFont.getName())) {
font.set(PDType0Font.load(document, typeFont, true));
}
});
if (font.get() != null) {
return new LoadedChineseFont(font.get(), collection);
}
} finally {
if (font.get() == null) {
collection.close();
}
}
return null;
}
private boolean isSimplifiedChineseFont(String fontName) {
if (fontName == null) {
return false;
}
String lowerName = fontName.toLowerCase();
return lowerName.contains("sc")
|| lowerName.contains("hans")
|| lowerName.contains("gb")
|| lowerName.contains("hei")
|| lowerName.contains("song")
|| lowerName.contains("yahei")
|| lowerName.contains("simsun")
|| lowerName.contains("simhei");
}
private LoadedChineseFont loadFirstCollectionFont(PDDocument document, File file) throws IOException {
TrueTypeCollection collection = new TrueTypeCollection(file);
AtomicReference<PDType0Font> font = new AtomicReference<>();
try {
collection.processAllFonts(typeFont -> {
if (font.get() == null && supportsTrueTypeSubset(typeFont)) {
font.set(PDType0Font.load(document, typeFont, true));
}
});
if (font.get() != null) {
return new LoadedChineseFont(font.get(), collection);
}
} finally {
if (font.get() == null) {
collection.close();
}
}
return null;
}
private boolean supportsTrueTypeSubset(org.apache.fontbox.ttf.TrueTypeFont typeFont) throws IOException {
return typeFont.getTableMap().containsKey("glyf");
}
private record LoadedChineseFont(PDType0Font font, Closeable fontSource) implements Closeable {
@Override
public void close() throws IOException {
if (fontSource != null) {
fontSource.close();
}
}
return font.get();
}
private List<IndexedUploadSubject> indexedRows(List<CcdiProjectOverviewReportUploadSubjectVO> rows) {

View File

@@ -12,6 +12,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.common.exception.ServiceException;
import java.io.File;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -19,13 +21,14 @@ import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewReportPdfExporterTest {
@Test
void shouldExportOverviewReportPdf() throws Exception {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter();
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter(resolveTestFontPath());
MockHttpServletResponse response = new MockHttpServletResponse();
exporter.export(response, buildReport());
@@ -36,6 +39,41 @@ class CcdiProjectOverviewReportPdfExporterTest {
assertTrue(response.getContentAsByteArray().length > 1000);
}
@Test
void shouldRejectBlankPdfFontPath() {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter("");
MockHttpServletResponse response = new MockHttpServletResponse();
ServiceException exception = assertThrows(ServiceException.class, () -> exporter.export(response, buildReport()));
assertEquals("未配置PDF中文字体路径无法导出PDF报告", exception.getMessage());
}
@Test
void shouldRejectUnavailablePdfFontPath() {
String missingPath = "/tmp/ccdi-missing-pdf-font-" + System.nanoTime() + ".ttc";
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter(missingPath);
MockHttpServletResponse response = new MockHttpServletResponse();
ServiceException exception = assertThrows(ServiceException.class, () -> exporter.export(response, buildReport()));
assertTrue(exception.getMessage().contains(missingPath));
}
private String resolveTestFontPath() {
List<String> candidates = List.of(
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Supplemental/Songti.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"
);
return candidates.stream()
.filter(path -> new File(path).isFile())
.findFirst()
.orElse(candidates.get(0));
}
private CcdiProjectOverviewReportVO buildReport() {
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
CcdiProject project = new CcdiProject();