409 lines
22 KiB
C#
409 lines
22 KiB
C#
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)
|
|
{
|
|
return RenderPagesToPng(template, data, dpi).First();
|
|
}
|
|
|
|
public IReadOnlyList<byte[]> RenderPagesToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
|
{
|
|
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
|
|
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).ToList();
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
|
|
ITemplateDataProvider provider)
|
|
{
|
|
var values = TemplateDataResolver.Resolve(template.Manifest, 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) => template.Layout.UsesPageTemplates
|
|
? BuildFlowDocument(template, values)
|
|
: BuildLegacyDocument(template, values);
|
|
|
|
private static IDocument BuildLegacyDocument(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 IDocument BuildFlowDocument(LoadedTemplate template,
|
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
|
{
|
|
var firstPage = template.Layout.PageTemplates.First(x =>
|
|
x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
|
|
var continuationPage = template.Layout.PageTemplates.FirstOrDefault(x =>
|
|
x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase)) ?? firstPage;
|
|
var populatedFlows = template.Layout.ContentFlows.Where(x => x.Elements.Count > 0).ToList();
|
|
if (populatedFlows.Count > 1)
|
|
throw new InvalidDataException("Aktuell darf genau ein Content-Flow Inhalt enthalten. Weitere Flow-Slots können bereits gestaltet werden, parallele paginierende Flows folgen in einer späteren Formatstufe.");
|
|
var primaryFlow = populatedFlows.FirstOrDefault() ?? template.Layout.ContentFlows.FirstOrDefault();
|
|
|
|
return Document.Create(document =>
|
|
{
|
|
document.Page(page =>
|
|
{
|
|
var pageWidth = UnitConverter.Points(template.Layout.Width, template.Layout.Unit);
|
|
var pageHeight = UnitConverter.Points(template.Layout.Height, template.Layout.Unit);
|
|
page.Size(pageWidth, pageHeight);
|
|
page.Margin(0);
|
|
ConfigurePageCanvas(page.Background(), firstPage, continuationPage, template, values);
|
|
|
|
if (primaryFlow is null)
|
|
{
|
|
page.Content().Height(1);
|
|
return;
|
|
}
|
|
|
|
var firstSlot = FindSlot(firstPage, primaryFlow.Name);
|
|
var continuationSlot = FindSlot(continuationPage, primaryFlow.Name) ?? firstSlot;
|
|
if (firstSlot is null)
|
|
throw new InvalidDataException($"Für content-flow „{primaryFlow.Name}“ fehlt ein flow-slot auf der ersten Seite.");
|
|
|
|
if (Math.Abs(firstSlot.X - continuationSlot!.X) > 0.01f ||
|
|
Math.Abs(firstSlot.Width - continuationSlot.Width) > 0.01f)
|
|
throw new InvalidDataException($"Der primäre Flow „{primaryFlow.Name}“ muss vorerst auf erster und Folgeseite dieselbe X-Position und Breite besitzen.");
|
|
|
|
var left = UnitConverter.Points(firstSlot.X, template.Layout.Unit);
|
|
var right = UnitConverter.Points(template.Layout.Width - firstSlot.X - firstSlot.Width, template.Layout.Unit);
|
|
page.MarginLeft(left);
|
|
page.MarginRight(right);
|
|
ConfigureVariableVerticalSlot(page, firstSlot, continuationSlot, template.Layout);
|
|
page.Content().Column(column => RenderFlowColumn(column, primaryFlow.Elements, template, values));
|
|
});
|
|
});
|
|
}
|
|
|
|
private static void ConfigureVariableVerticalSlot(PageDescriptor page, FlowSlotDefinition first,
|
|
FlowSlotDefinition continuation, TemplateLayout layout)
|
|
{
|
|
var firstTop = UnitConverter.Points(first.Y, layout.Unit);
|
|
var continuationTop = UnitConverter.Points(continuation.Y, layout.Unit);
|
|
var firstBottom = UnitConverter.Points(layout.Height - first.Y - first.Height, layout.Unit);
|
|
var continuationBottom = UnitConverter.Points(layout.Height - continuation.Y - continuation.Height, layout.Unit);
|
|
page.Header().Column(column =>
|
|
{
|
|
column.Item().ShowOnce().Height(firstTop);
|
|
column.Item().SkipOnce().Height(continuationTop);
|
|
});
|
|
page.Footer().Column(column =>
|
|
{
|
|
column.Item().ShowOnce().Height(firstBottom);
|
|
column.Item().SkipOnce().Height(continuationBottom);
|
|
});
|
|
}
|
|
|
|
private static FlowSlotDefinition? FindSlot(PageTemplateDefinition page, string name) =>
|
|
page.FlowSlots.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private static void ConfigurePageCanvas(IContainer canvas, PageTemplateDefinition first,
|
|
PageTemplateDefinition continuation, LoadedTemplate template,
|
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
|
{
|
|
canvas.Layers(layers =>
|
|
{
|
|
layers.PrimaryLayer().Background(Colors.White);
|
|
foreach (var element in first.Elements)
|
|
{
|
|
var current = element;
|
|
layers.Layer().ShowOnce().Element(root => RenderElement(root, current, template, values));
|
|
}
|
|
foreach (var element in continuation.Elements)
|
|
{
|
|
var current = element;
|
|
layers.Layer().SkipOnce().Element(root => RenderElement(root, current, template, values));
|
|
}
|
|
});
|
|
}
|
|
|
|
private static void RenderFlowColumn(ColumnDescriptor column, IReadOnlyList<TemplateElement> elements,
|
|
LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderValue> values)
|
|
{
|
|
for (var index = 0; index < elements.Count; index++)
|
|
{
|
|
var element = elements[index];
|
|
var gap = UnitConverter.Points(ParseFloat(element.Attributes, "gap", 0), template.Layout.Unit);
|
|
var keepWithNext = ParseBool(element.Attributes, "keep-with-next") && index + 1 < elements.Count;
|
|
if (keepWithNext)
|
|
{
|
|
var next = elements[++index];
|
|
column.Item().PaddingTop(gap).PreventPageBreak().Column(group =>
|
|
{
|
|
group.Item().Element(container => RenderFlowElement(container, element, template, values));
|
|
var nextGap = UnitConverter.Points(ParseFloat(next.Attributes, "gap", 0), template.Layout.Unit);
|
|
group.Item().PaddingTop(nextGap).Element(container => RenderFlowElement(container, next, template, values));
|
|
});
|
|
}
|
|
else
|
|
column.Item().PaddingTop(gap).Element(container => RenderFlowElement(container, element, template, values));
|
|
}
|
|
}
|
|
|
|
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
|
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
|
{
|
|
switch (element)
|
|
{
|
|
case TextElement text:
|
|
RenderResolvedText(container, text.Content, text.Placeholder, text.Format,
|
|
values, template.Manifest, text.Attributes);
|
|
break;
|
|
case TextBoxElement box:
|
|
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
|
|
values, template.Manifest, box.Attributes);
|
|
break;
|
|
case ImageElement image:
|
|
var imageContainer = container;
|
|
if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit));
|
|
if (image.Height > 0) imageContainer = imageContainer.Height(UnitConverter.Points(image.Height, template.Layout.Unit));
|
|
imageContainer.Image(GetAsset(template, image.Path)).FitArea();
|
|
break;
|
|
case TableElement table when values.GetValueOrDefault(table.Placeholder) is TableValue tableValue:
|
|
TableElementRenderer.Render(container, tableValue, table.Attributes);
|
|
break;
|
|
case ChartElement chart when values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue:
|
|
var chartHeight = UnitConverter.Points(ParseFloat(chart.Attributes, "h", 50), template.Layout.Unit);
|
|
ChartElementRenderer.Render(container.Height(chartHeight), chartValue, chart.ChartType, chart.Attributes);
|
|
break;
|
|
}
|
|
}
|
|
|
|
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);
|
|
RenderResolvedText(textContainer, text.Content, text.Placeholder, text.Format,
|
|
values, template.Manifest, text.Attributes);
|
|
break;
|
|
case TextBoxElement box:
|
|
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
|
values, template.Manifest, 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 (ParseBool(attributes, "underline")) descriptor.Underline();
|
|
if (attributes.TryGetValue("color", out var color)) descriptor.FontColor(color);
|
|
}
|
|
|
|
private static void RenderResolvedText(IContainer container, string content, string? placeholder, string? format,
|
|
IReadOnlyDictionary<string, PlaceholderValue> values, TemplateManifest manifest,
|
|
IReadOnlyDictionary<string, string> elementAttributes)
|
|
{
|
|
var attributes = TextAttributes(manifest, placeholder, elementAttributes);
|
|
var definition = placeholder is null ? null
|
|
: manifest.Placeholders.FirstOrDefault(x => x.Name.Equals(placeholder, StringComparison.Ordinal));
|
|
if (definition is { IsConstant: true, Type: PlaceholderType.Text or PlaceholderType.Multiline })
|
|
{
|
|
RenderRichText(container, TemplateRichText.Parse(definition.ConstantValue ?? ""), values, attributes);
|
|
return;
|
|
}
|
|
RenderText(container, ResolveContent(content, placeholder, format, values), attributes);
|
|
}
|
|
|
|
private static void RenderRichText(IContainer container, IReadOnlyList<TemplateRichTextRun> runs,
|
|
IReadOnlyDictionary<string, PlaceholderValue> values, IReadOnlyDictionary<string, string> attributes)
|
|
{
|
|
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
|
|
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() };
|
|
var fontSize = ParseFloat(attributes, "size", 11);
|
|
var globalBold = ParseBool(attributes, "bold");
|
|
var globalItalic = ParseBool(attributes, "italic");
|
|
var globalUnderline = ParseBool(attributes, "underline");
|
|
attributes.TryGetValue("color", out var color);
|
|
aligned.Text(text =>
|
|
{
|
|
foreach (var run in runs)
|
|
{
|
|
var content = run.Placeholder is null ? run.Text
|
|
: values.TryGetValue(run.Placeholder, out var value) ? Format(value, run.Format) : "";
|
|
var span = text.Span(content).FontSize(fontSize);
|
|
if (globalBold || run.Bold) span.SemiBold();
|
|
if (globalItalic || run.Italic) span.Italic();
|
|
if (globalUnderline || run.Underline) span.Underline();
|
|
if (color is not null) span.FontColor(color);
|
|
}
|
|
});
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, string> TextAttributes(TemplateManifest manifest, string? placeholder,
|
|
IReadOnlyDictionary<string, string> elementAttributes)
|
|
{
|
|
if (placeholder is null) return elementAttributes;
|
|
var definition = manifest.Placeholders.FirstOrDefault(x => x.Name.Equals(placeholder, StringComparison.Ordinal));
|
|
if (definition is null || (!definition.Bold && !definition.Italic && !definition.Underline)) return elementAttributes;
|
|
var result = new Dictionary<string, string>(elementAttributes, StringComparer.OrdinalIgnoreCase);
|
|
if (definition.Bold) result["bold"] = "true";
|
|
if (definition.Italic) result["italic"] = "true";
|
|
if (definition.Underline) result["underline"] = "true";
|
|
return result;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|