This commit is contained in:
@@ -23,7 +23,9 @@ public interface ITemplateDataProvider
|
||||
}
|
||||
|
||||
public sealed record PageSizeDefinition(float Width, float Height, string Unit = "mm");
|
||||
public sealed record PlaceholderDefinition(string Name, PlaceholderType Type, bool Required = false);
|
||||
public sealed record PlaceholderDefinition(string Name, PlaceholderType Type, bool Required = false,
|
||||
bool IsConstant = false, string? ConstantValue = null,
|
||||
bool Bold = false, bool Italic = false, bool Underline = false);
|
||||
|
||||
public sealed class TemplateManifest
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
|
||||
ITemplateDataProvider provider)
|
||||
{
|
||||
var values = provider.GetValues();
|
||||
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;
|
||||
@@ -71,11 +71,12 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
.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);
|
||||
RenderResolvedText(textContainer, text.Content, text.Placeholder, text.Format,
|
||||
values, template.Manifest, text.Attributes);
|
||||
break;
|
||||
case TextBoxElement box:
|
||||
RenderText(Position(root, box, unit).Shrink(),
|
||||
ResolveContent(box.Content, box.Placeholder, box.Format, values), box.Attributes);
|
||||
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)
|
||||
@@ -104,9 +105,63 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# LehrerApp Templating
|
||||
|
||||
## Konstante Platzhalter und Hervorhebung
|
||||
|
||||
Ein Platzhalter kann seinen Wert vollständig im Vorlagenpaket tragen. `IsConstant=true` bewirkt,
|
||||
dass `ConstantValue` beim Rendern immer verwendet wird; ein gleichnamiger Wert aus dem externen
|
||||
`ITemplateDataProvider` wird bewusst ignoriert. Damit eignen sich Konstanten besonders für lange
|
||||
Textbausteine in `TEXTBOX`, rechtliche Hinweise oder wiederkehrende Fußtexte.
|
||||
|
||||
```csharp
|
||||
new PlaceholderDefinition(
|
||||
"Datenschutzhinweis",
|
||||
PlaceholderType.Multiline,
|
||||
IsConstant: true,
|
||||
ConstantValue: "Dieser längere Text wird im Paket gespeichert.",
|
||||
Bold: true,
|
||||
Italic: false,
|
||||
Underline: false);
|
||||
```
|
||||
|
||||
Konstante Werte werden für `Text`, `Multiline`, `Date` und `Number` unterstützt. Die Eigenschaften
|
||||
`Bold`, `Italic` und `Underline` wirken, wenn der Platzhalter direkt von einem `TEXT`- oder
|
||||
`TEXTBOX`-Element referenziert wird. In der Layout-DSL kann Unterstreichung außerdem direkt mit
|
||||
`underline=true` gesetzt werden.
|
||||
|
||||
Konstante Text- und Multiline-Werte unterstützen zusätzlich abschnittsweise Hervorhebung und
|
||||
eingebettete externe Platzhalter:
|
||||
|
||||
```text
|
||||
Sehr geehrte Familie [b]${Student.LastName}[/b],
|
||||
|
||||
bitte geben Sie die [u]unterschriebene Erklärung[/u] bis [i]Freitag[/i] zurück.
|
||||
```
|
||||
|
||||
Unterstützt werden `[b]…[/b]`, `[i]…[/i]` und `[u]…[/u]`, auch verschachtelt. Die Klammerform
|
||||
`${Name}` ist in Fließtext vorzuziehen; `${Datum|dd.MM.yyyy}` erlaubt zusätzlich ein Format.
|
||||
Eingebettete Platzhalter müssen im Manifest als externe Platzhalter deklariert sein. Ihre gelieferten
|
||||
Werte werden immer als reiner Text behandelt und können deshalb kein Markup einschleusen. Mit
|
||||
`\[`, `\$` und `\\` lassen sich die Steuerzeichen wörtlich ausgeben.
|
||||
|
||||
## Freie Paketmetadaten
|
||||
|
||||
Jedes neu gespeicherte `.lavorlage`-Paket enthält eine lesbare `metadata.txt`. Pro Zeile steht ein
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public static class TemplateDataResolver
|
||||
{
|
||||
public static IReadOnlyList<string> ValidateConstants(TemplateManifest manifest)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
var definitions = manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(x => x.Key, x => x.First(), StringComparer.Ordinal);
|
||||
foreach (var constant in manifest.Placeholders.Where(x => x.IsConstant))
|
||||
{
|
||||
try
|
||||
{
|
||||
ConstantValue(constant);
|
||||
if (constant.Type is not (PlaceholderType.Text or PlaceholderType.Multiline)) continue;
|
||||
foreach (var reference in TemplateRichText.UsedPlaceholders(constant.ConstantValue ?? ""))
|
||||
{
|
||||
if (!definitions.TryGetValue(reference, out var target))
|
||||
issues.Add($"Konstanter Text „{constant.Name}“ verwendet den nicht deklarierten Platzhalter „{reference}“.");
|
||||
else if (target.IsConstant)
|
||||
issues.Add($"Konstanter Text „{constant.Name}“ darf nur externe Platzhalter verwenden; „{reference}“ ist ebenfalls konstant.");
|
||||
else if (target.Type is not (PlaceholderType.Text or PlaceholderType.Multiline
|
||||
or PlaceholderType.Date or PlaceholderType.Number))
|
||||
issues.Add($"Eingebetteter Platzhalter „{reference}“ hat keinen textuell darstellbaren Typ.");
|
||||
}
|
||||
}
|
||||
catch (InvalidDataException ex) { issues.Add(ex.Message); }
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, PlaceholderValue> Resolve(TemplateManifest manifest,
|
||||
IReadOnlyDictionary<string, PlaceholderValue> externalValues)
|
||||
{
|
||||
var result = new Dictionary<string, PlaceholderValue>(externalValues, StringComparer.Ordinal);
|
||||
foreach (var definition in manifest.Placeholders.Where(x => x.IsConstant))
|
||||
result[definition.Name] = ConstantValue(definition);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PlaceholderValue ConstantValue(PlaceholderDefinition definition)
|
||||
{
|
||||
var raw = definition.ConstantValue ?? "";
|
||||
return definition.Type switch
|
||||
{
|
||||
PlaceholderType.Text => new TextValue(raw),
|
||||
PlaceholderType.Multiline => new MultilineValue(raw),
|
||||
PlaceholderType.Date => new DateValue(ParseDate(definition, raw)),
|
||||
PlaceholderType.Number => new NumberValue(ParseNumber(definition, raw)),
|
||||
_ => throw new InvalidDataException(
|
||||
$"Platzhalter „{definition.Name}“ vom Typ {definition.Type} kann keinen konstanten Textwert verwenden."),
|
||||
};
|
||||
}
|
||||
|
||||
private static DateOnly ParseDate(PlaceholderDefinition definition, string raw)
|
||||
{
|
||||
if (DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
||||
|| DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out date)) return date;
|
||||
throw Error(definition, "Datum, z. B. 2026-08-30");
|
||||
}
|
||||
|
||||
private static decimal ParseNumber(PlaceholderDefinition definition, string raw)
|
||||
{
|
||||
if (decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)
|
||||
|| decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out number)) return number;
|
||||
throw Error(definition, "Zahl");
|
||||
}
|
||||
|
||||
private static InvalidDataException Error(PlaceholderDefinition definition, string expected) =>
|
||||
new($"Konstanter Wert für „{definition.Name}“ ist ungültig; erwartet wird: {expected}.");
|
||||
}
|
||||
@@ -111,6 +111,8 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ wird im Layout verwendet, aber nicht im Manifest deklariert."));
|
||||
foreach (var duplicate in manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal).Where(x => x.Count() > 1))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{duplicate.Key}“ ist mehrfach deklariert."));
|
||||
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
|
||||
issues.Add(new(ValidationSeverity.Error, issue));
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(manifest, layout, assets, sourceName);
|
||||
}
|
||||
@@ -123,6 +125,7 @@ public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
var issues = new List<ValidationIssue>();
|
||||
foreach (var placeholder in template.Manifest.Placeholders)
|
||||
{
|
||||
if (placeholder.IsConstant) continue;
|
||||
if (!providedTypes.TryGetValue(placeholder.Name, out var actual))
|
||||
{
|
||||
if (placeholder.Required) issues.Add(new(ValidationSeverity.Error, $"Pflichtwert „{placeholder.Name}“ fehlt."));
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public sealed record TemplateRichTextRun(string Text, string? Placeholder, string? Format,
|
||||
bool Bold, bool Italic, bool Underline)
|
||||
{
|
||||
public bool IsPlaceholder => Placeholder is not null;
|
||||
}
|
||||
|
||||
public static class TemplateRichText
|
||||
{
|
||||
public static IReadOnlyList<TemplateRichTextRun> Parse(string source)
|
||||
{
|
||||
var runs = new List<TemplateRichTextRun>();
|
||||
var literal = new StringBuilder();
|
||||
var styles = new Stack<char>();
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (literal.Length == 0) return;
|
||||
AddRun(runs, new(literal.ToString(), null, null,
|
||||
styles.Contains('b'), styles.Contains('i'), styles.Contains('u')));
|
||||
literal.Clear();
|
||||
}
|
||||
|
||||
for (var index = 0; index < source.Length;)
|
||||
{
|
||||
if (source[index] == '\\' && index + 1 < source.Length
|
||||
&& source[index + 1] is '\\' or '[' or '$')
|
||||
{ literal.Append(source[index + 1]); index += 2; continue; }
|
||||
|
||||
if (TryTag(source, index, out var tag, out var closing, out var tagLength))
|
||||
{
|
||||
Flush();
|
||||
if (closing)
|
||||
{
|
||||
if (styles.Count == 0 || styles.Peek() != tag)
|
||||
throw new InvalidDataException($"Rich-Text-Tag [/{tag}] ist nicht passend geöffnet.");
|
||||
styles.Pop();
|
||||
}
|
||||
else styles.Push(tag);
|
||||
index += tagLength; continue;
|
||||
}
|
||||
|
||||
if (source[index] == '$' && TryPlaceholder(source, index, out var name, out var format, out var length))
|
||||
{
|
||||
Flush();
|
||||
AddRun(runs, new("", name, format,
|
||||
styles.Contains('b'), styles.Contains('i'), styles.Contains('u')));
|
||||
index += length; continue;
|
||||
}
|
||||
|
||||
literal.Append(source[index++]);
|
||||
}
|
||||
|
||||
Flush();
|
||||
if (styles.Count > 0) throw new InvalidDataException($"Rich-Text-Tag [{styles.Peek()}] wurde nicht geschlossen.");
|
||||
return runs;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> UsedPlaceholders(string source) => Parse(source)
|
||||
.Where(x => x.Placeholder is not null).Select(x => x.Placeholder!).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
private static bool TryTag(string source, int index, out char tag, out bool closing, out int length)
|
||||
{
|
||||
tag = default; closing = false; length = 0;
|
||||
if (index + 2 < source.Length && source[index] == '[' && source[index + 2] == ']'
|
||||
&& source[index + 1] is 'b' or 'i' or 'u')
|
||||
{ tag = source[index + 1]; length = 3; return true; }
|
||||
if (index + 3 < source.Length && source[index] == '[' && source[index + 1] == '/'
|
||||
&& source[index + 3] == ']' && source[index + 2] is 'b' or 'i' or 'u')
|
||||
{ tag = source[index + 2]; closing = true; length = 4; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryPlaceholder(string source, int index, out string name, out string? format, out int length)
|
||||
{
|
||||
name = ""; format = null; length = 0;
|
||||
if (index + 1 >= source.Length) return false;
|
||||
if (source[index + 1] == '{')
|
||||
{
|
||||
var end = source.IndexOf('}', index + 2);
|
||||
if (end < 0) throw new InvalidDataException("Platzhalter mit '${' wurde nicht mit '}' geschlossen.");
|
||||
var content = source[(index + 2)..end];
|
||||
var parts = content.Split('|', 2);
|
||||
name = parts[0].Trim(); format = parts.Length == 2 ? parts[1] : null;
|
||||
if (name.Length == 0) throw new InvalidDataException("Ein eingebetteter Platzhaltername darf nicht leer sein.");
|
||||
length = end - index + 1; return true;
|
||||
}
|
||||
if (!IsNameStart(source[index + 1])) return false;
|
||||
var cursor = index + 2;
|
||||
while (cursor < source.Length && IsNamePart(source[cursor])) cursor++;
|
||||
name = source[(index + 1)..cursor]; length = cursor - index; return true;
|
||||
}
|
||||
|
||||
private static bool IsNameStart(char value) => char.IsLetter(value) || value == '_';
|
||||
private static bool IsNamePart(char value) => char.IsLetterOrDigit(value) || value is '_' or '.' or '-';
|
||||
|
||||
private static void AddRun(List<TemplateRichTextRun> runs, TemplateRichTextRun run)
|
||||
{
|
||||
if (!run.IsPlaceholder && runs.LastOrDefault() is { IsPlaceholder: false } previous
|
||||
&& previous.Bold == run.Bold && previous.Italic == run.Italic && previous.Underline == run.Underline)
|
||||
runs[^1] = previous with { Text = previous.Text + run.Text };
|
||||
else runs.Add(run);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user