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 lombok.RequiredArgsConstructor;
|
||||||
import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode;
|
import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode;
|
||||||
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
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.PDDocument;
|
||||||
|
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||||
import org.apache.pdfbox.pdmodel.PDPage;
|
import org.apache.pdfbox.pdmodel.PDPage;
|
||||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||||
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
|
||||||
|
|
@ -31,6 +30,8 @@ import org.commonmark.node.Node;
|
||||||
import org.commonmark.parser.Parser;
|
import org.commonmark.parser.Parser;
|
||||||
import org.commonmark.renderer.html.HtmlRenderer;
|
import org.commonmark.renderer.html.HtmlRenderer;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Element;
|
||||||
|
import org.jsoup.parser.Tag;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
@ -38,6 +39,7 @@ import org.springframework.stereotype.Service;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
@ -48,8 +50,6 @@ import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|
@ -62,6 +62,8 @@ public class MeetingExportServiceImpl implements MeetingExportService {
|
||||||
private static final float WATERMARK_LINE_GAP = 34f;
|
private static final float WATERMARK_LINE_GAP = 34f;
|
||||||
private static final float WATERMARK_BASE_STEP_X = 430f;
|
private static final float WATERMARK_BASE_STEP_X = 430f;
|
||||||
private static final float WATERMARK_BASE_STEP_Y = 300f;
|
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 MeetingSummaryFileService meetingSummaryFileService;
|
||||||
private final SysTenantUserService sysTenantUserService;
|
private final SysTenantUserService sysTenantUserService;
|
||||||
|
|
@ -96,110 +98,64 @@ public class MeetingExportServiceImpl implements MeetingExportService {
|
||||||
Files.createDirectories(exportDir);
|
Files.createDirectories(exportDir);
|
||||||
|
|
||||||
Path exportPath = exportDir.resolve("summary." + ext);
|
Path exportPath = exportDir.resolve("summary." + ext);
|
||||||
boolean isPdf = "pdf".equals(ext);
|
boolean isPdf = "pdf".equals(ext);
|
||||||
boolean needRegenerate = !Files.exists(exportPath)
|
boolean needRegenerate = !Files.exists(exportPath)
|
||||||
|| Files.getLastModifiedTime(exportPath).toMillis() < Files.getLastModifiedTime(summarySourcePath).toMillis();
|
|| 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) {
|
if (needRegenerate) {
|
||||||
String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8);
|
String markdown = Files.readString(summarySourcePath, StandardCharsets.UTF_8);
|
||||||
meetingDetail.setSummaryContent(meetingSummaryFileService.stripFrontMatter(markdown));
|
meetingDetail.setSummaryContent(meetingSummaryFileService.stripFrontMatter(markdown));
|
||||||
baseBytes = "docx".equals(ext) ? buildWordBytes(meetingDetail) : buildPdfBytes(meetingDetail);
|
baseBytes = "docx".equals(ext)
|
||||||
Files.write(exportPath, baseBytes);
|
? new MeetingWordDocumentBuilder().build(meetingDetail)
|
||||||
|
: buildPdfBytes(meetingDetail);
|
||||||
|
Files.write(exportPath, baseBytes);
|
||||||
} else {
|
} else {
|
||||||
baseBytes = Files.readAllBytes(exportPath);
|
baseBytes = Files.readAllBytes(exportPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] bytes = isPdf
|
byte[] bytes = isPdf
|
||||||
? applyPdfWatermark(baseBytes, resolveWatermarkText(loginUser))
|
? applyPdfWatermark(baseBytes, resolveWatermarkText(loginUser))
|
||||||
: baseBytes;
|
: baseBytes;
|
||||||
|
|
||||||
return new MeetingSummaryExportResult(bytes, contentType, safeTitle + "-AI-Summary." + ext);
|
return new MeetingSummaryExportResult(bytes, contentType, safeTitle + "." + ext);
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
throw new RuntimeException("导出失败:" + ex.getMessage(), ex);
|
throw new RuntimeException("导出失败:" + ex.getMessage(), ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] buildWordBytes(MeetingVO meeting) throws IOException {
|
private boolean isCurrentWordExport(Path exportPath) {
|
||||||
try (XWPFDocument document = new XWPFDocument();
|
try (InputStream input = Files.newInputStream(exportPath);
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
XWPFDocument document = new XWPFDocument(input)) {
|
||||||
XWPFParagraph title = document.createParagraph();
|
var property = document.getProperties()
|
||||||
XWPFRun titleRun = title.createRun();
|
.getCustomProperties()
|
||||||
titleRun.setBold(true);
|
.getProperty(MeetingWordDocumentBuilder.EXPORT_VERSION_PROPERTY);
|
||||||
titleRun.setFontSize(16);
|
return property != null
|
||||||
titleRun.setText((meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI Summary");
|
&& property.isSetLpwstr()
|
||||||
|
&& MeetingWordDocumentBuilder.EXPORT_VERSION.equals(property.getLpwstr());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
XWPFParagraph timeP = document.createParagraph();
|
private boolean isCurrentPdfExport(Path exportPath) {
|
||||||
timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime()));
|
try (PDDocument document = PDDocument.load(exportPath.toFile())) {
|
||||||
|
String version = document.getDocumentInformation()
|
||||||
XWPFParagraph hostP = document.createParagraph();
|
.getCustomMetadataValue(PDF_EXPORT_VERSION_PROPERTY);
|
||||||
hostP.createRun().setText("Host: " + (meeting.getHostName() == null ? "" : meeting.getHostName()));
|
return PDF_EXPORT_VERSION.equals(version);
|
||||||
|
} catch (Exception ignored) {
|
||||||
XWPFParagraph participantsP = document.createParagraph();
|
return false;
|
||||||
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 byte[] buildPdfBytes(MeetingVO meeting) throws IOException {
|
private byte[] buildPdfBytes(MeetingVO meeting) throws IOException {
|
||||||
Parser parser = Parser.builder().build();
|
String xhtml = buildPdfXhtml(meeting);
|
||||||
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();
|
|
||||||
|
|
||||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
PdfRendererBuilder builder = new PdfRendererBuilder();
|
PdfRendererBuilder builder = new PdfRendererBuilder();
|
||||||
|
|
@ -229,12 +185,134 @@ public class MeetingExportServiceImpl implements MeetingExportService {
|
||||||
builder.withHtmlContent(xhtml, null);
|
builder.withHtmlContent(xhtml, null);
|
||||||
builder.toStream(out);
|
builder.toStream(out);
|
||||||
builder.run();
|
builder.run();
|
||||||
return out.toByteArray();
|
return markCurrentPdfExport(out.toByteArray());
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
throw new IOException("PDF 生成失败", 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 {
|
private byte[] applyPdfWatermark(byte[] pdfBytes, String watermarkText) throws IOException {
|
||||||
if (watermarkText == null || watermarkText.isBlank()) {
|
if (watermarkText == null || watermarkText.isBlank()) {
|
||||||
return pdfBytes;
|
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