This commit is contained in:
@@ -41,6 +41,19 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
|
||||
{
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
|
||||
Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
var output = Path.Combine(_directory, "Konstant.pdf");
|
||||
|
||||
Assert.True(vm.CanGenerate);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ public partial class SettingsViewModel
|
||||
private LetterTemplateListItem CreateItem(InstalledTemplate template)
|
||||
{
|
||||
var loaded = _letterTemplates.Load(template);
|
||||
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count,
|
||||
loaded.Manifest.Placeholders.Count(x => x.Required));
|
||||
return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count(x => !x.IsConstant),
|
||||
loaded.Manifest.Placeholders.Count(x => !x.IsConstant && x.Required));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues();
|
||||
var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type));
|
||||
foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error));
|
||||
foreach (var required in loaded.Manifest.Placeholders.Where(x => x.Required && values.TryGetValue(x.Name, out var value) && IsEmpty(value)))
|
||||
foreach (var required in loaded.Manifest.Placeholders.Where(x => !x.IsConstant && x.Required
|
||||
&& values.TryGetValue(x.Name, out var value) && IsEmpty(value)))
|
||||
Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
|
||||
|
||||
@@ -53,6 +53,10 @@ public sealed class ProjectLifecycleTests
|
||||
placeholder.Type = LehrerApp.Templating.PlaceholderType.Text;
|
||||
placeholder.Required = true;
|
||||
placeholder.Sample = "Deutsch";
|
||||
placeholder.IsConstant = true;
|
||||
placeholder.Bold = true;
|
||||
placeholder.Italic = true;
|
||||
placeholder.Underline = true;
|
||||
|
||||
Assert.Same(placeholder, viewModel.SelectedPlaceholder);
|
||||
Assert.True(viewModel.HasSelectedPlaceholder);
|
||||
@@ -61,6 +65,11 @@ public sealed class ProjectLifecycleTests
|
||||
Assert.Equal("Sprache", definition.Name);
|
||||
Assert.Equal(LehrerApp.Templating.PlaceholderType.Text, definition.Type);
|
||||
Assert.True(definition.Required);
|
||||
Assert.True(definition.IsConstant);
|
||||
Assert.Equal("Deutsch", definition.ConstantValue);
|
||||
Assert.True(definition.Bold);
|
||||
Assert.True(definition.Italic);
|
||||
Assert.True(definition.Underline);
|
||||
|
||||
viewModel.RemoveSelectedPlaceholder();
|
||||
Assert.Empty(viewModel.Placeholders);
|
||||
|
||||
@@ -109,6 +109,8 @@ public partial class DesignerViewModel : ObservableObject
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
|
||||
foreach (var path in layout.Elements.Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
|
||||
if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
|
||||
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, new Dictionary<string, byte[]>(Assets));
|
||||
}
|
||||
@@ -145,7 +147,9 @@ public partial class DesignerViewModel : ObservableObject
|
||||
SelectedMetadata = null;
|
||||
Placeholders.Clear();
|
||||
foreach (var placeholder in template.Manifest.Placeholders)
|
||||
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required, DesignerPlaceholder.SampleFor(placeholder.Type)));
|
||||
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required,
|
||||
placeholder.IsConstant ? placeholder.ConstantValue ?? "" : DesignerPlaceholder.SampleFor(placeholder.Type),
|
||||
placeholder.IsConstant, placeholder.Bold, placeholder.Italic, placeholder.Underline));
|
||||
SelectedPlaceholder = Placeholders.FirstOrDefault();
|
||||
Assets.Clear(); AssetItems.Clear();
|
||||
foreach (var asset in template.Assets) AddOrReplaceAsset(asset.Key, asset.Value, keepName: true);
|
||||
@@ -201,7 +205,9 @@ public partial class DesignerViewModel : ObservableObject
|
||||
var name = placeholder.Name.Trim();
|
||||
if (name.Length == 0) throw new InvalidDataException("Ein Platzhaltername darf nicht leer sein.");
|
||||
if (!names.Add(name)) throw new InvalidDataException($"Platzhalter „{name}“ ist mehrfach definiert.");
|
||||
result.Add(new(name, placeholder.Type, placeholder.Required));
|
||||
result.Add(new(name, placeholder.Type, placeholder.Required, placeholder.IsConstant,
|
||||
placeholder.IsConstant ? placeholder.Sample : null,
|
||||
placeholder.Bold, placeholder.Italic, placeholder.Underline));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -439,8 +445,29 @@ public partial class DesignerPlaceholder : ObservableObject
|
||||
[ObservableProperty] private PlaceholderType _type;
|
||||
[ObservableProperty] private bool _required;
|
||||
[ObservableProperty] private string _sample;
|
||||
public DesignerPlaceholder(string name, PlaceholderType type, bool required, string sample)
|
||||
{ _name = name; _type = type; _required = required; _sample = sample; }
|
||||
[ObservableProperty] private bool _isConstant;
|
||||
[ObservableProperty] private bool _bold;
|
||||
[ObservableProperty] private bool _italic;
|
||||
[ObservableProperty] private bool _underline;
|
||||
public bool SupportsConstantValue => Type is PlaceholderType.Text or PlaceholderType.Multiline
|
||||
or PlaceholderType.Date or PlaceholderType.Number;
|
||||
public bool SupportsRichText => IsConstant && Type is PlaceholderType.Text or PlaceholderType.Multiline;
|
||||
|
||||
public DesignerPlaceholder(string name, PlaceholderType type, bool required, string sample,
|
||||
bool isConstant = false, bool bold = false, bool italic = false, bool underline = false)
|
||||
{
|
||||
_name = name; _type = type; _required = required; _sample = sample;
|
||||
_isConstant = isConstant; _bold = bold; _italic = italic; _underline = underline;
|
||||
}
|
||||
|
||||
partial void OnTypeChanged(PlaceholderType value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SupportsConstantValue));
|
||||
OnPropertyChanged(nameof(SupportsRichText));
|
||||
if (!SupportsConstantValue) IsConstant = false;
|
||||
}
|
||||
|
||||
partial void OnIsConstantChanged(bool value) => OnPropertyChanged(nameof(SupportsRichText));
|
||||
|
||||
public PlaceholderValue ToValue() => Type switch
|
||||
{
|
||||
|
||||
@@ -113,9 +113,33 @@
|
||||
<ComboBox ItemsSource="{Binding PlaceholderTypes}" SelectedItem="{Binding SelectedPlaceholder.Type, Mode=TwoWay}"/></StackPanel>
|
||||
</Grid>
|
||||
<CheckBox Content="Pflichtwert" IsChecked="{Binding SelectedPlaceholder.Required, Mode=TwoWay}"/>
|
||||
<StackPanel><TextBlock Text="Beispielwert für die Vorschau" Classes="label"/>
|
||||
<TextBox Text="{Binding SelectedPlaceholder.Sample, Mode=TwoWay}" AcceptsReturn="True"
|
||||
<CheckBox IsChecked="{Binding SelectedPlaceholder.IsConstant, Mode=TwoWay}"
|
||||
IsEnabled="{Binding SelectedPlaceholder.SupportsConstantValue}">
|
||||
<TextBlock Text="Beispielwert fest im Paket verwenden (konstant, extern nicht überschreibbar)"
|
||||
TextWrapping="Wrap"/>
|
||||
</CheckBox>
|
||||
<StackPanel><TextBlock Text="Beispielwert / konstanter Paketwert" Classes="label"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,3,0,5">
|
||||
<Button Content="F" FontWeight="Bold" Padding="10,3" Click="OnBoldSelection"
|
||||
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text fett setzen"/>
|
||||
<Button Content="K" FontStyle="Italic" Padding="10,3" Click="OnItalicSelection"
|
||||
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text kursiv setzen"/>
|
||||
<Button Content="U̲" Padding="10,3" Click="OnUnderlineSelection"
|
||||
IsEnabled="{Binding SelectedPlaceholder.SupportsRichText}" ToolTip.Tip="Markierten Text unterstreichen"/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="PlaceholderValueTextBox" Text="{Binding SelectedPlaceholder.Sample, Mode=TwoWay}" AcceptsReturn="True"
|
||||
MinHeight="68" MaxHeight="120" TextWrapping="Wrap" PlaceholderText="Beispieldaten eingeben"/></StackPanel>
|
||||
<TextBlock IsVisible="{Binding SelectedPlaceholder.SupportsRichText}"
|
||||
Text="Externe Werte im Text: ${Student.LastName}. Markup: [b]fett[/b], [i]kursiv[/i], [u]unterstrichen[/u]."
|
||||
FontSize="11" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Hervorhebung bei direkter Verwendung in TEXT/TEXTBOX" Classes="label"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<CheckBox Content="Fett" IsChecked="{Binding SelectedPlaceholder.Bold, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Kursiv" IsChecked="{Binding SelectedPlaceholder.Italic, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Unterstrichen" IsChecked="{Binding SelectedPlaceholder.Underline, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<Button Content="Platzhalter entfernen" HorizontalAlignment="Left" Click="OnRemovePlaceholder"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -39,6 +39,23 @@ public partial class MainWindow : Window
|
||||
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
|
||||
}
|
||||
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder();
|
||||
private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]");
|
||||
private void OnItalicSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[i]", "[/i]");
|
||||
private void OnUnderlineSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[u]", "[/u]");
|
||||
|
||||
private void WrapPlaceholderSelection(string opening, string closing)
|
||||
{
|
||||
if (_viewModel.SelectedPlaceholder is not { SupportsRichText: true } placeholder
|
||||
|| this.FindControl<TextBox>("PlaceholderValueTextBox") is not { } editor) return;
|
||||
var source = editor.Text ?? "";
|
||||
var start = Math.Min(editor.SelectionStart, editor.SelectionEnd);
|
||||
var end = Math.Max(editor.SelectionStart, editor.SelectionEnd);
|
||||
var updated = source[..start] + opening + source[start..end] + closing + source[end..];
|
||||
placeholder.Sample = updated; editor.Text = updated;
|
||||
editor.SelectionStart = start + opening.Length;
|
||||
editor.SelectionEnd = end + opening.Length;
|
||||
editor.Focus(); _viewModel.CanExport = false;
|
||||
}
|
||||
private void OnAddElement(object? sender, RoutedEventArgs e)
|
||||
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||
private void OnMeasureOverlayMode(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -110,7 +110,8 @@ public sealed class StarterTemplateLibrary
|
||||
LayoutFile = source.LayoutFile,
|
||||
MetadataFile = source.MetadataFile,
|
||||
Metadata = new(source.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required)).ToList(),
|
||||
Placeholders = source.Placeholders.Select(x => new PlaceholderDefinition(x.Name, x.Type, x.Required,
|
||||
x.IsConstant, x.ConstantValue, x.Bold, x.Italic, x.Underline)).ToList(),
|
||||
};
|
||||
|
||||
private static StarterTemplateItem ToItem(TemplateManifest manifest, string path) =>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Templating.Tests;
|
||||
|
||||
public sealed class ConstantPlaceholderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolver_KonstanterWertUeberschreibtExternenWert()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Hinweis", PlaceholderType.Multiline, true, true, "Interner Langtext")],
|
||||
};
|
||||
var external = new Dictionary<string, PlaceholderValue>
|
||||
{ ["Hinweis"] = new MultilineValue("Extern manipuliert") };
|
||||
|
||||
var resolved = TemplateDataResolver.Resolve(manifest, external);
|
||||
|
||||
Assert.Equal("Interner Langtext", Assert.IsType<MultilineValue>(resolved["Hinweis"]).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_BenoetigtFuerKonstantenPflichtwertKeineExternenDatenUndUnterstuetztHervorhebung()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "konstant", Name = "Konstant",
|
||||
Placeholders = [new("Hinweis", PlaceholderType.Multiline, true, true,
|
||||
"Dieser Text lebt im Paket.", Bold: true, Italic: true, Underline: true)],
|
||||
};
|
||||
var layout = new LayoutParser().Parse("PAGE 210 297 mm\nTEXTBOX 20 20 170 50 $Hinweis size=11");
|
||||
var template = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
|
||||
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(template, new EmptyProvider());
|
||||
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BehandeltKonstantenPflichtwertAlsInternErfuellt()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Placeholders =
|
||||
[
|
||||
new("Intern", PlaceholderType.Text, true, true, "Fest"),
|
||||
new("Extern", PlaceholderType.Text, true),
|
||||
],
|
||||
};
|
||||
var template = new LoadedTemplate(manifest, new(210, 297, "mm", []), new Dictionary<string, byte[]>());
|
||||
|
||||
var result = new TemplateLoader().Validate(template, new Dictionary<string, PlaceholderType>());
|
||||
|
||||
Assert.Single(result.Issues);
|
||||
Assert.Contains("Extern", result.Issues[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaketRoundtrip_BehaeltKonstantenWertUndTextformatierung()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"constant-{Guid.NewGuid():N}.lavorlage");
|
||||
try
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "konstant", Name = "Konstant",
|
||||
Placeholders = [new("Baustein", PlaceholderType.Text, false, true, "Fest im Paket",
|
||||
Bold: true, Italic: false, Underline: true)],
|
||||
};
|
||||
TemplatePackage.Create(path, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Baustein",
|
||||
new Dictionary<string, byte[]>());
|
||||
|
||||
var definition = Assert.Single(new TemplateLoader().LoadFromPackage(path).Manifest.Placeholders);
|
||||
|
||||
Assert.True(definition.IsConstant);
|
||||
Assert.Equal("Fest im Paket", definition.ConstantValue);
|
||||
Assert.True(definition.Bold);
|
||||
Assert.True(definition.Underline);
|
||||
}
|
||||
finally { if (File.Exists(path)) File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichTextParser_UnterstuetztVerschachtelteHervorhebungUndExternePlatzhalter()
|
||||
{
|
||||
var runs = TemplateRichText.Parse("Hallo [b]Familie [i]${Student.LastName}[/i][/b]!");
|
||||
|
||||
var placeholder = Assert.Single(runs, x => x.IsPlaceholder);
|
||||
Assert.Equal("Student.LastName", placeholder.Placeholder);
|
||||
Assert.True(placeholder.Bold);
|
||||
Assert.True(placeholder.Italic);
|
||||
Assert.False(placeholder.Underline);
|
||||
Assert.Throws<InvalidDataException>(() => TemplateRichText.Parse("[b]nicht geschlossen"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternerWertWirdImKonstantenRichTextNichtAlsMarkupInterpretiert()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "sicher", Name = "Sicher",
|
||||
Placeholders =
|
||||
[
|
||||
new("Baustein", PlaceholderType.Multiline, true, true,
|
||||
"Sehr geehrte Familie [b]${Student.LastName}[/b],"),
|
||||
new("Student.LastName", PlaceholderType.Text, true),
|
||||
],
|
||||
};
|
||||
var template = new LoadedTemplate(manifest,
|
||||
new LayoutParser().Parse("PAGE 210 297 mm\nTEXTBOX 20 20 170 50 $Baustein"),
|
||||
new Dictionary<string, byte[]>());
|
||||
var provider = new ValuesProvider(new Dictionary<string, PlaceholderValue>
|
||||
{ ["Student.LastName"] = new TextValue("[u]nicht als Markup geöffnet") });
|
||||
|
||||
var pdf = new QuestTemplateRenderer().RenderToPdf(template, provider);
|
||||
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KonstantenValidierung_MeldetNichtDeklarierteEingebettetePlatzhalter()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Placeholders = [new("Baustein", PlaceholderType.Text, false, true, "Hallo ${Unbekannt}")],
|
||||
};
|
||||
|
||||
var issue = Assert.Single(TemplateDataResolver.ValidateConstants(manifest));
|
||||
|
||||
Assert.Contains("Unbekannt", issue);
|
||||
}
|
||||
|
||||
private sealed class EmptyProvider : ITemplateDataProvider
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() =>
|
||||
new Dictionary<string, PlaceholderValue>();
|
||||
}
|
||||
|
||||
private sealed class ValuesProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{
|
||||
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
||||
}
|
||||
}
|
||||
@@ -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