diff --git a/.gitignore b/.gitignore index 7fc23f6..0f96ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .vscode bin obj +wwwroot *.jfm *.dbmdl *.user diff --git a/YD_Event.Application/Certificate/CertificateGenerateService.cs b/YD_Event.Application/Certificate/CertificateGenerateService.cs index e114c7b..d87f20c 100644 --- a/YD_Event.Application/Certificate/CertificateGenerateService.cs +++ b/YD_Event.Application/Certificate/CertificateGenerateService.cs @@ -12,8 +12,12 @@ using Microsoft.Extensions.Logging; using System.Diagnostics; using System.ComponentModel.DataAnnotations; using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; using System.IO; using System.Linq; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; namespace YD_Event.Application.Certificate; @@ -138,7 +142,7 @@ public class CertificateGenerateService : IDynamicApiController, ITransient using var httpClient = new HttpClient(); var templateStream = new MemoryStream(await httpClient.GetByteArrayAsync(template.DocxTemplateUrl)); - // 7. 生成二维码数据 + // 7. 生成二维码数据(用于DOC模板中的占位符) var qrCodeData = new Dictionary(); if (input.GenerateQRCode) { @@ -150,20 +154,33 @@ public class CertificateGenerateService : IDynamicApiController, ITransient // 8. 处理模板 var processedStream = _docxProcessor.ProcessTemplate(templateStream, input.Data, qrCodeData); - // 9. 转换为PDF - var pdfStream = await ConvertDocToPdf(processedStream); + // 9. 先生成证书保存地址(预设的PDF地址) + var pdfUrl = GeneratePdfUrl(input.CertificateId, certificate, template); + + // 10. 生成二维码图片(使用预设的PDF地址) + byte[] qrCodeImage = null; + if (input.GenerateQRCode) + { + using var qrStream = _qrCodeService.GenerateQRCode(pdfUrl, input.QRCodeSize); + using var memoryStream = new MemoryStream(); + qrStream.CopyTo(memoryStream); + qrCodeImage = memoryStream.ToArray(); + } + + // 11. 转换为PDF(包含二维码) + var pdfStream = await ConvertDocToPdf(processedStream, qrCodeImage); - // 10. 保存PDF文件 - var pdfUrl = await SavePdfFile(pdfStream, certificate, template); + // 12. 保存PDF文件到预设地址 + await SavePdfFileToUrl(pdfStream, pdfUrl); - // 11. 生成二维码图片(如果需要) + // 12. 生成二维码图片(如果需要) string qrCodeUrl = null; if (input.GenerateQRCode) { qrCodeUrl = await GenerateQRCodeImage(input.CertificateId, input.QRCodeSize); } - // 12. 设置结果 + // 13. 设置结果 result.PdfUrl = pdfUrl; result.QRCodeUrl = qrCodeUrl; result.VerificationUrl = GenerateCertificateUrl(input.CertificateId); @@ -233,358 +250,493 @@ public class CertificateGenerateService : IDynamicApiController, ITransient } /// - /// 转换DOC为PDF(使用DocumentFormat.OpenXml解析并生成PDF) + /// 转换DOC为PDF(使用QuestPDF和DocumentFormat.OpenXml) /// - private async Task ConvertDocToPdf(Stream docStream) + private async Task ConvertDocToPdf(Stream docStream, byte[] qrCodeImage = null) { try { - _logger.LogInformation("开始DOC转PDF转换"); + _logger.LogInformation("开始DOCX转PDF转换"); - var pdfStream = new MemoryStream(); - string content = ""; + // 重置流位置 + docStream.Position = 0; - try - { - // 尝试解析DOCX文件 - content = await ExtractDocxContent(docStream); - _logger.LogInformation("DOCX解析成功,内容长度: {Length}", content.Length); - } - catch (Exception ex) - { - _logger.LogError(ex, "DOCX解析失败,尝试直接读取"); - // 如果解析失败,直接读取流内容 - docStream.Position = 0; - using var reader = new StreamReader(docStream, System.Text.Encoding.UTF8); - content = await reader.ReadToEndAsync(); - } + // 使用QuestPDF许可证 + QuestPDF.Settings.License = LicenseType.Community; - // 如果内容为空,使用默认内容 - if (string.IsNullOrWhiteSpace(content)) + // 解析DOCX内容 + var documentData = await ExtractDocumentData(docStream); + + // 添加二维码图片 + if (qrCodeImage != null) { - content = "Certificate Content\n\nThis is a certificate generated from DOC template.\n\nTemplate processing completed successfully."; + documentData.QRCodeImage = qrCodeImage; + documentData.QRCodeMimeType = "image/png"; } // 生成PDF - await GeneratePdfFromContent(pdfStream, content); + var pdfStream = new MemoryStream(); + await GeneratePdfWithQuestPDF(pdfStream, documentData); pdfStream.Position = 0; - _logger.LogInformation("DOC转PDF完成,PDF大小: {Size} bytes", pdfStream.Length); + _logger.LogInformation("DOCX转PDF完成,PDF大小: {Size} bytes", pdfStream.Length); return pdfStream; } catch (Exception ex) { - _logger.LogError(ex, "DOC转PDF失败,使用备用方案"); - return await GenerateSimplePdf(docStream); + _logger.LogError(ex, "DOCX转PDF失败"); + throw new Exception("DOCX转PDF转换失败", ex); } } /// - /// 提取DOCX文件内容 + /// 提取DOCX文档数据(包括段落、表格、背景图片等) /// - private async Task ExtractDocxContent(Stream docStream) + private async Task ExtractDocumentData(Stream docStream) { - var content = new System.Text.StringBuilder(); + var documentData = new DocumentData(); using var wordprocessingDocument = WordprocessingDocument.Open(docStream, false); var body = wordprocessingDocument.MainDocumentPart?.Document?.Body; + // 提取背景图片 + await ExtractBackgroundImage(wordprocessingDocument, documentData); + if (body != null) { - // 提取所有段落文本 - foreach (var paragraph in body.Elements()) + // 提取段落(包含位置信息) + foreach (var paragraph in body.Elements()) { var paragraphText = paragraph.InnerText?.Trim(); if (!string.IsNullOrEmpty(paragraphText)) { - content.AppendLine(paragraphText); + var paragraphData = new ParagraphData + { + Text = paragraphText, + Style = DetermineParagraphStyle(paragraph), + FontSize = GetParagraphFontSize(paragraph), + Alignment = GetParagraphAlignment(paragraph), + IsBold = IsParagraphBold(paragraph), + FontColor = GetParagraphColor(paragraph) + }; + + // 尝试获取位置信息 + GetParagraphPosition(paragraph, paragraphData); + + documentData.Paragraphs.Add(paragraphData); } } - // 提取表格内容 - foreach (var table in body.Elements()) + // 提取表格 + foreach (var table in body.Elements()) { - foreach (var row in table.Elements()) + var tableData = new TableData(); + foreach (var row in table.Elements()) { - var rowText = new System.Text.StringBuilder(); - foreach (var cell in row.Elements()) + var rowData = new List(); + foreach (var cell in row.Elements()) { - var cellText = cell.InnerText?.Trim(); - if (!string.IsNullOrEmpty(cellText)) - { - rowText.Append(cellText + " | "); - } + var cellText = cell.InnerText?.Trim() ?? string.Empty; + rowData.Add(cellText); } - if (rowText.Length > 0) + if (rowData.Any()) { - content.AppendLine(rowText.ToString().TrimEnd(" | ".ToCharArray())); + tableData.Rows.Add(rowData); } } + if (tableData.Rows.Any()) + { + documentData.Tables.Add(tableData); + } } } - return content.ToString(); + return documentData; } /// - /// 从内容生成PDF + /// 提取背景图片 /// - private async Task GeneratePdfFromContent(Stream pdfStream, string content) + private async Task ExtractBackgroundImage(WordprocessingDocument wordDoc, DocumentData documentData) { - using var writer = new StreamWriter(pdfStream, System.Text.Encoding.ASCII, leaveOpen: true); - - // PDF头部 - await writer.WriteAsync("%PDF-1.4\n"); - - // 对象1:Catalog - await writer.WriteAsync("1 0 obj\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync("/Type /Catalog\n"); - await writer.WriteAsync("/Pages 2 0 R\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("endobj\n"); - - // 对象2:Pages - await writer.WriteAsync("2 0 obj\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync("/Type /Pages\n"); - await writer.WriteAsync("/Kids [3 0 R]\n"); - await writer.WriteAsync("/Count 1\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("endobj\n"); - - // 对象3:Page - await writer.WriteAsync("3 0 obj\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync("/Type /Page\n"); - await writer.WriteAsync("/Parent 2 0 R\n"); - await writer.WriteAsync("/MediaBox [0 0 595 842]\n"); - await writer.WriteAsync("/Resources <<\n"); - await writer.WriteAsync("/Font <<\n"); - await writer.WriteAsync("/F1 <<\n"); - await writer.WriteAsync("/Type /Font\n"); - await writer.WriteAsync("/Subtype /Type1\n"); - await writer.WriteAsync("/BaseFont /Arial\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("/Contents 4 0 R\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("endobj\n"); - - // 准备文本内容 - var lines = content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); - var textContent = new System.Text.StringBuilder(); - var yPosition = 750; - - // 添加标题 - textContent.Append("BT /F1 16 Tf 50 750 Td (Certificate) Tj ET\n"); - yPosition -= 30; - - // 添加内容行 - foreach (var line in lines.Take(20)) // 限制行数避免PDF过大 - { - var cleanLine = line.Replace("(", "\\(").Replace(")", "\\)").Replace("\r", "").Trim(); - - if (cleanLine.Length > 0) - { - // 处理长行 - if (cleanLine.Length > 70) - { - cleanLine = cleanLine.Substring(0, 70) + "..."; - } - - // 处理非ASCII字符 - var asciiLine = ConvertToAscii(cleanLine); - - textContent.Append($"BT /F1 12 Tf 50 {yPosition} Td ({asciiLine}) Tj ET\n"); - yPosition -= 18; // 行间距 - - // 如果位置太低,停止添加 - if (yPosition < 50) break; - } - } - - var contentText = textContent.ToString(); - var contentBytes = System.Text.Encoding.ASCII.GetBytes(contentText); - - // 对象4:Content Stream - await writer.WriteAsync("4 0 obj\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync($"/Length {contentBytes.Length}\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("stream\n"); - await writer.FlushAsync(); - - // 直接写入字节到流 - pdfStream.Write(contentBytes, 0, contentBytes.Length); - - await writer.WriteAsync("\nendstream\n"); - await writer.WriteAsync("endobj\n"); - - // 记录当前位置用于xref - var currentPos = pdfStream.Length; - - // 交叉引用表 - var xrefOffset = pdfStream.Length; - await writer.WriteAsync("xref\n"); - await writer.WriteAsync("0 5\n"); - await writer.WriteAsync("0000000000 65535 f\n"); - await writer.WriteAsync("0000000010 00000 n\n"); - await writer.WriteAsync("0000000079 00000 n\n"); - await writer.WriteAsync("0000000173 00000 n\n"); - await writer.WriteAsync($"000000{currentPos.ToString().PadLeft(6, '0')} 00000 n\n"); - - // Trailer - await writer.WriteAsync("trailer\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync("/Size 5\n"); - await writer.WriteAsync("/Root 1 0 R\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("startxref\n"); - await writer.WriteAsync($"{xrefOffset}\n"); - await writer.WriteAsync("%%EOF\n"); - - await writer.FlushAsync(); - } - - /// - /// 转换文本为ASCII兼容格式 - /// - private string ConvertToAscii(string text) - { - var result = new System.Text.StringBuilder(); - - foreach (char c in text) - { - if (c < 128) // ASCII字符 - { - result.Append(c); - } - else - { - // 非ASCII字符转换为占位符 - result.Append("?"); - } - } - - return result.ToString(); - } - - /// - /// 生成简单的PDF作为备用方案 - /// - private async Task GenerateSimplePdf(Stream docStream) - { - _logger.LogInformation("使用备用PDF生成方案"); - - var pdfStream = new MemoryStream(); - try { - // 重置流位置 - docStream.Position = 0; - - // 读取DOC内容 - string content; - try + var mainPart = wordDoc.MainDocumentPart; + if (mainPart != null) { - using var reader = new StreamReader(docStream, System.Text.Encoding.UTF8); - content = await reader.ReadToEndAsync(); + // 查找文档中的图片 + var imageParts = mainPart.GetPartsOfType().ToList(); + + if (imageParts.Any()) + { + // 尝试获取第一个图片作为背景图片 + var firstImage = imageParts.FirstOrDefault(); + if (firstImage != null) + { + using var imageStream = firstImage.GetStream(FileMode.Open, FileAccess.Read); + using var memoryStream = new MemoryStream(); + await imageStream.CopyToAsync(memoryStream); + + documentData.BackgroundImage = memoryStream.ToArray(); + documentData.BackgroundImageMimeType = firstImage.ContentType; + + _logger.LogInformation("成功提取背景图片,类型: {Type}, 大小: {Size} bytes", + documentData.BackgroundImageMimeType, documentData.BackgroundImage.Length); + } + } + + // 如果没有找到图片,尝试从页眉页脚中查找 + if (documentData.BackgroundImage == null) + { + var headerParts = mainPart.HeaderParts; + foreach (var headerPart in headerParts) + { + var headerImages = headerPart.GetPartsOfType(); + foreach (var image in headerImages) + { + using var imageStream = image.GetStream(FileMode.Open, FileAccess.Read); + using var memoryStream = new MemoryStream(); + await imageStream.CopyToAsync(memoryStream); + + documentData.BackgroundImage = memoryStream.ToArray(); + documentData.BackgroundImageMimeType = image.ContentType; + + _logger.LogInformation("从页眉中提取背景图片,类型: {Type}, 大小: {Size} bytes", + documentData.BackgroundImageMimeType, documentData.BackgroundImage.Length); + break; + } + if (documentData.BackgroundImage != null) break; + } + } } - catch - { - // 如果无法读取DOC内容,使用默认内容 - content = "Original document content could not be read."; - } - - // 创建简单的PDF内容 - using var writer = new StreamWriter(pdfStream, System.Text.Encoding.UTF8, leaveOpen: true); - - await writer.WriteAsync("%PDF-1.4\n"); - await writer.WriteAsync("1 0 obj\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync("/Type /Catalog\n"); - await writer.WriteAsync("/Pages 2 0 R\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("endobj\n"); - - await writer.WriteAsync("2 0 obj\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync("/Type /Pages\n"); - await writer.WriteAsync("/Kids [3 0 R]\n"); - await writer.WriteAsync("/Count 1\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("endobj\n"); - - await writer.WriteAsync("3 0 obj\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync("/Type /Page\n"); - await writer.WriteAsync("/Parent 2 0 R\n"); - await writer.WriteAsync("/MediaBox [0 0 595 842]\n"); - await writer.WriteAsync("/Resources <<\n"); - await writer.WriteAsync("/Font <<\n"); - await writer.WriteAsync("/F1 <<\n"); - await writer.WriteAsync("/Type /Font\n"); - await writer.WriteAsync("/Subtype /Type1\n"); - await writer.WriteAsync("/BaseFont /Arial\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync($"/Contents 4 0 R\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("endobj\n"); - - // 简化的内容(只显示基本信息) - var displayContent = content.Length > 0 - ? content.Substring(0, Math.Min(500, content.Length)) - : "Document content is empty or could not be read."; - - var simpleContent = "Certificate Generated\n\nNote: This is a simplified PDF generated\nbecause LibreOffice is not available.\n\nOriginal document content:\n" + displayContent; - - var contentBytes = System.Text.Encoding.UTF8.GetBytes(simpleContent); - - await writer.WriteAsync("4 0 obj\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync($"/Length {contentBytes.Length}\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("stream\n"); - await writer.FlushAsync(); - pdfStream.Write(contentBytes, 0, contentBytes.Length); - await writer.WriteAsync("\nendstream\n"); - await writer.WriteAsync("endobj\n"); - - await writer.WriteAsync("xref\n"); - await writer.WriteAsync("0 5\n"); - await writer.WriteAsync("0000000000 65535 f\n"); - await writer.WriteAsync("0000000010 00000 n\n"); - await writer.WriteAsync("0000000079 00000 n\n"); - await writer.WriteAsync("0000000173 00000 n\n"); - - // 计算正确的偏移量 - var xrefOffset = pdfStream.Length; - await writer.WriteAsync($"000000{Math.Max(300, contentBytes.Length + 200).ToString().PadLeft(6, '0')} 00000 n\n"); - await writer.WriteAsync("trailer\n"); - await writer.WriteAsync("<<\n"); - await writer.WriteAsync("/Size 5\n"); - await writer.WriteAsync("/Root 1 0 R\n"); - await writer.WriteAsync(">>\n"); - await writer.WriteAsync("startxref\n"); - await writer.WriteAsync($"{xrefOffset}\n"); - await writer.WriteAsync("%%EOF\n"); - - await writer.FlushAsync(); - - _logger.LogInformation("备用PDF生成完成,大小: {Size} bytes", pdfStream.Length); } catch (Exception ex) { - _logger.LogError(ex, "备用PDF生成也失败了"); - throw new Exception("PDF生成完全失败", ex); + _logger.LogWarning(ex, "提取背景图片失败"); + } + } + + /// + /// 获取段落字体大小 + /// + private double? GetParagraphFontSize(Paragraph paragraph) + { + var runProperties = paragraph.Elements() + .SelectMany(r => r.Elements()) + .FirstOrDefault(); + + if (runProperties?.FontSize != null) + { + var fontSize = runProperties.FontSize.Val; + if (double.TryParse(fontSize, out double size)) + { + return size / 2.0; // OpenXML 中字体大小是半磅为单位 + } } - pdfStream.Position = 0; - return pdfStream; + return null; + } + + /// + /// 获取段落对齐方式 + /// + private string GetParagraphAlignment(Paragraph paragraph) + { + var paragraphProperties = paragraph.ParagraphProperties; + if (paragraphProperties?.Justification != null) + { + var justification = paragraphProperties.Justification.Val?.ToString().ToLower(); + return justification switch + { + "center" => "center", + "right" => "right", + "both" => "justify", + _ => "left" + }; + } + + return "left"; + } + + /// + /// 检查段落是否为粗体 + /// + private bool IsParagraphBold(Paragraph paragraph) + { + var runProperties = paragraph.Elements() + .SelectMany(r => r.Elements()) + .FirstOrDefault(); + + return runProperties?.Bold != null; + } + + /// + /// 获取段落字体颜色 + /// + private string GetParagraphColor(Paragraph paragraph) + { + var runProperties = paragraph.Elements() + .SelectMany(r => r.Elements()) + .FirstOrDefault(); + + if (runProperties?.Color != null) + { + var color = runProperties.Color.Val; + if (!string.IsNullOrEmpty(color)) + { + return color; + } + } + + return "black"; + } + + /// + /// 获取段落位置信息 + /// + private void GetParagraphPosition(Paragraph paragraph, ParagraphData paragraphData) + { + try + { + var paragraphProperties = paragraph.ParagraphProperties; + if (paragraphProperties != null) + { + _logger.LogDebug("处理段落: {Text}", paragraphData.Text); + + // 检查是否有绝对定位 + var frameProperties = paragraphProperties.Elements().FirstOrDefault(); + if (frameProperties != null) + { + _logger.LogDebug("找到FrameProperties"); + + // 提取位置信息 + if (frameProperties.Width != null) + { + var widthStr = frameProperties.Width.Value.ToString(); + _logger.LogDebug("Width: {Width}", widthStr); + if (uint.TryParse(widthStr, out uint width)) + { + paragraphData.PositionX = width; + _logger.LogDebug("设置PositionX: {X}", width); + } + } + + if (frameProperties.Height != null) + { + var heightStr = frameProperties.Height.Value.ToString(); + _logger.LogDebug("Height: {Height}", heightStr); + if (uint.TryParse(heightStr, out uint height)) + { + paragraphData.PositionY = height; + _logger.LogDebug("设置PositionY: {Y}", height); + } + } + } + else + { + _logger.LogDebug("未找到FrameProperties"); + } + + // 如果没有绝对定位,检查段落缩进 + if (paragraphData.PositionX == null) + { + var indentation = paragraphProperties.Elements().FirstOrDefault(); + if (indentation != null && indentation.Left != null) + { + var leftStr = indentation.Left.Value.ToString(); + _logger.LogDebug("Left Indentation: {Indent}", leftStr); + if (uint.TryParse(leftStr, out uint leftIndent)) + { + paragraphData.PositionX = leftIndent / 20; // 转换为点 + _logger.LogDebug("通过缩进设置PositionX: {X}", paragraphData.PositionX); + } + } + } + + // 检查段落间距 + var spacingBetweenLines = paragraphProperties.SpacingBetweenLines; + if (spacingBetweenLines != null) + { + _logger.LogDebug("SpacingBetweenLines: {Spacing}", spacingBetweenLines.ToString()); + } + } + + _logger.LogDebug("最终位置 - X: {X}, Y: {Y}", paragraphData.PositionX, paragraphData.PositionY); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取段落位置信息失败"); + } + } + + /// + /// 使用QuestPDF生成PDF + /// + private async Task GeneratePdfWithQuestPDF(Stream pdfStream, DocumentData documentData) + { + var questDocument = QuestPDF.Fluent.Document.Create(container => + { + container.Page(page => + { + page.Margin(0); + page.Size(PageSizes.A4); + page.DefaultTextStyle(x => x.FontSize(12).FontFamily(QuestPDF.Helpers.Fonts.Calibri)); + + // 添加背景图片 + if (documentData.BackgroundImage != null) + { + page.Background() + .Image(documentData.BackgroundImage) + .FitArea(); + } + + // 使用相对定位来放置文本 + page.Content().Column(column => + { + // 根据段落信息进行精确定位 + foreach (var paragraph in documentData.Paragraphs) + { + var fontSize = paragraph.FontSize ?? 12; + var isBold = paragraph.IsBold; + + // 使用从DOCX中提取的实际位置信息 + var positionX = paragraph.PositionX ?? 50; // 默认左边距 + var positionY = paragraph.PositionY ?? 50; // 默认顶部边距 + + // 跳过无效的文本内容 + if (string.IsNullOrWhiteSpace(paragraph.Text) || + paragraph.Text.Contains("left-") || + paragraph.Text.Contains("right-") || + paragraph.Text.Contains("top-") || + paragraph.Text.Contains("bottom-")) + { + continue; + } + + // 创建文本元素,使用原始位置 + var textElement = column.Item().PaddingTop((float)positionY).PaddingLeft((float)positionX).Text(paragraph.Text) + .FontSize((float)fontSize) + .FontColor(QuestPDF.Helpers.Colors.Black); + + // 如果需要粗体,应用到同一个元素 + if (isBold) + { + textElement.Bold(); + } + } + + // 添加二维码到右下角 + if (documentData.QRCodeImage != null) + { + column.Item().PaddingTop(500).PaddingRight(50).AlignRight().Width(80).Height(80).Image(documentData.QRCodeImage); + } + }); + + page.Footer() + .AlignCenter() + .Text(x => + { + x.Span("生成时间: ").FontSize(8).FontColor(QuestPDF.Helpers.Colors.Grey.Darken1); + x.CurrentPageNumber().FontSize(8).FontColor(QuestPDF.Helpers.Colors.Grey.Darken1); + }); + }); + }); + + // 生成PDF到流 + questDocument.GeneratePdf(pdfStream); + await Task.CompletedTask; // 为了保持异步签名 + } + + /// + /// 根据段落内容确定文本位置(基于A4页面210x297mm,坐标单位为点) + /// + private (double X, double Y) GetTextPosition(ParagraphData paragraph) + { + // 根据文本内容和样式判断位置 + var text = paragraph.Text; + + // 根据证书模板的布局进行定位(A4页面,左上角为原点) + // 调整坐标以更好地匹配证书模板 + if (text.Contains("第九届上海国际交互绳大奖赛")) + { + return (150, 80); // 顶部标题位置(居中) + } + else if (text.Contains("Double Dutch Contest Shanghai")) + { + return (150, 120); // 英文标题位置(居中) + } + else if (text.Contains("CERTIFICATE")) + { + return (250, 200); // 证书标题位置(居中) + } + else if (text.Contains("比赛证书")) + { + return (250, 240); // 中文证书标题位置(居中) + } + else if (text.Contains("参赛单位")) + { + return (100, 320); // 参赛单位标签位置 + } + else if (text.Contains("{{danwei}}")) + { + return (200, 320); // 参赛单位值位置 + } + else if (text.Contains("参赛运动员")) + { + return (100, 370); // 参赛运动员标签位置 + } + else if (text.Contains("{{name}}")) + { + return (200, 370); // 参赛运动员值位置 + } + else if (text.Contains("项目组别")) + { + return (100, 420); // 项目组别标签位置 + } + else if (text.Contains("名次")) + { + return (100, 470); // 名次标签位置 + } + else if (text.Contains("第九届上海国际交互绳大奖赛组委会")) + { + return (150, 550); // 底部组织机构位置(居中) + } + else if (text.Contains("二零二五年十一月")) + { + return (250, 590); // 日期位置(居中) + } + + // 默认位置 + return (50, 50); + } + + /// + /// 确定段落样式 + /// + private string DetermineParagraphStyle(Paragraph paragraph) + { + // 检查是否为标题 + var paragraphProperties = paragraph.ParagraphProperties; + if (paragraphProperties != null) + { + var style = paragraphProperties.ParagraphStyleId?.Val?.Value?.ToLower(); + if (style != null) + { + if (style.Contains("heading") || style.Contains("title")) + { + return "heading"; + } + } + } + + // 根据文本特征判断 + var text = paragraph.InnerText?.Trim() ?? string.Empty; + if (text.Length < 50 && text.Length > 0) + { + return "subheading"; + } + + return "normal"; } /// @@ -608,15 +760,15 @@ public class CertificateGenerateService : IDynamicApiController, ITransient // macOS "/Applications/LibreOffice.app/Contents/MacOS/soffice" }; - /* + foreach (var path in possiblePaths) { - if (File.Exists(path)) + if (System.IO.File.Exists(path)) { _logger.LogInformation("找到LibreOffice: {Path}", path); return path; } - }*/ + } // 如果找不到,尝试使用系统PATH中的命令 return OperatingSystem.IsWindows() ? "soffice" : "libreoffice"; @@ -761,4 +913,124 @@ public class CertificateGenerateService : IDynamicApiController, ITransient var baseUrl = $"{request.Scheme}://{request.Host.Value}"; return _qrCodeService.GenerateCertificateUrl(certificateId, baseUrl); } + + /// + /// 生成PDF保存地址 + /// + private string GeneratePdfUrl(long certificateId, YD_Event.Core.Entities.Certificate certificate, CertificateTemplate template) + { + var options = _ossOptionsMonitor.CurrentValue; + var now = DateTime.Today; + string name = $"certificate_{_idGenerator.Encode(_idGenerator.NewLong())}.pdf"; + string filePath = $"/certificates/{now.Year}/{now.Month:D2}/{now.Day:D2}/"; + string finalUrl = string.Empty; + + if (!options.Enable) + { + // 本地存储 + string localDir = string.Concat(options.Bucket.TrimEnd('/'), filePath); + + // 预测最终 URL + var request = _httpContextAccessor.HttpContext?.Request; + if (request != null) + { + finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir}{name}"; + } + } + else + { + // OSS 上传 + string fileName = $"{filePath}{name}"; + + // 预测最终 URL + string bucketPrefix = (options.Provider == OSSProvider.Minio) ? $"/{options.Bucket}" : string.Empty; + finalUrl = $"{options.Domain.TrimEnd('/')}{bucketPrefix}{fileName}"; + } + + return finalUrl; + } + + /// + /// 保存PDF文件到指定URL + /// + private async Task SavePdfFileToUrl(Stream pdfStream, string targetUrl) + { + var options = _ossOptionsMonitor.CurrentValue; + + // 从URL中解析文件路径 + var uri = new Uri(targetUrl); + var path = uri.AbsolutePath; + + // 移除域名部分,获取相对路径 + string relativePath = path; + if (path.StartsWith("/")) + { + relativePath = path.Substring(1); + } + + pdfStream.Position = 0; + + if (!options.Enable) + { + // 本地存储 + string localDir = Path.GetDirectoryName(relativePath); + string fileName = Path.GetFileName(relativePath); + string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir); + + if (!Directory.Exists(absoluteDir)) + { + Directory.CreateDirectory(absoluteDir); + } + + string fullPath = Path.Combine(absoluteDir, fileName); + + using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write); + await pdfStream.CopyToAsync(fileStream); + + _logger.LogInformation("PDF文件保存到本地: {FullPath}", fullPath); + } + else + { + // OSS 上传 + await _ossService.PutObjectAsync(options.Bucket, relativePath, pdfStream); + + _logger.LogInformation("PDF文件上传到OSS: {FileName}", relativePath); + } + } +} + +/// +/// 文档数据模型 +/// +public class DocumentData +{ + public List Paragraphs { get; set; } = new List(); + public List Tables { get; set; } = new List(); + public byte[] BackgroundImage { get; set; } = null; + public string BackgroundImageMimeType { get; set; } = string.Empty; + public byte[] QRCodeImage { get; set; } = null; + public string QRCodeMimeType { get; set; } = string.Empty; +} + +/// +/// 段落数据模型 +/// +public class ParagraphData +{ + public string Text { get; set; } = string.Empty; + public string Style { get; set; } = "normal"; // normal, heading, subheading + public double? PositionX { get; set; } = null; + public double? PositionY { get; set; } = null; + public double? FontSize { get; set; } = null; + public string Alignment { get; set; } = "left"; // left, center, right + public bool IsBold { get; set; } = false; + public string FontColor { get; set; } = "black"; +} + +/// +/// 表格数据模型 +/// +public class TableData +{ + public List> Rows { get; set; } = new List>(); } diff --git a/YD_Event.Application/YD_Event.Application.xml b/YD_Event.Application/YD_Event.Application.xml index 6a72e0e..3aef3f0 100644 --- a/YD_Event.Application/YD_Event.Application.xml +++ b/YD_Event.Application/YD_Event.Application.xml @@ -319,29 +319,59 @@ 批量生成PDF证书 - + - 转换DOC为PDF(使用DocumentFormat.OpenXml解析并生成PDF) + 转换DOC为PDF(使用QuestPDF和DocumentFormat.OpenXml) - + - 提取DOCX文件内容 + 提取DOCX文档数据(包括段落、表格、背景图片等) - + - 从内容生成PDF + 提取背景图片 - + - 转换文本为ASCII兼容格式 + 获取段落字体大小 - + - 生成简单的PDF作为备用方案 + 获取段落对齐方式 + + + + + 检查段落是否为粗体 + + + + + 获取段落字体颜色 + + + + + 获取段落位置信息 + + + + + 使用QuestPDF生成PDF + + + + + 根据段落内容确定文本位置(基于A4页面210x297mm,坐标单位为点) + + + + + 确定段落样式 @@ -369,6 +399,31 @@ 生成证书URL + + + 生成PDF保存地址 + + + + + 保存PDF文件到指定URL + + + + + 文档数据模型 + + + + + 段落数据模型 + + + + + 表格数据模型 + + 证书管理服务