diff --git a/backend/pom.xml b/backend/pom.xml index 9a9949f..89e193b 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -104,6 +104,26 @@ poi-ooxml 5.2.5 + + org.commonmark + commonmark + 0.21.0 + + + com.openhtmltopdf + openhtmltopdf-core + 1.0.10 + + + com.openhtmltopdf + openhtmltopdf-pdfbox + 1.0.10 + + + org.jsoup + jsoup + 1.17.2 + diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 116e0b5..fd63f42 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -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 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 = "" + + "" + + "" + title + "" + + "" + + "会议时间:" + time + "" + + "|" + + "参会人:" + participants + "" + + "" + + "" + htmlBody + "" + + "" + + "由 iMeeting 智能助手生成" + + "" + + ""; + + 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); } } diff --git a/backend/src/main/resources/fonts/NotoSansSC-VF.ttf b/backend/src/main/resources/fonts/NotoSansSC-VF.ttf new file mode 100644 index 0000000..cc79aef Binary files /dev/null and b/backend/src/main/resources/fonts/NotoSansSC-VF.ttf differ diff --git a/backend/src/main/resources/fonts/SimsunExtG.ttf b/backend/src/main/resources/fonts/SimsunExtG.ttf new file mode 100644 index 0000000..d34997c Binary files /dev/null and b/backend/src/main/resources/fonts/SimsunExtG.ttf differ diff --git a/backend/src/main/resources/fonts/simsunb.ttf b/backend/src/main/resources/fonts/simsunb.ttf new file mode 100644 index 0000000..0302282 Binary files /dev/null and b/backend/src/main/resources/fonts/simsunb.ttf differ diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 99f867e..26db02a 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -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); }