Compare commits
4 Commits
66f84af96c
...
5df4f3d840
| Author | SHA1 | Date |
|---|---|---|
|
|
5df4f3d840 | |
|
|
c8df0d9604 | |
|
|
cd4b5365d6 | |
|
|
58674642e7 |
|
|
@ -16,9 +16,8 @@ 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.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||
|
|
@ -31,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;
|
||||
|
|
@ -38,6 +39,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 +50,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
|
||||
|
|
@ -62,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;
|
||||
|
|
@ -96,110 +98,64 @@ 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;
|
||||
}
|
||||
if (!needRegenerate && isPdf && !isCurrentPdfExport(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
|
||||
? 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);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
|
@ -229,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;
|
||||
|
|
@ -419,116 +497,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<MdBlock> parseMarkdownBlocks(String markdown) {
|
||||
List<MdBlock> 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<MdBlock> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
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;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class MeetingWordDocumentBuilder {
|
||||
|
||||
static final String EXPORT_VERSION_PROPERTY = "iMeetingWordExportVersion";
|
||||
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})"
|
||||
);
|
||||
|
||||
byte[] build(MeetingVO meeting) throws IOException {
|
||||
try (XWPFDocument document = new XWPFDocument();
|
||||
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(22);
|
||||
titleRun.setText(meeting.getTitle() == null ? "Meeting" : meeting.getTitle());
|
||||
|
||||
XWPFParagraph timeP = document.createParagraph();
|
||||
appendMetadata(timeP, "会议时间:" + formatMeetingTime(meeting));
|
||||
|
||||
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) {
|
||||
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) {
|
||||
applyBodyParagraphStyle(paragraph);
|
||||
appendMarkdownRuns(paragraph, block.text, false, BODY_FONT_SIZE);
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
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<MdBlock> parseMarkdownBlocks(String markdown) {
|
||||
List<MdBlock> 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 (HORIZONTAL_RULE.matcher(line).matches()) {
|
||||
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.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()));
|
||||
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<MdBlock> 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,
|
||||
QUOTE,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue