diff --git a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
index 1b485ce..492dcc9 100644
--- a/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
+++ b/LehrerApp.Desktop.Tests/ExamCoverSheetTests.cs
@@ -204,6 +204,25 @@ public sealed class ExamCoverSheetTests : IDisposable
Assert.Equal(new TextValue("G-Kurs"), Values(Niveau.E)["Exam.Course"]);
}
+ // Der Vorlagen-Designer bietet genau diesen Katalog an - liefert die Hauptapp einen Namen
+ // nicht oder mit anderem Typ, scheitert die Vorlage erst beim Drucken.
+ [Fact]
+ public void Platzhalter_DeckenDenKatalogDesDesignersMitGleichenTypenAb()
+ {
+ var store = Store();
+ Import(store, "deckblatt", "Deckblatt", TemplateReportTypes.ExamCover);
+ var template = store.Load(store.GetTemplates().Single());
+
+ var values = ExamCoverPlaceholderBuilder.Build(_exam, _group, "Chemie", _anna, null, false,
+ new GradingService(), template, new DateOnly(2026, 1, 1));
+
+ Assert.All(ExamCoverPlaceholders.All, known =>
+ {
+ Assert.True(values.ContainsKey(known.Name), $"{known.Name} fehlt");
+ Assert.Equal(known.Type, values[known.Name].Type);
+ });
+ }
+
[Fact]
public void TeacherSettings_SpeichertDasKuerzel()
{
diff --git a/LehrerApp.Desktop/Services/ExamPointsTableDrawingBuilder.cs b/LehrerApp.Desktop/Services/ExamPointsTableDrawingBuilder.cs
index b665cad..b4427a5 100644
--- a/LehrerApp.Desktop/Services/ExamPointsTableDrawingBuilder.cs
+++ b/LehrerApp.Desktop/Services/ExamPointsTableDrawingBuilder.cs
@@ -1,82 +1,21 @@
-using System.Globalization;
using LehrerApp.Core.Services;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
-/// Zeichnet die Punktetabelle des Klausur-Deckblatts (Aufgabe / Max. Punktzahl /
-/// Punktzahl). Die Spalten haben eine feste Breite: passen nicht alle Aufgaben nebeneinander in die
-/// Box der Vorlage, folgt darunter ein weiterer Tabellenblock, statt alles zu verkleinern. Gedacht
-/// für eine FLOWDRAWBOX … fit=content, damit nachfolgende Elemente mitwandern.
+/// Brücke von den Klausurdaten zur Punktetabelle des Deckblatts. Gezeichnet wird in
+/// (Vorlagenbibliothek), damit der Vorlagen-Designer dieselbe
+/// Tabelle in der Vorschau zeigt.
public static class ExamPointsTableDrawingBuilder
{
- public const string PlaceholderName = "Exam.PointsTable";
+ public const string PlaceholderName = ExamPointsTableDrawing.PlaceholderName;
- private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
-
- // Referenzentwurf in Millimetern, abgemessen an der Schulvorlage.
- private const float LabelWidth = 38f, ColumnWidth = 20.5f;
- private const float HeaderHeight = 5.5f, MaximumHeight = 8.5f, PointsHeight = 11.5f;
- private const float BlockGap = 4f, Padding = 1.5f, Stroke = 0.2f, HeavyStroke = 0.45f;
- private const float FontSize = 10f;
-
- /// Breite der deklarierten Box in der Maßeinheit der Vorlage.
- /// 1 mm in der Maßeinheit der Vorlage (siehe
- /// ).
public static DrawingValue Build(IReadOnlyList columns, float contentWidth = 170,
- float millimeterScale = 1)
- {
- if (columns.Count == 0) return new DrawingValue([], 0);
- var k = millimeterScale;
- var perBlock = ColumnsPerBlock(contentWidth, millimeterScale);
- // Halbe Strichstärke Abstand zum Rand, sonst schneidet die Box die äußeren Linien an.
- var origin = HeavyStroke / 2 * k;
- var blockHeight = (HeaderHeight + MaximumHeight + PointsHeight) * k;
- var commands = new List();
- var y = origin;
- for (var start = 0; start < columns.Count; start += perBlock)
- {
- if (start > 0) y += BlockGap * k;
- var block = columns.Skip(start).Take(perBlock).ToList();
- var maximumY = y + HeaderHeight * k;
- var pointsY = maximumY + MaximumHeight * k;
+ float millimeterScale = 1) =>
+ ExamPointsTableDrawing.Build(
+ columns.Select(c => new ExamPointsTableColumn(c.Number, c.MaxPoints, c.IsBonus, c.AchievedPoints)).ToList(),
+ contentWidth, millimeterScale);
- Cell(commands, origin, y, LabelWidth * k, HeaderHeight * k, "Aufgabe", DrawingTextAlignment.AlignRight, false, k);
- Cell(commands, origin, maximumY, LabelWidth * k, MaximumHeight * k, "Max. Punktzahl", DrawingTextAlignment.AlignRight, false, k);
- Cell(commands, origin, pointsY, LabelWidth * k, PointsHeight * k, "Punktzahl", DrawingTextAlignment.AlignRight, true, k);
- for (var index = 0; index < block.Count; index++)
- {
- var column = block[index];
- var x = origin + (LabelWidth + index * ColumnWidth) * k;
- Cell(commands, x, y, ColumnWidth * k, HeaderHeight * k,
- column.IsBonus ? "" : $"Nr. {column.Number}", DrawingTextAlignment.AlignCenter, false, k);
- Cell(commands, x, maximumY, ColumnWidth * k, MaximumHeight * k,
- (column.IsBonus ? "+" : "") + Format(column.MaxPoints), DrawingTextAlignment.AlignCenter, false, k);
- Cell(commands, x, pointsY, ColumnWidth * k, PointsHeight * k,
- column.AchievedPoints is { } achieved ? Format(achieved) : "", DrawingTextAlignment.AlignCenter, false, k);
- }
- commands.Add(new DrawLine(origin, pointsY, origin + (LabelWidth + block.Count * ColumnWidth) * k, pointsY,
- StrokeWidth: HeavyStroke * k));
- y += blockHeight;
- }
- return new DrawingValue(commands, y + origin);
- }
-
- /// Wie viele Aufgaben bei fester Spaltenbreite nebeneinander in die Box passen.
public static int ColumnsPerBlock(float contentWidth, float millimeterScale = 1) =>
- Math.Max(1, (int)Math.Floor((contentWidth / millimeterScale - HeavyStroke - LabelWidth + 0.01f) / ColumnWidth));
-
- private static void Cell(List commands, float x, float y, float width, float height,
- string text, DrawingTextAlignment alignment, bool bold, float k)
- {
- commands.Add(new DrawRectangle(x, y, width, height, StrokeWidth: Stroke * k));
- if (text.Length == 0) return;
- // DrawStringEx setzt die Grundlinie auf Y + Schriftgröße; so sitzt die Zeile optisch mittig.
- var fontHeight = FontSize * UnitConverter.Points(1, "pt") / UnitConverter.Points(1, "mm") * k;
- var textY = y + height / 2 - fontHeight * 0.64f;
- commands.Add(new DrawStringEx(x + Padding * k, textY, y + height - textY, width - 2 * Padding * k, text,
- alignment, FontSize, Bold: bold));
- }
-
- private static string Format(double value) => value.ToString("0.##", German);
+ ExamPointsTableDrawing.ColumnsPerBlock(contentWidth, millimeterScale);
}
diff --git a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs
index fd71e90..9ecbd12 100644
--- a/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs
+++ b/LehrerApp.Desktop/Services/LetterPlaceholderBuilder.cs
@@ -51,34 +51,8 @@ public static class LetterPlaceholderBuilder
_ => false,
};
- /// Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe eines Platzhalters (in der Maßeinheit der
- /// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". DrawingValue-Builder wie
- /// entwerfen ihr Raster in Millimetern;
- /// ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm") deklarierten Vorlage viel zu
- /// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed unterscheidet DRAWBOX (feste,
- /// nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von QuestTemplateRenderer
- /// stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von FLOWDRAWBOX (fließt bei
- /// Bedarf automatisch auf weitere Seiten, siehe DrawingElementRenderer.Slice) - nur bei
- /// IsFixed=true darf/muss ein Builder seine Höhe an Height anpassen.
- public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
-
+ /// Siehe — dort liegt die Suche, damit auch der
+ /// Vorlagen-Designer sie nutzen kann.
public static DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) =>
- FindDeclaredDrawingBox(template.Layout, placeholderName) ??
- (template.ContinuationLayout is not null ? FindDeclaredDrawingBox(template.ContinuationLayout, placeholderName) : null);
-
- private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName)
- {
- var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
- .Concat(layout.ContentFlows.SelectMany(f => f.Elements))
- .Select(e => e switch
- {
- DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
- FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
- _ => null,
- })
- .FirstOrDefault(b => b is not null);
- if (box is null) return null;
- var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
- return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
- }
+ DrawingBoxLocator.Find(template, placeholderName);
}
diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
index c33907e..408b430 100644
--- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs
@@ -157,9 +157,9 @@ public partial class CreateLetterDialogViewModel : ObservableObject
/// Platzhalter, damit der Kalender/die Fehltagesliste die real verfügbare Fläche ausfüllen statt
/// eine feste Breite zu raten (siehe LetterPlaceholderBuilder.FindDeclaredDrawingBox). Fällt auf
/// 170mm zurück, wenn keine Vorlage gewählt ist oder die Box nicht gefunden wird.
- private LetterPlaceholderBuilder.DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
+ private DeclaredDrawingBox DeclaredDrawingBox(string placeholderName)
{
- var fallback = new LetterPlaceholderBuilder.DeclaredDrawingBox(170, float.PositiveInfinity, 1, false);
+ var fallback = new DeclaredDrawingBox(170, float.PositiveInfinity, 1, false);
if (SelectedTemplate is null) return fallback;
try { return LetterPlaceholderBuilder.FindDeclaredDrawingBox(_templates.Load(SelectedTemplate.Model), placeholderName) ?? fallback; }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return fallback; }
diff --git a/LehrerApp.TemplateDesigner.Tests/ExamCoverDesignerTests.cs b/LehrerApp.TemplateDesigner.Tests/ExamCoverDesignerTests.cs
new file mode 100644
index 0000000..baf2cec
--- /dev/null
+++ b/LehrerApp.TemplateDesigner.Tests/ExamCoverDesignerTests.cs
@@ -0,0 +1,102 @@
+using LehrerApp.TemplateDesigner;
+using LehrerApp.Templating;
+using Xunit;
+
+namespace LehrerApp.TemplateDesigner.Tests;
+
+public sealed class ExamCoverDesignerTests
+{
+ [Fact]
+ public void SetUpExamCover_SetztVorlagentypUndErgaenztFehlendePlatzhalter()
+ {
+ var viewModel = new DesignerViewModel();
+ viewModel.Reset();
+ viewModel.Placeholders.Add(new("Exam.Title", PlaceholderType.Text, true, "Mein Titel"));
+
+ viewModel.SetUpExamCover();
+ viewModel.SetUpExamCover();
+
+ Assert.True(viewModel.IsExamCover);
+ Assert.Single(viewModel.MetadataItems, x => x.Name == TemplateMetadataKeys.ReportType);
+ Assert.Equal(ExamCoverPlaceholders.All.Count, viewModel.Placeholders.Count);
+ // Vorhandene Platzhalter bleiben samt Beispielwert erhalten.
+ Assert.Equal("Mein Titel", viewModel.Placeholders.Single(x => x.Name == "Exam.Title").Sample);
+ Assert.Equal("G-Kurs", viewModel.Placeholders.Single(x => x.Name == "Exam.Course").Sample);
+ Assert.Equal(PlaceholderType.Date, viewModel.Placeholders.Single(x => x.Name == "Exam.Date").Type);
+ Assert.Equal(TemplateReportTypes.ExamCover,
+ viewModel.BuildLoaded().Manifest.Metadata[TemplateMetadataKeys.ReportType]);
+ }
+
+ [Fact]
+ public void SetUpExamCover_AbweichenderTyp_WirdGemeldetStattUeberschrieben()
+ {
+ var viewModel = new DesignerViewModel();
+ viewModel.Reset();
+ viewModel.Placeholders.Add(new("Exam.Date", PlaceholderType.Text, false, "18.12."));
+
+ viewModel.SetUpExamCover();
+
+ Assert.Equal(PlaceholderType.Text, viewModel.Placeholders.Single(x => x.Name == "Exam.Date").Type);
+ Assert.Contains("Exam.Date", viewModel.Status);
+ }
+
+ [Fact]
+ public void Punktetabelle_WirdAlsFlowDrawBoxMitFitContentInDenContentFlowVorbereitet()
+ {
+ var viewModel = new DesignerViewModel();
+ viewModel.Reset();
+ viewModel.SelectedSpecialContent = viewModel.SpecialContents.Single(x =>
+ x.PlaceholderName == ExamPointsTableDrawing.PlaceholderName);
+
+ viewModel.PrepareSelectedSpecialContent();
+ viewModel.AddElement();
+
+ Assert.Contains("FLOWDRAWBOX $Exam.PointsTable w=165 h=150 fit=content", viewModel.LayoutSource);
+ var layout = viewModel.BuildLoaded().Layout;
+ Assert.IsType(layout.ContentFlows.Single().Elements.Single());
+ }
+
+ [Fact]
+ public void Vorschau_ZeigtDieEchtePunktetabelleUndSprechendeBeispielwerte()
+ {
+ var viewModel = new DesignerViewModel();
+ viewModel.Reset();
+ viewModel.SetUpExamCover();
+ viewModel.LayoutSource = """
+ PAGE 210 297 mm
+ #pragma format-version 3
+ #pragma page-template first
+ TEXT 20 20 "Fach: $Subject.Name $Exam.Course $Teacher.Abbreviation" size=12
+ #pragma flow-slot body x=20 y=40 w=170 h=230
+ #pragma end-page-template
+ #pragma content-flow body
+ FLOWDRAWBOX $Exam.PointsTable w=100 h=150 fit=content
+ TEXT "Gesamtpunkte: $Result.Points von $Exam.MaxPoints Punkte" size=11 gap=8
+ #pragma end-content-flow
+ """;
+
+ using var document = UglyToad.PdfPig.PdfDocument.Open(viewModel.RenderCurrentPdf());
+ var text = document.GetPage(1).Text.Replace(" ", "");
+
+ Assert.Contains("Fach:ChemieG-KursHED", text);
+ Assert.Contains("Gesamtpunkte:52,5von62Punkte", text);
+ Assert.Contains("+3", text);
+ // 100 mm Boxbreite: drei Aufgaben je Block, also drei Blöcke für neun Spalten.
+ Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(text, "Max.Punktzahl").Count);
+ }
+
+ [Fact]
+ public void Laden_EinerDeckblattVorlage_VerwendetSprechendeBeispielwerte()
+ {
+ var source = new DesignerViewModel();
+ source.Reset();
+ source.SetUpExamCover();
+ var loaded = source.BuildLoaded();
+
+ var viewModel = new DesignerViewModel();
+ viewModel.Load(loaded, source.LayoutSource);
+
+ Assert.True(viewModel.IsExamCover);
+ Assert.Equal("Atombau und PSE", viewModel.Placeholders.Single(x => x.Name == "Exam.Title").Sample);
+ }
+}
diff --git a/LehrerApp.TemplateDesigner/DesignerViewModel.cs b/LehrerApp.TemplateDesigner/DesignerViewModel.cs
index f10d462..b81e125 100644
--- a/LehrerApp.TemplateDesigner/DesignerViewModel.cs
+++ b/LehrerApp.TemplateDesigner/DesignerViewModel.cs
@@ -169,8 +169,51 @@ public partial class DesignerViewModel : ObservableObject
public ITemplateDataProvider BuildDataProvider() => new DesignerDataProvider(Placeholders.ToDictionary(
x => x.Name.Trim(), x => x.ToValue(), StringComparer.Ordinal));
- public byte[] RenderCurrentPdf() =>
- new QuestTemplateRenderer().RenderToPdf(BuildLoaded(), BuildDataProvider());
+ public byte[] RenderCurrentPdf()
+ {
+ var loaded = BuildLoaded();
+ return new QuestTemplateRenderer().RenderToPdf(loaded, FitSampleData(loaded, BuildDataProvider()));
+ }
+
+ /// Beispieldaten, deren Aussehen von der Vorlage abhängt: die Punktetabelle des
+ /// Klausur-Deckblatts richtet ihre Blockbreite nach der im Layout deklarierten Box, genau wie
+ /// später in der Hauptapp.
+ private static ITemplateDataProvider FitSampleData(LoadedTemplate template, ITemplateDataProvider provider)
+ {
+ var values = provider.GetValues();
+ if (values.GetValueOrDefault(ExamPointsTableDrawing.PlaceholderName) is not DrawingValue) return provider;
+ return new DesignerDataProvider(new Dictionary(values, StringComparer.Ordinal)
+ { [ExamPointsTableDrawing.PlaceholderName] = ExamCoverPlaceholders.SampleTable(template) });
+ }
+
+ public bool IsExamCover => MetadataItems.Any(x =>
+ x.Name.Trim().Equals(TemplateMetadataKeys.ReportType, StringComparison.OrdinalIgnoreCase)
+ && x.Value.Trim().Equals(TemplateReportTypes.ExamCover, StringComparison.OrdinalIgnoreCase));
+
+ /// Macht das Projekt zur Klausur-Deckblatt-Vorlage: setzt den Vorlagentyp und legt
+ /// alle Platzhalter an, die die Hauptapp dafür befüllt (mit Beispielwerten für die Vorschau).
+ /// Vorhandene Platzhalter und das Layout bleiben unangetastet.
+ public void SetUpExamCover()
+ {
+ var reportType = MetadataItems.FirstOrDefault(x =>
+ x.Name.Trim().Equals(TemplateMetadataKeys.ReportType, StringComparison.OrdinalIgnoreCase));
+ if (reportType is null) MetadataItems.Add(new(TemplateMetadataKeys.ReportType, TemplateReportTypes.ExamCover));
+ else reportType.Value = TemplateReportTypes.ExamCover;
+
+ var conflicts = new List();
+ var added = 0;
+ foreach (var known in ExamCoverPlaceholders.All)
+ {
+ var existing = Placeholders.FirstOrDefault(p => p.Name.Trim() == known.Name);
+ if (existing is null) { Placeholders.Add(new(known.Name, known.Type, false, known.Sample)); added++; }
+ else if (existing.Type != known.Type) conflicts.Add(known.Name);
+ }
+ CanExport = false;
+ if (conflicts.Count > 0)
+ SetStatus($"Als Klausur-Deckblatt eingerichtet, {added} Platzhalter ergänzt. Achtung, abweichender Typ bei: {string.Join(", ", conflicts)} — die Hauptapp liefert dafür einen anderen Typ.", true);
+ else
+ SetStatus($"Als Klausur-Deckblatt eingerichtet, {added} Platzhalter ergänzt. Die Punktetabelle steht unter „LehrerApp-Sonderinhalte“.", false);
+ }
public void ApplyPdfImport(PdfImportResult import)
{
@@ -226,7 +269,8 @@ public partial class DesignerViewModel : ObservableObject
NewContent = "$" + special.PlaceholderName;
NewWidth = special.RecommendedWidth;
NewHeight = special.RecommendedHeight;
- NewAttributes = "";
+ NewAttributes = special.RecommendedAttributes;
+ if (special.InContentFlow) NewElementScope = "Content-Flow";
CanExport = false;
SetStatus($"„{special.DisplayName}“ vorbereitet. Position und Größe prüfen, dann ins Layout übernehmen.", false);
}
@@ -255,7 +299,7 @@ public partial class DesignerViewModel : ObservableObject
Placeholders.Clear();
foreach (var placeholder in template.Manifest.Placeholders)
Placeholders.Add(new(placeholder.Name, placeholder.Type, placeholder.Required,
- placeholder.IsConstant ? placeholder.ConstantValue ?? "" : DesignerPlaceholder.SampleFor(placeholder.Type),
+ placeholder.IsConstant ? placeholder.ConstantValue ?? "" : DesignerPlaceholder.SampleFor(placeholder.Name, placeholder.Type),
placeholder.IsConstant, placeholder.Bold, placeholder.Italic, placeholder.Underline));
SelectedPlaceholder = Placeholders.FirstOrDefault();
Assets.Clear(); AssetItems.Clear();
@@ -305,14 +349,13 @@ public partial class DesignerViewModel : ObservableObject
public void PreviewTemplate(LoadedTemplate template)
{
- var provider = BuildSampleDataProvider(template.Manifest);
- RenderPreviewPages(template, provider);
+ RenderPreviewPages(template, BuildSampleDataProvider(template.Manifest));
SetStatus($"Vorschau von „{template.Manifest.Name}“ mit {PreviewPages.Count} Seite(n).", false);
}
public void RenderPreviewPages(LoadedTemplate template, ITemplateDataProvider provider)
{
- var pages = new QuestTemplateRenderer().RenderPagesToPng(template, provider);
+ var pages = new QuestTemplateRenderer().RenderPagesToPng(template, FitSampleData(template, provider));
ClearPreviewPages();
for (var index = 0; index < pages.Count; index++)
{
@@ -331,7 +374,8 @@ public partial class DesignerViewModel : ObservableObject
var canvasLayout = new TemplateLayout(loaded.Layout.Width, loaded.Layout.Height, loaded.Layout.Unit,
pageTemplate.Elements);
var canvasTemplate = new LoadedTemplate(loaded.Manifest, canvasLayout, loaded.Assets, loaded.SourceName);
- var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(canvasTemplate, BuildDataProvider());
+ var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(canvasTemplate,
+ FitSampleData(canvasTemplate, BuildDataProvider()));
using var stream = new MemoryStream(bytes);
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = new Bitmap(stream);
SelectedPreviewPage = null; PreviewImage = _pageTemplatePreview;
@@ -385,7 +429,7 @@ public partial class DesignerViewModel : ObservableObject
private static ITemplateDataProvider BuildSampleDataProvider(TemplateManifest manifest) =>
new DesignerDataProvider(manifest.Placeholders.ToDictionary(x => x.Name,
- x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Type)).ToValue(),
+ x => new DesignerPlaceholder(x.Name, x.Type, x.Required, DesignerPlaceholder.SampleFor(x.Name, x.Type)).ToValue(),
StringComparer.Ordinal));
public void AddElement()
@@ -739,7 +783,8 @@ public sealed record DesignerPreviewPage(int Number, Bitmap Image)
}
public sealed record SpecialContentItem(string DisplayName, string PlaceholderName, string Description,
- string RecommendedElementType, string RecommendedWidth, string RecommendedHeight);
+ string RecommendedElementType, string RecommendedWidth, string RecommendedHeight,
+ string RecommendedAttributes = "", bool InContentFlow = false);
public static class SpecialContentCatalog
{
@@ -751,6 +796,9 @@ public static class SpecialContentCatalog
new("Fehltage nach Datum", "Student.AbsenceDays",
"Chronologische Liste mit Datum, Fehlzeit oder ganzem Fehltag sowie Entschuldigungsstatus.",
"FLOWDRAWBOX", "170", "140"),
+ new("Punktetabelle (Klausur-Deckblatt)", ExamPointsTableDrawing.PlaceholderName,
+ "Aufgabe / Max. Punktzahl / Punktzahl mit festen Spaltenbreiten; bei vielen Aufgaben folgt ein weiterer Block, und die Elemente darunter im Content-Flow wandern mit. Die Breite bestimmt, wie viele Aufgaben nebeneinander passen (165 = sechs).",
+ "FLOWDRAWBOX", "165", "150", "fit=content", InContentFlow: true),
];
public static DrawingValue SampleDrawing(string placeholderName) => placeholderName switch
@@ -776,6 +824,7 @@ public static class SpecialContentCatalog
new DrawString(42, 27, "Ganzer Fehltag", 7, "#374151"),
new DrawString(116, 27, "Entschuldigt", 7, "#2E7D32"),
], 38),
+ ExamPointsTableDrawing.PlaceholderName => ExamPointsTableDrawing.Build(ExamCoverPlaceholders.SampleColumns, 165),
_ => DesignerPlaceholder.GenericSampleDrawing(),
};
}
@@ -837,6 +886,11 @@ public partial class DesignerPlaceholder : ObservableObject
PlaceholderType.Drawing => SpecialContentCatalog.SampleDrawing(Name),
_ => new TextValue(Sample),
};
+ /// Bekannte LehrerApp-Platzhalter (Klausur-Deckblatt) bekommen einen sprechenden
+ /// Beispielwert statt des allgemeinen je Typ.
+ public static string SampleFor(string name, PlaceholderType type) =>
+ ExamCoverPlaceholders.Find(name.Trim()) is { } known && known.Type == type && type != PlaceholderType.Drawing
+ ? known.Sample : SampleFor(type);
public static string SampleFor(PlaceholderType type) => type switch
{ PlaceholderType.Date => DateTime.Today.ToString("yyyy-MM-dd"), PlaceholderType.Number => "42,5",
PlaceholderType.Table => "Datum;Grund|01.09.;Krank", PlaceholderType.Chart => "Sep:2;Okt:3;Nov:1",
diff --git a/LehrerApp.TemplateDesigner/MainWindow.axaml b/LehrerApp.TemplateDesigner/MainWindow.axaml
index 628a136..fae264d 100644
--- a/LehrerApp.TemplateDesigner/MainWindow.axaml
+++ b/LehrerApp.TemplateDesigner/MainWindow.axaml
@@ -85,6 +85,8 @@
+
diff --git a/LehrerApp.TemplateDesigner/MainWindow.axaml.cs b/LehrerApp.TemplateDesigner/MainWindow.axaml.cs
index b1ae43a..cb19a8a 100644
--- a/LehrerApp.TemplateDesigner/MainWindow.axaml.cs
+++ b/LehrerApp.TemplateDesigner/MainWindow.axaml.cs
@@ -38,6 +38,7 @@ public partial class MainWindow : Window
if (_viewModel.SelectedMetadata is not { } selected) return;
_viewModel.MetadataItems.Remove(selected); _viewModel.SelectedMetadata = null; _viewModel.CanExport = false;
}
+ private void OnSetUpExamCover(object? sender, RoutedEventArgs e) => _viewModel.SetUpExamCover();
private void OnRemovePlaceholder(object? sender, RoutedEventArgs e) => _viewModel.RemoveSelectedPlaceholder();
private void OnBoldSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[b]", "[/b]");
private void OnItalicSelection(object? sender, RoutedEventArgs e) => WrapPlaceholderSelection("[i]", "[/i]");
diff --git a/LehrerApp.Templating/ExamCover.cs b/LehrerApp.Templating/ExamCover.cs
new file mode 100644
index 0000000..a3b2471
--- /dev/null
+++ b/LehrerApp.Templating/ExamCover.cs
@@ -0,0 +1,176 @@
+using System.Globalization;
+
+namespace LehrerApp.Templating;
+
+/// Eine Spalte der Punktetabelle auf dem Klausur-Deckblatt. Bonusaufgaben tragen keine
+/// Nummer; ist null, solange nichts eingetragen werden soll.
+public sealed record ExamPointsTableColumn(string Number, double MaxPoints, bool IsBonus, double? AchievedPoints);
+
+/// Zeichnet die Punktetabelle des Klausur-Deckblatts (Aufgabe / Max. Punktzahl /
+/// Punktzahl). Die Spalten haben eine feste Breite: passen nicht alle Aufgaben nebeneinander in die
+/// Box der Vorlage, folgt darunter ein weiterer Tabellenblock, statt alles zu verkleinern. Gedacht
+/// für eine FLOWDRAWBOX … fit=content, damit nachfolgende Elemente mitwandern. Liegt in der
+/// Vorlagenbibliothek, damit Hauptapp und Vorlagen-Designer exakt dieselbe Tabelle zeichnen.
+public static class ExamPointsTableDrawing
+{
+ public const string PlaceholderName = "Exam.PointsTable";
+
+ private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
+
+ // Referenzentwurf in Millimetern, abgemessen an der Schulvorlage.
+ private const float LabelWidth = 38f, ColumnWidth = 20.5f;
+ private const float HeaderHeight = 5.5f, MaximumHeight = 8.5f, PointsHeight = 11.5f;
+ private const float BlockGap = 4f, Padding = 1.5f, Stroke = 0.2f, HeavyStroke = 0.45f;
+ private const float FontSize = 10f;
+
+ /// Breite der deklarierten Box in der Maßeinheit der Vorlage.
+ /// 1 mm in der Maßeinheit der Vorlage (siehe
+ /// ).
+ public static DrawingValue Build(IReadOnlyList columns, float contentWidth = 170,
+ float millimeterScale = 1)
+ {
+ if (columns.Count == 0) return new DrawingValue([], 0);
+ var k = millimeterScale;
+ var perBlock = ColumnsPerBlock(contentWidth, millimeterScale);
+ // Halbe Strichstärke Abstand zum Rand, sonst schneidet die Box die äußeren Linien an.
+ var origin = HeavyStroke / 2 * k;
+ var blockHeight = (HeaderHeight + MaximumHeight + PointsHeight) * k;
+ var commands = new List();
+ var y = origin;
+ for (var start = 0; start < columns.Count; start += perBlock)
+ {
+ if (start > 0) y += BlockGap * k;
+ var block = columns.Skip(start).Take(perBlock).ToList();
+ var maximumY = y + HeaderHeight * k;
+ var pointsY = maximumY + MaximumHeight * k;
+
+ Cell(commands, origin, y, LabelWidth * k, HeaderHeight * k, "Aufgabe", DrawingTextAlignment.AlignRight, false, k);
+ Cell(commands, origin, maximumY, LabelWidth * k, MaximumHeight * k, "Max. Punktzahl", DrawingTextAlignment.AlignRight, false, k);
+ Cell(commands, origin, pointsY, LabelWidth * k, PointsHeight * k, "Punktzahl", DrawingTextAlignment.AlignRight, true, k);
+ for (var index = 0; index < block.Count; index++)
+ {
+ var column = block[index];
+ var x = origin + (LabelWidth + index * ColumnWidth) * k;
+ Cell(commands, x, y, ColumnWidth * k, HeaderHeight * k,
+ column.IsBonus ? "" : $"Nr. {column.Number}", DrawingTextAlignment.AlignCenter, false, k);
+ Cell(commands, x, maximumY, ColumnWidth * k, MaximumHeight * k,
+ (column.IsBonus ? "+" : "") + Format(column.MaxPoints), DrawingTextAlignment.AlignCenter, false, k);
+ Cell(commands, x, pointsY, ColumnWidth * k, PointsHeight * k,
+ column.AchievedPoints is { } achieved ? Format(achieved) : "", DrawingTextAlignment.AlignCenter, false, k);
+ }
+ commands.Add(new DrawLine(origin, pointsY, origin + (LabelWidth + block.Count * ColumnWidth) * k, pointsY,
+ StrokeWidth: HeavyStroke * k));
+ y += blockHeight;
+ }
+ return new DrawingValue(commands, y + origin);
+ }
+
+ /// Wie viele Aufgaben bei fester Spaltenbreite nebeneinander in die Box passen.
+ public static int ColumnsPerBlock(float contentWidth, float millimeterScale = 1) =>
+ Math.Max(1, (int)Math.Floor((contentWidth / millimeterScale - HeavyStroke - LabelWidth + 0.01f) / ColumnWidth));
+
+ private static void Cell(List commands, float x, float y, float width, float height,
+ string text, DrawingTextAlignment alignment, bool bold, float k)
+ {
+ commands.Add(new DrawRectangle(x, y, width, height, StrokeWidth: Stroke * k));
+ if (text.Length == 0) return;
+ // DrawStringEx setzt die Grundlinie auf Y + Schriftgröße; so sitzt die Zeile optisch mittig.
+ var fontHeight = FontSize * UnitConverter.Points(1, "pt") / UnitConverter.Points(1, "mm") * k;
+ var textY = y + height / 2 - fontHeight * 0.64f;
+ commands.Add(new DrawStringEx(x + Padding * k, textY, y + height - textY, width - 2 * Padding * k, text,
+ alignment, FontSize, Bold: bold));
+ }
+
+ private static string Format(double value) => value.ToString("0.##", German);
+}
+
+/// Ein Platzhalter, den die Hauptapp für Klausur-Deckblätter befüllt, mit einem
+/// Beispielwert für die Vorschau im Vorlagen-Designer.
+public sealed record ExamCoverPlaceholder(string Name, PlaceholderType Type, string Sample, string Description);
+
+/// Katalog der Platzhalter für Vorlagen mit report-type=exam-cover. Die Hauptapp
+/// (ExamCoverPlaceholderBuilder) muss genau diese Namen und Typen liefern; ein Test dort
+/// hält beide Seiten zusammen.
+public static class ExamCoverPlaceholders
+{
+ public static IReadOnlyList All { get; } =
+ [
+ new("Student.Name", PlaceholderType.Text, "Beispiel, Erika", "Name des Schülers (Nachname, Vorname)"),
+ new("Student.FirstName", PlaceholderType.Text, "Erika", "Vorname des Schülers"),
+ new("Student.LastName", PlaceholderType.Text, "Beispiel", "Nachname des Schülers"),
+ new("Group.Name", PlaceholderType.Text, "9 Ch G", "Name der Lerngruppe"),
+ new("Group.GradeLevel", PlaceholderType.Text, "9", "Jahrgangsstufe der Lerngruppe"),
+ new("Subject.Name", PlaceholderType.Text, "Chemie", "Fach der Lerngruppe"),
+ new("SchoolYear", PlaceholderType.Text, "2025/26", "Schuljahr der Lerngruppe"),
+ new("Teacher.Abbreviation", PlaceholderType.Text, "HED", "Kürzel der Lehrkraft (Einstellungen, je Druck änderbar)"),
+ new("Exam.Title", PlaceholderType.Text, "Atombau und PSE", "Titel der Arbeit"),
+ new("Exam.Date", PlaceholderType.Date, "2025-12-18", "Datum der Arbeit"),
+ new("Exam.Number", PlaceholderType.Text, "1", "Nummer der Arbeit"),
+ new("Exam.Kind", PlaceholderType.Text, "Klausur", "Art der Arbeit"),
+ new("Exam.Niveau", PlaceholderType.Text, "G-Niveau", "Niveau der Arbeit"),
+ new("Exam.Course", PlaceholderType.Text, "G-Kurs", "E-Kurs / G-Kurs, leer im Klassenverband"),
+ new("Exam.Variant", PlaceholderType.Text, "A", "Geschriebene Fassung, leer ohne Fassungen"),
+ new("Exam.MaxPoints", PlaceholderType.Text, "62", "Erreichbare Punktzahl ohne Bonusaufgaben"),
+ new("Exam.ApprovalDate", PlaceholderType.Text, "08.12.2025", "Datum der Genehmigung"),
+ new("Exam.AnnouncedDate", PlaceholderType.Text, "04.12.2025", "Datum der Ankündigung"),
+ new("Exam.ReturnDate", PlaceholderType.Text, "08.01.2026", "Datum der Rückgabe"),
+ new("Result.Points", PlaceholderType.Text, "52,5", "Erreichte Punkte, als Blanko eine Linie"),
+ new("Result.Percent", PlaceholderType.Text, "85 %", "Erreichter Anteil, als Blanko leer"),
+ new("Result.Grade", PlaceholderType.Text, "2", "Note, als Blanko eine Linie"),
+ new(ExamPointsTableDrawing.PlaceholderName, PlaceholderType.Drawing, "",
+ "Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl)"),
+ ];
+
+ public static ExamCoverPlaceholder? Find(string name) =>
+ All.FirstOrDefault(x => x.Name.Equals(name, StringComparison.Ordinal));
+
+ /// Acht Aufgaben und eine Bonusaufgabe: genug für einen zweiten Tabellenblock, damit
+ /// die Vorschau zeigt, wie die Elemente darunter mitwandern.
+ public static IReadOnlyList SampleColumns { get; } =
+ [
+ new("1", 5, false, 4), new("2", 12, false, 10.5), new("3", 6, false, 6), new("4", 4, false, 3),
+ new("5", 9, false, 7), new("6", 9, false, 8), new("7", 3, false, 2), new("8", 14, false, 11),
+ new("", 3, true, 1),
+ ];
+
+ /// Beispieltabelle in der Größe der Box, die die Vorlage für
+ /// deklariert.
+ public static DrawingValue SampleTable(LoadedTemplate template) =>
+ DrawingBoxLocator.Find(template, ExamPointsTableDrawing.PlaceholderName) is { } box
+ ? ExamPointsTableDrawing.Build(SampleColumns, box.Width, box.MillimeterScale)
+ : ExamPointsTableDrawing.Build(SampleColumns);
+}
+
+/// Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe eines Platzhalters (in der Maßeinheit der
+/// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". Drawing-Builder entwerfen ihr
+/// Raster in Millimetern; ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm")
+/// deklarierten Vorlage viel zu klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed
+/// unterscheidet DRAWBOX (feste, nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von
+/// QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von
+/// FLOWDRAWBOX (fließt bei Bedarf automatisch auf weitere Seiten, siehe
+/// DrawingElementRenderer.Slice) - nur bei IsFixed=true darf/muss ein Builder seine Höhe an Height
+/// anpassen.
+public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
+
+public static class DrawingBoxLocator
+{
+ public static DeclaredDrawingBox? Find(LoadedTemplate template, string placeholderName) =>
+ Find(template.Layout, placeholderName) ??
+ (template.ContinuationLayout is not null ? Find(template.ContinuationLayout, placeholderName) : null);
+
+ private static DeclaredDrawingBox? Find(TemplateLayout layout, string placeholderName)
+ {
+ var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
+ .Concat(layout.ContentFlows.SelectMany(f => f.Elements))
+ .Select(e => e switch
+ {
+ DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
+ FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
+ _ => null,
+ })
+ .FirstOrDefault(b => b is not null);
+ if (box is null) return null;
+ var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
+ return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
+ }
+}
diff --git a/LehrerApp.Templating/README.md b/LehrerApp.Templating/README.md
index 528f762..389e815 100644
--- a/LehrerApp.Templating/README.md
+++ b/LehrerApp.Templating/README.md
@@ -234,6 +234,14 @@ stehen bereit:
| `Subject.Name`, `Group.GradeLevel` | Text | Fach und Jahrgangsstufe der Lerngruppe |
| `Exam.PointsTable` | Drawing | Punktetabelle (Aufgabe / Max. Punktzahl / Punktzahl) |
+Im Vorlagen-Designer richtet „Als Klausur-Deckblatt einrichten" (Reiter mit den Metadaten) ein
+Projekt dafür ein: Der Vorlagentyp wird gesetzt und alle Platzhalter aus dieser Tabelle werden mit
+Beispielwerten angelegt (`ExamCoverPlaceholders`, derselbe Katalog, gegen den die Hauptapp getestet
+ist). Die Punktetabelle steht unter „LehrerApp-Sonderinhalte" und wird als
+`FLOWDRAWBOX … fit=content` in den Content-Flow vorbereitet. Die Vorschau zeichnet sie mit
+`ExamPointsTableDrawing` — derselbe Code wie in der Hauptapp — in der Breite der deklarierten Box,
+mit acht Aufgaben und einer Bonusaufgabe.
+
Die Punktetabelle hat feste Spaltenbreiten (38 mm Beschriftung, 20,5 mm je Aufgabe). Passen nicht
alle Aufgaben in die Breite der Box, folgt darunter ein weiterer Block; verkleinert wird nichts.
Sie gehört deshalb als `FLOWDRAWBOX … fit=content` in einen `content-flow`. Bonusaufgaben stehen
diff --git a/TODO.md b/TODO.md
index 3863988..3a1c00c 100644
--- a/TODO.md
+++ b/TODO.md
@@ -4868,9 +4868,20 @@ beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuhole
**Sammel-PDF:** neues `ITemplateBatchRenderer.RenderBatchToPdf` (QuestPDF `Document.Merge`)
liefert zusätzlich die Seitenzahl je Datensatz; läuft ein Deckblatt wegen zu vieler Aufgaben
- auf eine zweite Seite, warnt der Dialog. Offen: Der TemplateDesigner bietet die
- Klausur-Platzhalter noch nicht mit Beispieldaten an — `report-type` und Platzhalternamen
- werden dort von Hand eingetragen.
+ auf eine zweite Seite, warnt der Dialog.
+
+ **TemplateDesigner:** "Als Klausur-Deckblatt einrichten" setzt `report-type=exam-cover` und
+ legt alle Deckblatt-Platzhalter mit Beispielwerten an; vorhandene Platzhalter bleiben, ein
+ abweichender Typ wird gemeldet statt überschrieben. Neuer Sonderinhalt "Punktetabelle
+ (Klausur-Deckblatt)" bereitet die `FLOWDRAWBOX … fit=content` im Content-Flow vor
+ (`SpecialContentItem` bekam dafür empfohlene Attribute und Ziel-Scope). Damit die Vorschau
+ dieselbe Tabelle zeigt wie der spätere Druck, zog das Zeichnen nach
+ `LehrerApp.Templating` um (`ExamPointsTableDrawing`, dazu der Platzhalterkatalog
+ `ExamCoverPlaceholders` und `DrawingBoxLocator`, vorher `LetterPlaceholderBuilder`); die
+ Beispieltabelle richtet sich nach der im Layout deklarierten Boxbreite. Ein Test in
+ `ExamCoverSheetTests` hält Katalog und `ExamCoverPlaceholderBuilder` zusammen. Beim Öffnen
+ einer Deckblatt-Vorlage bekommen bekannte Platzhalter sprechende Beispielwerte statt
+ "Beispielwert".
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
---