feat:导出pdf优化
parent
61da050438
commit
430459c331
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
Binary file not shown.
Binary file not shown.
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue