From c8df0d9604457c3e907a5c0144dcbc17fe74b205 Mon Sep 17 00:00:00 2001
From: puz <13060209078@163.com>
Date: Tue, 14 Jul 2026 14:15:23 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20PDF=E6=96=87=E6=A1=A3=E8=B0=83=E6=95=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../biz/impl/MeetingExportServiceImpl.java | 181 ++++++++++++++----
1 file changed, 142 insertions(+), 39 deletions(-)
diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java
index 1b3aa38..5aac1e3 100644
--- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java
+++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java
@@ -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 = "
" +
- "" +
- "
" + title + "
" +
- "
" +
- "Meeting Time: " + time + "" +
- "|" +
- "Host: " + host + "" +
- "|" +
- "Participants: " + participants + "" +
- "
" +
- "" + htmlBody + "
" +
- "" +
- "Generated by iMeeting AI Assistant" +
- "
" +
- "";
-
- 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 = "" +
+ "" +
+ "
" + title + "
" +
+ "
" +
+ "Meeting Time: " + time + "" +
+ "|" +
+ "Host: " + host + "" +
+ "|" +
+ "Participants: " + participants + "" +
+ "
" +
+ "" + htmlBody + "
" +
+ "" +
+ "Generated by iMeeting AI Assistant" +
+ "
" +
+ "";
+
+ 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 toPdfListRows(Element list, int level) {
+ List 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 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;