fix: fund report layout refactor and chart renderer fix

- FundAnagraficaRenderer: unified header style (DrawColHeader), DrawPerfGrid returns float, chart embedded in anagrafica section
- FundReportController: simplify filename to {isin}.pdf
- SkiaChartRendererV2: minor fix affecting certificate chart

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 15:56:21 +02:00
parent 9d8acbae6b
commit 9b938ef93d
5 changed files with 123 additions and 80 deletions

View File

@@ -93,7 +93,7 @@ public class FundReportController : ControllerBase
var pdfBytes = await _orchestrator.GenerateReportAsync(isin, branding); var pdfBytes = await _orchestrator.GenerateReportAsync(isin, branding);
var disposition = inline ? "inline" : "attachment"; var disposition = inline ? "inline" : "attachment";
Response.Headers.Append("Content-Disposition", Response.Headers.Append("Content-Disposition",
$"{disposition}; filename=fund_{isin}.pdf"); $"{disposition}; filename={isin}.pdf");
return File(pdfBytes, "application/pdf"); return File(pdfBytes, "application/pdf");
} }
catch (InvalidOperationException ex) when (ex.Message.Contains("Nessun dato")) catch (InvalidOperationException ex) when (ex.Message.Contains("Nessun dato"))

View File

@@ -11,11 +11,19 @@ public class FundAnagraficaRenderer
private const float PageW = 595f - 2 * PdfTheme.PageMargin; private const float PageW = 595f - 2 * PdfTheme.PageMargin;
private const float PageH = 842f - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight; private const float PageH = 842f - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight;
private const float ColGap = 8f; private const float ColGap = 8f;
private const float Col1W = 190f; private const float Col1W = 247f;
private const float Col2W = 125f; private const float Col2W = 78f; // ESG ridotto per dare spazio a Dati Anagrafici
private const float Col3W = PageW - Col1W - Col2W - 2 * ColGap; private const float Col3W = PageW - Col1W - Col2W - 2 * ColGap; // = 174
public PdfDocument Render(FundReportData data) // Strip box widths — usati sia nello strip che per allineare titolo e Col3
private const float StripW1 = 100f; // ISIN
private const float StripW2 = 225f; // CATEGORIA
private const float StripGap = 8f;
private const float StripTitleW = StripW1 + StripGap + StripW2; // = 333 → bordo destro titolo = bordo destro CATEGORIA
private const float StripBoxX = StripTitleW + StripGap; // = 341 → x di RANKING e box combinato
private const float StripW3 = PageW - StripBoxX; // = 174 → RANKING e box combinato fino al bordo pagina
public PdfDocument Render(FundReportData data, byte[]? chartPng = null)
{ {
var doc = new PdfDocument(); var doc = new PdfDocument();
var page = PdfTheme.AddA4Page(doc); var page = PdfTheme.AddA4Page(doc);
@@ -28,13 +36,15 @@ public class FundAnagraficaRenderer
y = DrawStrip(g, info, y); y = DrawStrip(g, info, y);
y += 8f; y += 8f;
float col1X = 0f; float contentY = y;
float col2X = Col1W + ColGap; float col1Bottom = DrawAnagrafici(g, info, 0f, Col1W, contentY);
float col3X = col2X + Col2W + ColGap; float col2Bottom = DrawEsg (g, info, Col1W + ColGap, Col2W, contentY);
float col3Bottom = DrawPerfGrid (g, info, Col1W + ColGap + Col2W + ColGap, Col3W, contentY);
DrawAnagrafici(g, info, col1X, Col1W, y); y = Math.Max(col1Bottom, Math.Max(col2Bottom, col3Bottom)) + 14f;
DrawEsg(g, info, col2X, Col2W, y);
DrawPerfGrid(g, info, col3X, Col3W, y); if (chartPng != null)
DrawChartImage(g, chartPng, y);
PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding); PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding);
return doc; return doc;
@@ -42,37 +52,42 @@ public class FundAnagraficaRenderer
private float DrawTitle(PdfGraphics g, FundInfo info, float y) private float DrawTitle(PdfGraphics g, FundInfo info, float y)
{ {
const float h = 22f; const float h = 36f;
g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(0, y, PageW, h));
// Barra blu titolo — bordo destro allineato con CATEGORIA
g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(0, y, StripTitleW, h));
var fmt = new PdfStringFormat var fmt = new PdfStringFormat
{ {
Alignment = PdfTextAlignment.Center, Alignment = PdfTextAlignment.Left,
LineAlignment = PdfVerticalAlignment.Middle LineAlignment = PdfVerticalAlignment.Middle
}; };
var title = $"{info.Tipo ?? "Fondo"} — {info.Strumento} — {info.Isin}"; var tipo = (info.Tipo ?? "FONDO").ToUpperInvariant();
g.DrawString(title, PdfTheme.Bold, g.DrawString($"{tipo} — {info.Strumento}", PdfTheme.SectionTitleFont,
new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 255, 255, 255))), new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 255, 255, 255))),
new RectangleF(0, y, PageW, h), fmt); new RectangleF(6f, y, StripTitleW - 6f, h), fmt);
// Box combinato PREZZO + AGGIORNATO AL — stesso x/w di RANKING
var prezzoStr = info.Prezzo.HasValue ? $"{info.Prezzo.Value:F2} {info.Valuta}" : "—";
var dataStr = info.DataPrezzo.HasValue ? info.DataPrezzo.Value.ToString("dd/MM/yyyy") : "—";
DrawCombinedStripBox(g, "PREZZO", prezzoStr, "AGGIORNATO AL", dataStr,
StripBoxX, y, StripW3, h);
return y + h; return y + h;
} }
private float DrawStrip(PdfGraphics g, FundInfo info, float y) private float DrawStrip(PdfGraphics g, FundInfo info, float y)
{ {
const float h = 36f; const float h = 36f;
const float w1 = 100f;
const float w2 = 225f;
const float w3 = 150f;
const float gap = 8f;
DrawStripBox(g, "RATING / RANK", DrawStripBox(g, "ISIN",
info.Isin,
0f, y, StripW1, h);
DrawStripBox(g, "CATEGORIA",
info.CategoriaMorningstar ?? "—",
StripW1 + StripGap, y, StripW2, h);
DrawStripBox(g, "RANKING",
info.Rank.HasValue ? info.Rank.Value.ToString("F2") : "—", info.Rank.HasValue ? info.Rank.Value.ToString("F2") : "—",
0f, y, w1, h); StripBoxX, y, StripW3, h);
DrawStripBox(g, "PREZZO",
info.Prezzo.HasValue ? $"{info.Prezzo.Value:F2} {info.Valuta}" : "—",
w1 + gap, y, w2, h);
DrawStripBox(g, "AGGIORNATO AL",
info.DataPrezzo.HasValue ? info.DataPrezzo.Value.ToString("dd/MM/yyyy") : "—",
w1 + gap + w2 + gap, y, w3, h);
return y + h; return y + h;
} }
@@ -82,7 +97,7 @@ public class FundAnagraficaRenderer
{ {
var bgBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 232, 238, 248))); var bgBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 232, 238, 248)));
var borderPen = new PdfPen(new PdfColor(PdfTheme.AccentBlue), 0.5f); var borderPen = new PdfPen(new PdfColor(PdfTheme.AccentBlue), 0.5f);
g.DrawRectangle(bgBrush, new RectangleF(x, y, w, h)); g.DrawRectangle(bgBrush, new RectangleF(x, y, w, h));
g.DrawRectangle(borderPen, new RectangleF(x, y, w, h)); g.DrawRectangle(borderPen, new RectangleF(x, y, w, h));
g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(x, y, 3f, h)); g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(x, y, 3f, h));
var grayBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 136, 136, 136))); var grayBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 136, 136, 136)));
@@ -93,29 +108,57 @@ public class FundAnagraficaRenderer
new RectangleF(x + 6f, y + 13f, w - 8f, h - 15f), leftFmt); new RectangleF(x + 6f, y + 13f, w - 8f, h - 15f), leftFmt);
} }
private void DrawAnagrafici(PdfGraphics g, FundInfo info, float x, float w, float y) // Un unico box con due coppie label/valore affiancate sulla stessa riga
private void DrawCombinedStripBox(PdfGraphics g,
string label1, string value1, string label2, string value2,
float x, float y, float w, float h)
{
var bgBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 232, 238, 248)));
var borderPen = new PdfPen(new PdfColor(PdfTheme.AccentBlue), 0.5f);
g.DrawRectangle(bgBrush, new RectangleF(x, y, w, h));
g.DrawRectangle(borderPen, new RectangleF(x, y, w, h));
g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(x, y, 3f, h));
var grayBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 136, 136, 136)));
var leftFmt = new PdfStringFormat { Alignment = PdfTextAlignment.Left };
float half = w / 2f;
// Sinistra: PREZZO
g.DrawString(label1, PdfTheme.Small, grayBrush,
new RectangleF(x + 6f, y + 2f, half - 8f, 10f), leftFmt);
g.DrawString(value1, PdfTheme.Bold, PdfTheme.AccentBlueBrush,
new RectangleF(x + 6f, y + 13f, half - 8f, h - 15f), leftFmt);
// Destra: AGGIORNATO AL
g.DrawString(label2, PdfTheme.Small, grayBrush,
new RectangleF(x + half + 2f, y + 2f, half - 6f, 10f), leftFmt);
g.DrawString(value2, PdfTheme.Bold, PdfTheme.AccentBlueBrush,
new RectangleF(x + half + 2f, y + 13f, half - 6f, h - 15f), leftFmt);
}
private float DrawAnagrafici(PdfGraphics g, FundInfo info, float x, float w, float y)
{ {
y = DrawColHeader(g, "Dati Anagrafici", x, w, y); y = DrawColHeader(g, "Dati Anagrafici", x, w, y);
var items = new (string Label, string Value)[] var items = new (string Label, string Value)[]
{ {
("Società", info.Societa ?? "—"), ("Società", info.Societa ?? "—"),
("Categoria MS", info.CategoriaMorningstar ?? "—"), ("Tipo", info.Tipo?.ToUpperInvariant() ?? "—"),
("Tipo", info.Tipo ?? "—"), ("Valuta", info.Valuta ?? "—"),
("Valuta", info.Valuta ?? "—"),
("Hedged", string.IsNullOrEmpty(info.Hedged) ? "—" : info.Hedged), ("Hedged", string.IsNullOrEmpty(info.Hedged) ? "—" : info.Hedged),
("Benchmark", info.Benchmark ?? "—"), ("Benchmark", info.Benchmark ?? "—"),
("Spese correnti", info.SpeseCorrenti.HasValue ? $"{info.SpeseCorrenti.Value:F2}%" : "—"), ("Spese correnti", info.SpeseCorrenti.HasValue ? $"{info.SpeseCorrenti.Value:F2}%" : "—"),
("Catalogo", info.Catalogo ?? "—"), ("Catalogo", info.Catalogo ?? "—"),
("Proventi", info.Proventi ?? "—"), ("Proventi", info.Proventi ?? "—"),
("Data lancio", info.DataLancio.HasValue ? info.DataLancio.Value.ToString("dd/MM/yyyy") : "—"), ("Data lancio", info.DataLancio.HasValue ? info.DataLancio.Value.ToString("dd/MM/yyyy") : "—"),
("Patrimonio", info.Patrimonio.HasValue ? $"{info.Patrimonio.Value:N0} EUR" : "—"), ("Patrimonio", info.Patrimonio.HasValue ? $"{info.Patrimonio.Value:N0} EUR" : "—"),
}; };
DrawKvList(g, items, x, w, y); DrawKvList(g, items, x, w, y);
return y + items.Length * PdfTheme.RowHeight;
} }
private void DrawEsg(PdfGraphics g, FundInfo info, float x, float w, float y) private float DrawEsg(PdfGraphics g, FundInfo info, float x, float w, float y)
{ {
y = DrawColHeader(g, "ESG Score", x, w, y); y = DrawColHeader(g, "ESG Score", x, w, y);
@@ -144,11 +187,14 @@ public class FundAnagraficaRenderer
new RectangleF(x + 5f, y + 14f, w - 8f, 20f)); new RectangleF(x + 5f, y + 14f, w - 8f, 20f));
y += cardH + 3f; y += cardH + 3f;
} }
return y;
} }
private void DrawPerfGrid(PdfGraphics g, FundInfo info, float x, float w, float y) private float DrawPerfGrid(PdfGraphics g, FundInfo info, float x, float w, float y)
{ {
y = DrawGridHeader(g, "PERFORMANCE · VOLATILITÀ · REND/RISK", x, w, y); // Intestazione con barretta blu laterale (stesso stile di Dati Anagrafici e ESG)
y = DrawColHeader(g, "PERFORMANCE · VOLATILITÀ · REND/RISK", x, w, y);
var periods = new (string Label, decimal? Perf, decimal? Vol, decimal? Rr)[] var periods = new (string Label, decimal? Perf, decimal? Vol, decimal? Rr)[]
{ {
@@ -161,8 +207,8 @@ public class FundAnagraficaRenderer
}; };
const float cellGap = 3f; const float cellGap = 3f;
const float cellH = 50f;
float cellW = (w - 2 * cellGap) / 3f; float cellW = (w - 2 * cellGap) / 3f;
const float cellH = 50f;
for (int i = 0; i < 6; i++) for (int i = 0; i < 6; i++)
{ {
@@ -173,6 +219,18 @@ public class FundAnagraficaRenderer
var p = periods[i]; var p = periods[i];
DrawPerfCell(g, p.Label, p.Perf, p.Vol, p.Rr, cx, cy, cellW, cellH); DrawPerfCell(g, p.Label, p.Perf, p.Vol, p.Rr, cx, cy, cellW, cellH);
} }
return y + 2 * (cellH + cellGap) - cellGap; // 2 righe
}
private void DrawChartImage(PdfGraphics g, byte[] pngBytes, float y)
{
float chartH = PageH - y - 4f;
if (chartH < 60f) return;
using var imgStream = new MemoryStream(pngBytes);
var pdfImage = PdfImage.FromStream(imgStream);
g.DrawImage(pdfImage, new RectangleF(0f, y, PageW, chartH));
} }
private void DrawPerfCell(PdfGraphics g, string period, private void DrawPerfCell(PdfGraphics g, string period,
@@ -230,27 +288,12 @@ public class FundAnagraficaRenderer
return y + 18f; return y + 18f;
} }
private float DrawGridHeader(PdfGraphics g, string title, float x, float w, float y)
{
const float h = 16f;
g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(x, y, w, h));
var fmt = new PdfStringFormat
{
Alignment = PdfTextAlignment.Left,
LineAlignment = PdfVerticalAlignment.Middle
};
g.DrawString(title, PdfTheme.SmallBold,
new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 255, 255, 255))),
new RectangleF(x + 4f, y, w - 6f, h), fmt);
return y + h + 4f;
}
private void DrawKvList(PdfGraphics g, (string Label, string Value)[] items, private void DrawKvList(PdfGraphics g, (string Label, string Value)[] items,
float x, float w, float y) float x, float w, float y)
{ {
float rh = PdfTheme.RowHeight; float rh = PdfTheme.RowHeight;
float labelW = w * 0.52f; float labelW = w * 0.30f;
float valueW = w * 0.48f; float valueW = w * 0.70f;
var labelBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 85, 85, 85))); var labelBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 85, 85, 85)));
var valueBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 33, 33, 33))); var valueBrush = new PdfSolidBrush(new PdfColor(Color.FromArgb(255, 33, 33, 33)));

View File

@@ -17,14 +17,12 @@ public class FundChartSectionRenderer
_logger = logger; _logger = logger;
} }
public async Task<PdfDocument?> RenderAsync(string isin, string instrumentName) public async Task<byte[]?> GetChartPngAsync(string isin, string instrumentName)
{ {
try try
{ {
var points = await _dataService.GetChartPricesAsync(isin); var points = await _dataService.GetChartPricesAsync(isin);
var pngBytes = FundSkiaChartRenderer.Render(points, instrumentName, return FundSkiaChartRenderer.Render(points, instrumentName, width: 1100, height: 550);
width: 1100, height: 650);
return WrapInPdf(pngBytes);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -33,6 +31,12 @@ public class FundChartSectionRenderer
} }
} }
public async Task<PdfDocument?> RenderAsync(string isin, string instrumentName)
{
var pngBytes = await GetChartPngAsync(isin, instrumentName);
return pngBytes != null ? WrapInPdf(pngBytes) : null;
}
private static PdfDocument WrapInPdf(byte[] pngBytes) private static PdfDocument WrapInPdf(byte[] pngBytes)
{ {
var doc = new PdfDocument(); var doc = new PdfDocument();

View File

@@ -46,23 +46,17 @@ public class FundReportOrchestrator : IFundReportOrchestrator
throw new InvalidOperationException($"Nessun dato trovato per ISIN {isin}"); throw new InvalidOperationException($"Nessun dato trovato per ISIN {isin}");
var reportData = new FundReportData { Info = info, ShowBranding = showBranding }; var reportData = new FundReportData { Info = info, ShowBranding = showBranding };
var sections = new List<PdfDocument>();
sections.Add(_anagraficaRenderer.Render(reportData)); var chartPng = await _chartRenderer.GetChartPngAsync(isin, info.Strumento);
if (chartPng != null)
_logger.LogInformation("FundChart generato per {Isin}", isin);
else
_logger.LogWarning("FundChart omesso per {Isin} (dati insufficienti o errore)", isin);
var doc = _anagraficaRenderer.Render(reportData, chartPng);
_logger.LogInformation("Sezione 'FundAnagrafica' generata per {Isin}", isin); _logger.LogInformation("Sezione 'FundAnagrafica' generata per {Isin}", isin);
var chartDoc = await _chartRenderer.RenderAsync(isin, info.Strumento); var finalPdf = _merger.Merge(new List<PdfDocument> { doc });
if (chartDoc != null)
{
sections.Add(chartDoc);
_logger.LogInformation("Sezione 'FundChart' generata per {Isin}", isin);
}
else
{
_logger.LogWarning("Sezione 'FundChart' omessa per {Isin} (dati insufficienti o errore)", isin);
}
var finalPdf = _merger.Merge(sections);
_cache.Set(cacheKey, finalPdf); _cache.Set(cacheKey, finalPdf);
_logger.LogInformation("Fund report generato per {Isin}: {Size} bytes", isin, finalPdf.Length); _logger.LogInformation("Fund report generato per {Isin}: {Size} bytes", isin, finalPdf.Length);
return finalPdf; return finalPdf;

View File

@@ -160,7 +160,9 @@ public static class SkiaChartRendererV2
} }
DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness); DrawSeriesV2(canvas, plotArea, ulPoints, minDate, maxDate, minY, maxY, color, thickness);
DrawSeriesEndLabel(plotArea, ulPoints, minDate, maxDate, minY, maxY, color, endLabel, rightLabels); // Il WorstOf ha già il label sul margine destro dalla linea tratteggiata — evita duplicato
if (ul.IsWorstOf != 1)
DrawSeriesEndLabel(plotArea, ulPoints, minDate, maxDate, minY, maxY, color, endLabel, rightLabels);
seriesLegend.Add((seriesLabel, color, false, thickness)); seriesLegend.Add((seriesLabel, color, false, thickness));
} }