using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Report; using GB5Shared.Export; namespace GB5Shared.Export.NativePdf { /// /// Native (PdfSharpCore, no browser) PDF export — an additional, explicit option /// (ReportFormat.PdfNative) alongside the existing Puppeteer-based PDF pipeline, not a /// replacement for it. See Phase 11/12 of the ReportViewer plan for the spike that justified this /// and the production design. Structured the same way as — /// buffer rows, reshape to title-keyed dictionaries, build the multi-level group tree via /// , drive a dedicated renderer. /// public sealed class NativePdfExport : INativePdfExport { public async Task Export( IAsyncEnumerable> records, IAsyncEnumerable mainHeaders, IAsyncEnumerable columnTitles, IAsyncEnumerable columnFieldNames, Stream outputStream, LoginDTO login, byte[]? logoBytes, CancellationToken cancellationToken = default, List? reportViewFields = null, byte reportViewPageFormat = 4, byte reportViewPageOrientation = 0) { reportViewFields ??= new List(); var headers = new List(); await foreach (var h in mainHeaders.WithCancellation(cancellationToken)) headers.Add(h); var titles = new List(); await foreach (var t in columnTitles.WithCancellation(cancellationToken)) titles.Add(t); var fieldNames = new List(); await foreach (var f in columnFieldNames.WithCancellation(cancellationToken)) fieldNames.Add(f); // A group/subtotal/grandtotal/merge key field can be hidden from the visible column list // (ISDISPLAY) yet still needed BY TITLE below (GroupTreeBuilder/ComputeAggregateRow) — same // fix and reasoning as ExcelExport.cs's identical block. reportViewFields must be the FULL // field list here (not display-filtered) for this to find anything — see the caller. var structuralOnlyFields = reportViewFields .Where(f => (f.ReportViewFieldsIsGroupcolumn == 0 || f.ReportViewFieldsIsSubTotal == 0 || f.ReportViewFieldsIsGrandTotal == 0 || f.ReportViewFieldsIsMergeColumn == 0) && !string.IsNullOrWhiteSpace(f.ReportViewFieldsFieldTitle) && !titles.Contains(f.ReportViewFieldsFieldTitle, StringComparer.OrdinalIgnoreCase)) .GroupBy(f => f.ReportViewFieldsFieldTitle, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .ToList(); // Buffered, title-keyed rows — same shape ExcelExport.cs and the Phase 11 spike both use, // needed to build GroupTreeBuilder's tree. ClosedXML/PdfSharpCore both fully materialize // their output regardless, so this isn't a new memory-cost regression over what already // exists for grouped Excel exports. var rowsByTitle = new List>(); await foreach (var record in records.WithCancellation(cancellationToken)) { var rowByTitle = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int col = 0; col < titles.Count && col < fieldNames.Count; col++) { record.TryGetValue(fieldNames[col], out var v); rowByTitle[titles[col]] = v!; } foreach (var f in structuralOnlyFields) { record.TryGetValue(f.ReportVsFieldsFieldName, out var v); rowByTitle[f.ReportViewFieldsFieldTitle] = v!; } rowsByTitle.Add(rowByTitle); } var groupColumns = reportViewFields .Where(f => f.ReportViewFieldsIsGroupcolumn == 0) .OrderBy(f => f.ReportViewFieldsDisplaySlNo) .ToList(); var groupTitleSet = new HashSet( groupColumns.Select(g => g.ReportViewFieldsFieldTitle ?? ""), StringComparer.OrdinalIgnoreCase); var subTotalFields = reportViewFields.Where(f => f.ReportViewFieldsIsSubTotal == 0).ToList(); var grandTotalFields = reportViewFields.Where(f => f.ReportViewFieldsIsGrandTotal == 0).ToList(); bool hasGrouping = groupColumns.Count > 0; // Actual PDF table columns: RowLevel==0 fields that are NOT a group column — mirrors // ReportExport.cs's HTML/PDF pipeline's NonGroupHeaders exactly (a group column renders as // a full-width banner via WriteGroupNode instead; a RowLevel>0 field renders as an indented // sub-line under its parent column instead — neither belongs in the grid as its own // column). Passing the FULL unfiltered titles/fieldNames here (as before) would draw every // group/row-level field as an extra column too, which is what silently blew the table past // the printable page width. var tableColumns = reportViewFields .Where(f => f.ReportViewFieldsRowLevel == 0 && !groupTitleSet.Contains(f.ReportViewFieldsFieldTitle ?? "")) .OrderBy(f => f.ReportViewFieldsDisplaySlNo) .ToList(); var tableTitles = tableColumns.Select(f => f.ReportViewFieldsFieldTitle ?? "").ToList(); var tableFieldNames = tableColumns.Select(f => f.ReportVsFieldsFieldName ?? "").ToList(); Dictionary ComputeAggregateRow(IEnumerable> rows, List fields) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); var materialized = rows as IList> ?? rows.ToList(); foreach (var f in fields) { var title = f.ReportViewFieldsFieldTitle ?? ""; var agg = new ColumnAggregator(f.ReportViewFieldsAggregationType, f.ReportVsFieldsFieldType); foreach (var row in materialized) { row.TryGetValue(title, out var v); agg.Accumulate(v); } result[title] = agg.Result; } return result; } var renderer = new NativePdfRenderer(tableTitles, tableFieldNames, reportViewFields, ReportTheme.FromLogin(login), logoBytes, reportViewPageFormat, reportViewPageOrientation); renderer.Start(headers); if (hasGrouping) { var groups = GroupTreeBuilder.BuildGroupNodes( rowsByTitle, groupColumns, 0, groupRows => subTotalFields.Count > 0 ? ComputeAggregateRow(groupRows, subTotalFields) : null); foreach (var node in groups) renderer.WriteGroupNode(node); } else { foreach (var row in rowsByTitle) renderer.DrawDataRow(row); } if (grandTotalFields.Count > 0) renderer.DrawTotalsRow("Grand Total", ComputeAggregateRow(rowsByTitle, grandTotalFields), isGrandTotal: true); renderer.Finish(outputStream); } } }