TemplateDesigner: SystemVars
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-31 23:47:48 +02:00
parent 11ef73ab1f
commit c6efdab9ef
7 changed files with 139 additions and 5 deletions
@@ -197,6 +197,8 @@
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
<TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
<TextBlock Text="Systemvariablen ohne Deklaration: $$today, $$curPage und $$maxPageNum (z. B. &quot;Seite $$curPage von $$maxPageNum&quot;)."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
<TextBlock Text="DRAWBOX und FLOWDRAWBOX nehmen deklarative Zeichenbefehle (Text, Linien, Rechtecke, Bilder) aus einer externen App entgegen."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
</StackPanel>
@@ -31,6 +31,26 @@ public sealed class TemplatingTests : IDisposable
Assert.Equal("line", Assert.IsType<ChartElement>(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<string, byte[]>());
var validation = new TemplateLoader().Validate(template, new Dictionary<string, PlaceholderType>());
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
new DictionaryProvider(new Dictionary<string, PlaceholderValue>()));
Assert.True(validation.IsValid);
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
}
[Fact]
public void FlowBox_FliesstAufFolgeseitenUndPaketEnthaeltFolgeseitenLayout()
{
+1
View File
@@ -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.");
+37 -1
View File
@@ -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);
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)
{
+20
View File
@@ -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,
+48
View File
@@ -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);
}
}
+7
View File
@@ -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();