final changes in pdfs closed xml
Here’s a complete evaluation of the branch:
Summary
Branch: RefactorPdfXlReports — one commit d6a75cccf
Goal: Reduce API container memory footprint by caching assets and removing expensive per-cell operations in ClosedXML.
There are three distinct workstreams, plus peripheral cleanups:
1. QuestPDF Asset Caching (11 files)
What changed: Header images, footer images, and QR codes were being loaded inside ComposeHeader()/ComposeFooter() — which meant File.ReadAllBytes() every time a page header/footer rendered. QR code generation (QRCodeGenerator → QRCodeData → PngByteQRCode) was also recreated per page.
Fix: Moved all these into field initializers — loaded once at construction time:
// Before: in ComposeHeader() — loaded per page
byte[] imageData = File.ReadAllBytes(Model.HeaderImg);
// After: field initializer — loaded once
private readonly byte[]? _headerImgBytes =
!string.IsNullOrEmpty(_InvoicePrintData.HeaderImg) && File.Exists(_InvoicePrintData.HeaderImg)
? File.ReadAllBytes(_InvoicePrintData.HeaderImg) : null;Similarly for QR codes — extracted to static helper GenerateQrCodeImage() that returns pre-rendered PNG bytes, stored in a field.
Affected: Invoice, Dncn, EwayInvoice, Grn, Indent, JV, JobWork, JobWorkMisc, Payslip, SalesOrder, Voucher, CrewPayslip (12 document classes)
Memory impact: High — prevents repeated disk I/O and QR code generation (which allocates intermediate objects per page). The null-conditional guards also prevent crashes when HeaderImg is empty/missing.
2. ClosedXML — AdjustToContents Optimization (3 files)
This is the core memory fix from the audit. AdjustToContents() calls text measurement on every cell, and by default ClosedXML uses SkiaSharp font rendering — which loads actual font files and does precise glyph measurement, consuming significant memory per worksheet.
2a. New: ClosedXmlGraphicEngine + ClosedXmlFastEngineScope
A custom IXLGraphicEngine registered in Program.cs that wraps the default engine:
- Normal mode (scope disabled): delegates to the default engine (full precision)
- Fast mode (scope enabled via
using (ClosedXmlFastEngineScope.Enable())): returns approximate text metrics based on character count × a heuristic:
| Method | Fast mode formula |
|---|---|
GetTextHeight |
fontSize × 1.3 × (dpi / 96) |
GetTextWidth |
text.Length × 7.5 × (fontSize/11) × (dpi/96) (8.6 if bold) |
GetMaxDigitWidth |
Same as width per digit |
This completely bypasses SkiaSharp/SixLabors font loading — no font file is ever read for measurement. The values are good enough for column auto-sizing.
2b. Style batching — CrewKraReport & LeadSummaryReport
Before: NumberFormat.Format = "0.00" was applied per-cell inside loops (potentially thousands of style objects created):
worksheet.Cell(i, columnvalIndex + k).Style.NumberFormat.Format = "0.00";After: A single range-level format applied once after the loop:
worksheet.Range(5, 3, i, columnvalIndex + monthsList.Count + 2).Style.NumberFormat.Format = "0.00";Why this matters: Each individual cell.Style.NumberFormat creates a new style object. AdjustToContents then needs to inspect every cell’s style. By applying a range format once, ClosedXML shares the style object across all cells in that range, drastically reducing internal style tracking overhead.
2c. Registration in Program.cs
ClosedXML.Excel.LoadOptions.DefaultGraphicEngine = new ClosedXmlGraphicEngine();3. QuestPDF_PrintLabel Refactor
- Removed the second constructor parameter
_mfgtrndata— it was an unusedListModelthat shadowed per-loop data - Removed mutable class state —
ComposeContentnow receives its data as parameters (QrPrint listModel, byte[] qrCodeImage) instead of depending on a field that was overwritten in a loop - QR generation moved to static helper — pre-rendered per label at the call site in the loop, not re-created inside
ComposeContent
Memory impact: Moderate — eliminates unnecessary allocations from the unused field and the QR code objects inside the composition path (which ran for every label in a multi-label loop).
4. Docker & Monitoring
| Change | What |
|---|---|
| docker-compose.yml / docker-compose-aws.yml | Removed memory: ${API_MEM_LIMIT:-1152m} limits under deploy.resources.limits |
| scripts/monitor_memory.sh (new) | Logs docker stats + host memory to a CSV every run — for verifying the footprint improvement |
The 1152m limit was removed — presumably these optimizations now keep the container under whatever threshold was causing OOM kills, or the limit was causing OOM kills because 1152m was too tight on burst.
Overall Assessment
| Change | Memory impact | Risk |
|---|---|---|
| Image/QR caching (field initializers) | High — eliminates repeated allocations per-page | Low — images already loaded, just earlier |
ClosedXmlGraphicEngine fast mode |
High — eliminates font loading entirely for AdjustToContents |
Medium — column widths become approximate, could cause text overflow in cells |
| Range-level NumberFormat batching | Medium — fewer style objects tracked by ClosedXML | Low — same format, same cells |
| PrintLabel constructor cleanup | Low | Low |
| Removing docker memory limit | N/A (operational) | Medium — removes hard cap, relies on optimizations |
Ponytail note: The fast graphic engine’s text.Length × 7.5 heuristic is good enough for AdjustToContents — the column widths just need to be “wide enough,” not pixel-perfect. If any cells show text clipping, AdjustToContents with a wider default or a “fast mode with padding” multiplier (e.g. 7.5 → 8.0) would handle it. Not worth adding unless someone reports clipping.