179 lines
11 KiB
C#
179 lines
11 KiB
C#
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 ? "Bonus" : $"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(ReportTextDrawing.RepeatInfo, PlaceholderType.Drawing, "", "Informationen zur ersten Arbeit und Bewertungsregel der Wiederholung"),
|
|
new(ReportTextDrawing.InterimGrades, PlaceholderType.Drawing, "", "Gespeicherter Zwischenstand: mündlich, schriftlich, vorläufige Zeugnisnote"),
|
|
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);
|
|
}
|
|
}
|