feat: PDF文档调整

dev_na
puz 2026-07-14 14:15:23 +08:00
parent cd4b5365d6
commit c8df0d9604
1 changed files with 142 additions and 39 deletions

View File

@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor;
import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
@ -29,6 +30,8 @@ import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.parser.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
@ -59,6 +62,8 @@ public class MeetingExportServiceImpl implements MeetingExportService {
private static final float WATERMARK_LINE_GAP = 34f;
private static final float WATERMARK_BASE_STEP_X = 430f;
private static final float WATERMARK_BASE_STEP_Y = 300f;
private static final String PDF_EXPORT_VERSION_PROPERTY = "iMeetingPdfExportVersion";
private static final String PDF_EXPORT_VERSION = "3";
private final MeetingSummaryFileService meetingSummaryFileService;
private final SysTenantUserService sysTenantUserService;
@ -99,6 +104,9 @@ public class MeetingExportServiceImpl implements MeetingExportService {
if (!needRegenerate && "docx".equals(ext) && !isCurrentWordExport(exportPath)) {
needRegenerate = true;
}
if (!needRegenerate && isPdf && !isCurrentPdfExport(exportPath)) {
needRegenerate = true;
}
byte[] baseBytes;
if (needRegenerate) {
@ -136,45 +144,18 @@ public class MeetingExportServiceImpl implements MeetingExportService {
}
}
private boolean isCurrentPdfExport(Path exportPath) {
try (PDDocument document = PDDocument.load(exportPath.toFile())) {
String version = document.getDocumentInformation()
.getCustomMetadataValue(PDF_EXPORT_VERSION_PROPERTY);
return PDF_EXPORT_VERSION.equals(version);
} catch (Exception ignored) {
return false;
}
}
private byte[] buildPdfBytes(MeetingVO meeting) throws IOException {
Parser parser = Parser.builder().build();
String markdown = meeting.getSummaryContent() == null ? "" : meeting.getSummaryContent();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().build();
String htmlBody = renderer.render(document);
String title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle();
String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString();
String host = meeting.getHostName() == null ? "" : meeting.getHostName();
String participants = meeting.getParticipants() == null ? "" : meeting.getParticipants();
String html = "<html><head><style>" +
"body { font-family: 'NotoSansSC', 'SimSun', sans-serif; padding: 20px; line-height: 1.6; color: #333; }" +
"h1, h2, h3 { color: #1890ff; border-bottom: 1px solid #eee; padding-bottom: 5px; }" +
"table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }" +
"table, th, td { border: 1px solid #ddd; }" +
"th, td { padding: 8px 12px; text-align: left; }" +
"th { background-color: #f5f5f5; font-weight: bold; }" +
"blockquote { padding: 8px 16px; color: #666; border-left: 4px solid #1890ff; background: #f0f7ff; margin: 0 0 16px 0; }" +
"</style></head><body>" +
"<div style='text-align:center; margin-bottom:30px; border-bottom: 2px solid #1890ff; padding-bottom:20px;'>" +
"<h1 style='font-size:28px; margin-bottom:12px; color:#000; border:none;'>" + title + "</h1>" +
"<div style='font-size:14px; color:#666;'>" +
"<span>Meeting Time: " + time + "</span>" +
"<span style='margin: 0 20px;'>|</span>" +
"<span>Host: " + host + "</span>" +
"<span style='margin: 0 20px;'>|</span>" +
"<span>Participants: " + participants + "</span>" +
"</div></div>" +
"<div class='markdown-body'>" + htmlBody + "</div>" +
"<div style='margin-top: 40px; text-align: right; font-size: 12px; color: #999; border-top: 1px dashed #eee; padding-top: 10px;'>" +
"Generated by iMeeting AI Assistant" +
"</div>" +
"</body></html>";
org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html);
jsoupDoc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml);
String xhtml = jsoupDoc.html();
String xhtml = buildPdfXhtml(meeting);
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PdfRendererBuilder builder = new PdfRendererBuilder();
@ -204,12 +185,134 @@ public class MeetingExportServiceImpl implements MeetingExportService {
builder.withHtmlContent(xhtml, null);
builder.toStream(out);
builder.run();
return out.toByteArray();
return markCurrentPdfExport(out.toByteArray());
} catch (Exception ex) {
throw new IOException("PDF 生成失败", ex);
}
}
private byte[] markCurrentPdfExport(byte[] pdfBytes) throws IOException {
try (PDDocument document = PDDocument.load(pdfBytes);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PDDocumentInformation information = document.getDocumentInformation();
information.setCustomMetadataValue(PDF_EXPORT_VERSION_PROPERTY, PDF_EXPORT_VERSION);
document.setDocumentInformation(information);
document.save(out);
return out.toByteArray();
}
}
String buildPdfXhtml(MeetingVO meeting) {
Parser parser = Parser.builder().build();
String markdown = meeting.getSummaryContent() == null ? "" : meeting.getSummaryContent();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().build();
String htmlBody = renderer.render(document);
String title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle();
String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString();
String host = meeting.getHostName() == null ? "" : meeting.getHostName();
String participants = meeting.getParticipants() == null ? "" : meeting.getParticipants();
String html = "<html><head><style>" +
"body { font-family: 'NotoSansSC', 'SimSun', sans-serif; padding: 20px; line-height: 1.6; color: #333; }" +
"h1, h2, h3 { color: #1890ff; border-bottom: 1px solid #eee; padding-bottom: 5px; }" +
".markdown-body { font-size: 14px; }" +
".markdown-body p { margin: 0 0 8px 0; }" +
".pdf-list-item { margin: 2px 0 4px 0; padding-left: 10px; text-indent: -10px; line-height: 1.6; }" +
".pdf-list-level-1 { margin-left: 22px; }" +
".pdf-list-level-2 { margin-left: 44px; }" +
".pdf-list-level-3 { margin-left: 66px; }" +
".pdf-list-marker { display: inline-block; width: 10px; font-size: 12px; line-height: 1; vertical-align: 0.06em; color: #333; }" +
".pdf-list-content { display: inline; }" +
"table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }" +
"table, th, td { border: 1px solid #ddd; }" +
"th, td { padding: 8px 12px; text-align: left; }" +
"th { background-color: #f5f5f5; font-weight: bold; }" +
"blockquote { padding: 8px 16px; color: #666; border-left: 4px solid #1890ff; background: #f0f7ff; margin: 0 0 16px 0; }" +
"</style></head><body>" +
"<div style='text-align:center; margin-bottom:30px; border-bottom: 2px solid #1890ff; padding-bottom:20px;'>" +
"<h1 style='font-size:28px; margin-bottom:12px; color:#000; border:none;'>" + title + "</h1>" +
"<div style='font-size:14px; color:#666;'>" +
"<span>Meeting Time: " + time + "</span>" +
"<span style='margin: 0 20px;'>|</span>" +
"<span>Host: " + host + "</span>" +
"<span style='margin: 0 20px;'>|</span>" +
"<span>Participants: " + participants + "</span>" +
"</div></div>" +
"<div class='markdown-body'>" + htmlBody + "</div>" +
"<div style='margin-top: 40px; text-align: right; font-size: 12px; color: #999; border-top: 1px dashed #eee; padding-top: 10px;'>" +
"Generated by iMeeting AI Assistant" +
"</div>" +
"</body></html>";
org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html);
normalizeMarkdownLists(jsoupDoc.selectFirst(".markdown-body"), 0);
jsoupDoc.outputSettings().syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml);
return jsoupDoc.html();
}
private void normalizeMarkdownLists(Element container, int level) {
if (container == null) {
return;
}
for (Element child : new ArrayList<>(container.children())) {
if (isListElement(child)) {
for (org.jsoup.nodes.Node node : toPdfListRows(child, level)) {
child.before(node);
}
child.remove();
} else {
normalizeMarkdownLists(child, level);
}
}
}
private List<org.jsoup.nodes.Node> toPdfListRows(Element list, int level) {
List<org.jsoup.nodes.Node> rows = new ArrayList<>();
for (Element item : list.children()) {
if (!"li".equalsIgnoreCase(item.tagName())) {
continue;
}
Element row = new Element(Tag.valueOf("p"), "");
row.addClass("pdf-list-item");
row.addClass("pdf-list-level-" + Math.min(level, 3));
Element marker = new Element(Tag.valueOf("span"), "");
marker.addClass("pdf-list-marker");
marker.text("•");
row.appendChild(marker);
Element content = new Element(Tag.valueOf("span"), "");
content.addClass("pdf-list-content");
List<Element> nestedLists = new ArrayList<>();
for (org.jsoup.nodes.Node node : new ArrayList<>(item.childNodes())) {
if (node instanceof Element element && isListElement(element)) {
nestedLists.add(element);
} else if (node instanceof Element element && "p".equalsIgnoreCase(element.tagName())) {
for (org.jsoup.nodes.Node paragraphNode : new ArrayList<>(element.childNodes())) {
content.appendChild(paragraphNode);
}
element.remove();
} else {
content.appendChild(node);
}
}
row.appendChild(content);
rows.add(row);
for (Element nestedList : nestedLists) {
rows.addAll(toPdfListRows(nestedList, level + 1));
}
}
return rows;
}
private boolean isListElement(Element element) {
return "ul".equalsIgnoreCase(element.tagName()) || "ol".equalsIgnoreCase(element.tagName());
}
private byte[] applyPdfWatermark(byte[] pdfBytes, String watermarkText) throws IOException {
if (watermarkText == null || watermarkText.isBlank()) {
return pdfBytes;