Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ccc3201d2 | ||
|
|
ab1feba0f0 | ||
|
|
b2d0ccd30d |
@@ -45,9 +45,9 @@
|
|||||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
||||||
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||||
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
||||||
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||||
|
|||||||
@@ -45,9 +45,9 @@
|
|||||||
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
<ComboBoxItem Content="Heute"/><ComboBoxItem Content="Letzte 7 Tage"/><ComboBoxItem Content="Letzte 30 Tage"/>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
<TextBlock Grid.Column="1" Text="von" VerticalAlignment="Center" Opacity="0.6"/>
|
||||||
<DatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
<CalendarDatePicker Grid.Column="2" SelectedDate="{Binding StartDate}" HorizontalAlignment="Stretch"/>
|
||||||
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
<TextBlock Grid.Column="3" Text="bis" VerticalAlignment="Center" Opacity="0.6"/>
|
||||||
<DatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
<CalendarDatePicker Grid.Column="4" SelectedDate="{Binding EndDate}" HorizontalAlignment="Stretch"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||||
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
<TextBox Grid.Column="0" Text="{Binding StudentFilter, Mode=TwoWay}" PlaceholderText="Schüler*in filtern…"/>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
|
<DataTemplate x:DataType="svc:UntisTeacherDto"><TextBlock Text="{Binding DisplayName}"/></DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
<DatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
|
<CalendarDatePicker Grid.Column="1" SelectedDate="{Binding WeekDate}"/>
|
||||||
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
<Button Grid.Column="2" Content="Woche laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<ScrollViewer Grid.Row="2">
|
<ScrollViewer Grid.Row="2">
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/>
|
Text="Nur eigene WebUntis-Einträge (Benutzer = eigener Login). Zeilen ohne automatische Zuordnung bitte manuell einem/einer Schüler*in zuweisen. Bereits lokal vorhandene Einträge sind gesperrt."/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||||
<DatePicker SelectedDate="{Binding StartDate}"/>
|
<CalendarDatePicker SelectedDate="{Binding StartDate}"/>
|
||||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||||
<DatePicker SelectedDate="{Binding EndDate}"/>
|
<CalendarDatePicker SelectedDate="{Binding EndDate}"/>
|
||||||
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -70,4 +70,40 @@ public sealed class OverlayEditorTests
|
|||||||
Assert.Equal(200, viewModel.OverlayPageHeight);
|
Assert.Equal(200, viewModel.OverlayPageHeight);
|
||||||
Assert.Equal("pt", viewModel.OverlayPageUnit);
|
Assert.Equal("pt", viewModel.OverlayPageUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlowSlotVerschieben_AendertNurDasPragmaDerAusgewaehltenSeite()
|
||||||
|
{
|
||||||
|
var viewModel = new DesignerViewModel();
|
||||||
|
var layout = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource);
|
||||||
|
var firstSlot = layout.PageTemplates.Single(x => x.Name == "first").FlowSlots.Single();
|
||||||
|
var continuationSlot = layout.PageTemplates.Single(x => x.Name == "continuation").FlowSlots.Single();
|
||||||
|
|
||||||
|
viewModel.ApplyElementGeometry(new(firstSlot.Line, "FLOW", 25, 95, 160, 165));
|
||||||
|
|
||||||
|
var updated = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource);
|
||||||
|
var updatedFirst = updated.PageTemplates.Single(x => x.Name == "first").FlowSlots.Single();
|
||||||
|
var updatedContinuation = updated.PageTemplates.Single(x => x.Name == "continuation").FlowSlots.Single();
|
||||||
|
Assert.Equal(25, updatedFirst.X);
|
||||||
|
Assert.Equal(95, updatedFirst.Y);
|
||||||
|
Assert.Equal(continuationSlot.X, updatedContinuation.X);
|
||||||
|
Assert.Equal(continuationSlot.Y, updatedContinuation.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ElementKannGezieltInContentFlowEingefuegtWerden()
|
||||||
|
{
|
||||||
|
var viewModel = new DesignerViewModel
|
||||||
|
{
|
||||||
|
NewElementScope = "Content-Flow", SelectedContentFlow = "body",
|
||||||
|
NewElementType = "TEXT", NewContent = "Nachsatz", NewAttributes = "gap=5",
|
||||||
|
};
|
||||||
|
|
||||||
|
viewModel.AddElement();
|
||||||
|
|
||||||
|
var flow = new LehrerApp.Templating.LayoutParser().Parse(viewModel.LayoutSource).ContentFlows.Single();
|
||||||
|
var text = Assert.IsType<LehrerApp.Templating.TextElement>(flow.Elements.Last());
|
||||||
|
Assert.Equal("Nachsatz", text.Content);
|
||||||
|
Assert.Equal("5", text.Attributes["gap"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ public sealed class ProjectLifecycleTests
|
|||||||
viewModel.Reset();
|
viewModel.Reset();
|
||||||
|
|
||||||
Assert.Equal("neue-vorlage", viewModel.TemplateId);
|
Assert.Equal("neue-vorlage", viewModel.TemplateId);
|
||||||
Assert.Equal("PAGE 210 297 mm\n", viewModel.LayoutSource);
|
Assert.Contains("#pragma page-template first", viewModel.LayoutSource);
|
||||||
|
Assert.Contains("#pragma page-template continuation", viewModel.LayoutSource);
|
||||||
|
Assert.Contains("#pragma content-flow body", viewModel.LayoutSource);
|
||||||
Assert.Empty(viewModel.Placeholders);
|
Assert.Empty(viewModel.Placeholders);
|
||||||
Assert.Empty(viewModel.Assets);
|
Assert.Empty(viewModel.Assets);
|
||||||
Assert.Empty(viewModel.AssetItems);
|
Assert.Empty(viewModel.AssetItems);
|
||||||
@@ -91,6 +93,21 @@ public sealed class ProjectLifecycleTests
|
|||||||
Assert.Contains("mehrfach", exception.Message);
|
Assert.Contains("mehrfach", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlteAbsoluteLayouts_WerdenBeimOeffnenVerlustfreiInSeitentypEingebettet()
|
||||||
|
{
|
||||||
|
const string source = "PAGE 210 297 mm\nTEXT 20 30 \"Altbestand\" size=12\n";
|
||||||
|
var layout = new LehrerApp.Templating.LayoutParser().Parse(source);
|
||||||
|
|
||||||
|
var migrated = DesignerViewModel.MigrateLegacyLayout(source, layout);
|
||||||
|
var parsed = new LehrerApp.Templating.LayoutParser().Parse(migrated);
|
||||||
|
|
||||||
|
Assert.True(parsed.UsesPageTemplates);
|
||||||
|
Assert.Equal("Altbestand", Assert.IsType<LehrerApp.Templating.TextElement>(
|
||||||
|
parsed.PageTemplates.Single(x => x.Name == "first").Elements.Single()).Content);
|
||||||
|
Assert.Empty(parsed.ContentFlows.Single().Elements);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AktuelleAnsicht_KannAlsPdfGerendertWerden()
|
public void AktuelleAnsicht_KannAlsPdfGerendertWerden()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ namespace LehrerApp.TemplateDesigner;
|
|||||||
|
|
||||||
public partial class DesignerViewModel : ObservableObject
|
public partial class DesignerViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
|
private Bitmap? _pageTemplatePreview;
|
||||||
[ObservableProperty] private string _templateId = "elternbrief-standard";
|
[ObservableProperty] private string _templateId = "elternbrief-standard";
|
||||||
[ObservableProperty] private string _templateName = "Elternbrief Standard";
|
[ObservableProperty] private string _templateName = "Elternbrief Standard";
|
||||||
[ObservableProperty] private string _description = "Briefvorlage mit Schul-Briefkopf";
|
[ObservableProperty] private string _description = "Briefvorlage mit Schul-Briefkopf";
|
||||||
@@ -25,6 +26,10 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _newImageScale = "100";
|
[ObservableProperty] private string _newImageScale = "100";
|
||||||
[ObservableProperty] private string _newContent = "$Brieftext";
|
[ObservableProperty] private string _newContent = "$Brieftext";
|
||||||
[ObservableProperty] private string _newAttributes = "size=11";
|
[ObservableProperty] private string _newAttributes = "size=11";
|
||||||
|
[ObservableProperty] private string _newElementScope = "Seitenvorlage (fest)";
|
||||||
|
[ObservableProperty] private string _selectedContentFlow = "body";
|
||||||
|
[ObservableProperty] private string _newPageTemplateName = "continuation";
|
||||||
|
[ObservableProperty] private string _newFlowName = "body";
|
||||||
[ObservableProperty] private Bitmap? _previewImage;
|
[ObservableProperty] private Bitmap? _previewImage;
|
||||||
[ObservableProperty] private string _status = "Bereit.";
|
[ObservableProperty] private string _status = "Bereit.";
|
||||||
[ObservableProperty] private string _statusColor = "#475569";
|
[ObservableProperty] private string _statusColor = "#475569";
|
||||||
@@ -41,11 +46,14 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
[ObservableProperty] private double _overlayPageWidth = 210;
|
[ObservableProperty] private double _overlayPageWidth = 210;
|
||||||
[ObservableProperty] private double _overlayPageHeight = 297;
|
[ObservableProperty] private double _overlayPageHeight = 297;
|
||||||
[ObservableProperty] private string _overlayPageUnit = "mm";
|
[ObservableProperty] private string _overlayPageUnit = "mm";
|
||||||
|
[ObservableProperty] private string _selectedPageTemplate = "first";
|
||||||
|
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
|
||||||
|
|
||||||
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
|
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
|
||||||
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
|
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
|
||||||
public IReadOnlyList<string> ElementTypes { get; } =
|
public IReadOnlyList<string> ElementTypes { get; } =
|
||||||
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
|
["TEXT", "TEXTBOX", "FLOWBOX", "DRAWBOX", "FLOWDRAWBOX", "IMG", "TABLE", "CHART"];
|
||||||
|
public IReadOnlyList<string> ElementScopes { get; } = ["Seitenvorlage (fest)", "Content-Flow"];
|
||||||
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
|
public ObservableCollection<DesignerPlaceholder> Placeholders { get; } =
|
||||||
[
|
[
|
||||||
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
new("Datum", PlaceholderType.Date, true, DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
|
||||||
@@ -62,6 +70,9 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
public Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, byte[]> Assets { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
public ObservableCollection<DesignerAsset> AssetItems { get; } = [];
|
public ObservableCollection<DesignerAsset> AssetItems { get; } = [];
|
||||||
public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = [];
|
public ObservableCollection<StarterTemplateItem> StarterTemplates { get; } = [];
|
||||||
|
public ObservableCollection<string> PageTemplateNames { get; } = ["first"];
|
||||||
|
public ObservableCollection<string> ContentFlowNames { get; } = ["body"];
|
||||||
|
public ObservableCollection<DesignerPreviewPage> PreviewPages { get; } = [];
|
||||||
public bool HasSelectedPlaceholder => SelectedPlaceholder is not null;
|
public bool HasSelectedPlaceholder => SelectedPlaceholder is not null;
|
||||||
public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null;
|
public bool HasNoSelectedPlaceholder => SelectedPlaceholder is null;
|
||||||
|
|
||||||
@@ -73,10 +84,12 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(HasNoSelectedPlaceholder));
|
OnPropertyChanged(nameof(HasNoSelectedPlaceholder));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedPreviewPageChanged(DesignerPreviewPage? value) => PreviewImage = value?.Image;
|
||||||
|
|
||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
|
TemplateId = "neue-vorlage"; TemplateName = "Neue Vorlage"; Description = "";
|
||||||
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = "PAGE 210 297 mm\n";
|
PageWidth = 210; PageHeight = 297; Unit = "mm"; LayoutSource = EmptyStructuredLayout;
|
||||||
UseContinuationLayout = false;
|
UseContinuationLayout = false;
|
||||||
ContinuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
|
ContinuationLayoutSource = "PAGE 210 297 mm\nFLOWBOX 20 20 170 257 $Brieftext size=11\n";
|
||||||
Placeholders.Clear(); SelectedPlaceholder = null;
|
Placeholders.Clear(); SelectedPlaceholder = null;
|
||||||
@@ -86,7 +99,7 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
Assets.Clear(); AssetItems.Clear(); SelectedAsset = null;
|
Assets.Clear(); AssetItems.Clear(); SelectedAsset = null;
|
||||||
NewElementType = "TEXT"; NewX = "20"; NewY = "50"; NewWidth = "170"; NewHeight = "30";
|
NewElementType = "TEXT"; NewX = "20"; NewY = "50"; NewWidth = "170"; NewHeight = "30";
|
||||||
NewImageScale = "100"; NewContent = "$Brieftext"; NewAttributes = "size=11";
|
NewImageScale = "100"; NewContent = "$Brieftext"; NewAttributes = "size=11";
|
||||||
PreviewImage?.Dispose(); PreviewImage = null; CanExport = false;
|
ClearPreviewPages(); CanExport = false;
|
||||||
OverlayMode = OverlayEditorMode.Measure; OverlayCoordinates = "x=– · y=–";
|
OverlayMode = OverlayEditorMode.Measure; OverlayCoordinates = "x=– · y=–";
|
||||||
SelectedOverlayElement = "Kein Element ausgewählt";
|
SelectedOverlayElement = "Kein Element ausgewählt";
|
||||||
SetStatus("Neues Projekt angelegt.", false);
|
SetStatus("Neues Projekt angelegt.", false);
|
||||||
@@ -118,19 +131,25 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
|
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ ist nicht deklariert."));
|
||||||
foreach (var path in layouts.SelectMany(x => x.Elements).Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
|
foreach (var path in layouts.SelectMany(x => x.Elements).Select(x => x switch { BackgroundElement b => b.Path, ImageElement i => i.Path, _ => null }).Where(x => x is not null))
|
||||||
if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
|
if (!Assets.ContainsKey(path!)) issues.Add(new(ValidationSeverity.Error, $"Asset „{path}“ fehlt."));
|
||||||
var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
|
if (layout.UsesPageTemplates && continuationLayout is not null)
|
||||||
if (firstFlows.Count > 1)
|
issues.Add(new(ValidationSeverity.Error,
|
||||||
issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens eine FLOWBOX enthalten."));
|
"Layoutformat 3 enthält Folgeseiten als page-template und kann nicht zusätzlich continuation.tpl verwenden."));
|
||||||
if (continuationLayout is not null)
|
if (!layout.UsesPageTemplates)
|
||||||
{
|
{
|
||||||
var continuationFlows = continuationLayout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
|
var firstFlows = layout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
|
||||||
if (firstFlows.Count != 1 || continuationFlows.Count != 1)
|
if (firstFlows.Count > 1)
|
||||||
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau ein fließendes Element enthalten."));
|
issues.Add(new(ValidationSeverity.Error, "Ein Legacy-Layout darf höchstens eine FLOWBOX enthalten."));
|
||||||
else if (firstFlows[0].GetType() != continuationFlows[0].GetType())
|
if (continuationLayout is not null)
|
||||||
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
|
{
|
||||||
else if (firstFlows[0].X != continuationFlows[0].X || firstFlows[0].Y != continuationFlows[0].Y
|
var continuationFlows = continuationLayout.Elements.Where(x => x is FlowBoxElement or FlowDrawBoxElement).ToList();
|
||||||
|| firstFlows[0].Width != continuationFlows[0].Width || firstFlows[0].Height != continuationFlows[0].Height)
|
if (firstFlows.Count != 1 || continuationFlows.Count != 1)
|
||||||
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
|
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen jeweils genau ein fließendes Element enthalten."));
|
||||||
|
else if (firstFlows[0].GetType() != continuationFlows[0].GetType())
|
||||||
|
issues.Add(new(ValidationSeverity.Error, "Haupt- und Folgeseite müssen denselben fließenden Elementtyp verwenden."));
|
||||||
|
else if (firstFlows[0].X != continuationFlows[0].X || firstFlows[0].Y != continuationFlows[0].Y
|
||||||
|
|| firstFlows[0].Width != continuationFlows[0].Width || firstFlows[0].Height != continuationFlows[0].Height)
|
||||||
|
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
|
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
|
||||||
issues.Add(new(ValidationSeverity.Error, issue));
|
issues.Add(new(ValidationSeverity.Error, issue));
|
||||||
@@ -186,10 +205,13 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
|
|
||||||
public void Load(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null)
|
public void Load(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null)
|
||||||
{
|
{
|
||||||
|
if (!template.Layout.UsesPageTemplates && continuationLayoutSource is null)
|
||||||
|
layoutSource = MigrateLegacyLayout(layoutSource, template.Layout);
|
||||||
TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description;
|
TemplateId = template.Manifest.Id; TemplateName = template.Manifest.Name; Description = template.Manifest.Description;
|
||||||
PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height;
|
PageWidth = (decimal)template.Manifest.PageSize.Width; PageHeight = (decimal)template.Manifest.PageSize.Height;
|
||||||
Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource;
|
Unit = template.Manifest.PageSize.Unit; LayoutSource = layoutSource;
|
||||||
UseContinuationLayout = template.Manifest.ContinuationLayoutFile is not null;
|
UseContinuationLayout = !template.Layout.UsesPageTemplates
|
||||||
|
&& template.Manifest.ContinuationLayoutFile is not null;
|
||||||
ContinuationLayoutSource = continuationLayoutSource ?? "PAGE 210 297 mm\n";
|
ContinuationLayoutSource = continuationLayoutSource ?? "PAGE 210 297 mm\n";
|
||||||
MetadataItems.Clear();
|
MetadataItems.Clear();
|
||||||
foreach (var item in template.Manifest.Metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
foreach (var item in template.Manifest.Metadata.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
@@ -207,6 +229,36 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false);
|
CanExport = true; SetStatus($"„{template.Manifest.Name}“ geladen.", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string MigrateLegacyLayout(string source, TemplateLayout layout)
|
||||||
|
{
|
||||||
|
if (layout.UsesPageTemplates) return source;
|
||||||
|
var statements = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')
|
||||||
|
.Where(line =>
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
return trimmed.Length > 0 && !trimmed.StartsWith('#')
|
||||||
|
&& !trimmed.StartsWith("PAGE ", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}).Select(line => line.Trim()).ToList();
|
||||||
|
var margin = layout.Unit.Equals("mm", StringComparison.OrdinalIgnoreCase) ? 20f : layout.Width * 0.08f;
|
||||||
|
var x = FormatNumber(margin); var y = FormatNumber(margin);
|
||||||
|
var width = FormatNumber(Math.Max(1, layout.Width - margin * 2));
|
||||||
|
var height = FormatNumber(Math.Max(1, layout.Height - margin * 2));
|
||||||
|
var result = new List<string>
|
||||||
|
{
|
||||||
|
$"PAGE {FormatNumber(layout.Width)} {FormatNumber(layout.Height)} {layout.Unit}",
|
||||||
|
"#pragma format-version 3", "", "#pragma page-template first",
|
||||||
|
};
|
||||||
|
result.AddRange(statements);
|
||||||
|
result.AddRange([
|
||||||
|
$"#pragma flow-slot body x={x} y={y} w={width} h={height}",
|
||||||
|
"#pragma end-page-template", "", "#pragma page-template continuation",
|
||||||
|
$"#pragma flow-slot body x={x} y={y} w={width} h={height}",
|
||||||
|
"#pragma end-page-template", "", "#pragma content-flow body",
|
||||||
|
"#pragma end-content-flow", "",
|
||||||
|
]);
|
||||||
|
return string.Join('\n', result);
|
||||||
|
}
|
||||||
|
|
||||||
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null, string? newId = null)
|
public void LoadAsNewProject(LoadedTemplate template, string layoutSource, string? continuationLayoutSource = null, string? newId = null)
|
||||||
{
|
{
|
||||||
Load(template, layoutSource, continuationLayoutSource);
|
Load(template, layoutSource, continuationLayoutSource);
|
||||||
@@ -219,10 +271,44 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
public void PreviewTemplate(LoadedTemplate template)
|
public void PreviewTemplate(LoadedTemplate template)
|
||||||
{
|
{
|
||||||
var provider = BuildSampleDataProvider(template.Manifest);
|
var provider = BuildSampleDataProvider(template.Manifest);
|
||||||
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(template, provider);
|
RenderPreviewPages(template, provider);
|
||||||
|
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);
|
||||||
|
ClearPreviewPages();
|
||||||
|
for (var index = 0; index < pages.Count; index++)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(pages[index]);
|
||||||
|
PreviewPages.Add(new(index + 1, new Bitmap(stream)));
|
||||||
|
}
|
||||||
|
SelectedPreviewPage = PreviewPages.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PreviewSelectedPageTemplateCanvas()
|
||||||
|
{
|
||||||
|
var loaded = BuildLoaded();
|
||||||
|
var pageTemplate = loaded.Layout.PageTemplates.FirstOrDefault(x =>
|
||||||
|
x.Name.Equals(SelectedPageTemplate, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (pageTemplate is null) return;
|
||||||
|
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());
|
||||||
using var stream = new MemoryStream(bytes);
|
using var stream = new MemoryStream(bytes);
|
||||||
PreviewImage?.Dispose(); PreviewImage = new Bitmap(stream);
|
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = new Bitmap(stream);
|
||||||
SetStatus($"Vorschau von „{template.Manifest.Name}“.", false);
|
SelectedPreviewPage = null; PreviewImage = _pageTemplatePreview;
|
||||||
|
SetStatus($"Bearbeitungsansicht des Seitentyps „{SelectedPageTemplate}“.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearPreviewPages()
|
||||||
|
{
|
||||||
|
_pageTemplatePreview?.Dispose(); _pageTemplatePreview = null;
|
||||||
|
PreviewImage = null; SelectedPreviewPage = null;
|
||||||
|
foreach (var page in PreviewPages) page.Image.Dispose();
|
||||||
|
PreviewPages.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetStarterTemplates(IEnumerable<StarterTemplateItem> templates, string? selectedId = null)
|
public void SetStarterTemplates(IEnumerable<StarterTemplateItem> templates, string? selectedId = null)
|
||||||
@@ -270,22 +356,93 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
public void AddElement()
|
public void AddElement()
|
||||||
{
|
{
|
||||||
var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim();
|
var attrs = string.IsNullOrWhiteSpace(NewAttributes) ? "" : " " + NewAttributes.Trim();
|
||||||
var line = NewElementType switch
|
var flowElement = NewElementScope == "Content-Flow";
|
||||||
|
var line = (NewElementType, flowElement) switch
|
||||||
{
|
{
|
||||||
"TEXT" => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
|
("TEXT", false) => $"TEXT {NewX} {NewY} {QuoteIfLiteral(NewContent)}{attrs}",
|
||||||
"TEXTBOX" => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
|
("TEXTBOX", false) => $"TEXTBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
|
||||||
"FLOWBOX" => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
|
("FLOWBOX", false) => $"FLOWBOX {NewX} {NewY} {NewWidth} {NewHeight} {QuoteIfLiteral(NewContent)}{attrs}",
|
||||||
"DRAWBOX" => $"DRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
("DRAWBOX", false) => $"DRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||||
"FLOWDRAWBOX" => $"FLOWDRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
("FLOWDRAWBOX", false) => $"FLOWDRAWBOX {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||||
"IMG" => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
|
("IMG", false) => $"IMG {NewContent} {NewX} {NewY} {NewWidth} {NewHeight} scale={NormalizedImageScale()}%",
|
||||||
"TABLE" => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
("TABLE", false) => $"TABLE {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||||
"CHART" => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
("CHART", false) => $"CHART {NewX} {NewY} {NewWidth} {NewHeight} {NewContent}{attrs}",
|
||||||
|
("TEXT", true) => $"TEXT {QuoteIfLiteral(NewContent)}{attrs}",
|
||||||
|
("TEXTBOX", true) => $"TEXTBOX {QuoteIfLiteral(NewContent)}{attrs}",
|
||||||
|
("FLOWBOX", true) => $"FLOWBOX {QuoteIfLiteral(NewContent)}{attrs}",
|
||||||
|
("DRAWBOX", true) => $"DRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
|
||||||
|
("FLOWDRAWBOX", true) => $"FLOWDRAWBOX {NewContent} w={NewWidth} h={NewHeight}{attrs}",
|
||||||
|
("IMG", true) => $"IMG {NewContent} w={NewWidth} h={NewHeight} scale={NormalizedImageScale()}%{attrs}",
|
||||||
|
("TABLE", true) => $"TABLE {NewContent}{attrs}",
|
||||||
|
("CHART", true) => $"CHART {NewContent} h={NewHeight}{attrs}",
|
||||||
_ => throw new InvalidOperationException("Unbekannter Elementtyp."),
|
_ => throw new InvalidOperationException("Unbekannter Elementtyp."),
|
||||||
};
|
};
|
||||||
LayoutSource = LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
|
var parsedLayout = new LayoutParser().Parse(LayoutSource);
|
||||||
|
LayoutSource = parsedLayout.UsesPageTemplates
|
||||||
|
? InsertIntoSection(LayoutSource, line, flowElement ? "content-flow" : "page-template",
|
||||||
|
flowElement ? SelectedContentFlow : SelectedPageTemplate)
|
||||||
|
: LayoutSource.TrimEnd() + Environment.NewLine + line + Environment.NewLine;
|
||||||
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
|
CanExport = false; SetStatus("Element ergänzt. Vorschau zur Prüfung aktualisieren.", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AddPageTemplate()
|
||||||
|
{
|
||||||
|
var name = ValidateSectionName(NewPageTemplateName, "Seitentyp");
|
||||||
|
if (PageTemplateNames.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidDataException($"Seitentyp „{name}“ existiert bereits.");
|
||||||
|
var block = $"#pragma page-template {name}\n#pragma end-page-template\n";
|
||||||
|
var marker = LayoutSource.IndexOf("#pragma content-flow", StringComparison.OrdinalIgnoreCase);
|
||||||
|
LayoutSource = marker < 0 ? LayoutSource.TrimEnd() + "\n\n" + block
|
||||||
|
: LayoutSource.Insert(marker, block + "\n");
|
||||||
|
SelectedPageTemplate = name; CanExport = false;
|
||||||
|
SetStatus($"Seitentyp „{name}“ angelegt.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddContentFlow()
|
||||||
|
{
|
||||||
|
var name = ValidateSectionName(NewFlowName, "Flow");
|
||||||
|
if (ContentFlowNames.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidDataException($"Content-Flow „{name}“ existiert bereits.");
|
||||||
|
LayoutSource = LayoutSource.TrimEnd() + $"\n\n#pragma content-flow {name}\n#pragma end-content-flow\n";
|
||||||
|
SelectedContentFlow = name; CanExport = false;
|
||||||
|
SetStatus($"Content-Flow „{name}“ angelegt. Lege nun gleichnamige Slots auf den Seitentypen an.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddFlowSlot()
|
||||||
|
{
|
||||||
|
var name = ValidateSectionName(NewFlowName, "Flow-Slot");
|
||||||
|
var line = $"#pragma flow-slot {name} x={NewX} y={NewY} w={NewWidth} h={NewHeight}";
|
||||||
|
LayoutSource = InsertIntoSection(LayoutSource, line, "page-template", SelectedPageTemplate);
|
||||||
|
CanExport = false;
|
||||||
|
SetStatus($"Flow-Slot „{name}“ auf „{SelectedPageTemplate}“ angelegt.", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ValidateSectionName(string value, string label)
|
||||||
|
{
|
||||||
|
var name = value.Trim();
|
||||||
|
if (name.Length == 0 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '-' or '_')))
|
||||||
|
throw new InvalidDataException($"{label}-Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten.");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string InsertIntoSection(string source, string line, string section, string name)
|
||||||
|
{
|
||||||
|
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
|
||||||
|
var start = lines.FindIndex(x =>
|
||||||
|
{
|
||||||
|
var tokens = x.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return tokens.Length >= 3 && tokens[0].Equals("#pragma", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& tokens[1].Equals(section, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& tokens[2].Equals(name, StringComparison.OrdinalIgnoreCase);
|
||||||
|
});
|
||||||
|
if (start < 0) throw new InvalidDataException($"{section} „{name}“ wurde nicht gefunden.");
|
||||||
|
var endDirective = section == "content-flow" ? "end-content-flow" : "end-page-template";
|
||||||
|
var end = lines.FindIndex(start + 1, x => x.Trim().Equals($"#pragma {endDirective}", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (end < 0) throw new InvalidDataException($"{section} „{name}“ ist nicht geschlossen.");
|
||||||
|
lines.Insert(end, line);
|
||||||
|
return string.Join('\n', lines);
|
||||||
|
}
|
||||||
|
|
||||||
public DesignerAsset ImportAsset(string sourceName, byte[] bytes) => AddOrReplaceAsset(sourceName, bytes, keepName: false);
|
public DesignerAsset ImportAsset(string sourceName, byte[] bytes) => AddOrReplaceAsset(sourceName, bytes, keepName: false);
|
||||||
|
|
||||||
public DesignerAsset ImportBackground(string sourceName, byte[] bytes)
|
public DesignerAsset ImportBackground(string sourceName, byte[] bytes)
|
||||||
@@ -346,6 +503,19 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
public void ApplyElementGeometry(OverlayElementGeometry geometry)
|
public void ApplyElementGeometry(OverlayElementGeometry geometry)
|
||||||
{
|
{
|
||||||
var layout = new LayoutParser().Parse(LayoutSource);
|
var layout = new LayoutParser().Parse(LayoutSource);
|
||||||
|
if (geometry.Keyword == "FLOW")
|
||||||
|
{
|
||||||
|
var slot = layout.PageTemplates.SelectMany(x => x.FlowSlots)
|
||||||
|
.FirstOrDefault(x => x.Line == geometry.Line)
|
||||||
|
?? throw new InvalidDataException($"Flow-Slot in Zeile {geometry.Line} wurde nicht gefunden.");
|
||||||
|
var slotLines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
|
||||||
|
slotLines[geometry.Line - 1] = $"#pragma flow-slot {slot.Name} x={FormatNumber(geometry.X)} "
|
||||||
|
+ $"y={FormatNumber(geometry.Y)} w={FormatNumber(geometry.Width)} h={FormatNumber(geometry.Height)}";
|
||||||
|
LayoutSource = string.Join('\n', slotLines); CanExport = false;
|
||||||
|
SelectedOverlayElement = $"Flow {slot.Name} · Zeile {geometry.Line}";
|
||||||
|
SetStatus($"Flow-Slot „{slot.Name}“ verschoben/skaliert.", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line)
|
var element = layout.Elements.FirstOrDefault(x => x.Line == geometry.Line)
|
||||||
?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden.");
|
?? throw new InvalidDataException($"Element in Zeile {geometry.Line} wurde nicht gefunden.");
|
||||||
var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
|
var lines = LayoutSource.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
|
||||||
@@ -383,6 +553,20 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
var page = new LayoutParser().Parse(value);
|
var page = new LayoutParser().Parse(value);
|
||||||
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
|
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
|
||||||
|
var names = page.PageTemplates.Select(x => x.Name).ToList();
|
||||||
|
if (names.Count == 0) names.Add("legacy");
|
||||||
|
var selected = names.Contains(SelectedPageTemplate, StringComparer.OrdinalIgnoreCase)
|
||||||
|
? SelectedPageTemplate : names[0];
|
||||||
|
PageTemplateNames.Clear();
|
||||||
|
foreach (var name in names) PageTemplateNames.Add(name);
|
||||||
|
SelectedPageTemplate = selected;
|
||||||
|
var flows = page.ContentFlows.Select(x => x.Name).ToList();
|
||||||
|
if (flows.Count == 0) flows.Add("body");
|
||||||
|
var selectedFlow = flows.Contains(SelectedContentFlow, StringComparer.OrdinalIgnoreCase)
|
||||||
|
? SelectedContentFlow : flows[0];
|
||||||
|
ContentFlowNames.Clear();
|
||||||
|
foreach (var flow in flows) ContentFlowNames.Add(flow);
|
||||||
|
SelectedContentFlow = selectedFlow;
|
||||||
}
|
}
|
||||||
catch (TemplateValidationException) { }
|
catch (TemplateValidationException) { }
|
||||||
}
|
}
|
||||||
@@ -465,15 +649,44 @@ public partial class DesignerViewModel : ObservableObject
|
|||||||
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
|
{ Status = text; StatusColor = error ? "#B91C1C" : "#166534"; }
|
||||||
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
|
private static string QuoteIfLiteral(string value) => value.StartsWith('$') ? value : $"\"{value.Replace("\"", "\\\"")}\"";
|
||||||
|
|
||||||
private const string DefaultLayout = """
|
private const string EmptyStructuredLayout = """
|
||||||
# Elternbrief Standard
|
|
||||||
PAGE 210 297 mm
|
PAGE 210 297 mm
|
||||||
|
#pragma format-version 3
|
||||||
|
|
||||||
|
#pragma page-template first
|
||||||
|
#pragma flow-slot body x=20 y=30 w=170 h=247
|
||||||
|
#pragma end-page-template
|
||||||
|
|
||||||
|
#pragma page-template continuation
|
||||||
|
#pragma flow-slot body x=20 y=20 w=170 h=257
|
||||||
|
#pragma end-page-template
|
||||||
|
|
||||||
|
#pragma content-flow body
|
||||||
|
#pragma end-content-flow
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DefaultLayout = """
|
||||||
|
# Elternbrief Standard · Format 3
|
||||||
|
PAGE 210 297 mm
|
||||||
|
#pragma format-version 3
|
||||||
|
|
||||||
|
#pragma page-template first
|
||||||
TEXT 20 25 "Elternbrief" size=18 bold=true color=#1E3A8A
|
TEXT 20 25 "Elternbrief" size=18 bold=true color=#1E3A8A
|
||||||
TEXT 20 43 $Datum|dd.MM.yyyy size=10
|
TEXT 20 43 $Datum|dd.MM.yyyy size=10
|
||||||
TEXT 20 55 $Empfaenger size=11
|
TEXT 20 55 $Empfaenger size=11
|
||||||
TEXT 20 75 "Sehr geehrte/r $Anrede," size=11
|
TEXT 20 75 "Sehr geehrte/r $Anrede," size=11
|
||||||
TEXTBOX 20 90 170 155 $Brieftext size=11 wrap=true
|
#pragma flow-slot body x=20 y=90 w=170 h=175
|
||||||
TEXT 20 265 $LehrerName size=10 italic=true
|
#pragma end-page-template
|
||||||
|
|
||||||
|
#pragma page-template continuation
|
||||||
|
#pragma flow-slot body x=20 y=25 w=170 h=240
|
||||||
|
#pragma end-page-template
|
||||||
|
|
||||||
|
#pragma content-flow body
|
||||||
|
TEXTBOX $Brieftext size=11 overflow=continue
|
||||||
|
TEXT "Mit freundlichen Grüßen" size=10 gap=8 keep-with-next=true
|
||||||
|
TEXT $LehrerName size=10 italic=true gap=4
|
||||||
|
#pragma end-content-flow
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +696,11 @@ public partial class DesignerMetadata(string name, string value) : ObservableObj
|
|||||||
[ObservableProperty] private string _value = value;
|
[ObservableProperty] private string _value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DesignerPreviewPage(int Number, Bitmap Image)
|
||||||
|
{
|
||||||
|
public string Display => $"Dokumentseite {Number}";
|
||||||
|
}
|
||||||
|
|
||||||
public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
|
public partial class DesignerAsset(string name, int pixelWidth, int pixelHeight, long byteCount) : ObservableObject
|
||||||
{
|
{
|
||||||
[ObservableProperty] private int _usageCount;
|
[ObservableProperty] private int _usageCount;
|
||||||
|
|||||||
@@ -30,14 +30,18 @@ public sealed class LayoutOverlayEditor : Control
|
|||||||
AvaloniaProperty.Register<LayoutOverlayEditor, bool>(nameof(SnapToGrid), true);
|
AvaloniaProperty.Register<LayoutOverlayEditor, bool>(nameof(SnapToGrid), true);
|
||||||
public static readonly StyledProperty<double> GridSizeProperty =
|
public static readonly StyledProperty<double> GridSizeProperty =
|
||||||
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(GridSize), 1);
|
AvaloniaProperty.Register<LayoutOverlayEditor, double>(nameof(GridSize), 1);
|
||||||
|
public static readonly StyledProperty<string> PageTemplateNameProperty =
|
||||||
|
AvaloniaProperty.Register<LayoutOverlayEditor, string>(nameof(PageTemplateName), "first");
|
||||||
|
|
||||||
private readonly Pen _normalPen = new(new SolidColorBrush(Color.Parse("#2563EB")), 1.5);
|
private readonly Pen _normalPen = new(new SolidColorBrush(Color.Parse("#2563EB")), 1.5);
|
||||||
private readonly Pen _selectedPen = new(new SolidColorBrush(Color.Parse("#DC2626")), 2.5);
|
private readonly Pen _selectedPen = new(new SolidColorBrush(Color.Parse("#DC2626")), 2.5);
|
||||||
private readonly Pen _measurePen = new(new SolidColorBrush(Color.Parse("#D97706")), 2);
|
private readonly Pen _measurePen = new(new SolidColorBrush(Color.Parse("#D97706")), 2);
|
||||||
private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromArgb(55, 37, 99, 235)), 1);
|
private readonly Pen _gridPen = new(new SolidColorBrush(Color.FromArgb(55, 37, 99, 235)), 1);
|
||||||
|
private readonly Pen _flowPen = new(new SolidColorBrush(Color.Parse("#7C3AED")), 2, dashStyle: DashStyle.Dash);
|
||||||
private readonly IBrush _normalFill = new SolidColorBrush(Color.FromArgb(30, 37, 99, 235));
|
private readonly IBrush _normalFill = new SolidColorBrush(Color.FromArgb(30, 37, 99, 235));
|
||||||
private readonly IBrush _selectedFill = new SolidColorBrush(Color.FromArgb(35, 220, 38, 38));
|
private readonly IBrush _selectedFill = new SolidColorBrush(Color.FromArgb(35, 220, 38, 38));
|
||||||
private readonly IBrush _handleFill = new SolidColorBrush(Color.Parse("#DC2626"));
|
private readonly IBrush _handleFill = new SolidColorBrush(Color.Parse("#DC2626"));
|
||||||
|
private readonly IBrush _flowFill = new SolidColorBrush(Color.FromArgb(38, 124, 58, 237));
|
||||||
private readonly List<OverlayItem> _items = [];
|
private readonly List<OverlayItem> _items = [];
|
||||||
private OverlayItem? _selected;
|
private OverlayItem? _selected;
|
||||||
private Point? _pointerStartDsl;
|
private Point? _pointerStartDsl;
|
||||||
@@ -52,6 +56,7 @@ public sealed class LayoutOverlayEditor : Control
|
|||||||
public OverlayEditorMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); }
|
public OverlayEditorMode Mode { get => GetValue(ModeProperty); set => SetValue(ModeProperty, value); }
|
||||||
public bool SnapToGrid { get => GetValue(SnapToGridProperty); set => SetValue(SnapToGridProperty, value); }
|
public bool SnapToGrid { get => GetValue(SnapToGridProperty); set => SetValue(SnapToGridProperty, value); }
|
||||||
public double GridSize { get => GetValue(GridSizeProperty); set => SetValue(GridSizeProperty, value); }
|
public double GridSize { get => GetValue(GridSizeProperty); set => SetValue(GridSizeProperty, value); }
|
||||||
|
public string PageTemplateName { get => GetValue(PageTemplateNameProperty); set => SetValue(PageTemplateNameProperty, value); }
|
||||||
|
|
||||||
public event EventHandler<OverlayMeasurement>? MeasurementCompleted;
|
public event EventHandler<OverlayMeasurement>? MeasurementCompleted;
|
||||||
public event EventHandler<OverlayElementGeometry>? ElementGeometryChanged;
|
public event EventHandler<OverlayElementGeometry>? ElementGeometryChanged;
|
||||||
@@ -61,7 +66,8 @@ public sealed class LayoutOverlayEditor : Control
|
|||||||
static LayoutOverlayEditor()
|
static LayoutOverlayEditor()
|
||||||
{
|
{
|
||||||
AffectsRender<LayoutOverlayEditor>(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty,
|
AffectsRender<LayoutOverlayEditor>(PreviewImageProperty, LayoutSourceProperty, PageWidthProperty,
|
||||||
PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty);
|
PageHeightProperty, PageUnitProperty, ModeProperty, SnapToGridProperty, GridSizeProperty,
|
||||||
|
PageTemplateNameProperty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; }
|
public LayoutOverlayEditor() { Focusable = true; ClipToBounds = true; }
|
||||||
@@ -80,7 +86,9 @@ public sealed class LayoutOverlayEditor : Control
|
|||||||
var geometry = ReferenceEquals(item, _selected) && _workingRectDsl is { } working ? working : item.Rect;
|
var geometry = ReferenceEquals(item, _selected) && _workingRectDsl is { } working ? working : item.Rect;
|
||||||
var rect = ToControl(geometry, page);
|
var rect = ToControl(geometry, page);
|
||||||
var selected = ReferenceEquals(item, _selected);
|
var selected = ReferenceEquals(item, _selected);
|
||||||
context.DrawRectangle(selected ? _selectedFill : _normalFill, selected ? _selectedPen : _normalPen,
|
var fill = selected ? _selectedFill : item.IsFlowSlot ? _flowFill : _normalFill;
|
||||||
|
var pen = selected ? _selectedPen : item.IsFlowSlot ? _flowPen : _normalPen;
|
||||||
|
context.DrawRectangle(fill, pen,
|
||||||
rect, 2, 2);
|
rect, 2, 2);
|
||||||
if (selected && item.Resizable)
|
if (selected && item.Resizable)
|
||||||
context.FillRectangle(_handleFill, new Rect(rect.Right - 6, rect.Bottom - 6, 12, 12), 2);
|
context.FillRectangle(_handleFill, new Rect(rect.Right - 6, rect.Bottom - 6, 12, 12), 2);
|
||||||
@@ -188,7 +196,11 @@ public sealed class LayoutOverlayEditor : Control
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var layout = new LayoutParser().Parse(LayoutSource);
|
var layout = new LayoutParser().Parse(LayoutSource);
|
||||||
foreach (var element in layout.Elements)
|
var pageTemplate = layout.PageTemplates.FirstOrDefault(x =>
|
||||||
|
x.Name.Equals(PageTemplateName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? layout.PageTemplates.FirstOrDefault();
|
||||||
|
var visibleElements = pageTemplate?.Elements ?? layout.Elements;
|
||||||
|
foreach (var element in visibleElements)
|
||||||
{
|
{
|
||||||
if (element is BackgroundElement) continue;
|
if (element is BackgroundElement) continue;
|
||||||
var keyword = element switch
|
var keyword = element switch
|
||||||
@@ -202,8 +214,11 @@ public sealed class LayoutOverlayEditor : Control
|
|||||||
ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true),
|
ImageElement image => (image.Width * ImageScale(image), image.Height * ImageScale(image), true),
|
||||||
_ => ((double)element.Width, element.Height, true),
|
_ => ((double)element.Width, element.Height, true),
|
||||||
};
|
};
|
||||||
_items.Add(new(element.Line, keyword, new Rect(element.X, element.Y, width, height), resizable));
|
_items.Add(new(element.Line, keyword, new Rect(element.X, element.Y, width, height), resizable, false));
|
||||||
}
|
}
|
||||||
|
if (pageTemplate is not null)
|
||||||
|
foreach (var slot in pageTemplate.FlowSlots)
|
||||||
|
_items.Add(new(slot.Line, "FLOW", new Rect(slot.X, slot.Y, slot.Width, slot.Height), true, true));
|
||||||
if (_selected is not null)
|
if (_selected is not null)
|
||||||
_selected = _items.FirstOrDefault(x => x.Line == _selected.Line);
|
_selected = _items.FirstOrDefault(x => x.Line == _selected.Line);
|
||||||
}
|
}
|
||||||
@@ -244,7 +259,7 @@ public sealed class LayoutOverlayEditor : Control
|
|||||||
private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch
|
private double PointsToUnit(double points) => PageUnit.ToLowerInvariant() switch
|
||||||
{ "mm" => points * 25.4 / 72, "cm" => points * 2.54 / 72, "in" => points / 72, _ => points };
|
{ "mm" => points * 25.4 / 72, "cm" => points * 2.54 / 72, "in" => points / 72, _ => points };
|
||||||
|
|
||||||
private sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable);
|
private sealed record OverlayItem(int Line, string Keyword, Rect Rect, bool Resizable, bool IsFlowSlot);
|
||||||
private enum DragKind { None, Measure, Move, Resize }
|
private enum DragKind { None, Measure, Move, Resize }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<Border Grid.Row="1" Padding="18,12" Background="#172554">
|
<Border Grid.Row="1" Padding="18,12" Background="#172554">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/>
|
<StackPanel><TextBlock Text="Vorlagen-Designer" Foreground="White" FontWeight="Bold" FontSize="20"/>
|
||||||
<TextBlock Text="Portable PDF-Briefpakete · Schema 1" Foreground="#BFDBFE" FontSize="12"/></StackPanel>
|
<TextBlock Text="Portable PDF-Briefpakete · Layoutformat 3" Foreground="#BFDBFE" FontSize="12"/></StackPanel>
|
||||||
<StackPanel Grid.Column="1" HorizontalAlignment="Right">
|
<StackPanel Grid.Column="1" HorizontalAlignment="Right">
|
||||||
<TextBlock x:Name="DocumentNameText" Foreground="White" FontWeight="SemiBold" HorizontalAlignment="Right"/>
|
<TextBlock x:Name="DocumentNameText" Foreground="White" FontWeight="SemiBold" HorizontalAlignment="Right"/>
|
||||||
<TextBlock x:Name="DocumentPathText" Foreground="#BFDBFE" FontSize="11" HorizontalAlignment="Right"/>
|
<TextBlock x:Name="DocumentPathText" Foreground="#BFDBFE" FontSize="11" HorizontalAlignment="Right"/>
|
||||||
@@ -174,10 +174,53 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem Header="Seiten & Flows">
|
||||||
|
<ScrollViewer Padding="8">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<TextBlock Text="Seitentypen" Classes="section"/>
|
||||||
|
<TextBlock Text="Jeder Seitentyp beschreibt nur seine eigenen festen Elemente und Flow-Slots."
|
||||||
|
TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
|
||||||
|
<ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate, Mode=TwoWay}"/>
|
||||||
|
<Grid ColumnDefinitions="*,8,Auto">
|
||||||
|
<TextBox Text="{Binding NewPageTemplateName}" PlaceholderText="z. B. continuation"/>
|
||||||
|
<Button Grid.Column="2" Content="Seitentyp anlegen" Click="OnAddPageTemplate"/>
|
||||||
|
</Grid>
|
||||||
|
<Separator/>
|
||||||
|
<TextBlock Text="Content-Flows" Classes="section"/>
|
||||||
|
<TextBlock Text="Ein Flow läuft automatisch in den gleichnamigen Slot der ersten und anschließend der Folgeseiten."
|
||||||
|
TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
|
||||||
|
<ComboBox ItemsSource="{Binding ContentFlowNames}" SelectedItem="{Binding SelectedContentFlow, Mode=TwoWay}"/>
|
||||||
|
<Grid ColumnDefinitions="*,8,Auto">
|
||||||
|
<TextBox Text="{Binding NewFlowName}" PlaceholderText="z. B. body"/>
|
||||||
|
<Button Grid.Column="2" Content="Flow anlegen" Click="OnAddContentFlow"/>
|
||||||
|
</Grid>
|
||||||
|
<Separator/>
|
||||||
|
<TextBlock Text="Flow-Slot auf ausgewählter Seite" Classes="section"/>
|
||||||
|
<TextBlock Text="Die Bounding Box wird rechts violett gestrichelt dargestellt und kann dort verschoben und skaliert werden."
|
||||||
|
TextWrapping="Wrap" Opacity="0.7" FontSize="12"/>
|
||||||
|
<Grid ColumnDefinitions="*,6,*,6,*,6,*">
|
||||||
|
<StackPanel><TextBlock Text="X" Classes="label"/><TextBox Text="{Binding NewX}"/></StackPanel>
|
||||||
|
<StackPanel Grid.Column="2"><TextBlock Text="Y" Classes="label"/><TextBox Text="{Binding NewY}"/></StackPanel>
|
||||||
|
<StackPanel Grid.Column="4"><TextBlock Text="Breite" Classes="label"/><TextBox Text="{Binding NewWidth}"/></StackPanel>
|
||||||
|
<StackPanel Grid.Column="6"><TextBlock Text="Höhe" Classes="label"/><TextBox Text="{Binding NewHeight}"/></StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<Button Content="Flow-Slot anlegen" HorizontalAlignment="Left" Click="OnAddFlowSlot"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
<TabItem Header="Elemente">
|
<TabItem Header="Elemente">
|
||||||
<ScrollViewer Padding="8">
|
<ScrollViewer Padding="8">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<TextBlock Text="Element hinzufügen" Classes="section"/>
|
<TextBlock Text="Element hinzufügen" Classes="section"/>
|
||||||
|
<StackPanel><TextBlock Text="Einfügen in" Classes="label"/>
|
||||||
|
<ComboBox ItemsSource="{Binding ElementScopes}" SelectedItem="{Binding NewElementScope}"/></StackPanel>
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<StackPanel><TextBlock Text="Seitentyp für feste Elemente" Classes="label"/>
|
||||||
|
<ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate}"/></StackPanel>
|
||||||
|
<StackPanel Grid.Column="2"><TextBlock Text="Content-Flow für fließende Elemente" Classes="label"/>
|
||||||
|
<ComboBox ItemsSource="{Binding ContentFlowNames}" SelectedItem="{Binding SelectedContentFlow}"/></StackPanel>
|
||||||
|
</Grid>
|
||||||
<StackPanel><TextBlock Text="Elementtyp" Classes="label"/>
|
<StackPanel><TextBlock Text="Elementtyp" Classes="label"/>
|
||||||
<ComboBox ItemsSource="{Binding ElementTypes}" SelectedItem="{Binding NewElementType}"/></StackPanel>
|
<ComboBox ItemsSource="{Binding ElementTypes}" SelectedItem="{Binding NewElementType}"/></StackPanel>
|
||||||
<Grid ColumnDefinitions="*,6,*,6,*,6,*">
|
<Grid ColumnDefinitions="*,6,*,6,*,6,*">
|
||||||
@@ -195,6 +238,8 @@
|
|||||||
<Button Content="Element ins Layout übernehmen" Click="OnAddElement"/>
|
<Button Content="Element ins Layout übernehmen" Click="OnAddElement"/>
|
||||||
<TextBlock Text="Tipp: Koordinaten können rechts im Messmodus direkt aus der Vorschau übernommen werden."
|
<TextBlock Text="Tipp: Koordinaten können rechts im Messmodus direkt aus der Vorschau übernommen werden."
|
||||||
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
|
TextWrapping="Wrap" Opacity="0.65" FontSize="11"/>
|
||||||
|
<TextBlock Text="Elemente im Content-Flow verwenden Reihenfolge, gap und keep-with-next; ihre X/Y-Koordinaten werden nicht benötigt."
|
||||||
|
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||||
<TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
|
<TextBlock Text="FLOWBOX verteilt Text automatisch über beliebig viele Seiten. TEXTBOX bleibt ein fester Bereich."
|
||||||
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
TextWrapping="Wrap" Foreground="#166534" FontSize="12"/>
|
||||||
<TextBlock Text="Systemvariablen ohne Deklaration: $$today, $$curPage und $$maxPageNum (z. B. "Seite $$curPage von $$maxPageNum")."
|
<TextBlock Text="Systemvariablen ohne Deklaration: $$today, $$curPage und $$maxPageNum (z. B. "Seite $$curPage von $$maxPageNum")."
|
||||||
@@ -232,11 +277,19 @@
|
|||||||
<Border Grid.Column="2" Background="#E2E8F0" Padding="18">
|
<Border Grid.Column="2" Background="#E2E8F0" Padding="18">
|
||||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Text="Visueller Layout-Editor" Classes="section"/>
|
<StackPanel><TextBlock Text="Visueller Layout-Editor" Classes="section"/>
|
||||||
|
<ComboBox ItemsSource="{Binding PageTemplateNames}" SelectedItem="{Binding SelectedPageTemplate, Mode=TwoWay}"
|
||||||
|
MinWidth="150" Margin="0,5,0,0"/>
|
||||||
|
<Button Content="Seitentyp anzeigen" Margin="0,5,0,0" Click="OnPageTemplateSelectionChanged"/></StackPanel>
|
||||||
<TextBlock Grid.Column="1" Text="{Binding OverlayCoordinates}" FontFamily="Monospace"
|
<TextBlock Grid.Column="1" Text="{Binding OverlayCoordinates}" FontFamily="Monospace"
|
||||||
FontSize="11" VerticalAlignment="Center"/>
|
FontSize="11" VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<StackPanel Grid.Row="1" Spacing="7" Margin="0,10,0,0">
|
<StackPanel Grid.Row="1" Spacing="7" Margin="0,10,0,0">
|
||||||
|
<Grid ColumnDefinitions="Auto,8,*">
|
||||||
|
<TextBlock Text="Vorschau" VerticalAlignment="Center" Classes="label"/>
|
||||||
|
<ComboBox Grid.Column="2" ItemsSource="{Binding PreviewPages}" SelectedItem="{Binding SelectedPreviewPage}"
|
||||||
|
DisplayMemberBinding="{Binding Display}" PlaceholderText="Noch nicht gerendert"/>
|
||||||
|
</Grid>
|
||||||
<Grid ColumnDefinitions="*,8,*">
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
<Button Grid.Column="0" Content="Koordinaten messen" Click="OnMeasureOverlayMode"/>
|
<Button Grid.Column="0" Content="Koordinaten messen" Click="OnMeasureOverlayMode"/>
|
||||||
<Button Grid.Column="2" Content="Elemente verschieben" Click="OnEditOverlayMode"/>
|
<Button Grid.Column="2" Content="Elemente verschieben" Click="OnEditOverlayMode"/>
|
||||||
@@ -254,6 +307,7 @@
|
|||||||
LayoutSource="{Binding LayoutSource, Mode=TwoWay}"
|
LayoutSource="{Binding LayoutSource, Mode=TwoWay}"
|
||||||
PageWidth="{Binding OverlayPageWidth}" PageHeight="{Binding OverlayPageHeight}"
|
PageWidth="{Binding OverlayPageWidth}" PageHeight="{Binding OverlayPageHeight}"
|
||||||
PageUnit="{Binding OverlayPageUnit}" Mode="{Binding OverlayMode}"
|
PageUnit="{Binding OverlayPageUnit}" Mode="{Binding OverlayMode}"
|
||||||
|
PageTemplateName="{Binding SelectedPageTemplate}"
|
||||||
SnapToGrid="{Binding SnapOverlayToGrid}" GridSize="{Binding OverlayGridSize}"
|
SnapToGrid="{Binding SnapOverlayToGrid}" GridSize="{Binding OverlayGridSize}"
|
||||||
MeasurementCompleted="OnOverlayMeasurementCompleted"
|
MeasurementCompleted="OnOverlayMeasurementCompleted"
|
||||||
ElementGeometryChanged="OnOverlayElementGeometryChanged"
|
ElementGeometryChanged="OnOverlayElementGeometryChanged"
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
private void OnAddElement(object? sender, RoutedEventArgs e)
|
private void OnAddElement(object? sender, RoutedEventArgs e)
|
||||||
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
{ try { _viewModel.AddElement(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||||
|
private void OnAddPageTemplate(object? sender, RoutedEventArgs e)
|
||||||
|
{ try { _viewModel.AddPageTemplate(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||||
|
private void OnAddContentFlow(object? sender, RoutedEventArgs e)
|
||||||
|
{ try { _viewModel.AddContentFlow(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||||
|
private void OnAddFlowSlot(object? sender, RoutedEventArgs e)
|
||||||
|
{ try { _viewModel.AddFlowSlot(); } catch (Exception ex) { _viewModel.SetStatus(ex.Message, true); } }
|
||||||
|
private void OnPageTemplateSelectionChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!IsLoaded) return;
|
||||||
|
try { _viewModel.PreviewSelectedPageTemplateCanvas(); }
|
||||||
|
catch (Exception ex) { _viewModel.SetStatus($"Seitentyp-Vorschau fehlgeschlagen: {ex.Message}", true); }
|
||||||
|
}
|
||||||
private void OnMeasureOverlayMode(object? sender, RoutedEventArgs e)
|
private void OnMeasureOverlayMode(object? sender, RoutedEventArgs e)
|
||||||
{ _viewModel.OverlayMode = OverlayEditorMode.Measure; _viewModel.SelectedOverlayElement = "Messmodus aktiv"; }
|
{ _viewModel.OverlayMode = OverlayEditorMode.Measure; _viewModel.SelectedOverlayElement = "Messmodus aktiv"; }
|
||||||
private void OnEditOverlayMode(object? sender, RoutedEventArgs e)
|
private void OnEditOverlayMode(object? sender, RoutedEventArgs e)
|
||||||
@@ -125,10 +137,9 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bytes = new QuestTemplateRenderer().RenderFirstPageToPng(_viewModel.BuildLoaded(), _viewModel.BuildDataProvider());
|
_viewModel.RenderPreviewPages(_viewModel.BuildLoaded(), _viewModel.BuildDataProvider());
|
||||||
using var stream = new MemoryStream(bytes);
|
_viewModel.CanExport = true;
|
||||||
_viewModel.PreviewImage?.Dispose(); _viewModel.PreviewImage = new Bitmap(stream);
|
_viewModel.SetStatus($"Validierung erfolgreich. Vorschau enthält {_viewModel.PreviewPages.Count} Seite(n).", false);
|
||||||
_viewModel.CanExport = true; _viewModel.SetStatus("Validierung erfolgreich. Vorschau ist aktuell.", false);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{ _viewModel.CanExport = false; _viewModel.SetStatus(ex.Message, true); }
|
{ _viewModel.CanExport = false; _viewModel.SetStatus(ex.Message, true); }
|
||||||
|
|||||||
@@ -171,6 +171,71 @@ public sealed class TemplatingTests : IDisposable
|
|||||||
Assert.Equal(3, exception.Result.Issues.Count(issue => issue.Line is not null));
|
Assert.Equal(3, exception.Result.Issues.Count(issue => issue.Line is not null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LayoutParser_LiestSeitentypenSlotsUndContentFlows()
|
||||||
|
{
|
||||||
|
var layout = new LayoutParser().Parse("""
|
||||||
|
PAGE 210 297 mm
|
||||||
|
#pragma format-version 3
|
||||||
|
#pragma page-template first
|
||||||
|
TEXT 20 20 "Briefkopf"
|
||||||
|
#pragma flow-slot body x=20 y=80 w=170 h=190
|
||||||
|
#pragma end-page-template
|
||||||
|
#pragma page-template continuation
|
||||||
|
#pragma flow-slot body x=20 y=25 w=170 h=245
|
||||||
|
#pragma end-page-template
|
||||||
|
#pragma content-flow body
|
||||||
|
TEXTBOX $Text size=11 overflow=continue
|
||||||
|
TEXT "Gruß" gap=8 keep-with-next=true
|
||||||
|
TEXT $Name
|
||||||
|
#pragma end-content-flow
|
||||||
|
""");
|
||||||
|
|
||||||
|
Assert.Equal(3, layout.FormatVersion);
|
||||||
|
Assert.Equal(2, layout.PageTemplates.Count);
|
||||||
|
Assert.Equal(80, layout.PageTemplates[0].FlowSlots.Single().Y);
|
||||||
|
Assert.Equal(3, layout.ContentFlows.Single().Elements.Count);
|
||||||
|
Assert.IsType<TextBoxElement>(layout.ContentFlows.Single().Elements[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Renderer_FliesstLangenTextAufFolgeseitenWeiter()
|
||||||
|
{
|
||||||
|
var manifest = new TemplateManifest
|
||||||
|
{
|
||||||
|
Id = "flow", Name = "Flow",
|
||||||
|
Placeholders = [new("Text", PlaceholderType.Multiline, true), new("Name", PlaceholderType.Text, true)],
|
||||||
|
};
|
||||||
|
var layout = new LayoutParser().Parse("""
|
||||||
|
PAGE 210 297 mm
|
||||||
|
#pragma format-version 3
|
||||||
|
#pragma page-template first
|
||||||
|
TEXT 20 15 "Erste Seite" size=16
|
||||||
|
#pragma flow-slot body x=20 y=55 w=170 h=215
|
||||||
|
#pragma end-page-template
|
||||||
|
#pragma page-template continuation
|
||||||
|
TEXT 20 12 "Folgeseite" size=9
|
||||||
|
#pragma flow-slot body x=20 y=25 w=170 h=245
|
||||||
|
#pragma end-page-template
|
||||||
|
#pragma content-flow body
|
||||||
|
TEXTBOX $Text size=11 overflow=continue
|
||||||
|
TEXT "Mit freundlichen Grüßen" gap=8 keep-with-next=true
|
||||||
|
TEXT $Name gap=3
|
||||||
|
#pragma end-content-flow
|
||||||
|
""");
|
||||||
|
var loaded = new LoadedTemplate(manifest, layout, new Dictionary<string, byte[]>());
|
||||||
|
var longText = string.Join('\n', Enumerable.Repeat("Dies ist eine ausreichend lange Textzeile für den Seitenumbruch.", 180));
|
||||||
|
|
||||||
|
var pages = new QuestTemplateRenderer().RenderPagesToPng(loaded,
|
||||||
|
new DictionaryProvider(new Dictionary<string, PlaceholderValue>
|
||||||
|
{
|
||||||
|
["Text"] = new MultilineValue(longText), ["Name"] = new TextValue("M. Mustermann"),
|
||||||
|
}), 40);
|
||||||
|
|
||||||
|
Assert.True(pages.Count >= 3);
|
||||||
|
Assert.All(pages, page => Assert.True(page.Length > 500));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Loader_BlockiertPathTraversal()
|
public void Loader_BlockiertPathTraversal()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ public sealed class LayoutParser
|
|||||||
{
|
{
|
||||||
var issues = new List<ValidationIssue>();
|
var issues = new List<ValidationIssue>();
|
||||||
var elements = new List<TemplateElement>();
|
var elements = new List<TemplateElement>();
|
||||||
|
var pageTemplates = new List<PageTemplateDefinition>();
|
||||||
|
var contentFlows = new List<ContentFlowDefinition>();
|
||||||
|
string? currentPageName = null, currentFlowName = null;
|
||||||
|
List<TemplateElement>? currentPageElements = null, currentFlowElements = null;
|
||||||
|
List<FlowSlotDefinition>? currentSlots = null;
|
||||||
|
var formatVersion = 1;
|
||||||
float width = 0, height = 0;
|
float width = 0, height = 0;
|
||||||
var unit = "mm";
|
var unit = "mm";
|
||||||
var pageSeen = false;
|
var pageSeen = false;
|
||||||
@@ -18,15 +24,75 @@ public sealed class LayoutParser
|
|||||||
{
|
{
|
||||||
var lineNumber = index + 1;
|
var lineNumber = index + 1;
|
||||||
var raw = lines[index].Trim();
|
var raw = lines[index].Trim();
|
||||||
if (raw.Length == 0 || raw.StartsWith('#')) continue;
|
if (raw.Length == 0) continue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (raw.StartsWith("#pragma", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var pragma = Tokenize(raw);
|
||||||
|
Require(pragma, 2);
|
||||||
|
switch (pragma[1].ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "format-version":
|
||||||
|
Require(pragma, 3);
|
||||||
|
if (!int.TryParse(pragma[2], out formatVersion) || formatVersion is < 1 or > 3)
|
||||||
|
throw new FormatException("format-version muss zwischen 1 und 3 liegen.");
|
||||||
|
break;
|
||||||
|
case "page-template":
|
||||||
|
Require(pragma, 3);
|
||||||
|
if (currentPageName is not null || currentFlowName is not null)
|
||||||
|
throw new FormatException("Verschachtelte Bereiche sind nicht erlaubt.");
|
||||||
|
currentPageName = pragma[2]; currentPageElements = []; currentSlots = [];
|
||||||
|
break;
|
||||||
|
case "end-page-template":
|
||||||
|
if (currentPageName is null || currentPageElements is null || currentSlots is null)
|
||||||
|
throw new FormatException("Kein page-template ist geöffnet.");
|
||||||
|
if (pageTemplates.Any(x => x.Name.Equals(currentPageName, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new FormatException($"page-template „{currentPageName}“ ist mehrfach definiert.");
|
||||||
|
pageTemplates.Add(new(currentPageName, currentPageElements, currentSlots));
|
||||||
|
currentPageName = null; currentPageElements = null; currentSlots = null;
|
||||||
|
break;
|
||||||
|
case "flow-slot":
|
||||||
|
Require(pragma, 3);
|
||||||
|
if (currentPageName is null || currentSlots is null)
|
||||||
|
throw new FormatException("flow-slot muss innerhalb eines page-template stehen.");
|
||||||
|
var slotAttributes = Attributes(pragma, 3);
|
||||||
|
foreach (var required in new[] { "x", "y", "w", "h" })
|
||||||
|
if (!slotAttributes.ContainsKey(required))
|
||||||
|
throw new FormatException($"flow-slot benötigt {required}=…");
|
||||||
|
var slot = new FlowSlotDefinition(lineNumber, pragma[2], Number(slotAttributes["x"]),
|
||||||
|
Number(slotAttributes["y"]), Number(slotAttributes["w"]), Number(slotAttributes["h"]));
|
||||||
|
if (slot.Width <= 0 || slot.Height <= 0) throw new FormatException("Flow-Slot muss eine positive Größe haben.");
|
||||||
|
if (currentSlots.Any(x => x.Name.Equals(slot.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new FormatException($"flow-slot „{slot.Name}“ ist in dieser Seitenvorlage mehrfach definiert.");
|
||||||
|
currentSlots.Add(slot);
|
||||||
|
break;
|
||||||
|
case "content-flow":
|
||||||
|
Require(pragma, 3);
|
||||||
|
if (currentPageName is not null || currentFlowName is not null)
|
||||||
|
throw new FormatException("Verschachtelte Bereiche sind nicht erlaubt.");
|
||||||
|
currentFlowName = pragma[2]; currentFlowElements = [];
|
||||||
|
break;
|
||||||
|
case "end-content-flow":
|
||||||
|
if (currentFlowName is null || currentFlowElements is null)
|
||||||
|
throw new FormatException("Kein content-flow ist geöffnet.");
|
||||||
|
if (contentFlows.Any(x => x.Name.Equals(currentFlowName, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new FormatException($"content-flow „{currentFlowName}“ ist mehrfach definiert.");
|
||||||
|
contentFlows.Add(new(currentFlowName, currentFlowElements));
|
||||||
|
currentFlowName = null; currentFlowElements = null;
|
||||||
|
break;
|
||||||
|
default: throw new FormatException($"Unbekanntes Pragma „{pragma[1]}“.");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (raw.StartsWith('#')) continue;
|
||||||
var tokens = Tokenize(raw);
|
var tokens = Tokenize(raw);
|
||||||
if (tokens.Count == 0) continue;
|
if (tokens.Count == 0) continue;
|
||||||
var keyword = tokens[0].ToUpperInvariant();
|
var keyword = tokens[0].ToUpperInvariant();
|
||||||
if (!pageSeen && keyword != "PAGE")
|
if (!pageSeen && keyword != "PAGE")
|
||||||
throw new FormatException("PAGE muss das erste Statement sein.");
|
throw new FormatException("PAGE muss das erste Statement sein.");
|
||||||
|
|
||||||
|
TemplateElement? parsedElement = null;
|
||||||
switch (keyword)
|
switch (keyword)
|
||||||
{
|
{
|
||||||
case "PAGE":
|
case "PAGE":
|
||||||
@@ -39,56 +105,94 @@ public sealed class LayoutParser
|
|||||||
pageSeen = true;
|
pageSeen = true;
|
||||||
break;
|
break;
|
||||||
case "BG":
|
case "BG":
|
||||||
Require(tokens, 2); elements.Add(new BackgroundElement(lineNumber, tokens[1])); break;
|
if (currentFlowName is not null) throw new FormatException("BG ist in einem content-flow nicht erlaubt.");
|
||||||
|
Require(tokens, 2); parsedElement = new BackgroundElement(lineNumber, tokens[1]); break;
|
||||||
case "IMG":
|
case "IMG":
|
||||||
Require(tokens, 6);
|
var flowImage = currentFlowName is not null;
|
||||||
var imageAttributes = Attributes(tokens, 6);
|
Require(tokens, flowImage ? 2 : 6);
|
||||||
|
var imageAttributes = Attributes(tokens, flowImage ? 2 : 6);
|
||||||
if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale);
|
if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale);
|
||||||
elements.Add(new ImageElement(lineNumber, tokens[1],
|
parsedElement = flowImage
|
||||||
Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), Number(tokens[5]), imageAttributes));
|
? new ImageElement(lineNumber, tokens[1], 0, 0,
|
||||||
|
OptionalNumber(imageAttributes, "w"), OptionalNumber(imageAttributes, "h"), imageAttributes)
|
||||||
|
: new ImageElement(lineNumber, tokens[1], Number(tokens[2]), Number(tokens[3]),
|
||||||
|
Number(tokens[4]), Number(tokens[5]), imageAttributes);
|
||||||
break;
|
break;
|
||||||
case "TEXT":
|
case "TEXT":
|
||||||
Require(tokens, 4);
|
var flowText = currentFlowName is not null;
|
||||||
var textRef = Reference(tokens[3]);
|
Require(tokens, flowText ? 2 : 4);
|
||||||
elements.Add(new TextElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
var textContent = tokens[flowText ? 1 : 3];
|
||||||
tokens[3], textRef.Name, textRef.Format, Attributes(tokens, 4))); break;
|
var textRef = Reference(textContent);
|
||||||
|
parsedElement = new TextElement(lineNumber, flowText ? 0 : Number(tokens[1]),
|
||||||
|
flowText ? 0 : Number(tokens[2]), textContent, textRef.Name, textRef.Format,
|
||||||
|
Attributes(tokens, flowText ? 2 : 4)); break;
|
||||||
case "TEXTBOX":
|
case "TEXTBOX":
|
||||||
Require(tokens, 6);
|
var flowBox = currentFlowName is not null;
|
||||||
var boxRef = Reference(tokens[5]);
|
Require(tokens, flowBox ? 2 : 6);
|
||||||
elements.Add(new TextBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
var boxContent = tokens[flowBox ? 1 : 5];
|
||||||
Number(tokens[3]), Number(tokens[4]), tokens[5], boxRef.Name, boxRef.Format,
|
var boxRef = Reference(boxContent);
|
||||||
Attributes(tokens, 6))); break;
|
parsedElement = new TextBoxElement(lineNumber, flowBox ? 0 : Number(tokens[1]),
|
||||||
|
flowBox ? 0 : Number(tokens[2]), flowBox ? 0 : Number(tokens[3]),
|
||||||
|
flowBox ? 0 : Number(tokens[4]), boxContent, boxRef.Name, boxRef.Format,
|
||||||
|
Attributes(tokens, flowBox ? 2 : 6)); break;
|
||||||
case "FLOWBOX":
|
case "FLOWBOX":
|
||||||
Require(tokens, 6);
|
var nestedFlowBox = currentFlowName is not null;
|
||||||
var flowRef = Reference(tokens[5]);
|
Require(tokens, nestedFlowBox ? 2 : 6);
|
||||||
elements.Add(new FlowBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
var flowContent = tokens[nestedFlowBox ? 1 : 5];
|
||||||
Number(tokens[3]), Number(tokens[4]), tokens[5], flowRef.Name, flowRef.Format,
|
var flowRef = Reference(flowContent);
|
||||||
Attributes(tokens, 6))); break;
|
parsedElement = new FlowBoxElement(lineNumber,
|
||||||
|
nestedFlowBox ? 0 : Number(tokens[1]), nestedFlowBox ? 0 : Number(tokens[2]),
|
||||||
|
nestedFlowBox ? 0 : Number(tokens[3]), nestedFlowBox ? 0 : Number(tokens[4]),
|
||||||
|
flowContent, flowRef.Name, flowRef.Format, Attributes(tokens, nestedFlowBox ? 2 : 6));
|
||||||
|
break;
|
||||||
case "DRAWBOX":
|
case "DRAWBOX":
|
||||||
Require(tokens, 6);
|
var flowDrawing = currentFlowName is not null;
|
||||||
elements.Add(new DrawBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
Require(tokens, flowDrawing ? 2 : 6);
|
||||||
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6)));
|
var drawingAttributes = Attributes(tokens, flowDrawing ? 2 : 6);
|
||||||
|
parsedElement = new DrawBoxElement(lineNumber,
|
||||||
|
flowDrawing ? 0 : Number(tokens[1]), flowDrawing ? 0 : Number(tokens[2]),
|
||||||
|
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "w") : Number(tokens[3]),
|
||||||
|
flowDrawing ? RequiredPositiveNumber(drawingAttributes, "h") : Number(tokens[4]),
|
||||||
|
RequiredReference(tokens[flowDrawing ? 1 : 5]), drawingAttributes);
|
||||||
break;
|
break;
|
||||||
case "FLOWDRAWBOX":
|
case "FLOWDRAWBOX":
|
||||||
Require(tokens, 6);
|
var nestedFlowDrawing = currentFlowName is not null;
|
||||||
elements.Add(new FlowDrawBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
Require(tokens, nestedFlowDrawing ? 2 : 6);
|
||||||
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6)));
|
var flowDrawingAttributes = Attributes(tokens, nestedFlowDrawing ? 2 : 6);
|
||||||
|
parsedElement = new FlowDrawBoxElement(lineNumber,
|
||||||
|
nestedFlowDrawing ? 0 : Number(tokens[1]), nestedFlowDrawing ? 0 : Number(tokens[2]),
|
||||||
|
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "w") : Number(tokens[3]),
|
||||||
|
nestedFlowDrawing ? RequiredPositiveNumber(flowDrawingAttributes, "h") : Number(tokens[4]),
|
||||||
|
RequiredReference(tokens[nestedFlowDrawing ? 1 : 5]), flowDrawingAttributes);
|
||||||
break;
|
break;
|
||||||
case "TABLE":
|
case "TABLE":
|
||||||
Require(tokens, 6);
|
var flowTable = currentFlowName is not null;
|
||||||
elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
Require(tokens, flowTable ? 2 : 6);
|
||||||
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6)));
|
parsedElement = new TableElement(lineNumber, flowTable ? 0 : Number(tokens[1]),
|
||||||
|
flowTable ? 0 : Number(tokens[2]), flowTable ? 0 : Number(tokens[3]),
|
||||||
|
flowTable ? 0 : Number(tokens[4]), RequiredReference(tokens[flowTable ? 1 : 5]),
|
||||||
|
Attributes(tokens, flowTable ? 2 : 6));
|
||||||
break;
|
break;
|
||||||
case "CHART":
|
case "CHART":
|
||||||
Require(tokens, 6);
|
var flowChart = currentFlowName is not null;
|
||||||
var attributes = Attributes(tokens, 6);
|
Require(tokens, flowChart ? 2 : 6);
|
||||||
|
var attributes = Attributes(tokens, flowChart ? 2 : 6);
|
||||||
var chartType = attributes.GetValueOrDefault("type", "bar").ToLowerInvariant();
|
var chartType = attributes.GetValueOrDefault("type", "bar").ToLowerInvariant();
|
||||||
if (chartType is not ("line" or "bar")) throw new FormatException("CHART type muss line oder bar sein.");
|
if (chartType is not ("line" or "bar")) throw new FormatException("CHART type muss line oder bar sein.");
|
||||||
elements.Add(new ChartElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
|
parsedElement = new ChartElement(lineNumber, flowChart ? 0 : Number(tokens[1]),
|
||||||
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), chartType, attributes));
|
flowChart ? 0 : Number(tokens[2]), flowChart ? 0 : Number(tokens[3]),
|
||||||
|
flowChart ? 0 : Number(tokens[4]), RequiredReference(tokens[flowChart ? 1 : 5]), chartType, attributes);
|
||||||
break;
|
break;
|
||||||
default: throw new FormatException($"Unbekanntes Element „{tokens[0]}“.");
|
default: throw new FormatException($"Unbekanntes Element „{tokens[0]}“.");
|
||||||
}
|
}
|
||||||
|
if (parsedElement is not null)
|
||||||
|
{
|
||||||
|
elements.Add(parsedElement);
|
||||||
|
if (currentPageElements is not null) currentPageElements.Add(parsedElement);
|
||||||
|
else if (currentFlowElements is not null) currentFlowElements.Add(parsedElement);
|
||||||
|
else if (pageTemplates.Count > 0 || contentFlows.Count > 0 || formatVersion >= 3)
|
||||||
|
throw new FormatException("Elemente müssen in page-template oder content-flow stehen.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (FormatException ex)
|
catch (FormatException ex)
|
||||||
{
|
{
|
||||||
@@ -97,8 +201,32 @@ public sealed class LayoutParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!pageSeen) issues.Add(new(ValidationSeverity.Error, "PAGE fehlt."));
|
if (!pageSeen) issues.Add(new(ValidationSeverity.Error, "PAGE fehlt."));
|
||||||
|
if (currentPageName is not null) issues.Add(new(ValidationSeverity.Error, $"page-template „{currentPageName}“ wurde nicht geschlossen."));
|
||||||
|
if (currentFlowName is not null) issues.Add(new(ValidationSeverity.Error, $"content-flow „{currentFlowName}“ wurde nicht geschlossen."));
|
||||||
|
if (pageTemplates.Count > 0 || contentFlows.Count > 0)
|
||||||
|
{
|
||||||
|
var firstTemplate = pageTemplates.FirstOrDefault(x => x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var continuationTemplate = pageTemplates.FirstOrDefault(x => x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (firstTemplate is null)
|
||||||
|
issues.Add(new(ValidationSeverity.Error, "Die Seitenvorlage „first“ fehlt."));
|
||||||
|
foreach (var flow in contentFlows)
|
||||||
|
{
|
||||||
|
if (firstTemplate is not null && !firstTemplate.FlowSlots.Any(slot => slot.Name.Equals(flow.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
issues.Add(new(ValidationSeverity.Error, $"Für content-flow „{flow.Name}“ fehlt der gleichnamige flow-slot im Seitentyp „first“."));
|
||||||
|
if (continuationTemplate is not null && !continuationTemplate.FlowSlots.Any(slot => slot.Name.Equals(flow.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
issues.Add(new(ValidationSeverity.Error, $"Für content-flow „{flow.Name}“ fehlt der gleichnamige flow-slot im Seitentyp „continuation“."));
|
||||||
|
}
|
||||||
|
foreach (var slot in pageTemplates.SelectMany(page => page.FlowSlots))
|
||||||
|
if (slot.X < 0 || slot.Y < 0 || slot.X + slot.Width > width || slot.Y + slot.Height > height)
|
||||||
|
issues.Add(new(ValidationSeverity.Error, $"Flow-Slot „{slot.Name}“ in Zeile {slot.Line} liegt außerhalb der Seite.", slot.Line));
|
||||||
|
}
|
||||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||||
return new(width, height, unit, elements);
|
return new(width, height, unit, elements)
|
||||||
|
{
|
||||||
|
FormatVersion = formatVersion,
|
||||||
|
PageTemplates = pageTemplates,
|
||||||
|
ContentFlows = contentFlows,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string? Name, string? Format) Reference(string value)
|
private static (string? Name, string? Format) Reference(string value)
|
||||||
@@ -116,6 +244,17 @@ public sealed class LayoutParser
|
|||||||
private static float Number(string value) => float.TryParse(value, NumberStyles.Float,
|
private static float Number(string value) => float.TryParse(value, NumberStyles.Float,
|
||||||
CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl.");
|
CultureInfo.InvariantCulture, out var result) ? result : throw new FormatException($"„{value}“ ist keine Zahl.");
|
||||||
|
|
||||||
|
private static float OptionalNumber(IReadOnlyDictionary<string, string> attributes, string name) =>
|
||||||
|
attributes.TryGetValue(name, out var value) ? Number(value) : 0;
|
||||||
|
|
||||||
|
private static float RequiredPositiveNumber(IReadOnlyDictionary<string, string> attributes, string name)
|
||||||
|
{
|
||||||
|
if (!attributes.TryGetValue(name, out var raw))
|
||||||
|
throw new FormatException($"Das Element benötigt {name}=…");
|
||||||
|
var value = Number(raw);
|
||||||
|
return value > 0 ? value : throw new FormatException($"{name} muss positiv sein.");
|
||||||
|
}
|
||||||
|
|
||||||
public static float Percentage(string value)
|
public static float Percentage(string value)
|
||||||
{
|
{
|
||||||
var normalized = value.EndsWith('%') ? value[..^1] : value;
|
var normalized = value.EndsWith('%') ? value[..^1] : value;
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ public sealed record ChartElement(int Line, float X, float Y, float Width, float
|
|||||||
string Placeholder, string ChartType, IReadOnlyDictionary<string, string> Attributes)
|
string Placeholder, string ChartType, IReadOnlyDictionary<string, string> Attributes)
|
||||||
: TemplateElement(Line, X, Y, Width, Height, Attributes);
|
: TemplateElement(Line, X, Y, Width, Height, Attributes);
|
||||||
|
|
||||||
|
public sealed record FlowSlotDefinition(int Line, string Name, float X, float Y, float Width, float Height);
|
||||||
|
public sealed record PageTemplateDefinition(string Name, IReadOnlyList<TemplateElement> Elements,
|
||||||
|
IReadOnlyList<FlowSlotDefinition> FlowSlots);
|
||||||
|
public sealed record ContentFlowDefinition(string Name, IReadOnlyList<TemplateElement> Elements);
|
||||||
|
|
||||||
internal static class EmptyAttributes
|
internal static class EmptyAttributes
|
||||||
{
|
{
|
||||||
public static readonly IReadOnlyDictionary<string, string> Value =
|
public static readonly IReadOnlyDictionary<string, string> Value =
|
||||||
@@ -139,7 +144,13 @@ internal static class EmptyAttributes
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed record TemplateLayout(float Width, float Height, string Unit,
|
public sealed record TemplateLayout(float Width, float Height, string Unit,
|
||||||
IReadOnlyList<TemplateElement> Elements);
|
IReadOnlyList<TemplateElement> Elements)
|
||||||
|
{
|
||||||
|
public int FormatVersion { get; init; } = 1;
|
||||||
|
public IReadOnlyList<PageTemplateDefinition> PageTemplates { get; init; } = [];
|
||||||
|
public IReadOnlyList<ContentFlowDefinition> ContentFlows { get; init; } = [];
|
||||||
|
public bool UsesPageTemplates => PageTemplates.Count > 0 || ContentFlows.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout,
|
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout,
|
||||||
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "",
|
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "",
|
||||||
|
|||||||
@@ -15,9 +15,14 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
|||||||
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
|
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
|
||||||
|
|
||||||
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
||||||
|
{
|
||||||
|
return RenderPagesToPng(template, data, dpi).First();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<byte[]> RenderPagesToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
|
||||||
{
|
{
|
||||||
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
|
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
|
||||||
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).First();
|
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
|
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
|
||||||
@@ -33,6 +38,14 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
|||||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||||
{
|
{
|
||||||
values = PreparePagedDrawings(template, values);
|
values = PreparePagedDrawings(template, values);
|
||||||
|
return template.Layout.UsesPageTemplates
|
||||||
|
? BuildFlowDocument(template, values)
|
||||||
|
: BuildLegacyDocument(template, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDocument BuildLegacyDocument(LoadedTemplate template,
|
||||||
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||||
|
{
|
||||||
var flowBox = template.Layout.Elements.SingleOrDefault(x => x is FlowBoxElement or FlowDrawBoxElement);
|
var flowBox = template.Layout.Elements.SingleOrDefault(x => x is FlowBoxElement or FlowDrawBoxElement);
|
||||||
return Document.Create(document => document.Page(page =>
|
return Document.Create(document => document.Page(page =>
|
||||||
{
|
{
|
||||||
@@ -45,7 +58,7 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
|||||||
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
layers.PrimaryLayer().Width(UnitConverter.Points(template.Layout.Width, template.Layout.Unit))
|
||||||
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
.Height(UnitConverter.Points(template.Layout.Height, template.Layout.Unit)).Background(Colors.White);
|
||||||
else
|
else
|
||||||
RenderFlowElement(layers.PrimaryLayer(), flowBox, template, values);
|
RenderLegacyFlowElement(layers.PrimaryLayer(), flowBox, template, values);
|
||||||
|
|
||||||
RenderStaticLayers(layers, template.Layout, template, values,
|
RenderStaticLayers(layers, template.Layout, template, values,
|
||||||
template.ContinuationLayout is null ? null : static container => container.ShowOnce());
|
template.ContinuationLayout is null ? null : static container => container.ShowOnce());
|
||||||
@@ -97,7 +110,7 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
|
private static void RenderLegacyFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
|
||||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||||
{
|
{
|
||||||
var unit = template.Layout.Unit;
|
var unit = template.Layout.Unit;
|
||||||
@@ -117,6 +130,161 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IDocument BuildFlowDocument(LoadedTemplate template,
|
||||||
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||||
|
{
|
||||||
|
var firstPage = template.Layout.PageTemplates.First(x =>
|
||||||
|
x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var continuationPage = template.Layout.PageTemplates.FirstOrDefault(x =>
|
||||||
|
x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase)) ?? firstPage;
|
||||||
|
var populatedFlows = template.Layout.ContentFlows.Where(x => x.Elements.Count > 0).ToList();
|
||||||
|
if (populatedFlows.Count > 1)
|
||||||
|
throw new InvalidDataException("Aktuell darf genau ein Content-Flow Inhalt enthalten. Weitere Flow-Slots können bereits gestaltet werden, parallele paginierende Flows folgen in einer späteren Formatstufe.");
|
||||||
|
var primaryFlow = populatedFlows.FirstOrDefault() ?? template.Layout.ContentFlows.FirstOrDefault();
|
||||||
|
|
||||||
|
return Document.Create(document =>
|
||||||
|
{
|
||||||
|
document.Page(page =>
|
||||||
|
{
|
||||||
|
var pageWidth = UnitConverter.Points(template.Layout.Width, template.Layout.Unit);
|
||||||
|
var pageHeight = UnitConverter.Points(template.Layout.Height, template.Layout.Unit);
|
||||||
|
page.Size(pageWidth, pageHeight);
|
||||||
|
page.Margin(0);
|
||||||
|
ConfigurePageCanvas(page.Background(), firstPage, continuationPage, template, values);
|
||||||
|
|
||||||
|
if (primaryFlow is null)
|
||||||
|
{
|
||||||
|
page.Content().Height(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstSlot = FindSlot(firstPage, primaryFlow.Name);
|
||||||
|
var continuationSlot = FindSlot(continuationPage, primaryFlow.Name) ?? firstSlot;
|
||||||
|
if (firstSlot is null)
|
||||||
|
throw new InvalidDataException($"Für content-flow „{primaryFlow.Name}“ fehlt ein flow-slot auf der ersten Seite.");
|
||||||
|
|
||||||
|
if (Math.Abs(firstSlot.X - continuationSlot!.X) > 0.01f ||
|
||||||
|
Math.Abs(firstSlot.Width - continuationSlot.Width) > 0.01f)
|
||||||
|
throw new InvalidDataException($"Der primäre Flow „{primaryFlow.Name}“ muss vorerst auf erster und Folgeseite dieselbe X-Position und Breite besitzen.");
|
||||||
|
|
||||||
|
var left = UnitConverter.Points(firstSlot.X, template.Layout.Unit);
|
||||||
|
var right = UnitConverter.Points(template.Layout.Width - firstSlot.X - firstSlot.Width, template.Layout.Unit);
|
||||||
|
page.MarginLeft(left);
|
||||||
|
page.MarginRight(right);
|
||||||
|
ConfigureVariableVerticalSlot(page, firstSlot, continuationSlot, template.Layout);
|
||||||
|
page.Content().Column(column => RenderFlowColumn(column, primaryFlow.Elements, template, values));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureVariableVerticalSlot(PageDescriptor page, FlowSlotDefinition first,
|
||||||
|
FlowSlotDefinition continuation, TemplateLayout layout)
|
||||||
|
{
|
||||||
|
var firstTop = UnitConverter.Points(first.Y, layout.Unit);
|
||||||
|
var continuationTop = UnitConverter.Points(continuation.Y, layout.Unit);
|
||||||
|
var firstBottom = UnitConverter.Points(layout.Height - first.Y - first.Height, layout.Unit);
|
||||||
|
var continuationBottom = UnitConverter.Points(layout.Height - continuation.Y - continuation.Height, layout.Unit);
|
||||||
|
page.Header().Column(column =>
|
||||||
|
{
|
||||||
|
column.Item().ShowOnce().Height(firstTop);
|
||||||
|
column.Item().SkipOnce().Height(continuationTop);
|
||||||
|
});
|
||||||
|
page.Footer().Column(column =>
|
||||||
|
{
|
||||||
|
column.Item().ShowOnce().Height(firstBottom);
|
||||||
|
column.Item().SkipOnce().Height(continuationBottom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlowSlotDefinition? FindSlot(PageTemplateDefinition page, string name) =>
|
||||||
|
page.FlowSlots.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static void ConfigurePageCanvas(IContainer canvas, PageTemplateDefinition first,
|
||||||
|
PageTemplateDefinition continuation, LoadedTemplate template,
|
||||||
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||||
|
{
|
||||||
|
canvas.Layers(layers =>
|
||||||
|
{
|
||||||
|
layers.PrimaryLayer().Background(Colors.White);
|
||||||
|
foreach (var element in first.Elements)
|
||||||
|
{
|
||||||
|
var current = element;
|
||||||
|
layers.Layer().ShowOnce().Element(root => RenderElement(root, current, template, values));
|
||||||
|
}
|
||||||
|
foreach (var element in continuation.Elements)
|
||||||
|
{
|
||||||
|
var current = element;
|
||||||
|
layers.Layer().SkipOnce().Element(root => RenderElement(root, current, template, values));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderFlowColumn(ColumnDescriptor column, IReadOnlyList<TemplateElement> elements,
|
||||||
|
LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < elements.Count; index++)
|
||||||
|
{
|
||||||
|
var element = elements[index];
|
||||||
|
var gap = UnitConverter.Points(ParseFloat(element.Attributes, "gap", 0), template.Layout.Unit);
|
||||||
|
var keepWithNext = ParseBool(element.Attributes, "keep-with-next") && index + 1 < elements.Count;
|
||||||
|
if (keepWithNext)
|
||||||
|
{
|
||||||
|
var next = elements[++index];
|
||||||
|
column.Item().PaddingTop(gap).PreventPageBreak().Column(group =>
|
||||||
|
{
|
||||||
|
group.Item().Element(container => RenderFlowElement(container, element, template, values));
|
||||||
|
var nextGap = UnitConverter.Points(ParseFloat(next.Attributes, "gap", 0), template.Layout.Unit);
|
||||||
|
group.Item().PaddingTop(nextGap).Element(container => RenderFlowElement(container, next, template, values));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
column.Item().PaddingTop(gap).Element(container => RenderFlowElement(container, element, template, values));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
|
||||||
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||||
|
{
|
||||||
|
switch (element)
|
||||||
|
{
|
||||||
|
case TextElement text:
|
||||||
|
RenderResolvedText(container, text.Content, text.Placeholder, text.Format,
|
||||||
|
values, template.Manifest, text.Attributes);
|
||||||
|
break;
|
||||||
|
case TextBoxElement box:
|
||||||
|
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
|
||||||
|
values, template.Manifest, box.Attributes);
|
||||||
|
break;
|
||||||
|
case FlowBoxElement box:
|
||||||
|
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
|
||||||
|
values, template.Manifest, box.Attributes);
|
||||||
|
break;
|
||||||
|
case DrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
|
||||||
|
DrawingElementRenderer.RenderFixed(
|
||||||
|
container.Width(UnitConverter.Points(drawing.Width, template.Layout.Unit))
|
||||||
|
.Height(UnitConverter.Points(drawing.Height, template.Layout.Unit)),
|
||||||
|
drawingValue, drawing.Width, drawing.Height, template.Layout.Unit);
|
||||||
|
break;
|
||||||
|
case FlowDrawBoxElement drawing when values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue:
|
||||||
|
DrawingElementRenderer.RenderFlow(container, drawingValue,
|
||||||
|
drawing.Width, drawing.Height, template.Layout.Unit);
|
||||||
|
break;
|
||||||
|
case ImageElement image:
|
||||||
|
var imageContainer = container;
|
||||||
|
if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit));
|
||||||
|
if (image.Height > 0) imageContainer = imageContainer.Height(UnitConverter.Points(image.Height, template.Layout.Unit));
|
||||||
|
imageContainer.Image(GetAsset(template, image.Path)).FitArea();
|
||||||
|
break;
|
||||||
|
case TableElement table when values.GetValueOrDefault(table.Placeholder) is TableValue tableValue:
|
||||||
|
TableElementRenderer.Render(container, tableValue, table.Attributes);
|
||||||
|
break;
|
||||||
|
case ChartElement chart when values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue:
|
||||||
|
var chartHeight = UnitConverter.Points(ParseFloat(chart.Attributes, "h", 50), template.Layout.Unit);
|
||||||
|
ChartElementRenderer.Render(container.Height(chartHeight), chartValue, chart.ChartType, chart.Attributes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
|
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
|
||||||
IReadOnlyDictionary<string, PlaceholderValue> values)
|
IReadOnlyDictionary<string, PlaceholderValue> values)
|
||||||
{
|
{
|
||||||
@@ -145,14 +313,19 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
|
|||||||
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
||||||
values, template.Manifest, box.Attributes);
|
values, template.Manifest, box.Attributes);
|
||||||
break;
|
break;
|
||||||
case FlowBoxElement:
|
case FlowBoxElement box:
|
||||||
|
RenderResolvedText(Position(root, box, unit).Shrink(), box.Content, box.Placeholder, box.Format,
|
||||||
|
values, template.Manifest, box.Attributes);
|
||||||
break;
|
break;
|
||||||
case DrawBoxElement drawing:
|
case DrawBoxElement drawing:
|
||||||
if (values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
|
if (values.GetValueOrDefault(drawing.Placeholder) is { } drawingValue)
|
||||||
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), drawingValue,
|
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), drawingValue,
|
||||||
drawing.Width, drawing.Height, unit);
|
drawing.Width, drawing.Height, unit);
|
||||||
break;
|
break;
|
||||||
case FlowDrawBoxElement:
|
case FlowDrawBoxElement drawing:
|
||||||
|
if (values.GetValueOrDefault(drawing.Placeholder) is { } flowDrawingValue)
|
||||||
|
DrawingElementRenderer.RenderFixed(Position(root, drawing, unit), flowDrawingValue,
|
||||||
|
drawing.Width, drawing.Height, unit);
|
||||||
break;
|
break;
|
||||||
case TableElement table:
|
case TableElement table:
|
||||||
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
|
if (values.GetValueOrDefault(table.Placeholder) is TableValue tableValue)
|
||||||
|
|||||||
@@ -1392,6 +1392,18 @@ Zugangsdaten; Schülerdaten, Fehlzeiten und der unverschlüsselte CSV-Report pas
|
|||||||
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
|
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
|
||||||
unentschuldigt übernommen werden; fremde/ganztägige Abwesenheiten erzeugen keine lokale Stunde.
|
unentschuldigt übernommen werden; fremde/ganztägige Abwesenheiten erzeugen keine lokale Stunde.
|
||||||
|
|
||||||
|
**Nachtrag (September 2026, Nutzer-Feedback) — Zeitraum-Datumsfelder ließen sich nicht ändern:**
|
||||||
|
Avalonias WinUI-artiger Spinner-`DatePicker` übernimmt Änderungen im Tag/Monat/Jahr-Flyout nur über
|
||||||
|
einen separaten Häkchen-Button; ein Klick daneben verwirft sie stillschweigend. Alle verbliebenen
|
||||||
|
Zeitraum-Datumsfelder mit diesem `DatePicker` auf `CalendarDatePicker` umgestellt (gleiche
|
||||||
|
`DateTimeOffset?`-Bindung, kein Typwechsel nötig) — bereits das etablierte Muster für frei wählbare
|
||||||
|
Einzeldatumsfelder in dieser Codebasis (`WithdrawStudentDialog`, `CreateLetterDialog`, 7.2.3), dort
|
||||||
|
ohne dieses Problem, da ein Klick auf einen Kalendertag sofort übernimmt statt einen
|
||||||
|
Bestätigungsschritt zu verlangen. Betroffen: [ClassTeacherRegisterView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml),
|
||||||
|
[ClassTeacherAbsencesView.axaml](LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml),
|
||||||
|
[WebUntisTimetableImportDialog.axaml](LehrerApp.Desktop/Views/Planning/WebUntisTimetableImportDialog.axaml)
|
||||||
|
und [WebUntisDocumentationComparisonDialog.axaml](LehrerApp.Desktop/Views/Students/WebUntisDocumentationComparisonDialog.axaml).
|
||||||
|
|
||||||
**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
|
**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
|
||||||
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
|
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
|
||||||
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
|
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Templating-Layoutformat 3
|
||||||
|
|
||||||
|
Layoutformat 3 trennt feste Seitengestaltung von fortlaufendem Dokumentinhalt.
|
||||||
|
|
||||||
|
## Grundaufbau
|
||||||
|
|
||||||
|
```text
|
||||||
|
PAGE 210 297 mm
|
||||||
|
#pragma format-version 3
|
||||||
|
|
||||||
|
#pragma page-template first
|
||||||
|
IMG briefkopf.png 0 0 210 55
|
||||||
|
#pragma flow-slot body x=20 y=90 w=170 h=175
|
||||||
|
#pragma end-page-template
|
||||||
|
|
||||||
|
#pragma page-template continuation
|
||||||
|
IMG folgeseite.png 0 0 210 25
|
||||||
|
#pragma flow-slot body x=20 y=30 w=170 h=235
|
||||||
|
#pragma end-page-template
|
||||||
|
|
||||||
|
#pragma content-flow body
|
||||||
|
TEXTBOX $Brieftext size=11 overflow=continue
|
||||||
|
TEXT "Mit freundlichen Grüßen" gap=8 keep-with-next=true
|
||||||
|
TEXT $LehrerName gap=3 italic=true
|
||||||
|
#pragma end-content-flow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seitentypen
|
||||||
|
|
||||||
|
- `first` ist verpflichtend und gestaltet die erste Seite.
|
||||||
|
- `continuation` gestaltet alle Folgeseiten. Fehlt dieser Typ, wird `first` wiederholt.
|
||||||
|
- Elemente innerhalb eines `page-template` sind absolut positioniert und beeinflussen den Textfluss nicht.
|
||||||
|
- Jede Seitenvorlage beschreibt ausschließlich ihre eigenen Elemente und Slots.
|
||||||
|
|
||||||
|
## Flow-Slots und Content-Flows
|
||||||
|
|
||||||
|
Ein `flow-slot` definiert eine Bounding Box innerhalb einer Seitenvorlage. Ein gleichnamiger
|
||||||
|
`content-flow` liefert den fortlaufenden Inhalt. Ist `continuation` vorhanden, benötigt jeder
|
||||||
|
Content-Flow auf `first` und `continuation` einen gleichnamigen Slot.
|
||||||
|
|
||||||
|
Elemente in einem Content-Flow werden ohne X/Y-Koordinaten angegeben:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TEXT "Absatz" size=11 gap=4
|
||||||
|
TEXTBOX $MehrzeiligerText size=11 overflow=continue
|
||||||
|
TABLE $Zeilen size=9
|
||||||
|
CHART $Werte type=bar h=50
|
||||||
|
IMG unterschrift.png w=45 h=18
|
||||||
|
```
|
||||||
|
|
||||||
|
- `gap` ist der Abstand zum Vorgänger in der Seiteneinheit.
|
||||||
|
- `keep-with-next=true` hält das Element nach Möglichkeit mit seinem Nachfolger zusammen.
|
||||||
|
- Text und Tabellen dürfen automatisch auf Folgeseiten weiterlaufen.
|
||||||
|
|
||||||
|
## Designer
|
||||||
|
|
||||||
|
Der Tab **Seiten & Flows** verwaltet Seitentypen, Content-Flows und Flow-Slots. Der visuelle
|
||||||
|
Editor zeigt den Slot des gewählten Seitentyps violett gestrichelt an. Er kann wie ein anderes
|
||||||
|
Element verschoben und skaliert werden. Die Vorschauseiten-Auswahl zeigt alle tatsächlich aus den
|
||||||
|
Beispieldaten erzeugten Dokumentseiten; **Seitentyp anzeigen** rendert stattdessen die feste
|
||||||
|
Gestaltung der gewählten Seitenvorlage.
|
||||||
|
|
||||||
|
Beim Öffnen eines alten absoluten Layouts bettet der Designer dessen Elemente unverändert in den
|
||||||
|
Seitentyp `first` ein und ergänzt leere `body`-Slots sowie einen leeren Content-Flow. Erst beim
|
||||||
|
anschließenden Speichern wird das migrierte Layout in das Paket geschrieben.
|
||||||
|
|
||||||
|
## Aktuelle Grenzen
|
||||||
|
|
||||||
|
Die erste Renderer-Ausbaustufe unterstützt einen mit Inhalt gefüllten, paginierenden Content-Flow.
|
||||||
|
Weitere Slots und leere Flows können bereits gestaltet werden. Beim primären Flow müssen X-Position
|
||||||
|
und Breite auf erster und Folgeseite noch identisch sein; Y-Position und Höhe dürfen sich unterscheiden.
|
||||||
|
Diese Einschränkung wird beim Rendern mit einer verständlichen Fehlermeldung geprüft.
|
||||||
Binary file not shown.
Reference in New Issue
Block a user