feat:导出pdf优化
parent
61da050438
commit
430459c331
|
|
@ -104,6 +104,26 @@
|
|||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark</artifactId>
|
||||
<version>0.21.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.openhtmltopdf</groupId>
|
||||
<artifactId>openhtmltopdf-core</artifactId>
|
||||
<version>1.0.10</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.openhtmltopdf</groupId>
|
||||
<artifactId>openhtmltopdf-pdfbox</artifactId>
|
||||
<version>1.0.10</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.17.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
||||
import org.commonmark.node.Node;
|
||||
import org.commonmark.parser.Parser;
|
||||
import org.commonmark.renderer.html.HtmlRenderer;
|
||||
import org.jsoup.Jsoup;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
|
@ -279,35 +285,77 @@ public class MeetingController {
|
|||
}
|
||||
|
||||
private byte[] buildPdfBytes(MeetingVO meeting) throws IOException {
|
||||
try (PDDocument document = new PDDocument();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
List<PDFont> fonts = loadPdfFonts(document);
|
||||
final float margin = 50f;
|
||||
final float pageWidth = PDRectangle.A4.getWidth() - 2 * margin;
|
||||
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);
|
||||
|
||||
PdfCtx ctx = newPdfPage(document, margin);
|
||||
ctx = writeWrappedPdf(document, ctx, (meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI Summary",
|
||||
fonts, 14f, 18f, margin, pageWidth);
|
||||
ctx = writeWrappedPdf(document, ctx, "Meeting Time: " + meeting.getMeetingTime(), fonts, 11f, 15f, margin, pageWidth);
|
||||
ctx = writeWrappedPdf(document, ctx, "Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants()), fonts, 11f, 15f, margin, pageWidth);
|
||||
ctx.y -= 10f;
|
||||
String title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle();
|
||||
String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString();
|
||||
String participants = meeting.getParticipants() == null ? "未指定" : meeting.getParticipants();
|
||||
|
||||
for (MdBlock block : parseMarkdownBlocks(meeting.getSummaryContent())) {
|
||||
if (block.type == MdType.HEADING) {
|
||||
int size = Math.max(12, 18 - (block.level - 1) * 2);
|
||||
ctx = writeWrappedPdf(document, ctx, toPlainInline(block.text), fonts, size, size + 4f, margin, pageWidth);
|
||||
ctx.y -= 4f;
|
||||
} else if (block.type == MdType.LIST) {
|
||||
ctx = writeWrappedPdf(document, ctx, "- " + toPlainInline(block.text), fonts, 11f, 16f, margin, pageWidth);
|
||||
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>会议时间:" + time + "</span>" +
|
||||
"<span style='margin: 0 20px;'>|</span>" +
|
||||
"<span>参会人:" + 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;'>" +
|
||||
"由 iMeeting 智能助手生成" +
|
||||
"</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()) {
|
||||
PdfRendererBuilder builder = new PdfRendererBuilder();
|
||||
builder.useFastMode();
|
||||
|
||||
// Register fonts from classpath
|
||||
try {
|
||||
java.io.InputStream fontStream = getClass().getResourceAsStream("/fonts/simsunb.ttf");
|
||||
if (fontStream != null) {
|
||||
File tempFont = File.createTempFile("simsunb", ".ttf");
|
||||
tempFont.deleteOnExit();
|
||||
java.nio.file.Files.copy(fontStream, tempFont.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
builder.useFont(tempFont, "SimSun");
|
||||
fontStream.close();
|
||||
} else {
|
||||
ctx = writeWrappedPdf(document, ctx, toPlainInline(block.text), fonts, 11f, 16f, margin, pageWidth);
|
||||
ctx.y -= 2f;
|
||||
System.out.println("Warning: simsunb.ttf not found in classpath (/fonts/simsunb.ttf).");
|
||||
}
|
||||
|
||||
java.io.InputStream notoStream = getClass().getResourceAsStream("/fonts/NotoSansSC-VF.ttf");
|
||||
if (notoStream != null) {
|
||||
File tempNoto = File.createTempFile("notosans", ".ttf");
|
||||
tempNoto.deleteOnExit();
|
||||
java.nio.file.Files.copy(notoStream, tempNoto.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
builder.useFont(tempNoto, "NotoSansSC");
|
||||
notoStream.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("Error loading font from classpath: " + e.getMessage());
|
||||
}
|
||||
|
||||
ctx.content.close();
|
||||
document.save(out);
|
||||
builder.withHtmlContent(xhtml, null);
|
||||
builder.toStream(out);
|
||||
builder.run();
|
||||
return out.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new IOException("PDF generation failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -36,8 +36,6 @@ import {
|
|||
} from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import dayjs from 'dayjs';
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
import {
|
||||
getMeetingDetail,
|
||||
getTranscripts,
|
||||
|
|
@ -367,51 +365,8 @@ const MeetingDetail: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (format === 'pdf') {
|
||||
try {
|
||||
setDownloadLoading('pdf');
|
||||
if (!summaryPdfRef.current) {
|
||||
message.error('未找到可导出的总结内容');
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = await html2canvas(summaryPdfRef.current, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
});
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
const pdf = new jsPDF('p', 'mm', 'a4');
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
const imgWidth = pageWidth;
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
|
||||
while (heightLeft > 0) {
|
||||
position = heightLeft - imgHeight;
|
||||
pdf.addPage();
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
const fileName = `${(meeting.title || 'meeting').replace(/[\\\\/:*?\"<>|\\r\\n]/g, '_')}-AI-summary.pdf`;
|
||||
pdf.save(fileName);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('PDF导出失败');
|
||||
} finally {
|
||||
setDownloadLoading(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDownloadLoading('word');
|
||||
setDownloadLoading(format);
|
||||
const res = await downloadMeetingSummary(meeting.id, format);
|
||||
const contentType: string =
|
||||
res.headers['content-type'] ||
|
||||
|
|
@ -437,7 +392,7 @@ const MeetingDetail: React.FC = () => {
|
|||
a.href = url;
|
||||
a.download = getFileNameFromDisposition(
|
||||
res.headers['content-disposition'],
|
||||
`meeting-summary.${format === 'pdf' ? 'pdf' : 'docx'}`,
|
||||
`${(meeting.title || 'meeting').replace(/[\\\\/:*?\"<>|\\r\\n]/g, '_')}-AI纪要.${format === 'pdf' ? 'pdf' : 'docx'}`,
|
||||
);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
|
@ -445,7 +400,7 @@ const MeetingDetail: React.FC = () => {
|
|||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error('下载失败');
|
||||
message.error(`${format.toUpperCase()}下载失败`);
|
||||
} finally {
|
||||
setDownloadLoading(null);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue