1720 lines
73 KiB
C#
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
|
|
};
|
|
}
|
|
}
|