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 _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 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 GeneratePdfAsync(int templateId, Dictionary dataContext) { var template = await _context.ReportTemplates.FindAsync(templateId); if (template == null) throw new ArgumentException($"Template with ID {templateId} not found"); var aprt = JsonSerializer.Deserialize(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(); } /// /// Context for page-level special variables during rendering /// private class PageContext { public int PageNumber { get; set; } public int TotalPages { get; set; } } /// /// Migrates legacy templates without pages to the new multi-page format. /// Creates a default page and assigns all orphan elements to it. /// private static void MigrateTemplatePages(AprtTemplate aprt) { if (aprt.Pages == null || aprt.Pages.Count == 0) { // Create default page aprt.Pages = new List { 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; } } /// /// Renders all content to a high-resolution bitmap using SkiaSharp. /// This ensures images and all elements are rendered exactly as designed. /// private byte[] RenderContentToBitmap(List elements, float contentWidthMm, float contentHeightMm, Dictionary dataContext, Dictionary 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(); } /// /// 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) /// private void RenderElementToCanvas(SKCanvas canvas, AprtElement element, float scaleX, float scaleY, Dictionary dataContext, Dictionary 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 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 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 dataContext) { if (element.Columns == null || element.Columns.Count == 0) return; var items = ResolveTableDataSource(element.DataSource, dataContext)?.ToList() ?? new List(); // 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); } /// /// Parses a CSS color string to SKColor /// 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 }; } /// /// Generates SVG content with absolute positioned elements. /// All positions in the template are in mm, relative to the content area (inside margins). /// private string GenerateSvgContent(List elements, float contentWidthMm, float contentHeightMm, Dictionary dataContext, Dictionary 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($""); // Add style definitions svgBuilder.AppendLine(""); svgBuilder.AppendLine(""); svgBuilder.AppendLine(""); // 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(""); return svgBuilder.ToString(); } /// /// 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 /// private string RenderElementToSvg(AprtElement element, float x, float y, float width, float height, float centerX, float centerY, Dictionary dataContext, Dictionary 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($""); 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($""); 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($""); } // Border for text element if ((style?.BorderWidth ?? 0) > 0) { svg.AppendLine($""); } // 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($"{escapedText}"); 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($""); svg.AppendLine($"[{element.Type}]"); break; } return svg.ToString(); } /// /// Renders image element to SVG. Handles data URIs, API URLs, and resource references. /// private string RenderImageToSvg(AprtElement element, float x, float y, float width, float height, Dictionary 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 $""; } // No image found - render placeholder _logger.LogWarning("Image element {ElementId}: No image source found, rendering placeholder", element.Id); return $"" + $"[Immagine]"; } /// /// Guesses MIME type from image bytes by checking magic numbers /// 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 } /// /// Renders table element to SVG. All dimensions in mm. /// private string RenderTableToSvg(AprtElement element, float x, float y, float width, float height, Dictionary 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(); // 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($""); // 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($"" + $"{System.Security.SecurityElement.Escape(col.Header)}"); 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($""); 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($"{escapedValue}"); currentX += colWidth; } // Row border svg.AppendLine($""); currentY += rowHeightMm; } // Table border var tableHeight = headerHeightMm + items.Count * rowHeightMm; svg.AppendLine($""); return svg.ToString(); } /// /// Formats a float for SVG with invariant culture and reasonable precision /// private static string Fmt(float value) => value.ToString("F2", CultureInfo.InvariantCulture); /// /// Escapes color for SVG (handles 'transparent') /// 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 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 { ["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? ResolveTableDataSource(string? dataSource, Dictionary 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 collection) { return collection; } // Handle IEnumerable that's not IEnumerable if (collectionValue is System.Collections.IEnumerable enumerable) { return enumerable.Cast(); } } return null; } // Direct dataset reference if (dataContext.TryGetValue(dataSource, out var data)) { if (data is IEnumerable items) { return items; } // Handle IEnumerable that's not IEnumerable if (data is System.Collections.IEnumerable enumerable && data is not string) { return enumerable.Cast(); } // Single object - wrap in array return new[] { data }; } return null; } private string ResolveContent(AprtContent? content, Dictionary 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 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 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 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 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> LoadResourcesAsync(AprtTemplate template) { var resources = new Dictionary(); // 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 }; } }