diff --git a/LehrerApp.TemplateDesigner/MainWindow.axaml b/LehrerApp.TemplateDesigner/MainWindow.axaml
index df3b324..9acb6ee 100644
--- a/LehrerApp.TemplateDesigner/MainWindow.axaml
+++ b/LehrerApp.TemplateDesigner/MainWindow.axaml
@@ -197,6 +197,8 @@
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
+
diff --git a/LehrerApp.Templating.Tests/TemplatingTests.cs b/LehrerApp.Templating.Tests/TemplatingTests.cs
index 150b3e8..0405340 100644
--- a/LehrerApp.Templating.Tests/TemplatingTests.cs
+++ b/LehrerApp.Templating.Tests/TemplatingTests.cs
@@ -31,6 +31,26 @@ public sealed class TemplatingTests : IDisposable
Assert.Equal("line", Assert.IsType(layout.Elements[6]).ChartType);
}
+ [Fact]
+ public void Systemvariablen_BrauchenKeineManifestDeklarationUndRendernMitSeitenzahlen()
+ {
+ var manifest = new TemplateManifest { Id = "system", Name = "Systemvariablen" };
+ var layout = new LayoutParser().Parse("""
+ PAGE 210 297 mm
+ TEXT 20 10 "Stand: $$today" size=9
+ TEXT 140 285 "Seite $$curPage von $$maxPageNum" size=9
+ FLOWBOX 20 20 170 250 "Langer Inhalt für zwei Seiten.\nLanger Inhalt für zwei Seiten."
+ """);
+ var template = new LoadedTemplate(manifest, layout, new Dictionary());
+
+ var validation = new TemplateLoader().Validate(template, new Dictionary());
+ var pdf = new QuestTemplateRenderer().RenderToPdf(template,
+ new DictionaryProvider(new Dictionary()));
+
+ Assert.True(validation.IsValid);
+ Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
+ }
+
[Fact]
public void FlowBox_FliesstAufFolgeseitenUndPaketEnthaeltFolgeseitenLayout()
{
diff --git a/LehrerApp.Templating/LayoutParser.cs b/LehrerApp.Templating/LayoutParser.cs
index 4d738dc..9144e49 100644
--- a/LehrerApp.Templating/LayoutParser.cs
+++ b/LehrerApp.Templating/LayoutParser.cs
@@ -103,6 +103,7 @@ public sealed class LayoutParser
private static (string? Name, string? Format) Reference(string value)
{
+ if (SystemVariables.IsStandalone(value)) return (null, null);
if (!value.StartsWith('$')) return (null, null);
var parts = value[1..].Split('|', 2);
if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt.");
diff --git a/LehrerApp.Templating/QuestTemplateRenderer.cs b/LehrerApp.Templating/QuestTemplateRenderer.cs
index 74e4bbf..23d7809 100644
--- a/LehrerApp.Templating/QuestTemplateRenderer.cs
+++ b/LehrerApp.Templating/QuestTemplateRenderer.cs
@@ -177,6 +177,15 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
{
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();
@@ -216,15 +225,42 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
{
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);
+ 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 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 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 TextAttributes(TemplateManifest manifest, string? placeholder,
IReadOnlyDictionary elementAttributes)
{
diff --git a/LehrerApp.Templating/README.md b/LehrerApp.Templating/README.md
index 33dfc98..0f1614b 100644
--- a/LehrerApp.Templating/README.md
+++ b/LehrerApp.Templating/README.md
@@ -119,6 +119,26 @@ haben. Haupt- und Folgeseitenlayout müssen dieselbe Seitengröße besitzen; ihr
technischen Gründen dieselbe Position und Größe haben. Ohne Folgeseitenlayout werden die statischen
Elemente der ersten Seite auf jeder erzeugten Seite wiederholt.
+## Systemvariablen
+
+Textinhalte können drei vom Renderer bereitgestellte Variablen verwenden. Sie werden nicht im
+Manifest deklariert und nicht vom `ITemplateDataProvider` geliefert:
+
+- `$$today` – aktuelles lokales Datum im deutschen Kurzformat
+- `$$curPage` – aktuelle Seitenzahl
+- `$$maxPageNum` – Gesamtzahl der Seiten
+
+Sie können allein oder innerhalb eines Literals stehen, beispielsweise:
+
+```text
+TEXT 20 10 "Stand: $$today" size=9
+TEXT 145 285 "Seite $$curPage von $$maxPageNum" size=9 align=right
+```
+
+QuestPDF löst aktuelle und gesamte Seitenzahl während der Dokumenterzeugung auf. Dafür ist kein
+zusätzlicher Renderdurchlauf durch die Anwendung erforderlich. Die Variablen funktionieren auch in
+konstanten Rich-Text-Platzhaltern.
+
## Konstante Platzhalter und Hervorhebung
Ein Platzhalter kann seinen Wert vollständig im Vorlagenpaket tragen. `IsConstant=true` bewirkt,
diff --git a/LehrerApp.Templating/SystemVariables.cs b/LehrerApp.Templating/SystemVariables.cs
new file mode 100644
index 0000000..8cf2648
--- /dev/null
+++ b/LehrerApp.Templating/SystemVariables.cs
@@ -0,0 +1,48 @@
+namespace LehrerApp.Templating;
+
+internal static class SystemVariables
+{
+ internal const string CurrentPage = "curPage";
+ internal const string MaximumPageNumber = "maxPageNum";
+ internal const string Today = "today";
+
+ private static readonly string[] Names = [CurrentPage, MaximumPageNumber, Today];
+
+ internal static bool IsStandalone(string value) =>
+ TryRead(value, 0, out var length) && length == value.Length;
+
+ internal static bool Contains(string value) => Names.Any(name =>
+ value.Contains("$$" + name, StringComparison.Ordinal));
+
+ internal static bool TryRead(string source, int index, out int length)
+ {
+ length = 0;
+ if (index < 0 || index + 2 > source.Length || source[index] != '$' || source[index + 1] != '$')
+ return false;
+ foreach (var name in Names)
+ {
+ var token = "$$" + name;
+ if (!source.AsSpan(index).StartsWith(token, StringComparison.Ordinal)) continue;
+ var end = index + token.Length;
+ if (end < source.Length && (char.IsLetterOrDigit(source[end]) || source[end] is '_' or '-' or '.'))
+ continue;
+ length = token.Length;
+ return true;
+ }
+ return false;
+ }
+
+ internal static IEnumerable<(string Text, string? Name)> Split(string source)
+ {
+ var literalStart = 0;
+ for (var index = 0; index < source.Length;)
+ {
+ if (!TryRead(source, index, out var length)) { index++; continue; }
+ if (index > literalStart) yield return (source[literalStart..index], null);
+ yield return (source.Substring(index, length), source.Substring(index + 2, length - 2));
+ index += length;
+ literalStart = index;
+ }
+ if (literalStart < source.Length) yield return (source[literalStart..], null);
+ }
+}
diff --git a/LehrerApp.Templating/TemplateRichText.cs b/LehrerApp.Templating/TemplateRichText.cs
index cd9ad14..cfc5789 100644
--- a/LehrerApp.Templating/TemplateRichText.cs
+++ b/LehrerApp.Templating/TemplateRichText.cs
@@ -30,6 +30,13 @@ public static class TemplateRichText
&& source[index + 1] is '\\' or '[' or '$')
{ literal.Append(source[index + 1]); index += 2; continue; }
+ if (source[index] == '$' && SystemVariables.TryRead(source, index, out var systemLength))
+ {
+ literal.Append(source, index, systemLength);
+ index += systemLength;
+ continue;
+ }
+
if (TryTag(source, index, out var tag, out var closing, out var tagLength))
{
Flush();