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 82b8640..1b3aa38 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 @@ -116,7 +116,7 @@ public class MeetingExportServiceImpl implements MeetingExportService { ? applyPdfWatermark(baseBytes, resolveWatermarkText(loginUser)) : baseBytes; - return new MeetingSummaryExportResult(bytes, contentType, safeTitle + "-AI-Summary." + ext); + return new MeetingSummaryExportResult(bytes, contentType, safeTitle + "." + ext); } catch (IOException ex) { throw new RuntimeException("导出失败:" + ex.getMessage(), ex); } 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 index 4f3c662..b5a97ba 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingWordDocumentBuilder.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingWordDocumentBuilder.java @@ -1,12 +1,21 @@ package com.imeeting.service.biz.impl; import com.imeeting.dto.biz.MeetingVO; +import org.apache.poi.xwpf.usermodel.LineSpacingRule; +import org.apache.poi.xwpf.usermodel.ParagraphAlignment; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBody; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STPageOrientation; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.math.BigInteger; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; @@ -15,7 +24,20 @@ import java.util.regex.Pattern; final class MeetingWordDocumentBuilder { static final String EXPORT_VERSION_PROPERTY = "iMeetingWordExportVersion"; - static final String EXPORT_VERSION = "3"; + static final String EXPORT_VERSION = "12"; + private static final String DOCUMENT_FONT = "仿宋_GB2312"; + private static final int BODY_FONT_SIZE = 14; + private static final int BODY_FIRST_LINE_INDENT = 640; + private static final int BODY_LINE_SPACING = 29; + private static final DateTimeFormatter MEETING_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final Pattern BODY_STYLE_LIST_PREFIX = Pattern.compile( + "^\\*\\*([^*\\r\\n::]+?)(?:\\*\\*[\\h\\u200B]*([::])|([::])[\\h\\u200B]*\\*\\*)" + ); + private static final Pattern FULLY_BOLD_LIST_ITEM = Pattern.compile( + "^\\*\\*([^*\\r\\n]+)\\*\\*$" + ); + private static final Pattern QUOTE_PREFIX = Pattern.compile("^[>>]+[\\h\\u200B]*"); + private static final Pattern HORIZONTAL_RULE = Pattern.compile("^[-*_](?:[\\h\\u200B]*[-*_]){2,}$"); 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})" @@ -26,46 +48,55 @@ final class MeetingWordDocumentBuilder { ByteArrayOutputStream out = new ByteArrayOutputStream()) { document.getProperties().getCustomProperties() .addProperty(EXPORT_VERSION_PROPERTY, EXPORT_VERSION); + applyPageLayout(document); XWPFParagraph title = document.createParagraph(); + title.setAlignment(ParagraphAlignment.CENTER); + title.setSpacingAfter(240); XWPFRun titleRun = title.createRun(); titleRun.setBold(true); - titleRun.setFontSize(16); - titleRun.setText((meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI Summary"); + titleRun.setFontSize(22); + titleRun.setText(meeting.getTitle() == null ? "Meeting" : meeting.getTitle()); XWPFParagraph timeP = document.createParagraph(); - timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime())); + appendMetadata(timeP, "会议时间:" + formatMeetingTime(meeting)); - 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(); + XWPFParagraph separator = document.createParagraph(); + separator.setSpacingAfter(120); 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); + applyHeadingParagraphStyle(paragraph); + int size = Math.max(12, 16 - (block.level - 1) * 2); appendMarkdownRuns(paragraph, block.text, true, size); + } else if (block.type == MdType.QUOTE) { + applyBodyParagraphStyle(paragraph); + appendPlainText(paragraph, block.text, BODY_FONT_SIZE); + } else if (block.type == MdType.LIST && isBodyStyleListItem(block.text)) { + applyBodyParagraphStyle(paragraph); + appendMarkdownRuns(paragraph, demoteBodyStyleListPrefix(block.text), false, BODY_FONT_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); + applyBodyParagraphStyle(paragraph); + appendMarkdownRuns(paragraph, block.text, false, BODY_FONT_SIZE); } else { - appendMarkdownRuns(paragraph, block.text, false, 12); + applyBodyParagraphStyle(paragraph); + appendMarkdownRuns(paragraph, block.text, false, BODY_FONT_SIZE); } } + appendGeneratedByFooter(document); + applyDocumentFont(document); document.write(out); return out.toByteArray(); } } + private String formatMeetingTime(MeetingVO meeting) { + return meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().format(MEETING_TIME_FORMATTER); + } + private String normalizeMixedContentSpacing(String markdown) { if (markdown == null) { return null; @@ -73,6 +104,119 @@ final class MeetingWordDocumentBuilder { return MIXED_CONTENT_SPACING.matcher(markdown).replaceAll(""); } + private boolean isBodyStyleListItem(String text) { + if (text == null) { + return false; + } + Matcher prefixMatcher = BODY_STYLE_LIST_PREFIX.matcher(text); + if (prefixMatcher.find() && !text.substring(prefixMatcher.end()).isBlank()) { + return true; + } + Matcher fullBoldMatcher = FULLY_BOLD_LIST_ITEM.matcher(text); + if (!fullBoldMatcher.matches()) { + return false; + } + String plainText = fullBoldMatcher.group(1).trim(); + int colonIndex = firstTitleColonIndex(plainText); + return colonIndex > 0 && colonIndex < plainText.length() - 1; + } + + private String demoteBodyStyleListPrefix(String text) { + return toPlainInline(text).trim() + .replaceFirst("[\\h\\u200B]*([::])[\\h\\u200B]*", "$1"); + } + + private int firstTitleColonIndex(String text) { + for (int index = 0; index < text.length(); index++) { + char current = text.charAt(index); + if (current == ':') { + return index; + } + if (current == ':' + && (index == 0 || !Character.isDigit(text.charAt(index - 1))) + && (index == text.length() - 1 || !Character.isDigit(text.charAt(index + 1)))) { + return index; + } + } + return -1; + } + + private void applyBodyParagraphStyle(XWPFParagraph paragraph) { + paragraph.setAlignment(ParagraphAlignment.BOTH); + paragraph.setFirstLineIndent(BODY_FIRST_LINE_INDENT); + paragraph.setSpacingBetween(BODY_LINE_SPACING, LineSpacingRule.EXACT); + paragraph.setSpacingBefore(0); + paragraph.setSpacingAfter(0); + } + + private void applyHeadingParagraphStyle(XWPFParagraph paragraph) { + if (!paragraph.getCTP().isSetPPr()) { + paragraph.getCTP().addNewPPr(); + } + paragraph.setKeepNext(true); + paragraph.setSpacingBefore(240); + paragraph.setSpacingAfter(120); + } + + private void appendMetadata(XWPFParagraph paragraph, String text) { + paragraph.setAlignment(ParagraphAlignment.RIGHT); + paragraph.setSpacingAfter(80); + XWPFRun run = paragraph.createRun(); + run.setFontSize(12); + run.setText(text); + } + + private void appendPlainText(XWPFParagraph paragraph, String text, int size) { + String plainText = toPlainInline(text); + if (plainText.isEmpty()) { + return; + } + XWPFRun run = paragraph.createRun(); + run.setFontSize(size); + run.setText(plainText); + } + + private void appendGeneratedByFooter(XWPFDocument document) { + XWPFParagraph paragraph = document.createParagraph(); + paragraph.setAlignment(ParagraphAlignment.CENTER); + paragraph.setSpacingBefore(480); + paragraph.setSpacingAfter(0); + + XWPFRun run = paragraph.createRun(); + run.setItalic(true); + run.setFontSize(10); + run.setText("—— 内容由智听云AI生成 ——"); + } + + private void applyPageLayout(XWPFDocument document) { + CTBody body = document.getDocument().getBody(); + CTSectPr section = body.isSetSectPr() ? body.getSectPr() : body.addNewSectPr(); + CTPageSz pageSize = section.isSetPgSz() ? section.getPgSz() : section.addNewPgSz(); + pageSize.setW(BigInteger.valueOf(11906)); + pageSize.setH(BigInteger.valueOf(16838)); + pageSize.setOrient(STPageOrientation.PORTRAIT); + + CTPageMar margins = section.isSetPgMar() ? section.getPgMar() : section.addNewPgMar(); + margins.setTop(BigInteger.valueOf(2098)); + margins.setRight(BigInteger.valueOf(1531)); + margins.setBottom(BigInteger.valueOf(1871)); + margins.setLeft(BigInteger.valueOf(1531)); + margins.setHeader(BigInteger.valueOf(708)); + margins.setFooter(BigInteger.valueOf(708)); + margins.setGutter(BigInteger.ZERO); + } + + private void applyDocumentFont(XWPFDocument document) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + for (XWPFRun run : paragraph.getRuns()) { + run.setFontFamily(DOCUMENT_FONT, XWPFRun.FontCharRange.eastAsia); + run.setFontFamily(DOCUMENT_FONT, XWPFRun.FontCharRange.ascii); + run.setFontFamily(DOCUMENT_FONT, XWPFRun.FontCharRange.hAnsi); + run.setFontFamily(DOCUMENT_FONT, XWPFRun.FontCharRange.cs); + } + } + } + private void appendMarkdownRuns(XWPFParagraph paragraph, String text, boolean defaultBold, int size) { String input = text == null ? "" : text; Matcher matcher = Pattern.compile("\\*\\*(.+?)\\*\\*").matcher(input); @@ -118,6 +262,10 @@ final class MeetingWordDocumentBuilder { flushParagraph(blocks, paragraph); continue; } + if (HORIZONTAL_RULE.matcher(line).matches()) { + flushParagraph(blocks, paragraph); + continue; + } if (line.startsWith("#")) { flushParagraph(blocks, paragraph); int level = 0; @@ -128,6 +276,11 @@ final class MeetingWordDocumentBuilder { 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.QUOTE, 0, QUOTE_PREFIX.matcher(line).replaceFirst(""))); + continue; + } if (line.startsWith("- ") || line.startsWith("* ")) { flushParagraph(blocks, paragraph); blocks.add(new MdBlock(MdType.LIST, 0, line.substring(2).trim())); @@ -170,6 +323,7 @@ final class MeetingWordDocumentBuilder { private enum MdType { HEADING, + QUOTE, LIST, PARAGRAPH }