报告XP.ReportEngine基础类设计和接口开发。

This commit is contained in:
QI Mingxuan
2026-05-11 16:40:24 +08:00
parent 18111b8468
commit 1573a33a02
31 changed files with 2596 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
+18
View File
@@ -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.
+18
View File
@@ -0,0 +1,18 @@
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Interfaces
{
/// <summary>
/// 数据绑定器接口 | Data binder interface
/// </summary>
public interface IDataBinder
{
/// <summary>
/// 将上下文数据绑定到模板 | Bind context data to template
/// </summary>
/// <param name="template">报告模板 | Report template</param>
/// <param name="context">报告上下文 | Report context</param>
/// <returns>绑定后的模板(元素内容已替换)| Bound template with resolved content</returns>
ReportTemplate Bind(ReportTemplate template, ReportContext context);
}
}
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Interfaces
{
/// <summary>
/// 排版引擎接口 | Layout engine interface
/// </summary>
public interface ILayoutEngine
{
/// <summary>
/// 计算页面布局 | Calculate page layout
/// </summary>
/// <param name="template">绑定后的模板 | Bound template</param>
/// <param name="options">生成选项 | Generation options</param>
/// <returns>排版后的页面列表 | List of laid-out pages</returns>
List<LayoutPage> CalculateLayout(ReportTemplate template, ReportGenerationOptions options);
}
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.IO;
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Interfaces
{
/// <summary>
/// PDF 渲染器接口 | PDF renderer interface
/// </summary>
public interface IPdfRenderer
{
/// <summary>
/// 将排版结果渲染为 PDF | Render layout result to PDF
/// </summary>
/// <param name="pages">排版后的页面列表 | Laid-out pages</param>
/// <param name="options">生成选项 | Generation options</param>
/// <returns>PDF 内存流 | PDF memory stream</returns>
MemoryStream Render(List<LayoutPage> pages, ReportGenerationOptions options);
}
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Interfaces
{
/// <summary>
/// 报告数据适配器接口 | Report data adapter interface
/// 将 XP.ImageProcessing 的 OutputData 转换为 ReportContext
/// </summary>
public interface IReportDataAdapter
{
/// <summary>
/// 将处理器输出数据适配为报告上下文 | Adapt processor output data to report context
/// </summary>
/// <param name="processorOutputs">处理器输出字典列表 | List of processor output dictionaries</param>
/// <param name="metadata">报告元数据 | Report metadata</param>
/// <returns>报告上下文 | Report context</returns>
ReportContext Adapt(List<ProcessorOutput> processorOutputs, ReportMetadata metadata);
}
}
@@ -0,0 +1,19 @@
using System.Threading.Tasks;
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Interfaces
{
/// <summary>
/// 报告生成器接口(格式无关)| Report generator interface (format-agnostic)
/// </summary>
public interface IReportGenerator
{
/// <summary>
/// 异步生成报告 | Generate report asynchronously
/// </summary>
/// <param name="context">报告上下文数据 | Report context data</param>
/// <param name="options">生成选项 | Generation options</param>
/// <returns>生成结果 | Generation result</returns>
Task<ReportResult> GenerateAsync(ReportContext context, ReportGenerationOptions options);
}
}
@@ -0,0 +1,15 @@
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Interfaces
{
/// <summary>
/// 报告生成器工厂接口 | Report generator factory interface
/// </summary>
public interface IReportGeneratorFactory
{
/// <summary>
/// 根据输出格式创建生成器 | Create generator by output format
/// </summary>
IReportGenerator Create(ReportOutputFormat format);
}
}
@@ -0,0 +1,22 @@
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Interfaces
{
/// <summary>
/// 模板引擎接口 | Template engine interface
/// </summary>
public interface ITemplateEngine
{
/// <summary>
/// 加载并验证模板 | Load and validate template
/// </summary>
/// <param name="templatePath">模板文件路径 | Template file path</param>
/// <returns>解析后的模板对象 | Parsed template object</returns>
ReportTemplate LoadTemplate(string templatePath);
/// <summary>
/// 验证模板结构完整性 | Validate template structure integrity
/// </summary>
TemplateValidationResult Validate(ReportTemplate template);
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace XP.ReportEngine.Models
{
/// <summary>
/// 图像数据封装 | Image data wrapper
/// </summary>
public class ImageData
{
/// <summary>
/// 图像来源类型 | Image source type
/// </summary>
public ImageSourceType SourceType { get; set; }
/// <summary>
/// 字节数组数据(当 SourceType 为 Bytes 时)| Byte array data
/// </summary>
public byte[] Bytes { get; set; }
/// <summary>
/// 文件路径(当 SourceType 为 FilePath 时)| File path
/// </summary>
public string FilePath { get; set; }
/// <summary>
/// BitmapSource 引用(当 SourceType 为 BitmapSource 时)| BitmapSource reference
/// </summary>
public object BitmapSource { get; set; }
}
public enum ImageSourceType
{
Bytes,
FilePath,
BitmapSource
}
}
+29
View File
@@ -0,0 +1,29 @@
using System.Collections.Generic;
namespace XP.ReportEngine.Models
{
/// <summary>
/// 排版后的页面 | Laid-out page
/// </summary>
public class LayoutPage
{
public int PageNumber { get; set; }
public List<LayoutElement> Elements { get; set; } = new();
}
/// <summary>
/// 排版后的元素(含计算后的绝对坐标)| Laid-out element with computed absolute coordinates
/// </summary>
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<Dictionary<string, object>> ResolvedTableData { get; set; }
public ImageData ResolvedImage { get; set; }
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.Collections.Generic;
namespace XP.ReportEngine.Models
{
/// <summary>
/// 处理器输出数据封装 | Processor output data wrapper
/// </summary>
public class ProcessorOutput
{
/// <summary>
/// 处理器类型名称 | Processor type name
/// </summary>
public string ProcessorType { get; set; }
/// <summary>
/// 输出数据字典 | Output data dictionary
/// </summary>
public Dictionary<string, object> OutputData { get; set; } = new();
/// <summary>
/// 关联的已标注图像 | Associated annotated image
/// </summary>
public ImageData AnnotatedImage { get; set; }
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
namespace XP.ReportEngine.Models
{
/// <summary>
/// 报告上下文,包含生成报告所需的全部数据 | Report context containing all data needed for report generation
/// </summary>
public class ReportContext
{
/// <summary>
/// 报告元数据 | Report metadata
/// </summary>
public ReportMetadata Metadata { get; set; }
/// <summary>
/// 检测结果数据集合(按处理器类型分组)| Inspection result data grouped by processor type
/// </summary>
public List<InspectionResultGroup> ResultGroups { get; set; } = new();
/// <summary>
/// 图像数据字典(键为 dataKey,值为图像数据)| Image data dictionary (key=dataKey, value=image data)
/// </summary>
public Dictionary<string, ImageData> Images { get; set; } = new();
/// <summary>
/// 自定义属性字典(用于数据绑定的扁平化键值对)| Custom properties for data binding
/// </summary>
public Dictionary<string, object> Properties { get; set; } = new();
}
/// <summary>
/// 报告元数据 | Report metadata
/// </summary>
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; }
}
/// <summary>
/// 检测结果分组 | Inspection result group
/// </summary>
public class InspectionResultGroup
{
/// <summary>
/// 处理器类型标识 | Processor type identifier
/// </summary>
public string ProcessorType { get; set; }
/// <summary>
/// 数据来源标识 | Data source identifier
/// </summary>
public string SourceId { get; set; }
/// <summary>
/// 分类结果(Pass/Fail| Classification result
/// </summary>
public string Classification { get; set; }
/// <summary>
/// 结果键值对 | Result key-value pairs
/// </summary>
public Dictionary<string, object> Data { get; set; } = new();
/// <summary>
/// 表格数据行(用于表格渲染)| Table data rows for table rendering
/// </summary>
public List<Dictionary<string, object>> TableRows { get; set; } = new();
}
}
@@ -0,0 +1,30 @@
namespace XP.ReportEngine.Models
{
/// <summary>
/// 报告生成选项 | Report generation options
/// </summary>
public class ReportGenerationOptions
{
/// <summary>
/// 模板文件路径 | Template file path
/// </summary>
public string TemplatePath { get; set; }
/// <summary>
/// 输出文件路径(可选,为 null 时仅返回 MemoryStream| Output file path (optional)
/// </summary>
public string OutputFilePath { get; set; }
/// <summary>
/// 输出格式 | Output format
/// </summary>
public ReportOutputFormat Format { get; set; } = ReportOutputFormat.Pdf;
}
public enum ReportOutputFormat
{
Pdf,
Excel,
Csv
}
}
+22
View File
@@ -0,0 +1,22 @@
using System;
using System.IO;
namespace XP.ReportEngine.Models
{
/// <summary>
/// 报告生成结果 | Report generation result
/// </summary>
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 };
}
}
+38
View File
@@ -0,0 +1,38 @@
using System.Collections.Generic;
namespace XP.ReportEngine.Models
{
/// <summary>
/// 报告模板定义 | Report template definition
/// </summary>
public class ReportTemplate
{
public DocumentSettings Document { get; set; }
public List<TemplatePage> Pages { get; set; } = new();
public Dictionary<string, StyleDefinition> 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
{
/// <summary>
/// 页面类型:homepage / metricData / defectDetails / bgaInspection / voidInspection / viaFillInspection
/// </summary>
public string Type { get; set; }
public List<TemplateElement> Elements { get; set; } = new();
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Collections.Generic;
namespace XP.ReportEngine.Models
{
public class TemplateElement
{
/// <summary>
/// 元素类型:text / image / table / divider / grid
/// </summary>
public string Type { get; set; }
/// <summary>
/// 文本内容(可含 ${} 绑定表达式)| Text content with binding expressions
/// </summary>
public string Content { get; set; }
/// <summary>
/// 样式名称引用 | Style name reference
/// </summary>
public string Style { get; set; }
/// <summary>
/// 数据绑定键 | Data binding key
/// </summary>
public string DataKey { get; set; }
/// <summary>
/// 位置坐标 [x, y]mm| Position coordinates in mm
/// </summary>
public float[] Position { get; set; }
/// <summary>
/// 尺寸 [width, height]mm| Size in mm
/// </summary>
public float[] Size { get; set; }
/// <summary>
/// 表格列定义 | Table column definitions
/// </summary>
public List<ColumnDefinition> Columns { get; set; }
/// <summary>
/// 是否显示边框 | Whether to show border
/// </summary>
public bool Border { get; set; }
/// <summary>
/// 定位方式:absolute / flow | Positioning mode
/// </summary>
public string Positioning { get; set; } = "absolute";
/// <summary>
/// 表格数据行(数据绑定阶段填充,用于排版计算和渲染)
/// Table data rows (populated during data binding phase, used for layout calculation and rendering)
/// </summary>
[Newtonsoft.Json.JsonIgnore]
public List<Dictionary<string, object>> TableData { get; set; }
/// <summary>
/// 图像数据(数据绑定阶段填充)| Image data (populated during data binding phase)
/// </summary>
[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; }
}
}
@@ -0,0 +1,17 @@
namespace XP.ReportEngine.Models
{
/// <summary>
/// 模板验证结果 | Template validation result
/// </summary>
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 };
}
}
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+1
View File
@@ -0,0 +1 @@
@@ -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
{
/// <summary>
/// 表达式数据绑定器实现 | Expression data binder implementation
/// 支持 ${} 语法的数据绑定、格式化函数和本地化键解析
/// Supports ${} syntax data binding, format functions and localization key resolution
/// </summary>
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<ExpressionDataBinder>() ?? 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<string, object> 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<ReportTemplate>(json);
}
}
}
@@ -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
{
/// <summary>
/// iText 7 PDF 渲染器实现 | iText 7 PDF renderer implementation
/// </summary>
public class ITextPdfRenderer : IPdfRenderer
{
private readonly ILoggerService _logger;
private readonly ILocalizationService _localizationService;
/// <summary>
/// mm 到 points 的转换系数 | mm to points conversion factor
/// </summary>
private const float MmToPoints = 2.83465f;
/// <summary>
/// A4 页面宽度(mm| A4 page width in mm
/// </summary>
private const float A4WidthMm = 210f;
/// <summary>
/// A4 页面高度(mm| A4 page height in mm
/// </summary>
private const float A4HeightMm = 297f;
/// <summary>
/// 表头背景色 | Table header background color
/// </summary>
private const string HeaderBackgroundColor = "#E0E0E0";
/// <summary>
/// 表格奇数行背景色 | Table odd row background color
/// </summary>
private const string OddRowBackgroundColor = "#FFFFFF";
/// <summary>
/// 表格偶数行背景色 | Table even row background color
/// </summary>
private const string EvenRowBackgroundColor = "#F5F5F5";
private PdfFont _cjkFont;
private PdfFont _westernFont;
public ITextPdfRenderer(ILoggerService logger, ILocalizationService localizationService)
{
_logger = logger?.ForModule<ITextPdfRenderer>() ?? throw new ArgumentNullException(nameof(logger));
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
InitializeFonts();
}
#region 7.1 PDF | Basic PDF document creation
/// <summary>
/// 将排版结果渲染为 PDF 内存流 | Render layout result to PDF memory stream
/// </summary>
/// <param name="pages">排版后的页面列表 | Laid-out pages</param>
/// <param name="options">生成选项 | Generation options</param>
/// <returns>PDF 内存流 | PDF memory stream</returns>
public MemoryStream Render(List<LayoutPage> 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;
}
/// <summary>
/// 渲染单个页面 | Render a single page
/// </summary>
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);
}
}
}
/// <summary>
/// 根据元素类型分发渲染 | Dispatch rendering based on element type
/// </summary>
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
/// <summary>
/// 渲染文本元素 | Render text element
/// </summary>
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
/// <summary>
/// 初始化字体(加载嵌入资源字体)| Initialize fonts (load embedded resource fonts)
/// </summary>
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");
}
}
/// <summary>
/// 从嵌入资源加载字体 | Load font from embedded resource
/// </summary>
/// <param name="resourceName">嵌入资源名称 | Embedded resource name</param>
/// <returns>PDF 字体对象 | PDF font object</returns>
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);
}
}
/// <summary>
/// 根据当前语言获取合适的字体 | Get appropriate font based on current language
/// zh-CN、zh-TW → CJK 字体;en-US → 西文字体
/// </summary>
/// <returns>PDF 字体 | PDF font</returns>
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
/// <summary>
/// 渲染图像元素 | Render image element
/// </summary>
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);
}
}
/// <summary>
/// 从 ImageData 获取字节数组 | Get byte array from ImageData
/// </summary>
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
/// <summary>
/// 将 BitmapSource 转换为 PNG 编码的字节数组 | Convert BitmapSource to PNG-encoded byte array
/// </summary>
/// <param name="bitmapSource">WPF BitmapSource 对象 | WPF BitmapSource object</param>
/// <returns>PNG 编码的字节数组 | PNG-encoded byte array</returns>
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
/// <summary>
/// 渲染图像缺失时的占位矩形(带"无图像 | No Image"文本标签)
/// Render placeholder rectangle when image is missing (with "无图像 | No Image" text label)
/// </summary>
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
/// <summary>
/// 渲染表格元素 | Render table element
/// </summary>
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
/// <summary>
/// 渲染分隔线元素 | Render divider element
/// </summary>
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
/// <summary>
/// 将 PDF 内存流保存到文件 | Save PDF memory stream to file
/// </summary>
/// <param name="pdfStream">PDF 内存流 | PDF memory stream</param>
/// <param name="filePath">输出文件路径 | Output file path</param>
/// <returns>保存结果(成功/失败)| Save result (success/failure)</returns>
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
/// <summary>
/// 解析十六进制颜色字符串为 iText Color 对象 | Parse hex color string to iText Color object
/// 支持格式:#RRGGBB 或 #RGB | Supports formats: #RRGGBB or #RGB
/// </summary>
/// <param name="hexColor">十六进制颜色字符串 | Hex color string</param>
/// <returns>iText Color 对象,解析失败返回黑色 | iText Color object, returns black on failure</returns>
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;
}
/// <summary>
/// 解析对齐方式字符串为 iText TextAlignment | Parse alignment string to iText TextAlignment
/// </summary>
/// <param name="align">对齐方式字符串(left/center/right| Alignment string</param>
/// <returns>iText TextAlignment 枚举值 | iText TextAlignment enum value</returns>
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
}
}
@@ -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
{
/// <summary>
/// JSON 模板引擎实现 | JSON template engine implementation
/// 负责加载、反序列化和验证 JSON 格式的报告模板
/// Responsible for loading, deserializing and validating JSON report templates
/// </summary>
public class JsonTemplateEngine : ITemplateEngine
{
private readonly ILoggerService _logger;
/// <summary>
/// 默认样式定义(当模板引用未定义的样式名称时使用)
/// Default style definition (used when template references undefined style name)
/// </summary>
public static readonly StyleDefinition DefaultStyle = new()
{
Font = null,
Size = 12f,
Bold = false,
Italic = false,
Color = "#000000",
Align = "left",
BackgroundColor = null
};
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="logger">日志服务 | Logger service</param>
public JsonTemplateEngine(ILoggerService logger)
{
_logger = logger?.ForModule<JsonTemplateEngine>() ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 加载并反序列化 JSON 模板文件 | Load and deserialize JSON template file
/// </summary>
/// <param name="templatePath">模板文件路径 | Template file path</param>
/// <returns>解析后的模板对象,文件不存在时返回 null | Parsed template object, null if file not found</returns>
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<ReportTemplate>(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);
}
}
/// <summary>
/// 验证模板结构完整性 | Validate template structure integrity
/// 检查 document、pages、styles 必需字段是否存在
/// Checks if required fields (document, pages, styles) are present
/// </summary>
/// <param name="template">待验证的模板 | Template to validate</param>
/// <returns>验证结果 | Validation result</returns>
public TemplateValidationResult Validate(ReportTemplate template)
{
if (template == null)
{
return TemplateValidationResult.Invalid("模板对象为 null | Template object is null");
}
var missingFields = new List<string>();
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();
}
/// <summary>
/// 解析样式名称,未定义时回退为默认样式 | Resolve style name, fallback to default if undefined
/// </summary>
/// <param name="template">报告模板 | Report template</param>
/// <param name="styleName">样式名称 | Style name</param>
/// <returns>解析后的样式定义 | Resolved style definition</returns>
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;
}
}
}
@@ -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
{
/// <summary>
/// 页面排版引擎实现 | Page layout engine implementation
/// 负责计算页面元素位置、处理分页和自适应布局
/// Responsible for calculating element positions, handling pagination and adaptive layout
/// </summary>
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;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="logger">日志服务 | Logger service</param>
/// <param name="templateEngine">模板引擎(用于样式解析)| Template engine (for style resolution)</param>
public PageLayoutEngine(ILoggerService logger, JsonTemplateEngine templateEngine)
{
_logger = logger?.ForModule<PageLayoutEngine>() ?? throw new ArgumentNullException(nameof(logger));
_templateEngine = templateEngine ?? throw new ArgumentNullException(nameof(templateEngine));
}
/// <summary>
/// 计算页面布局 | Calculate page layout
/// 遍历模板中所有页面和元素,根据定位方式计算最终坐标,处理分页和表格跨页
/// Iterates through all pages and elements in template, calculates final coordinates based on positioning mode,
/// handles pagination and table page-splitting
/// </summary>
/// <param name="template">绑定后的模板 | Bound template</param>
/// <param name="options">生成选项 | Generation options</param>
/// <returns>排版后的页面列表 | List of laid-out pages</returns>
public List<LayoutPage> 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<LayoutPage>();
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<LayoutElement>()
};
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<LayoutElement>()
};
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;
}
/// <summary>
/// 处理绝对定位元素 | Process absolute positioned element
/// 元素坐标 = Position + Margins 偏移
/// Element coordinates = Position + Margins offset
/// </summary>
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
};
}
/// <summary>
/// 创建流式定位的布局元素 | Create flow positioned layout element
/// </summary>
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
};
}
/// <summary>
/// 处理表格跨页拆分 | Process table with page-split
/// 按行高计算剩余空间,超出时拆分到新页面,续页重复表头行
/// Calculate remaining space by row height, split to new page when exceeded, repeat header on continuation pages
/// </summary>
/// <returns>处理后的当前 Y 坐标 | Current Y coordinate after processing</returns>
private float ProcessTableWithPageSplit(
TemplateElement element, ReportTemplate template, MarginSettings margins,
float availableHeight, float availableWidth,
float currentY, List<LayoutPage> 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<LayoutElement>()
};
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<LayoutElement>()
};
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<LayoutElement>()
};
pages.Add(currentPage);
currentY = margins.Top;
}
}
return currentY;
}
/// <summary>
/// 计算图像等比缩放尺寸 | Calculate proportionally scaled image dimensions
/// 保持宽高比,确保缩放后的宽度和高度均不超过目标区域
/// Maintain aspect ratio, ensure scaled width and height don't exceed target area
/// </summary>
/// <param name="imageWidth">原始图像宽度 | Original image width</param>
/// <param name="imageHeight">原始图像高度 | Original image height</param>
/// <param name="targetWidth">目标区域宽度 | Target area width</param>
/// <param name="targetHeight">目标区域高度 | Target area height</param>
/// <returns>缩放后的尺寸 | Scaled dimensions</returns>
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);
}
/// <summary>
/// 计算元素高度 | Calculate element height
/// 根据元素类型和 Size 定义确定高度
/// Determine height based on element type and Size definition
/// </summary>
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
};
}
/// <summary>
/// 计算表格高度 | Calculate table height
/// 表头行 + 数据行数 × 默认行高
/// Header row + data row count × default row height
/// </summary>
private float CalculateTableHeight(TemplateElement element)
{
var tableData = GetTableDataFromElement(element);
var dataRowCount = tableData?.Count ?? 0;
// 表头 1 行 + 数据行 | 1 header row + data rows
return DefaultRowHeight + (dataRowCount * DefaultRowHeight);
}
/// <summary>
/// 计算元素宽度 | Calculate element width
/// </summary>
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;
}
/// <summary>
/// 从元素获取表格数据 | Get table data from element
/// 表格数据在数据绑定阶段通过 TableData 属性填充
/// Table data is populated during data binding phase via TableData property
/// </summary>
private List<Dictionary<string, object>> GetTableDataFromElement(TemplateElement element)
{
// 表格数据在数据绑定阶段已填充到 TemplateElement.TableData
// Table data is populated during data binding phase into TemplateElement.TableData
return element.TableData;
}
}
}
+1
View File
@@ -0,0 +1 @@
+10 -1
View File
@@ -4,6 +4,9 @@
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="itext7" Version="8.0.5" />
<PackageReference Include="itext7.bouncy-castle-adapter" Version="8.0.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Prism.Wpf" Version="9.0.537" />
<PackageReference Include="Telerik.UI.for.Wpf.NetCore.Xaml" Version="2024.1.408" />
</ItemGroup>
@@ -13,4 +16,10 @@
<ItemGroup>
<Folder Include="Documents\" />
</ItemGroup>
</Project>
<!-- 字体文件嵌入资源配置 | Font files embedded resource configuration -->
<!-- 注意:需要手动将实际字体文件添加到 Fonts/ 目录 | Note: Actual font files need to be added manually to Fonts/ directory -->
<ItemGroup>
<EmbeddedResource Include="Fonts\*.otf" />
<EmbeddedResource Include="Fonts\*.ttf" />
</ItemGroup>
</Project>