Files
LehrerApp/LehrerApp.Templating/QuestTemplateRenderer.cs
T
admin c6efdab9ef
CI / build-and-test (push) Canceled after 0s
TemplateDesigner: SystemVars
2026-08-31 23:47:48 +02:00

553 lines
29 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)
{
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 = 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)
{
values = PreparePagedDrawings(template, values);
var flowBox = template.Layout.Elements.SingleOrDefault(x => x is FlowBoxElement or FlowDrawBoxElement);
return 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 =>
{
if (flowBox is null)
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
else
RenderFlowElement(layers.PrimaryLayer(), flowBox, template, values);
RenderStaticLayers(layers, template.Layout, template, values,
template.ContinuationLayout is null ? null : static container => container.ShowOnce());
if (template.ContinuationLayout is { } continuation)
RenderStaticLayers(layers, continuation, template, values, static container => container.SkipOnce());
});
}));
}
private static IReadOnlyDictionary<string, PlaceholderValue> PreparePagedDrawings(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var result = new Dictionary<string, PlaceholderValue>(values, StringComparer.Ordinal);
var layouts = template.ContinuationLayout is null
? new[] { template.Layout }
: new[] { template.Layout, template.ContinuationLayout };
var drawingElements = layouts.SelectMany(x => x.Elements)
.Where(x => x is DrawBoxElement or FlowDrawBoxElement).ToList();
foreach (var group in drawingElements.GroupBy(x => x switch
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => "" }))
{
if (values.GetValueOrDefault(group.Key) is not PagedDrawingValue) continue;
var signatures = group.Select(x => (x.GetType(), x.Width, x.Height)).Distinct().Count();
if (signatures > 1)
throw new InvalidDataException($"Der Callback-Platzhalter „{group.Key}“ wird in unterschiedlich großen oder unterschiedlichen Zeichenboxen verwendet.");
}
foreach (var element in drawingElements)
{
var placeholder = element switch
{ DrawBoxElement box => box.Placeholder, FlowDrawBoxElement box => box.Placeholder, _ => null };
if (placeholder is null || values.GetValueOrDefault(placeholder) is not PagedDrawingValue callback) continue;
if (result.GetValueOrDefault(placeholder) is RecordedDrawingValue) continue;
var pages = element is FlowDrawBoxElement
? DrawingElementRenderer.RecordPages(callback, element.Width, element.Height, callback.MaxPages)
: [DrawingElementRenderer.RecordSingle(callback, element.Width, element.Height)];
result[placeholder] = new RecordedDrawingValue(pages);
}
return result;
}
private static void RenderStaticLayers(LayersDescriptor layers, TemplateLayout layout, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values, Func<IContainer, IContainer>? visibility)
{
foreach (var element in layout.Elements.Where(x => x is not (FlowBoxElement or FlowDrawBoxElement)))
{
var current = element;
layers.Layer().Element(container => RenderElement(
visibility is null ? container : visibility(container), current, template, values));
}
}
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var unit = template.Layout.Unit;
container
.PaddingLeft(UnitConverter.Points(element.X, unit))
.PaddingTop(UnitConverter.Points(element.Y, unit))
.PaddingRight(UnitConverter.Points(Math.Max(0, template.Layout.Width - element.X - element.Width), unit))
.PaddingBottom(UnitConverter.Points(Math.Max(0, template.Layout.Height - element.Y - element.Height), unit))
.Element(content =>
{
if (element is FlowBoxElement box)
RenderResolvedText(content, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
else if (element is FlowDrawBoxElement drawing
&& values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFlow(content, drawingValue, drawing.Width, drawing.Height, unit);
});
}
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 FlowBoxElement:
break;
case DrawBoxElement drawing:
if (values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), drawingValue,
drawing.Width, drawing.Height, unit);
break;
case FlowDrawBoxElement:
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() };
if (SystemVariables.Contains(content))
{
aligned.Text(text =>
{
text.DefaultTextStyle(style => ApplyTextStyle(style, attributes));
AppendSystemVariables(text, content);
});
return;
}
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) : "";
foreach (var span in AppendSystemVariables(text, content))
{
span.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 TextStyle ApplyTextStyle(TextStyle style, IReadOnlyDictionary<string, string> attributes)
{
style = style.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) style = style.SemiBold();
if (ParseBool(attributes, "italic")) style = style.Italic();
if (ParseBool(attributes, "underline")) style = style.Underline();
if (attributes.TryGetValue("color", out var color)) style = style.FontColor(color);
return style;
}
private static IEnumerable<TextSpanDescriptor> AppendSystemVariables(TextDescriptor text, string content)
{
foreach (var part in SystemVariables.Split(content))
{
yield return part.Name switch
{
SystemVariables.CurrentPage => text.CurrentPageNumber(),
SystemVariables.MaximumPageNumber => text.TotalPages(),
SystemVariables.Today => text.Span(DateTime.Today.ToString("d", CultureInfo.GetCultureInfo("de-DE"))),
_ => text.Span(part.Text),
};
}
}
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;
}
internal sealed record RecordedDrawingValue(IReadOnlyList<IReadOnlyList<DrawingCommand>> Pages)
: PlaceholderValue(PlaceholderType.Drawing);
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 DrawingElementRenderer
{
public static void RenderFixed(IContainer container, PlaceholderValue value, float width, float height, string unit)
{
var drawing = value switch
{
DrawingValue direct => direct,
RecordedDrawingValue recorded => new DrawingValue(recorded.Pages.FirstOrDefault() ?? [], height),
_ => throw new InvalidDataException("DRAWBOX erwartet einen Drawing-Platzhalter."),
};
container.Svg(BuildSvg(drawing, width, height, 0, unit));
}
public static void RenderFlow(IContainer container, PlaceholderValue value, float width, float pageHeight, string unit)
{
var pages = value switch
{
DrawingValue direct => Slice(direct, pageHeight),
RecordedDrawingValue recorded => recorded.Pages
.Select(commands => new DrawingValue(commands, pageHeight)).ToList(),
_ => throw new InvalidDataException("FLOWDRAWBOX erwartet einen Drawing-Platzhalter."),
};
container.Column(column =>
{
for (var page = 0; page < pages.Count; page++)
{
if (page > 0) column.Item().PageBreak();
var offset = value is DrawingValue ? page * pageHeight : 0;
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
BuildSvg(pages[page], width, pageHeight, offset, unit));
}
});
}
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
{
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
return Enumerable.Repeat(value, pageCount).ToList();
}
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
float width, float height, int maxPages)
{
if (maxPages is < 1 or > 10_000)
throw new InvalidDataException("PagedDrawingValue.MaxPages muss zwischen 1 und 10000 liegen.");
var pages = new List<IReadOnlyList<DrawingCommand>>();
var state = value.InitialState;
for (var pageNumber = 1; pageNumber <= maxPages; pageNumber++)
{
var canvas = new DrawingCanvas();
var context = new DrawingPageContext(width, height, pageNumber, canvas, state);
var finished = value.DrawPage(context);
pages.Add(canvas.Commands.ToList());
state = context.State;
if (finished) return pages;
}
throw new InvalidDataException($"Der Zeichen-Callback war nach {maxPages} Seiten noch nicht beendet.");
}
internal static IReadOnlyList<DrawingCommand> RecordSingle(PagedDrawingValue value, float width, float height)
{
var canvas = new DrawingCanvas();
value.DrawPage(new DrawingPageContext(width, height, 1, canvas, value.InitialState));
return canvas.Commands.ToList();
}
private static string BuildSvg(DrawingValue value, float width, float height, float verticalOffset, string unit)
{
if (!float.IsFinite(value.ContentHeight) || value.ContentHeight < 0)
throw new InvalidDataException("DrawingValue.ContentHeight muss eine endliche, nichtnegative Zahl sein.");
if (value.Commands.Count > 100_000)
throw new InvalidDataException("DrawingValue enthält zu viele Zeichenbefehle.");
var svg = new StringBuilder();
svg.Append(CultureInfo.InvariantCulture,
$"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 {verticalOffset} {width} {height}\" overflow=\"hidden\">");
float? currentX = null, currentY = null;
foreach (var command in value.Commands)
{
switch (command)
{
case MoveTo move:
Validate(move.X, move.Y); currentX = move.X; currentY = move.Y;
break;
case LineTo line when currentX is not null && currentY is not null:
Validate(line.X, line.Y, line.StrokeWidth); Positive(line.StrokeWidth);
AppendLine(svg, currentX.Value, currentY.Value, line.X, line.Y, line.Color, line.StrokeWidth);
currentX = line.X; currentY = line.Y;
break;
case LineTo:
throw new InvalidDataException("LineTo benötigt ein vorheriges MoveTo.");
case DrawLine line:
Validate(line.X1, line.Y1, line.X2, line.Y2, line.StrokeWidth); Positive(line.StrokeWidth);
AppendLine(svg, line.X1, line.Y1, line.X2, line.Y2, line.Color, line.StrokeWidth);
break;
case DrawRectangle rectangle:
Validate(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
Positive(rectangle.Width, rectangle.Height, rectangle.StrokeWidth);
svg.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{rectangle.X}\" y=\"{rectangle.Y}\" width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" stroke=\"{Attribute(rectangle.StrokeColor)}\" stroke-width=\"{rectangle.StrokeWidth}\" fill=\"{Attribute(rectangle.FillColor)}\"/>");
break;
case DrawString text:
Validate(text.X, text.Y, text.FontSize);
Positive(text.FontSize);
var localFontSize = text.FontSize / UnitConverter.Points(1, unit);
svg.Append(CultureInfo.InvariantCulture,
$"<text x=\"{text.X}\" y=\"{text.Y + localFontSize}\" font-size=\"{localFontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">{SecurityElement.Escape(text.Text)}</text>");
break;
case DrawStringEx text:
Validate(text.X, text.Y, text.Height, text.Width, text.FontSize);
Positive(text.Height, text.Width, text.FontSize);
AppendTextBox(svg, text, unit);
break;
case DrawImage image:
Validate(image.X, image.Y, image.Width, image.Height);
Positive(image.Width, image.Height);
if (image.MimeType is not ("image/png" or "image/jpeg"))
throw new InvalidDataException("DrawImage unterstützt nur PNG und JPEG.");
if (image.Data.Length > 20 * 1024 * 1024)
throw new InvalidDataException("Ein DrawImage-Bild darf höchstens 20 MB groß sein.");
svg.Append(CultureInfo.InvariantCulture,
$"<image x=\"{image.X}\" y=\"{image.Y}\" width=\"{image.Width}\" height=\"{image.Height}\" href=\"data:{image.MimeType};base64,{Convert.ToBase64String(image.Data)}\" preserveAspectRatio=\"xMidYMid meet\"/>");
break;
default: throw new InvalidDataException($"Unbekannter Zeichenbefehl {command.GetType().Name}.");
}
}
return svg.Append("</svg>").ToString();
}
private static void AppendLine(StringBuilder svg, float x1, float y1, float x2, float y2,
string color, float strokeWidth) => svg.Append(CultureInfo.InvariantCulture,
$"<line x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\" stroke=\"{Attribute(color)}\" stroke-width=\"{strokeWidth}\"/>");
private static string Attribute(string value)
{
if (value != "none" && !System.Text.RegularExpressions.Regex.IsMatch(value,
@"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,24})$"))
throw new InvalidDataException($"Ungültiger Zeichenfarbwert „{value}“.");
return value;
}
private static string FontFamily(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "sans-serif";
if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^[\p{L}\p{N} _.,-]{1,80}$"))
throw new InvalidDataException($"Ungültige Schriftfamilie „{value}“.");
return SecurityElement.Escape(value) ?? "sans-serif";
}
private static void AppendTextBox(StringBuilder svg, DrawStringEx text, string unit)
{
var fontSize = text.FontSize / UnitConverter.Points(1, unit);
var (x, anchor) = text.Alignment switch
{
DrawingTextAlignment.AlignCenter => (text.X + text.Width / 2, "middle"),
DrawingTextAlignment.AlignRight => (text.X + text.Width, "end"),
_ => (text.X, "start"),
};
var clipId = "clip" + Guid.NewGuid().ToString("N");
svg.Append(CultureInfo.InvariantCulture,
$"<defs><clipPath id=\"{clipId}\"><rect x=\"{text.X}\" y=\"{text.Y}\" width=\"{text.Width}\" height=\"{text.Height}\"/></clipPath></defs>");
svg.Append(CultureInfo.InvariantCulture,
$"<text x=\"{x}\" y=\"{text.Y + fontSize}\" text-anchor=\"{anchor}\" clip-path=\"url(#{clipId})\" font-size=\"{fontSize}\" fill=\"{Attribute(text.Color)}\" font-family=\"{FontFamily(text.FontFamily)}\" font-weight=\"{(text.Bold ? "600" : "400")}\" font-style=\"{(text.Italic ? "italic" : "normal")}\">");
var lines = text.Text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
for (var index = 0; index < lines.Length; index++)
svg.Append(CultureInfo.InvariantCulture,
$"<tspan x=\"{x}\" dy=\"{(index == 0 ? 0 : fontSize * 1.2f)}\">{SecurityElement.Escape(lines[index])}</tspan>");
svg.Append("</text>");
}
private static void Validate(params float[] values)
{
if (values.Any(x => !float.IsFinite(x))) throw new InvalidDataException("Zeichenkoordinaten müssen endlich sein.");
}
private static void Positive(params float[] values)
{
if (values.Any(x => x < 0)) throw new InvalidDataException("Zeichengrößen dürfen nicht negativ sein.");
}
}
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();
}
}