TemplateDesigner: Klausur-Deckblatt einrichten und Punktetabelle in der Vorschau
CI / build-and-test (push) Canceled after 0s
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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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]");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
Reference in new issue
Block a user