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;