1501 lines
57 KiB
C#
1501 lines
57 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 证书生成服务
|
||
/// </summary>
|
||
public class CertificateGenerateService : IDynamicApiController, ITransient
|
||
{
|
||
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Certificate> _certificateRepository;
|
||
private readonly ISqlSugarRepository<CertificateTemplate> _templateRepository;
|
||
private readonly ISqlSugarRepository<CertificatePlaceholder> _placeholderRepository;
|
||
private readonly FileService _fileService;
|
||
private readonly IOptionsMonitor<OssConnectionOptions> _ossOptionsMonitor;
|
||
private readonly IIdGenerator _idGenerator;
|
||
private readonly IWebHostEnvironment _webHostEnvironment;
|
||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||
private readonly ILogger<CertificateGenerateService> _logger;
|
||
private readonly DocxTemplateProcessor _docxProcessor;
|
||
private readonly QRCodeService _qrCodeService;
|
||
|
||
public CertificateGenerateService(
|
||
ISqlSugarRepository<YD_Event.Core.Entities.Certificate> certificateRepository,
|
||
ISqlSugarRepository<CertificateTemplate> templateRepository,
|
||
ISqlSugarRepository<CertificatePlaceholder> placeholderRepository,
|
||
FileService fileService,
|
||
IOptionsMonitor<OssConnectionOptions> ossOptionsMonitor,
|
||
IIdGenerator idGenerator,
|
||
IWebHostEnvironment webHostEnvironment,
|
||
IHttpContextAccessor httpContextAccessor,
|
||
ILogger<CertificateGenerateService> 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;
|
||
|
||
// 初始化字体配置(使用新的字体助手)
|
||
CertificateFontHelper.ConfigureFonts(logger);
|
||
}
|
||
|
||
// 旧的字体配置方法已删除,使用新的 CertificateFontHelper
|
||
|
||
/// <summary>
|
||
/// 生成PDF证书
|
||
/// </summary>
|
||
[DisplayName("生成PDF证书")]
|
||
public async Task<PdfCertificateGenerationResult> 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<string, string>();
|
||
if (input.GenerateQRCode)
|
||
{
|
||
var certificateUrl = GenerateCertificateUrl(input.CertificateId);
|
||
qrCodeData["CertificateQR"] = certificateUrl;
|
||
qrCodeData["VerificationQR"] = certificateUrl + "/verify";
|
||
}
|
||
|
||
// 8. 设置占位符映射(支持中文显示名 -> 英文键名)
|
||
var placeholderMapping = new Dictionary<string, string>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 批量生成PDF证书
|
||
/// </summary>
|
||
[DisplayName("批量生成PDF证书")]
|
||
public async Task<BatchPdfCertificateGenerationResult> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 转换DOC为PDF(使用QuestPDF)
|
||
/// </summary>
|
||
private async Task<Stream> ConvertDocToPdf(Stream docStream, byte[] qrCodeImage = null, Dictionary<string, string> data = null, Dictionary<string, string> qrCodeData = null, Dictionary<string, string> placeholderMapping = null)
|
||
{
|
||
_logger.LogInformation("开始DOCX转PDF转换(使用QuestPDF)");
|
||
|
||
// 重置流位置
|
||
docStream.Position = 0;
|
||
|
||
// 解析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();
|
||
try
|
||
{
|
||
await GeneratePdfWithQuestPDF(pdfStream, documentData);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var a = 1;
|
||
throw;
|
||
}
|
||
|
||
pdfStream.Position = 0;
|
||
_logger.LogInformation("DOCX转PDF完成,PDF大小: {Size} bytes", pdfStream.Length);
|
||
return pdfStream;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 替换DocumentData中的占位符
|
||
/// </summary>
|
||
private void ReplacePlaceholdersInDocumentData(DocumentData documentData, Dictionary<string, string> data, Dictionary<string, string> qrCodeData, Dictionary<string, string> 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/// <summary>
|
||
/// 提取DOCX文档数据(包括段落、表格、背景图片等)
|
||
/// </summary>
|
||
private async Task<DocumentData> ExtractDocumentData(Stream docStream)
|
||
{
|
||
var documentData = new DocumentData();
|
||
var processedTexts = new HashSet<string>(); // 记录已处理的文本,避免重复
|
||
|
||
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<Paragraph>())
|
||
{
|
||
// 检查段落是否包含 Drawing 对象,如果是则跳过(已在 ExtractDrawingObjects 中处理)
|
||
var hasDrawing = paragraph.Descendants<DocumentFormat.OpenXml.Wordprocessing.Drawing>().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<Table>())
|
||
{
|
||
var tableData = new TableData();
|
||
foreach (var row in table.Elements<TableRow>())
|
||
{
|
||
var rowData = new List<string>();
|
||
foreach (var cell in row.Elements<TableCell>())
|
||
{
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 按照Y轴坐标进行升序排序,确保文本框显示顺序与Word文档视觉顺序一致
|
||
documentData.Paragraphs = documentData.Paragraphs
|
||
.OrderBy(p => p.PositionY ?? double.MaxValue) // 使用PositionY排序,没有位置的放在最后
|
||
.ThenBy(p => p.PositionX ?? double.MaxValue) // Y轴相同的情况下按X轴排序
|
||
.ToList();
|
||
|
||
// 记录排序后的顺序,便于调试
|
||
_logger.LogDebug("文本框排序完成,顺序: {Order}",
|
||
string.Join(" -> ", documentData.Paragraphs.Select((p, i) => $"[{i}]{p.Text}(Y={p.PositionY})")));
|
||
|
||
return documentData;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提取背景图片
|
||
/// </summary>
|
||
private async Task ExtractBackgroundImage(WordprocessingDocument wordDoc, DocumentData documentData)
|
||
{
|
||
try
|
||
{
|
||
var mainPart = wordDoc.MainDocumentPart;
|
||
if (mainPart != null)
|
||
{
|
||
// 查找文档中的图片
|
||
var imageParts = mainPart.GetPartsOfType<ImagePart>().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<ImagePart>();
|
||
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, "提取背景图片失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取段落字体大小
|
||
/// </summary>
|
||
private double? GetParagraphFontSize(Paragraph paragraph)
|
||
{
|
||
var runProperties = paragraph.Elements<Run>()
|
||
.SelectMany(r => r.Elements<RunProperties>())
|
||
.FirstOrDefault();
|
||
|
||
if (runProperties?.FontSize != null)
|
||
{
|
||
var fontSize = runProperties.FontSize.Val;
|
||
if (double.TryParse(fontSize, out double size))
|
||
{
|
||
return size / 2.0; // OpenXML 中字体大小是半磅为单位
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取段落对齐方式
|
||
/// </summary>
|
||
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";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查段落是否为粗体
|
||
/// </summary>
|
||
private bool IsParagraphBold(Paragraph paragraph)
|
||
{
|
||
var runProperties = paragraph.Elements<Run>()
|
||
.SelectMany(r => r.Elements<RunProperties>())
|
||
.FirstOrDefault();
|
||
|
||
return runProperties?.Bold != null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取段落字体颜色
|
||
/// </summary>
|
||
private string GetParagraphColor(Paragraph paragraph)
|
||
{
|
||
var runProperties = paragraph.Elements<Run>()
|
||
.SelectMany(r => r.Elements<RunProperties>())
|
||
.FirstOrDefault();
|
||
|
||
if (runProperties?.Color != null)
|
||
{
|
||
var color = runProperties.Color.Val;
|
||
if (!string.IsNullOrEmpty(color))
|
||
{
|
||
return color;
|
||
}
|
||
}
|
||
|
||
return "black";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 判断是否应该跳过该段落
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清理段落文本,移除不需要的内容
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提取 Drawing 对象(浮于文字上方的文本框)
|
||
/// </summary>
|
||
private void ExtractDrawingObjects(Body body, DocumentData documentData, HashSet<string> processedTexts = null)
|
||
{
|
||
try
|
||
{
|
||
// 查找所有包含 Drawing 的段落
|
||
foreach (var paragraph in body.Elements<Paragraph>())
|
||
{
|
||
// 查找段落中的 Drawing 元素
|
||
var drawings = paragraph.Descendants<DocumentFormat.OpenXml.Wordprocessing.Drawing>().ToList();
|
||
|
||
foreach (var drawing in drawings)
|
||
{
|
||
ExtractTextFromDrawing(drawing, documentData, processedTexts);
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "提取 Drawing 对象失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 Drawing 对象中提取文本和位置信息
|
||
/// </summary>
|
||
private void ExtractTextFromDrawing(DocumentFormat.OpenXml.Wordprocessing.Drawing drawing, DocumentData documentData, HashSet<string> processedTexts = null)
|
||
{
|
||
try
|
||
{
|
||
// 尝试从 Drawing 对象中提取文本框内容
|
||
var textBoxContents = drawing.Descendants<DocumentFormat.OpenXml.Wordprocessing.TextBoxContent>().ToList();
|
||
|
||
foreach (var textBoxContent in textBoxContents)
|
||
{
|
||
// 提取文本框中的所有段落
|
||
var paragraphs = textBoxContent.Elements<Paragraph>().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 对象提取文本失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 Drawing 对象中提取位置信息
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
// 方法3: 提取文本框宽度
|
||
ExtractWidthFromDrawing(outerXml, paragraphData);
|
||
|
||
_logger.LogDebug("最终提取的位置 - X: {X}, Y: {Y}, Width: {Width}",
|
||
paragraphData.PositionX, paragraphData.PositionY, paragraphData.Width);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "从 Drawing 对象提取位置信息失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 Drawing 对象 XML 中提取宽度信息
|
||
/// </summary>
|
||
private void ExtractWidthFromDrawing(string xml, ParagraphData paragraphData)
|
||
{
|
||
try
|
||
{
|
||
// 方法1: 查找 cx 属性,常见格式 ext cx="3000000"
|
||
var cxMatch = System.Text.RegularExpressions.Regex.Match(xml, @"<wp:ext[^>]*?cx=[""'](\d+)[""']");
|
||
if (cxMatch.Success)
|
||
{
|
||
if (long.TryParse(cxMatch.Groups[1].Value, out long cx))
|
||
{
|
||
paragraphData.Width = EmuToPoints(cx);
|
||
_logger.LogDebug("提取到宽度 (EMU): {EMU}, (点): {Points}", cx, paragraphData.Width);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 方法2: 查找 extents cx 属性
|
||
var extentsCxMatch = System.Text.RegularExpressions.Regex.Match(xml, @"extents\s*=\s*[\""'](?:\d+)\s*,\s*(\d+)[""']");
|
||
if (extentsCxMatch.Success)
|
||
{
|
||
if (long.TryParse(extentsCxMatch.Groups[1].Value, out long cx))
|
||
{
|
||
paragraphData.Width = EmuToPoints(cx);
|
||
_logger.LogDebug("提取到 extents 宽度 (EMU): {EMU}, (点): {Points}", cx, paragraphData.Width);
|
||
return;
|
||
}
|
||
}
|
||
|
||
_logger.LogDebug("未找到宽度信息,使用默认值");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "提取宽度信息失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 XML 中提取 simplePos 属性
|
||
/// </summary>
|
||
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 失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 XML 中提取 positionOffset 元素
|
||
/// </summary>
|
||
private void ExtractPositionOffset(string xml, ParagraphData paragraphData)
|
||
{
|
||
try
|
||
{
|
||
// 分别提取 positionH 和 positionV 中的 posOffset 和 relativeFrom
|
||
// 这样可以更准确地获取 X 和 Y 坐标及其参照对象
|
||
var positionHPattern = @"<wp:positionH[^>]*relativeFrom=[""']([^""']+)[""'][^>]*>(?:\s*<wp:posOffset[^>]*>(\d+)</wp:posOffset>\s*)</wp:positionH>";
|
||
var positionHMatch = System.Text.RegularExpressions.Regex.Match(
|
||
xml,
|
||
positionHPattern,
|
||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||
|
||
var positionVPattern = @"<wp:positionV[^>]*relativeFrom=[""']([^""']+)[""'][^>]*>(?:\s*<wp:posOffset[^>]*>(\d+)</wp:posOffset>\s*)</wp:positionV>";
|
||
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, @"<wp:posOffset[^>]*>(\d+)</wp:posOffset>");
|
||
|
||
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 失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从 XML 中提取 distL, distT 等距离属性
|
||
/// </summary>
|
||
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, "提取距离属性失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// EMU (English Metric Unit) 转换为点
|
||
/// 1 英寸 = 914400 EMU
|
||
/// 1 英寸 = 72 点
|
||
/// 所以:1 EMU = 72 / 914400 点 = 1 / 12700 点
|
||
/// </summary>
|
||
private double EmuToPoints(long emu)
|
||
{
|
||
return emu / 12700.0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取段落位置信息
|
||
/// </summary>
|
||
private void GetParagraphPosition(Paragraph paragraph, ParagraphData paragraphData)
|
||
{
|
||
try
|
||
{
|
||
var paragraphProperties = paragraph.ParagraphProperties;
|
||
if (paragraphProperties != null)
|
||
{
|
||
_logger.LogDebug("处理段落: {Text}", paragraphData.Text);
|
||
|
||
// 检查是否有绝对定位
|
||
var frameProperties = paragraphProperties.Elements<FrameProperties>().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<Indentation>().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, "获取段落位置信息失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 使用QuestPDF生成PDF - 简化版,使用字体助手
|
||
/// </summary>
|
||
private async Task GeneratePdfWithQuestPDF(Stream pdfStream, DocumentData documentData)
|
||
{
|
||
_logger.LogInformation("开始生成PDF...");
|
||
|
||
// 获取选中的字体
|
||
var selectedFont = CertificateFontHelper.GetSelectedFont();
|
||
_logger.LogInformation($"使用字体: {selectedFont ?? "默认字体"}");
|
||
|
||
// 记录提取到的位置信息,便于调试
|
||
_logger.LogInformation($"提取到 {documentData.Paragraphs.Count} 个文本框:");
|
||
for (int i = 0; i < documentData.Paragraphs.Count; i++)
|
||
{
|
||
var p = documentData.Paragraphs[i];
|
||
_logger.LogInformation($" [{i}] 文本: {p.Text}, X: {p.PositionX}, Y: {p.PositionY}, Width: {p.Width}, 字号: {p.FontSize}, 对齐: {p.Alignment}");
|
||
}
|
||
|
||
var questDocument = QuestPDF.Fluent.Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Margin(0);
|
||
page.Size(PageSizes.A4);
|
||
|
||
// 设置默认文本样式
|
||
page.DefaultTextStyle(x =>
|
||
{
|
||
var style = x.FontSize(12).FontColor(QuestPDF.Helpers.Colors.Black);
|
||
return CertificateFontHelper.ApplyFont(style);
|
||
});
|
||
|
||
// 添加背景图片
|
||
if (documentData.BackgroundImage != null)
|
||
{
|
||
page.Background()
|
||
.Image(documentData.BackgroundImage)
|
||
.FitArea();
|
||
}
|
||
|
||
// 解决方案:保持文本框位置不变,在固定宽度容器内对齐
|
||
// 这样文本是在文本框内部对齐,而不是移动文本框位置
|
||
float cumulativeHeight = 0;
|
||
|
||
page.Content().Column(column =>
|
||
{
|
||
column.Spacing(0);
|
||
|
||
foreach (var paragraph in documentData.Paragraphs)
|
||
{
|
||
var fontSize = paragraph.FontSize ?? 14;
|
||
var isBold = paragraph.IsBold;
|
||
var text = paragraph.Text;
|
||
|
||
// X偏移+80保持不变
|
||
// Y坐标减去之前所有文本占用的高度进行补偿
|
||
float baseX = (float)(paragraph.PositionX ?? 50) + 90;
|
||
float y = (float)(paragraph.PositionY ?? 50) + 75 - cumulativeHeight;
|
||
|
||
// 使用从 Word 中提取到的文本框宽度,如未提取到则使用默认值 300
|
||
float textBoxWidth = (float)(paragraph.Width ?? 300);
|
||
|
||
_logger.LogInformation($"放置文本框: 文本={text}, baseX={baseX}, Y={y}, width={textBoxWidth}, alignment={paragraph.Alignment}");
|
||
|
||
// 创建元素并根据对齐方式设置
|
||
var item = column.Item()
|
||
.TranslateX(baseX)
|
||
.TranslateY(y)
|
||
.Width(textBoxWidth);
|
||
|
||
// 创建对齐的文本元素
|
||
QuestPDF.Infrastructure.IContainer alignedContainer;
|
||
switch (paragraph.Alignment?.ToLower())
|
||
{
|
||
case "center":
|
||
alignedContainer = item.AlignCenter();
|
||
break;
|
||
case "right":
|
||
alignedContainer = item.AlignRight();
|
||
break;
|
||
case "left":
|
||
case "justify":
|
||
default:
|
||
alignedContainer = item.AlignLeft();
|
||
break;
|
||
}
|
||
|
||
// 添加文本并设置样式
|
||
var textElement = alignedContainer
|
||
.Text(text)
|
||
.FontSize((float)fontSize)
|
||
.FontColor(Colors.Black);
|
||
|
||
if (!string.IsNullOrEmpty(selectedFont))
|
||
{
|
||
textElement = textElement.FontFamily(selectedFont);
|
||
}
|
||
|
||
if (isBold)
|
||
{
|
||
textElement = textElement.Bold();
|
||
}
|
||
|
||
// 估算当前文本行的高度并累积
|
||
cumulativeHeight += (float)(fontSize * 1.5f);
|
||
}
|
||
});
|
||
|
||
// 添加二维码到右下角
|
||
if (documentData.QRCodeImage != null)
|
||
{
|
||
float qrSize = 100;
|
||
page.Footer().AlignRight().PaddingRight(30).PaddingBottom(30)
|
||
.Width(qrSize).Height(qrSize)
|
||
.Image(documentData.QRCodeImage);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 生成PDF到流
|
||
_logger.LogInformation("正在生成PDF...");
|
||
questDocument.GeneratePdf(pdfStream);
|
||
_logger.LogInformation("PDF生成完成!");
|
||
await Task.CompletedTask;
|
||
}
|
||
|
||
|
||
|
||
/// <summary>
|
||
/// 确定段落样式
|
||
/// </summary>
|
||
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";
|
||
}
|
||
|
||
|
||
|
||
/// <summary>
|
||
/// 保存PDF文件
|
||
/// </summary>
|
||
private async Task<string> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成二维码图片
|
||
/// </summary>
|
||
private async Task<string> 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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存二维码图片
|
||
/// </summary>
|
||
private async Task<string> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成证书URL
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成PDF保存地址
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存PDF文件到指定URL
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
/// <summary>
|
||
/// 文档数据模型
|
||
/// </summary>
|
||
public class DocumentData
|
||
{
|
||
public List<ParagraphData> Paragraphs { get; set; } = new List<ParagraphData>();
|
||
public List<TableData> Tables { get; set; } = new List<TableData>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 段落数据模型
|
||
/// </summary>
|
||
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? Width { 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";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 表格数据模型
|
||
/// </summary>
|
||
public class TableData
|
||
{
|
||
public List<List<string>> Rows { get; set; } = new List<List<string>>();
|
||
}
|