376 lines
17 KiB
C#
376 lines
17 KiB
C#
using System.IO.Compression;
|
|
using LehrerApp.Templating;
|
|
using Xunit;
|
|
|
|
namespace LehrerApp.Templating.Tests;
|
|
|
|
public sealed class TemplatingTests : IDisposable
|
|
{
|
|
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lavorlage-{Guid.NewGuid():N}");
|
|
public TemplatingTests() => Directory.CreateDirectory(_directory);
|
|
|
|
[Fact]
|
|
public void LayoutParser_LiestAlleElementtypenUndAttribute()
|
|
{
|
|
var layout = new LayoutParser().Parse("""
|
|
# Beispiel
|
|
PAGE 210 297 mm
|
|
BG background.png
|
|
IMG logo.png 15 15 30 12 scale=50%
|
|
TEXT 20 45 $Datum|dd.MM.yyyy size=10
|
|
TEXTBOX 20 90 170 120 $Brieftext wrap=true
|
|
FLOWBOX 20 20 170 250 $Brieftext size=11
|
|
TABLE 20 215 170 40 $Zeilen size=9
|
|
CHART 20 260 170 25 $Werte type=line
|
|
""");
|
|
|
|
Assert.Equal(7, layout.Elements.Count);
|
|
Assert.Equal("50%", Assert.IsType<ImageElement>(layout.Elements[1]).Attributes["scale"]);
|
|
Assert.Equal("dd.MM.yyyy", Assert.IsType<TextElement>(layout.Elements[2]).Format);
|
|
Assert.IsType<FlowBoxElement>(layout.Elements[4]);
|
|
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()
|
|
{
|
|
var path = Path.Combine(_directory, "flow.lavorlage");
|
|
var manifest = new TemplateManifest
|
|
{
|
|
Id = "flow", Name = "Fließtext", ContinuationLayoutFile = "continuation.tpl",
|
|
Placeholders = [new("Text", PlaceholderType.Multiline, true)],
|
|
};
|
|
var first = "PAGE 210 297 mm\nTEXT 20 10 \"Erste Seite\" size=14\nFLOWBOX 20 25 170 252 $Text size=11";
|
|
var continuation = "PAGE 210 297 mm\nTEXT 20 10 \"Folgeseite\" size=10\nFLOWBOX 20 25 170 252 $Text size=11";
|
|
TemplatePackage.Create(path, manifest, first, new Dictionary<string, byte[]>(), continuation);
|
|
|
|
var loaded = new TemplateLoader().LoadFromPackage(path);
|
|
var longText = string.Join('\n', Enumerable.Repeat(
|
|
"Ein langer Klassenbucheintrag mit ausreichend Inhalt für den automatischen Seitenumbruch.", 250));
|
|
var pdf = new QuestTemplateRenderer().RenderToPdf(loaded,
|
|
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Text"] = new MultilineValue(longText) }));
|
|
var source = System.Text.Encoding.ASCII.GetString(pdf);
|
|
|
|
Assert.NotNull(loaded.ContinuationLayout);
|
|
Assert.True(System.Text.RegularExpressions.Regex.Matches(source, @"/Type\s*/Page\b").Count > 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void DrawBox_RendertDeklarativeExterneZeichenbefehleInFesterBox()
|
|
{
|
|
var template = new LoadedTemplate(new TemplateManifest
|
|
{
|
|
Placeholders = [new("Grafik", PlaceholderType.Drawing, true)],
|
|
}, new LayoutParser().Parse("PAGE 210 297 mm\nDRAWBOX 20 20 100 60 $Grafik"),
|
|
new Dictionary<string, byte[]>());
|
|
var drawing = new DrawingValue(
|
|
[new DrawRectangle(0, 0, 100, 60, "#1D4ED8", 1, "#EFF6FF"),
|
|
new DrawString(5, 5, "Externer Inhalt", 11, Bold: true),
|
|
new MoveTo(5, 25), new LineTo(95, 25, "#DC2626", 1.5f),
|
|
new DrawLine(5, 35, 95, 50, "#059669", 1)], 60);
|
|
|
|
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
|
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Grafik"] = drawing }));
|
|
|
|
Assert.Single(System.Text.RegularExpressions.Regex.Matches(
|
|
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b"));
|
|
}
|
|
|
|
// Regression: RenderFlow fensterte Seiten bisher nur über einen SVG-viewBox-Y-Offset, den
|
|
// QuestPDFs Svg()-Renderer nicht respektiert - dadurch landete auf jeder Folgeseite (nahezu)
|
|
// der komplette Zeicheninhalt erneut, statt nur des jeweiligen Ausschnitts (siehe Slice() in
|
|
// QuestTemplateRenderer.DrawingElementRenderer). Prüft per echter PDF-Textextraktion, dass
|
|
// seitenspezifische Marker nur auf ihrer jeweiligen Seite auftauchen.
|
|
[Fact]
|
|
public void FlowDrawBox_ZerschneidetInhaltEchtStattIhnAufFolgeseitenZuWiederholen()
|
|
{
|
|
var template = new LoadedTemplate(new TemplateManifest
|
|
{
|
|
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
|
|
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker"),
|
|
new Dictionary<string, byte[]>());
|
|
var drawing = new DrawingValue(
|
|
[
|
|
new DrawStringEx(0, 0, 12, 170, "MARKERTOP", DrawingTextAlignment.AlignLeft, 10),
|
|
new DrawStringEx(0, 90, 12, 170, "MARKERNEARBOTTOM", DrawingTextAlignment.AlignLeft, 10),
|
|
new DrawStringEx(0, 105, 12, 170, "MARKERAFTERBOUNDARY", DrawingTextAlignment.AlignLeft, 10),
|
|
new DrawStringEx(0, 190, 12, 170, "MARKERBOTTOM", DrawingTextAlignment.AlignLeft, 10),
|
|
], 200);
|
|
|
|
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
|
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing }));
|
|
using var document = UglyToad.PdfPig.PdfDocument.Open(pdf);
|
|
var pages = document.GetPages().Select(p => p.Text).ToList();
|
|
|
|
Assert.Equal(2, pages.Count);
|
|
Assert.Contains("MARKERTOP", pages[0]);
|
|
Assert.Contains("MARKERNEARBOTTOM", pages[0]);
|
|
Assert.DoesNotContain("MARKERAFTERBOUNDARY", pages[0]);
|
|
Assert.DoesNotContain("MARKERBOTTOM", pages[0]);
|
|
Assert.DoesNotContain("MARKERTOP", pages[1]);
|
|
Assert.DoesNotContain("MARKERNEARBOTTOM", pages[1]);
|
|
Assert.Contains("MARKERAFTERBOUNDARY", pages[1]);
|
|
Assert.Contains("MARKERBOTTOM", pages[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlowDrawBox_PaginertHohenDeklarativenZeichenraum()
|
|
{
|
|
var template = new LoadedTemplate(new TemplateManifest
|
|
{
|
|
Placeholders = [new("Protokoll", PlaceholderType.Drawing, true)],
|
|
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Protokoll"),
|
|
new Dictionary<string, byte[]>());
|
|
var drawing = new DrawingValue(
|
|
Enumerable.Range(0, 80).SelectMany(i => (DrawingCommand[])
|
|
[new DrawString(0, i * 10, $"Zeile {i + 1}", 8), new DrawLine(0, i * 10 + 9, 160, i * 10 + 9, "#CBD5E1", .3f)])
|
|
.ToList(), 800);
|
|
|
|
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
|
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Protokoll"] = drawing }));
|
|
|
|
Assert.True(System.Text.RegularExpressions.Regex.Matches(
|
|
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count >= 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlowDrawBox_CallbackErhaeltSeitenflaecheStateUndSteuertSeitenwechsel()
|
|
{
|
|
var template = new LoadedTemplate(new TemplateManifest
|
|
{
|
|
Placeholders = [new("Bericht", PlaceholderType.Drawing, true)],
|
|
}, new LayoutParser().Parse("PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 257 $Bericht"),
|
|
new Dictionary<string, byte[]>());
|
|
var invocations = 0;
|
|
var drawing = new PagedDrawingValue(context =>
|
|
{
|
|
invocations++;
|
|
var item = context.State is int value ? value : 0;
|
|
context.Canvas.DrawRectangle(0, 0, context.Width, 20, "#1D4ED8", .5f, "#EFF6FF");
|
|
context.Canvas.DrawStringEx(0, 2, 14, context.Width, $"Untrennbarer Block {item + 1}",
|
|
DrawingTextAlignment.AlignCenter, 11, "Arial", "#1E3A8A", bold: true);
|
|
context.State = item + 1;
|
|
return context.PageNumber == 3;
|
|
}, InitialState: 0);
|
|
|
|
var pdf = new QuestTemplateRenderer().RenderToPdf(template,
|
|
new DictionaryProvider(new Dictionary<string, PlaceholderValue> { ["Bericht"] = drawing }));
|
|
|
|
Assert.Equal(3, invocations);
|
|
Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(
|
|
System.Text.Encoding.ASCII.GetString(pdf), @"/Type\s*/Page\b").Count);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("scale=0%")]
|
|
[InlineData("scale=-10%")]
|
|
[InlineData("scale=1001%")]
|
|
[InlineData("scale=abc")]
|
|
public void LayoutParser_LehntUngueltigeBildskalierungAb(string attribute)
|
|
{
|
|
var exception = Assert.Throws<TemplateValidationException>(() =>
|
|
new LayoutParser().Parse($"PAGE 210 297 mm\nIMG logo.png 10 10 20 20 {attribute}"));
|
|
|
|
Assert.Contains(exception.Result.Issues, issue => issue.Message.Contains("scale", StringComparison.OrdinalIgnoreCase)
|
|
|| issue.Message.Contains("keine Zahl", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void LayoutParser_MeldetAlleSyntaxfehlerMitZeilen()
|
|
{
|
|
var exception = Assert.Throws<TemplateValidationException>(() => new LayoutParser().Parse("""
|
|
TEXT 1 2 "vor PAGE"
|
|
PAGE x 297 mm
|
|
UNBEKANNT 1
|
|
"""));
|
|
|
|
Assert.True(exception.Result.Issues.Count >= 3);
|
|
Assert.Equal(3, exception.Result.Issues.Count(issue => issue.Line is not null));
|
|
}
|
|
|
|
[Fact]
|
|
public void LayoutParser_LiestSeitentypenSlotsUndContentFlows()
|
|
{
|
|
var layout = new LayoutParser().Parse("""
|
|
PAGE 210 297 mm
|
|
#pragma format-version 3
|
|
#pragma page-template first
|
|
TEXT 20 20 "Briefkopf"
|
|
#pragma flow-slot body x=20 y=80 w=170 h=190
|
|
#pragma end-page-template
|
|
#pragma page-template continuation
|
|
#pragma flow-slot body x=20 y=25 w=170 h=245
|
|
#pragma end-page-template
|
|
#pragma content-flow body
|
|
TEXTBOX $Text size=11 overflow=continue
|
|
TEXT "Gruß" gap=8 keep-with-next=true
|
|
TEXT $Name
|
|
#pragma end-content-flow
|
|
""");
|
|
|
|
Assert.Equal(3, layout.FormatVersion);
|
|
Assert.Equal(2, layout.PageTemplates.Count);
|
|
Assert.Equal(80, layout.PageTemplates[0].FlowSlots.Single().Y);
|
|
Assert.Equal(3, layout.ContentFlows.Single().Elements.Count);
|
|
Assert.IsType<TextBoxElement>(layout.ContentFlows.Single().Elements[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Renderer_FliesstLangenTextAufFolgeseitenWeiter()
|
|
{
|
|
var manifest = new TemplateManifest
|
|
{
|
|
Id = "flow", Name = "Flow",
|
|
Placeholders = [new("Text", PlaceholderType.Multiline, true), new("Name", PlaceholderType.Text, true)],
|
|
};
|
|
var layout = new LayoutParser().Parse("""
|
|
PAGE 210 297 mm
|
|
#pragma format-version 3
|
|
#pragma page-template first
|
|
TEXT 20 15 "Erste Seite" size=16
|
|
#pragma flow-slot body x=20 y=55 w=170 h=215
|
|
#pragma end-page-template
|
|
#pragma page-template continuation
|
|
TEXT 20 12 "Folgeseite" size=9
|
|
#pragma flow-slot body x=20 y=25 w=170 h=245
|
|
#pragma end-page-template
|
|
#pragma content-flow body
|
|
TEXTBOX $Text size=11 overflow=continue
|
|
TEXT "Mit freundlichen Grüßen" gap=8 keep-with-next=true
|
|
TEXT $Name gap=3
|
|
#pragma end-content-flow
|
|
""");
|
|
var loaded = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
|
|
var longText = string.Join('\n', Enumerable.Repeat("Dies ist eine ausreichend lange Textzeile für den Seitenumbruch.", 180));
|
|
|
|
var pages = new QuestTemplateRenderer().RenderPagesToPng(loaded,
|
|
new DictionaryProvider(new Dictionary<string, PlaceholderValue>
|
|
{
|
|
["Text"] = new MultilineValue(longText), ["Name"] = new TextValue("M. Mustermann"),
|
|
}), 40);
|
|
|
|
Assert.True(pages.Count >= 3);
|
|
Assert.All(pages, page => Assert.True(page.Length > 500));
|
|
}
|
|
|
|
[Fact]
|
|
public void Loader_BlockiertPathTraversal()
|
|
{
|
|
var path = Path.Combine(_directory, "unsafe.lavorlage");
|
|
using (var archive = ZipFile.Open(path, ZipArchiveMode.Create))
|
|
{
|
|
Write(archive, "../manifest.json", "{}");
|
|
Write(archive, "manifest.json", ManifestJson());
|
|
Write(archive, "layout.tpl", "PAGE 210 297 mm");
|
|
}
|
|
|
|
var exception = Assert.Throws<TemplateValidationException>(() => new TemplateLoader().LoadFromPackage(path));
|
|
Assert.Contains(exception.Result.Issues, x => x.Message.Contains("Unsicherer Paketpfad"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Loader_SammeltUnbekanntenPlatzhalterUndFehlendesBild()
|
|
{
|
|
var path = Path.Combine(_directory, "invalid.lavorlage");
|
|
using (var archive = ZipFile.Open(path, ZipArchiveMode.Create))
|
|
{
|
|
Write(archive, "manifest.json", ManifestJson());
|
|
Write(archive, "layout.tpl", "PAGE 210 297 mm\nBG fehlt.png\nTEXT 10 10 $Unbekannt");
|
|
}
|
|
|
|
var exception = Assert.Throws<TemplateValidationException>(() => new TemplateLoader().LoadFromPackage(path));
|
|
Assert.Equal(2, exception.Result.Issues.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_SammeltFehlendeUndFalscheDatentypen()
|
|
{
|
|
var loaded = new LoadedTemplate(new TemplateManifest
|
|
{
|
|
Placeholders = [new("Datum", PlaceholderType.Date, true), new("Text", PlaceholderType.Text, true)],
|
|
}, new(210, 297, "mm", []), new Dictionary<string, byte[]>());
|
|
|
|
var result = new TemplateLoader().Validate(loaded,
|
|
new Dictionary<string, PlaceholderType> { ["Datum"] = PlaceholderType.Text });
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(2, result.Issues.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Paket_RoundtripUndPdfSindGueltig()
|
|
{
|
|
var path = Path.Combine(_directory, "brief.lavorlage");
|
|
var manifest = new TemplateManifest
|
|
{
|
|
Id = "brief", Name = "Brief",
|
|
Placeholders = [new("Datum", PlaceholderType.Date, true), new("Text", PlaceholderType.Multiline, true)],
|
|
};
|
|
TemplatePackage.Create(path, manifest, """
|
|
PAGE 210 297 mm
|
|
TEXT 20 20 "Elternbrief" size=18 bold=true color=#1D4ED8
|
|
TEXT 20 35 $Datum|dd.MM.yyyy size=10
|
|
TEXTBOX 20 50 170 200 $Text size=11
|
|
""", new Dictionary<string, byte[]>());
|
|
var loaded = new TemplateLoader().LoadFromPackage(path);
|
|
var pdf = new QuestTemplateRenderer().RenderToPdf(loaded, new DictionaryProvider(new Dictionary<string, PlaceholderValue>
|
|
{
|
|
["Datum"] = new DateValue(new DateOnly(2026, 8, 30)),
|
|
["Text"] = new MultilineValue("Sehr geehrte Eltern,\n\ndies ist ein Testbrief."),
|
|
}));
|
|
|
|
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
|
|
Assert.True(pdf.Length > 1_000);
|
|
}
|
|
|
|
[Fact]
|
|
public void Paket_RoundtripErhaeltAuchNochNichtVerwendeteBildAssets()
|
|
{
|
|
var path = Path.Combine(_directory, "assets.lavorlage");
|
|
var png = Convert.FromBase64String(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
|
|
TemplatePackage.Create(path, new TemplateManifest { Id = "assets", Name = "Assets" },
|
|
"PAGE 210 297 mm\nIMG logo.png 10 10 20 20",
|
|
new Dictionary<string, byte[]> { ["logo.png"] = png, ["assets/siegel.png"] = png });
|
|
|
|
var loaded = new TemplateLoader().LoadFromPackage(path);
|
|
|
|
Assert.Equal(2, loaded.Assets.Count);
|
|
Assert.Contains("assets/siegel.png", loaded.Assets.Keys);
|
|
}
|
|
|
|
private static string ManifestJson() => """
|
|
{ "schemaVersion": 1, "id": "test", "name": "Test",
|
|
"pageSize": { "width": 210, "height": 297, "unit": "mm" },
|
|
"layoutFile": "layout.tpl", "placeholders": [] }
|
|
""";
|
|
private static void Write(ZipArchive archive, string path, string content)
|
|
{ using var writer = new StreamWriter(archive.CreateEntry(path).Open()); writer.Write(content); }
|
|
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
|
}
|
|
|
|
internal sealed class DictionaryProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
|
{
|
|
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
|
|
}
|