Skip to content

Commit 7aabcb6

Browse files
committed
feat: improve PDF native library diagnostics and add PdfExportOptions abstraction
- Add PdfEnvironmentInfo for cross-platform environment diagnostics with actionable install suggestions for Linux/macOS/Windows/Alpine - Add PdfExportOptions abstraction layer to decouple PDF config from WkHtmlToPdfDotNet types, with backward-compatible [Obsolete] shims - Add IPdfNativeLibraryService with CheckEnvironment() API - Add ServiceCollectionExtensions.AddMagicodesPdfExporter() for DI - Wrap native library loading failures in InvalidOperationException with full diagnostic report instead of raw NotSupportedException - Remove duplicate native libraries, keep only libwkhtmltox-next.0.dylib - Cache environment check result to avoid repeated file system scans - Add PDF environment diagnostic tests and DI integration tests - Fix netstandard2.0 compatibility (IndexOf instead of Contains)
1 parent 14b68b8 commit 7aabcb6

20 files changed

Lines changed: 1413 additions & 109 deletions

src/Magicodes.ExporterAndImporter.AspNetCore/Extensions/MagicodesBase.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Magicodes.ExporterAndImporter.Pdf;
55
using Magicodes.ExporterAndImporter.Word;
66
using Microsoft.AspNetCore.Http;
7+
using Microsoft.Extensions.DependencyInjection;
78
using Newtonsoft.Json;
89
using System;
910
using System.Data;
@@ -14,6 +15,17 @@ namespace Magicodes.ExporterAndImporter.Extensions
1415
{
1516
public class MagicodesBase
1617
{
18+
private readonly IServiceProvider _serviceProvider;
19+
20+
public MagicodesBase() : this(null)
21+
{
22+
}
23+
24+
public MagicodesBase(IServiceProvider serviceProvider)
25+
{
26+
_serviceProvider = serviceProvider;
27+
}
28+
1729
public async Task<string> ReadResponseBodyStreamAsync(Stream bodyStream)
1830
{
1931
bodyStream.Seek(0, SeekOrigin.Begin);
@@ -30,6 +42,7 @@ public async Task<bool> HandleSuccessfulReqeustAsync(HttpContext context, object
3042
var contentType = "";
3143
string filename = DateTime.Now.ToString("yyyyMMddHHmmss");
3244
byte[] result = null;
45+
var serviceProvider = context?.RequestServices ?? _serviceProvider;
3346
switch (context.Request.Headers["Magicodes-Type"])
3447
{
3548
case HttpContentMediaType.XLSXHttpContentMediaType:
@@ -42,10 +55,11 @@ public async Task<bool> HandleSuccessfulReqeustAsync(HttpContext context, object
4255
case HttpContentMediaType.PDFHttpContentMediaType:
4356
filename += ".pdf";
4457
contentType = HttpContentMediaType.PDFHttpContentMediaType;
45-
IExportFileByTemplate pdfexporter = new PdfExporter();
58+
var pdfExporter = serviceProvider?.GetService<IPdfExporter>()
59+
?? new PdfExporter(serviceProvider?.GetService<IPdfNativeLibraryService>() ?? new PdfNativeLibraryService());
4660
var tpl = await File.ReadAllTextAsync(tplPath);
4761
var obj = JsonConvert.DeserializeObject(body.ToString(), type);
48-
result = await pdfexporter.ExportBytesByTemplate(obj, tpl, type);
62+
result = await pdfExporter.ExportBytesByTemplate(obj, tpl, type);
4963
break;
5064
case HttpContentMediaType.HTMLHttpContentMediaType:
5165
filename += ".html";

src/Magicodes.ExporterAndImporter.AspNetCore/Extensions/MagicodesMiddleware.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Magicodes.ExporterAndImporter.Attributes;
22
using Microsoft.AspNetCore.Http;
3+
using Microsoft.Extensions.DependencyInjection;
34
using Microsoft.Extensions.Logging;
45
using System.IO;
56
using System.Threading.Tasks;
@@ -10,12 +11,10 @@ public class MagicodesMiddleware
1011
{
1112
private readonly RequestDelegate _next;
1213
private readonly ILogger<MagicodesMiddleware> _logger;
13-
private readonly MagicodesBase _extensions;
1414
public MagicodesMiddleware(RequestDelegate next, ILogger<MagicodesMiddleware> logger)
1515
{
1616
_next = next;
1717
_logger = logger;
18-
_extensions = new MagicodesBase();
1918
}
2019
public async Task InvokeAsync(HttpContext context)
2120
{
@@ -30,8 +29,9 @@ public async Task InvokeAsync(HttpContext context)
3029
context.Response.Body = memoryStream;
3130
await _next.Invoke(context);
3231
context.Response.Body = originalResponseBodyStream;
33-
var bodyAsText = await _extensions.ReadResponseBodyStreamAsync(memoryStream);
34-
var isSuccessful = await _extensions.HandleSuccessfulReqeustAsync(context: context, body: bodyAsText, tplPath: endpointMagicodesData.TemplatePath,
32+
var extensions = context.RequestServices.GetService<MagicodesBase>() ?? new MagicodesBase(context.RequestServices);
33+
var bodyAsText = await extensions.ReadResponseBodyStreamAsync(memoryStream);
34+
var isSuccessful = await extensions.HandleSuccessfulReqeustAsync(context: context, body: bodyAsText, tplPath: endpointMagicodesData.TemplatePath,
3535
type: endpointMagicodesData.Type);
3636
if (!isSuccessful)
3737
{

src/Magicodes.ExporterAndImporter.AspNetCore/Filters/MagicodesFilter.cs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.AspNetCore.Http;
44
using Microsoft.AspNetCore.Mvc;
55
using Microsoft.AspNetCore.Mvc.Filters;
6+
using Microsoft.Extensions.DependencyInjection;
67
using Newtonsoft.Json;
78
using Newtonsoft.Json.Converters;
89
using System.Threading.Tasks;
@@ -11,12 +12,6 @@ namespace Magicodes.ExporterAndImporter.Filters
1112
{
1213
public class MagicodesFilter : IAsyncResultFilter
1314
{
14-
private readonly MagicodesBase _extensions;
15-
public MagicodesFilter()
16-
{
17-
_extensions = new MagicodesBase();
18-
}
19-
2015
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
2116
{
2217
var result = context.Result;
@@ -26,9 +21,11 @@ public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultE
2621
var endpointMagicodesData = endpoint?.Metadata.GetMetadata<IMagicodesData>();
2722
if (endpointMagicodesData != null)
2823
{
24+
var extensions = context.HttpContext.RequestServices.GetService<MagicodesBase>()
25+
?? new MagicodesBase(context.HttpContext.RequestServices);
2926
var timeConverter = new IsoDateTimeConverter { DateTimeFormat = "yyyy-MM-dd HH:mm:ss" };
3027
var json = JsonConvert.SerializeObject(objectResult.Value, timeConverter);
31-
var isSuccessful = await _extensions.HandleSuccessfulReqeustAsync(context: context.HttpContext, body: json, tplPath: endpointMagicodesData.TemplatePath,
28+
var isSuccessful = await extensions.HandleSuccessfulReqeustAsync(context: context.HttpContext, body: json, tplPath: endpointMagicodesData.TemplatePath,
3229
type: endpointMagicodesData.Type);
3330
if (!isSuccessful)
3431
{
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("Magicodes.ExporterAndImporter.Tests")]
4+
[assembly: InternalsVisibleTo("Magicodes.IE.Tests")]

src/Magicodes.ExporterAndImporter.Pdf/IPdfExporter.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ namespace Magicodes.ExporterAndImporter.Pdf
99
/// </summary>
1010
public interface IPdfExporter : IExportListFileByTemplate, IExportFileByTemplate
1111
{
12+
Task<byte[]> ExportListBytesByTemplate<T>(ICollection<T> data, PdfExportOptions pdfExportOptions,
13+
string template) where T : class;
14+
15+
Task<byte[]> ExportBytesByTemplate<T>(T data, PdfExportOptions pdfExportOptions, string template)
16+
where T : class;
17+
1218
/// <summary>
1319
/// 导出Pdf
1420
/// </summary>
@@ -30,4 +36,4 @@ Task<byte[]> ExportListBytesByTemplate<T>(ICollection<T> data, PdfExporterAttrib
3036
Task<byte[]> ExportBytesByTemplate<T>(T data, PdfExporterAttribute pdfExporterAttribute, string template)
3137
where T : class;
3238
}
33-
}
39+
}

src/Magicodes.ExporterAndImporter.Pdf/Magicodes.IE.Pdf.csproj

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,12 @@
1111
<ItemGroup>
1212
<PackageReference Include="Haukcode.WkHtmlToPdfDotNet" Version="1.5.95" />
1313
</ItemGroup>
14+
<!-- macOS arm64 native library (Haukcode.WkHtmlToPdfDotNet provides other platforms) -->
15+
<ItemGroup>
16+
<None Include="runtimes/**" Pack="true" PackagePath="runtimes/" CopyToOutputDirectory="PreserveNewest" />
17+
</ItemGroup>
1418
<ItemGroup>
1519
<ProjectReference Include="..\Magicodes.ExporterAndImporter.Html\Magicodes.IE.Html.csproj" />
1620
</ItemGroup>
17-
<!--<ItemGroup>
18-
<None Include="runtimes/linux-x64/native/wkhtmltox.so">
19-
<Pack>true</Pack>
20-
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
21-
<PackagePath>runtimes/linux-x64/native/wkhtmltox.so</PackagePath>
22-
</None>
23-
</ItemGroup>-->
2421

2522
</Project>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System.Runtime.InteropServices;
2+
using System.Text;
3+
4+
namespace Magicodes.ExporterAndImporter.Pdf
5+
{
6+
/// <summary>
7+
/// PDF 导出环境诊断信息。
8+
/// 参考 SkiaSharp.SKImageInfo 和 PuppeteerSharp.BrowserFetcher 设计模式。
9+
/// 使用 <see cref="Check"/> 获取当前环境的完整诊断。
10+
/// </summary>
11+
/// <example>
12+
/// <code>
13+
/// var info = PdfEnvironmentInfo.Check();
14+
/// if (!info.IsAvailable)
15+
/// {
16+
/// Console.WriteLine(info); // 输出:[FAIL] osx-arm64: wkhtmltopdf native library is NOT available
17+
/// // Fix: brew install wkhtmltopdf
18+
/// }
19+
/// </code>
20+
/// </example>
21+
public sealed class PdfEnvironmentInfo
22+
{
23+
/// <summary>
24+
/// 当前平台标识(如 "osx-arm64", "linux-x64", "win-x64")。
25+
/// </summary>
26+
public string Platform { get; init; }
27+
28+
/// <summary>
29+
/// wkhtmltopdf native 库是否可用。
30+
/// 为 true 表示 PDF 导出功能可以正常工作。
31+
/// </summary>
32+
public bool IsAvailable { get; init; }
33+
34+
/// <summary>
35+
/// 找到的 native 库文件路径。
36+
/// 可能来自 runtimes/ 目录或系统路径。
37+
/// </summary>
38+
public string NativeLibraryPath { get; init; }
39+
40+
/// <summary>
41+
/// native 库文件是否存在(不论是否能加载)。
42+
/// 文件存在但加载失败通常意味着缺少系统依赖(Qt、fontconfig 等)。
43+
/// </summary>
44+
public bool NativeLibraryFileExists { get; init; }
45+
46+
/// <summary>
47+
/// native 库是否能成功加载(通过 NativeLibrary.TryLoad 探测)。
48+
/// </summary>
49+
public bool NativeLibraryLoadable { get; init; }
50+
51+
/// <summary>
52+
/// 加载失败时的详细错误信息。
53+
/// </summary>
54+
public string ErrorDetail { get; init; }
55+
56+
/// <summary>
57+
/// 针对当前平台的安装建议。
58+
/// 包含具体的安装命令。
59+
/// </summary>
60+
public string InstallSuggestion { get; init; }
61+
62+
/// <summary>
63+
/// 检查当前环境并返回诊断信息。不会抛出异常。
64+
/// </summary>
65+
/// <returns>包含完整诊断信息的 <see cref="PdfEnvironmentInfo"/> 实例。</returns>
66+
public static PdfEnvironmentInfo Check()
67+
{
68+
return PdfNativeLibraryBootstrapper.CheckEnvironment();
69+
}
70+
71+
/// <summary>
72+
/// 返回格式化的诊断报告。
73+
/// </summary>
74+
public override string ToString()
75+
{
76+
if (IsAvailable)
77+
return $"[OK] {Platform}: native library loaded from {NativeLibraryPath}";
78+
79+
var sb = new StringBuilder();
80+
sb.AppendLine($"[FAIL] {Platform}: wkhtmltopdf native library is NOT available");
81+
if (!string.IsNullOrEmpty(NativeLibraryPath))
82+
sb.AppendLine($" Found: {NativeLibraryPath}");
83+
if (!string.IsNullOrEmpty(ErrorDetail))
84+
sb.AppendLine($" Error: {ErrorDetail}");
85+
if (!string.IsNullOrEmpty(InstallSuggestion))
86+
sb.AppendLine($" Fix: {InstallSuggestion}");
87+
return sb.ToString();
88+
}
89+
}
90+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using System;
2+
3+
namespace Magicodes.ExporterAndImporter.Pdf
4+
{
5+
public class PdfExportOptions
6+
{
7+
public string DocumentTitle { get; set; }
8+
9+
public PdfOrientation Orientation { get; set; } = PdfOrientation.Landscape;
10+
11+
public PdfPageSize PageSize { get; set; } = PdfPageSize.Standard("A4");
12+
13+
public bool EnablePagesCount { get; set; }
14+
15+
public bool WriteHtml { get; set; }
16+
17+
public PdfHeaderOptions Header { get; set; }
18+
19+
public PdfFooterOptions Footer { get; set; }
20+
21+
public PdfMarginOptions Margins { get; set; }
22+
}
23+
24+
public enum PdfOrientation
25+
{
26+
Landscape = 0,
27+
Portrait = 1
28+
}
29+
30+
public class PdfPageSize
31+
{
32+
public bool IsCustom { get; set; }
33+
34+
public string StandardName { get; set; } = "A4";
35+
36+
public PdfCustomPaperSize CustomSize { get; set; }
37+
38+
public static PdfPageSize Standard(string standardName)
39+
{
40+
if (string.IsNullOrWhiteSpace(standardName))
41+
{
42+
throw new ArgumentException("A standard page size name is required.", nameof(standardName));
43+
}
44+
45+
return new PdfPageSize
46+
{
47+
IsCustom = false,
48+
StandardName = standardName,
49+
CustomSize = null
50+
};
51+
}
52+
53+
public static PdfPageSize Custom(PdfCustomPaperSize customSize)
54+
{
55+
if (customSize == null)
56+
{
57+
throw new ArgumentNullException(nameof(customSize));
58+
}
59+
60+
return new PdfPageSize
61+
{
62+
IsCustom = true,
63+
StandardName = null,
64+
CustomSize = customSize
65+
};
66+
}
67+
}
68+
69+
public class PdfCustomPaperSize
70+
{
71+
public double Width { get; set; }
72+
73+
public double Height { get; set; }
74+
75+
public PdfMeasurementUnit Unit { get; set; } = PdfMeasurementUnit.Millimeters;
76+
}
77+
78+
public enum PdfMeasurementUnit
79+
{
80+
Inches = 0,
81+
Millimeters = 1,
82+
Centimeters = 2
83+
}
84+
85+
public class PdfHeaderOptions
86+
{
87+
public int? FontSize { get; set; }
88+
89+
public string FontName { get; set; }
90+
91+
public string Left { get; set; }
92+
93+
public string Center { get; set; }
94+
95+
public string Right { get; set; }
96+
97+
public bool? Line { get; set; }
98+
99+
public double? Spacing { get; set; }
100+
101+
public string HtmlUrl { get; set; }
102+
}
103+
104+
public class PdfFooterOptions
105+
{
106+
public int? FontSize { get; set; }
107+
108+
public string FontName { get; set; }
109+
110+
public string Left { get; set; }
111+
112+
public string Center { get; set; }
113+
114+
public string Right { get; set; }
115+
116+
public bool? Line { get; set; }
117+
118+
public double? Spacing { get; set; }
119+
120+
public string HtmlUrl { get; set; }
121+
}
122+
123+
public class PdfMarginOptions
124+
{
125+
public PdfMeasurementUnit Unit { get; set; } = PdfMeasurementUnit.Millimeters;
126+
127+
public double? Top { get; set; }
128+
129+
public double? Bottom { get; set; }
130+
131+
public double? Left { get; set; }
132+
133+
public double? Right { get; set; }
134+
}
135+
}

0 commit comments

Comments
 (0)