This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
using System.Globalization;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewRenderer
|
||||
{
|
||||
static QuestTemplateRenderer() => QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
public byte[] RenderToPdf(LoadedTemplate template, ITemplateDataProvider data) =>
|
||||
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
|
||||
|
||||
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
||||
{
|
||||
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
|
||||
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).First();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
|
||||
ITemplateDataProvider provider)
|
||||
{
|
||||
var values = provider.GetValues();
|
||||
var validation = new TemplateLoader().Validate(template, values.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||
if (!validation.IsValid) throw new TemplateValidationException(validation);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static IDocument BuildDocument(LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document =>
|
||||
{
|
||||
document.Page(page =>
|
||||
{
|
||||
page.Size(UnitConverter.Points(template.Layout.Width, template.Layout.Unit),
|
||||
UnitConverter.Points(template.Layout.Height, template.Layout.Unit));
|
||||
page.Margin(0);
|
||||
page.Content().Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
||||
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
||||
foreach (var element in template.Layout.Elements)
|
||||
{
|
||||
var current = element;
|
||||
layers.Layer().Element(container => RenderElement(container, current, template, values));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
var unit = template.Layout.Unit;
|
||||
switch (element)
|
||||
{
|
||||
case BackgroundElement background:
|
||||
root.Width(UnitConverter.Points(template.Layout.Width, unit))
|
||||
.Height(UnitConverter.Points(template.Layout.Height, unit))
|
||||
.Image(GetAsset(template, background.Path)).FitArea();
|
||||
break;
|
||||
case ImageElement image:
|
||||
var imageScale = image.Attributes.TryGetValue("scale", out var scale)
|
||||
? LayoutParser.Percentage(scale) : 1f;
|
||||
Position(root, image, unit, imageScale).Image(GetAsset(template, image.Path)).FitArea();
|
||||
break;
|
||||
case TextElement text:
|
||||
var textContainer = root.TranslateX(UnitConverter.Points(text.X, unit))
|
||||
.TranslateY(UnitConverter.Points(text.Y, unit))
|
||||
.Width(UnitConverter.Points(Math.Max(0, template.Layout.Width - text.X), unit))
|
||||
.Height(QuestTemplateRenderer.ParseFloat(text.Attributes, "size", 11) * 1.8f);
|
||||
RenderText(textContainer, ResolveContent(text.Content, text.Placeholder, text.Format, values), text.Attributes);
|
||||
break;
|
||||
case TextBoxElement box:
|
||||
RenderText(Position(root, box, unit).Shrink(),
|
||||
ResolveContent(box.Content, box.Placeholder, box.Format, values), box.Attributes);
|
||||
break;
|
||||
case TableElement table:
|
||||
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
|
||||
TableElementRenderer.Render(Position(root, table, unit), tableValue, table.Attributes);
|
||||
break;
|
||||
case ChartElement chart:
|
||||
if (values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue)
|
||||
ChartElementRenderer.Render(Position(root, chart, unit), chartValue, chart.ChartType, chart.Attributes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static IContainer Position(IContainer root, TemplateElement element, string unit, float scale = 1) => root
|
||||
.TranslateX(UnitConverter.Points(element.X, unit)).TranslateY(UnitConverter.Points(element.Y, unit))
|
||||
.Width(UnitConverter.Points(element.Width * scale, unit)).Height(UnitConverter.Points(element.Height * scale, unit));
|
||||
|
||||
private static byte[] GetAsset(LoadedTemplate template, string path) =>
|
||||
template.Assets.TryGetValue(TemplateLoader.Normalize(path), out var bytes) ? bytes
|
||||
: throw new InvalidDataException($"Asset „{path}“ fehlt.");
|
||||
|
||||
internal static void RenderText(IContainer container, string content, IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
|
||||
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
|
||||
var descriptor = aligned.Text(content);
|
||||
descriptor.FontSize(ParseFloat(attributes, "size", 11));
|
||||
if (ParseBool(attributes, "bold")) descriptor.SemiBold();
|
||||
if (ParseBool(attributes, "italic")) descriptor.Italic();
|
||||
if (attributes.TryGetValue("color", out var color)) descriptor.FontColor(color);
|
||||
}
|
||||
|
||||
private static string ResolveContent(string content, string? placeholder, string? format,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||
{
|
||||
if (placeholder is not null)
|
||||
return values.TryGetValue(placeholder, out var value) ? Format(value, format) : "";
|
||||
var result = content;
|
||||
foreach (var entry in values)
|
||||
result = result.Replace("$" + entry.Key, Format(entry.Value, null), StringComparison.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Format(PlaceholderValue value, string? format) => value switch
|
||||
{
|
||||
TextValue text => text.Value,
|
||||
MultilineValue text => text.Value,
|
||||
DateValue date => date.Value.ToString(format ?? "d", CultureInfo.GetCultureInfo("de-DE")),
|
||||
NumberValue number => number.Value.ToString(format, CultureInfo.GetCultureInfo("de-DE")),
|
||||
_ => "",
|
||||
};
|
||||
|
||||
internal static float ParseFloat(IReadOnlyDictionary<string, string> attributes, string key, float fallback) =>
|
||||
attributes.TryGetValue(key, out var raw) && float.TryParse(raw, NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var value) ? value : fallback;
|
||||
internal static bool ParseBool(IReadOnlyDictionary<string, string> attributes, string key) =>
|
||||
attributes.TryGetValue(key, out var raw) && bool.TryParse(raw, out var value) && value;
|
||||
}
|
||||
|
||||
public static class UnitConverter
|
||||
{
|
||||
public static float Points(float value, string unit) => unit.ToLowerInvariant() switch
|
||||
{ "mm" => value * 72f / 25.4f, "cm" => value * 72f / 2.54f, "in" => value * 72f, "pt" => value,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unbekannte Einheit.") };
|
||||
}
|
||||
|
||||
internal static class TableElementRenderer
|
||||
{
|
||||
public static void Render(IContainer container, TableValue value, IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
if (value.Columns.Count == 0) return;
|
||||
var fontSize = QuestTemplateRenderer.ParseFloat(attributes, "size", 9);
|
||||
container.Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns => { foreach (var _ in value.Columns) columns.RelativeColumn(); });
|
||||
table.Header(header =>
|
||||
{
|
||||
foreach (var column in value.Columns)
|
||||
header.Cell().Background(Colors.Grey.Lighten2).Border(0.5f).BorderColor(Colors.Grey.Medium)
|
||||
.Padding(3).Text(column).FontSize(fontSize).SemiBold();
|
||||
});
|
||||
foreach (var row in value.Rows)
|
||||
for (var i = 0; i < value.Columns.Count; i++)
|
||||
table.Cell().Border(0.5f).BorderColor(Colors.Grey.Lighten1).Padding(3)
|
||||
.Text(i < row.Count ? row[i] : "").FontSize(fontSize);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ChartElementRenderer
|
||||
{
|
||||
public static void Render(IContainer container, ChartValue value, string chartType,
|
||||
IReadOnlyDictionary<string, string> attributes)
|
||||
{
|
||||
var svg = BuildSvg(value, chartType);
|
||||
container.Svg(svg);
|
||||
}
|
||||
|
||||
private static string BuildSvg(ChartValue value, string chartType)
|
||||
{
|
||||
var points = value.Series.SelectMany(x => x.Points).ToList();
|
||||
if (points.Count == 0) return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\"/>";
|
||||
var max = Math.Max(1m, points.Max(x => x.Y));
|
||||
var colors = new[] { "#2563EB", "#DC2626", "#059669", "#7C3AED" };
|
||||
var svg = new StringBuilder("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 600 200\">");
|
||||
svg.Append("<line x1=\"40\" y1=\"170\" x2=\"590\" y2=\"170\" stroke=\"#94A3B8\"/><line x1=\"40\" y1=\"10\" x2=\"40\" y2=\"170\" stroke=\"#94A3B8\"/>");
|
||||
for (var seriesIndex = 0; seriesIndex < value.Series.Count; seriesIndex++)
|
||||
{
|
||||
var series = value.Series[seriesIndex];
|
||||
var color = colors[seriesIndex % colors.Length];
|
||||
var step = 530d / Math.Max(1, series.Points.Count);
|
||||
var coordinates = new List<string>();
|
||||
for (var i = 0; i < series.Points.Count; i++)
|
||||
{
|
||||
var x = 50 + i * step + step / 2; var y = 165 - (double)(series.Points[i].Y / max) * 145;
|
||||
if (chartType == "bar") svg.Append(CultureInfo.InvariantCulture,
|
||||
$"<rect x=\"{x - step * .28:F1}\" y=\"{y:F1}\" width=\"{step * .56:F1}\" height=\"{165 - y:F1}\" fill=\"{color}\" opacity=\".8\"/>");
|
||||
else coordinates.Add(FormattableString.Invariant($"{x:F1},{y:F1}"));
|
||||
svg.Append($"<text x=\"{x:F1}\" y=\"188\" font-size=\"10\" text-anchor=\"middle\" fill=\"#475569\">{SecurityElement.Escape(series.Points[i].X)}</text>");
|
||||
}
|
||||
if (chartType == "line") svg.Append($"<polyline points=\"{string.Join(' ', coordinates)}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"3\"/>");
|
||||
}
|
||||
return svg.Append("</svg>").ToString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user