fix: Umbruchlogik
CI / build-and-test (push) Waiting to run

This commit is contained in:
2026-09-16 02:21:21 +02:00
parent ef3e9dbbb6
commit 861f2c76ca
4 changed files with 117 additions and 3 deletions
@@ -0,0 +1,40 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class _TempFlowCheck2
{
[Fact]
public void MarkerTest()
{
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
Directory.CreateDirectory(outDir);
var commands = new List<DrawingCommand>
{
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
};
var drawing = new DrawingValue(commands, 200);
var manifest = new TemplateManifest
{
Id = "marker", Name = "Marker",
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
};
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
var loadedLayout = new LayoutParser().Parse(layout);
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
for (var i = 0; i < pngs.Count; i++)
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
}
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
}
@@ -3,6 +3,7 @@
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="PdfPig" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
@@ -96,6 +96,43 @@ public sealed class TemplatingTests : IDisposable
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()
{
+39 -3
View File
@@ -534,18 +534,54 @@ internal static class DrawingElementRenderer
for (var page = 0; page < pages.Count; page++)
{
if (page > 0) column.Item().PageBreak();
var offset = value is DrawingValue ? page * pageHeight : 0;
// Koordinaten sind nach Slice()/RecordPages() bereits seitenlokal (bei 0 beginnend) -
// kein zusätzlicher viewBox-Y-Offset nötig oder sinnvoll (siehe Slice()).
column.Item().Height(UnitConverter.Points(pageHeight, unit)).Svg(
BuildSvg(pages[page], width, pageHeight, offset, unit));
BuildSvg(pages[page], width, pageHeight, 0, unit));
}
});
}
// Schneidet die Zeichenbefehle anhand ihrer Y-Koordinate in Seiten auf und verschiebt sie auf
// seitenlokale Koordinaten (Y=0 am Seitenanfang). Ein SVG-Element mit "viewBox" und
// "overflow=hidden" wird von QuestPDFs Svg()-Renderer NICHT zuverlässig geclippt - ein
// (Y-)Offset im viewBox allein reicht nicht, um pro Seite nur den jeweiligen Ausschnitt zu
// zeigen; ohne dieses echte Zerschneiden landet auf jeder Seite (nahezu) der komplette
// Zeicheninhalt erneut (Bug: doppelte/überlappende Fehlzeitenliste über mehrere Seiten).
private static List<DrawingValue> Slice(DrawingValue value, float pageHeight)
{
var pageCount = Math.Max(1, (int)Math.Ceiling(Math.Max(0, value.ContentHeight) / pageHeight));
return Enumerable.Repeat(value, pageCount).ToList();
var pages = new List<List<DrawingCommand>>();
for (var i = 0; i < pageCount; i++) pages.Add([]);
var pendingLinePage = 0;
foreach (var command in value.Commands)
{
var y = CommandY(command);
if (y is null) { pages[0].Add(command); continue; }
var page = Math.Clamp((int)(y.Value / pageHeight), 0, pageCount - 1);
// LineTo gehört inhaltlich zum vorherigen MoveTo (eine Linie) - beide müssen auf
// derselben Seite landen, sonst fehlt beim Rendern der Linie der Startpunkt.
if (command is MoveTo) pendingLinePage = page;
else if (command is LineTo) page = pendingLinePage;
pages[page].Add(ShiftY(command, page * pageHeight));
}
return pages.Select(commands => new DrawingValue(commands, pageHeight)).ToList();
}
private static float? CommandY(DrawingCommand command) => command switch
{
DrawString s => s.Y, DrawStringEx s => s.Y, MoveTo m => m.Y, LineTo l => l.Y,
DrawRectangle r => r.Y, DrawImage i => i.Y, DrawLine ln => Math.Min(ln.Y1, ln.Y2), _ => null,
};
private static DrawingCommand ShiftY(DrawingCommand command, float dy) => command switch
{
DrawString s => s with { Y = s.Y - dy }, DrawStringEx s => s with { Y = s.Y - dy },
MoveTo m => m with { Y = m.Y - dy }, LineTo l => l with { Y = l.Y - dy },
DrawRectangle r => r with { Y = r.Y - dy }, DrawImage i => i with { Y = i.Y - dy },
DrawLine ln => ln with { Y1 = ln.Y1 - dy, Y2 = ln.Y2 - dy },
_ => command,
};
internal static List<IReadOnlyList<DrawingCommand>> RecordPages(PagedDrawingValue value,
float width, float height, int maxPages)