using System.Globalization;
using GB5Shared.DTO.Report;
using PdfSharpCore.Drawing;
using PdfSharpCore.Fonts;
using PdfSharpCore.Pdf;
namespace GB5Shared.Export.NativePdf
{
///
/// Draws a report directly via PdfSharpCore's XGraphics canvas — no browser, no chunk-then-merge.
/// Production version of the Phase 11 spike's renderer; see that phase's memory/plan notes for the
/// two real findings that shaped this: PdfSharpCore's own XTextFormatter word-wrap helper has an
/// unexplained justify-like spacing bug (confirmed, persists even with explicit Left alignment) — this
/// class uses a hand-rolled -based wrap instead — and pagination is a single
/// cursor check (), confirmed correct hundreds of pages into a real render.
///
/// Column set passed in must already exclude group columns (rendered as full-width banners via
/// ) — reportViewFields is still the FULL field list, used to look
/// up per-title formatting metadata for group values and row-level sub-rows, neither of which has a
/// slot in _columns.
///
public sealed class NativePdfRenderer
{
private sealed class Col
{
public required string Title;
public required string FieldName;
public byte FieldType;
public byte Alignment;
public string? CalFormat;
public double Width = 80;
}
// A ReportViewFieldsRowLevel > 0 field — rendered as an indented sub-line under its nearest
// preceding RowLevel==0 field's column (ParentTitle), exactly mirroring ReportExport.cs's HTML/
// PDF pipeline's mainFields/subFields/subToParentMap + renderSubRows/renderGroupNodes helpers.
private sealed class SubField
{
public required string Title;
public required string ParentTitle;
public int Level;
public byte FieldType;
public string? CalFormat;
}
private readonly List
_columns;
private readonly List _subFields;
private readonly Dictionary _metaByTitle;
private readonly ReportTheme _theme;
private readonly byte[]? _logoBytes;
private readonly double _pageWidth;
private readonly double _pageHeight;
private PdfDocument _doc = null!;
private PdfPage _page = null!;
private XGraphics _gfx = null!;
private double _y;
private List? _headerRows;
private DateTime _generatedOn;
public int PageCount { get; private set; }
private const double MarginLeft = 40, MarginTop = 40, MarginBottom = 44, MarginRight = 40, RowHeight = 16;
// Deliberately NOT static readonly fields: a class's static field initializers run as part of
// its type initializer, which fires on first use — strictly BEFORE this instance constructor's
// body, including before EnsureFontResolverRegistered() below. Constructing an XFont that early
// makes PdfSharpCore "use" its (unset) default font-resolver state, and it then throws "Must not
// change font resolver after it was once used" the moment this constructor tries to register
// LatoFontResolver. Confirmed by hitting this exact exception before making these instance
// fields assigned here, after the resolver is guaranteed registered.
private readonly XFont _titleFont;
private readonly XFont _headerFont;
private readonly XFont _bodyFont;
private readonly XFont _boldBodyFont;
private readonly XFont _footerFont;
private static readonly object FontResolverLock = new();
private static bool _fontResolverRegistered;
public NativePdfRenderer(List titles, List fieldNames, List? reportViewFields,
ReportTheme theme, byte[]? logoBytes, byte pageFormat = 4, byte pageOrientation = 0)
{
EnsureFontResolverRegistered();
_theme = theme;
_logoBytes = logoBytes;
reportViewFields ??= new();
var (pageWidth, pageHeight) = GetPageDimensions(pageFormat);
if (pageOrientation == 1) (pageWidth, pageHeight) = (pageHeight, pageWidth);
_pageWidth = pageWidth;
_pageHeight = pageHeight;
_metaByTitle = reportViewFields
.Where(f => !string.IsNullOrEmpty(f.ReportViewFieldsFieldTitle))
.GroupBy(f => f.ReportViewFieldsFieldTitle, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
_columns = new List
();
for (int i = 0; i < titles.Count && i < fieldNames.Count; i++)
{
_metaByTitle.TryGetValue(titles[i], out var meta);
_columns.Add(new Col
{
Title = titles[i],
FieldName = fieldNames[i],
FieldType = meta?.ReportVsFieldsFieldType ?? byte.MaxValue,
Alignment = meta?.ReportViewFieldsAlignment ?? 0,
CalFormat = meta?.ReportViewFieldsCalFormat,
// A configured FieldWidth (Excel/Grid's own convention) is respected as a starting
// ratio; see the auto-fit scaling below — PdfSharpCore draws at fixed absolute
// coordinates (no CSS-style flexible table layout the way the Puppeteer/HTML pipeline
// gets for free), so an unscaled column set silently overflows the page edge instead
// of wrapping or shrinking.
Width = meta?.ReportViewFieldsFieldWidth > 0 ? meta.ReportViewFieldsFieldWidth : 80,
});
}
double totalWidth = _columns.Sum(c => c.Width);
double availableWidth = _pageWidth - MarginLeft - MarginRight;
double widthScale = 1.0;
if (totalWidth > 0 && availableWidth > 0)
{
widthScale = availableWidth / totalWidth;
foreach (var c in _columns) c.Width *= widthScale;
}
// A many-column report (this whole renderer's reason for existing) can still shrink each
// column below what its own body text needs even after the width scale above — clipping
// (see DrawClippedString) guarantees that no longer bleeds into the next column, but a
// right-aligned number would still lose its LEADING digits to the clip, which is worse than
// a smaller-but-complete number. Shrinking the font alongside a genuinely tight column set
// keeps values fully readable instead of silently truncated; a wide-open column set (few
// columns) never grows the font past its normal size, only ever shrinks it.
double fontScale = Math.Min(1.0, widthScale);
double bodySize = Math.Max(6, 8 * fontScale);
double headerSize = Math.Max(6.5, 9 * fontScale);
double footerSize = Math.Max(6, 8 * fontScale);
_titleFont = new XFont("Lato", 16, XFontStyle.Bold);
_headerFont = new XFont("Lato", headerSize, XFontStyle.Bold);
_bodyFont = new XFont("Lato", bodySize, XFontStyle.Regular);
_boldBodyFont = new XFont("Lato", bodySize, XFontStyle.Bold);
_footerFont = new XFont("Lato", footerSize, XFontStyle.Regular);
// Parent lookup mirrors ReportExport.cs's HtmlExportCore/PdfExportCore exactly: for each
// RowLevel>0 field, its parent is the nearest PRECEDING RowLevel==0 field by DisplaySlNo
// (mainFields there includes group columns too — this is a display-column concern, not a
// grouping one, and the two axes are independent).
var mainFieldsForParentLookup = reportViewFields
.Where(f => f.ReportViewFieldsRowLevel == 0)
.OrderBy(f => f.ReportViewFieldsDisplaySlNo)
.ToList();
_subFields = reportViewFields
.Where(f => f.ReportViewFieldsRowLevel > 0)
.OrderBy(f => f.ReportViewFieldsDisplaySlNo)
.Select(f =>
{
var parent = mainFieldsForParentLookup
.Where(m => m.ReportViewFieldsDisplaySlNo <= f.ReportViewFieldsDisplaySlNo)
.OrderByDescending(m => m.ReportViewFieldsDisplaySlNo)
.FirstOrDefault();
return new SubField
{
Title = f.ReportViewFieldsFieldTitle ?? "",
ParentTitle = parent?.ReportViewFieldsFieldTitle ?? "",
Level = f.ReportViewFieldsRowLevel,
FieldType = f.ReportVsFieldsFieldType,
CalFormat = f.ReportViewFieldsCalFormat,
};
})
.ToList();
}
private static void EnsureFontResolverRegistered()
{
if (_fontResolverRegistered) return;
lock (FontResolverLock)
{
if (_fontResolverRegistered) return;
GlobalFontSettings.FontResolver = new LatoFontResolver();
_fontResolverRegistered = true;
}
}
// Points-per-inch dimensions — mirrors ReportExport.cs's GetPaperFormatFromEnum exactly (same
// byte convention/values) so a native and Puppeteer PDF of the same report view render at the
// same physical page size. PdfSharpCore's own PageSize enum stops at A6/B5/Letter/Legal — sizes
// beyond that (A7-A10) are set directly via PdfPage.Width/Height instead of that enum.
private static (double Width, double Height) GetPageDimensions(byte pageFormat) => pageFormat switch
{
0 => (2384, 3370), // A0
1 => (1684, 2384), // A1
2 => (1191, 1684), // A2
3 => (842, 1191), // A3
4 => (595, 842), // A4
5 => (420, 595), // A5
6 => (298, 420), // A6
7 => (2.9134 * 72, 4.1339 * 72), // A7
8 => (2.0472 * 72, 2.9134 * 72), // A8
9 => (1.4567 * 72, 2.0472 * 72), // A9
10 => (1.0236 * 72, 1.4567 * 72), // A10
11 => (2835, 4008), // B0
12 => (2004, 2835), // B1
13 => (1417, 2004), // B2
14 => (1001, 1417), // B3
15 => (709, 1001), // B4
16 => (499, 709), // B5
17 => (612, 792), // Letter
18 => (612, 1008), // Legal
19 => (612, 936), // Note/Folio (8.5x13in)
_ => (595, 842), // Default A4
};
private static XColor FromHex(string hex, XColor fallback)
{
try
{
hex = hex.TrimStart('#');
return XColor.FromArgb(
Convert.ToInt32(hex.Substring(0, 2), 16),
Convert.ToInt32(hex.Substring(2, 2), 16),
Convert.ToInt32(hex.Substring(4, 2), 16));
}
catch { return fallback; }
}
private double TableWidth => _columns.Sum(c => c.Width);
private double TableRight => MarginLeft + TableWidth;
private double ColX(int index) => MarginLeft + _columns.Take(index).Sum(c => c.Width);
// Clips to the cell's own rect before drawing — a many-column report (this renderer's whole
// reason for the auto-fit scaling above) can still land a column narrower than its longest
// value even after scaling (e.g. a wide Indian-grouped decimal in a tight column). PdfSharpCore's
// DrawString(text, font, brush, XRect, format) does NOT clip on its own — unclipped overflow
// paints straight through into the next column's cell, which is what made narrow columns look
// like garbled overlapping text instead of just a tight-but-legible fit.
private void DrawClippedString(string text, XFont font, XBrush brush, XRect rect, XStringFormat format)
{
_gfx.Save();
_gfx.IntersectClip(rect);
_gfx.DrawString(text, font, brush, rect, format);
_gfx.Restore();
}
/// headerRows mirrors Excel's ReportViewExcelHeaderDTO-driven banner exactly (same
/// per-row Font/FontSize/Color override convention as ExcelStyleHelpers.ApplyReportHeaderRowStyle,
/// re-implemented independently here rather than shared — PDF drawing and Excel cell styling are
/// different enough targets that forcing one shared helper would be more awkward than two small,
/// parallel implementations).
public void Start(List headerRows)
{
_doc = new PdfDocument();
_headerRows = headerRows;
_generatedOn = DateTime.UtcNow;
NewPage(headerRows);
}
private void NewPage(List? headerRows)
{
// Each page gets its own XGraphics, reassigned here without ever disposing the previous
// one — harmless while _doc.Save() was the only thing that ever touched a page again, but
// Finish() below re-opens EVERY page (including already-"closed" ones) to stamp the
// footer, and PdfSharpCore throws if a page still has a live XGraphics attached.
_gfx?.Dispose();
_page = _doc.AddPage();
_page.Width = XUnit.FromPoint(_pageWidth);
_page.Height = XUnit.FromPoint(_pageHeight);
_gfx = XGraphics.FromPdfPage(_page);
PageCount++;
_y = MarginTop;
if (headerRows != null)
{
double logoReservedWidth = 0;
// Repeated on every page — matches the Puppeteer/HTML pipeline's headerTemplate, which
// is a literal per-page print header (same logo, same banner text, every page).
if (_logoBytes != null)
{
try
{
var logoBytesCopy = _logoBytes;
var image = XImage.FromStream(() => new MemoryStream(logoBytesCopy));
double logoHeight = 30;
double logoWidth = image.PixelWidth > 0 ? logoHeight * image.PixelWidth / image.PixelHeight : logoHeight;
_gfx.DrawImage(image, TableRight - logoWidth, _y, logoWidth, logoHeight);
logoReservedWidth = logoWidth + 10;
}
catch { /* a corrupt/unreadable bundled logo must never fail the whole export */ }
}
foreach (var h in headerRows)
{
if (string.IsNullOrWhiteSpace(h.Expression)) continue;
var color = string.IsNullOrWhiteSpace(h.Color) ? FromHex(_theme.PrimaryColorHex, XColor.FromArgb(0x1F, 0x4E, 0x78)) : FromHex(h.Color, XColors.Black);
var size = h.FontSize > 0 ? h.FontSize : 14;
var font = new XFont("Lato", size, XFontStyle.Bold);
_gfx.DrawString(h.Expression, font, new XSolidBrush(color), new XRect(MarginLeft, _y, TableWidth - logoReservedWidth, size + 6),
new XStringFormat { Alignment = XStringAlignment.Near, LineAlignment = XLineAlignment.Near });
_y += size + 8;
}
_y += 6;
}
DrawHeaderRow();
}
private void EnsureSpace(double neededHeight)
{
if (_y + neededHeight > _pageHeight - MarginBottom)
NewPage(_headerRows);
}
private void DrawHeaderRow()
{
var themeColor = FromHex(_theme.PrimaryColorHex, XColor.FromArgb(0x1F, 0x4E, 0x78));
_gfx.DrawRectangle(new XSolidBrush(themeColor), MarginLeft, _y, TableWidth, RowHeight);
for (int i = 0; i < _columns.Count; i++)
{
var c = _columns[i];
DrawClippedString(c.Title, _headerFont, XBrushes.White, new XRect(ColX(i) + 2, _y, c.Width - 4, RowHeight),
new XStringFormat { Alignment = c.Alignment == 2 ? XStringAlignment.Far : XStringAlignment.Near, LineAlignment = XLineAlignment.Center });
}
_y += RowHeight;
}
private static bool TryToDateTime(object? value, out DateTime dt)
{
dt = default;
if (value is DateTime d) { dt = d; return true; }
var s = value?.ToString();
if (string.IsNullOrWhiteSpace(s)) return false;
if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto)) { dt = dto.Date; return true; }
return DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out dt);
}
// Mirrors ReportExport.cs's own FormatWithCalFormat/FormatDateValue exactly (Indian ##,##,##0-
// style grouping needs an en-IN culture format, not .NET's default group-of-3; date CalFormat
// tokens need "mm"->"MM" case normalization since .NET's custom date format treats lowercase
// "mm" as MINUTES, not month — an un-normalized calFormat like "dd/mm/yyyy" silently printed the
// time-of-day's minute component, e.g. "03/00/2026" for a midnight timestamp, instead of the
// month) — those methods are private to a different class, so this is a small, deliberate,
// self-contained re-implementation, same reasoning as ExcelExport.cs's own independent value
// formatting).
private static string FormatValue(object? value, byte fieldType, string? calFormat)
{
if (value == null) return "";
if (fieldType == 3 && TryToDateTime(value, out var dt))
{
string userFormat = string.IsNullOrWhiteSpace(calFormat) ? "dd/MM/yyyy" : calFormat;
string normalizedFormat = userFormat
.Replace("mmm", "MMM", StringComparison.OrdinalIgnoreCase)
.Replace("mm", "MM", StringComparison.OrdinalIgnoreCase);
return dt.ToString(normalizedFormat, CultureInfo.InvariantCulture);
}
if (fieldType == 0 || fieldType == 6)
{
if (!decimal.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
return value.ToString() ?? "";
if (string.IsNullOrWhiteSpace(calFormat))
return d.ToString("N2", CultureInfo.InvariantCulture);
int decimals = 2;
int dotIdx = calFormat.IndexOf('.');
if (dotIdx >= 0) decimals = calFormat.Length - dotIdx - 1;
return calFormat.Contains("##,##,##0")
? d.ToString("N" + decimals, new CultureInfo("en-IN"))
: d.ToString("N" + decimals, CultureInfo.InvariantCulture);
}
return value.ToString() ?? "";
}
private string FormatValueForTitle(string title, object? value)
{
_metaByTitle.TryGetValue(title, out var meta);
return FormatValue(value, meta?.ReportVsFieldsFieldType ?? byte.MaxValue, meta?.ReportViewFieldsCalFormat);
}
// Hand-rolled word-wrap via MeasureString — see class doc comment for why, not XTextFormatter.
private List WrapText(string text, XFont font, double maxWidth)
{
var words = text.Split(' ');
var lines = new List();
var current = "";
foreach (var word in words)
{
var candidate = current.Length == 0 ? word : current + " " + word;
if (_gfx.MeasureString(candidate, font).Width > maxWidth && current.Length > 0)
{
lines.Add(current);
current = word;
}
else current = candidate;
}
if (current.Length > 0) lines.Add(current);
return lines.Count > 0 ? lines : new List { "" };
}
private int _zebra;
public void DrawDataRow(Dictionary row)
{
// Wrap every text column that might realistically overflow its width rather than hardcoding
// one "Particulars"-style column name — a small, defensible generalization over the spike.
var wrappedPerCol = new Dictionary>();
double rowHeight = RowHeight;
for (int i = 0; i < _columns.Count; i++)
{
var c = _columns[i];
if (c.FieldType == 1 || c.FieldType == byte.MaxValue) // text
{
row.TryGetValue(c.Title, out var v);
var text = v?.ToString() ?? "";
if (_gfx.MeasureString(text, _bodyFont).Width > c.Width - 4)
{
var lines = WrapText(text, _bodyFont, c.Width - 4);
wrappedPerCol[i] = lines;
rowHeight = Math.Max(rowHeight, lines.Count * (_bodyFont.Height + 1) + 4);
}
}
}
EnsureSpace(rowHeight);
if ((_zebra++ % 2) == 1)
_gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(0xF2, 0xF2, 0xF2)), MarginLeft, _y, TableWidth, rowHeight);
for (int i = 0; i < _columns.Count; i++)
{
var c = _columns[i];
double x = ColX(i);
if (wrappedPerCol.TryGetValue(i, out var lines))
{
double ly = _y + 2;
foreach (var line in lines)
{
_gfx.DrawString(line, _bodyFont, XBrushes.Black, new XPoint(x + 2, ly + _bodyFont.Height * 0.8));
ly += _bodyFont.Height + 1;
}
}
else
{
row.TryGetValue(c.Title, out var v);
var text = FormatValue(v, c.FieldType, c.CalFormat);
DrawClippedString(text, _bodyFont, XBrushes.Black, new XRect(x + 2, _y, c.Width - 4, rowHeight),
new XStringFormat { Alignment = c.Alignment == 2 ? XStringAlignment.Far : XStringAlignment.Near, LineAlignment = XLineAlignment.Center });
}
}
_y += rowHeight;
if (_subFields.Count > 0) DrawSubRows(row);
}
// Row-level (ReportViewFieldsRowLevel > 0) sub-lines for one record — indented under their
// parent column, grouped by level then by parent so multiple sub-fields sharing one parent
// stack as separate lines within a single sub-row. Mirrors ReportExport.cs's renderSubRows/
// renderGroupNodes sub-row helpers exactly (same RowLevel/ParentField grouping, same "blank
// cell when the parent isn't a visible column" fallback — e.g. when the parent is itself a
// group column, excluded from _columns).
private void DrawSubRows(Dictionary row)
{
var present = new List<(SubField Field, object Value)>();
foreach (var sf in _subFields)
{
if (row.TryGetValue(sf.Title, out var v) && v != null && !string.IsNullOrWhiteSpace(v.ToString()))
present.Add((sf, v));
}
if (present.Count == 0) return;
foreach (var levelGroup in present.GroupBy(p => p.Field.Level).OrderBy(g => g.Key))
{
var byParent = levelGroup
.GroupBy(p => p.Field.ParentTitle, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(p => FormatValue(p.Value, p.Field.FieldType, p.Field.CalFormat)).ToList(),
StringComparer.OrdinalIgnoreCase);
var cellLines = new Dictionary>();
int maxLines = 1;
for (int i = 0; i < _columns.Count; i++)
{
if (byParent.TryGetValue(_columns[i].Title, out var lines))
{
cellLines[i] = lines;
maxLines = Math.Max(maxLines, lines.Count);
}
}
if (cellLines.Count == 0) continue; // parent isn't a visible column — nothing to anchor to
double indent = levelGroup.Key * 12;
double subRowHeight = maxLines * (_bodyFont.Height + 1) + 4;
EnsureSpace(subRowHeight);
foreach (var (colIndex, lines) in cellLines)
{
double x = ColX(colIndex) + 2 + indent;
double cellWidth = Math.Max(0, _columns[colIndex].Width - 4 - indent);
double ly = _y + 2;
foreach (var line in lines)
{
DrawClippedString(line, _bodyFont, XBrushes.DimGray, new XRect(x, ly, cellWidth, _bodyFont.Height + 1),
new XStringFormat { Alignment = XStringAlignment.Near, LineAlignment = XLineAlignment.Near });
ly += _bodyFont.Height + 1;
}
}
_y += subRowHeight;
}
}
public void DrawGroupHeader(string title, string displayValue, int level)
{
EnsureSpace(RowHeight);
var accent = FromHex(_theme.AccentColorHex, XColor.FromArgb(0x2E, 0x75, 0xB6));
_gfx.DrawRectangle(new XSolidBrush(XColor.FromArgb(0xE8, 0xE8, 0xE8)), MarginLeft, _y, TableWidth, RowHeight);
_gfx.DrawString($"{title}: {displayValue}", _boldBodyFont, new XSolidBrush(accent),
new XRect(MarginLeft + 4 + level * 12, _y, TableWidth - 8, RowHeight),
new XStringFormat { LineAlignment = XLineAlignment.Center });
_y += RowHeight;
}
public void DrawTotalsRow(string label, Dictionary totals, bool isGrandTotal)
{
EnsureSpace(RowHeight);
var fill = isGrandTotal ? XColor.FromArgb(0xD9, 0xE1, 0xF2) : XColor.FromArgb(0xFF, 0xF2, 0xCC);
_gfx.DrawRectangle(new XSolidBrush(fill), MarginLeft, _y, TableWidth, RowHeight);
if (isGrandTotal)
_gfx.DrawLine(new XPen(XColors.Black, 1.5), MarginLeft, _y, TableRight, _y);
for (int i = 0; i < _columns.Count; i++)
{
var c = _columns[i];
string text = i == 0 ? label : (totals.TryGetValue(c.Title, out var v) ? FormatValue(v, c.FieldType, c.CalFormat) : "");
DrawClippedString(text, _boldBodyFont, XBrushes.Black, new XRect(ColX(i) + 2, _y, c.Width - 4, RowHeight),
new XStringFormat { Alignment = c.Alignment == 2 ? XStringAlignment.Far : XStringAlignment.Near, LineAlignment = XLineAlignment.Center });
}
_y += RowHeight;
}
/// Recursively renders a GroupTreeBuilder node — identical shape to GroupTreeBuilder's
/// own doc comment (LevelTitle/LevelValue/IsLeaf/Children|Rows/SubTotal).
/// increases by one per nesting depth (outermost group column = 0), matching ReportExport.cs's
/// renderGroupNodes indentation — a single fixed level here used to render every nested group at
/// the same indent regardless of depth.
public void WriteGroupNode(object nodeObj, int level = 0)
{
if (nodeObj is not Dictionary node) return;
string levelTitle = node.TryGetValue("LevelTitle", out var lt) ? lt?.ToString() ?? "" : "";
object? levelValueRaw = node.TryGetValue("LevelValue", out var lv) ? lv : null;
bool isLeaf = node.TryGetValue("IsLeaf", out var ilObj) && ilObj is bool ilb && ilb;
// GroupTreeBuilder's LevelValue is the raw row value's ToString() (see BuildGroupNodes) —
// format it through the group column's own FieldType/CalFormat before display, otherwise a
// date group shows its untouched default ToString() (e.g. "4/3/2026 12:00:00 AM") instead of
// the configured display format (e.g. "03/Apr/2026").
string displayValue = FormatValueForTitle(levelTitle, levelValueRaw);
DrawGroupHeader(levelTitle, displayValue, level);
if (isLeaf)
{
if (node.TryGetValue("Rows", out var rowsObj) && rowsObj is IEnumerable