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"/> TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
<TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich." <TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/> 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." <TextBlock Text="DRAWBOX und FLOWDRAWBOX nehmen deklarative Zeichenbefehle (Text, Linien, Rechtecke, Bilder) aus einer externen App entgegen."
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/> TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
</StackPanel> </StackPanel>
@@ -31,6 +31,26 @@ public sealed class TemplatingTests : IDisposable
Assert.Equal("line", Assert.IsType<ChartElement>(layout.Elements[6]).ChartType); 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] [Fact]
public void FlowBox_FliesstAufFolgeseitenUndPaketEnthaeltFolgeseitenLayout() public void FlowBox_FliesstAufFolgeseitenUndPaketEnthaeltFolgeseitenLayout()
{ {
+1
View File
@@ -103,6 +103,7 @@ public sealed class LayoutParser
private static (string? Name, string? Format) Reference(string value) private static (string? Name, string? Format) Reference(string value)
{ {
if (SystemVariables.IsStandalone(value)) return (null, null);
if (!value.StartsWith('$')) return (null, null); if (!value.StartsWith('$')) return (null, null);
var parts = value[1..].Split('|', 2); var parts = value[1..].Split('|', 2);
if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt."); if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt.");
+41 -5
View File
@@ -177,6 +177,15 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
{ {
var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch var aligned = attributes.GetValueOrDefault("align", "left").ToLowerInvariant() switch
{ "center" => container.AlignCenter(), "right" => container.AlignRight(), _ => container.AlignLeft() }; { "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); var descriptor = aligned.Text(content);
descriptor.FontSize(ParseFloat(attributes, "size", 11)); descriptor.FontSize(ParseFloat(attributes, "size", 11));
if (ParseBool(attributes, "bold")) descriptor.SemiBold(); if (ParseBool(attributes, "bold")) descriptor.SemiBold();
@@ -216,15 +225,42 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
{ {
var content = run.Placeholder is null ? run.Text var content = run.Placeholder is null ? run.Text
: values.TryGetValue(run.Placeholder, out var value) ? Format(value, run.Format) : ""; : 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))
if (globalBold || run.Bold) span.SemiBold(); {
if (globalItalic || run.Italic) span.Italic(); span.FontSize(fontSize);
if (globalUnderline || run.Underline) span.Underline(); if (globalBold || run.Bold) span.SemiBold();
if (color is not null) span.FontColor(color); 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, private static IReadOnlyDictionary<string, string> TextAttributes(TemplateManifest manifest, string? placeholder,
IReadOnlyDictionary<string, string> elementAttributes) 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 technischen Gründen dieselbe Position und Größe haben. Ohne Folgeseitenlayout werden die statischen
Elemente der ersten Seite auf jeder erzeugten Seite wiederholt. 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 ## Konstante Platzhalter und Hervorhebung
Ein Platzhalter kann seinen Wert vollständig im Vorlagenpaket tragen. `IsConstant=true` bewirkt, 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 '$') && source[index + 1] is '\\' or '[' or '$')
{ literal.Append(source[index + 1]); index += 2; continue; } { 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)) if (TryTag(source, index, out var tag, out var closing, out var tagLength))
{ {
Flush(); Flush();