feat:导出pdf优化

dev_na
chenhao 2026-03-06 09:59:29 +08:00
parent 61da050438
commit 430459c331
6 changed files with 93 additions and 70 deletions

View File

@ -104,6 +104,26 @@
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>5.2.5</version> <version>5.2.5</version>
</dependency> </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> </dependencies>
<build> <build>

View File

@ -30,6 +30,12 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; 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.ByteArrayOutputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -279,35 +285,77 @@ public class MeetingController {
} }
private byte[] buildPdfBytes(MeetingVO meeting) throws IOException { private byte[] buildPdfBytes(MeetingVO meeting) throws IOException {
try (PDDocument document = new PDDocument(); Parser parser = Parser.builder().build();
ByteArrayOutputStream out = new ByteArrayOutputStream()) { String markdown = meeting.getSummaryContent() == null ? "" : meeting.getSummaryContent();
List<PDFont> fonts = loadPdfFonts(document); Node document = parser.parse(markdown);
final float margin = 50f; HtmlRenderer renderer = HtmlRenderer.builder().build();
final float pageWidth = PDRectangle.A4.getWidth() - 2 * margin; String htmlBody = renderer.render(document);
PdfCtx ctx = newPdfPage(document, margin); String title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle();
ctx = writeWrappedPdf(document, ctx, (meeting.getTitle() == null ? "Meeting" : meeting.getTitle()) + " - AI Summary", String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString();
fonts, 14f, 18f, margin, pageWidth); String participants = meeting.getParticipants() == null ? "未指定" : meeting.getParticipants();
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;
for (MdBlock block : parseMarkdownBlocks(meeting.getSummaryContent())) { String html = "<html><head><style>" +
if (block.type == MdType.HEADING) { "body { font-family: 'NotoSansSC', 'SimSun', sans-serif; padding: 20px; line-height: 1.6; color: #333; }" +
int size = Math.max(12, 18 - (block.level - 1) * 2); "h1, h2, h3 { color: #1890ff; border-bottom: 1px solid #eee; padding-bottom: 5px; }" +
ctx = writeWrappedPdf(document, ctx, toPlainInline(block.text), fonts, size, size + 4f, margin, pageWidth); "table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }" +
ctx.y -= 4f; "table, th, td { border: 1px solid #ddd; }" +
} else if (block.type == MdType.LIST) { "th, td { padding: 8px 12px; text-align: left; }" +
ctx = writeWrappedPdf(document, ctx, "- " + toPlainInline(block.text), fonts, 11f, 16f, margin, pageWidth); "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 { } else {
ctx = writeWrappedPdf(document, ctx, toPlainInline(block.text), fonts, 11f, 16f, margin, pageWidth); System.out.println("Warning: simsunb.ttf not found in classpath (/fonts/simsunb.ttf).");
ctx.y -= 2f;
}
} }
ctx.content.close(); java.io.InputStream notoStream = getClass().getResourceAsStream("/fonts/NotoSansSC-VF.ttf");
document.save(out); 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());
}
builder.withHtmlContent(xhtml, null);
builder.toStream(out);
builder.run();
return out.toByteArray(); return out.toByteArray();
} catch (Exception e) {
throw new IOException("PDF generation failed", e);
} }
} }

Binary file not shown.

View File

@ -36,8 +36,6 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import { import {
getMeetingDetail, getMeetingDetail,
getTranscripts, getTranscripts,
@ -367,51 +365,8 @@ const MeetingDetail: React.FC = () => {
return; return;
} }
if (format === 'pdf') {
try { try {
setDownloadLoading('pdf'); setDownloadLoading(format);
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');
const res = await downloadMeetingSummary(meeting.id, format); const res = await downloadMeetingSummary(meeting.id, format);
const contentType: string = const contentType: string =
res.headers['content-type'] || res.headers['content-type'] ||
@ -437,7 +392,7 @@ const MeetingDetail: React.FC = () => {
a.href = url; a.href = url;
a.download = getFileNameFromDisposition( a.download = getFileNameFromDisposition(
res.headers['content-disposition'], 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); document.body.appendChild(a);
a.click(); a.click();
@ -445,7 +400,7 @@ const MeetingDetail: React.FC = () => {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
message.error('下载失败'); message.error(`${format.toUpperCase()}下载失败`);
} finally { } finally {
setDownloadLoading(null); setDownloadLoading(null);
} }