From 1573a33a02ffbf624cbc03c34436b9f461841bb3 Mon Sep 17 00:00:00 2001 From: QI Mingxuan Date: Mon, 11 May 2026 16:40:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A5=E5=91=8AXP.ReportEngine=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E7=B1=BB=E8=AE=BE=E8=AE=A1=E5=92=8C=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- XP.ReportEngine/Fonts/.gitkeep | 1 + XP.ReportEngine/Fonts/README.md | 18 + XP.ReportEngine/Interfaces/IDataBinder.cs | 18 + XP.ReportEngine/Interfaces/ILayoutEngine.cs | 19 + XP.ReportEngine/Interfaces/IPdfRenderer.cs | 20 + .../Interfaces/IReportDataAdapter.cs | 20 + .../Interfaces/IReportGenerator.cs | 19 + .../Interfaces/IReportGeneratorFactory.cs | 15 + XP.ReportEngine/Interfaces/ITemplateEngine.cs | 22 + XP.ReportEngine/Models/ImageData.cs | 35 + XP.ReportEngine/Models/LayoutPage.cs | 29 + XP.ReportEngine/Models/ProcessorOutput.cs | 25 + XP.ReportEngine/Models/ReportContext.cs | 74 ++ .../Models/ReportGenerationOptions.cs | 30 + XP.ReportEngine/Models/ReportResult.cs | 22 + XP.ReportEngine/Models/ReportTemplate.cs | 38 + XP.ReportEngine/Models/TemplateElement.cs | 84 ++ .../Models/TemplateValidationResult.cs | 17 + XP.ReportEngine/Module/.gitkeep | 1 + .../Resources/Resources.en-US.resx | 61 ++ XP.ReportEngine/Resources/Resources.resx | 120 +++ .../Resources/Resources.zh-CN.resx | 61 ++ .../Resources/Resources.zh-TW.resx | 61 ++ XP.ReportEngine/Services/.gitkeep | 1 + .../Services/ExpressionDataBinder.cs | 365 +++++++++ XP.ReportEngine/Services/ITextPdfRenderer.cs | 773 ++++++++++++++++++ .../Services/JsonTemplateEngine.cs | 165 ++++ XP.ReportEngine/Services/PageLayoutEngine.cs | 469 +++++++++++ XP.ReportEngine/Templates/.gitkeep | 1 + XP.ReportEngine/XP.ReportEngine.csproj | 11 +- XplorePlane.sln | 4 +- 31 files changed, 2596 insertions(+), 3 deletions(-) create mode 100644 XP.ReportEngine/Fonts/.gitkeep create mode 100644 XP.ReportEngine/Fonts/README.md create mode 100644 XP.ReportEngine/Interfaces/IDataBinder.cs create mode 100644 XP.ReportEngine/Interfaces/ILayoutEngine.cs create mode 100644 XP.ReportEngine/Interfaces/IPdfRenderer.cs create mode 100644 XP.ReportEngine/Interfaces/IReportDataAdapter.cs create mode 100644 XP.ReportEngine/Interfaces/IReportGenerator.cs create mode 100644 XP.ReportEngine/Interfaces/IReportGeneratorFactory.cs create mode 100644 XP.ReportEngine/Interfaces/ITemplateEngine.cs create mode 100644 XP.ReportEngine/Models/ImageData.cs create mode 100644 XP.ReportEngine/Models/LayoutPage.cs create mode 100644 XP.ReportEngine/Models/ProcessorOutput.cs create mode 100644 XP.ReportEngine/Models/ReportContext.cs create mode 100644 XP.ReportEngine/Models/ReportGenerationOptions.cs create mode 100644 XP.ReportEngine/Models/ReportResult.cs create mode 100644 XP.ReportEngine/Models/ReportTemplate.cs create mode 100644 XP.ReportEngine/Models/TemplateElement.cs create mode 100644 XP.ReportEngine/Models/TemplateValidationResult.cs create mode 100644 XP.ReportEngine/Module/.gitkeep create mode 100644 XP.ReportEngine/Resources/Resources.en-US.resx create mode 100644 XP.ReportEngine/Resources/Resources.resx create mode 100644 XP.ReportEngine/Resources/Resources.zh-CN.resx create mode 100644 XP.ReportEngine/Resources/Resources.zh-TW.resx create mode 100644 XP.ReportEngine/Services/.gitkeep create mode 100644 XP.ReportEngine/Services/ExpressionDataBinder.cs create mode 100644 XP.ReportEngine/Services/ITextPdfRenderer.cs create mode 100644 XP.ReportEngine/Services/JsonTemplateEngine.cs create mode 100644 XP.ReportEngine/Services/PageLayoutEngine.cs create mode 100644 XP.ReportEngine/Templates/.gitkeep diff --git a/XP.ReportEngine/Fonts/.gitkeep b/XP.ReportEngine/Fonts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/XP.ReportEngine/Fonts/.gitkeep @@ -0,0 +1 @@ + diff --git a/XP.ReportEngine/Fonts/README.md b/XP.ReportEngine/Fonts/README.md new file mode 100644 index 0000000..0c24001 --- /dev/null +++ b/XP.ReportEngine/Fonts/README.md @@ -0,0 +1,18 @@ +# 字体文件目录 | Font Files Directory + +## 所需字体文件 | Required Font Files + +请将以下字体文件手动添加到此目录: + +1. **NotoSansCJKsc-Regular.otf** — 中文字体(CJK 字符支持)| Chinese font (CJK character support) +2. **NotoSans-Regular.ttf** — 西文字体 | Western font + +## 下载地址 | Download Links + +- Noto Sans CJK: https://github.com/googlefonts/noto-cjk/releases +- Noto Sans: https://fonts.google.com/noto/specimen/Noto+Sans + +## 配置说明 | Configuration Notes + +字体文件已在 .csproj 中配置为嵌入资源(EmbeddedResource),添加文件后无需额外配置。 +Font files are configured as EmbeddedResource in .csproj, no additional configuration needed after adding files. diff --git a/XP.ReportEngine/Interfaces/IDataBinder.cs b/XP.ReportEngine/Interfaces/IDataBinder.cs new file mode 100644 index 0000000..7063ff8 --- /dev/null +++ b/XP.ReportEngine/Interfaces/IDataBinder.cs @@ -0,0 +1,18 @@ +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Interfaces +{ + /// + /// 数据绑定器接口 | Data binder interface + /// + public interface IDataBinder + { + /// + /// 将上下文数据绑定到模板 | Bind context data to template + /// + /// 报告模板 | Report template + /// 报告上下文 | Report context + /// 绑定后的模板(元素内容已替换)| Bound template with resolved content + ReportTemplate Bind(ReportTemplate template, ReportContext context); + } +} diff --git a/XP.ReportEngine/Interfaces/ILayoutEngine.cs b/XP.ReportEngine/Interfaces/ILayoutEngine.cs new file mode 100644 index 0000000..732ff3e --- /dev/null +++ b/XP.ReportEngine/Interfaces/ILayoutEngine.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Interfaces +{ + /// + /// 排版引擎接口 | Layout engine interface + /// + public interface ILayoutEngine + { + /// + /// 计算页面布局 | Calculate page layout + /// + /// 绑定后的模板 | Bound template + /// 生成选项 | Generation options + /// 排版后的页面列表 | List of laid-out pages + List CalculateLayout(ReportTemplate template, ReportGenerationOptions options); + } +} diff --git a/XP.ReportEngine/Interfaces/IPdfRenderer.cs b/XP.ReportEngine/Interfaces/IPdfRenderer.cs new file mode 100644 index 0000000..fa159e1 --- /dev/null +++ b/XP.ReportEngine/Interfaces/IPdfRenderer.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.IO; +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Interfaces +{ + /// + /// PDF 渲染器接口 | PDF renderer interface + /// + public interface IPdfRenderer + { + /// + /// 将排版结果渲染为 PDF | Render layout result to PDF + /// + /// 排版后的页面列表 | Laid-out pages + /// 生成选项 | Generation options + /// PDF 内存流 | PDF memory stream + MemoryStream Render(List pages, ReportGenerationOptions options); + } +} diff --git a/XP.ReportEngine/Interfaces/IReportDataAdapter.cs b/XP.ReportEngine/Interfaces/IReportDataAdapter.cs new file mode 100644 index 0000000..264ab7c --- /dev/null +++ b/XP.ReportEngine/Interfaces/IReportDataAdapter.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Interfaces +{ + /// + /// 报告数据适配器接口 | Report data adapter interface + /// 将 XP.ImageProcessing 的 OutputData 转换为 ReportContext + /// + public interface IReportDataAdapter + { + /// + /// 将处理器输出数据适配为报告上下文 | Adapt processor output data to report context + /// + /// 处理器输出字典列表 | List of processor output dictionaries + /// 报告元数据 | Report metadata + /// 报告上下文 | Report context + ReportContext Adapt(List processorOutputs, ReportMetadata metadata); + } +} diff --git a/XP.ReportEngine/Interfaces/IReportGenerator.cs b/XP.ReportEngine/Interfaces/IReportGenerator.cs new file mode 100644 index 0000000..4d2e15d --- /dev/null +++ b/XP.ReportEngine/Interfaces/IReportGenerator.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Interfaces +{ + /// + /// 报告生成器接口(格式无关)| Report generator interface (format-agnostic) + /// + public interface IReportGenerator + { + /// + /// 异步生成报告 | Generate report asynchronously + /// + /// 报告上下文数据 | Report context data + /// 生成选项 | Generation options + /// 生成结果 | Generation result + Task GenerateAsync(ReportContext context, ReportGenerationOptions options); + } +} diff --git a/XP.ReportEngine/Interfaces/IReportGeneratorFactory.cs b/XP.ReportEngine/Interfaces/IReportGeneratorFactory.cs new file mode 100644 index 0000000..56f1d72 --- /dev/null +++ b/XP.ReportEngine/Interfaces/IReportGeneratorFactory.cs @@ -0,0 +1,15 @@ +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Interfaces +{ + /// + /// 报告生成器工厂接口 | Report generator factory interface + /// + public interface IReportGeneratorFactory + { + /// + /// 根据输出格式创建生成器 | Create generator by output format + /// + IReportGenerator Create(ReportOutputFormat format); + } +} diff --git a/XP.ReportEngine/Interfaces/ITemplateEngine.cs b/XP.ReportEngine/Interfaces/ITemplateEngine.cs new file mode 100644 index 0000000..7596ab4 --- /dev/null +++ b/XP.ReportEngine/Interfaces/ITemplateEngine.cs @@ -0,0 +1,22 @@ +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Interfaces +{ + /// + /// 模板引擎接口 | Template engine interface + /// + public interface ITemplateEngine + { + /// + /// 加载并验证模板 | Load and validate template + /// + /// 模板文件路径 | Template file path + /// 解析后的模板对象 | Parsed template object + ReportTemplate LoadTemplate(string templatePath); + + /// + /// 验证模板结构完整性 | Validate template structure integrity + /// + TemplateValidationResult Validate(ReportTemplate template); + } +} diff --git a/XP.ReportEngine/Models/ImageData.cs b/XP.ReportEngine/Models/ImageData.cs new file mode 100644 index 0000000..b1aa0ed --- /dev/null +++ b/XP.ReportEngine/Models/ImageData.cs @@ -0,0 +1,35 @@ +namespace XP.ReportEngine.Models +{ + /// + /// 图像数据封装 | Image data wrapper + /// + public class ImageData + { + /// + /// 图像来源类型 | Image source type + /// + public ImageSourceType SourceType { get; set; } + + /// + /// 字节数组数据(当 SourceType 为 Bytes 时)| Byte array data + /// + public byte[] Bytes { get; set; } + + /// + /// 文件路径(当 SourceType 为 FilePath 时)| File path + /// + public string FilePath { get; set; } + + /// + /// BitmapSource 引用(当 SourceType 为 BitmapSource 时)| BitmapSource reference + /// + public object BitmapSource { get; set; } + } + + public enum ImageSourceType + { + Bytes, + FilePath, + BitmapSource + } +} diff --git a/XP.ReportEngine/Models/LayoutPage.cs b/XP.ReportEngine/Models/LayoutPage.cs new file mode 100644 index 0000000..6658e97 --- /dev/null +++ b/XP.ReportEngine/Models/LayoutPage.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace XP.ReportEngine.Models +{ + /// + /// 排版后的页面 | Laid-out page + /// + public class LayoutPage + { + public int PageNumber { get; set; } + public List Elements { get; set; } = new(); + } + + /// + /// 排版后的元素(含计算后的绝对坐标)| Laid-out element with computed absolute coordinates + /// + public class LayoutElement + { + public TemplateElement Source { get; set; } + public float X { get; set; } + public float Y { get; set; } + public float Width { get; set; } + public float Height { get; set; } + public StyleDefinition ResolvedStyle { get; set; } + public string ResolvedContent { get; set; } + public List> ResolvedTableData { get; set; } + public ImageData ResolvedImage { get; set; } + } +} diff --git a/XP.ReportEngine/Models/ProcessorOutput.cs b/XP.ReportEngine/Models/ProcessorOutput.cs new file mode 100644 index 0000000..3594ad7 --- /dev/null +++ b/XP.ReportEngine/Models/ProcessorOutput.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace XP.ReportEngine.Models +{ + /// + /// 处理器输出数据封装 | Processor output data wrapper + /// + public class ProcessorOutput + { + /// + /// 处理器类型名称 | Processor type name + /// + public string ProcessorType { get; set; } + + /// + /// 输出数据字典 | Output data dictionary + /// + public Dictionary OutputData { get; set; } = new(); + + /// + /// 关联的已标注图像 | Associated annotated image + /// + public ImageData AnnotatedImage { get; set; } + } +} diff --git a/XP.ReportEngine/Models/ReportContext.cs b/XP.ReportEngine/Models/ReportContext.cs new file mode 100644 index 0000000..6b08c73 --- /dev/null +++ b/XP.ReportEngine/Models/ReportContext.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace XP.ReportEngine.Models +{ + /// + /// 报告上下文,包含生成报告所需的全部数据 | Report context containing all data needed for report generation + /// + public class ReportContext + { + /// + /// 报告元数据 | Report metadata + /// + public ReportMetadata Metadata { get; set; } + + /// + /// 检测结果数据集合(按处理器类型分组)| Inspection result data grouped by processor type + /// + public List ResultGroups { get; set; } = new(); + + /// + /// 图像数据字典(键为 dataKey,值为图像数据)| Image data dictionary (key=dataKey, value=image data) + /// + public Dictionary Images { get; set; } = new(); + + /// + /// 自定义属性字典(用于数据绑定的扁平化键值对)| Custom properties for data binding + /// + public Dictionary Properties { get; set; } = new(); + } + + /// + /// 报告元数据 | Report metadata + /// + public class ReportMetadata + { + public string ReportId { get; set; } + public DateTime InspectionDate { get; set; } + public string SampleName { get; set; } + public string OperatorName { get; set; } + public string Description { get; set; } + } + + /// + /// 检测结果分组 | Inspection result group + /// + public class InspectionResultGroup + { + /// + /// 处理器类型标识 | Processor type identifier + /// + public string ProcessorType { get; set; } + + /// + /// 数据来源标识 | Data source identifier + /// + public string SourceId { get; set; } + + /// + /// 分类结果(Pass/Fail)| Classification result + /// + public string Classification { get; set; } + + /// + /// 结果键值对 | Result key-value pairs + /// + public Dictionary Data { get; set; } = new(); + + /// + /// 表格数据行(用于表格渲染)| Table data rows for table rendering + /// + public List> TableRows { get; set; } = new(); + } +} diff --git a/XP.ReportEngine/Models/ReportGenerationOptions.cs b/XP.ReportEngine/Models/ReportGenerationOptions.cs new file mode 100644 index 0000000..a111849 --- /dev/null +++ b/XP.ReportEngine/Models/ReportGenerationOptions.cs @@ -0,0 +1,30 @@ +namespace XP.ReportEngine.Models +{ + /// + /// 报告生成选项 | Report generation options + /// + public class ReportGenerationOptions + { + /// + /// 模板文件路径 | Template file path + /// + public string TemplatePath { get; set; } + + /// + /// 输出文件路径(可选,为 null 时仅返回 MemoryStream)| Output file path (optional) + /// + public string OutputFilePath { get; set; } + + /// + /// 输出格式 | Output format + /// + public ReportOutputFormat Format { get; set; } = ReportOutputFormat.Pdf; + } + + public enum ReportOutputFormat + { + Pdf, + Excel, + Csv + } +} diff --git a/XP.ReportEngine/Models/ReportResult.cs b/XP.ReportEngine/Models/ReportResult.cs new file mode 100644 index 0000000..c95753c --- /dev/null +++ b/XP.ReportEngine/Models/ReportResult.cs @@ -0,0 +1,22 @@ +using System; +using System.IO; + +namespace XP.ReportEngine.Models +{ + /// + /// 报告生成结果 | Report generation result + /// + public class ReportResult + { + public bool IsSuccess { get; set; } + public MemoryStream PdfStream { get; set; } + public string ErrorMessage { get; set; } + public Exception Exception { get; set; } + + public static ReportResult Success(MemoryStream stream) + => new() { IsSuccess = true, PdfStream = stream }; + + public static ReportResult Failure(string message, Exception ex = null) + => new() { IsSuccess = false, ErrorMessage = message, Exception = ex }; + } +} diff --git a/XP.ReportEngine/Models/ReportTemplate.cs b/XP.ReportEngine/Models/ReportTemplate.cs new file mode 100644 index 0000000..56e2ba5 --- /dev/null +++ b/XP.ReportEngine/Models/ReportTemplate.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace XP.ReportEngine.Models +{ + /// + /// 报告模板定义 | Report template definition + /// + public class ReportTemplate + { + public DocumentSettings Document { get; set; } + public List Pages { get; set; } = new(); + public Dictionary Styles { get; set; } = new(); + } + + public class DocumentSettings + { + public string PageSize { get; set; } = "A4"; + public string Orientation { get; set; } = "Portrait"; + public MarginSettings Margins { get; set; } = new(); + } + + public class MarginSettings + { + public float Top { get; set; } = 20f; + public float Bottom { get; set; } = 20f; + public float Left { get; set; } = 20f; + public float Right { get; set; } = 20f; + } + + public class TemplatePage + { + /// + /// 页面类型:homepage / metricData / defectDetails / bgaInspection / voidInspection / viaFillInspection + /// + public string Type { get; set; } + public List Elements { get; set; } = new(); + } +} diff --git a/XP.ReportEngine/Models/TemplateElement.cs b/XP.ReportEngine/Models/TemplateElement.cs new file mode 100644 index 0000000..b807db5 --- /dev/null +++ b/XP.ReportEngine/Models/TemplateElement.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; + +namespace XP.ReportEngine.Models +{ + public class TemplateElement + { + /// + /// 元素类型:text / image / table / divider / grid + /// + public string Type { get; set; } + + /// + /// 文本内容(可含 ${} 绑定表达式)| Text content with binding expressions + /// + public string Content { get; set; } + + /// + /// 样式名称引用 | Style name reference + /// + public string Style { get; set; } + + /// + /// 数据绑定键 | Data binding key + /// + public string DataKey { get; set; } + + /// + /// 位置坐标 [x, y](mm)| Position coordinates in mm + /// + public float[] Position { get; set; } + + /// + /// 尺寸 [width, height](mm)| Size in mm + /// + public float[] Size { get; set; } + + /// + /// 表格列定义 | Table column definitions + /// + public List Columns { get; set; } + + /// + /// 是否显示边框 | Whether to show border + /// + public bool Border { get; set; } + + /// + /// 定位方式:absolute / flow | Positioning mode + /// + public string Positioning { get; set; } = "absolute"; + + /// + /// 表格数据行(数据绑定阶段填充,用于排版计算和渲染) + /// Table data rows (populated during data binding phase, used for layout calculation and rendering) + /// + [Newtonsoft.Json.JsonIgnore] + public List> TableData { get; set; } + + /// + /// 图像数据(数据绑定阶段填充)| Image data (populated during data binding phase) + /// + [Newtonsoft.Json.JsonIgnore] + public ImageData ImageData { get; set; } + } + + public class ColumnDefinition + { + public string Header { get; set; } + public string Field { get; set; } + public float Width { get; set; } + public string Align { get; set; } = "left"; + } + + public class StyleDefinition + { + public string Font { get; set; } + public float Size { get; set; } = 12f; + public bool Bold { get; set; } + public bool Italic { get; set; } + public string Color { get; set; } = "#000000"; + public string Align { get; set; } = "left"; + public string BackgroundColor { get; set; } + } +} diff --git a/XP.ReportEngine/Models/TemplateValidationResult.cs b/XP.ReportEngine/Models/TemplateValidationResult.cs new file mode 100644 index 0000000..6e2191a --- /dev/null +++ b/XP.ReportEngine/Models/TemplateValidationResult.cs @@ -0,0 +1,17 @@ +namespace XP.ReportEngine.Models +{ + /// + /// 模板验证结果 | Template validation result + /// + public class TemplateValidationResult + { + public bool IsValid { get; set; } + public string ErrorMessage { get; set; } + + public static TemplateValidationResult Valid() + => new() { IsValid = true }; + + public static TemplateValidationResult Invalid(string message) + => new() { IsValid = false, ErrorMessage = message }; + } +} diff --git a/XP.ReportEngine/Module/.gitkeep b/XP.ReportEngine/Module/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/XP.ReportEngine/Module/.gitkeep @@ -0,0 +1 @@ + diff --git a/XP.ReportEngine/Resources/Resources.en-US.resx b/XP.ReportEngine/Resources/Resources.en-US.resx new file mode 100644 index 0000000..459ff73 --- /dev/null +++ b/XP.ReportEngine/Resources/Resources.en-US.resx @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/XP.ReportEngine/Resources/Resources.resx b/XP.ReportEngine/Resources/Resources.resx new file mode 100644 index 0000000..44e9f97 --- /dev/null +++ b/XP.ReportEngine/Resources/Resources.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/XP.ReportEngine/Resources/Resources.zh-CN.resx b/XP.ReportEngine/Resources/Resources.zh-CN.resx new file mode 100644 index 0000000..459ff73 --- /dev/null +++ b/XP.ReportEngine/Resources/Resources.zh-CN.resx @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/XP.ReportEngine/Resources/Resources.zh-TW.resx b/XP.ReportEngine/Resources/Resources.zh-TW.resx new file mode 100644 index 0000000..459ff73 --- /dev/null +++ b/XP.ReportEngine/Resources/Resources.zh-TW.resx @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/XP.ReportEngine/Services/.gitkeep b/XP.ReportEngine/Services/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/XP.ReportEngine/Services/.gitkeep @@ -0,0 +1 @@ + diff --git a/XP.ReportEngine/Services/ExpressionDataBinder.cs b/XP.ReportEngine/Services/ExpressionDataBinder.cs new file mode 100644 index 0000000..4c58dcb --- /dev/null +++ b/XP.ReportEngine/Services/ExpressionDataBinder.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using XP.Common.Localization.Enums; +using XP.Common.Localization.Interfaces; +using XP.Common.Logging.Interfaces; +using XP.ReportEngine.Interfaces; +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Services +{ + /// + /// 表达式数据绑定器实现 | Expression data binder implementation + /// 支持 ${} 语法的数据绑定、格式化函数和本地化键解析 + /// Supports ${} syntax data binding, format functions and localization key resolution + /// + public class ExpressionDataBinder : IDataBinder + { + private readonly ILoggerService _logger; + private readonly ILocalizationService _localizationService; + + private static readonly Regex ExpressionPattern = new(@"\$\{([^}]+)\}", RegexOptions.Compiled); + private static readonly Regex LocalizationPattern = new(@"^loc:(.+)$", RegexOptions.Compiled); + private static readonly Regex FunctionPattern = new(@"^(\w+)\((.+)\)$", RegexOptions.Compiled); + private static readonly Regex IndexPattern = new(@"^([^\[]+)\[(\d+)\]$", RegexOptions.Compiled); + + public ExpressionDataBinder(ILoggerService logger, ILocalizationService localizationService) + { + _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); + _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + } + + public ReportTemplate Bind(ReportTemplate template, ReportContext context) + { + if (template == null) throw new ArgumentNullException(nameof(template)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + _logger.Info("开始数据绑定 | Starting data binding"); + var clonedTemplate = DeepClone(template); + + if (clonedTemplate.Pages != null) + { + foreach (var page in clonedTemplate.Pages) + { + if (page.Elements == null) continue; + foreach (var element in page.Elements) + { + BindElement(element, context); + } + } + } + + _logger.Info("数据绑定完成 | Data binding completed"); + return clonedTemplate; + } + + + private void BindElement(TemplateElement element, ReportContext context) + { + if (string.IsNullOrEmpty(element.Content)) return; + element.Content = ResolveAllExpressions(element.Content, context); + } + + private string ResolveAllExpressions(string input, ReportContext context) + { + return ExpressionPattern.Replace(input, match => + { + var expression = match.Groups[1].Value.Trim(); + return ResolveExpression(expression, context); + }); + } + + private string ResolveExpression(string expression, ReportContext context) + { + // 1. 本地化键 loc:ResourceKey | Localization key + var locMatch = LocalizationPattern.Match(expression); + if (locMatch.Success) + { + var resourceKey = locMatch.Groups[1].Value.Trim(); + return ResolveLocalizationKey(resourceKey); + } + + // 2. 格式化函数 functionName(params) | Format function + var funcMatch = FunctionPattern.Match(expression); + if (funcMatch.Success) + { + var functionName = funcMatch.Groups[1].Value; + var paramExpression = funcMatch.Groups[2].Value.Trim(); + return ResolveFormatFunction(functionName, paramExpression, context); + } + + // 3. 属性路径 | Property path + return ResolvePropertyPath(expression, context); + } + + private string ResolveLocalizationKey(string resourceKey) + { + try + { + var value = _localizationService.GetString(resourceKey); + if (value == null) + { + _logger.Warn("本地化键未找到: {Key} | Localization key not found: {Key}", resourceKey); + return string.Empty; + } + return value; + } + catch (Exception ex) + { + _logger.Warn("解析本地化键失败: {Key}, 错误: {Message} | Failed to resolve localization key: {Key}, error: {Message}", resourceKey, ex.Message); + return string.Empty; + } + } + + + private string ResolveFormatFunction(string functionName, string paramExpression, ReportContext context) + { + switch (functionName.ToLowerInvariant()) + { + case "formatdate": + { + var value = ResolvePropertyValue(paramExpression, context); + return FormatDate(value); + } + case "formatnumber": + { + var parts = paramExpression.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + var value = ResolvePropertyValue(parts[0].Trim(), context); + var decimals = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var d) ? d : 2; + return FormatNumber(value, decimals); + } + case "formatpercent": + { + var value = ResolvePropertyValue(paramExpression, context); + return FormatPercent(value); + } + default: + { + _logger.Warn("未知的格式化函数: {FunctionName} | Unknown format function: {FunctionName}", functionName); + return string.Empty; + } + } + } + + private string ResolvePropertyPath(string path, ReportContext context) + { + var value = ResolvePropertyValue(path, context); + if (value == null) + { + _logger.Warn("绑定属性未找到: {Path},替换为空字符串 | Binding property not found: {Path}, replacing with empty string", path); + return string.Empty; + } + return ConvertToString(value); + } + + private object ResolvePropertyValue(string path, ReportContext context) + { + if (string.IsNullOrWhiteSpace(path)) return null; + path = path.Trim(); + + // 优先从 Properties 字典查找 | First look up in Properties dictionary + if (context.Properties != null && context.Properties.TryGetValue(path, out var directValue)) + { + return directValue; + } + + // 尝试从 context 对象解析嵌套路径 | Try nested path from context object + var resolved = ResolveNestedPath(path, context); + return resolved; + } + + + private object ResolveNestedPath(string path, object root) + { + if (root == null || string.IsNullOrWhiteSpace(path)) return null; + + var segments = path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); + var current = root; + + foreach (var segment in segments) + { + if (current == null) return null; + + var indexMatch = IndexPattern.Match(segment); + if (indexMatch.Success) + { + var propertyName = indexMatch.Groups[1].Value; + var index = int.Parse(indexMatch.Groups[2].Value); + current = GetPropertyValue(current, propertyName); + if (current == null) return null; + current = GetIndexedValue(current, index); + } + else + { + current = GetPropertyValue(current, segment); + } + } + + return current; + } + + private object GetPropertyValue(object obj, string propertyName) + { + if (obj == null || string.IsNullOrWhiteSpace(propertyName)) return null; + + // 字典访问 | Dictionary access + if (obj is IDictionary dict) + { + if (dict.TryGetValue(propertyName, out var dictValue)) + return dictValue; + foreach (var kvp in dict) + { + if (string.Equals(kvp.Key, propertyName, StringComparison.OrdinalIgnoreCase)) + return kvp.Value; + } + return null; + } + + // 反射获取属性 | Reflection property access + var type = obj.GetType(); + var propInfo = type.GetProperty(propertyName, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (propInfo != null) + { + try { return propInfo.GetValue(obj); } + catch { return null; } + } + + return null; + } + + private object GetIndexedValue(object obj, int index) + { + if (obj == null || index < 0) return null; + + if (obj is IList list) + { + return index < list.Count ? list[index] : null; + } + + if (obj is IEnumerable enumerable) + { + var i = 0; + foreach (var item in enumerable) + { + if (i == index) return item; + i++; + } + } + + return null; + } + + + private string FormatDate(object value) + { + if (value == null) return string.Empty; + + DateTime dateTime; + if (value is DateTime dt) + { + dateTime = dt; + } + else if (DateTime.TryParse(value.ToString(), out var parsed)) + { + dateTime = parsed; + } + else + { + _logger.Warn("无法将值转换为日期: {Value} | Cannot convert value to date: {Value}", value); + return value.ToString(); + } + + var format = _localizationService.CurrentLanguage switch + { + SupportedLanguage.ZhCN => "yyyy年MM月dd日", + SupportedLanguage.ZhTW => "yyyy年MM月dd日", + SupportedLanguage.EnUS => "MM/dd/yyyy", + _ => "yyyy-MM-dd" + }; + + return dateTime.ToString(format); + } + + private string FormatNumber(object value, int decimals) + { + if (value == null) return string.Empty; + + if (!TryConvertToDouble(value, out var number)) + { + _logger.Warn("无法将值转换为数字: {Value} | Cannot convert value to number: {Value}", value); + return value.ToString(); + } + + var culture = GetCultureInfo(); + return number.ToString($"N{decimals}", culture); + } + + private string FormatPercent(object value) + { + if (value == null) return string.Empty; + + if (!TryConvertToDouble(value, out var number)) + { + _logger.Warn("无法将值转换为百分比: {Value} | Cannot convert value to percentage: {Value}", value); + return value.ToString(); + } + + // 值在 0-1 范围内视为小数百分比 | Values in 0-1 range treated as decimal percentage + if (number >= 0 && number <= 1) + { + number *= 100; + } + + var culture = GetCultureInfo(); + return number.ToString("F2", culture) + "%"; + } + + + private bool TryConvertToDouble(object value, out double result) + { + result = 0; + if (value == null) return false; + + switch (value) + { + case double d: result = d; return true; + case float f: result = f; return true; + case int i: result = i; return true; + case long l: result = l; return true; + case decimal dec: result = (double)dec; return true; + default: + return double.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out result); + } + } + + private CultureInfo GetCultureInfo() + { + return _localizationService.CurrentLanguage switch + { + SupportedLanguage.ZhCN => new CultureInfo("zh-CN"), + SupportedLanguage.ZhTW => new CultureInfo("zh-TW"), + SupportedLanguage.EnUS => new CultureInfo("en-US"), + _ => CultureInfo.InvariantCulture + }; + } + + private string ConvertToString(object value) + { + if (value == null) return string.Empty; + if (value is DateTime dt) return FormatDate(dt); + return value.ToString(); + } + + private ReportTemplate DeepClone(ReportTemplate template) + { + var json = JsonConvert.SerializeObject(template); + return JsonConvert.DeserializeObject(json); + } + } +} diff --git a/XP.ReportEngine/Services/ITextPdfRenderer.cs b/XP.ReportEngine/Services/ITextPdfRenderer.cs new file mode 100644 index 0000000..784d6f9 --- /dev/null +++ b/XP.ReportEngine/Services/ITextPdfRenderer.cs @@ -0,0 +1,773 @@ +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; +using iText.Kernel.Font; +using iText.Kernel.Geom; +using iText.Kernel.Pdf; +using iText.Kernel.Pdf.Canvas.Draw; +using iText.Layout; +using iText.Layout.Borders; +using iText.Layout.Element; +using iText.Layout.Properties; +using XP.Common.Localization.Enums; +using XP.Common.Localization.Interfaces; +using XP.Common.Logging.Interfaces; +using XP.ReportEngine.Interfaces; +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Services +{ + /// + /// iText 7 PDF 渲染器实现 | iText 7 PDF renderer implementation + /// + public class ITextPdfRenderer : IPdfRenderer + { + private readonly ILoggerService _logger; + private readonly ILocalizationService _localizationService; + + /// + /// mm 到 points 的转换系数 | mm to points conversion factor + /// + private const float MmToPoints = 2.83465f; + + /// + /// A4 页面宽度(mm)| A4 page width in mm + /// + private const float A4WidthMm = 210f; + + /// + /// A4 页面高度(mm)| A4 page height in mm + /// + private const float A4HeightMm = 297f; + + /// + /// 表头背景色 | Table header background color + /// + private const string HeaderBackgroundColor = "#E0E0E0"; + + /// + /// 表格奇数行背景色 | Table odd row background color + /// + private const string OddRowBackgroundColor = "#FFFFFF"; + + /// + /// 表格偶数行背景色 | Table even row background color + /// + private const string EvenRowBackgroundColor = "#F5F5F5"; + + private PdfFont _cjkFont; + private PdfFont _westernFont; + + public ITextPdfRenderer(ILoggerService logger, ILocalizationService localizationService) + { + _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); + _localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + InitializeFonts(); + } + + #region 7.1 基础 PDF 文档创建 | Basic PDF document creation + + /// + /// 将排版结果渲染为 PDF 内存流 | Render layout result to PDF memory stream + /// + /// 排版后的页面列表 | Laid-out pages + /// 生成选项 | Generation options + /// PDF 内存流 | PDF memory stream + public MemoryStream Render(List pages, ReportGenerationOptions options) + { + _logger.Info("开始 PDF 渲染,共 {PageCount} 页 | Starting PDF rendering, {PageCount} pages", pages?.Count ?? 0); + + var memoryStream = new MemoryStream(); + + try + { + using (var writer = new PdfWriter(memoryStream, new WriterProperties().SetFullCompressionMode(true))) + { + // 防止 PdfWriter 关闭时关闭底层流 | Prevent PdfWriter from closing the underlying stream + writer.SetCloseStream(false); + + using (var pdfDocument = new PdfDocument(writer)) + { + // 设置 A4 页面尺寸 | Set A4 page size + pdfDocument.SetDefaultPageSize(PageSize.A4); + + using (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) + { + 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]); + } + } + } + } + } + + // 重置流位置以便后续读取 | Reset stream position for subsequent reading + memoryStream.Position = 0; + _logger.Info("PDF 渲染完成 | PDF rendering completed"); + } + catch (Exception ex) + { + _logger.Error(ex, "PDF 渲染过程中发生错误 | Error occurred during PDF rendering: {Message}", ex.Message); + throw; + } + + return memoryStream; + } + + /// + /// 渲染单个页面 | Render a single page + /// + private void RenderPage(Document document, LayoutPage page) + { + if (page?.Elements == null) return; + + foreach (var element in page.Elements) + { + try + { + RenderElement(document, element); + } + catch (Exception ex) + { + _logger.Warn("渲染元素失败,跳过该元素 | Failed to render element, skipping: {Message}", ex.Message); + } + } + } + + /// + /// 根据元素类型分发渲染 | Dispatch rendering based on element type + /// + private void RenderElement(Document document, LayoutElement element) + { + if (element?.Source == null) return; + + var elementType = element.Source.Type?.ToLowerInvariant(); + + switch (elementType) + { + case "text": + RenderTextElement(document, element); + break; + case "image": + RenderImageElement(document, element); + break; + case "table": + RenderTableElement(document, element); + break; + case "divider": + RenderDividerElement(document, element); + break; + default: + _logger.Warn("未知的元素类型:{Type},跳过渲染 | Unknown element type: {Type}, skipping", elementType); + break; + } + } + + #endregion + + #region 7.2 文本元素渲染 | Text element rendering + + /// + /// 渲染文本元素 | Render text element + /// + private void RenderTextElement(Document document, LayoutElement element) + { + var content = element.ResolvedContent ?? string.Empty; + var style = element.ResolvedStyle ?? new StyleDefinition(); + + var paragraph = new Paragraph(content); + + // 应用字体 | Apply font + var font = GetFontForCurrentLanguage(); + paragraph.SetFont(font); + + // 应用字体大小 | Apply font size + paragraph.SetFontSize(style.Size); + + // 应用粗体 | Apply bold + if (style.Bold) + { + paragraph.SetBold(); + } + + // 应用斜体 | Apply italic + if (style.Italic) + { + paragraph.SetItalic(); + } + + // 应用字体颜色 | Apply font color + var color = ParseColor(style.Color); + if (color != null) + { + paragraph.SetFontColor(color); + } + + // 应用对齐方式 | Apply text alignment + paragraph.SetTextAlignment(ParseTextAlignment(style.Align)); + + // 应用背景色 | Apply background color + if (!string.IsNullOrEmpty(style.BackgroundColor)) + { + var bgColor = ParseColor(style.BackgroundColor); + if (bgColor != null) + { + paragraph.SetBackgroundColor(bgColor); + } + } + + // 设置固定位置(如果有坐标信息)| Set fixed position if coordinates available + if (element.Width > 0) + { + paragraph.SetWidth(element.Width * MmToPoints); + } + + document.Add(paragraph); + } + + #endregion + + #region 7.3 字体管理 | Font management + + /// + /// 初始化字体(加载嵌入资源字体)| Initialize fonts (load embedded resource fonts) + /// + private void InitializeFonts() + { + try + { + _cjkFont = LoadEmbeddedFont("XP.ReportEngine.Fonts.NotoSansCJKsc-Regular.otf"); + _logger.Info("CJK 字体加载成功 | CJK font loaded successfully"); + } + catch (Exception ex) + { + _logger.Warn("CJK 嵌入字体加载失败,将使用后备字体 | CJK embedded font load failed, will use fallback: {Message}", ex.Message); + _cjkFont = null; + } + + 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 (_cjkFont == null && _westernFont == null) + { + _logger.Warn("所有嵌入字体不可用,使用 Helvetica 后备字体 | All embedded fonts unavailable, using Helvetica fallback"); + } + } + + /// + /// 从嵌入资源加载字体 | Load font from embedded resource + /// + /// 嵌入资源名称 | Embedded resource name + /// PDF 字体对象 | PDF font object + private PdfFont LoadEmbeddedFont(string resourceName) + { + var assembly = Assembly.GetExecutingAssembly(); + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + { + throw new FileNotFoundException($"嵌入资源未找到 | Embedded resource not found: {resourceName}"); + } + + var fontBytes = new byte[stream.Length]; + stream.Read(fontBytes, 0, fontBytes.Length); + + var fontProgram = FontProgramFactory.CreateFont(fontBytes); + return PdfFontFactory.CreateFont(fontProgram, PdfEncodings.IDENTITY_H, PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED); + } + } + + /// + /// 根据当前语言获取合适的字体 | Get appropriate font based on current language + /// zh-CN、zh-TW → CJK 字体;en-US → 西文字体 + /// + /// PDF 字体 | PDF font + private PdfFont GetFontForCurrentLanguage() + { + var language = _localizationService.CurrentLanguage; + + PdfFont selectedFont; + switch (language) + { + case SupportedLanguage.ZhCN: + case SupportedLanguage.ZhTW: + selectedFont = _cjkFont; + break; + case SupportedLanguage.EnUS: + default: + selectedFont = _westernFont; + break; + } + + // 后备字体逻辑 | Fallback font logic + if (selectedFont != null) + { + return selectedFont; + } + + // 尝试使用另一种嵌入字体 | Try the other embedded font + if (_cjkFont != null) return _cjkFont; + if (_westernFont != null) return _westernFont; + + // 最终后备:使用 iText 内置 Helvetica | Final fallback: use built-in Helvetica + return PdfFontFactory.CreateFont(iText.IO.Font.Constants.StandardFonts.HELVETICA); + } + + #endregion + + #region 7.4 图像嵌入渲染 | Image embedding rendering + + /// + /// 渲染图像元素 | Render image element + /// + private void RenderImageElement(Document document, LayoutElement element) + { + var imageData = element.ResolvedImage; + + // 如果图像数据缺失,渲染占位矩形 | If image data is missing, render placeholder + if (imageData == null) + { + _logger.Warn("图像数据为空,渲染占位矩形 | Image data is null, rendering placeholder"); + RenderImagePlaceholder(document, element); + return; + } + + try + { + byte[] imageBytes = GetImageBytes(imageData); + + if (imageBytes == null || imageBytes.Length == 0) + { + _logger.Warn("图像字节数据为空,渲染占位矩形 | Image byte data is empty, rendering placeholder"); + RenderImagePlaceholder(document, element); + return; + } + + // 创建 iText 图像对象 | Create iText image object + var iTextImageData = iText.IO.Image.ImageDataFactory.Create(imageBytes); + var image = new Image(iTextImageData); + + // 计算目标区域尺寸(mm → points)| Calculate target area size (mm → points) + float targetWidthPt = element.Width * MmToPoints; + float targetHeightPt = element.Height * MmToPoints; + + // 等比缩放以适应目标区域 | Scale proportionally to fit target area + if (targetWidthPt > 0 && targetHeightPt > 0) + { + float imageWidth = image.GetImageWidth(); + float imageHeight = image.GetImageHeight(); + + float scaleX = targetWidthPt / imageWidth; + float scaleY = targetHeightPt / imageHeight; + float scale = Math.Min(scaleX, scaleY); + + image.SetWidth(imageWidth * scale); + image.SetHeight(imageHeight * scale); + } + else if (targetWidthPt > 0) + { + image.SetWidth(targetWidthPt); + image.ScaleToFit(targetWidthPt, float.MaxValue); + } + + // 应用边框 | Apply border + if (element.Source?.Border == true) + { + image.SetBorder(new SolidBorder(ColorConstants.BLACK, 1f)); + } + + document.Add(image); + } + catch (Exception ex) + { + _logger.Warn("图像渲染失败,渲染占位矩形 | Image rendering failed, rendering placeholder: {Message}", ex.Message); + RenderImagePlaceholder(document, element); + } + } + + /// + /// 从 ImageData 获取字节数组 | Get byte array from ImageData + /// + private byte[] GetImageBytes(ImageData imageData) + { + switch (imageData.SourceType) + { + case ImageSourceType.Bytes: + return imageData.Bytes; + + case ImageSourceType.FilePath: + if (!string.IsNullOrEmpty(imageData.FilePath) && File.Exists(imageData.FilePath)) + { + return File.ReadAllBytes(imageData.FilePath); + } + _logger.Warn("图像文件不存在:{Path} | Image file not found: {Path}", imageData.FilePath); + return null; + + case ImageSourceType.BitmapSource: + if (imageData.BitmapSource is BitmapSource bitmapSource) + { + return ConvertBitmapSourceToBytes(bitmapSource); + } + _logger.Warn("BitmapSource 对象无效 | BitmapSource object is invalid"); + return null; + + default: + _logger.Warn("未知的图像来源类型:{Type} | Unknown image source type: {Type}", imageData.SourceType); + return null; + } + } + + #endregion + + #region 7.5 BitmapSource 转 byte[] | BitmapSource to byte[] conversion + + /// + /// 将 BitmapSource 转换为 PNG 编码的字节数组 | Convert BitmapSource to PNG-encoded byte array + /// + /// WPF BitmapSource 对象 | WPF BitmapSource object + /// PNG 编码的字节数组 | PNG-encoded byte array + public static byte[] ConvertBitmapSourceToBytes(BitmapSource bitmapSource) + { + if (bitmapSource == null) + { + return null; + } + + using (var memoryStream = new MemoryStream()) + { + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmapSource)); + encoder.Save(memoryStream); + return memoryStream.ToArray(); + } + } + + #endregion + + #region 7.6 图像占位矩形渲染 | Image placeholder rendering + + /// + /// 渲染图像缺失时的占位矩形(带"无图像 | No Image"文本标签) + /// Render placeholder rectangle when image is missing (with "无图像 | No Image" text label) + /// + private void RenderImagePlaceholder(Document document, LayoutElement element) + { + float widthPt = element.Width > 0 ? element.Width * MmToPoints : 100f * MmToPoints; + float heightPt = element.Height > 0 ? element.Height * MmToPoints : 60f * MmToPoints; + + // 使用表格模拟占位矩形(带边框和居中文本)| Use table to simulate placeholder rectangle + var table = new Table(1); + table.SetWidth(widthPt); + + var cell = new Cell(); + cell.SetHeight(heightPt); + cell.SetBorder(new SolidBorder(ColorConstants.GRAY, 1f)); + cell.SetBackgroundColor(new DeviceRgb(245, 245, 245)); + + // 居中显示"无图像 | No Image"文本 | Center "无图像 | No Image" text + var placeholderText = new Paragraph("无图像 | No Image"); + var font = GetFontForCurrentLanguage(); + placeholderText.SetFont(font); + placeholderText.SetFontSize(10f); + placeholderText.SetFontColor(ColorConstants.GRAY); + placeholderText.SetTextAlignment(TextAlignment.CENTER); + + cell.SetVerticalAlignment(VerticalAlignment.MIDDLE); + cell.Add(placeholderText); + table.AddCell(cell); + + document.Add(table); + } + + #endregion + + #region 7.7 表格渲染 | Table rendering + + /// + /// 渲染表格元素 | Render table element + /// + private void RenderTableElement(Document document, LayoutElement element) + { + var columns = element.Source?.Columns; + var tableData = element.ResolvedTableData; + + if (columns == null || columns.Count == 0) + { + _logger.Warn("表格列定义为空,跳过渲染 | Table column definitions are empty, skipping"); + return; + } + + // 计算列宽(mm → points)| Calculate column widths (mm → points) + var columnWidths = new float[columns.Count]; + for (int i = 0; i < columns.Count; i++) + { + columnWidths[i] = columns[i].Width > 0 ? columns[i].Width * MmToPoints : 30f * MmToPoints; + } + + var table = new Table(columnWidths); + table.SetWidth(UnitValue.CreatePercentValue(100)); + + var font = GetFontForCurrentLanguage(); + + // 渲染表头行 | Render header row + var headerBgColor = ParseColor(HeaderBackgroundColor); + foreach (var column in columns) + { + var headerCell = new Cell(); + var headerParagraph = new Paragraph(column.Header ?? string.Empty); + headerParagraph.SetFont(font); + headerParagraph.SetFontSize(10f); + headerParagraph.SetBold(); + headerParagraph.SetTextAlignment(ParseTextAlignment(column.Align)); + + headerCell.Add(headerParagraph); + headerCell.SetBackgroundColor(headerBgColor); + headerCell.SetBorder(new SolidBorder(ColorConstants.LIGHT_GRAY, 0.5f)); + + table.AddHeaderCell(headerCell); + } + + // 渲染数据行(交替背景色)| Render data rows (alternating background colors) + if (tableData != null) + { + for (int rowIndex = 0; rowIndex < tableData.Count; rowIndex++) + { + var rowData = tableData[rowIndex]; + var rowBgColor = rowIndex % 2 == 0 + ? ParseColor(OddRowBackgroundColor) + : ParseColor(EvenRowBackgroundColor); + + foreach (var column in columns) + { + var dataCell = new Cell(); + + // 从行数据中获取字段值 | Get field value from row data + string cellValue = string.Empty; + if (rowData != null && !string.IsNullOrEmpty(column.Field) && rowData.ContainsKey(column.Field)) + { + cellValue = rowData[column.Field]?.ToString() ?? string.Empty; + } + + var cellParagraph = new Paragraph(cellValue); + cellParagraph.SetFont(font); + cellParagraph.SetFontSize(9f); + cellParagraph.SetTextAlignment(ParseTextAlignment(column.Align)); + + dataCell.Add(cellParagraph); + dataCell.SetBackgroundColor(rowBgColor); + dataCell.SetBorder(new SolidBorder(ColorConstants.LIGHT_GRAY, 0.5f)); + + table.AddCell(dataCell); + } + } + } + + document.Add(table); + } + + #endregion + + #region 7.8 分隔线渲染 | Divider rendering + + /// + /// 渲染分隔线元素 | Render divider element + /// + private void RenderDividerElement(Document document, LayoutElement element) + { + var style = element.ResolvedStyle; + + // 确定分隔线颜色(从样式或默认灰色)| Determine divider color (from style or default gray) + Color lineColor = ColorConstants.GRAY; + if (style != null && !string.IsNullOrEmpty(style.Color)) + { + var parsedColor = ParseColor(style.Color); + if (parsedColor != null) + { + lineColor = parsedColor; + } + } + + // 使用 LineSeparator 渲染水平分隔线 | Use LineSeparator to render horizontal divider + var lineSeparator = new LineSeparator(new SolidLine(1f)); + lineSeparator.SetStrokeColor(lineColor); + + // 设置宽度为可用区域全宽 | Set width to full available area + if (element.Width > 0) + { + lineSeparator.SetWidth(element.Width * MmToPoints); + } + + // 添加上下间距 | Add vertical spacing + lineSeparator.SetMarginTop(5f); + lineSeparator.SetMarginBottom(5f); + + document.Add(lineSeparator); + } + + #endregion + + #region 7.9 PDF 保存到文件 | PDF save to file + + /// + /// 将 PDF 内存流保存到文件 | Save PDF memory stream to file + /// + /// PDF 内存流 | PDF memory stream + /// 输出文件路径 | Output file path + /// 保存结果(成功/失败)| Save result (success/failure) + public ReportResult SaveToFile(MemoryStream pdfStream, string filePath) + { + if (pdfStream == null) + { + return ReportResult.Failure("PDF 流为空,无法保存 | PDF stream is null, cannot save"); + } + + if (string.IsNullOrWhiteSpace(filePath)) + { + return ReportResult.Failure("输出文件路径为空 | Output file path is empty"); + } + + try + { + _logger.Info("开始保存 PDF 到文件:{FilePath} | Saving PDF to file: {FilePath}", filePath); + + // 确保目标目录存在 | Ensure target directory exists + var directory = System.IO.Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // 保存流位置并重置 | Save stream position and reset + long originalPosition = pdfStream.Position; + pdfStream.Position = 0; + + using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + pdfStream.CopyTo(fileStream); + } + + // 恢复流位置 | Restore stream position + pdfStream.Position = originalPosition; + + _logger.Info("PDF 文件保存成功:{FilePath} | PDF file saved successfully: {FilePath}", filePath); + return ReportResult.Success(pdfStream); + } + catch (UnauthorizedAccessException ex) + { + _logger.Error(ex, "PDF 保存失败:无写入权限 | PDF save failed: no write permission: {Path}", filePath); + return ReportResult.Failure($"无法写入文件,权限不足:{filePath} | Cannot write file, insufficient permissions: {filePath}", ex); + } + catch (DirectoryNotFoundException ex) + { + _logger.Error(ex, "PDF 保存失败:目录不存在 | PDF save failed: directory not found: {Path}", filePath); + return ReportResult.Failure($"目标目录不存在:{filePath} | Target directory not found: {filePath}", ex); + } + catch (IOException ex) + { + _logger.Error(ex, "PDF 保存失败:IO 错误 | PDF save failed: IO error: {Path}", filePath); + return ReportResult.Failure($"文件保存 IO 错误:{ex.Message} | File save IO error: {ex.Message}", ex); + } + catch (Exception ex) + { + _logger.Error(ex, "PDF 保存失败:未知错误 | PDF save failed: unknown error: {Path}", filePath); + return ReportResult.Failure($"文件保存过程中发生错误:{ex.Message} | Error occurred during file save: {ex.Message}", ex); + } + } + + #endregion + + #region 辅助方法 | Helper methods + + /// + /// 解析十六进制颜色字符串为 iText Color 对象 | Parse hex color string to iText Color object + /// 支持格式:#RRGGBB 或 #RGB | Supports formats: #RRGGBB or #RGB + /// + /// 十六进制颜色字符串 | Hex color string + /// iText Color 对象,解析失败返回黑色 | iText Color object, returns black on failure + private Color ParseColor(string hexColor) + { + if (string.IsNullOrEmpty(hexColor)) + { + return ColorConstants.BLACK; + } + + try + { + var hex = hexColor.TrimStart('#'); + + if (hex.Length == 3) + { + // 扩展 #RGB 为 #RRGGBB | Expand #RGB to #RRGGBB + hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}"; + } + + if (hex.Length == 6) + { + int r = Convert.ToInt32(hex.Substring(0, 2), 16); + int g = Convert.ToInt32(hex.Substring(2, 2), 16); + int b = Convert.ToInt32(hex.Substring(4, 2), 16); + return new DeviceRgb(r, g, b); + } + } + catch (Exception ex) + { + _logger.Warn("颜色解析失败:{Color},使用默认黑色 | Color parsing failed: {Color}, using default black: {Message}", hexColor, ex.Message); + } + + return ColorConstants.BLACK; + } + + /// + /// 解析对齐方式字符串为 iText TextAlignment | Parse alignment string to iText TextAlignment + /// + /// 对齐方式字符串(left/center/right)| Alignment string + /// iText TextAlignment 枚举值 | iText TextAlignment enum value + private TextAlignment ParseTextAlignment(string align) + { + switch (align?.ToLowerInvariant()) + { + case "center": + return TextAlignment.CENTER; + case "right": + return TextAlignment.RIGHT; + case "justify": + return TextAlignment.JUSTIFIED; + case "left": + default: + return TextAlignment.LEFT; + } + } + + #endregion + } +} diff --git a/XP.ReportEngine/Services/JsonTemplateEngine.cs b/XP.ReportEngine/Services/JsonTemplateEngine.cs new file mode 100644 index 0000000..d7f9036 --- /dev/null +++ b/XP.ReportEngine/Services/JsonTemplateEngine.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using XP.Common.Logging.Interfaces; +using XP.ReportEngine.Interfaces; +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Services +{ + /// + /// JSON 模板引擎实现 | JSON template engine implementation + /// 负责加载、反序列化和验证 JSON 格式的报告模板 + /// Responsible for loading, deserializing and validating JSON report templates + /// + public class JsonTemplateEngine : ITemplateEngine + { + private readonly ILoggerService _logger; + + /// + /// 默认样式定义(当模板引用未定义的样式名称时使用) + /// Default style definition (used when template references undefined style name) + /// + public static readonly StyleDefinition DefaultStyle = new() + { + Font = null, + Size = 12f, + Bold = false, + Italic = false, + Color = "#000000", + Align = "left", + BackgroundColor = null + }; + + /// + /// 构造函数 | Constructor + /// + /// 日志服务 | Logger service + public JsonTemplateEngine(ILoggerService logger) + { + _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// 加载并反序列化 JSON 模板文件 | Load and deserialize JSON template file + /// + /// 模板文件路径 | Template file path + /// 解析后的模板对象,文件不存在时返回 null | Parsed template object, null if file not found + public ReportTemplate LoadTemplate(string templatePath) + { + if (string.IsNullOrWhiteSpace(templatePath)) + { + _logger.Warn("模板文件路径为空 | Template file path is null or empty"); + return null; + } + + if (!File.Exists(templatePath)) + { + _logger.Warn("模板文件未找到: {Path} | Template file not found: {Path}", templatePath); + return null; + } + + _logger.Info("开始加载模板文件: {Path} | Loading template file: {Path}", templatePath); + + try + { + var json = File.ReadAllText(templatePath); + var template = JsonConvert.DeserializeObject(json); + + _logger.Info("模板文件加载成功 | Template file loaded successfully"); + return template; + } + catch (JsonReaderException ex) + { + // JSON 语法错误,包含错误位置信息 | JSON syntax error with position info + var message = $"模板 JSON 语法错误,行 {ex.LineNumber},位置 {ex.LinePosition}: {ex.Message} | " + + $"Template JSON syntax error at line {ex.LineNumber}, position {ex.LinePosition}: {ex.Message}"; + _logger.Error(ex, message); + throw new InvalidOperationException(message, ex); + } + catch (JsonSerializationException ex) + { + // JSON 反序列化错误 | JSON deserialization error + var message = $"模板 JSON 反序列化失败: {ex.Message} | Template JSON deserialization failed: {ex.Message}"; + _logger.Error(ex, message); + throw new InvalidOperationException(message, ex); + } + } + + /// + /// 验证模板结构完整性 | Validate template structure integrity + /// 检查 document、pages、styles 必需字段是否存在 + /// Checks if required fields (document, pages, styles) are present + /// + /// 待验证的模板 | Template to validate + /// 验证结果 | Validation result + public TemplateValidationResult Validate(ReportTemplate template) + { + if (template == null) + { + return TemplateValidationResult.Invalid("模板对象为 null | Template object is null"); + } + + var missingFields = new List(); + + if (template.Document == null) + { + missingFields.Add("document"); + } + + if (template.Pages == null || template.Pages.Count == 0) + { + missingFields.Add("pages"); + } + + if (template.Styles == null) + { + missingFields.Add("styles"); + } + + if (missingFields.Count > 0) + { + var fieldList = string.Join(", ", missingFields); + var message = $"模板缺少必需字段: {fieldList} | Template missing required fields: {fieldList}"; + _logger.Warn(message); + return TemplateValidationResult.Invalid(message); + } + + _logger.Info("模板验证通过 | Template validation passed"); + return TemplateValidationResult.Valid(); + } + + /// + /// 解析样式名称,未定义时回退为默认样式 | Resolve style name, fallback to default if undefined + /// + /// 报告模板 | Report template + /// 样式名称 | Style name + /// 解析后的样式定义 | Resolved style definition + public StyleDefinition ResolveStyle(ReportTemplate template, string styleName) + { + // 样式名称为空时直接返回默认样式 | Return default style if style name is empty + if (string.IsNullOrWhiteSpace(styleName)) + { + return DefaultStyle; + } + + // 模板或样式字典为空时返回默认样式 | Return default style if template or styles dictionary is null + if (template?.Styles == null) + { + _logger.Warn("模板样式字典为空,使用默认样式: {StyleName} | Template styles dictionary is null, using default style: {StyleName}", styleName); + return DefaultStyle; + } + + // 查找样式,找到则返回,否则回退为默认样式并记录警告 + // Look up style, return if found, otherwise fallback to default and log warning + if (template.Styles.TryGetValue(styleName, out var style)) + { + return style; + } + + _logger.Warn("未定义的样式名称 '{StyleName}',使用默认样式 | Undefined style name '{StyleName}', using default style", styleName); + return DefaultStyle; + } + } +} diff --git a/XP.ReportEngine/Services/PageLayoutEngine.cs b/XP.ReportEngine/Services/PageLayoutEngine.cs new file mode 100644 index 0000000..5e2257d --- /dev/null +++ b/XP.ReportEngine/Services/PageLayoutEngine.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using XP.Common.Logging.Interfaces; +using XP.ReportEngine.Interfaces; +using XP.ReportEngine.Models; + +namespace XP.ReportEngine.Services +{ + /// + /// 页面排版引擎实现 | Page layout engine implementation + /// 负责计算页面元素位置、处理分页和自适应布局 + /// Responsible for calculating element positions, handling pagination and adaptive layout + /// + public class PageLayoutEngine : ILayoutEngine + { + private readonly ILoggerService _logger; + private readonly JsonTemplateEngine _templateEngine; + + // A4 页面尺寸(mm)| A4 page dimensions (mm) + private const float A4Width = 210f; + private const float A4Height = 297f; + + // 默认行高估算(mm)| Default row height estimate (mm) + private const float DefaultRowHeight = 8f; + + // 默认文本元素高度(mm)| Default text element height (mm) + private const float DefaultTextHeight = 10f; + + // 默认分隔线高度(mm)| Default divider height (mm) + private const float DefaultDividerHeight = 2f; + + /// + /// 构造函数 | Constructor + /// + /// 日志服务 | Logger service + /// 模板引擎(用于样式解析)| Template engine (for style resolution) + public PageLayoutEngine(ILoggerService logger, JsonTemplateEngine templateEngine) + { + _logger = logger?.ForModule() ?? throw new ArgumentNullException(nameof(logger)); + _templateEngine = templateEngine ?? throw new ArgumentNullException(nameof(templateEngine)); + } + + /// + /// 计算页面布局 | Calculate page layout + /// 遍历模板中所有页面和元素,根据定位方式计算最终坐标,处理分页和表格跨页 + /// Iterates through all pages and elements in template, calculates final coordinates based on positioning mode, + /// handles pagination and table page-splitting + /// + /// 绑定后的模板 | Bound template + /// 生成选项 | Generation options + /// 排版后的页面列表 | List of laid-out pages + public List CalculateLayout(ReportTemplate template, ReportGenerationOptions options) + { + if (template == null) throw new ArgumentNullException(nameof(template)); + + _logger.Info("开始排版计算 | Starting layout calculation"); + + var margins = template.Document?.Margins ?? new MarginSettings(); + var availableWidth = A4Width - margins.Left - margins.Right; + var availableHeight = A4Height - margins.Top - margins.Bottom; + + var pages = new List(); + var currentPageNumber = 1; + + if (template.Pages == null || template.Pages.Count == 0) + { + _logger.Warn("模板无页面定义 | Template has no page definitions"); + return pages; + } + + foreach (var templatePage in template.Pages) + { + if (templatePage.Elements == null || templatePage.Elements.Count == 0) + { + continue; + } + + // 分离绝对定位和流式定位元素 | Separate absolute and flow positioned elements + var absoluteElements = templatePage.Elements + .Where(e => string.Equals(e.Positioning, "absolute", StringComparison.OrdinalIgnoreCase)) + .ToList(); + var flowElements = templatePage.Elements + .Where(e => string.Equals(e.Positioning, "flow", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // 创建当前页面 | Create current page + var currentPage = new LayoutPage + { + PageNumber = currentPageNumber, + Elements = new List() + }; + pages.Add(currentPage); + + // 处理绝对定位元素(不参与分页)| Process absolute positioned elements (no pagination) + foreach (var element in absoluteElements) + { + var layoutElement = ProcessAbsoluteElement(element, template, margins); + currentPage.Elements.Add(layoutElement); + } + + // 处理流式定位元素(参与分页)| Process flow positioned elements (with pagination) + var currentY = margins.Top; + + foreach (var element in flowElements) + { + var elementHeight = CalculateElementHeight(element); + var elementWidth = CalculateElementWidth(element, availableWidth); + + // 检查是否需要分页 | Check if pagination is needed + if (element.Type == "table" && element.Columns != null) + { + // 表格跨页拆分逻辑 | Table page-split logic + currentY = ProcessTableWithPageSplit( + element, template, margins, availableHeight, availableWidth, + currentY, pages, ref currentPage, ref currentPageNumber); + } + else + { + // 普通元素分页检查 | Normal element pagination check + if (currentY + elementHeight > margins.Top + availableHeight) + { + // 创建新页面 | Create new page + currentPageNumber++; + currentPage = new LayoutPage + { + PageNumber = currentPageNumber, + Elements = new List() + }; + pages.Add(currentPage); + currentY = margins.Top; + } + + var layoutElement = CreateFlowLayoutElement( + element, template, margins, currentY, elementWidth, elementHeight); + currentPage.Elements.Add(layoutElement); + + // 累计 Y 坐标 | Accumulate Y coordinate + currentY += elementHeight; + } + } + + currentPageNumber++; + } + + _logger.Info("排版计算完成,共 {PageCount} 页 | Layout calculation completed, {PageCount} pages total", pages.Count); + return pages; + } + + /// + /// 处理绝对定位元素 | Process absolute positioned element + /// 元素坐标 = Position + Margins 偏移 + /// Element coordinates = Position + Margins offset + /// + private LayoutElement ProcessAbsoluteElement(TemplateElement element, ReportTemplate template, MarginSettings margins) + { + var x = margins.Left + (element.Position != null && element.Position.Length > 0 ? element.Position[0] : 0f); + var y = margins.Top + (element.Position != null && element.Position.Length > 1 ? element.Position[1] : 0f); + var width = element.Size != null && element.Size.Length > 0 ? element.Size[0] : 0f; + var height = element.Size != null && element.Size.Length > 1 ? element.Size[1] : 0f; + + // 图像等比缩放 | Image proportional scaling + if (element.Type == "image" && width > 0 && height > 0) + { + var scaled = CalculateScaledImageDimensions(width, height, width, height); + width = scaled.Width; + height = scaled.Height; + } + + var resolvedStyle = _templateEngine.ResolveStyle(template, element.Style); + + return new LayoutElement + { + Source = element, + X = x, + Y = y, + Width = width, + Height = height, + ResolvedStyle = resolvedStyle, + ResolvedContent = element.Content, + ResolvedTableData = element.TableData, + ResolvedImage = element.ImageData + }; + } + + /// + /// 创建流式定位的布局元素 | Create flow positioned layout element + /// + private LayoutElement CreateFlowLayoutElement( + TemplateElement element, ReportTemplate template, MarginSettings margins, + float currentY, float width, float height) + { + var x = margins.Left; + + // 如果元素有 Position 定义,使用 X 偏移 | If element has Position defined, use X offset + if (element.Position != null && element.Position.Length > 0) + { + x = margins.Left + element.Position[0]; + } + + // 图像等比缩放 | Image proportional scaling + if (element.Type == "image") + { + var targetWidth = width; + var targetHeight = height; + var imageWidth = element.Size != null && element.Size.Length > 0 ? element.Size[0] : width; + var imageHeight = element.Size != null && element.Size.Length > 1 ? element.Size[1] : height; + + if (imageWidth > 0 && imageHeight > 0 && targetWidth > 0 && targetHeight > 0) + { + var scaled = CalculateScaledImageDimensions(imageWidth, imageHeight, targetWidth, targetHeight); + width = scaled.Width; + height = scaled.Height; + } + } + + var resolvedStyle = _templateEngine.ResolveStyle(template, element.Style); + + return new LayoutElement + { + Source = element, + X = x, + Y = currentY, + Width = width, + Height = height, + ResolvedStyle = resolvedStyle, + ResolvedContent = element.Content, + ResolvedTableData = element.TableData, + ResolvedImage = element.ImageData + }; + } + + /// + /// 处理表格跨页拆分 | Process table with page-split + /// 按行高计算剩余空间,超出时拆分到新页面,续页重复表头行 + /// Calculate remaining space by row height, split to new page when exceeded, repeat header on continuation pages + /// + /// 处理后的当前 Y 坐标 | Current Y coordinate after processing + private float ProcessTableWithPageSplit( + TemplateElement element, ReportTemplate template, MarginSettings margins, + float availableHeight, float availableWidth, + float currentY, List pages, + ref LayoutPage currentPage, ref int currentPageNumber) + { + var resolvedStyle = _templateEngine.ResolveStyle(template, element.Style); + var tableWidth = element.Size != null && element.Size.Length > 0 ? element.Size[0] : availableWidth; + + // 计算表头高度(1 行)| Calculate header height (1 row) + var headerHeight = DefaultRowHeight; + + // 获取表格数据行数 | Get table data row count + var tableData = GetTableDataFromElement(element); + var totalDataRows = tableData?.Count ?? 0; + + if (totalDataRows == 0) + { + // 空表格,仅渲染表头 | Empty table, render header only + var emptyTableHeight = headerHeight; + if (currentY + emptyTableHeight > margins.Top + availableHeight) + { + currentPageNumber++; + currentPage = new LayoutPage + { + PageNumber = currentPageNumber, + Elements = new List() + }; + pages.Add(currentPage); + currentY = margins.Top; + } + + var emptyTableElement = new LayoutElement + { + Source = element, + X = margins.Left, + Y = currentY, + Width = tableWidth, + Height = emptyTableHeight, + ResolvedStyle = resolvedStyle, + ResolvedContent = element.Content, + ResolvedTableData = tableData + }; + currentPage.Elements.Add(emptyTableElement); + currentY += emptyTableHeight; + return currentY; + } + + // 计算当前页面剩余空间 | Calculate remaining space on current page + var remainingHeight = (margins.Top + availableHeight) - currentY; + var totalTableHeight = headerHeight + (totalDataRows * DefaultRowHeight); + + // 如果整个表格能放下,直接放置 | If entire table fits, place directly + if (totalTableHeight <= remainingHeight) + { + var tableElement = new LayoutElement + { + Source = element, + X = margins.Left, + Y = currentY, + Width = tableWidth, + Height = totalTableHeight, + ResolvedStyle = resolvedStyle, + ResolvedContent = element.Content, + ResolvedTableData = tableData + }; + currentPage.Elements.Add(tableElement); + currentY += totalTableHeight; + return currentY; + } + + // 需要跨页拆分 | Need to split across pages + var currentRowIndex = 0; + + while (currentRowIndex < totalDataRows) + { + // 计算当前页面可容纳的数据行数(需预留表头空间)| Calculate rows that fit on current page (reserve header space) + var currentRemainingHeight = (margins.Top + availableHeight) - currentY; + var rowsOnCurrentPage = (int)Math.Floor((currentRemainingHeight - headerHeight) / DefaultRowHeight); + + if (rowsOnCurrentPage <= 0) + { + // 当前页面空间不足以放置表头+至少一行数据,创建新页面 + // Current page doesn't have space for header + at least one data row, create new page + currentPageNumber++; + currentPage = new LayoutPage + { + PageNumber = currentPageNumber, + Elements = new List() + }; + pages.Add(currentPage); + currentY = margins.Top; + currentRemainingHeight = availableHeight; + rowsOnCurrentPage = (int)Math.Floor((currentRemainingHeight - headerHeight) / DefaultRowHeight); + } + + // 确定本页实际放置的行数 | Determine actual rows to place on this page + var rowsToPlace = Math.Min(rowsOnCurrentPage, totalDataRows - currentRowIndex); + var splitData = tableData.Skip(currentRowIndex).Take(rowsToPlace).ToList(); + var splitHeight = headerHeight + (rowsToPlace * DefaultRowHeight); + + var splitElement = new LayoutElement + { + Source = element, + X = margins.Left, + Y = currentY, + Width = tableWidth, + Height = splitHeight, + ResolvedStyle = resolvedStyle, + ResolvedContent = element.Content, + ResolvedTableData = splitData + }; + currentPage.Elements.Add(splitElement); + currentY += splitHeight; + currentRowIndex += rowsToPlace; + + // 如果还有剩余行,创建新页面继续 | If there are remaining rows, create new page to continue + if (currentRowIndex < totalDataRows) + { + currentPageNumber++; + currentPage = new LayoutPage + { + PageNumber = currentPageNumber, + Elements = new List() + }; + pages.Add(currentPage); + currentY = margins.Top; + } + } + + return currentY; + } + + /// + /// 计算图像等比缩放尺寸 | Calculate proportionally scaled image dimensions + /// 保持宽高比,确保缩放后的宽度和高度均不超过目标区域 + /// Maintain aspect ratio, ensure scaled width and height don't exceed target area + /// + /// 原始图像宽度 | Original image width + /// 原始图像高度 | Original image height + /// 目标区域宽度 | Target area width + /// 目标区域高度 | Target area height + /// 缩放后的尺寸 | Scaled dimensions + public (float Width, float Height) CalculateScaledImageDimensions( + float imageWidth, float imageHeight, float targetWidth, float targetHeight) + { + if (imageWidth <= 0 || imageHeight <= 0 || targetWidth <= 0 || targetHeight <= 0) + { + return (0f, 0f); + } + + // 如果图像已经在目标区域内,无需缩放 | If image already fits, no scaling needed + if (imageWidth <= targetWidth && imageHeight <= targetHeight) + { + return (imageWidth, imageHeight); + } + + // 计算宽度和高度的缩放比例,取较小值以确保两个维度都不超出 + // Calculate scale ratios for width and height, use the smaller one to ensure both dimensions fit + var widthRatio = targetWidth / imageWidth; + var heightRatio = targetHeight / imageHeight; + var scale = Math.Min(widthRatio, heightRatio); + + var scaledWidth = imageWidth * scale; + var scaledHeight = imageHeight * scale; + + return (scaledWidth, scaledHeight); + } + + /// + /// 计算元素高度 | Calculate element height + /// 根据元素类型和 Size 定义确定高度 + /// Determine height based on element type and Size definition + /// + private float CalculateElementHeight(TemplateElement element) + { + // 如果有明确的 Size 定义,使用 Size[1] 作为高度 | If Size is defined, use Size[1] as height + if (element.Size != null && element.Size.Length > 1 && element.Size[1] > 0) + { + return element.Size[1]; + } + + // 根据元素类型使用默认高度 | Use default height based on element type + return element.Type?.ToLowerInvariant() switch + { + "text" => DefaultTextHeight, + "divider" => DefaultDividerHeight, + "image" => DefaultTextHeight, + "table" => CalculateTableHeight(element), + _ => DefaultTextHeight + }; + } + + /// + /// 计算表格高度 | Calculate table height + /// 表头行 + 数据行数 × 默认行高 + /// Header row + data row count × default row height + /// + private float CalculateTableHeight(TemplateElement element) + { + var tableData = GetTableDataFromElement(element); + var dataRowCount = tableData?.Count ?? 0; + // 表头 1 行 + 数据行 | 1 header row + data rows + return DefaultRowHeight + (dataRowCount * DefaultRowHeight); + } + + /// + /// 计算元素宽度 | Calculate element width + /// + private float CalculateElementWidth(TemplateElement element, float availableWidth) + { + if (element.Size != null && element.Size.Length > 0 && element.Size[0] > 0) + { + return element.Size[0]; + } + return availableWidth; + } + + /// + /// 从元素获取表格数据 | Get table data from element + /// 表格数据在数据绑定阶段通过 TableData 属性填充 + /// Table data is populated during data binding phase via TableData property + /// + private List> GetTableDataFromElement(TemplateElement element) + { + // 表格数据在数据绑定阶段已填充到 TemplateElement.TableData + // Table data is populated during data binding phase into TemplateElement.TableData + return element.TableData; + } + } +} diff --git a/XP.ReportEngine/Templates/.gitkeep b/XP.ReportEngine/Templates/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/XP.ReportEngine/Templates/.gitkeep @@ -0,0 +1 @@ + diff --git a/XP.ReportEngine/XP.ReportEngine.csproj b/XP.ReportEngine/XP.ReportEngine.csproj index ad1209d..f26f2a6 100644 --- a/XP.ReportEngine/XP.ReportEngine.csproj +++ b/XP.ReportEngine/XP.ReportEngine.csproj @@ -4,6 +4,9 @@ true + + + @@ -13,4 +16,10 @@ - \ No newline at end of file + + + + + + + diff --git a/XplorePlane.sln b/XplorePlane.sln index ece241e..326513c 100644 --- a/XplorePlane.sln +++ b/XplorePlane.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.5.11723.231 stable +# Visual Studio Version 17 +VisualStudioVersion = 17.14.37203.1 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XplorePlane", "XplorePlane\XplorePlane.csproj", "{07978DB9-4B88-4F42-9054-73992742BD6A}" EndProject