Files
zentral/src/Apollinare.API/Services/Reports/ReportGeneratorService.cs
2025-11-28 11:09:30 +01:00

1720 lines
73 KiB
C#

using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Apollinare.Domain.Entities;
using Apollinare.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using SkiaSharp;
namespace Apollinare.API.Services.Reports;
public class ReportGeneratorService
{
private readonly AppollinareDbContext _context;
private readonly ILogger<ReportGeneratorService> _logger;
private static readonly Regex BindingRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled);
// Conversion factor: 1mm = 3.7795275591 px at 96 DPI
// This matches the frontend MM_TO_PX constant exactly
private const float MM_TO_PX = 3.7795275591f;
public ReportGeneratorService(AppollinareDbContext context, ILogger<ReportGeneratorService> logger)
{
_context = context;
_logger = logger;
// Configure QuestPDF license (Community is free for revenue < $1M)
QuestPDF.Settings.License = LicenseType.Community;
// Enable debugging for development
QuestPDF.Settings.EnableDebugging = true;
}
public async Task<byte[]> GeneratePdfAsync(int templateId, Dictionary<string, object> dataContext)
{
var template = await _context.ReportTemplates.FindAsync(templateId);
if (template == null)
throw new ArgumentException($"Template with ID {templateId} not found");
var aprt = JsonSerializer.Deserialize<AprtTemplate>(template.TemplateJson)
?? throw new InvalidOperationException("Invalid template JSON");
// Load resources (fonts and images)
var resources = await LoadResourcesAsync(aprt);
// Migrate legacy templates without pages
MigrateTemplatePages(aprt);
_logger.LogInformation("Generating PDF with {PageCount} pages, {ElementCount} total elements",
aprt.Pages.Count, aprt.Elements.Count(e => e.Visible));
// Debug: log all pages and elements pageIds
_logger.LogInformation("Pages in template: {Pages}", string.Join(", ", aprt.Pages.Select(p => $"{p.Name}({p.Id})")));
_logger.LogInformation("Elements pageIds: {PageIds}", string.Join(", ", aprt.Elements.Select(e => $"{e.Name ?? e.Id}->'{e.PageId ?? "NULL"}'")));
const float dpi = 300f;
var totalPages = aprt.Pages.Count;
// Pre-render all pages to avoid closure issues in QuestPDF lambda
var pageRenderData = new List<(float pageWidth, float pageHeight, string? bgColor, byte[] imageBytes)>();
for (int pageIndex = 0; pageIndex < aprt.Pages.Count; pageIndex++)
{
var pageDefinition = aprt.Pages[pageIndex];
var currentPageNumber = pageIndex + 1;
// Determine page settings (use page override or template default)
var pageSize = pageDefinition.PageSize ?? aprt.Meta.PageSize;
var orientation = pageDefinition.Orientation ?? aprt.Meta.Orientation;
var pageDimensions = GetPageDimensionsMm(pageSize, orientation);
var pageWidth = pageDimensions.Width;
var pageHeight = pageDimensions.Height;
// Get visible elements for this page
var pageElements = aprt.Elements
.Where(e => e.Visible && (e.PageId == pageDefinition.Id ||
(string.IsNullOrEmpty(e.PageId) && pageDefinition.Id == aprt.Pages[0].Id)))
.ToList();
_logger.LogInformation("Page '{PageName}' ({PageId}): {ElementCount} elements (of {TotalElements} total), Size: {Width}x{Height}mm, Page {CurrentPage}/{TotalPages}",
pageDefinition.Name, pageDefinition.Id, pageElements.Count, aprt.Elements.Count,
pageWidth, pageHeight, currentPageNumber, totalPages);
_logger.LogInformation(" Elements on this page: {Elements}",
string.Join(", ", pageElements.Select(e => e.Name ?? e.Id)));
// Render full page to bitmap (element coordinates are relative to entire page)
// Pass page context for special variables
var pageContext = new PageContext { PageNumber = currentPageNumber, TotalPages = totalPages };
var pageImageBytes = RenderContentToBitmap(
pageElements, pageWidth, pageHeight, dataContext, resources, dpi,
pageDefinition.BackgroundColor, pageContext);
pageRenderData.Add((pageWidth, pageHeight, pageDefinition.BackgroundColor, pageImageBytes));
}
// Generate PDF using QuestPDF with pre-rendered pages
var document = Document.Create(container =>
{
foreach (var (pageWidth, pageHeight, bgColor, imageBytes) in pageRenderData)
{
container.Page(page =>
{
// Set page size - no margins since we render the full page as bitmap
page.Size(pageWidth, pageHeight, Unit.Millimetre);
page.Margin(0);
// Content - use the rendered bitmap for the full page
page.Content()
.Image(imageBytes)
.FitArea();
});
}
});
return document.GeneratePdf();
}
/// <summary>
/// Context for page-level special variables during rendering
/// </summary>
private class PageContext
{
public int PageNumber { get; set; }
public int TotalPages { get; set; }
}
/// <summary>
/// Migrates legacy templates without pages to the new multi-page format.
/// Creates a default page and assigns all orphan elements to it.
/// </summary>
private static void MigrateTemplatePages(AprtTemplate aprt)
{
if (aprt.Pages == null || aprt.Pages.Count == 0)
{
// Create default page
aprt.Pages = new List<AprtPage>
{
new AprtPage { Id = "page-1", Name = "Pagina 1" }
};
}
// Assign orphan elements (without pageId) to the first page
var firstPageId = aprt.Pages[0].Id;
foreach (var element in aprt.Elements.Where(e => string.IsNullOrEmpty(e.PageId)))
{
element.PageId = firstPageId;
}
}
/// <summary>
/// Renders all content to a high-resolution bitmap using SkiaSharp.
/// This ensures images and all elements are rendered exactly as designed.
/// </summary>
private byte[] RenderContentToBitmap(List<AprtElement> elements, float contentWidthMm, float contentHeightMm,
Dictionary<string, object> dataContext, Dictionary<string, object> resources, float dpi,
string? backgroundColor = null, PageContext? pageContext = null)
{
// Use a SINGLE uniform scale for both axes - critical for correct element positioning
// With FitArea(), the bitmap will fill the page maintaining proportions
var scale = dpi / 25.4f; // pixels per mm at target DPI
var widthPx = (int)Math.Round(contentWidthMm * scale);
var heightPx = (int)Math.Round(contentHeightMm * scale);
// Use the SAME scale for both X and Y
var scaleX = scale;
var scaleY = scale;
_logger.LogDebug("Rendering bitmap: {Width}x{Height}px, scale: {Scale} px/mm, page: {PageW}x{PageH}mm",
widthPx, heightPx, scale, contentWidthMm, contentHeightMm);
using var surface = SKSurface.Create(new SKImageInfo(widthPx, heightPx, SKColorType.Rgba8888, SKAlphaType.Premul));
var canvas = surface.Canvas;
// Page background color (default: white)
var bgColor = !string.IsNullOrEmpty(backgroundColor) ? ParseColor(backgroundColor) : SKColors.White;
canvas.Clear(bgColor);
// Render each element using consistent scale factors
foreach (var element in elements.OrderBy(e => elements.IndexOf(e)))
{
RenderElementToCanvas(canvas, element, scaleX, scaleY, dataContext, resources, pageContext);
}
// Encode to PNG
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
return data.ToArray();
}
/// <summary>
/// Renders a single element to the SkiaSharp canvas.
///
/// IMPORTANT: Fabric.js coordinate system explanation:
/// - With originX='left' and originY='top' (default), left/top represent the top-left corner
/// of the object's bounding box BEFORE rotation
/// - When rotated, the object rotates around its center
/// - The saved left/top values are the position of the top-left corner AFTER rotation
/// (i.e., the rotated origin point's position on canvas)
///
/// To render correctly:
/// 1. Calculate the center of the object using the rotated origin formula
/// 2. Rotate canvas around this center
/// 3. Draw the element centered on this point (offset by -width/2, -height/2)
/// </summary>
private void RenderElementToCanvas(SKCanvas canvas, AprtElement element, float scaleX, float scaleY,
Dictionary<string, object> dataContext, Dictionary<string, object> resources, PageContext? pageContext = null)
{
// Convert mm to pixels
var left = element.Position.X * scaleX;
var top = element.Position.Y * scaleY;
var width = element.Position.Width * scaleX;
var height = element.Position.Height * scaleY;
var angleRad = element.Position.Rotation * (float)Math.PI / 180f;
// Calculate the center of the object in canvas coordinates
// In Fabric.js with originX='left', originY='top':
// The saved (left, top) is the position of the top-left corner after rotation
// Center = left + rotated(width/2, height/2)
var halfWidth = width / 2;
var halfHeight = height / 2;
var cos = (float)Math.Cos(angleRad);
var sin = (float)Math.Sin(angleRad);
var centerX = left + halfWidth * cos - halfHeight * sin;
var centerY = top + halfWidth * sin + halfHeight * cos;
// Calculate where to draw the element (top-left of unrotated rect centered on centerX/Y)
var drawX = centerX - halfWidth;
var drawY = centerY - halfHeight;
_logger.LogDebug("Rendering element {Id} type={Type}: left={Left}, top={Top}, size={W}x{H}, angle={Angle}° -> center=({CX},{CY}), draw=({DX},{DY})",
element.Id, element.Type,
left, top, width, height, element.Position.Rotation,
centerX, centerY, drawX, drawY);
// Save canvas state for rotation
canvas.Save();
// Apply rotation around the center of the object
if (element.Position.Rotation != 0)
{
canvas.RotateDegrees(element.Position.Rotation, centerX, centerY);
}
var style = element.Style;
switch (element.Type.ToLower())
{
case "shape":
RenderShapeToCanvas(canvas, drawX, drawY, width, height, style);
break;
case "line":
RenderLineToCanvas(canvas, drawX, drawY, width, height, style);
break;
case "text":
RenderTextToCanvas(canvas, element, drawX, drawY, width, height, scaleX, scaleY, dataContext, pageContext);
break;
case "image":
RenderImageToCanvas(canvas, element, drawX, drawY, width, height, resources);
break;
case "table":
RenderTableToCanvas(canvas, element, drawX, drawY, width, height, scaleX, scaleY, dataContext);
break;
}
// Restore canvas state
canvas.Restore();
}
private void RenderShapeToCanvas(SKCanvas canvas, float x, float y, float width, float height, AprtStyle? style)
{
var rect = new SKRect(x, y, x + width, y + height);
// Fill
var bgColor = style?.BackgroundColor ?? "transparent";
if (!string.IsNullOrEmpty(bgColor) && bgColor.ToLower() != "transparent")
{
using var fillPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = ParseColor(bgColor),
IsAntialias = true
};
canvas.DrawRect(rect, fillPaint);
}
// Stroke
var strokeWidth = style?.BorderWidth ?? 0;
if (strokeWidth > 0)
{
using var strokePaint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = ParseColor(style?.BorderColor ?? "#000000"),
StrokeWidth = strokeWidth,
IsAntialias = true
};
canvas.DrawRect(rect, strokePaint);
}
}
private void RenderLineToCanvas(SKCanvas canvas, float x, float y, float width, float height, AprtStyle? style)
{
var lineY = y + height / 2;
var lineWidth = Math.Max(1f, style?.BorderWidth ?? 1);
using var paint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = ParseColor(style?.Color ?? "#000000"),
StrokeWidth = lineWidth,
IsAntialias = true
};
canvas.DrawLine(x, lineY, x + width, lineY, paint);
}
private void RenderTextToCanvas(SKCanvas canvas, AprtElement element, float x, float y, float width, float height,
float scaleX, float scaleY, Dictionary<string, object> dataContext, PageContext? pageContext = null)
{
var style = element.Style;
var text = ResolveContent(element.Content, dataContext, pageContext);
if (string.IsNullOrEmpty(text)) return;
// Use average scale for font size to maintain proportions
var avgScale = (scaleX + scaleY) / 2f;
// Font size: template stores screen pixels at 96 DPI
// Convert: fontSize (screen px) -> mm -> render px
// 1 screen px at 96 DPI = 25.4/96 = 0.2646 mm
var fontSizePx = (style?.FontSize ?? 12) * 0.2646f * avgScale;
// Background
if (!string.IsNullOrEmpty(style?.BackgroundColor) && style.BackgroundColor.ToLower() != "transparent")
{
using var bgPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = ParseColor(style.BackgroundColor),
IsAntialias = true
};
canvas.DrawRect(x, y, width, height, bgPaint);
}
// Border
if ((style?.BorderWidth ?? 0) > 0)
{
using var borderPaint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = ParseColor(style!.BorderColor ?? "#000000"),
StrokeWidth = style.BorderWidth * 0.2646f * avgScale,
IsAntialias = true
};
canvas.DrawRect(x, y, width, height, borderPaint);
}
// Text
using var textPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = ParseColor(style?.Color ?? "#000000"),
TextSize = fontSizePx,
IsAntialias = true,
Typeface = SKTypeface.FromFamilyName(
style?.FontFamily ?? "Helvetica",
style?.FontWeight?.ToLower() == "bold" ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
SKFontStyleWidth.Normal,
style?.FontStyle?.ToLower() == "italic" ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright
)
};
// Calculate text position based on alignment
float textX = style?.TextAlign?.ToLower() switch
{
"center" => x + width / 2,
"right" => x + width,
_ => x
};
textPaint.TextAlign = style?.TextAlign?.ToLower() switch
{
"center" => SKTextAlign.Center,
"right" => SKTextAlign.Right,
_ => SKTextAlign.Left
};
// Vertical alignment
var metrics = textPaint.FontMetrics;
float textY = style?.VerticalAlign?.ToLower() switch
{
"middle" => y + height / 2 - (metrics.Ascent + metrics.Descent) / 2,
"bottom" => y + height - metrics.Descent,
_ => y - metrics.Ascent // top
};
canvas.DrawText(text, textX, textY, textPaint);
}
private void RenderImageToCanvas(SKCanvas canvas, AprtElement element, float x, float y, float width, float height,
Dictionary<string, object> resources)
{
SKBitmap? bitmap = null;
try
{
// Try to load image from various sources
if (element.ImageSettings?.Src != null)
{
var src = element.ImageSettings.Src;
if (src.StartsWith("data:image"))
{
// Data URI - extract base64 data
var commaIndex = src.IndexOf(',');
if (commaIndex > 0)
{
var base64Data = src.Substring(commaIndex + 1);
var imageBytes = Convert.FromBase64String(base64Data);
bitmap = SKBitmap.Decode(imageBytes);
_logger.LogDebug("Image {ElementId}: Decoded from data URI ({Bytes} bytes)", element.Id, imageBytes.Length);
}
}
else if (src.StartsWith("/api/report-resources/images/"))
{
// API URL - load from resources
var match = Regex.Match(src, @"/api/report-resources/images/(\d+)");
if (match.Success)
{
var imageId = match.Groups[1].Value;
if (resources.TryGetValue($"image_{imageId}", out var imgData) && imgData is byte[] bytes)
{
bitmap = SKBitmap.Decode(bytes);
_logger.LogDebug("Image {ElementId}: Loaded from resources (ID: {ImageId})", element.Id, imageId);
}
}
}
}
// Fallback: Check Content.ResourceId
if (bitmap == null && element.Content?.ResourceId != null)
{
if (resources.TryGetValue($"image_{element.Content.ResourceId}", out var resourceData) && resourceData is byte[] bytes)
{
bitmap = SKBitmap.Decode(bytes);
_logger.LogDebug("Image {ElementId}: Loaded from Content.ResourceId", element.Id);
}
}
if (bitmap != null)
{
var destRect = new SKRect(x, y, x + width, y + height);
// Apply flip transformations if needed
if (element.ImageSettings?.FlipHorizontal == true || element.ImageSettings?.FlipVertical == true)
{
canvas.Save();
if (element.ImageSettings.FlipHorizontal == true)
{
canvas.Scale(-1, 1, x + width / 2, y + height / 2);
}
if (element.ImageSettings.FlipVertical == true)
{
canvas.Scale(1, -1, x + width / 2, y + height / 2);
}
}
using var paint = new SKPaint
{
IsAntialias = true,
FilterQuality = SKFilterQuality.High
};
canvas.DrawBitmap(bitmap, destRect, paint);
if (element.ImageSettings?.FlipHorizontal == true || element.ImageSettings?.FlipVertical == true)
{
canvas.Restore();
}
_logger.LogDebug("Image {ElementId}: Rendered at ({X}, {Y}) size ({Width}x{Height})",
element.Id, x, y, width, height);
}
else
{
// No image found - render placeholder
_logger.LogWarning("Image {ElementId}: No source found, rendering placeholder", element.Id);
RenderImagePlaceholder(canvas, x, y, width, height);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to render image {ElementId}", element.Id);
RenderImagePlaceholder(canvas, x, y, width, height);
}
finally
{
bitmap?.Dispose();
}
}
private void RenderImagePlaceholder(SKCanvas canvas, float x, float y, float width, float height)
{
// Draw placeholder rectangle
using var fillPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = new SKColor(240, 240, 240),
IsAntialias = true
};
canvas.DrawRect(x, y, width, height, fillPaint);
using var strokePaint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = new SKColor(200, 200, 200),
StrokeWidth = 1,
PathEffect = SKPathEffect.CreateDash(new[] { 4f, 4f }, 0),
IsAntialias = true
};
canvas.DrawRect(x, y, width, height, strokePaint);
// Draw "Image" text
using var textPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = new SKColor(150, 150, 150),
TextSize = Math.Min(width, height) * 0.15f,
TextAlign = SKTextAlign.Center,
IsAntialias = true
};
canvas.DrawText("[Immagine]", x + width / 2, y + height / 2, textPaint);
}
private void RenderTableToCanvas(SKCanvas canvas, AprtElement element, float x, float y, float width, float height,
float scaleX, float scaleY, Dictionary<string, object> dataContext)
{
if (element.Columns == null || element.Columns.Count == 0) return;
var items = ResolveTableDataSource(element.DataSource, dataContext)?.ToList() ?? new List<object>();
// Use average scale for consistent proportions
var avgScale = (scaleX + scaleY) / 2f;
// Table dimensions (values are in mm, convert to pixels)
var rowHeight = 5f * scaleY;
var headerHeight = 6f * scaleY;
var fontSize = 2.5f * avgScale;
var padding = 1f * scaleX;
var currentY = y;
// Calculate column widths
var totalColumnWidth = element.Columns.Sum(c => c.Width);
var colScale = width / totalColumnWidth;
// Header background
using var headerBgPaint = new SKPaint { Style = SKPaintStyle.Fill, Color = new SKColor(224, 224, 224) };
canvas.DrawRect(x, currentY, width, headerHeight, headerBgPaint);
// Header text
var currentX = x;
using var headerTextPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = SKColors.Black,
TextSize = fontSize,
IsAntialias = true,
Typeface = SKTypeface.FromFamilyName("Helvetica", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright)
};
foreach (var col in element.Columns)
{
var colWidth = col.Width * colScale;
headerTextPaint.TextAlign = col.Align switch
{
"center" => SKTextAlign.Center,
"right" => SKTextAlign.Right,
_ => SKTextAlign.Left
};
var textX = col.Align switch
{
"center" => currentX + colWidth / 2,
"right" => currentX + colWidth - padding,
_ => currentX + padding
};
canvas.DrawText(col.Header ?? "", textX, currentY + headerHeight / 2 + fontSize / 3, headerTextPaint);
currentX += colWidth;
}
currentY += headerHeight;
// Data rows
using var rowTextPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = SKColors.Black,
TextSize = fontSize,
IsAntialias = true
};
using var rowBgPaint = new SKPaint { Style = SKPaintStyle.Fill };
using var linePaint = new SKPaint { Style = SKPaintStyle.Stroke, Color = new SKColor(224, 224, 224), StrokeWidth = 0.5f };
for (int rowIndex = 0; rowIndex < items.Count; rowIndex++)
{
var item = items[rowIndex];
currentX = x;
// Row background
rowBgPaint.Color = rowIndex % 2 == 0 ? SKColors.White : new SKColor(248, 248, 248);
canvas.DrawRect(x, currentY, width, rowHeight, rowBgPaint);
foreach (var col in element.Columns)
{
var colWidth = col.Width * colScale;
var fieldPath = col.Field.Trim();
if (fieldPath.StartsWith("{{") && fieldPath.EndsWith("}}"))
fieldPath = fieldPath.Substring(2, fieldPath.Length - 4).Trim();
var value = GetPropertyValue(item, fieldPath);
var formattedValue = FormatValue(value, col.Format) ?? "";
rowTextPaint.TextAlign = col.Align switch
{
"center" => SKTextAlign.Center,
"right" => SKTextAlign.Right,
_ => SKTextAlign.Left
};
var textX = col.Align switch
{
"center" => currentX + colWidth / 2,
"right" => currentX + colWidth - padding,
_ => currentX + padding
};
canvas.DrawText(formattedValue, textX, currentY + rowHeight / 2 + fontSize / 3, rowTextPaint);
currentX += colWidth;
}
// Row border
canvas.DrawLine(x, currentY + rowHeight, x + width, currentY + rowHeight, linePaint);
currentY += rowHeight;
}
// Table border
using var borderPaint = new SKPaint { Style = SKPaintStyle.Stroke, Color = new SKColor(200, 200, 200), StrokeWidth = 1 };
var tableHeight = headerHeight + items.Count * rowHeight;
canvas.DrawRect(x, y, width, tableHeight, borderPaint);
}
/// <summary>
/// Parses a CSS color string to SKColor
/// </summary>
private static SKColor ParseColor(string color)
{
if (string.IsNullOrEmpty(color) || color.ToLower() == "transparent")
return SKColors.Transparent;
if (color.StartsWith("#"))
{
// Hex color
var hex = color.TrimStart('#');
if (hex.Length == 3)
{
// Short form #RGB -> #RRGGBB
hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}";
}
if (hex.Length == 6)
{
return new SKColor(
Convert.ToByte(hex.Substring(0, 2), 16),
Convert.ToByte(hex.Substring(2, 2), 16),
Convert.ToByte(hex.Substring(4, 2), 16)
);
}
if (hex.Length == 8)
{
// RGBA
return new SKColor(
Convert.ToByte(hex.Substring(0, 2), 16),
Convert.ToByte(hex.Substring(2, 2), 16),
Convert.ToByte(hex.Substring(4, 2), 16),
Convert.ToByte(hex.Substring(6, 2), 16)
);
}
}
// Try named colors
return color.ToLower() switch
{
"black" => SKColors.Black,
"white" => SKColors.White,
"red" => SKColors.Red,
"green" => SKColors.Green,
"blue" => SKColors.Blue,
"yellow" => SKColors.Yellow,
"gray" or "grey" => SKColors.Gray,
_ => SKColors.Black
};
}
/// <summary>
/// Generates SVG content with absolute positioned elements.
/// All positions in the template are in mm, relative to the content area (inside margins).
/// </summary>
private string GenerateSvgContent(List<AprtElement> elements, float contentWidthMm, float contentHeightMm,
Dictionary<string, object> dataContext, Dictionary<string, object> resources)
{
// SVG viewBox in mm - this ensures 1 unit = 1mm in our coordinate system
var svgBuilder = new System.Text.StringBuilder();
// Use mm as the unit system in SVG via viewBox
// The viewBox maps the coordinate space, so positions in mm work directly
svgBuilder.AppendLine($"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" " +
$"width=\"{contentWidthMm}mm\" height=\"{contentHeightMm}mm\" " +
$"viewBox=\"0 0 {contentWidthMm.ToString(CultureInfo.InvariantCulture)} {contentHeightMm.ToString(CultureInfo.InvariantCulture)}\">");
// Add style definitions
svgBuilder.AppendLine("<defs>");
svgBuilder.AppendLine("<style type=\"text/css\">");
svgBuilder.AppendLine("text { font-family: 'Helvetica', 'Arial', sans-serif; }");
svgBuilder.AppendLine("</style>");
svgBuilder.AppendLine("</defs>");
// Render each element at its absolute position (positions are already in mm)
foreach (var element in elements.OrderBy(e => elements.IndexOf(e)))
{
// Positions from the template are in mm, relative to content area
// Apply the same Fabric.js coordinate transformation as in RenderElementToCanvas
var left = element.Position.X;
var top = element.Position.Y;
var width = element.Position.Width;
var height = element.Position.Height;
var angleRad = element.Position.Rotation * (float)Math.PI / 180f;
// Calculate center using Fabric.js formula (originX='left', originY='top')
var halfWidth = width / 2;
var halfHeight = height / 2;
var cos = (float)Math.Cos(angleRad);
var sin = (float)Math.Sin(angleRad);
var centerX = left + halfWidth * cos - halfHeight * sin;
var centerY = top + halfWidth * sin + halfHeight * cos;
// Calculate draw position (top-left of unrotated rect centered on center)
var drawX = centerX - halfWidth;
var drawY = centerY - halfHeight;
_logger.LogDebug("SVG element {ElementId} type={Type}: left={Left}, top={Top}, angle={Angle}° -> center=({CX},{CY}), draw=({DX},{DY})",
element.Id, element.Type, left, top, element.Position.Rotation, centerX, centerY, drawX, drawY);
svgBuilder.AppendLine(RenderElementToSvg(element, drawX, drawY, width, height, centerX, centerY, dataContext, resources));
}
svgBuilder.AppendLine("</svg>");
return svgBuilder.ToString();
}
/// <summary>
/// Renders a single element to SVG. All dimensions are in mm.
/// x, y are the draw coordinates (top-left of the unrotated element)
/// centerX, centerY are the center coordinates for rotation
/// </summary>
private string RenderElementToSvg(AprtElement element, float x, float y, float width, float height,
float centerX, float centerY, Dictionary<string, object> dataContext, Dictionary<string, object> resources)
{
var svg = new System.Text.StringBuilder();
var style = element.Style;
// Apply rotation transform if needed (rotate around the calculated center)
var transform = element.Position.Rotation != 0
? $" transform=\"rotate({Fmt(element.Position.Rotation)} {Fmt(centerX)} {Fmt(centerY)})\""
: "";
switch (element.Type.ToLower())
{
case "shape":
var bgColor = style?.BackgroundColor ?? "transparent";
var strokeColor = style?.BorderColor ?? "#000000";
var strokeWidth = style?.BorderWidth ?? 0;
svg.AppendLine($"<rect x=\"{Fmt(x)}\" y=\"{Fmt(y)}\" width=\"{Fmt(width)}\" height=\"{Fmt(height)}\" " +
$"fill=\"{EscapeSvgColor(bgColor)}\" stroke=\"{EscapeSvgColor(strokeColor)}\" stroke-width=\"{Fmt(strokeWidth)}\"{transform} />");
break;
case "line":
var lineColor = style?.Color ?? "#000000";
var lineWidth = Math.Max(0.3f, style?.BorderWidth ?? 1); // Minimum line width for visibility
// Draw horizontal line in the middle of the element height
var lineY = y + height / 2;
svg.AppendLine($"<line x1=\"{Fmt(x)}\" y1=\"{Fmt(lineY)}\" x2=\"{Fmt(x + width)}\" y2=\"{Fmt(lineY)}\" " +
$"stroke=\"{EscapeSvgColor(lineColor)}\" stroke-width=\"{Fmt(lineWidth)}\"{transform} />");
break;
case "text":
var text = ResolveContent(element.Content, dataContext);
var escapedText = System.Security.SecurityElement.Escape(text) ?? "";
// Font size in mm (converted from the style which is in points/pixels)
// The template stores fontSize that looks correct on screen at 96 DPI
// In our mm-based SVG, we need to convert: fontSize (screen px) → mm
// At 96 DPI: 1px = 0.2646mm, so fontSize_mm = fontSize_px * 0.2646
var fontSizeMm = (style?.FontSize ?? 12) * 0.2646f;
var fontColor = style?.Color ?? "#000000";
var fontWeight = style?.FontWeight?.ToLower() == "bold" ? "bold" : "normal";
var fontStyle = style?.FontStyle?.ToLower() == "italic" ? "italic" : "normal";
var fontFamily = style?.FontFamily ?? "Helvetica";
// Calculate text anchor based on alignment
var textAnchor = style?.TextAlign?.ToLower() switch
{
"center" => "middle",
"right" => "end",
_ => "start"
};
// Calculate x position based on alignment
var textX = style?.TextAlign?.ToLower() switch
{
"center" => x + width / 2,
"right" => x + width,
_ => x
};
// Background for text element
if (!string.IsNullOrEmpty(style?.BackgroundColor) && style.BackgroundColor != "transparent")
{
svg.AppendLine($"<rect x=\"{Fmt(x)}\" y=\"{Fmt(y)}\" width=\"{Fmt(width)}\" height=\"{Fmt(height)}\" " +
$"fill=\"{EscapeSvgColor(style.BackgroundColor)}\" />");
}
// Border for text element
if ((style?.BorderWidth ?? 0) > 0)
{
svg.AppendLine($"<rect x=\"{Fmt(x)}\" y=\"{Fmt(y)}\" width=\"{Fmt(width)}\" height=\"{Fmt(height)}\" " +
$"fill=\"none\" stroke=\"{EscapeSvgColor(style!.BorderColor ?? "#000000")}\" stroke-width=\"{Fmt(style.BorderWidth)}\" />");
}
// Text baseline adjustment
// SVG text y coordinate is the baseline. For top-aligned text in a box:
// baseline = top + fontSizeMm * 0.85 (approximate ascender height)
var textY = style?.VerticalAlign?.ToLower() switch
{
"middle" => y + height / 2 + fontSizeMm * 0.35f,
"bottom" => y + height - fontSizeMm * 0.2f,
_ => y + fontSizeMm * 0.85f // top (default)
};
svg.AppendLine($"<text x=\"{Fmt(textX)}\" y=\"{Fmt(textY)}\" font-size=\"{Fmt(fontSizeMm)}\" " +
$"font-family=\"{fontFamily}\" fill=\"{EscapeSvgColor(fontColor)}\" font-weight=\"{fontWeight}\" " +
$"font-style=\"{fontStyle}\" text-anchor=\"{textAnchor}\"{transform}>{escapedText}</text>");
break;
case "image":
var imageSvg = RenderImageToSvg(element, x, y, width, height, resources, transform);
if (!string.IsNullOrEmpty(imageSvg))
svg.AppendLine(imageSvg);
break;
case "table":
var tableSvg = RenderTableToSvg(element, x, y, width, height, dataContext);
if (!string.IsNullOrEmpty(tableSvg))
svg.AppendLine(tableSvg);
break;
default:
// Unknown type - render placeholder box
svg.AppendLine($"<rect x=\"{Fmt(x)}\" y=\"{Fmt(y)}\" width=\"{Fmt(width)}\" height=\"{Fmt(height)}\" " +
$"fill=\"#f0f0f0\" stroke=\"#cccccc\" stroke-width=\"0.3\" />");
svg.AppendLine($"<text x=\"{Fmt(x + width / 2)}\" y=\"{Fmt(y + height / 2)}\" font-size=\"3\" " +
$"fill=\"#999999\" text-anchor=\"middle\">[{element.Type}]</text>");
break;
}
return svg.ToString();
}
/// <summary>
/// Renders image element to SVG. Handles data URIs, API URLs, and resource references.
/// </summary>
private string RenderImageToSvg(AprtElement element, float x, float y, float width, float height,
Dictionary<string, object> resources, string transform)
{
string? imageHref = null;
// Priority 1: Check imageSettings.src for data URI or URL
if (element.ImageSettings?.Src != null)
{
var src = element.ImageSettings.Src;
if (src.StartsWith("data:image"))
{
// Already a data URI - use directly
imageHref = src;
_logger.LogDebug("Image element {ElementId}: Using embedded data URI", element.Id);
}
else if (src.StartsWith("/api/report-resources/images/"))
{
// API URL - try to load from resources or database
var match = Regex.Match(src, @"/api/report-resources/images/(\d+)");
if (match.Success)
{
var imageId = match.Groups[1].Value;
if (resources.TryGetValue($"image_{imageId}", out var imgData) && imgData is byte[] bytes)
{
var mimeType = GuessMimeType(bytes);
imageHref = $"data:{mimeType};base64,{Convert.ToBase64String(bytes)}";
_logger.LogDebug("Image element {ElementId}: Loaded from resources (ID: {ImageId})", element.Id, imageId);
}
}
}
else if (src.StartsWith("http://") || src.StartsWith("https://"))
{
// External URL - use directly (browser/PDF renderer will fetch)
imageHref = src;
_logger.LogDebug("Image element {ElementId}: Using external URL", element.Id);
}
}
// Priority 2: Check Content.ResourceId
if (imageHref == null && element.Content?.ResourceId != null)
{
if (resources.TryGetValue($"image_{element.Content.ResourceId}", out var resourceData) && resourceData is byte[] bytes)
{
var mimeType = GuessMimeType(bytes);
imageHref = $"data:{mimeType};base64,{Convert.ToBase64String(bytes)}";
_logger.LogDebug("Image element {ElementId}: Loaded from Content.ResourceId", element.Id);
}
}
if (imageHref != null)
{
// Determine preserveAspectRatio based on fitMode
var preserveAspectRatio = element.ImageSettings?.FitMode switch
{
"cover" => "xMidYMid slice",
"fill" => "none",
"none" => "xMinYMin",
_ => "xMidYMid meet" // "contain" is default
};
return $"<image x=\"{Fmt(x)}\" y=\"{Fmt(y)}\" width=\"{Fmt(width)}\" height=\"{Fmt(height)}\" " +
$"href=\"{imageHref}\" preserveAspectRatio=\"{preserveAspectRatio}\"{transform} />";
}
// No image found - render placeholder
_logger.LogWarning("Image element {ElementId}: No image source found, rendering placeholder", element.Id);
return $"<rect x=\"{Fmt(x)}\" y=\"{Fmt(y)}\" width=\"{Fmt(width)}\" height=\"{Fmt(height)}\" " +
$"fill=\"#f0f0f0\" stroke=\"#cccccc\" stroke-width=\"0.3\" stroke-dasharray=\"2,2\" />" +
$"<text x=\"{Fmt(x + width / 2)}\" y=\"{Fmt(y + height / 2)}\" font-size=\"3\" " +
$"fill=\"#999999\" text-anchor=\"middle\">[Immagine]</text>";
}
/// <summary>
/// Guesses MIME type from image bytes by checking magic numbers
/// </summary>
private static string GuessMimeType(byte[] bytes)
{
if (bytes.Length < 4) return "image/png";
// PNG: 89 50 4E 47
if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
return "image/png";
// JPEG: FF D8 FF
if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
return "image/jpeg";
// GIF: 47 49 46
if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46)
return "image/gif";
// WebP: 52 49 46 46 ... 57 45 42 50
if (bytes.Length >= 12 && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46
&& bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50)
return "image/webp";
return "image/png"; // Default
}
/// <summary>
/// Renders table element to SVG. All dimensions in mm.
/// </summary>
private string RenderTableToSvg(AprtElement element, float x, float y, float width, float height,
Dictionary<string, object> dataContext)
{
if (element.Columns == null || element.Columns.Count == 0)
return string.Empty;
var svg = new System.Text.StringBuilder();
var items = ResolveTableDataSource(element.DataSource, dataContext)?.ToList() ?? new List<object>();
// Table dimensions in mm
var rowHeightMm = 5f;
var headerHeightMm = 6f;
var fontSizeMm = 2.5f;
var paddingMm = 1f;
var currentY = y;
// Calculate column widths proportionally to fit the table width
var totalColumnWidth = element.Columns.Sum(c => c.Width);
var scale = width / totalColumnWidth;
// Draw header row background
svg.AppendLine($"<rect x=\"{Fmt(x)}\" y=\"{Fmt(currentY)}\" width=\"{Fmt(width)}\" height=\"{Fmt(headerHeightMm)}\" fill=\"#e0e0e0\" />");
// Draw header text
var currentX = x;
foreach (var col in element.Columns)
{
var colWidth = col.Width * scale;
var textX = col.Align switch
{
"center" => currentX + colWidth / 2,
"right" => currentX + colWidth - paddingMm,
_ => currentX + paddingMm
};
var anchor = col.Align switch
{
"center" => "middle",
"right" => "end",
_ => "start"
};
svg.AppendLine($"<text x=\"{Fmt(textX)}\" y=\"{Fmt(currentY + headerHeightMm / 2 + fontSizeMm / 3)}\" " +
$"font-size=\"{Fmt(fontSizeMm)}\" font-weight=\"bold\" fill=\"#000000\" text-anchor=\"{anchor}\">" +
$"{System.Security.SecurityElement.Escape(col.Header)}</text>");
currentX += colWidth;
}
currentY += headerHeightMm;
// Draw data rows
for (int rowIndex = 0; rowIndex < items.Count; rowIndex++)
{
var item = items[rowIndex];
currentX = x;
// Row background (alternating)
var rowBg = rowIndex % 2 == 0 ? "#ffffff" : "#f8f8f8";
svg.AppendLine($"<rect x=\"{Fmt(x)}\" y=\"{Fmt(currentY)}\" width=\"{Fmt(width)}\" height=\"{Fmt(rowHeightMm)}\" fill=\"{rowBg}\" />");
foreach (var col in element.Columns)
{
var colWidth = col.Width * scale;
// Get field value
var fieldPath = col.Field.Trim();
if (fieldPath.StartsWith("{{") && fieldPath.EndsWith("}}"))
fieldPath = fieldPath.Substring(2, fieldPath.Length - 4).Trim();
var value = GetPropertyValue(item, fieldPath);
var formattedValue = FormatValue(value, col.Format);
var escapedValue = System.Security.SecurityElement.Escape(formattedValue) ?? "";
var textX = col.Align switch
{
"center" => currentX + colWidth / 2,
"right" => currentX + colWidth - paddingMm,
_ => currentX + paddingMm
};
var anchor = col.Align switch
{
"center" => "middle",
"right" => "end",
_ => "start"
};
svg.AppendLine($"<text x=\"{Fmt(textX)}\" y=\"{Fmt(currentY + rowHeightMm / 2 + fontSizeMm / 3)}\" " +
$"font-size=\"{Fmt(fontSizeMm)}\" fill=\"#000000\" text-anchor=\"{anchor}\">{escapedValue}</text>");
currentX += colWidth;
}
// Row border
svg.AppendLine($"<line x1=\"{Fmt(x)}\" y1=\"{Fmt(currentY + rowHeightMm)}\" x2=\"{Fmt(x + width)}\" y2=\"{Fmt(currentY + rowHeightMm)}\" stroke=\"#e0e0e0\" stroke-width=\"0.2\" />");
currentY += rowHeightMm;
}
// Table border
var tableHeight = headerHeightMm + items.Count * rowHeightMm;
svg.AppendLine($"<rect x=\"{Fmt(x)}\" y=\"{Fmt(y)}\" width=\"{Fmt(width)}\" height=\"{Fmt(tableHeight)}\" fill=\"none\" stroke=\"#cccccc\" stroke-width=\"0.3\" />");
return svg.ToString();
}
/// <summary>
/// Formats a float for SVG with invariant culture and reasonable precision
/// </summary>
private static string Fmt(float value) => value.ToString("F2", CultureInfo.InvariantCulture);
/// <summary>
/// Escapes color for SVG (handles 'transparent')
/// </summary>
private static string EscapeSvgColor(string color)
{
if (string.IsNullOrEmpty(color) || color.ToLower() == "transparent")
return "none";
return color;
}
private static (float Width, float Height) GetPageDimensionsMm(string size, string orientation)
{
var (width, height) = size.ToUpper() switch
{
"A3" => (297f, 420f),
"A4" => (210f, 297f),
"A5" => (148f, 210f),
"LETTER" => (216f, 279f),
"LEGAL" => (216f, 356f),
_ => (210f, 297f) // Default A4
};
return orientation.ToLower() == "landscape" ? (height, width) : (width, height);
}
public async Task<byte[]> GenerateEventoPdfAsync(int eventoId, int? templateId = null)
{
// Load evento with all related data
var evento = await _context.Eventi
.Include(e => e.Cliente)
.Include(e => e.Location)
.Include(e => e.TipoEvento)
.Include(e => e.DettagliOspiti).ThenInclude(d => d.TipoOspite)
.Include(e => e.DettagliPrelievo).ThenInclude(d => d.Articolo)
.Include(e => e.DettagliRisorse).ThenInclude(d => d.Risorsa)
.Include(e => e.Acconti)
.Include(e => e.AltriCosti)
.FirstOrDefaultAsync(e => e.Id == eventoId);
if (evento == null)
throw new ArgumentException($"Evento with ID {eventoId} not found");
// If no template specified, use default or generate basic PDF
if (templateId == null)
{
return GenerateDefaultEventoPdf(evento);
}
var dataContext = new Dictionary<string, object>
{
["evento"] = evento,
["cliente"] = evento.Cliente ?? new Cliente(),
["location"] = evento.Location ?? new Location(),
["ospiti"] = evento.DettagliOspiti.ToList(),
["prelievo"] = evento.DettagliPrelievo.ToList(),
["risorse"] = evento.DettagliRisorse.ToList(),
["acconti"] = evento.Acconti.ToList(),
["altriCosti"] = evento.AltriCosti.ToList()
};
return await GeneratePdfAsync(templateId.Value, dataContext);
}
private byte[] GenerateDefaultEventoPdf(Evento evento)
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(15, Unit.Millimetre);
page.DefaultTextStyle(x => x.FontSize(10));
// Header
page.Header().Element(header =>
{
header.Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("SCHEDA EVENTO").Bold().FontSize(20).FontColor(Colors.Blue.Darken2);
col.Item().Text($"Codice: {evento.Codice ?? $"EVT-{evento.Id:D5}"}").FontSize(12);
});
row.ConstantItem(150).Column(col =>
{
col.Item().AlignRight().Text($"Data: {evento.DataEvento:dd/MM/yyyy}").Bold();
col.Item().AlignRight().Text(GetStatoLabel(evento.Stato)).FontColor(GetStatoColor(evento.Stato));
});
});
});
// Content
page.Content().PaddingVertical(10).Column(content =>
{
// Client info
content.Item().Background(Colors.Grey.Lighten4).Padding(10).Column(clientSection =>
{
clientSection.Item().Text("CLIENTE").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
clientSection.Item().PaddingTop(5).Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text(evento.Cliente?.RagioneSociale ?? "Non specificato").Bold();
if (!string.IsNullOrEmpty(evento.Cliente?.Indirizzo))
col.Item().Text($"{evento.Cliente.Indirizzo}, {evento.Cliente.Citta} ({evento.Cliente.Provincia})");
if (!string.IsNullOrEmpty(evento.Cliente?.Telefono))
col.Item().Text($"Tel: {evento.Cliente.Telefono}");
});
row.RelativeItem().Column(col =>
{
if (!string.IsNullOrEmpty(evento.Cliente?.Email))
col.Item().Text($"Email: {evento.Cliente.Email}");
if (!string.IsNullOrEmpty(evento.Cliente?.PartitaIva))
col.Item().Text($"P.IVA: {evento.Cliente.PartitaIva}");
});
});
});
content.Item().PaddingVertical(5);
// Location info
content.Item().Background(Colors.Grey.Lighten4).Padding(10).Column(locSection =>
{
locSection.Item().Text("LOCATION").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
locSection.Item().PaddingTop(5).Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text(evento.Location?.Nome ?? "Non specificata").Bold();
if (!string.IsNullOrEmpty(evento.Location?.Indirizzo))
col.Item().Text($"{evento.Location.Indirizzo}, {evento.Location.Citta} ({evento.Location.Provincia})");
});
row.RelativeItem().Column(col =>
{
if (evento.OraInizio.HasValue)
col.Item().Text($"Ora: {evento.OraInizio:hh\\:mm} - {evento.OraFine:hh\\:mm}");
if (evento.Location?.DistanzaKm > 0)
col.Item().Text($"Distanza: {evento.Location.DistanzaKm} km");
});
});
});
content.Item().PaddingVertical(5);
// Event details
content.Item().Row(row =>
{
row.RelativeItem().Background(Colors.Grey.Lighten4).Padding(10).Column(col =>
{
col.Item().Text("TIPO EVENTO").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
col.Item().PaddingTop(5).Text(evento.TipoEvento?.Descrizione ?? "Non specificato").Bold();
});
row.ConstantItem(10);
row.RelativeItem().Background(Colors.Grey.Lighten4).Padding(10).Column(col =>
{
col.Item().Text("OSPITI").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
col.Item().PaddingTop(5).Text($"{evento.NumeroOspiti ?? 0} totali").Bold();
});
});
content.Item().PaddingVertical(10);
// Guests table
if (evento.DettagliOspiti.Any())
{
content.Item().Text("DETTAGLIO OSPITI").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
content.Item().PaddingTop(5).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
});
table.Header(header =>
{
header.Cell().Background(Colors.Blue.Darken2).Padding(5).Text("Tipo").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignRight().Text("Numero").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignRight().Text("Costo Unit.").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignRight().Text("Sconto").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Blue.Darken2).Padding(5).AlignRight().Text("Totale").FontColor(Colors.White).Bold();
});
foreach (var ospite in evento.DettagliOspiti.OrderBy(o => o.Ordine))
{
var totale = ospite.Numero * (ospite.CostoUnitario ?? 0) * (1 - (ospite.Sconto ?? 0) / 100);
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.Text(ospite.TipoOspite?.Descrizione ?? "N/D");
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.AlignRight().Text(ospite.Numero.ToString());
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.AlignRight().Text(FormatCurrency(ospite.CostoUnitario ?? 0));
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.AlignRight().Text($"{ospite.Sconto ?? 0}%");
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.AlignRight().Text(FormatCurrency(totale)).Bold();
}
});
}
content.Item().PaddingVertical(10);
// Other costs table
if (evento.AltriCosti.Any())
{
content.Item().Text("ALTRI COSTI").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
content.Item().PaddingTop(5).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(4);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
});
table.Header(header =>
{
header.Cell().Background(Colors.Orange.Darken2).Padding(5).Text("Descrizione").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Orange.Darken2).Padding(5).AlignRight().Text("Costo").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Orange.Darken2).Padding(5).AlignRight().Text("Qtà").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Orange.Darken2).Padding(5).AlignRight().Text("IVA").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Orange.Darken2).Padding(5).AlignRight().Text("Totale").FontColor(Colors.White).Bold();
});
foreach (var costo in evento.AltriCosti.OrderBy(c => c.Ordine))
{
var totale = costo.CostoUnitario * costo.Quantita;
if (costo.ApplicaIva)
totale *= (1 + costo.AliquotaIva / 100);
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(costo.Descrizione);
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5).AlignRight().Text(FormatCurrency(costo.CostoUnitario));
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5).AlignRight().Text(costo.Quantita.ToString("N2"));
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5).AlignRight().Text(costo.ApplicaIva ? $"{costo.AliquotaIva}%" : "-");
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5).AlignRight().Text(FormatCurrency(totale)).Bold();
}
});
}
content.Item().PaddingVertical(10);
// Resources table
if (evento.DettagliRisorse.Any())
{
content.Item().Text("RISORSE ASSEGNATE").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
content.Item().PaddingTop(5).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(3);
columns.RelativeColumn(2);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
columns.RelativeColumn(1);
});
table.Header(header =>
{
header.Cell().Background(Colors.Green.Darken2).Padding(5).Text("Risorsa").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Green.Darken2).Padding(5).Text("Ruolo").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Green.Darken2).Padding(5).AlignCenter().Text("Orario").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Green.Darken2).Padding(5).AlignRight().Text("Ore").FontColor(Colors.White).Bold();
header.Cell().Background(Colors.Green.Darken2).Padding(5).AlignRight().Text("Costo").FontColor(Colors.White).Bold();
});
foreach (var risorsa in evento.DettagliRisorse)
{
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.Text($"{risorsa.Risorsa?.Nome} {risorsa.Risorsa?.Cognome}".Trim());
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.Text(risorsa.Ruolo ?? "-");
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.AlignCenter().Text($"{risorsa.OraInizio:hh\\:mm} - {risorsa.OraFine:hh\\:mm}");
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.AlignRight().Text(risorsa.OreLavoro?.ToString("N1") ?? "-");
table.Cell().BorderBottom(1).BorderColor(Colors.Grey.Lighten2).Padding(5)
.AlignRight().Text(FormatCurrency(risorsa.Costo ?? 0));
}
});
}
content.Item().PaddingVertical(10);
// Totals summary
content.Item().Background(Colors.Blue.Lighten5).Padding(10).Column(totals =>
{
totals.Item().Text("RIEPILOGO COSTI").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
totals.Item().PaddingTop(10).Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("Costo Totale:").Bold();
col.Item().Text("Totale Acconti:");
col.Item().Text("Saldo da Pagare:").Bold().FontColor(Colors.Red.Darken2);
});
row.ConstantItem(150).Column(col =>
{
col.Item().AlignRight().Text(FormatCurrency(evento.CostoTotale ?? 0)).FontSize(14).Bold();
col.Item().AlignRight().Text(FormatCurrency(evento.TotaleAcconti ?? 0));
col.Item().AlignRight().Text(FormatCurrency(evento.Saldo ?? 0)).FontSize(14).Bold().FontColor(Colors.Red.Darken2);
});
});
});
// Notes
if (!string.IsNullOrEmpty(evento.NoteCliente) || !string.IsNullOrEmpty(evento.NoteInterne))
{
content.Item().PaddingVertical(10);
content.Item().Text("NOTE").Bold().FontSize(12).FontColor(Colors.Blue.Darken2);
if (!string.IsNullOrEmpty(evento.NoteCliente))
{
content.Item().PaddingTop(5).Text("Note Cliente:").Bold();
content.Item().Text(evento.NoteCliente);
}
if (!string.IsNullOrEmpty(evento.NoteInterne))
{
content.Item().PaddingTop(5).Text("Note Interne:").Bold();
content.Item().Text(evento.NoteInterne);
}
}
});
// Footer
page.Footer().AlignCenter().Text(text =>
{
text.Span("Pagina ");
text.CurrentPageNumber();
text.Span(" di ");
text.TotalPages();
text.Span($" - Generato il {DateTime.Now:dd/MM/yyyy HH:mm}");
});
});
});
return document.GeneratePdf();
}
private IEnumerable<object>? ResolveTableDataSource(string? dataSource, Dictionary<string, object> dataContext)
{
if (string.IsNullOrEmpty(dataSource)) return null;
// Check if it's a path like "dataset.collection" (e.g., "evento.dettagliOspiti")
if (dataSource.Contains('.'))
{
var parts = dataSource.Split('.', 2);
var datasetId = parts[0];
var collectionPath = parts[1];
if (dataContext.TryGetValue(datasetId, out var dataset))
{
var collectionValue = GetPropertyValue(dataset, collectionPath);
if (collectionValue is IEnumerable<object> collection)
{
return collection;
}
// Handle IEnumerable that's not IEnumerable<object>
if (collectionValue is System.Collections.IEnumerable enumerable)
{
return enumerable.Cast<object>();
}
}
return null;
}
// Direct dataset reference
if (dataContext.TryGetValue(dataSource, out var data))
{
if (data is IEnumerable<object> items)
{
return items;
}
// Handle IEnumerable that's not IEnumerable<object>
if (data is System.Collections.IEnumerable enumerable && data is not string)
{
return enumerable.Cast<object>();
}
// Single object - wrap in array
return new[] { data };
}
return null;
}
private string ResolveContent(AprtContent? content, Dictionary<string, object> dataContext, PageContext? pageContext = null)
{
if (content == null) return string.Empty;
var result = content.Type?.ToLower() switch
{
"static" => content.Value ?? string.Empty,
"binding" => ResolveBindingWithFormat(content.Expression ?? content.Value ?? string.Empty, dataContext, content.Format, pageContext),
"expression" => ResolveExpression(content.Value ?? string.Empty, dataContext, pageContext),
_ => content.Value ?? string.Empty
};
return result;
}
private string ResolveBindingWithFormat(string expression, Dictionary<string, object> dataContext, string? format, PageContext? pageContext = null)
{
return BindingRegex.Replace(expression, match =>
{
var path = match.Groups[1].Value.Trim();
var value = ResolveBindingPath(path, dataContext, pageContext);
if (value != null && !string.IsNullOrEmpty(format))
{
return FormatValue(value, format);
}
return value?.ToString() ?? string.Empty;
});
}
private string ResolveBinding(string expression, Dictionary<string, object> dataContext, PageContext? pageContext = null)
{
return BindingRegex.Replace(expression, match =>
{
var path = match.Groups[1].Value.Trim();
var value = ResolveBindingPath(path, dataContext, pageContext);
return value?.ToString() ?? string.Empty;
});
}
private object? ResolveBindingPath(string path, Dictionary<string, object> dataContext, PageContext? pageContext = null)
{
// Handle special variables
if (path.StartsWith("$"))
{
return path switch
{
"$pageNumber" => pageContext?.PageNumber.ToString() ?? "1",
"$totalPages" => pageContext?.TotalPages.ToString() ?? "1",
"$date" => DateTime.Now.ToString("dd/MM/yyyy"),
"$time" => DateTime.Now.ToString("HH:mm"),
"$datetime" => DateTime.Now.ToString("dd/MM/yyyy HH:mm"),
_ => path
};
}
var parts = path.Split('.');
object? current = null;
// Try standard path resolution: datasetId.propertyPath
if (parts.Length > 0 && dataContext.TryGetValue(parts[0], out var root))
{
current = root;
for (int i = 1; i < parts.Length && current != null; i++)
{
current = GetPropertyValue(current, parts[i]);
}
}
// Fallback: if not found and path has no dots, search across all datasets
// This handles legacy bindings like {{dataEvento}} instead of {{evento.dataEvento}}
if (current == null && parts.Length == 1)
{
foreach (var kvp in dataContext)
{
var foundValue = GetPropertyValue(kvp.Value, path);
if (foundValue != null)
{
current = foundValue;
break;
}
}
}
return current;
}
private string ResolveExpression(string expression, Dictionary<string, object> dataContext, PageContext? pageContext = null)
{
return ResolveBinding(expression, dataContext, pageContext);
}
private object? GetPropertyValue(object obj, string propertyPath)
{
var parts = propertyPath.Split('.');
object? current = obj;
foreach (var part in parts)
{
if (current == null) return null;
var type = current.GetType();
var prop = type.GetProperty(part, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase);
if (prop != null)
{
current = prop.GetValue(current);
}
else
{
return null;
}
}
return current;
}
private string FormatValue(object? value, string? format)
{
if (value == null) return string.Empty;
return format?.ToLower() switch
{
"currency" => FormatCurrency(Convert.ToDecimal(value)),
"date" => value is DateTime dt ? dt.ToString("dd/MM/yyyy") : value.ToString() ?? string.Empty,
"datetime" => value is DateTime dtt ? dtt.ToString("dd/MM/yyyy HH:mm") : value.ToString() ?? string.Empty,
"number" => Convert.ToDecimal(value).ToString("N2", CultureInfo.GetCultureInfo("it-IT")),
"percent" => $"{Convert.ToDecimal(value):N2}%",
_ => value.ToString() ?? string.Empty
};
}
private static string FormatCurrency(decimal value)
{
return value.ToString("C2", CultureInfo.GetCultureInfo("it-IT"));
}
private static PageSize GetPageSize(string size, string orientation)
{
var pageSize = size.ToUpper() switch
{
"A3" => PageSizes.A3,
"A4" => PageSizes.A4,
"A5" => PageSizes.A5,
"LETTER" => PageSizes.Letter,
"LEGAL" => PageSizes.Legal,
_ => PageSizes.A4
};
return orientation.ToLower() == "landscape" ? pageSize.Landscape() : pageSize.Portrait();
}
private async Task<Dictionary<string, object>> LoadResourcesAsync(AprtTemplate template)
{
var resources = new Dictionary<string, object>();
// Load images from template resources
foreach (var img in template.Resources.Images)
{
if (!string.IsNullOrEmpty(img.Data))
{
resources[$"image_{img.Id}"] = Convert.FromBase64String(img.Data);
}
else if (img.Url?.StartsWith("/api/") == true)
{
var match = Regex.Match(img.Url, @"/api/report-resources/images/(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out var imageId))
{
var dbImage = await _context.ReportImages.FindAsync(imageId);
if (dbImage != null)
{
resources[$"image_{img.Id}"] = dbImage.ImageData;
}
}
}
}
// Also preload any images referenced in elements by API URL
foreach (var element in template.Elements.Where(e => e.Type == "image"))
{
if (element.ImageSettings?.Src?.StartsWith("/api/report-resources/images/") == true)
{
var match = Regex.Match(element.ImageSettings.Src, @"/api/report-resources/images/(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out var imageId))
{
var key = $"image_{imageId}";
if (!resources.ContainsKey(key))
{
var dbImage = await _context.ReportImages.FindAsync(imageId);
if (dbImage != null)
{
resources[key] = dbImage.ImageData;
}
}
}
}
}
return resources;
}
private static string GetStatoLabel(Apollinare.Domain.Enums.StatoEvento stato)
{
return stato switch
{
Apollinare.Domain.Enums.StatoEvento.Scheda => "SCHEDA",
Apollinare.Domain.Enums.StatoEvento.Preventivo => "PREVENTIVO",
Apollinare.Domain.Enums.StatoEvento.Confermato => "CONFERMATO",
_ => stato.ToString()
};
}
private static string GetStatoColor(Apollinare.Domain.Enums.StatoEvento stato)
{
return stato switch
{
Apollinare.Domain.Enums.StatoEvento.Scheda => Colors.Grey.Medium,
Apollinare.Domain.Enums.StatoEvento.Preventivo => Colors.Orange.Medium,
Apollinare.Domain.Enums.StatoEvento.Confermato => Colors.Green.Medium,
_ => Colors.Grey.Medium
};
}
}