diff --git a/XP.ReportEngine/Configs/ConfigLoader.cs b/XP.ReportEngine/Configs/ConfigLoader.cs new file mode 100644 index 0000000..7b08002 --- /dev/null +++ b/XP.ReportEngine/Configs/ConfigLoader.cs @@ -0,0 +1,207 @@ +using System; +using System.Configuration; +using System.IO; +using XP.Common.Logging.Interfaces; + +namespace XP.ReportEngine.Configs +{ + /// + /// 报告引擎配置加载器(读取 App.config)| Report engine configuration loader (reads from App.config) + /// + public class ConfigLoader + { + private readonly ILoggerService _logger; + + /// + /// 构造函数 | Constructor + /// + /// 日志服务 | Logger service + public ConfigLoader(ILoggerService logger) + { + _logger = logger.ForModule(); + } + + /// + /// 从 App.config 加载报告引擎配置 | Load report engine configuration from App.config + /// + /// 配置前缀,默认为 "Report" | Configuration prefix, default is "Report" + /// 报告配置对象 | Report configuration object + public ReportConfig LoadReportConfig(string prefix = "Report") + { + try + { + _logger.Info("开始从 App.config 加载报告引擎配置,前缀: {Prefix} | Loading report config from App.config, prefix: {Prefix}", prefix); + + var config = new ReportConfig(); + + // 读取输出目录 | Read output directory + var outputDirectory = ConfigurationManager.AppSettings[$"{prefix}:OutputDirectory"]; + if (!string.IsNullOrEmpty(outputDirectory)) + { + config.OutputDirectory = outputDirectory; + } + + // 读取模板路径 | Read template path + var templatePath = ConfigurationManager.AppSettings[$"{prefix}:TemplatePath"]; + if (!string.IsNullOrEmpty(templatePath)) + { + config.TemplatePath = templatePath; + } + + // 读取文件名模式 | Read file name pattern + var fileNamePattern = ConfigurationManager.AppSettings[$"{prefix}:FileNamePattern"]; + if (!string.IsNullOrEmpty(fileNamePattern)) + { + config.FileNamePattern = fileNamePattern; + } + + // 读取重复文件名自动累加设置 | Read auto-increment on duplicate setting + var autoIncrement = ConfigurationManager.AppSettings[$"{prefix}:AutoIncrementOnDuplicate"]; + if (bool.TryParse(autoIncrement, out var autoIncrementValue)) + { + config.AutoIncrementOnDuplicate = autoIncrementValue; + } + + // 读取自动打开设置 | Read auto-open setting + var autoOpen = ConfigurationManager.AppSettings[$"{prefix}:AutoOpenAfterGenerate"]; + if (bool.TryParse(autoOpen, out var autoOpenValue)) + { + config.AutoOpenAfterGenerate = autoOpenValue; + } + + // 读取页面尺寸 | Read page size + var pageSize = ConfigurationManager.AppSettings[$"{prefix}:DefaultPageSize"]; + if (!string.IsNullOrEmpty(pageSize)) + { + config.DefaultPageSize = pageSize; + } + + // 读取页面方向 | Read page orientation + var orientation = ConfigurationManager.AppSettings[$"{prefix}:DefaultOrientation"]; + if (!string.IsNullOrEmpty(orientation)) + { + config.DefaultOrientation = orientation; + } + + // 读取边距配置 | Read margin configuration + var marginTop = ConfigurationManager.AppSettings[$"{prefix}:MarginTop"]; + if (float.TryParse(marginTop, out var marginTopValue)) + { + config.MarginTop = marginTopValue; + } + + var marginBottom = ConfigurationManager.AppSettings[$"{prefix}:MarginBottom"]; + if (float.TryParse(marginBottom, out var marginBottomValue)) + { + config.MarginBottom = marginBottomValue; + } + + var marginLeft = ConfigurationManager.AppSettings[$"{prefix}:MarginLeft"]; + if (float.TryParse(marginLeft, out var marginLeftValue)) + { + config.MarginLeft = marginLeftValue; + } + + var marginRight = ConfigurationManager.AppSettings[$"{prefix}:MarginRight"]; + if (float.TryParse(marginRight, out var marginRightValue)) + { + config.MarginRight = marginRightValue; + } + + // 读取公司名称 | Read company name + var companyName = ConfigurationManager.AppSettings[$"{prefix}:CompanyName"]; + if (!string.IsNullOrEmpty(companyName)) + { + config.CompanyName = companyName; + } + + // 读取公司 Logo 路径 | Read company logo path + var companyLogo = ConfigurationManager.AppSettings[$"{prefix}:CompanyLogo"]; + if (!string.IsNullOrEmpty(companyLogo)) + { + config.CompanyLogo = companyLogo; + } + + // 验证配置 | Validate configuration + ValidateConfig(config); + + _logger.Info("报告引擎配置加载成功:输出目录={OutputDir}, 模板={Template} | Report config loaded: OutputDir={OutputDir}, Template={Template}", + config.OutputDirectory, config.TemplatePath); + + return config; + } + catch (Exception ex) + { + _logger.Warn("加载报告引擎配置失败,使用默认配置 | Failed to load report config, using defaults: {Message}", ex.Message); + return new ReportConfig(); + } + } + + /// + /// 验证配置参数 | Validate configuration parameters + /// + /// 报告配置对象 | Report configuration object + private void ValidateConfig(ReportConfig config) + { + // 验证输出目录(不存在则尝试创建)| Validate output directory (create if not exists) + if (string.IsNullOrWhiteSpace(config.OutputDirectory)) + { + _logger.Warn("OutputDirectory 为空,使用默认值 | OutputDirectory is empty, using default"); + config.OutputDirectory = new ReportConfig().OutputDirectory; + } + + // 验证模板路径 | Validate template path + if (string.IsNullOrWhiteSpace(config.TemplatePath)) + { + _logger.Warn("TemplatePath 为空,使用默认值 | TemplatePath is empty, using default"); + config.TemplatePath = @"Templates\StandardReportTemplate.json"; + } + + // 验证文件名模式 | Validate file name pattern + if (string.IsNullOrWhiteSpace(config.FileNamePattern)) + { + _logger.Warn("FileNamePattern 为空,使用默认值 | FileNamePattern is empty, using default"); + config.FileNamePattern = "{ReportId}"; + } + + // 验证页面方向 | Validate page orientation + if (config.DefaultOrientation != "Portrait" && config.DefaultOrientation != "Landscape") + { + _logger.Warn("DefaultOrientation 无效: {Value},使用默认值 Portrait | DefaultOrientation invalid: {Value}, using default Portrait", config.DefaultOrientation); + config.DefaultOrientation = "Portrait"; + } + + // 验证边距范围(0-100mm)| Validate margin range (0-100mm) + config.MarginTop = ClampMargin(config.MarginTop, "MarginTop"); + config.MarginBottom = ClampMargin(config.MarginBottom, "MarginBottom"); + config.MarginLeft = ClampMargin(config.MarginLeft, "MarginLeft"); + config.MarginRight = ClampMargin(config.MarginRight, "MarginRight"); + + // 验证 Logo 路径(如果配置了则检查文件是否存在)| Validate logo path (check file exists if configured) + if (!string.IsNullOrEmpty(config.CompanyLogo)) + { + var logoPath = Path.IsPathRooted(config.CompanyLogo) + ? config.CompanyLogo + : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, config.CompanyLogo); + + if (!File.Exists(logoPath)) + { + _logger.Warn("公司 Logo 文件不存在: {Path},将不显示 Logo | Company logo file not found: {Path}, logo will not be displayed", logoPath); + } + } + } + + /// + /// 将边距值限制在有效范围内 | Clamp margin value to valid range + /// + private float ClampMargin(float value, string name) + { + if (value < 0 || value > 100) + { + _logger.Warn("{Name} 超出有效范围 [0, 100]: {Value},使用默认值 20 | {Name} out of valid range [0, 100]: {Value}, using default 20", name, value); + return 20f; + } + return value; + } + } +} diff --git a/XP.ReportEngine/Configs/ReportConfig.cs b/XP.ReportEngine/Configs/ReportConfig.cs new file mode 100644 index 0000000..8198b6e --- /dev/null +++ b/XP.ReportEngine/Configs/ReportConfig.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace XP.ReportEngine.Configs +{ + /// + /// 报告引擎配置模型 | Report engine configuration model + /// + public class ReportConfig + { + /// + /// 报告输出文件夹路径 | Report output directory path + /// + public string OutputDirectory { get; set; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "XplorePlane", "Reports"); + + /// + /// 报告模板文件路径(相对或绝对)| Report template file path (relative or absolute) + /// + public string TemplatePath { get; set; } = @"Templates\StandardReportTemplate.json"; + + /// + /// 输出文件名模式,支持占位符 | Output file name pattern, supports placeholders + /// 支持的占位符 | Supported placeholders: + /// {ReportId} - 报告编号(如 RPT-20250512-001) + /// {CncProgram} - CNC 程序名称 + /// {ProductName} - 产品名称 + /// {ProductCode} - 产品类型码 + /// {WorkpieceSN} - 工件 SN 码 + /// {DeviceId} - 检测设备编号(本机) + /// {MachineId} - 生产机台号 + /// {Date} - 日期(yyyyMMdd) + /// {Time} - 时间(HHmmss) + /// {Result} - 综合检测结论(Pass/Fail) + /// + public string FileNamePattern { get; set; } = "{ReportId}"; + + /// + /// 文件名重复时是否自动累加序号 | Whether to auto-increment suffix when file name duplicates + /// true: 重复时生成 filename(1).pdf, filename(2).pdf ... + /// false: 直接覆盖同名文件 + /// + public bool AutoIncrementOnDuplicate { get; set; } = true; + + /// + /// 生成后是否自动打开 PDF 阅读器 | Whether to auto-open PDF viewer after generation + /// + public bool AutoOpenAfterGenerate { get; set; } = false; + + /// + /// 默认页面尺寸 | Default page size + /// + public string DefaultPageSize { get; set; } = "A4"; + + /// + /// 默认页面方向(Portrait / Landscape)| Default page orientation + /// + public string DefaultOrientation { get; set; } = "Portrait"; + + /// + /// 默认上边距(mm)| Default top margin (mm) + /// + public float MarginTop { get; set; } = 20f; + + /// + /// 默认下边距(mm)| Default bottom margin (mm) + /// + public float MarginBottom { get; set; } = 20f; + + /// + /// 默认左边距(mm)| Default left margin (mm) + /// + public float MarginLeft { get; set; } = 20f; + + /// + /// 默认右边距(mm)| Default right margin (mm) + /// + public float MarginRight { get; set; } = 20f; + + /// + /// 报告中显示的公司名称 | Company name displayed in report + /// + public string CompanyName { get; set; } = "海克斯康制造智能技术(青岛)有限公司"; + + /// + /// 公司 Logo 图片路径(可选,为空则不显示)| Company logo image path (optional, empty means no logo) + /// + public string CompanyLogo { get; set; } = string.Empty; + + /// + /// 获取解析后的模板绝对路径 | Get resolved absolute template path + /// 如果 TemplatePath 是相对路径,则基于应用程序目录解析 + /// If TemplatePath is relative, resolves based on application directory + /// + public string GetResolvedTemplatePath() + { + if (Path.IsPathRooted(TemplatePath)) + { + return TemplatePath; + } + return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, TemplatePath); + } + + /// + /// 根据文件名模式和上下文参数生成实际文件名 | Generate actual file name based on pattern and context parameters + /// + /// 占位符参数字典 | Placeholder parameter dictionary + /// 生成的文件名(不含扩展名)| Generated file name (without extension) + public string ResolveFileName(Dictionary parameters) + { + var fileName = FileNamePattern; + + // 替换所有已知占位符 | Replace all known placeholders + if (parameters != null) + { + foreach (var kvp in parameters) + { + fileName = fileName.Replace($"{{{kvp.Key}}}", SanitizeFileName(kvp.Value ?? "")); + } + } + + // 替换日期和时间(始终可用)| Replace date and time (always available) + fileName = fileName.Replace("{Date}", DateTime.Now.ToString("yyyyMMdd")); + fileName = fileName.Replace("{Time}", DateTime.Now.ToString("HHmmss")); + + // 清理未被替换的占位符(替换为空)| Clean up unreplaced placeholders + fileName = System.Text.RegularExpressions.Regex.Replace(fileName, @"\{[^}]+\}", ""); + + // 移除连续的分隔符 | Remove consecutive separators + fileName = System.Text.RegularExpressions.Regex.Replace(fileName, @"[_\-]{2,}", "_"); + fileName = fileName.Trim('_', '-'); + + return string.IsNullOrWhiteSpace(fileName) ? "Report" : fileName; + } + + /// + /// 解析最终输出文件完整路径(含重复累加逻辑)| Resolve final output file full path (with duplicate increment logic) + /// + /// 占位符参数字典 | Placeholder parameter dictionary + /// 文件扩展名(含点号,如 ".pdf")| File extension (with dot, e.g. ".pdf") + /// 最终输出文件完整路径 | Final output file full path + public string ResolveOutputFilePath(Dictionary parameters, string extension = ".pdf") + { + var baseName = ResolveFileName(parameters); + var outputDir = OutputDirectory; + + // 确保输出目录存在 | Ensure output directory exists + if (!Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + var filePath = Path.Combine(outputDir, baseName + extension); + + // 重复累加逻辑 | Duplicate increment logic + if (AutoIncrementOnDuplicate && File.Exists(filePath)) + { + int counter = 1; + string newPath; + do + { + newPath = Path.Combine(outputDir, $"{baseName}({counter}){extension}"); + counter++; + } while (File.Exists(newPath)); + + filePath = newPath; + } + + return filePath; + } + + /// + /// 清理文件名中的非法字符 | Sanitize illegal characters in file name + /// + private static string SanitizeFileName(string name) + { + var invalidChars = Path.GetInvalidFileNameChars(); + foreach (var c in invalidChars) + { + name = name.Replace(c, '_'); + } + return name; + } + } +} diff --git a/XP.ReportEngine/Documents/App.config.example b/XP.ReportEngine/Documents/App.config.example new file mode 100644 index 0000000..45247d9 --- /dev/null +++ b/XP.ReportEngine/Documents/App.config.example @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XP.ReportEngine/Documents/FontFilesGuidance.md b/XP.ReportEngine/Documents/FontFilesGuidance.md index 767ae02..ce0850d 100644 --- a/XP.ReportEngine/Documents/FontFilesGuidance.md +++ b/XP.ReportEngine/Documents/FontFilesGuidance.md @@ -1,54 +1,34 @@ -# 字体文件目录 | Font Files Directory +# 字体方案说明 | Font Strategy -## 所需字体文件 | Required Font Files +## 当前方案 | Current Approach -请将以下字体文件手动添加到 `XP.ReportEngine/Fonts/` 目录: +XP.ReportEngine 使用 **Windows 系统自带字体** 生成 PDF,无需额外下载或嵌入字体文件。 -1. **NotoSansCJKsc-Regular.otf** — 中文字体(CJK 字符支持)| Chinese font (CJK character support) -2. **NotoSans-Regular.ttf** — 西文字体 | Western font +| 语言 | 字体 | 文件 | 说明 | +|------|------|------|------| +| 简体中文 / 繁体中文 | 微软雅黑 | `C:\Windows\Fonts\msyh.ttc` | Windows 10+ 自带,支持简繁体 | +| 西文(英文等) | Arial | `C:\Windows\Fonts\arial.ttf` | Windows 自带 | -## 下载地址 | Download Links +## 为什么使用系统字体 | Why System Fonts -- Noto Sans CJK: https://github.com/googlefonts/noto-cjk/releases - - 下载 `NotoSansCJKsc-Regular.otf`(简体中文版本) - - 文件大小约 16MB -- Noto Sans: https://fonts.google.com/noto/specimen/Noto+Sans - - 下载 Regular 字重的 TTF 文件 - - 文件大小约 500KB - -## 下载步骤 | Download Steps - -### NotoSansCJKsc-Regular.otf - -1. 访问 https://github.com/googlefonts/noto-cjk/releases -2. 找到最新 Release,展开 Assets -3. 下载 `NotoSansCJKsc-Regular.otf`(或从 Sans 包中提取) -4. 将文件放入 `XP.ReportEngine/Fonts/` 目录 - -### NotoSans-Regular.ttf - -1. 访问 https://fonts.google.com/noto/specimen/Noto+Sans -2. 点击 "Download family" -3. 解压后找到 `NotoSans-Regular.ttf` -4. 将文件放入 `XP.ReportEngine/Fonts/` 目录 - -## 配置说明 | Configuration Notes - -字体文件已在 `.csproj` 中配置为嵌入资源(EmbeddedResource),添加文件后无需额外配置。 -Font files are configured as EmbeddedResource in .csproj, no additional configuration needed after adding files. - -```xml - - - - -``` +1. **Telerik RadPdfViewer 兼容性** — 系统字体生成的 PDF 在 Telerik RadPdfViewer 中显示正常,不会出现中文乱码 +2. **无需额外部署** — 不需要随应用程序分发字体文件,减小安装包体积 +3. **跨阅读器兼容** — 字体以子集嵌入 PDF 中(`PREFER_EMBEDDED`),在任何 PDF 阅读器中都能正确显示 ## 后备机制 | Fallback Mechanism -如果字体文件缺失,ITextPdfRenderer 会按以下顺序降级: -1. 尝试加载嵌入的 CJK 字体和西文字体 -2. 加载失败时记录警告日志 -3. 最终使用 iText 内置 Helvetica 字体(不支持中文字符) +ITextPdfRenderer 按以下顺序加载字体: -因此,如果需要生成包含中文内容的报告,**必须**添加 NotoSansCJKsc-Regular.otf 字体文件。 +1. 微软雅黑(`msyh.ttc`)→ 加载失败时尝试宋体(`simsun.ttc`) +2. Arial(`arial.ttf`)→ 加载失败时使用 iText 内置 Helvetica +3. 最终后备:Helvetica(不支持中文字符) + +## 系统要求 | System Requirements + +- Windows 10 或更高版本(微软雅黑为系统预装字体) +- 如果在精简版 Windows 上运行,需确保系统已安装微软雅黑字体 + +## Fonts 目录 | Fonts Directory + +`XP.ReportEngine/Fonts/` 目录当前为空,保留用于未来可能的自定义字体扩展。 +如需添加自定义字体,可在 `ITextPdfRenderer.InitializeFonts()` 中扩展加载逻辑。 diff --git a/XP.ReportEngine/Documents/README.md b/XP.ReportEngine/Documents/README.md index f4c63ac..ecb8bb8 100644 --- a/XP.ReportEngine/Documents/README.md +++ b/XP.ReportEngine/Documents/README.md @@ -47,7 +47,7 @@ XP.ReportEngine/ │ ├── ITextPdfRenderer.cs # iText 7 PDF 渲染 │ ├── ProcessorDataAdapter.cs # 处理器数据适配 │ └── ReportIdGenerator.cs # 报告编号生成 -├── Fonts/ # 嵌入字体文件(需手动添加) +├── Fonts/ # 字体目录(当前使用系统字体,目录保留备用) ├── Templates/ # JSON 报告模板 │ └── StandardReportTemplate.json ├── Resources/ # 多语言资源文件 @@ -62,11 +62,9 @@ XP.ReportEngine/ ## 快速开始 -### 1. 添加字体文件 +### 1. 字体说明 -将以下字体文件放入 `Fonts/` 目录(详见 `Documents/字体文件说明.md`): -- `NotoSansCJKsc-Regular.otf` — 中文 CJK 字体 -- `NotoSans-Regular.ttf` — 西文字体 +模块使用 Windows 系统自带字体(微软雅黑 + Arial),无需额外添加字体文件。详见 `Documents/FontFilesGuidance.md`。 ### 2. 通过 DI 容器调用 diff --git a/XP.ReportEngine/Fonts/.gitkeep b/XP.ReportEngine/Fonts/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/XP.ReportEngine/Fonts/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/XP.ReportEngine/Fonts/NotoSans-Regular.ttf b/XP.ReportEngine/Fonts/NotoSans-Regular.ttf deleted file mode 100644 index 4bac02f..0000000 Binary files a/XP.ReportEngine/Fonts/NotoSans-Regular.ttf and /dev/null differ diff --git a/XP.ReportEngine/Fonts/NotoSerifCJKtc-Regular.otf b/XP.ReportEngine/Fonts/NotoSerifCJKtc-Regular.otf deleted file mode 100644 index a59de8d..0000000 Binary files a/XP.ReportEngine/Fonts/NotoSerifCJKtc-Regular.otf and /dev/null differ diff --git a/XP.ReportEngine/Fonts/NotoSerifSC-Regular.otf b/XP.ReportEngine/Fonts/NotoSerifSC-Regular.otf deleted file mode 100644 index be55fbd..0000000 Binary files a/XP.ReportEngine/Fonts/NotoSerifSC-Regular.otf and /dev/null differ diff --git a/XP.ReportEngine/Module/.gitkeep b/XP.ReportEngine/Module/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/XP.ReportEngine/Module/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/XP.ReportEngine/ReportEngineModule.cs b/XP.ReportEngine/ReportEngineModule.cs index aae3f36..79701d8 100644 --- a/XP.ReportEngine/ReportEngineModule.cs +++ b/XP.ReportEngine/ReportEngineModule.cs @@ -3,8 +3,12 @@ using Prism.Modularity; using System.Resources; using XP.Common.Localization; using XP.Common.Localization.Interfaces; +using XP.Common.Logging.Interfaces; +using XP.ReportEngine.Configs; using XP.ReportEngine.Interfaces; using XP.ReportEngine.Services; +using XP.ReportEngine.ViewModels; +using XP.ReportEngine.Views; namespace XP.ReportEngine { @@ -41,6 +45,17 @@ namespace XP.ReportEngine /// public void RegisterTypes(IContainerRegistry containerRegistry) { + // 注册配置加载器(瞬态)| Register config loader (transient) + containerRegistry.Register(); + + // 加载并注册配置为单例 | Load and register config as singleton + containerRegistry.RegisterSingleton(container => + { + var logger = container.Resolve(); + var loader = new Configs.ConfigLoader(logger); + return loader.LoadReportConfig(); + }); + // 注册报告生成器(瞬态)| Register report generator (transient) containerRegistry.Register(); @@ -65,6 +80,10 @@ namespace XP.ReportEngine // 注册报告编号生成器(单例,维护每日计数器状态)| Register report ID generator (singleton, maintains daily counter state) containerRegistry.RegisterSingleton(); + // 注册演示窗口 ViewModel 和 View | Register demo window ViewModel and View + containerRegistry.Register(); + containerRegistry.Register(); + System.Console.WriteLine("[ReportEngineModule] 类型注册完成 | Type registration completed"); } } diff --git a/XP.ReportEngine/Services/.gitkeep b/XP.ReportEngine/Services/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/XP.ReportEngine/Services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/XP.ReportEngine/Services/ExpressionDataBinder.cs b/XP.ReportEngine/Services/ExpressionDataBinder.cs index 4c58dcb..fc33d4f 100644 --- a/XP.ReportEngine/Services/ExpressionDataBinder.cs +++ b/XP.ReportEngine/Services/ExpressionDataBinder.cs @@ -61,8 +61,44 @@ namespace XP.ReportEngine.Services private void BindElement(TemplateElement element, ReportContext context) { - if (string.IsNullOrEmpty(element.Content)) return; - element.Content = ResolveAllExpressions(element.Content, context); + // 文本内容绑定 | Text content binding + if (!string.IsNullOrEmpty(element.Content)) + { + element.Content = ResolveAllExpressions(element.Content, context); + } + + // 图像元素绑定:通过 DataKey 从 ReportContext.Images 获取 ImageData + // Image element binding: get ImageData from ReportContext.Images via DataKey + if (string.Equals(element.Type, "image", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(element.DataKey) + && context.Images != null + && context.Images.TryGetValue(element.DataKey, out var imageData)) + { + element.ImageData = imageData; + } + + // 表格数据绑定:通过 DataKey 从 ReportContext.Properties 获取表格行数据 + // Table data binding: get table row data from ReportContext.Properties via DataKey + if (string.Equals(element.Type, "table", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(element.DataKey) + && context.Properties != null + && context.Properties.TryGetValue(element.DataKey, out var tableValue) + && tableValue is List> tableRows) + { + element.TableData = tableRows; + } + + // 表格列头绑定 | Table column header binding + if (element.Columns != null) + { + foreach (var column in element.Columns) + { + if (!string.IsNullOrEmpty(column.Header)) + { + column.Header = ResolveAllExpressions(column.Header, context); + } + } + } } private string ResolveAllExpressions(string input, ReportContext context) diff --git a/XP.ReportEngine/Services/ITextPdfRenderer.cs b/XP.ReportEngine/Services/ITextPdfRenderer.cs index 87a0ebd..4e4892b 100644 --- a/XP.ReportEngine/Services/ITextPdfRenderer.cs +++ b/XP.ReportEngine/Services/ITextPdfRenderer.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Reflection; using System.Windows.Media.Imaging; using iText.IO.Font; using iText.Kernel.Colors; @@ -59,15 +58,16 @@ namespace XP.ReportEngine.Services /// private const string EvenRowBackgroundColor = "#F5F5F5"; - private PdfFont _cjkScFont; - private PdfFont _cjkTcFont; + private PdfFont _cjkFont; private PdfFont _westernFont; + private bool _fontsInitialized; + private readonly object _fontLock = new(); public ITextPdfRenderer(ILoggerService logger, ILocalizationService localizationService) { _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); - InitializeFonts(); + // 字体延迟加载,不在构造函数中阻塞 | Fonts loaded lazily, not blocking in constructor } #region 7.1 基础 PDF 文档创建 | Basic PDF document creation @@ -86,43 +86,42 @@ namespace XP.ReportEngine.Services try { - using (var writer = new PdfWriter(memoryStream, new WriterProperties().SetFullCompressionMode(true))) + var writer = new PdfWriter(memoryStream, new WriterProperties().SetFullCompressionMode(true)); + // 防止 PdfWriter 关闭时关闭底层流 | Prevent PdfWriter from closing the underlying stream + writer.SetCloseStream(false); + + var pdfDocument = new PdfDocument(writer); + // 设置 A4 页面尺寸 | Set A4 page size + pdfDocument.SetDefaultPageSize(PageSize.A4); + + var document = new Document(pdfDocument); + + // 设置默认边距(使用默认 20mm)| Set default margins (20mm default) + float marginTop = 20f * MmToPoints; + float marginBottom = 20f * MmToPoints; + float marginLeft = 20f * MmToPoints; + float marginRight = 20f * MmToPoints; + + document.SetMargins(marginTop, marginRight, marginBottom, marginLeft); + + if (pages != null && pages.Count > 0) { - // 防止 PdfWriter 关闭时关闭底层流 | Prevent PdfWriter from closing the underlying stream - writer.SetCloseStream(false); - - using (var pdfDocument = new PdfDocument(writer)) + for (int i = 0; i < pages.Count; i++) { - // 设置 A4 页面尺寸 | Set A4 page size - pdfDocument.SetDefaultPageSize(PageSize.A4); - - using (var document = new Document(pdfDocument)) + if (i > 0) { - // 设置默认边距(使用默认 20mm)| Set default margins (20mm default) - float marginTop = 20f * MmToPoints; - float marginBottom = 20f * MmToPoints; - float marginLeft = 20f * MmToPoints; - float marginRight = 20f * MmToPoints; - - document.SetMargins(marginTop, marginRight, marginBottom, marginLeft); - - if (pages != null && pages.Count > 0) - { - for (int i = 0; i < pages.Count; i++) - { - if (i > 0) - { - // 添加新页面 | Add new page - document.Add(new AreaBreak(AreaBreakType.NEXT_PAGE)); - } - - RenderPage(document, pages[i]); - } - } + // 添加新页面 | Add new page + document.Add(new AreaBreak(AreaBreakType.NEXT_PAGE)); } + + RenderPage(document, pages[i]); } } + // 仅关闭 Document(它会级联关闭 PdfDocument 和 PdfWriter) + // Only close Document (it cascades to PdfDocument and PdfWriter) + document.Close(); + // 重置流位置以便后续读取 | Reset stream position for subsequent reading memoryStream.Position = 0; _logger.Info("PDF 渲染完成 | PDF rendering completed"); @@ -252,97 +251,94 @@ namespace XP.ReportEngine.Services #region 7.3 字体管理 | Font management /// - /// 初始化字体(加载嵌入资源字体)| Initialize fonts (load embedded resource fonts) + /// 确保字体已初始化(线程安全的延迟加载)| Ensure fonts are initialized (thread-safe lazy loading) /// - private void InitializeFonts() + private void EnsureFontsInitialized() { - // 加载简体中文字体 | Load Simplified Chinese font - try + if (_fontsInitialized) return; + lock (_fontLock) { - _cjkScFont = LoadEmbeddedFont("XP.ReportEngine.Fonts.NotoSerifSC-Regular.otf"); - _logger.Info("简体中文字体加载成功 | Simplified Chinese font loaded successfully"); - } - catch (Exception ex) - { - _logger.Warn("简体中文嵌入字体加载失败,将使用后备字体 | SC embedded font load failed, will use fallback: {Message}", ex.Message); - _cjkScFont = null; - } - - // 加载繁体中文字体 | Load Traditional Chinese font - try - { - _cjkTcFont = LoadEmbeddedFont("XP.ReportEngine.Fonts.NotoSerifCJKtc-Regular.otf"); - _logger.Info("繁体中文字体加载成功 | Traditional Chinese font loaded successfully"); - } - catch (Exception ex) - { - _logger.Warn("繁体中文嵌入字体加载失败,将使用后备字体 | TC embedded font load failed, will use fallback: {Message}", ex.Message); - _cjkTcFont = null; - } - - // 加载西文字体 | Load Western font - try - { - _westernFont = LoadEmbeddedFont("XP.ReportEngine.Fonts.NotoSans-Regular.ttf"); - _logger.Info("西文字体加载成功 | Western font loaded successfully"); - } - catch (Exception ex) - { - _logger.Warn("西文嵌入字体加载失败,将使用后备字体 | Western embedded font load failed, will use fallback: {Message}", ex.Message); - _westernFont = null; - } - - // 如果嵌入字体都不可用,使用 iText 内置 Helvetica | If embedded fonts unavailable, use built-in Helvetica - if (_cjkScFont == null && _cjkTcFont == null && _westernFont == null) - { - _logger.Warn("所有嵌入字体不可用,使用 Helvetica 后备字体 | All embedded fonts unavailable, using Helvetica fallback"); + if (_fontsInitialized) return; + InitializeFonts(); + _fontsInitialized = true; } } /// - /// 从嵌入资源加载字体 | Load font from embedded resource + /// 初始化字体(从系统字体目录加载)| Initialize fonts (load from system fonts directory) + /// 使用 Windows 系统自带字体,确保 Telerik RadPdfViewer 兼容性 + /// Uses Windows built-in fonts to ensure Telerik RadPdfViewer compatibility /// - /// 嵌入资源名称 | Embedded resource name - /// PDF 字体对象 | PDF font object - private PdfFont LoadEmbeddedFont(string resourceName) + private void InitializeFonts() { - var assembly = Assembly.GetExecutingAssembly(); - using (var stream = assembly.GetManifestResourceStream(resourceName)) + var fontsDir = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts"); + + // 加载微软雅黑(支持简体中文、繁体中文)| Load Microsoft YaHei (supports Simplified & Traditional Chinese) + try { - if (stream == null) + var msyhPath = System.IO.Path.Combine(fontsDir, "msyh.ttc"); + _cjkFont = PdfFontFactory.CreateFont(msyhPath + ",0", PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED); + _logger.Info("中文字体加载成功(微软雅黑)| Chinese font loaded successfully (Microsoft YaHei)"); + } + catch (Exception ex) + { + _logger.Warn("微软雅黑加载失败,尝试后备字体 | Microsoft YaHei load failed, trying fallback: {Message}", ex.Message); + try { - throw new FileNotFoundException($"嵌入资源未找到 | Embedded resource not found: {resourceName}"); + // 后备:宋体 | Fallback: SimSun + var simsunPath = System.IO.Path.Combine(fontsDir, "simsun.ttc"); + _cjkFont = PdfFontFactory.CreateFont(simsunPath + ",0", PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED); + _logger.Info("中文后备字体加载成功(宋体)| Chinese fallback font loaded successfully (SimSun)"); } + catch (Exception ex2) + { + _logger.Warn("宋体加载失败 | SimSun load failed: {Message}", ex2.Message); + _cjkFont = null; + } + } - var fontBytes = new byte[stream.Length]; - stream.Read(fontBytes, 0, fontBytes.Length); + // 加载 Arial(西文字体)| Load Arial (Western font) + try + { + var arialPath = System.IO.Path.Combine(fontsDir, "arial.ttf"); + _westernFont = PdfFontFactory.CreateFont(arialPath, PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED); + _logger.Info("西文字体加载成功(Arial)| Western font loaded successfully (Arial)"); + } + catch (Exception ex) + { + _logger.Warn("Arial 加载失败,使用 Helvetica 后备 | Arial load failed, using Helvetica fallback: {Message}", ex.Message); + _westernFont = null; + } - var fontProgram = FontProgramFactory.CreateFont(fontBytes); - return PdfFontFactory.CreateFont(fontProgram, PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED); + // 如果系统字体都不可用,使用 iText 内置 Helvetica | If system fonts unavailable, use built-in Helvetica + if (_cjkFont == null && _westernFont == null) + { + _logger.Warn("所有系统字体不可用,使用 Helvetica 后备字体 | All system fonts unavailable, using Helvetica fallback"); } } /// /// 根据当前语言获取合适的字体 | Get appropriate font based on current language - /// zh-CN → 简体中文字体;zh-TW → 繁体中文字体;en-US → 西文字体 + /// zh-CN / zh-TW → 微软雅黑(支持简繁体);en-US → Arial /// /// PDF 字体 | PDF font private PdfFont GetFontForCurrentLanguage() { + // 延迟初始化字体 | Lazy initialize fonts + EnsureFontsInitialized(); + var language = _localizationService.CurrentLanguage; PdfFont selectedFont; switch (language) { case SupportedLanguage.ZhCN: - selectedFont = _cjkScFont; - break; case SupportedLanguage.ZhTW: - selectedFont = _cjkTcFont ?? _cjkScFont; // 繁体优先,简体后备 | TC preferred, SC fallback + selectedFont = _cjkFont; break; case SupportedLanguage.EnUS: default: - selectedFont = _westernFont; + selectedFont = _westernFont ?? _cjkFont; // 西文优先,中文后备(微软雅黑也支持西文)| Western preferred, CJK fallback (YaHei supports Western too) break; } @@ -352,9 +348,8 @@ namespace XP.ReportEngine.Services return selectedFont; } - // 尝试使用其他嵌入字体 | Try other embedded fonts - if (_cjkScFont != null) return _cjkScFont; - if (_cjkTcFont != null) return _cjkTcFont; + // 尝试使用其他字体 | Try other fonts + if (_cjkFont != null) return _cjkFont; if (_westernFont != null) return _westernFont; // 最终后备:使用 iText 内置 Helvetica | Final fallback: use built-in Helvetica diff --git a/XP.ReportEngine/Services/ProcessorDataAdapter.cs b/XP.ReportEngine/Services/ProcessorDataAdapter.cs index b525767..a5fe73c 100644 --- a/XP.ReportEngine/Services/ProcessorDataAdapter.cs +++ b/XP.ReportEngine/Services/ProcessorDataAdapter.cs @@ -59,11 +59,43 @@ namespace XP.ReportEngine.Services var group = AdaptProcessorOutput(output, i); context.ResultGroups.Add(group); - // 关联标注图像 | Associate annotated image + // 将结果数据扁平化到 Properties(供模板 ${key} 表达式绑定) + // Flatten result data to Properties (for template ${key} expression binding) + if (group.Data != null) + { + foreach (var kvp in group.Data) + { + context.Properties[kvp.Key] = kvp.Value; + } + } + + // 将 Classification 也放入 Properties | Also put Classification into Properties + if (!string.IsNullOrEmpty(group.Classification)) + { + context.Properties["classification"] = group.Classification; + } + + // 将表格数据以模板期望的 dataKey 存入 Properties + // Store table data with template-expected dataKey into Properties + if (group.TableRows != null && group.TableRows.Count > 0) + { + var tableKey = GetTableDataKey(output.ProcessorType); + if (!string.IsNullOrEmpty(tableKey)) + { + context.Properties[tableKey] = group.TableRows; + } + } + + // 关联标注图像(使用模板期望的 dataKey) + // Associate annotated image (using template-expected dataKey) if (output.AnnotatedImage != null) { - var imageKey = $"{output.ProcessorType}_{i}_annotated"; + var imageKey = GetImageDataKey(output.ProcessorType); context.Images[imageKey] = output.AnnotatedImage; + + // 同时保留原始键名以兼容其他调用方 | Also keep original key for other callers + var originalKey = $"{output.ProcessorType}_{i}_annotated"; + context.Images[originalKey] = output.AnnotatedImage; } } @@ -467,6 +499,34 @@ namespace XP.ReportEngine.Services return char.ToLowerInvariant(input[0]) + input.Substring(1); } + /// + /// 根据处理器类型获取模板中对应的图像 dataKey | Get template image dataKey by processor type + /// + private static string GetImageDataKey(string processorType) + { + return processorType switch + { + LineMeasurementProcessor => "lineMeasurementImage", + BgaVoidRateProcessor => "bgaInspectionImage", + VoidMeasurementProcessor => "voidInspectionImage", + FillRateProcessor => "viaFillImage", + _ => $"{processorType}_image" + }; + } + + /// + /// 根据处理器类型获取模板中对应的表格 dataKey | Get template table dataKey by processor type + /// + private static string GetTableDataKey(string processorType) + { + return processorType switch + { + BgaVoidRateProcessor => "bgaBallsTable", + VoidMeasurementProcessor => "voidsTable", + _ => null + }; + } + #endregion } } diff --git a/XP.ReportEngine/Templates/.gitkeep b/XP.ReportEngine/Templates/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/XP.ReportEngine/Templates/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/XP.ReportEngine/Templates/StandardReportTemplate.json b/XP.ReportEngine/Templates/StandardReportTemplate.json index 414249b..c93a758 100644 --- a/XP.ReportEngine/Templates/StandardReportTemplate.json +++ b/XP.ReportEngine/Templates/StandardReportTemplate.json @@ -8,6 +8,8 @@ { "type": "homepage", "elements": [ + { "type": "image", "dataKey": "companyLogo", "position": [0, 0], "size": [30, 15], "positioning": "absolute" }, + { "type": "text", "content": "${CompanyName}", "style": "body", "position": [32, 3], "positioning": "absolute" }, { "type": "text", "content": "${loc:Report_Title}", "style": "title", "position": [60, 40], "positioning": "absolute" }, { "type": "divider", "position": [0, 60], "size": [170, 1], "positioning": "absolute" }, { "type": "text", "content": "${loc:Report_Id}:${metadata.reportId}", "style": "body", "position": [0, 75], "positioning": "absolute" }, diff --git a/XP.ReportEngine/ViewModels/ReportDemoViewModel.cs b/XP.ReportEngine/ViewModels/ReportDemoViewModel.cs new file mode 100644 index 0000000..56b3402 --- /dev/null +++ b/XP.ReportEngine/ViewModels/ReportDemoViewModel.cs @@ -0,0 +1,664 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using Prism.Commands; +using Prism.Mvvm; +using XP.Common.GeneralForm.Views; +using XP.Common.Logging.Interfaces; +using XP.Common.PdfViewer.Interfaces; +using XP.ReportEngine.Configs; +using XP.ReportEngine.Interfaces; +using XP.ReportEngine.Models; +using XP.ReportEngine.Services; + +namespace XP.ReportEngine.ViewModels +{ + /// + /// 报告生成演示窗口 ViewModel | Report generation demo window ViewModel + /// 演示如何使用 XP.ReportEngine 生成 PDF 报告 + /// Demonstrates how to use XP.ReportEngine to generate PDF reports + /// + public class ReportDemoViewModel : BindableBase + { + private readonly IReportGenerator _reportGenerator; + private readonly IReportDataAdapter _dataAdapter; + private readonly IPdfViewerService _pdfViewerService; + private readonly IPdfPrintService _pdfPrintService; + private readonly ILoggerService _logger; + private readonly ReportIdGenerator _reportIdGenerator; + private readonly ReportConfig _reportConfig; + + private string _productName = "PCB-TEST-001"; + private string _operatorName = "戚明轩 mingxuan.qi@hexagon.com"; + private string _description = "BGA 焊球气泡率检测"; + private string _cncProgram = "Prog001"; + private string _productCode = "PCBA-X100"; + private string _workpieceSN = "SN20250001"; + private string _deviceId = "XP-CT-001"; + private string _machineId = "MC01"; + private string _statusMessage = "就绪"; + private string _lastOutputPath; + private bool _isGenerating; + + #region 属性 | Properties + + /// + /// 产品名称 | Product name + /// + public string ProductName + { + get => _productName; + set => SetProperty(ref _productName, value); + } + + /// + /// 操作员 | Operator + /// + public string OperatorName + { + get => _operatorName; + set => SetProperty(ref _operatorName, value); + } + + /// + /// 描述 | Description + /// + public string Description + { + get => _description; + set => SetProperty(ref _description, value); + } + + /// + /// CNC 程序名称 | CNC program name + /// + public string CncProgram + { + get => _cncProgram; + set => SetProperty(ref _cncProgram, value); + } + + /// + /// 产品类型码 | Product type code + /// + public string ProductCode + { + get => _productCode; + set => SetProperty(ref _productCode, value); + } + + /// + /// 工件 SN 码 | Workpiece serial number + /// + public string WorkpieceSN + { + get => _workpieceSN; + set => SetProperty(ref _workpieceSN, value); + } + + /// + /// 检测设备编号 | Inspection device ID + /// + public string DeviceId + { + get => _deviceId; + set => SetProperty(ref _deviceId, value); + } + + /// + /// 生产机台号 | Production machine ID + /// + public string MachineId + { + get => _machineId; + set => SetProperty(ref _machineId, value); + } + + /// + /// 状态信息 | Status message + /// + public string StatusMessage + { + get => _statusMessage; + set => SetProperty(ref _statusMessage, value); + } + + /// + /// 是否正在生成 | Whether generating + /// + public bool IsGenerating + { + get => _isGenerating; + set + { + if (SetProperty(ref _isGenerating, value)) + { + GenerateReportCommand.RaiseCanExecuteChanged(); + OpenViewerCommand.RaiseCanExecuteChanged(); + PrintReportCommand.RaiseCanExecuteChanged(); + } + } + } + + #endregion + + #region 命令 | Commands + + /// + /// 生成报告命令 | Generate report command + /// + public DelegateCommand GenerateReportCommand { get; } + + /// + /// 打开 PDF 阅读器命令 | Open PDF viewer command + /// + public DelegateCommand OpenViewerCommand { get; } + + /// + /// 打印报告命令 | Print report command + /// + public DelegateCommand PrintReportCommand { get; } + + #endregion + + /// + /// 构造函数 | Constructor + /// + public ReportDemoViewModel( + IReportGenerator reportGenerator, + IReportDataAdapter dataAdapter, + IPdfViewerService pdfViewerService, + IPdfPrintService pdfPrintService, + ILoggerService logger, + ReportIdGenerator reportIdGenerator, + ReportConfig reportConfig) + { + _reportGenerator = reportGenerator ?? throw new ArgumentNullException(nameof(reportGenerator)); + _dataAdapter = dataAdapter ?? throw new ArgumentNullException(nameof(dataAdapter)); + _pdfViewerService = pdfViewerService ?? throw new ArgumentNullException(nameof(pdfViewerService)); + _pdfPrintService = pdfPrintService ?? throw new ArgumentNullException(nameof(pdfPrintService)); + _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); + _reportIdGenerator = reportIdGenerator ?? throw new ArgumentNullException(nameof(reportIdGenerator)); + _reportConfig = reportConfig ?? throw new ArgumentNullException(nameof(reportConfig)); + + GenerateReportCommand = new DelegateCommand(async () => await GenerateReportAsync(), () => !IsGenerating); + OpenViewerCommand = new DelegateCommand(OpenViewer, () => !IsGenerating && !string.IsNullOrEmpty(_lastOutputPath)); + PrintReportCommand = new DelegateCommand(PrintReport, () => !IsGenerating && !string.IsNullOrEmpty(_lastOutputPath)); + } + + /// + /// 生成报告(带进度条)| Generate report (with progress window) + /// + private async Task GenerateReportAsync() + { + IsGenerating = true; + StatusMessage = "正在生成报告..."; + + try + { + // 构建文件名占位符参数 | Build file name placeholder parameters + var fileNameParams = new Dictionary + { + ["ReportId"] = _reportIdGenerator.GenerateNext(), + ["ProductName"] = ProductName, + ["CncProgram"] = CncProgram, + ["ProductCode"] = ProductCode, + ["WorkpieceSN"] = WorkpieceSN, + ["DeviceId"] = DeviceId, + ["MachineId"] = MachineId, + ["Result"] = "Pass" + }; + + // 确定输出路径:提示用户是否使用默认位置 | Determine output path: ask user whether to use default location + var defaultOutputPath = _reportConfig.ResolveOutputFilePath(fileNameParams); + var defaultFileName = System.IO.Path.GetFileName(defaultOutputPath); + + var result = MessageBox.Show( + $"是否将报告输出到默认位置?\n\n{defaultOutputPath}", + "输出位置确认", + MessageBoxButton.YesNoCancel, + MessageBoxImage.Question); + + if (result == MessageBoxResult.Cancel) + { + StatusMessage = "已取消生成"; + IsGenerating = false; + return; + } + + string outputPath; + if (result == MessageBoxResult.No) + { + // 用户选择自定义位置 | User chooses custom location + var saveDialog = new Microsoft.Win32.SaveFileDialog + { + Title = "选择报告保存位置", + Filter = "PDF 文件 (*.pdf)|*.pdf", + FileName = defaultFileName, + DefaultExt = ".pdf", + InitialDirectory = System.IO.Path.GetDirectoryName(defaultOutputPath) + }; + + if (saveDialog.ShowDialog() != true) + { + StatusMessage = "已取消生成"; + IsGenerating = false; + return; + } + + outputPath = saveDialog.FileName; + } + else + { + // 使用默认路径 | Use default path + outputPath = defaultOutputPath; + } + + // 创建进度条窗口 | Create progress window + var progressWindow = new ProgressWindow( + title: "报告生成中", + message: "正在准备数据...", + isCancelable: false, + logger: _logger); + + progressWindow.Owner = Application.Current.MainWindow; + progressWindow.Show(); + + try + { + // 步骤 1:准备模拟数据 | Step 1: Prepare mock data + progressWindow.UpdateProgress("正在准备检测数据...", 10); + await Task.Delay(300); // 模拟耗时 | Simulate delay + + var processorOutputs = CreateMockProcessorOutputs(); + + // 步骤 2:数据适配 | Step 2: Data adaptation + progressWindow.UpdateProgress("正在适配数据...", 30); + await Task.Delay(200); + + var metadata = new ReportMetadata + { + ReportId = fileNameParams["ReportId"], + InspectionDate = DateTime.Now, + SampleName = ProductName, + OperatorName = OperatorName, + Description = Description + }; + + var context = _dataAdapter.Adapt(processorOutputs, metadata); + + // 注入公司 Logo 图像数据 | Inject company logo image data + if (!string.IsNullOrEmpty(_reportConfig.CompanyLogo)) + { + var logoPath = System.IO.Path.IsPathRooted(_reportConfig.CompanyLogo) + ? _reportConfig.CompanyLogo + : System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _reportConfig.CompanyLogo); + + if (File.Exists(logoPath)) + { + context.Images["companyLogo"] = new ImageData + { + SourceType = ImageSourceType.FilePath, + FilePath = logoPath + }; + _logger.Info("公司 Logo 已加载:{Path} | Company logo loaded: {Path}", logoPath); + } + else + { + _logger.Warn("公司 Logo 文件不存在:{Path} | Company logo file not found: {Path}", logoPath); + } + } + + // 注入公司名称到上下文属性 | Inject company name into context properties + if (!string.IsNullOrEmpty(_reportConfig.CompanyName)) + { + context.Properties["CompanyName"] = _reportConfig.CompanyName; + } + + // 注入首页汇总表数据 | Inject homepage summary table data + context.Properties["summaryTable"] = CreateSummaryTableData(context); + + // 步骤 3:确定模板 | Step 3: Determine template + progressWindow.UpdateProgress("正在加载模板...", 50); + await Task.Delay(200); + + var templatePath = _reportConfig.GetResolvedTemplatePath(); + + var options = new ReportGenerationOptions + { + TemplatePath = templatePath, + OutputFilePath = outputPath, + Format = ReportOutputFormat.Pdf + }; + + // 步骤 4:生成 PDF | Step 4: Generate PDF + progressWindow.UpdateProgress("正在生成 PDF...", 70); + + var genResult = await _reportGenerator.GenerateAsync(context, options); + + // 步骤 5:处理结果 | Step 5: Handle result + progressWindow.UpdateProgress("正在完成...", 95); + await Task.Delay(200); + + if (genResult.IsSuccess) + { + _lastOutputPath = outputPath; + StatusMessage = $"报告生成成功:{outputPath}"; + _logger.Info("报告生成成功:{Path} | Report generated successfully: {Path}", outputPath); + + progressWindow.UpdateProgress("报告生成完成!", 100); + await Task.Delay(500); + + // 根据配置自动打开 PDF 阅读器 | Auto-open PDF viewer based on config + if (_reportConfig.AutoOpenAfterGenerate) + { + try + { + _pdfViewerService.OpenViewer(outputPath); + } + catch (Exception viewerEx) + { + _logger.Warn("自动打开 PDF 失败 | Auto-open PDF failed: {Message}", viewerEx.Message); + } + } + } + else + { + StatusMessage = $"报告生成失败:{genResult.ErrorMessage}"; + _logger.Error(null, "报告生成失败:{Message} | Report generation failed: {Message}", genResult.ErrorMessage); + + MessageBox.Show( + $"报告生成失败:\n{genResult.ErrorMessage}", + "错误", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + finally + { + progressWindow.Close(); + } + } + catch (Exception ex) + { + StatusMessage = $"报告生成异常:{ex.Message}"; + _logger.Error(ex, "报告生成异常 | Report generation exception: {Message}", ex.Message); + + MessageBox.Show( + $"报告生成过程中发生异常:\n{ex.Message}", + "错误", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + finally + { + IsGenerating = false; + OpenViewerCommand.RaiseCanExecuteChanged(); + PrintReportCommand.RaiseCanExecuteChanged(); + } + } + + /// + /// 打开 PDF 阅读器 | Open PDF viewer + /// + private void OpenViewer() + { + if (string.IsNullOrEmpty(_lastOutputPath) || !File.Exists(_lastOutputPath)) + { + MessageBox.Show("PDF 文件不存在,请先生成报告。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + try + { + // 使用 XP.Common PDF 阅读器打开 | Open with XP.Common PDF viewer + _pdfViewerService.OpenViewer(_lastOutputPath); + _logger.Info("使用内置 PDF 阅读器打开:{Path} | Opened with built-in PDF viewer: {Path}", _lastOutputPath); + } + catch (Exception ex) + { + _logger.Error(ex, "打开 PDF 失败 | Failed to open PDF: {Message}", ex.Message); + MessageBox.Show($"打开 PDF 失败:\n{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + /// + /// 打印报告 | Print report + /// + private void PrintReport() + { + if (string.IsNullOrEmpty(_lastOutputPath) || !File.Exists(_lastOutputPath)) + { + MessageBox.Show("PDF 文件不存在,请先生成报告。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + try + { + var confirmed = _pdfPrintService.PrintWithDialog(_lastOutputPath); + if (confirmed) + { + _logger.Info("报告已发送到打印机:{Path} | Report sent to printer: {Path}", _lastOutputPath); + StatusMessage = "报告已发送到打印机"; + } + } + catch (Exception ex) + { + _logger.Error(ex, "打印报告失败 | Failed to print report: {Message}", ex.Message); + MessageBox.Show($"打印失败:\n{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + #region 模拟数据 | Mock Data + + /// + /// 模拟图像目录路径 | Mock image directory path + /// + private const string MockImageDirectory = @"D:\XplorePlane\DetectorImages"; + + /// + /// 创建模拟处理器输出数据(演示用,覆盖所有检测类型) + /// Create mock processor outputs (for demo, covers all inspection types) + /// + private List CreateMockProcessorOutputs() + { + return new List + { + CreateLineMeasurementOutput(), + CreateBgaVoidRateOutput(), + CreateVoidMeasurementOutput(), + CreateFillRateOutput() + }; + } + + /// + /// 创建线测量处理器模拟数据 | Create line measurement processor mock data + /// + private ProcessorOutput CreateLineMeasurementOutput() + { + return new ProcessorOutput + { + ProcessorType = "LineMeasurementProcessor", + OutputData = new Dictionary + { + ["MeasurementType"] = "TwoPointDistance", + ["Point1"] = "X=125.32, Y=80.15", + ["Point2"] = "X=340.78, Y=80.15", + ["PixelDistance"] = 215.46, + ["ActualDistance"] = 3.256, + ["Unit"] = "mm", + ["Angle"] = 0.0 + }, + AnnotatedImage = LoadMockImage("BGA.png") + }; + } + + /// + /// 创建 BGA 气泡率处理器模拟数据 | Create BGA void rate processor mock data + /// + private ProcessorOutput CreateBgaVoidRateOutput() + { + return new ProcessorOutput + { + ProcessorType = "BgaVoidRateProcessor", + OutputData = new Dictionary + { + ["BgaCount"] = 64, + ["VoidRate"] = 0.028, + ["FillRate"] = 0.972, + ["TotalBgaArea"] = 12500.5, + ["TotalVoidArea"] = 350.2, + ["Classification"] = "Pass", + ["VoidLimit"] = 0.05, + ["BgaBalls"] = new List> + { + new() { ["Index"] = 1, ["VoidRate"] = 0.012, ["Area"] = 195.3, ["Classification"] = "Pass" }, + new() { ["Index"] = 2, ["VoidRate"] = 0.035, ["Area"] = 198.1, ["Classification"] = "Pass" }, + new() { ["Index"] = 3, ["VoidRate"] = 0.008, ["Area"] = 192.7, ["Classification"] = "Pass" }, + new() { ["Index"] = 4, ["VoidRate"] = 0.042, ["Area"] = 201.0, ["Classification"] = "Pass" }, + new() { ["Index"] = 5, ["VoidRate"] = 0.015, ["Area"] = 196.5, ["Classification"] = "Pass" }, + new() { ["Index"] = 6, ["VoidRate"] = 0.028, ["Area"] = 199.8, ["Classification"] = "Pass" }, + new() { ["Index"] = 7, ["VoidRate"] = 0.005, ["Area"] = 194.2, ["Classification"] = "Pass" }, + new() { ["Index"] = 8, ["VoidRate"] = 0.048, ["Area"] = 200.5, ["Classification"] = "Pass" }, + new() { ["Index"] = 9, ["VoidRate"] = 0.019, ["Area"] = 197.0, ["Classification"] = "Pass" }, + new() { ["Index"] = 10, ["VoidRate"] = 0.031, ["Area"] = 196.1, ["Classification"] = "Pass" } + } + }, + AnnotatedImage = LoadMockImage("BGA.png") + }; + } + + /// + /// 创建空隙测量处理器模拟数据 | Create void measurement processor mock data + /// + private ProcessorOutput CreateVoidMeasurementOutput() + { + return new ProcessorOutput + { + ProcessorType = "VoidMeasurementProcessor", + OutputData = new Dictionary + { + ["RoiArea"] = 5000.0, + ["TotalVoidArea"] = 125.8, + ["VoidRate"] = 0.025, + ["VoidLimit"] = 0.05, + ["VoidCount"] = 5, + ["MaxVoidArea"] = 65.2, + ["Classification"] = "Pass", + ["Voids"] = new List> + { + new() { ["Index"] = 1, ["Area"] = 65.2, ["AreaPercent"] = 1.30, ["CenterX"] = 120.5, ["CenterY"] = 85.3 }, + new() { ["Index"] = 2, ["Area"] = 38.4, ["AreaPercent"] = 0.77, ["CenterX"] = 200.1, ["CenterY"] = 150.8 }, + new() { ["Index"] = 3, ["Area"] = 22.2, ["AreaPercent"] = 0.44, ["CenterX"] = 80.0, ["CenterY"] = 220.5 }, + new() { ["Index"] = 4, ["Area"] = 15.6, ["AreaPercent"] = 0.31, ["CenterX"] = 310.2, ["CenterY"] = 95.7 }, + new() { ["Index"] = 5, ["Area"] = 8.9, ["AreaPercent"] = 0.18, ["CenterX"] = 155.8, ["CenterY"] = 280.1 } + } + }, + AnnotatedImage = LoadMockImage("Void.png") + }; + } + + /// + /// 创建通孔填锡率处理器模拟数据 | Create via fill rate processor mock data + /// + private ProcessorOutput CreateFillRateOutput() + { + return new ProcessorOutput + { + ProcessorType = "FillRateProcessor", + OutputData = new Dictionary + { + ["FillRate"] = 0.85, + ["VoidRate"] = 0.15, + ["FullDistance"] = 1.60, + ["FillDistance"] = 1.36, + ["THTLimit"] = 0.75, + ["Classification"] = "Pass", + ["E1"] = new Dictionary + { + ["CenterX"] = 256.0, ["CenterY"] = 256.0, + ["SemiAxisA"] = 120.5, ["SemiAxisB"] = 118.2, ["Angle"] = 2.3 + }, + ["E2"] = new Dictionary + { + ["CenterX"] = 256.0, ["CenterY"] = 256.0, + ["SemiAxisA"] = 95.8, ["SemiAxisB"] = 93.1, ["Angle"] = 2.3 + }, + ["E3"] = new Dictionary + { + ["CenterX"] = 256.0, ["CenterY"] = 256.0, + ["SemiAxisA"] = 70.2, ["SemiAxisB"] = 68.5, ["Angle"] = 1.8 + }, + ["E4"] = new Dictionary + { + ["CenterX"] = 256.0, ["CenterY"] = 256.0, + ["SemiAxisA"] = 45.0, ["SemiAxisB"] = 43.7, ["Angle"] = 1.5 + } + }, + AnnotatedImage = LoadMockImage("Void.png") + }; + } + + /// + /// 加载模拟图像文件 | Load mock image file + /// 从指定目录加载图像,文件不存在时返回 null + /// Loads image from specified directory, returns null if file not found + /// + /// 图像文件名 | Image file name + /// 图像数据对象或 null | ImageData object or null + private ImageData LoadMockImage(string fileName) + { + var filePath = System.IO.Path.Combine(MockImageDirectory, fileName); + if (!File.Exists(filePath)) + { + _logger.Warn("模拟图像文件不存在:{Path} | Mock image file not found: {Path}", filePath); + return null; + } + + return new ImageData + { + SourceType = ImageSourceType.FilePath, + FilePath = filePath + }; + } + + /// + /// 根据 ReportContext 中的结果分组生成首页汇总表数据 + /// Generate homepage summary table data from ReportContext result groups + /// + /// 报告上下文 | Report context + /// 汇总表行数据 | Summary table row data + private List> CreateSummaryTableData(ReportContext context) + { + var rows = new List>(); + + foreach (var group in context.ResultGroups) + { + var inspectionType = group.ProcessorType switch + { + "LineMeasurementProcessor" => "线测量 | Line Measurement", + "BgaVoidRateProcessor" => "BGA 气泡率检测 | BGA Void Rate", + "VoidMeasurementProcessor" => "空隙测量 | Void Measurement", + "FillRateProcessor" => "通孔填锡率 | Via Fill Rate", + _ => group.ProcessorType + }; + + var classification = string.IsNullOrEmpty(group.Classification) ? "N/A" : group.Classification; + var status = classification == "Pass" ? "✓ 合格" : classification == "Fail" ? "✗ 不合格" : "—"; + + rows.Add(new Dictionary + { + ["inspectionType"] = inspectionType, + ["classification"] = classification, + ["status"] = status + }); + } + + return rows; + } + + #endregion + } +} diff --git a/XP.ReportEngine/ViewModels/ViewAViewModel.cs b/XP.ReportEngine/ViewModels/ViewAViewModel.cs deleted file mode 100644 index 188146b..0000000 --- a/XP.ReportEngine/ViewModels/ViewAViewModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Prism.Commands; -using Prism.Mvvm; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace XP.ReportEngine.ViewModels -{ - public class ViewAViewModel : BindableBase - { - private string _message; - public string Message - { - get { return _message; } - set { SetProperty(ref _message, value); } - } - - public ViewAViewModel() - { - Message = "View A from your Prism Module"; - } - } -} diff --git a/XP.ReportEngine/Views/ReportDemoWindow.xaml b/XP.ReportEngine/Views/ReportDemoWindow.xaml new file mode 100644 index 0000000..f0c0d97 --- /dev/null +++ b/XP.ReportEngine/Views/ReportDemoWindow.xaml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +