报告XP.ReportEngine基础类设计和接口开发。
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user