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 rowsEnum) foreach (var r in rowsEnum.OfType>()) DrawDataRow(r); } else if (node.TryGetValue("Children", out var childrenObj) && childrenObj is IEnumerable childrenEnum) { foreach (var child in childrenEnum) WriteGroupNode(child, level + 1); } if (node.TryGetValue("SubTotal", out var subTotalObj) && subTotalObj is Dictionary subTotal) DrawTotalsRow("Sub Total", subTotal, isGrandTotal: false); } public void Finish(Stream outputStream) { // Real per-page "Page X of Y" footer, stamped as a post-process once every page exists — // Puppeteer gets this for free from Chromium's own pageNumber/totalPages footer template; // PdfSharpCore draws pages as it goes with no notion of "how many pages will there be", so // total page count is only known here, after content rendering finished. Re-opening each // already-drawn page in Append mode (instead of a second full content pass) keeps this a // single cheap extra pass — the point of this renderer over the Puppeteer path is speed. _gfx?.Dispose(); int totalPages = _doc.PageCount; for (int i = 0; i < totalPages; i++) { var page = _doc.Pages[i]; using var gfx = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append); double footerY = page.Height.Point - MarginBottom + 10; gfx.DrawLine(new XPen(XColors.Black, 0.5), MarginLeft, footerY - 6, page.Width.Point - MarginRight, footerY - 6); gfx.DrawString($"Page {i + 1} of {totalPages}", _footerFont, XBrushes.Gray, new XPoint(MarginLeft, footerY + 6)); gfx.DrawString($"Generated on {_generatedOn:dd-MMM-yyyy HH:mm} UTC", _footerFont, XBrushes.Gray, new XRect(MarginLeft, footerY, page.Width.Point - MarginLeft - MarginRight, 16), new XStringFormat { Alignment = XStringAlignment.Far, LineAlignment = XLineAlignment.Near }); } _doc.Save(outputStream); } } }