|
2 | 2 | using System.Text; |
3 | 3 | using System.Text.Json; |
4 | 4 | using System.Text.RegularExpressions; |
| 5 | +using QuestPDF.Fluent; |
| 6 | +using QuestPDF.Infrastructure; |
| 7 | +using QuestPDF.Helpers; |
5 | 8 | using ClosedXML.Excel; |
6 | 9 | using Juggle.Domain.Engine; |
7 | 10 | using Juggle.Infrastructure.Persistence; |
@@ -600,11 +603,187 @@ private static byte[] ExportExcelCore(JsonElement root, Dictionary<string, DataT |
600 | 603 | return ms.ToArray(); |
601 | 604 | } |
602 | 605 |
|
603 | | - /// <summary>导出 PDF — 基于 HTML 渲染 + QuestPDF 嵌入(降级为 HTML 输出供浏览器打印)</summary> |
| 606 | + /// <summary>导出 PDF — QuestPDF 生成真实 PDF(与 HTML/Excel 同一套扩展/聚合/样式逻辑)</summary> |
604 | 607 | public async Task<byte[]> ExportPdfAsync(string layoutJson, Dictionary<string, object?>? queryParams = null) |
605 | 608 | { |
606 | | - var html = await RenderToHtml(layoutJson, queryParams); |
607 | | - return Encoding.UTF8.GetBytes(html); |
| 609 | + using var doc = JsonDocument.Parse(layoutJson); |
| 610 | + var root = doc.RootElement; |
| 611 | + var datasets = await ResolveDatasetsAsync(root, queryParams); |
| 612 | + try |
| 613 | + { |
| 614 | + return ExportPdfCore(root, datasets); |
| 615 | + } |
| 616 | + catch (Exception ex) |
| 617 | + { |
| 618 | + // PDF 生成失败(如缺少中文字体)时降级为 HTML 输出 |
| 619 | + _lastPdfError = ex.ToString(); |
| 620 | + return Encoding.UTF8.GetBytes(RenderHtmlCore(root, datasets)); |
| 621 | + } |
| 622 | + } |
| 623 | + |
| 624 | + private static bool _pdfFontReady; |
| 625 | + private static string? _lastPdfError; |
| 626 | + /// <summary>最近一次 PDF 生成失败的原因(排查用)</summary> |
| 627 | + public static string? LastPdfError => _lastPdfError; |
| 628 | + private static readonly List<FileStream> _pdfFontStreams = new(); // 保持字体文件流存活 |
| 629 | + |
| 630 | + /// <summary>注册支持中文的字体(Windows 常见字体路径)。</summary> |
| 631 | + private static void EnsurePdfFont() |
| 632 | + { |
| 633 | + if (_pdfFontReady) return; |
| 634 | + QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community; |
| 635 | + string[] candidates = |
| 636 | + { |
| 637 | + @"C:\Windows\Fonts\msyh.ttc", // 微软雅黑 |
| 638 | + @"C:\Windows\Fonts\msyh.ttf", |
| 639 | + @"C:\Windows\Fonts\simsun.ttc", // 宋体 |
| 640 | + @"C:\Windows\Fonts\Deng.ttf", // 等线 |
| 641 | + @"C:\Windows\Fonts\simhei.ttf" // 黑体 |
| 642 | + }; |
| 643 | + foreach (var path in candidates) |
| 644 | + { |
| 645 | + if (!File.Exists(path)) continue; |
| 646 | + try |
| 647 | + { |
| 648 | + var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); |
| 649 | + QuestPDF.Drawing.FontManager.RegisterFontWithCustomName("RptFont", fs); |
| 650 | + _pdfFontStreams.Add(fs); |
| 651 | + _pdfFontReady = true; |
| 652 | + return; |
| 653 | + } |
| 654 | + catch { /* 尝试下一个字体 */ } |
| 655 | + } |
| 656 | + } |
| 657 | + |
| 658 | + private static byte[] ExportPdfCore(JsonElement root, Dictionary<string, DataTable> datasets) |
| 659 | + { |
| 660 | + EnsurePdfFont(); |
| 661 | + var cellMap = BuildCellMap(root); |
| 662 | + var maxCols = GetColCount(root); |
| 663 | + var materialized = MaterializeRows(root, datasets); |
| 664 | + var colWidths = GetColWidths(root, maxCols); |
| 665 | + var templateUsed = new HashSet<(int r, int c)>(); |
| 666 | + |
| 667 | + var document = QuestPDF.Fluent.Document.Create(container => |
| 668 | + { |
| 669 | + container.Page(page => |
| 670 | + { |
| 671 | + page.Size(QuestPDF.Helpers.PageSizes.A4); |
| 672 | + page.Margin(15); |
| 673 | + page.Content().Table(table => |
| 674 | + { |
| 675 | + table.ColumnsDefinition(cols => |
| 676 | + { |
| 677 | + foreach (var w in colWidths) |
| 678 | + cols.RelativeColumn(Math.Max(1, w)); |
| 679 | + }); |
| 680 | + |
| 681 | + foreach (var rr in materialized) |
| 682 | + { |
| 683 | + var used = new HashSet<int>(); |
| 684 | + for (int ci = 0; ci < maxCols; ci++) |
| 685 | + { |
| 686 | + if (used.Contains(ci)) continue; |
| 687 | + if (rr.DataIndex == null && templateUsed.Contains((rr.TemplateRow, ci))) continue; |
| 688 | + |
| 689 | + var hasCell = cellMap.TryGetValue((rr.TemplateRow, ci), out var cell); |
| 690 | + var value = hasCell ? ResolveCellValue(cell, rr, datasets) : ""; |
| 691 | + var colspan = hasCell ? Math.Max(1, GetInt(cell, "colspan", 1)) : 1; |
| 692 | + var rowspan = hasCell ? Math.Max(1, GetInt(cell, "rowspan", 1)) : 1; |
| 693 | + if (rr.DataIndex != null) rowspan = 1; |
| 694 | + |
| 695 | + var cellBase = table.Cell(); |
| 696 | + IContainer cellEl = cellBase; |
| 697 | + if (colspan > 1 && rowspan > 1) { cellBase.ColumnSpan((uint)colspan); cellEl = cellBase.RowSpan((uint)rowspan); } |
| 698 | + else if (colspan > 1) cellEl = cellBase.ColumnSpan((uint)colspan); |
| 699 | + else if (rowspan > 1) cellEl = cellBase.RowSpan((uint)rowspan); |
| 700 | + var height = GetRowHeight(root, rr.TemplateRow); |
| 701 | + if (height > 0) cellEl = cellEl.MinHeight(height * 0.75f); |
| 702 | + |
| 703 | + cellEl = ApplyPdfCellAppearance(cellEl, cell, hasCell, rr); |
| 704 | + |
| 705 | + var align = hasCell && cell.TryGetProperty("style", out var alS) |
| 706 | + && alS.TryGetProperty("align", out var al) && al.ValueKind == JsonValueKind.String |
| 707 | + ? al.GetString() : ""; |
| 708 | + cellEl.Padding(3).Text(text => |
| 709 | + { |
| 710 | + if (align == "center") text.AlignCenter(); |
| 711 | + else if (align == "right") text.AlignRight(); |
| 712 | + else text.AlignLeft(); |
| 713 | + text.DefaultTextStyle(st => |
| 714 | + { |
| 715 | + if (_pdfFontReady) st.FontFamily("RptFont"); |
| 716 | + if (hasCell && cell.TryGetProperty("style", out var sEl)) |
| 717 | + { |
| 718 | + if (sEl.TryGetProperty("bold", out var b) && b.ValueKind == JsonValueKind.True) st.Bold(); |
| 719 | + if (sEl.TryGetProperty("italic", out var it) && it.ValueKind == JsonValueKind.True) st.Italic(); |
| 720 | + if (sEl.TryGetProperty("underline", out var un) && un.ValueKind == JsonValueKind.True) st.Underline(); |
| 721 | + if (sEl.TryGetProperty("fontSize", out var fs) && fs.ValueKind == JsonValueKind.Number) st.FontSize(fs.GetSingle()); |
| 722 | + if (sEl.TryGetProperty("color", out var cl) && cl.ValueKind == JsonValueKind.String) |
| 723 | + st.FontColor(Color.FromHex(cl.GetString()!)); |
| 724 | + } |
| 725 | + else st.FontSize(10); |
| 726 | + return st; |
| 727 | + }); |
| 728 | + text.Span(value.Replace("\n", " ")); |
| 729 | + }); |
| 730 | + |
| 731 | + for (int dc = 1; dc < colspan; dc++) used.Add(ci + dc); |
| 732 | + if (rr.DataIndex == null) |
| 733 | + for (int dr = 0; dr < rowspan; dr++) |
| 734 | + for (int dc = 0; dc < colspan; dc++) |
| 735 | + if (dr > 0 || dc > 0) |
| 736 | + templateUsed.Add((rr.TemplateRow + dr, ci + dc)); |
| 737 | + } |
| 738 | + } |
| 739 | + }); |
| 740 | + }); |
| 741 | + }); |
| 742 | + return document.GeneratePdf(); |
| 743 | + } |
| 744 | + |
| 745 | + /// <summary>PDF 单元格外观(单一链式构建,返回链上容器):边框(分边/粗细/颜色)、背景色、斑马纹。</summary> |
| 746 | + private static IContainer ApplyPdfCellAppearance(IContainer cellEl, JsonElement cell, bool hasCell, MaterializedRow rr) |
| 747 | + { |
| 748 | + // 背景 |
| 749 | + if (hasCell && HasBgColor(cell)) |
| 750 | + cellEl = cellEl.Background(Color.FromHex(cell.GetProperty("style").GetProperty("bgColor").GetString()!)); |
| 751 | + else if (rr.Zebra && rr.RowNumber % 2 == 0) |
| 752 | + cellEl = cellEl.Background(Color.FromHex("#F5F7FA")); |
| 753 | + |
| 754 | + // 边框 |
| 755 | + if (hasCell && cell.TryGetProperty("style", out var sEl) && sEl.TryGetProperty("border", out var bdEl)) |
| 756 | + { |
| 757 | + if (bdEl.ValueKind == JsonValueKind.False) return cellEl; // 无边框 |
| 758 | + if (bdEl.ValueKind != JsonValueKind.Object) |
| 759 | + { |
| 760 | + return cellEl.Border(0.6f).BorderColor(Color.FromHex("#CCCCCC")); |
| 761 | + } |
| 762 | + var bw = Math.Max(0.4f, GetInt(bdEl, "width", 1) * 0.6f); |
| 763 | + var bc = Color.FromHex( |
| 764 | + bdEl.TryGetProperty("color", out var bce) && bce.ValueKind == JsonValueKind.String ? bce.GetString()! : "#333333"); |
| 765 | + if (bdEl.TryGetProperty("top", out var bte) && bte.ValueKind == JsonValueKind.True) cellEl = cellEl.BorderTop(bw); |
| 766 | + if (bdEl.TryGetProperty("bottom", out var bbe) && bbe.ValueKind == JsonValueKind.True) cellEl = cellEl.BorderBottom(bw); |
| 767 | + if (bdEl.TryGetProperty("left", out var ble) && ble.ValueKind == JsonValueKind.True) cellEl = cellEl.BorderLeft(bw); |
| 768 | + if (bdEl.TryGetProperty("right", out var bre) && bre.ValueKind == JsonValueKind.True) cellEl = cellEl.BorderRight(bw); |
| 769 | + return cellEl.BorderColor(bc); // QuestPDF 单元格边框颜色统一应用 |
| 770 | + } |
| 771 | + return cellEl.Border(0.6f).BorderColor(Color.FromHex("#CCCCCC")); |
| 772 | + } |
| 773 | + |
| 774 | + private static List<float> GetColWidths(JsonElement root, int maxCols) |
| 775 | + { |
| 776 | + var widths = new List<float>(); |
| 777 | + if (root.TryGetProperty("cols", out var colsEl) && colsEl.ValueKind == JsonValueKind.Array) |
| 778 | + { |
| 779 | + foreach (var col in colsEl.EnumerateArray()) |
| 780 | + { |
| 781 | + var w = col.TryGetProperty("width", out var we) && we.ValueKind == JsonValueKind.Number ? we.GetInt32() : 100; |
| 782 | + widths.Add(Math.Max(10, w) * 0.75f); // px → pt |
| 783 | + } |
| 784 | + } |
| 785 | + while (widths.Count < maxCols) widths.Add(75f); |
| 786 | + return widths; |
608 | 787 | } |
609 | 788 |
|
610 | 789 | // ===== Data Source Helpers ===== |
|
0 commit comments