Files
2026-10-22 11:24:43 +02:00

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);
}
}