Add constant rich text placeholders
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-30 23:23:48 +02:00
parent 28469bf547
commit 527c186090
15 changed files with 529 additions and 15 deletions
+3 -1
View File
@@ -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
{
+59 -4
View File
@@ -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)
{
+38
View File
@@ -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}.");
}
+3
View File
@@ -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."));
+107
View File
@@ -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);
}
}