using System.Drawing; using System.Globalization; using DevExpress.Office; using DevExpress.XtraRichEdit; using DevExpress.XtraRichEdit.API.Native; using Microsoft.Extensions.Options; namespace ROLAC.API.Services.Disbursement; /// /// Renders a check onto pre-printed 8.5"x11" three-stub stock using the DevExpress Office /// (RichEdit) API. Fields are placed as absolutely-positioned floating TextBoxes so they align to /// the boxes printed on the stock; every coordinate comes from /// (bound from "CheckPrint:Layout" in appsettings.json) so alignment can be tuned without recompiling. /// The page is one check stub on top followed by two identical receipt copies. /// public class CheckPrintService : ICheckPrintService { private readonly CheckPrintLayoutOptions _layout; public CheckPrintService(IOptions layout) { _layout = layout.Value; } public Task RenderPdfAsync(CheckPrintModel model) { using var server = new RichEditDocumentServer(); var doc = server.Document; doc.BeginUpdate(); try { doc.Unit = DocumentUnit.Inch; var section = doc.Sections[0]; section.Page.Width = 8.5f; section.Page.Height = 11f; section.Margins.Left = section.Margins.Right = 0.6f; section.Margins.Top = section.Margins.Bottom = 0.5f; // Floating TextBoxes must anchor to a paragraph; everything is positioned relative to // the page, so a single empty anchor paragraph at the document start is enough. var anchor = doc.Paragraphs[0]; BuildCheckStub(doc, anchor, model); BuildReceiptStub(doc, anchor, model, _layout.Receipt1OriginY); BuildReceiptStub(doc, anchor, model, _layout.Receipt2OriginY); } finally { doc.EndUpdate(); } var ms = new MemoryStream(); server.ExportToPdf(ms); ms.Position = 0; return Task.FromResult(ms); } // ── check page builders (absolute positioning) ───────────────────────────── private void BuildCheckStub(Document doc, Paragraph anchor, CheckPrintModel model) { var check = model.Check; var originY = _layout.CheckOriginY; PlaceField(doc, anchor, _layout.Payee, originY, 4.5f, check.PayeeName); PlaceField(doc, anchor, _layout.AmountNumeric, originY, 1.6f, FormatCurrency(check.Amount,13), rightAlign: false); PlaceField(doc, anchor, _layout.AmountWords, originY, 6.0f, model.AmountInWords); PlaceField(doc, anchor, _layout.CheckDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy")); if (!string.IsNullOrWhiteSpace(check.Memo)) PlaceField(doc, anchor, _layout.Memo, originY, 4.5f, check.Memo); } private void BuildReceiptStub(Document doc, Paragraph anchor, CheckPrintModel model, float originY) { var check = model.Check; PlaceField(doc, anchor, _layout.ReceiptPayee, originY, 4.5f, "Pay To The Order Of: " + check.PayeeName); PlaceField(doc, anchor, _layout.ReceiptAmount, originY, 1.6f, "Amount: " + FormatCurrency(check.Amount), rightAlign: true); PlaceField(doc, anchor, _layout.ReceiptDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy")); if (!string.IsNullOrWhiteSpace(check.Memo)) PlaceField(doc, anchor, _layout.ReceiptMemo, originY, 4.5f, check.Memo); BuildVoucherGrid(doc, anchor, model, originY); } /// /// Two-column voucher detail grid: up to 12 expense lines (6 per column), filled column-major. /// Beyond 12 lines, prints the first 12 plus an overflow hint so the receipt total stays honest. /// private void BuildVoucherGrid(Document doc, Paragraph anchor, CheckPrintModel model, float originY) { var grid = _layout.Grid; var blockWidth = grid.DateWidth + grid.DescWidth + grid.AmountWidth; const int rowsPerColumn = 6; const int maxLines = rowsPerColumn * 2; float ColumnX(int column) => grid.OriginX + column * (blockWidth + grid.ColumnGap); void PlaceRow(float x, float y, string date, string description, string amount, bool bold) { PlaceText(doc, anchor, x, y, grid.DateWidth, date, grid.FontSize, bold, rightAlign: false); PlaceText(doc, anchor, x + grid.DateWidth, y, grid.DescWidth, description, grid.FontSize, bold, rightAlign: false); PlaceText(doc, anchor, x + grid.DateWidth + grid.DescWidth, y, grid.AmountWidth, amount, grid.FontSize, bold, rightAlign: true); } if (grid.ShowGridHeaders) { var headerY = originY + grid.HeaderOffsetY; for (var column = 0; column < 2; column++) PlaceRow(ColumnX(column), headerY, "Date", "Description", "Amount", bold: true); } var printed = Math.Min(model.Lines.Count, maxLines); for (var i = 0; i < printed; i++) { var line = model.Lines[i]; var column = i < rowsPerColumn ? 0 : 1; var row = i % rowsPerColumn; var y = originY + grid.OffsetY + row * grid.RowHeight; var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? ""; PlaceRow(ColumnX(column), y, date, line.Description, FormatCurrency(line.Amount), bold: false); } if (model.Lines.Count > maxLines) { var remaining = model.Lines.Count - maxLines; var remainingTotal = model.Lines.Skip(maxLines).Sum(line => line.Amount); var hint = $"…and {remaining} more line(s) ({FormatCurrency(remainingTotal)})"; PlaceText(doc, anchor, grid.OriginX, originY + grid.OverflowOffsetY, blockWidth * 2 + grid.ColumnGap, hint, grid.FontSize, bold: false, rightAlign: false); } } /// Places one configured field's value at its stub-relative position. private void PlaceField(Document doc, Paragraph anchor, FieldPos field, float stubOriginY, float width, string text, bool rightAlign = false) { PlaceText(doc, anchor, field.X, stubOriginY + field.OffsetY, width, text, field.FontSize, field.Bold, rightAlign); } /// /// Inserts a borderless, fill-less floating TextBox positioned absolutely relative to the page /// (X from the left edge, Y from the top edge) and writes into it. /// private void PlaceText(Document doc, Paragraph anchor, float x, float y, float width, string text, float fontSize, bool bold, bool rightAlign) { var shape = doc.Shapes.InsertTextBox(anchor.Range.Start); shape.HorizontalAlignment = ShapeHorizontalAlignment.None; shape.VerticalAlignment = ShapeVerticalAlignment.None; shape.RelativeHorizontalPosition = ShapeRelativeHorizontalPosition.Page; shape.RelativeVerticalPosition = ShapeRelativeVerticalPosition.Page; // Pull the box up-and-left by the calibration inset so the text inside lands exactly on the // configured (x, y) rather than down-and-right of it. shape.Offset = new PointF(x - _layout.TextInsetX, y - _layout.TextInsetY); shape.Size = new SizeF(width, Math.Max(0.2f, fontSize / 72f + 0.08f)); shape.Fill.SetNoFill(); shape.Line.Fill.SetNoFill(); var body = shape.ShapeFormat.TextBox.Document; var range = body.InsertText(body.Range.Start, text); var characters = body.BeginUpdateCharacters(range); characters.FontSize = fontSize; characters.Bold = bold; body.EndUpdateCharacters(characters); if (rightAlign) { var paragraphs = body.BeginUpdateParagraphs(range); paragraphs.Alignment = ParagraphAlignment.Right; body.EndUpdateParagraphs(paragraphs); } } public Task RenderReceiptPdfAsync(CheckPrintModel model) { using var server = new RichEditDocumentServer(); var document = server.Document; document.BeginUpdate(); try { document.Unit = DocumentUnit.Inch; var section = document.Sections[0]; section.Page.Width = 8.5f; section.Page.Height = 11f; section.Margins.Left = section.Margins.Right = 0.8f; section.Margins.Top = section.Margins.Bottom = 0.8f; document.AppendHtmlText(BuildReceiptHtml(model)); } finally { document.EndUpdate(); } var stream = new MemoryStream(); server.ExportToPdf(stream); stream.Position = 0; return Task.FromResult(stream); } private static string BuildReceiptHtml(CheckPrintModel model) { var issuer = model.Issuer; var check = model.Check; var issuerAddress = Encode(JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode)); var payeeAddress = Encode(JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip)); var detailRows = new System.Text.StringBuilder(); foreach (var line in model.Lines) { var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? ""; detailRows.Append( "" + $"{Encode(date)}" + $"{Encode(line.Description)}" + $"{Encode(FormatCurrency(line.Amount))}" + ""); } var signedOn = check.ReceiptSignedAt.HasValue ? check.ReceiptSignedAt.Value.ToLocalTime().ToString("MM/dd/yyyy HH:mm") : ""; var signatureBlock = ""; if (model.SignatureImage is { Length: > 0 }) { var base64 = Convert.ToBase64String(model.SignatureImage); signatureBlock = $"

"; } return "
" + "

Disbursement Receipt / 簽收收據

" + $"

{Encode(issuer.Name)}
{issuerAddress}

" + "

Check Information / 支票資訊

" + "" + $"" + $"" + $"" + $"" + $"" + "
Check No. / 支票號碼{Encode(check.CheckNumber)}Date / 日期{check.CheckDate:MM/dd/yyyy}
Payee / 收款人{Encode(check.PayeeName)} {payeeAddress}
Amount / 金額{Encode(FormatCurrency(check.Amount))} — {Encode(model.AmountInWords)}
Memo / 摘要{Encode(check.Memo ?? "")}
" + "

Disbursement Detail / 撥款明細

" + "" + "" + detailRows + $"" + "
Date / 日期Description / 說明Amount / 金額
TOTAL / 合計{Encode(FormatCurrency(check.Amount))}
" + "

Acknowledgement of Receipt / 收款簽收

" + "

I acknowledge receipt of the above payment in full. / 本人確認已如數收到上述款項。

" + $"

Received by / 簽收人: {Encode(check.ReceiptSignedName ?? "")}
" + $"Date / 簽收日期: {Encode(signedOn)}

" + signatureBlock + "
"; } private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? ""); // ── low-level helpers ────────────────────────────────────────────────────── private static string FormatCurrency(decimal amount, int paddingAstric = 0) { var c = (CultureInfo)CultureInfo.GetCultureInfo("en-US").Clone(); c.NumberFormat.CurrencySymbol = ""; string formatedAmount = amount.ToString("#,##0.00", CultureInfo.GetCultureInfo("en-US")); return paddingAstric > 0 ? formatedAmount.PadLeft(paddingAstric, '*') : formatedAmount; } private static string JoinAddress(string? addr, string? city, string? state, string? zip) { var cityLine = string.Join(", ", new[] { city, string.Join(" ", new[] { state, zip }.Where(s => !string.IsNullOrWhiteSpace(s))) } .Where(s => !string.IsNullOrWhiteSpace(s))); return string.Join(" ", new[] { addr, cityLine }.Where(s => !string.IsNullOrWhiteSpace(s))); } }