ABP vNext + OpenXML / QuestPDF:复杂票据/发票模板与服务器端渲染

ABP vNext + OpenXML / QuestPDF:复杂票据/发票模板与服务器端渲染 ✨


📚 目录

ABP vNext + OpenXML / QuestPDF:复杂票据/发票模板与服务器端渲染 ✨0. TL;DR 🧭选型示意图(何时选 DOCX / PDF)🧪
1. 背景与目标 🎯2. 选型与边界 🧩2.1 OpenXML(DOCX)2.2 QuestPDF(PDF)
3. 模板体系(Template System)📦3.1 目录结构(多租户覆盖)模板体系概览图 🗂️
4. ABP 渲染模块设计 🧱4.1 接口与 DTO4.2 模块依赖(缓存 / Blob / 多租户 / VFS)4.3 渲染时序图 ⏱️
5. 字体注册6. 模板解析器(缓存索引,不缓存大对象;按需加载)🧠缓存/ETag 交互图 🧾
7. 数据模型 📘8. QuestPDF 渲染器 🖨️⚡️QuestPDF 渲染数据流图 🧵
9. OpenXML 渲染 📝OpenXML 处理流程图 🧩
10. 观测与 SLO 🔭11. 压测与清晰度评估 🧪📈11.1 方法级基准(BenchmarkDotNet)11.2 API 并发压测(k6)11.3 压测覆盖面图 📊11.4 清晰度面板要点 👁️
12. 可复现实操 🛠️13. 安全与合规 🛡️14. 选型建议(吞吐 × 清晰度 × 体积)🧮15. 依赖与建议 📎


0. TL;DR 🧭

OpenXML(DOCX):强模板化、可二次编辑;通过 SDT/书签 占位;表头跨页靠
w:tblHeader
;本版新增 多行表头支持嵌套 SDT 安全替换QuestPDF(PDF):服务端高吞吐;
Header/Content/Footer

Table.Header/Footer
页码 APIZXing → SVG 矢量条码;本版新增 流式输出固定文化格式化ABP 模块化封装:多租户模板覆盖;冷模板走 Blob,缓存仅存索引/ETag;支持 VFS(内嵌+覆盖)。中文/体积:禁用环境字体,显式注册 Noto/思源;仅携带必要字重;PDF 压缩 + 图片 ≥300DPI;极致体积可构建期做字体子集化。

选型示意图(何时选 DOCX / PDF)🧪


1. 背景与目标 🎯

业务诉求:复杂明细表、跨页表头、页眉页脚(骑缝章/页码)、二维码/条码、印章/水印、连续打印、多租户品牌定制。
技术目标:在 ABP 中沉淀通用“票据/发票渲染”模块模板仓库,实现性能稳定、版式可控、合规可维护


2. 选型与边界 🧩

2.1 OpenXML(DOCX)

适合:强模板化与需二次编辑。要点:SDT(内容控件)或书签占位;行模板克隆;
w:tblHeader
跨页表头;页眉/页脚资源替换。新增:本版 SDT 替换避免误删嵌套 SDT 文本,兼容
SdtContentCell/Paragraph/Run
;支持多行表头

2.2 QuestPDF(PDF)

适合:服务端直接产出 PDF 的高并发批量场景。要点
Table.Header/Footer
跨页;页码 API;ZXing 生成 纯二维码 SVG 注入;禁用环境字体并显式注册。新增流式输出 GeneratePdf(Stream) 降低内存峰值;金额/数量固定
zh-CN
文化。


3. 模板体系(Template System)📦

3.1 目录结构(多租户覆盖)


/templates
  /default
    invoice.docx
    invoice.qtpl.cs
    assets/
      fonts/NotoSansSC-Regular.ttf
      images/logo.png
      images/stamp.png
  /tenant-acme
    invoice.docx        # 覆盖
    invoice.qtpl.cs     # 覆盖

解析顺序
tenant → default
(未覆盖回退)。存储:冷模板存 Blob(MinIO/S3)(带 ETag/版本);分布式缓存仅存索引/ETag(不缓存大字节);ABP VFS 内嵌默认模板/字体,方便本地与租户覆盖。

模板体系概览图 🗂️


4. ABP 渲染模块设计 🧱

模块名示例:
Abp.Reporting

4.1 接口与 DTO


public record RenderRequest(
    string TemplateName,                  // e.g. "invoice"
    string Format,                        // "pdf" | "docx"
    Guid? TenantId,
    object Payload,                       // InvoiceModel
    IDictionary<string, byte[]>? Assets   // 动态章印等
);

public record RenderResult(string ContentType, byte[] Bytes);

public interface IInvoiceRenderer
{
    Task<RenderResult> RenderAsync(RenderRequest request, CancellationToken ct = default);
}

4.2 模块依赖(缓存 / Blob / 多租户 / VFS)


[DependsOn(
  typeof(AbpCachingModule),
  typeof(AbpBlobStoringModule),
  typeof(AbpVirtualFileSystemModule))]
public class AbpReportingModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        context.Services.AddTransient<IInvoiceRenderer, InvoiceRenderer>();
        context.Services.AddSingleton<ITemplateResolver, BlobTemplateResolver>();
        context.Services.AddSingleton<AppFonts>(); // 统一字体注册与获取
    }
}

4.3 渲染时序图 ⏱️


5. 字体注册


public sealed class AppFonts
{
    public FontDescriptor CjkRegular { get; }
    public FontDescriptor? CjkBold { get; }

    public AppFonts(IVirtualFileProvider vfs)
    {
        QuestPDF.Settings.UseEnvironmentFonts = false;

        var fontsDir = Path.Combine(AppContext.BaseDirectory, "templates", "default", "assets", "fonts");
        if (Directory.Exists(fontsDir))
            QuestPDF.Settings.FontDiscoveryPaths.Add(fontsDir);

        var vfsFile = vfs.GetFileInfo("/templates/default/assets/fonts/NotoSansSC-Regular.ttf");
        using var stream = vfsFile.Exists
            ? vfsFile.ReadAsStream()
            : File.OpenRead(Path.Combine(fontsDir, "NotoSansSC-Regular.ttf"));

        CjkRegular = FontManager.RegisterFont(stream);

        var boldPath = Path.Combine(fontsDir, "NotoSansSC-Bold.ttf");
        if (File.Exists(boldPath))
            using (var bs = File.OpenRead(boldPath))
                CjkBold = FontManager.RegisterFont(bs);
    }
}

6. 模板解析器(缓存索引,不缓存大对象;按需加载)🧠


public sealed class TemplateIndex
{
    public string? DocxPath { get; init; }       // "default/invoice.docx"
    public string? QuestDslPath { get; init; }   // "default/invoice.qtpl.cs"
    public string ETag { get; init; } = "";      // 版本/校验
}

public interface ITemplateResolver
{
    Task<(TemplateIndex Index, byte[]? DocxBytes, string? Dsl)>
        ResolveAsync(string name, Guid? tenantId, CancellationToken ct,
                     bool needDocx = false, bool needDsl = true);
}

public sealed class BlobTemplateResolver : ITemplateResolver
{
    private readonly IBlobContainer _blob;
    private readonly IDistributedCache<TemplateIndex> _cache;

    public async Task<(TemplateIndex, byte[]?, string?)> ResolveAsync(
        string name, Guid? tenantId, CancellationToken ct,
        bool needDocx = false, bool needDsl = true)
    {
        var key = $"{tenantId ?? Guid.Empty}:{name}";
        var index = await _cache.GetOrAddAsync(key, async () =>
        {
            var prefix = tenantId.HasValue ? $"tenant-{tenantId}/" : "default/";
            return new TemplateIndex
            {
                DocxPath = $"{prefix}{name}.docx",
                QuestDslPath = $"{prefix}{name}.qtpl.cs",
                ETag = await GetETagAsync($"{prefix}{name}.docx", ct) ?? Guid.NewGuid().ToString("N")
            };
        }, token: ct);

        byte[]? docx = null;
        string? dsl = null;

        if (needDocx && index.DocxPath is not null)
            docx = await _blob.GetAllBytesOrNullAsync(index.DocxPath, ct);

        if (needDsl && index.QuestDslPath is not null)
        {
            var dslBytes = await _blob.GetAllBytesOrNullAsync(index.QuestDslPath, ct);
            dsl = dslBytes is null ? null : Encoding.UTF8.GetString(dslBytes);
        }

        // TODO: 检测 Blob ETag 变化时刷新缓存索引
        return (index, docx, dsl);
    }

    private Task<string?> GetETagAsync(string path, CancellationToken ct)
        => Task.FromResult<string?>(null);
}

缓存/ETag 交互图 🧾


7. 数据模型 📘


public class InvoiceModel
{
    public string Title { get; set; } = "发票";
    public string InvoiceNo { get; set; } = "";
    public DateTime IssueDate { get; set; }
    public List<InvoiceItem> Items { get; set; } = new();
    public decimal Total => Items.Sum(i => i.Amount);
}

public class InvoiceItem
{
    public string Code { get; set; } = "";
    public string Name { get; set; } = "";
    public decimal Qty { get; set; }
    public decimal Amount { get; set; }
}

8. QuestPDF 渲染器 🖨️⚡️

流式输出
GeneratePdf(Stream)
;Web API 可直写
Response.Body
ZXing 纯二维码 SVG(无标签文字);固定文化:金额/数量用
zh-CN
固定矩阵尺寸 + 外层容器固定宽高:视觉一致,利于批量打印。


using System.Globalization;
using QuestPDF.Fluent;
using QuestPDF.Infrastructure;
using ZXing;
using ZXing.QrCode;
using ZXing.Rendering;

public class InvoiceRenderer : IInvoiceRenderer
{
    private static readonly CultureInfo InvoiceCulture = CultureInfo.GetCultureInfo("zh-CN");

    private readonly ITemplateResolver _resolver;
    private readonly ICurrentTenant _currentTenant;
    private readonly AppFonts _fonts;

    public InvoiceRenderer(ITemplateResolver resolver, ICurrentTenant currentTenant, AppFonts fonts)
    { _resolver = resolver; _currentTenant = currentTenant; _fonts = fonts; }

    public async Task<RenderResult> RenderAsync(RenderRequest req, CancellationToken ct = default)
    {
        // 按需:PDF 仅需 DSL(若你使用外部 DSL)
        var (_, _, _) = await _resolver.ResolveAsync(req.TemplateName, req.TenantId ?? _currentTenant.Id, ct,
                                                    needDocx: false, needDsl: true);
        if (!req.Format.Equals("pdf", StringComparison.OrdinalIgnoreCase))
            throw new NotSupportedException("Only PDF path shown here");

        var m = (InvoiceModel)req.Payload;

        static string MakeQrSvg(string text, int size = 180)
        {
            var matrix = new QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size);
            return new SvgRenderer().Render(matrix, BarcodeFormat.QR_CODE, null, null).Content; // 纯二维码
        }

        using var ms = new MemoryStream(capacity: 64 * 1024);  // 流式,降低内存峰值

        Document.Create(container =>
        {
            container.Page(p =>
            {
                p.Size(PageSizes.A4);
                p.Margin(36);
                p.DefaultTextStyle(t => t.Font(_fonts.CjkRegular).FontSize(11));

                p.Header().Row(r =>
                {
                    r.RelativeItem().Text($"{m.Title} - {m.InvoiceNo}").SemiBold().FontSize(14);
                    r.AutoItem().Width(90).Height(90).Svg(MakeQrSvg(m.InvoiceNo, 180));
                });

                p.Content().Table(t =>
                {
                    t.ColumnsDefinition(c =>
                    {
                        c.ConstantColumn(60);
                        c.RelativeColumn(3);
                        c.RelativeColumn(1);
                        c.RelativeColumn(1);
                    });

                    t.Header(h =>
                    {
                        h.Cell().Text("编号").SemiBold();
                        h.Cell().Text("名称").SemiBold();
                        h.Cell().AlignRight().Text("数量").SemiBold();
                        h.Cell().AlignRight().Text("金额").SemiBold();
                    });

                    foreach (var it in m.Items)
                    {
                        t.Cell().Text(it.Code);
                        t.Cell().Text(it.Name);
                        t.Cell().AlignRight().Text(it.Qty.ToString("N0", InvoiceCulture));
                        t.Cell().AlignRight().Text(it.Amount.ToString("N2", InvoiceCulture));
                    }

                    t.Footer(f =>
                    {
                        f.Cell().ColumnSpan(3).AlignRight().Text("合计:").SemiBold();
                        f.Cell().AlignRight().Text(m.Total.ToString("N2", InvoiceCulture)).SemiBold();
                    });
                });

                p.Footer().AlignRight().Text(t =>
                {
                    t.Span("第 "); t.CurrentPageNumber();
                    t.Span(" / "); t.TotalPages();
                    t.Span(" 页");
                });
            });
        }).GeneratePdf(ms);

        return new RenderResult("application/pdf", ms.ToArray());
    }
}

QuestPDF 渲染数据流图 🧵

API 直写(可选)
Document.Create(...).GeneratePdf(HttpContext.Response.Body);
(需先写
Content-Type: application/pdf
,并延后
Content-Length
)。


9. OpenXML 渲染 📝

MemoryStream 姿势正确SDT 替换(鲁棒版):区分
SdtContentRun/Paragraph/Cell/Block
,仅清当前容器文本(不影响嵌套 SDT),多行用
<w:br/>
行模板:优先 行级 SDT(Tag=RowTemplate)多行表头
headerRowCount
模板行就地复用:模板行填第 1 条,其余克隆,避免遗留空模板行。


using System.Globalization;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using DocumentFormat.OpenXml;

public static class DocxMerge
{
    public static byte[] Merge(byte[] template, InvoiceModel m, int headerRowCount = 1)
    {
        using var ms = new MemoryStream(template, writable: true);
        ms.Position = 0;
        using var doc = WordprocessingDocument.Open(ms, true);
        var main = doc.MainDocumentPart!.Document;

        // 1) 全文 SDT 替换
        foreach (var sdt in main.Descendants<SdtElement>())
        {
            var tag = sdt.SdtProperties?.GetFirstChild<Tag>()?.Val?.Value;
            if (string.IsNullOrWhiteSpace(tag)) continue;

            switch (tag)
            {
                case "InvoiceNo": ReplaceSdtText(sdt, m.InvoiceNo); break;
                case "IssueDate": ReplaceSdtText(sdt, m.IssueDate.ToString("yyyy-MM-dd", CultureInfo.GetCultureInfo("zh-CN"))); break;
                // ...
            }
        }

        // 2) 明细表处理
        var table = FindTargetTable(main, "DetailTable");
        if (table != null)
        {
            // 只保留“表头 + 行模板”
            while (table.Elements<TableRow>().Count() > headerRowCount + 1)
                table.RemoveChild(table.Elements<TableRow>().Last());

            var rowTpl = FindRowTemplate(table, headerRowCount);
            var items = m.Items.ToList();

            if (items.Count > 0)
            {
                FillRow(rowTpl, items[0]); // 模板行填第一条
                for (int i = 1; i < items.Count; i++)
                {
                    var row = (TableRow)rowTpl.CloneNode(true);
                    FillRow(row, items[i]);
                    table.AppendChild(row);
                }
            }
            MarkHeaderRows(table, headerRowCount);
        }

        main.Save();
        return ms.ToArray();
    }

    static void FillRow(TableRow row, InvoiceItem it)
    {
        foreach (var sdt in row.Descendants<SdtElement>())
        {
            var tag = sdt.SdtProperties?.GetFirstChild<Tag>()?.Val?.Value;
            if (tag == "Item.Code") ReplaceSdtText(sdt, it.Code);
            else if (tag == "Item.Name") ReplaceSdtText(sdt, it.Name);
            else if (tag == "Item.Qty") ReplaceSdtText(sdt, it.Qty.ToString("N0", CultureInfo.GetCultureInfo("zh-CN")));
            else if (tag == "Item.Amount") ReplaceSdtText(sdt, it.Amount.ToString("N2", CultureInfo.GetCultureInfo("zh-CN")));
        }
    }

    // —— SDT 文本替换(鲁棒版) ——
    static void ReplaceSdtText(SdtElement sdt, string value)
    {
        static void AppendLines(Run run, string text)
        {
            var lines = (text ?? string.Empty).Split(new[] { "
", "
" }, StringSplitOptions.None);
            for (int i = 0; i < lines.Length; i++)
            {
                run.AppendChild(new Text(lines[i]) { Space = SpaceProcessingModeValues.Preserve });
                if (i < lines.Length - 1) run.AppendChild(new Break());
            }
        }

        if (sdt.GetFirstChild<SdtContentRun>() is SdtContentRun runContent)
        {
            foreach (var t in runContent.Descendants<Text>()
                     .Where(t => !t.Ancestors<SdtElement>().Any(e => e != sdt)).ToList())
                t.Remove();
            var run = runContent.GetFirstChild<Run>() ?? runContent.AppendChild(new Run());
            AppendLines(run, value);
            return;
        }

        if (sdt.GetFirstChild<SdtContentParagraph>() is SdtContentParagraph pContent)
        {
            foreach (var t in pContent.Descendants<Text>()
                     .Where(t => !t.Ancestors<SdtElement>().Any(e => e != sdt)).ToList())
                t.Remove();
            var para = pContent.GetFirstChild<Paragraph>() ?? pContent.AppendChild(new Paragraph());
            var run = para.GetFirstChild<Run>() ?? para.AppendChild(new Run());
            AppendLines(run, value);
            return;
        }

        if (sdt.GetFirstChild<SdtContentCell>() is SdtContentCell cellContent)
        {
            foreach (var t in cellContent.Descendants<Text>()
                     .Where(t => !t.Ancestors<SdtElement>().Any(e => e != sdt)).ToList())
                t.Remove();
            var cell = cellContent.GetFirstChild<TableCell>() 
                       ?? cellContent.AppendChild(new TableCell(new TableCellProperties()));
            var para = cell.Descendants<Paragraph>().FirstOrDefault() ?? cell.AppendChild(new Paragraph());
            var run = para.GetFirstChild<Run>() ?? para.AppendChild(new Run());
            AppendLines(run, value);
            return;
        }

        if (sdt.GetFirstChild<SdtContentBlock>() is SdtContentBlock blockContent)
        {
            foreach (var t in blockContent.Descendants<Text>()
                     .Where(t => !t.Ancestors<SdtElement>().Any(e => e != sdt)).ToList())
                t.Remove();
            var para = blockContent.GetFirstChild<Paragraph>() ?? blockContent.AppendChild(new Paragraph());
            var run = para.GetFirstChild<Run>() ?? para.AppendChild(new Run());
            AppendLines(run, value);
            return;
        }

        // 兜底
        foreach (var t in sdt.Descendants<Text>()
                 .Where(t => !t.Ancestors<SdtElement>().Any(e => e != sdt)).ToList())
            t.Remove();
        var fallbackPara = sdt.Descendants<Paragraph>().FirstOrDefault() ?? sdt.AppendChild(new Paragraph());
        var fallbackRun = fallbackPara.GetFirstChild<Run>() ?? fallbackPara.AppendChild(new Run());
        AppendLines(fallbackRun, value);
    }

    // —— 目标表定位:优先表级 SDT(Tag=DetailTable),否则回退第一个表 ——
    static Table? FindTargetTable(Document doc, string sdtTag)
    {
        var sdt = doc.Descendants<SdtElement>()
                     .FirstOrDefault(x => x.SdtProperties?.GetFirstChild<Tag>()?.Val?.Value == sdtTag);
        return sdt?.Descendants<Table>().FirstOrDefault()
            ?? doc.Descendants<Table>().FirstOrDefault();
    }

    // —— 行模板定位:优先行级 SDT(Tag=RowTemplate),否则回退为 headerRowCount+1 行 ——
    static TableRow FindRowTemplate(Table table, int headerRowCount)
    {
        var rowSdt = table.Descendants<SdtElement>()
            .FirstOrDefault(x => x.SdtProperties?.GetFirstChild<Tag>()?.Val?.Value == "RowTemplate");
        if (rowSdt != null)
            return rowSdt.Ancestors<TableRow>().First();

        return table.Elements<TableRow>().ElementAt(headerRowCount); // 紧随表头之后
    }

    // —— 多行表头标记(默认 1 行,可配置,已修正 Math.Max/Min) ——
    static void MarkHeaderRows(Table table, int headerRowCount)
    {
        headerRowCount = Math.Max(1, Math.Min(headerRowCount, table.Elements<TableRow>().Count()));
        foreach (var row in table.Elements<TableRow>().Take(headerRowCount))
        {
            var trPr = row.GetFirstChild<TableRowProperties>() ?? row.PrependChild(new TableRowProperties());
            trPr.TableHeader = new TableHeader() { Val = OnOffOnlyValues.On };
        }
    }
}

OpenXML 处理流程图 🧩


10. 观测与 SLO 🔭

指标:渲染 p50/p95、失败率、输出大小、字体注册/缓存命中率;追踪
InvoiceId/TenantId/TemplateVersion
注入日志与 Trace;告警:渲染超时、字体缺失、图片 DPI 过低、模板版本不匹配。


11. 压测与清晰度评估 🧪📈

11.1 方法级基准(BenchmarkDotNet)


[MemoryDiagnoser]
public class RenderBench
{
    private InvoiceModel _m10, _m100, _m1000;
    private byte[] _docxTpl;

    [GlobalSetup]
    public void Setup()
    {
        // 初始化模型与模板字节
    }

    [Benchmark] public byte[] Pdf_10()    => RenderPdf(_m10);
    [Benchmark] public byte[] Pdf_100()   => RenderPdf(_m100);
    [Benchmark] public byte[] Pdf_1000()  => RenderPdf(_m1000);

    [Benchmark] public byte[] Docx_10()   => DocxMerge.Merge(_docxTpl, _m10, headerRowCount: 1);
    [Benchmark] public byte[] Docx_100()  => DocxMerge.Merge(_docxTpl, _m100, headerRowCount: 1);
    [Benchmark] public byte[] Docx_1000() => DocxMerge.Merge(_docxTpl, _m1000, headerRowCount: 1);

    private byte[] RenderPdf(InvoiceModel m)
    {
        // 复用 QuestPDF 渲染逻辑(也可直写 MemoryStream 后 ToArray)
        return Array.Empty<byte>();
    }
}

11.2 API 并发压测(k6)


import http from 'k6/http';
import { check, sleep } from 'k6';
export let options = { vus: 16, duration: '60s' };

export default function () {
  const url = 'http://localhost:8080/api/reporting/render?format=pdf&template=invoice';
  const payload = JSON.stringify({ /* InvoiceModel */ });
  const params = { headers: { 'Content-Type': 'application/json' } };
  const res = http.post(url, payload, params);
  check(res, { 'status 200': (r) => r.status === 200 });
  sleep(0.2);
}

11.3 压测覆盖面图 📊

11.4 清晰度面板要点 👁️

条码/二维码:PDF 端优先 SVG;DOCX 端若用位图建议 300–600DPI。表格线与字体:表格线 ≥0.5pt;对比不同中文字体(Noto/思源)的 OCR/扫码识别率与金额列数字对齐效果。


12. 可复现实操 🛠️

1)创建 ABP 工程


dotnet tool install -g Volo.Abp.Cli
abp new Acme.Invoice -t app -u mvc

2)添加
Abp.Reporting
模块(可选)


abp add-module Abp.Reporting --new --add-to-solution-file

3)启用 Blob(MinIO)与缓存


appsettings.json
配置 MinIO endpoint 与凭据;依赖
AbpBlobStoringModule

AbpCachingModule

AbpVirtualFileSystemModule
仅缓存 TemplateIndex;大对象走 Blob;ETag 变化触发索引刷新。

4)拷入模板仓库


/templates
上传至 Blob:
default/

tenant-<id>/
;默认模板/字体内嵌 VFS(便于本地与覆盖)。

5)注册字体

启动禁用环境字体;
FontDiscoveryPaths
绝对路径;VFS 回退;显式
FontManager.RegisterFont
获取
FontDescriptor

6)运行压测

BDN:
dotnet run -c Release
;k6:
k6 run script.js
(或 Docker)。


13. 安全与合规 🛡️

字体许可:优选 Noto/思源(OFL);仅携带必要字重;若需极致体积,构建阶段外部子集化。模板安全:上传管道建议加 宏/嵌入对象拦截、图片病毒扫描(如 ClamAV)、字体白名单校验。回滚:模板版本化与快照;渲染失败回退上版;PDF 路径异常可降级输出“简版 PDF(纯表格)”。


14. 选型建议(吞吐 × 清晰度 × 体积)🧮

优先 QuestPDF,辅以 OpenXML

需要 DOCX 可编辑/留档 → OpenXML;高并发批量输出 → QuestPDF(跨页、表头、矢量条码与资源复用更可控)。


15. 依赖与建议 📎

PDF:QuestPDF(MIT)条码:ZXing.Net(Apache-2.0)→ SVG;位图回退可用 SkiaSharp/ImageSharp 绑定字体:Noto/思源(SIL OFL 1.1)基准:BenchmarkDotNet压测:Grafana k6对象存储:MinIO/S3(ABP BlobStoring Provider)观测:Serilog + OpenTelemetry(可选)

© 版权声明

相关文章

暂无评论

none
暂无评论...