TemplateDesigner: Klausur-Deckblatt einrichten und Punktetabelle in der Vorschau
CI / build-and-test (push) Canceled after 0s

Platzhalterkatalog und Tabellen-Zeichner liegen jetzt in LehrerApp.Templating,
damit Designer und Hauptapp dieselbe Tabelle zeichnen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 10:47:12 +02:00
1 parent dd92128464
commit 98c5f9eba0
11 files changed
+400 -114

No files matched your search

@@ -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()
{
@@ -1,82 +1,21 @@
using System.Globalization;
using LehrerApp.Core.Services;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
/// <summary>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 <c>FLOWDRAWBOX … fit=content</c>, damit nachfolgende Elemente mitwandern.</summary>
/// <summary>Brücke von den Klausurdaten zur Punktetabelle des Deckblatts. Gezeichnet wird in
/// <see cref="ExamPointsTableDrawing"/> (Vorlagenbibliothek), damit der Vorlagen-Designer dieselbe
/// Tabelle in der Vorschau zeigt.</summary>
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;
/// <param name="contentWidth">Breite der deklarierten Box in der Maßeinheit der Vorlage.</param>
/// <param name="millimeterScale">1 mm in der Maßeinheit der Vorlage (siehe
/// <see cref="LetterPlaceholderBuilder.FindDeclaredDrawingBox(LoadedTemplate, string)"/>).</param>
public static DrawingValue Build(IReadOnlyList<ExamCoverTaskColumn> 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<DrawingCommand>();
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);
}
/// <summary>Wie viele Aufgaben bei fester Spaltenbreite nebeneinander in die Box passen.</summary>
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<DrawingCommand> 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);
}
@@ -51,34 +51,8 @@ public static class LetterPlaceholderBuilder
_ => false,
};
/// <summary>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
/// <see cref="StudentAttendanceCalendarDrawingBuilder"/> 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.</summary>
public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
/// <summary>Siehe <see cref="DrawingBoxLocator"/> — dort liegt die Suche, damit auch der
/// Vorlagen-Designer sie nutzen kann.</summary>
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);
}
@@ -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.</summary>
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; }
@@ -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<FlowDrawBoxElement>(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);
}
}
+64 -10
View File
@@ -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()));
}
/// <summary>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.</summary>
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<string, PlaceholderValue>(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));
/// <summary>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.</summary>
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<string>();
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),
};
/// <summary>Bekannte LehrerApp-Platzhalter (Klausur-Deckblatt) bekommen einen sprechenden
/// Beispielwert statt des allgemeinen je Typ.</summary>
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",
@@ -85,6 +85,8 @@
</DataGrid.Columns>
</DataGrid>
<Button Content="Markierten Metadateneintrag entfernen" HorizontalAlignment="Left" Click="OnRemoveMetadata"/>
<Button Content="Als Klausur-Deckblatt einrichten" HorizontalAlignment="Left" Click="OnSetUpExamCover"
ToolTip.Tip="Setzt report-type=exam-cover und legt alle Platzhalter an, die die LehrerApp für Deckblätter befüllt (Exam.*, Result.*, …) — mit Beispielwerten für die Vorschau."/>
</StackPanel>
</ScrollViewer>
</TabItem>
@@ -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]");
+176
View File
@@ -0,0 +1,176 @@
using System.Globalization;
namespace LehrerApp.Templating;
/// <summary>Eine Spalte der Punktetabelle auf dem Klausur-Deckblatt. Bonusaufgaben tragen keine
/// Nummer; <see cref="AchievedPoints"/> ist null, solange nichts eingetragen werden soll.</summary>
public sealed record ExamPointsTableColumn(string Number, double MaxPoints, bool IsBonus, double? AchievedPoints);
/// <summary>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 <c>FLOWDRAWBOX … fit=content</c>, damit nachfolgende Elemente mitwandern. Liegt in der
/// Vorlagenbibliothek, damit Hauptapp und Vorlagen-Designer exakt dieselbe Tabelle zeichnen.</summary>
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;
/// <param name="contentWidth">Breite der deklarierten Box in der Maßeinheit der Vorlage.</param>
/// <param name="millimeterScale">1 mm in der Maßeinheit der Vorlage (siehe
/// <see cref="DeclaredDrawingBox.MillimeterScale"/>).</param>
public static DrawingValue Build(IReadOnlyList<ExamPointsTableColumn> 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<DrawingCommand>();
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);
}
/// <summary>Wie viele Aufgaben bei fester Spaltenbreite nebeneinander in die Box passen.</summary>
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<DrawingCommand> 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);
}
/// <summary>Ein Platzhalter, den die Hauptapp für Klausur-Deckblätter befüllt, mit einem
/// Beispielwert für die Vorschau im Vorlagen-Designer.</summary>
public sealed record ExamCoverPlaceholder(string Name, PlaceholderType Type, string Sample, string Description);
/// <summary>Katalog der Platzhalter für Vorlagen mit <c>report-type=exam-cover</c>. Die Hauptapp
/// (<c>ExamCoverPlaceholderBuilder</c>) muss genau diese Namen und Typen liefern; ein Test dort
/// hält beide Seiten zusammen.</summary>
public static class ExamCoverPlaceholders
{
public static IReadOnlyList<ExamCoverPlaceholder> 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));
/// <summary>Acht Aufgaben und eine Bonusaufgabe: genug für einen zweiten Tabellenblock, damit
/// die Vorschau zeigt, wie die Elemente darunter mitwandern.</summary>
public static IReadOnlyList<ExamPointsTableColumn> 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),
];
/// <summary>Beispieltabelle in der Größe der Box, die die Vorlage für
/// <see cref="ExamPointsTableDrawing.PlaceholderName"/> deklariert.</summary>
public static DrawingValue SampleTable(LoadedTemplate template) =>
DrawingBoxLocator.Find(template, ExamPointsTableDrawing.PlaceholderName) is { } box
? ExamPointsTableDrawing.Build(SampleColumns, box.Width, box.MillimeterScale)
: ExamPointsTableDrawing.Build(SampleColumns);
}
/// <summary>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.</summary>
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);
}
}
+8
View File
@@ -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
+14 -3
View File
@@ -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).
---