using YD_Event.Application.Certificate.Dtos; using YD_Event.Core.Entities; using YD_Event.Core.Domain.Certificate; using SqlSugar; using Mapster; using Microsoft.Extensions.Options; using YD_Event.Core.Options; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System.Diagnostics; using System.ComponentModel.DataAnnotations; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using DocumentFormat.OpenXml.Drawing.Wordprocessing; using System.IO; using System.Linq; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; using YD_Event.Application.Common; namespace YD_Event.Application.Certificate; /// /// 证书生成服务 /// public class CertificateGenerateService : IDynamicApiController, ITransient { private readonly ISqlSugarRepository _certificateRepository; private readonly ISqlSugarRepository _templateRepository; private readonly ISqlSugarRepository _placeholderRepository; private readonly FileService _fileService; private readonly IOptionsMonitor _ossOptionsMonitor; private readonly IIdGenerator _idGenerator; private readonly IWebHostEnvironment _webHostEnvironment; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; private readonly DocxTemplateProcessor _docxProcessor; private readonly QRCodeService _qrCodeService; public CertificateGenerateService( ISqlSugarRepository certificateRepository, ISqlSugarRepository templateRepository, ISqlSugarRepository placeholderRepository, FileService fileService, IOptionsMonitor ossOptionsMonitor, IIdGenerator idGenerator, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContextAccessor, ILogger logger, DocxTemplateProcessor docxProcessor, QRCodeService qrCodeService) { _certificateRepository = certificateRepository; _templateRepository = templateRepository; _placeholderRepository = placeholderRepository; _fileService = fileService; _ossOptionsMonitor = ossOptionsMonitor; _idGenerator = idGenerator; _webHostEnvironment = webHostEnvironment; _httpContextAccessor = httpContextAccessor; _logger = logger; _docxProcessor = docxProcessor; _qrCodeService = qrCodeService; } /// /// 生成PDF证书 /// [DisplayName("生成PDF证书")] public async Task GeneratePdf(GeneratePdfCertificateInput input) { var stopwatch = Stopwatch.StartNew(); var result = new PdfCertificateGenerationResult { GeneratedTime = DateTime.Now, IsSuccess = true }; try { // 1. 获取证书信息 var certificate = await _certificateRepository.GetByIdAsync(input.CertificateId); if (certificate == null) { result.IsSuccess = false; result.ErrorMessage = "证书不存在"; return result; } if (certificate.Status != Core.Enum.AvailabilityStatus.Enable) { result.IsSuccess = false; result.ErrorMessage = "证书已禁用"; return result; } // 2. 获取模板 CertificateTemplate template; if (input.TemplateId.HasValue) { template = await _templateRepository.GetByIdAsync(input.TemplateId.Value); } else { template = await _templateRepository.GetByIdAsync(certificate.TemplateId); } if (template == null) { result.IsSuccess = false; result.ErrorMessage = "未找到有效证书模板"; return result; } // 3. 检查模板类型 if (template.TemplateType != "Doc") { result.IsSuccess = false; result.ErrorMessage = "指定的模板不是DOC类型"; return result; } // 4. 获取占位符配置 var placeholders = await _placeholderRepository.AsQueryable() .Where(x => x.TemplateId == template.Id) .OrderBy(x => x.Sort) .ToListAsync(); // 5. 验证必填数据 var requiredPlaceholders = placeholders.Where(x => x.IsRequired).Select(x => x.PlaceholderKey).ToList(); var validationResult = _docxProcessor.ValidateTemplateData(requiredPlaceholders, input.Data); if (!validationResult.IsValid) { result.IsSuccess = false; result.ErrorMessage = $"缺少必填数据: {string.Join(", ", validationResult.MissingPlaceholders)}"; return result; } // 6. 下载DOC模板 using var httpClient = new HttpClient(); var templateStream = new MemoryStream(await httpClient.GetByteArrayAsync(template.DocxTemplateUrl)); // 7. 生成二维码数据(用于DOC模板中的占位符) var qrCodeData = new Dictionary(); if (input.GenerateQRCode) { var certificateUrl = GenerateCertificateUrl(input.CertificateId); qrCodeData["CertificateQR"] = certificateUrl; qrCodeData["VerificationQR"] = certificateUrl + "/verify"; } // 8. 设置占位符映射(支持中文显示名 -> 英文键名) var placeholderMapping = new Dictionary(); foreach (var placeholder in placeholders) { if (!string.IsNullOrWhiteSpace(placeholder.DisplayName)) { placeholderMapping[placeholder.DisplayName] = placeholder.PlaceholderKey; } } _docxProcessor.SetPlaceholderMapping(placeholderMapping); _logger.LogInformation("设置占位符映射完成. 映射数量: {Count}", placeholderMapping.Count); // 9. 处理模板 var processedStream = _docxProcessor.ProcessTemplate(templateStream, input.Data, qrCodeData); // 10. 先生成证书保存地址(预设的PDF地址) var pdfUrl = GeneratePdfUrl(input.CertificateId, certificate, template); // 11. 生成二维码图片(使用预设的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(); } // 12. 转换为PDF(包含二维码和数据替换) var pdfStream = await ConvertDocToPdf(processedStream, qrCodeImage, input.Data, qrCodeData, placeholderMapping); // 13. 保存PDF文件到预设地址 await SavePdfFileToUrl(pdfStream, pdfUrl); // 14. 生成二维码图片(如果需要) string qrCodeUrl = null; if (input.GenerateQRCode) { qrCodeUrl = await GenerateQRCodeImage(input.CertificateId, input.QRCodeSize); } // 15. 设置结果 result.PdfUrl = pdfUrl; result.QRCodeUrl = qrCodeUrl; result.VerificationUrl = GenerateCertificateUrl(input.CertificateId); result.Warnings.AddRange(validationResult.Warnings); _logger.LogInformation("PDF证书生成成功. CertificateId: {CertificateId}, Url: {Url}", input.CertificateId, pdfUrl); } catch (Exception ex) { result.IsSuccess = false; result.ErrorMessage = ex.Message; _logger.LogError(ex, "PDF证书生成失败. CertificateId: {CertificateId}", input.CertificateId); } finally { stopwatch.Stop(); result.GenerationTimeMs = stopwatch.ElapsedMilliseconds; } return result; } /// /// 批量生成PDF证书 /// [DisplayName("批量生成PDF证书")] public async Task BatchGeneratePdf(BatchGeneratePdfCertificateInput input) { var result = new BatchPdfCertificateGenerationResult { StartTime = DateTime.Now, TotalCount = input.DataList.Count }; _logger.LogInformation("开始批量生成PDF证书. Count: {Count}", input.DataList.Count); foreach (var data in input.DataList) { var generateInput = new GeneratePdfCertificateInput { CertificateId = input.CertificateId, TemplateId = input.TemplateId, Data = data, GenerateQRCode = input.GenerateQRCode, QRCodeSize = input.QRCodeSize }; var generateResult = await GeneratePdf(generateInput); if (generateResult.IsSuccess) { result.SuccessResults.Add(generateResult); } else { result.FailedResults.Add(generateResult); } } result.EndTime = DateTime.Now; _logger.LogInformation("批量生成PDF证书完成. Total: {Total}, Success: {Success}, Failed: {Failed}", result.TotalCount, result.SuccessCount, result.FailedCount); return result; } /// /// 转换DOC为PDF(使用QuestPDF) /// private async Task ConvertDocToPdf(Stream docStream, byte[] qrCodeImage = null, Dictionary data = null, Dictionary qrCodeData = null, Dictionary placeholderMapping = null) { _logger.LogInformation("开始DOCX转PDF转换(使用QuestPDF)"); // 重置流位置 docStream.Position = 0; // 使用QuestPDF许可证 QuestPDF.Settings.License = LicenseType.Community; // 解析DOCX内容 var documentData = await ExtractDocumentData(docStream); // 添加二维码图片 if (qrCodeImage != null) { documentData.QRCodeImage = qrCodeImage; documentData.QRCodeMimeType = "image/png"; } // 替换占位符内容 if (data != null) { ReplacePlaceholdersInDocumentData(documentData, data, qrCodeData, placeholderMapping); } // 生成PDF var pdfStream = new MemoryStream(); await GeneratePdfWithQuestPDF(pdfStream, documentData); pdfStream.Position = 0; _logger.LogInformation("DOCX转PDF完成,PDF大小: {Size} bytes", pdfStream.Length); return pdfStream; } /// /// 替换DocumentData中的占位符 /// private void ReplacePlaceholdersInDocumentData(DocumentData documentData, Dictionary data, Dictionary qrCodeData, Dictionary placeholderMapping) { const string placeholderPattern = @"\{\{([^}]+)\}\}"; // 记录占位符映射信息(便于调试) if (placeholderMapping != null && placeholderMapping.Count > 0) { _logger.LogDebug("占位符映射配置: {Mapping}", string.Join(", ", placeholderMapping.Select(kvp => $"{kvp.Key}->{kvp.Value}"))); } // 记录数据字典中的key(便于调试) if (data != null && data.Count > 0) { _logger.LogDebug("数据字典中的key: {Keys}", string.Join(", ", data.Keys)); } foreach (var paragraph in documentData.Paragraphs) { var originalText = paragraph.Text; var processedText = originalText; // 使用正则表达式替换所有占位符 processedText = System.Text.RegularExpressions.Regex.Replace(processedText, placeholderPattern, match => { var key = match.Groups[1].Value.Trim(); _logger.LogDebug("处理占位符: {Key}", key); // 优先使用二维码数据 if (qrCodeData != null && qrCodeData.ContainsKey(key)) { _logger.LogDebug(" -> 使用二维码数据: {Value}", qrCodeData[key]); return qrCodeData[key]; } // 尝试使用占位符映射(中文显示名 -> 英文键名) string mappedKey = key; if (placeholderMapping != null && placeholderMapping.ContainsKey(key)) { mappedKey = placeholderMapping[key]; _logger.LogDebug(" -> 占位符映射: {Original} -> {Mapped}", key, mappedKey); } // 使用普通数据(先尝试映射后的key,再尝试原始key) if (data != null) { if (data.ContainsKey(mappedKey)) { _logger.LogDebug(" -> 使用数据字典(映射后): {Key} = {Value}", mappedKey, data[mappedKey]); return data[mappedKey] ?? string.Empty; } if (data.ContainsKey(key)) { _logger.LogDebug(" -> 使用数据字典(原始): {Key} = {Value}", key, data[key]); return data[key] ?? string.Empty; } } // 返回原始占位符(未找到数据) _logger.LogDebug(" -> 未找到数据,保留原始占位符"); return match.Value; }); if (originalText != processedText) { _logger.LogDebug("占位符替换: {Original} -> {Processed}", originalText, processedText); paragraph.Text = processedText; } } } /// /// 提取DOCX文档数据(包括段落、表格、背景图片等) /// private async Task ExtractDocumentData(Stream docStream) { var documentData = new DocumentData(); var processedTexts = new HashSet(); // 记录已处理的文本,避免重复 using var wordprocessingDocument = WordprocessingDocument.Open(docStream, false); var body = wordprocessingDocument.MainDocumentPart?.Document?.Body; // 提取背景图片 await ExtractBackgroundImage(wordprocessingDocument, documentData); if (body != null) { // 首先处理 Drawing 对象(浮于文字上方的文本框) ExtractDrawingObjects(body, documentData, processedTexts); // 提取段落(包含位置信息),但跳过包含Drawing对象的段落(避免重复) foreach (var paragraph in body.Elements()) { // 检查段落是否包含 Drawing 对象,如果是则跳过(已在 ExtractDrawingObjects 中处理) var hasDrawing = paragraph.Descendants().Any(); if (hasDrawing) { _logger.LogDebug("跳过包含 Drawing 对象的段落"); continue; } var paragraphText = paragraph.InnerText?.Trim(); if (!string.IsNullOrEmpty(paragraphText)) { // 增强过滤逻辑,过滤掉不应该出现的内容 if (ShouldSkipParagraph(paragraphText)) { _logger.LogDebug("跳过不应该的内容: {Text}", paragraphText); continue; } // 清理段落文本,移除不需要的内容 var cleanedText = CleanParagraphText(paragraphText); if (string.IsNullOrEmpty(cleanedText)) { continue; } // 检查是否已经处理过相同文本,避免重复 if (processedTexts.Contains(cleanedText)) { _logger.LogDebug("跳过重复文本: {Text}", cleanedText); continue; } var paragraphData = new ParagraphData { Text = cleanedText, Style = DetermineParagraphStyle(paragraph), FontSize = GetParagraphFontSize(paragraph), Alignment = GetParagraphAlignment(paragraph), IsBold = IsParagraphBold(paragraph), FontColor = GetParagraphColor(paragraph) }; // 尝试获取位置信息 GetParagraphPosition(paragraph, paragraphData); _logger.LogDebug("提取段落: Text={Text}, X={X}, Y={Y}", cleanedText, paragraphData.PositionX, paragraphData.PositionY); documentData.Paragraphs.Add(paragraphData); processedTexts.Add(cleanedText); } } // 提取表格 foreach (var table in body.Elements()) { var tableData = new TableData(); foreach (var row in table.Elements()) { var rowData = new List(); foreach (var cell in row.Elements()) { var cellText = cell.InnerText?.Trim() ?? string.Empty; rowData.Add(cellText); } if (rowData.Any()) { tableData.Rows.Add(rowData); } } if (tableData.Rows.Any()) { documentData.Tables.Add(tableData); } } } return documentData; } /// /// 提取背景图片 /// private async Task ExtractBackgroundImage(WordprocessingDocument wordDoc, DocumentData documentData) { try { var mainPart = wordDoc.MainDocumentPart; if (mainPart != null) { // 查找文档中的图片 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 (Exception 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 中字体大小是半磅为单位 } } 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 bool ShouldSkipParagraph(string text) { if (string.IsNullOrEmpty(text)) return true; // 过滤掉包含明显内部元数据的内容 var lowerText = text.ToLower(); // 过滤引用相关内容 if (lowerText.Contains("citation")) return true; // 过滤纯数字的定位信息 if (System.Text.RegularExpressions.Regex.IsMatch(text, @"^\d+$")) return true; // 过滤定位前缀(left-, right-, top-, bottom-) if (text.StartsWith("right-") || text.StartsWith("left-") || text.StartsWith("top-") || text.StartsWith("bottom-")) return true; return false; } /// /// 清理段落文本,移除不需要的内容 /// private string CleanParagraphText(string text) { if (string.IsNullOrEmpty(text)) return text; var cleaned = text; // 移除连续的数字序列(定位信息) cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"\d{6,}", ""); // 移除 left-xxx, right-xxx, top-xxx, bottom-xxx 这类定位信息 cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"(left-|right-|top-|bottom-)\d+", ""); // 移除重复的占位符(如 {{单位}}00{{单位}}) var placeholderPattern = @"\{\{([^}]+)\}\}"; var matches = System.Text.RegularExpressions.Regex.Matches(cleaned, placeholderPattern); if (matches.Count > 1) { // 保留第一个占位符,移除后续重复的 var firstMatch = matches[0]; cleaned = cleaned.Substring(0, firstMatch.Index + firstMatch.Length); } // 移除残留的 "00" 这类分隔符 cleaned = System.Text.RegularExpressions.Regex.Replace(cleaned, @"00+", ""); // 移除首尾空格 cleaned = cleaned.Trim(); return cleaned; } /// /// 提取 Drawing 对象(浮于文字上方的文本框) /// private void ExtractDrawingObjects(Body body, DocumentData documentData, HashSet processedTexts = null) { try { // 查找所有包含 Drawing 的段落 foreach (var paragraph in body.Elements()) { // 查找段落中的 Drawing 元素 var drawings = paragraph.Descendants().ToList(); foreach (var drawing in drawings) { ExtractTextFromDrawing(drawing, documentData, processedTexts); } } } catch (Exception ex) { _logger.LogError(ex, "提取 Drawing 对象失败"); } } /// /// 从 Drawing 对象中提取文本和位置信息 /// private void ExtractTextFromDrawing(DocumentFormat.OpenXml.Wordprocessing.Drawing drawing, DocumentData documentData, HashSet processedTexts = null) { try { // 尝试从 Drawing 对象中提取文本框内容 var textBoxContents = drawing.Descendants().ToList(); foreach (var textBoxContent in textBoxContents) { // 提取文本框中的所有段落 var paragraphs = textBoxContent.Elements().ToList(); foreach (var paragraph in paragraphs) { var paragraphText = paragraph.InnerText?.Trim(); if (!string.IsNullOrEmpty(paragraphText)) { // 增强过滤逻辑,过滤掉不应该出现的内容 if (ShouldSkipParagraph(paragraphText)) { _logger.LogDebug("跳过 Drawing 对象中的内容: {Text}", paragraphText); continue; } // 清理段落文本,移除不需要的内容 var cleanedText = CleanParagraphText(paragraphText); if (string.IsNullOrEmpty(cleanedText)) { continue; } // 检查是否已经处理过相同文本,避免重复 if (processedTexts != null && processedTexts.Contains(cleanedText)) { _logger.LogDebug("跳过 Drawing 对象中的重复文本: {Text}", cleanedText); continue; } var paragraphData = new ParagraphData { Text = cleanedText, Style = DetermineParagraphStyle(paragraph), FontSize = GetParagraphFontSize(paragraph), Alignment = GetParagraphAlignment(paragraph), IsBold = IsParagraphBold(paragraph), FontColor = GetParagraphColor(paragraph) }; // 尝试从 Drawing 对象中提取位置信息 ExtractPositionFromDrawing(drawing, paragraphData); _logger.LogDebug("从 Drawing 对象提取段落: Text={Text}, X={X}, Y={Y}", cleanedText, paragraphData.PositionX, paragraphData.PositionY); documentData.Paragraphs.Add(paragraphData); if (processedTexts != null) { processedTexts.Add(cleanedText); } } } } } catch (Exception ex) { _logger.LogError(ex, "从 Drawing 对象提取文本失败"); } } /// /// 从 Drawing 对象中提取位置信息 /// private void ExtractPositionFromDrawing(DocumentFormat.OpenXml.Wordprocessing.Drawing drawing, ParagraphData paragraphData) { try { _logger.LogDebug("开始解析 Drawing 对象位置信息"); var outerXml = drawing.OuterXml; _logger.LogDebug("Drawing 对象原始 XML (前800字符): {Xml}", outerXml.Substring(0, Math.Min(800, outerXml.Length))); // 方法1: 查找 simplePos 属性(通常用于绝对定位) ExtractSimplePosition(outerXml, paragraphData); // 方法2: 如果 simplePos 没有值,查找 positionOffset 元素 if (paragraphData.PositionX == null || paragraphData.PositionY == null) { ExtractPositionOffset(outerXml, paragraphData); } // 注意:不使用 distL, distT 等距离属性 // 这些属性不是文本框在页面上的坐标,而是: // - 要么是文本框的内边距(文本到边框的距离) // - 要么是文本框到页面边距的距离(而不是绝对坐标) // 我们目前使用写死定位方式,不需要依赖这些值 _logger.LogDebug("最终提取的位置 - X: {X}, Y: {Y}", paragraphData.PositionX, paragraphData.PositionY); } catch (Exception ex) { _logger.LogError(ex, "从 Drawing 对象提取位置信息失败"); } } /// /// 从 XML 中提取 simplePos 属性 /// private void ExtractSimplePosition(string xml, ParagraphData paragraphData) { try { // 查找 simplePos:x 和 simplePos:y 属性 var xMatch = System.Text.RegularExpressions.Regex.Match(xml, @"simplePos:x=[""'](\d+)[""']"); var yMatch = System.Text.RegularExpressions.Regex.Match(xml, @"simplePos:y=[""'](\d+)[""']"); if (xMatch.Success) { if (long.TryParse(xMatch.Groups[1].Value, out long x)) { paragraphData.PositionX = EmuToPoints(x); _logger.LogDebug("simplePos X (EMU): {EMU}, (点): {Points}", x, paragraphData.PositionX); } } if (yMatch.Success) { if (long.TryParse(yMatch.Groups[1].Value, out long y)) { paragraphData.PositionY = EmuToPoints(y); _logger.LogDebug("simplePos Y (EMU): {EMU}, (点): {Points}", y, paragraphData.PositionY); } } } catch (Exception ex) { _logger.LogError(ex, "提取 simplePos 失败"); } } /// /// 从 XML 中提取 positionOffset 元素 /// private void ExtractPositionOffset(string xml, ParagraphData paragraphData) { try { // 分别提取 positionH 和 positionV 中的 posOffset 和 relativeFrom // 这样可以更准确地获取 X 和 Y 坐标及其参照对象 var positionHPattern = @"]*relativeFrom=[""']([^""']+)[""'][^>]*>(?:\s*]*>(\d+)\s*)"; var positionHMatch = System.Text.RegularExpressions.Regex.Match( xml, positionHPattern, System.Text.RegularExpressions.RegexOptions.Singleline); var positionVPattern = @"]*relativeFrom=[""']([^""']+)[""'][^>]*>(?:\s*]*>(\d+)\s*)"; var positionVMatch = System.Text.RegularExpressions.Regex.Match( xml, positionVPattern, System.Text.RegularExpressions.RegexOptions.Singleline); // 日志输出参照对象信息 if (positionHMatch.Success) { _logger.LogDebug("positionH 参照对象: {RelativeFrom}", positionHMatch.Groups[1].Value); } if (positionVMatch.Success) { _logger.LogDebug("positionV 参照对象: {RelativeFrom}", positionVMatch.Groups[1].Value); } if (positionHMatch.Success && paragraphData.PositionX == null) { if (long.TryParse(positionHMatch.Groups[2].Value, out long x)) { paragraphData.PositionX = EmuToPoints(x); _logger.LogDebug("positionH posOffset (EMU): {EMU}, (点): {Points}", x, paragraphData.PositionX); } } if (positionVMatch.Success && paragraphData.PositionY == null) { if (long.TryParse(positionVMatch.Groups[2].Value, out long y)) { paragraphData.PositionY = EmuToPoints(y); _logger.LogDebug("positionV posOffset (EMU): {EMU}, (点): {Points}", y, paragraphData.PositionY); } } // 如果上面的方法失败,尝试简单的正则表达式匹配 if (!positionHMatch.Success || !positionVMatch.Success) { _logger.LogDebug("尝试备用方案:简单匹配 posOffset"); var allPosOffsetMatches = System.Text.RegularExpressions.Regex.Matches(xml, @"]*>(\d+)"); if (allPosOffsetMatches.Count >= 2) { if (long.TryParse(allPosOffsetMatches[0].Groups[1].Value, out long x) && paragraphData.PositionX == null) { paragraphData.PositionX = EmuToPoints(x); _logger.LogDebug("备用方案 - posOffset X (EMU): {EMU}, (点): {Points}", x, paragraphData.PositionX); } if (long.TryParse(allPosOffsetMatches[1].Groups[1].Value, out long y) && paragraphData.PositionY == null) { paragraphData.PositionY = EmuToPoints(y); _logger.LogDebug("备用方案 - posOffset Y (EMU): {EMU}, (点): {Points}", y, paragraphData.PositionY); } } } } catch (Exception ex) { _logger.LogError(ex, "提取 positionOffset 失败"); } } /// /// 从 XML 中提取 distL, distT 等距离属性 /// private void ExtractDistanceAttributes(string xml, ParagraphData paragraphData) { try { // 查找 distL (left distance) 和 distT (top distance) var distLMatch = System.Text.RegularExpressions.Regex.Match(xml, @"distL=[""'](\d+)[""']"); var distTMatch = System.Text.RegularExpressions.Regex.Match(xml, @"distT=[""'](\d+)[""']"); if (distLMatch.Success && paragraphData.PositionX == null) { if (long.TryParse(distLMatch.Groups[1].Value, out long x)) { paragraphData.PositionX = EmuToPoints(x); _logger.LogDebug("distL X (EMU): {EMU}, (点): {Points}", x, paragraphData.PositionX); } } if (distTMatch.Success && paragraphData.PositionY == null) { if (long.TryParse(distTMatch.Groups[1].Value, out long y)) { paragraphData.PositionY = EmuToPoints(y); _logger.LogDebug("distT Y (EMU): {EMU}, (点): {Points}", y, paragraphData.PositionY); } } } catch (Exception ex) { _logger.LogError(ex, "提取距离属性失败"); } } /// /// EMU (English Metric Unit) 转换为点 /// 1 英寸 = 914400 EMU /// 1 英寸 = 72 点 /// 所以:1 EMU = 72 / 914400 点 = 1 / 12700 点 /// private double EmuToPoints(long emu) { return emu / 12700.0; } /// /// 获取段落位置信息 /// 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(); } // 使用 Column 来放置所有内容,但使用 Translate 而非 Padding 定位 page.Content().Column(column => { // 根据段落信息进行定位(使用从Word文档提取的位置信息) foreach (var paragraph in documentData.Paragraphs) { var fontSize = paragraph.FontSize ?? 14; var isBold = paragraph.IsBold; // 日志输出段落信息,便于调试 _logger.LogDebug("处理段落: Text={Text}, X={X}, Y={Y}, FontSize={FontSize}, Bold={IsBold}", paragraph.Text, paragraph.PositionX, paragraph.PositionY, fontSize, isBold); // 直接使用从Word文档提取的位置信息,并增加110个单位进行调整 float x = (float)(paragraph.PositionX ?? 50) + 90; float y = (float)(paragraph.PositionY ?? 50) + 50; _logger.LogDebug("最终位置 - X={X}, Y={Y}", x, y); // 使用 Translate 而非 Padding 来定位,避免流式布局累加导致分页 var textElement = column.Item().TranslateX(x).TranslateY(y).Text(paragraph.Text) .FontSize((float)fontSize) .FontColor(QuestPDF.Helpers.Colors.Black) .AlignLeft(); if (isBold) { textElement.Bold(); } } }); // 添加二维码到右下角(使用 Footer) if (documentData.QRCodeImage != null) { float qrSize = 100; _logger.LogDebug("添加二维码: Size={Size}", qrSize); page.Footer().AlignRight().PaddingRight(30).PaddingBottom(30) .Width(qrSize).Height(qrSize) .Image(documentData.QRCodeImage); } }); }); // 生成PDF到流 questDocument.GeneratePdf(pdfStream); await Task.CompletedTask; // 为了保持异步签名 } /// /// 确定段落样式 /// 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"; } /// /// 保存PDF文件 /// private async Task SavePdfFile(Stream pdfStream, 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; pdfStream.Position = 0; if (!options.Enable) { // 本地存储 string localDir = string.Concat(options.Bucket?.TrimEnd('/') ?? "", filePath); string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir.TrimStart('/')); if (!Directory.Exists(absoluteDir)) { Directory.CreateDirectory(absoluteDir); } string fullPath = Path.Combine(absoluteDir, name); // 预测最终 URL var request = _httpContextAccessor.HttpContext?.Request; if (request != null) { finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir.TrimStart('/')}{name}"; } using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write); await pdfStream.CopyToAsync(fileStream); _logger.LogInformation("PDF文件保存到本地: {FullPath}", fullPath); } else { // OSS 上传 string fileName = $"{filePath.TrimStart('/')}{name}"; finalUrl = await _fileService.UploadFileStreamAsync(fileName, pdfStream); _logger.LogInformation("PDF文件上传到OSS: {FileName}", fileName); } return finalUrl; } /// /// 生成二维码图片 /// private async Task GenerateQRCodeImage(long certificateId, int size) { try { var certificateUrl = GenerateCertificateUrl(certificateId); using var qrStream = _qrCodeService.GenerateQRCode(certificateUrl, size); return await SaveQRCodeImage(qrStream, certificateId, size); } catch (Exception ex) { _logger.LogError(ex, "生成二维码图片失败. CertificateId: {CertificateId}", certificateId); return null; } } /// /// 保存二维码图片 /// private async Task SaveQRCodeImage(Stream qrStream, long certificateId, int size) { var options = _ossOptionsMonitor.CurrentValue; var now = DateTime.Today; string name = $"qr_{certificateId}_{_idGenerator.Encode(_idGenerator.NewLong())}"; string filePath = $"/{now.Year}/{now.Month:D2}/{now.Day:D2}/qrcodes/"; string extension = ".png"; string finalUrl = string.Empty; qrStream.Position = 0; if (!options.Enable) { string localDir = string.Concat(options.Bucket?.TrimEnd('/') ?? "", filePath); string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir.TrimStart('/')); if (!Directory.Exists(absoluteDir)) { Directory.CreateDirectory(absoluteDir); } string fullPath = Path.Combine(absoluteDir, $"{name}{extension}"); using var fileStream2 = new FileStream(fullPath, FileMode.Create, FileAccess.Write); await qrStream.CopyToAsync(fileStream2); var request = _httpContextAccessor.HttpContext?.Request; if (request != null) { finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir.TrimStart('/')}{name}{extension}"; } } else { // OSS 上传 string fileName = $"{filePath.TrimStart('/')}{name}{extension}"; finalUrl = await _fileService.UploadFileStreamAsync(fileName, qrStream); } return finalUrl; } /// /// 生成证书URL /// private string GenerateCertificateUrl(long certificateId) { var request = _httpContextAccessor.HttpContext?.Request; if (request == null) return string.Empty; 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.TrimStart('/')}{name}"; } } else { // OSS 上传 string fileName = $"{filePath.TrimStart('/')}{name}"; finalUrl = $"{options.Domain.TrimEnd('/')}/{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 _fileService.UploadFileStreamAsync(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>(); }