From 58674642e7437309534f4ff9e73960951b0e5745 Mon Sep 17 00:00:00 2001 From: puz <13060209078@163.com> Date: Mon, 13 Jul 2026 18:07:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20word=E6=96=87=E6=A1=A3=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../biz/impl/MeetingExportServiceImpl.java | 181 ++--------------- .../biz/impl/MeetingWordDocumentBuilder.java | 188 ++++++++++++++++++ 2 files changed, 210 insertions(+), 159 deletions(-) create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingWordDocumentBuilder.java 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 6c88d3c..82b8640 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 @@ -16,8 +16,6 @@ import com.unisbase.service.TenantManagementService; import lombok.RequiredArgsConstructor; import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode; import org.apache.poi.xwpf.usermodel.XWPFDocument; -import org.apache.poi.xwpf.usermodel.XWPFParagraph; -import org.apache.poi.xwpf.usermodel.XWPFRun; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; @@ -38,6 +36,7 @@ import org.springframework.stereotype.Service; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -48,8 +47,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @Service @RequiredArgsConstructor @@ -96,18 +93,23 @@ public class MeetingExportServiceImpl implements MeetingExportService { Files.createDirectories(exportDir); Path exportPath = exportDir.resolve("summary." + ext); - boolean isPdf = "pdf".equals(ext); + boolean isPdf = "pdf".equals(ext); boolean needRegenerate = !Files.exists(exportPath) || Files.getLastModifiedTime(exportPath).toMillis() < Files.getLastModifiedTime(summarySourcePath).toMillis(); + if (!needRegenerate && "docx".equals(ext) && !isCurrentWordExport(exportPath)) { + needRegenerate = true; + } - byte[] baseBytes; + byte[] baseBytes; if (needRegenerate) { String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8); meetingDetail.setSummaryContent(meetingSummaryFileService.stripFrontMatter(markdown)); - baseBytes = "docx".equals(ext) ? buildWordBytes(meetingDetail) : buildPdfBytes(meetingDetail); - Files.write(exportPath, baseBytes); + baseBytes = "docx".equals(ext) + ? new MeetingWordDocumentBuilder().build(meetingDetail) + : buildPdfBytes(meetingDetail); + Files.write(exportPath, baseBytes); } else { - baseBytes = Files.readAllBytes(exportPath); + baseBytes = Files.readAllBytes(exportPath); } byte[] bytes = isPdf @@ -120,44 +122,17 @@ public class MeetingExportServiceImpl implements MeetingExportService { } } - private byte[] buildWordBytes(MeetingVO meeting) throws IOException { - try (XWPFDocument document = new XWPFDocument(); - ByteArrayOutputStream out = new ByteArrayOutputStream()) { - XWPFParagraph title = document.createParagraph(); - XWPFRun titleRun = title.createRun(); - titleRun.setBold(true); - titleRun.setFontSize(16); - titleRun.setText((meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI Summary"); - - XWPFParagraph timeP = document.createParagraph(); - timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime())); - - XWPFParagraph hostP = document.createParagraph(); - hostP.createRun().setText("Host: " + (meeting.getHostName() == null ? "" : meeting.getHostName())); - - XWPFParagraph participantsP = document.createParagraph(); - participantsP.createRun().setText("Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants())); - - document.createParagraph(); - - for (MdBlock block : parseMarkdownBlocks(meeting.getSummaryContent())) { - XWPFParagraph p = document.createParagraph(); - if (block.type == MdType.HEADING) { - int size = Math.max(12, 18 - (block.level - 1) * 2); - appendMarkdownRuns(p, block.text, true, size); - } else if (block.type == MdType.LIST) { - p.setIndentationLeft(360); - XWPFRun bullet = p.createRun(); - bullet.setFontSize(12); - bullet.setText("- "); - appendMarkdownRuns(p, block.text, false, 12); - } else { - appendMarkdownRuns(p, block.text, false, 12); - } - } - - document.write(out); - return out.toByteArray(); + private boolean isCurrentWordExport(Path exportPath) { + try (InputStream input = Files.newInputStream(exportPath); + XWPFDocument document = new XWPFDocument(input)) { + var property = document.getProperties() + .getCustomProperties() + .getProperty(MeetingWordDocumentBuilder.EXPORT_VERSION_PROPERTY); + return property != null + && property.isSetLpwstr() + && MeetingWordDocumentBuilder.EXPORT_VERSION.equals(property.getLpwstr()); + } catch (Exception ignored) { + return false; } } @@ -419,116 +394,4 @@ public class MeetingExportServiceImpl implements MeetingExportService { } } - private void appendMarkdownRuns(XWPFParagraph paragraph, String text, boolean defaultBold, int size) { - String input = text == null ? "" : text; - Matcher matcher = Pattern.compile("\\*\\*(.+?)\\*\\*").matcher(input); - int start = 0; - while (matcher.find()) { - String normal = toPlainInline(input.substring(start, matcher.start())); - if (!normal.isEmpty()) { - XWPFRun run = paragraph.createRun(); - run.setBold(defaultBold); - run.setFontSize(size); - run.setText(normal); - } - String boldText = toPlainInline(matcher.group(1)); - if (!boldText.isEmpty()) { - XWPFRun run = paragraph.createRun(); - run.setBold(true); - run.setFontSize(size); - run.setText(boldText); - } - start = matcher.end(); - } - String tail = toPlainInline(input.substring(start)); - if (!tail.isEmpty()) { - XWPFRun run = paragraph.createRun(); - run.setBold(defaultBold); - run.setFontSize(size); - run.setText(tail); - } - } - - private List parseMarkdownBlocks(String markdown) { - List blocks = new ArrayList<>(); - if (markdown == null || markdown.trim().isEmpty()) { - return blocks; - } - - String[] lines = markdown.replace("\r\n", "\n").split("\n"); - StringBuilder paragraph = new StringBuilder(); - - for (String raw : lines) { - String line = raw == null ? "" : raw.trim(); - if (line.isEmpty()) { - flushParagraph(blocks, paragraph); - continue; - } - if (line.startsWith("#")) { - flushParagraph(blocks, paragraph); - int level = 0; - while (level < line.length() && line.charAt(level) == '#') { - level++; - } - level = Math.min(level, 6); - blocks.add(new MdBlock(MdType.HEADING, level, line.substring(level).trim())); - continue; - } - if (line.startsWith("- ") || line.startsWith("* ")) { - flushParagraph(blocks, paragraph); - blocks.add(new MdBlock(MdType.LIST, 0, line.substring(2).trim())); - continue; - } - Matcher ordered = Pattern.compile("^\\d+\\.\\s+(.*)$").matcher(line); - if (ordered.find()) { - flushParagraph(blocks, paragraph); - blocks.add(new MdBlock(MdType.LIST, 0, ordered.group(1).trim())); - continue; - } - - if (paragraph.length() > 0) { - paragraph.append(' '); - } - paragraph.append(line); - } - - flushParagraph(blocks, paragraph); - return blocks; - } - - private void flushParagraph(List blocks, StringBuilder paragraph) { - if (paragraph.length() > 0) { - blocks.add(new MdBlock(MdType.PARAGRAPH, 0, paragraph.toString())); - paragraph.setLength(0); - } - } - - private String toPlainInline(String input) { - if (input == null) { - return ""; - } - return input - .replaceAll("`([^`]+)`", "$1") - .replaceAll("\\*\\*(.*?)\\*\\*", "$1") - .replaceAll("\\*(.*?)\\*", "$1") - .replaceAll("\\[(.*?)]\\((.*?)\\)", "$1"); - } - - private enum MdType { - HEADING, - LIST, - PARAGRAPH - } - - private static class MdBlock { - private final MdType type; - private final int level; - private final String text; - - private MdBlock(MdType type, int level, String text) { - this.type = type; - this.level = level; - this.text = text; - } - } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingWordDocumentBuilder.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingWordDocumentBuilder.java new file mode 100644 index 0000000..4f3c662 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingWordDocumentBuilder.java @@ -0,0 +1,188 @@ +package com.imeeting.service.biz.impl; + +import com.imeeting.dto.biz.MeetingVO; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class MeetingWordDocumentBuilder { + + static final String EXPORT_VERSION_PROPERTY = "iMeetingWordExportVersion"; + static final String EXPORT_VERSION = "3"; + private static final Pattern MIXED_CONTENT_SPACING = Pattern.compile( + "(?<=\\p{IsHan})[\\h\\u200B]+(?=[\\p{IsLatin}\\p{N}])" + + "|(?<=[\\p{IsLatin}\\p{N}])[\\h\\u200B]+(?=\\p{IsHan})" + ); + + byte[] build(MeetingVO meeting) throws IOException { + try (XWPFDocument document = new XWPFDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + document.getProperties().getCustomProperties() + .addProperty(EXPORT_VERSION_PROPERTY, EXPORT_VERSION); + + XWPFParagraph title = document.createParagraph(); + XWPFRun titleRun = title.createRun(); + titleRun.setBold(true); + titleRun.setFontSize(16); + titleRun.setText((meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI Summary"); + + XWPFParagraph timeP = document.createParagraph(); + timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime())); + + XWPFParagraph hostP = document.createParagraph(); + hostP.createRun().setText("Host: " + (meeting.getHostName() == null ? "" : meeting.getHostName())); + + XWPFParagraph participantsP = document.createParagraph(); + participantsP.createRun().setText("Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants())); + + document.createParagraph(); + + String summaryContent = normalizeMixedContentSpacing(meeting.getSummaryContent()); + for (MdBlock block : parseMarkdownBlocks(summaryContent)) { + XWPFParagraph paragraph = document.createParagraph(); + if (block.type == MdType.HEADING) { + int size = Math.max(12, 18 - (block.level - 1) * 2); + appendMarkdownRuns(paragraph, block.text, true, size); + } else if (block.type == MdType.LIST) { + paragraph.setIndentationLeft(360); + XWPFRun bullet = paragraph.createRun(); + bullet.setFontSize(12); + bullet.setText("- "); + appendMarkdownRuns(paragraph, block.text, false, 12); + } else { + appendMarkdownRuns(paragraph, block.text, false, 12); + } + } + + document.write(out); + return out.toByteArray(); + } + } + + private String normalizeMixedContentSpacing(String markdown) { + if (markdown == null) { + return null; + } + return MIXED_CONTENT_SPACING.matcher(markdown).replaceAll(""); + } + + private void appendMarkdownRuns(XWPFParagraph paragraph, String text, boolean defaultBold, int size) { + String input = text == null ? "" : text; + Matcher matcher = Pattern.compile("\\*\\*(.+?)\\*\\*").matcher(input); + int start = 0; + while (matcher.find()) { + String normal = toPlainInline(input.substring(start, matcher.start())); + if (!normal.isEmpty()) { + XWPFRun run = paragraph.createRun(); + run.setBold(defaultBold); + run.setFontSize(size); + run.setText(normal); + } + String boldText = toPlainInline(matcher.group(1)); + if (!boldText.isEmpty()) { + XWPFRun run = paragraph.createRun(); + run.setBold(true); + run.setFontSize(size); + run.setText(boldText); + } + start = matcher.end(); + } + String tail = toPlainInline(input.substring(start)); + if (!tail.isEmpty()) { + XWPFRun run = paragraph.createRun(); + run.setBold(defaultBold); + run.setFontSize(size); + run.setText(tail); + } + } + + private List parseMarkdownBlocks(String markdown) { + List blocks = new ArrayList<>(); + if (markdown == null || markdown.trim().isEmpty()) { + return blocks; + } + + String[] lines = markdown.replace("\r\n", "\n").split("\n"); + StringBuilder paragraph = new StringBuilder(); + + for (String raw : lines) { + String line = raw == null ? "" : raw.trim(); + if (line.isEmpty()) { + flushParagraph(blocks, paragraph); + continue; + } + if (line.startsWith("#")) { + flushParagraph(blocks, paragraph); + int level = 0; + while (level < line.length() && line.charAt(level) == '#') { + level++; + } + level = Math.min(level, 6); + blocks.add(new MdBlock(MdType.HEADING, level, line.substring(level).trim())); + continue; + } + if (line.startsWith("- ") || line.startsWith("* ")) { + flushParagraph(blocks, paragraph); + blocks.add(new MdBlock(MdType.LIST, 0, line.substring(2).trim())); + continue; + } + Matcher ordered = Pattern.compile("^\\d+\\.\\s+(.*)$").matcher(line); + if (ordered.find()) { + flushParagraph(blocks, paragraph); + blocks.add(new MdBlock(MdType.LIST, 0, ordered.group(1).trim())); + continue; + } + + if (paragraph.length() > 0) { + paragraph.append(' '); + } + paragraph.append(line); + } + + flushParagraph(blocks, paragraph); + return blocks; + } + + private void flushParagraph(List blocks, StringBuilder paragraph) { + if (paragraph.length() > 0) { + blocks.add(new MdBlock(MdType.PARAGRAPH, 0, paragraph.toString())); + paragraph.setLength(0); + } + } + + private String toPlainInline(String input) { + if (input == null) { + return ""; + } + return input + .replaceAll("`([^`]+)`", "$1") + .replaceAll("\\*\\*(.*?)\\*\\*", "$1") + .replaceAll("\\*(.*?)\\*", "$1") + .replaceAll("\\[(.*?)]\\((.*?)\\)", "$1"); + } + + private enum MdType { + HEADING, + LIST, + PARAGRAPH + } + + private static class MdBlock { + private final MdType type; + private final int level; + private final String text; + + private MdBlock(MdType type, int level, String text) { + this.type = type; + this.level = level; + this.text = text; + } + } +}