CI / build-and-test (push) Canceled after 0s
# Conflicts: # LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs # LehrerApp.TemplateDesigner/DesignerViewModel.cs # LehrerApp.TemplateDesigner/MainWindow.axaml # LehrerApp.Templating/LayoutParser.cs # LehrerApp.Templating/QuestTemplateRenderer.cs
307 lines
19 KiB
C#
307 lines
19 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
|
|
namespace LehrerApp.Templating;
|
|
|
|
public sealed class LayoutParser
|
|
{
|
|
public TemplateLayout Parse(string source)
|
|
{
|
|
var issues = new List<ValidationIssue>();
|
|
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;
|
|
var unit = "mm";
|
|
var pageSeen = false;
|
|
var lines = source.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
|
|
|
|
for (var index = 0; index < lines.Length; index++)
|
|
{
|
|
var lineNumber = index + 1;
|
|
var raw = lines[index].Trim();
|
|
if (raw.Length == 0) continue;
|
|
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);
|
|
if (tokens.Count == 0) continue;
|
|
var keyword = tokens[0].ToUpperInvariant();
|
|
if (!pageSeen && keyword != "PAGE")
|
|
throw new FormatException("PAGE muss das erste Statement sein.");
|
|
|
|
TemplateElement? parsedElement = null;
|
|
switch (keyword)
|
|
{
|
|
case "PAGE":
|
|
if (pageSeen) throw new FormatException("PAGE darf nur einmal vorkommen.");
|
|
Require(tokens, 4);
|
|
width = Number(tokens[1]); height = Number(tokens[2]); unit = tokens[3].ToLowerInvariant();
|
|
if (width <= 0 || height <= 0) throw new FormatException("Seitengröße muss positiv sein.");
|
|
if (unit is not ("mm" or "cm" or "pt" or "in"))
|
|
throw new FormatException("Einheit muss mm, cm, pt oder in sein.");
|
|
pageSeen = true;
|
|
break;
|
|
case "BG":
|
|
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":
|
|
var flowImage = currentFlowName is not null;
|
|
Require(tokens, flowImage ? 2 : 6);
|
|
var imageAttributes = Attributes(tokens, flowImage ? 2 : 6);
|
|
if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale);
|
|
parsedElement = flowImage
|
|
? 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;
|
|
case "TEXT":
|
|
var flowText = currentFlowName is not null;
|
|
Require(tokens, flowText ? 2 : 4);
|
|
var textContent = tokens[flowText ? 1 : 3];
|
|
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":
|
|
var flowBox = currentFlowName is not null;
|
|
Require(tokens, flowBox ? 2 : 6);
|
|
var boxContent = tokens[flowBox ? 1 : 5];
|
|
var boxRef = Reference(boxContent);
|
|
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":
|
|
var nestedFlowBox = currentFlowName is not null;
|
|
Require(tokens, nestedFlowBox ? 2 : 6);
|
|
var flowContent = tokens[nestedFlowBox ? 1 : 5];
|
|
var flowRef = Reference(flowContent);
|
|
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":
|
|
var flowDrawing = currentFlowName is not null;
|
|
Require(tokens, flowDrawing ? 2 : 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;
|
|
case "FLOWDRAWBOX":
|
|
var nestedFlowDrawing = currentFlowName is not null;
|
|
Require(tokens, nestedFlowDrawing ? 2 : 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;
|
|
case "TABLE":
|
|
var flowTable = currentFlowName is not null;
|
|
Require(tokens, flowTable ? 2 : 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;
|
|
case "CHART":
|
|
var flowChart = currentFlowName is not null;
|
|
Require(tokens, flowChart ? 2 : 6);
|
|
var attributes = Attributes(tokens, flowChart ? 2 : 6);
|
|
var chartType = attributes.GetValueOrDefault("type", "bar").ToLowerInvariant();
|
|
if (chartType is not ("line" or "bar")) throw new FormatException("CHART type muss line oder bar sein.");
|
|
parsedElement = new ChartElement(lineNumber, flowChart ? 0 : Number(tokens[1]),
|
|
flowChart ? 0 : Number(tokens[2]), flowChart ? 0 : Number(tokens[3]),
|
|
flowChart ? 0 : Number(tokens[4]), RequiredReference(tokens[flowChart ? 1 : 5]), chartType, attributes);
|
|
break;
|
|
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)
|
|
{
|
|
issues.Add(new(ValidationSeverity.Error, $"Zeile {lineNumber}: {ex.Message}", lineNumber));
|
|
}
|
|
}
|
|
|
|
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));
|
|
return new(width, height, unit, elements)
|
|
{
|
|
FormatVersion = formatVersion,
|
|
PageTemplates = pageTemplates,
|
|
ContentFlows = contentFlows,
|
|
};
|
|
}
|
|
|
|
private static (string? Name, string? Format) Reference(string value)
|
|
{
|
|
if (SystemVariables.IsStandalone(value)) return (null, null);
|
|
if (!value.StartsWith('$')) return (null, null);
|
|
var parts = value[1..].Split('|', 2);
|
|
if (string.IsNullOrWhiteSpace(parts[0])) throw new FormatException("Platzhaltername fehlt.");
|
|
return (parts[0], parts.Length == 2 ? parts[1] : null);
|
|
}
|
|
|
|
private static string RequiredReference(string value) => Reference(value).Name
|
|
?? throw new FormatException("Das Element erwartet einen $Platzhalter.");
|
|
|
|
private static float Number(string value) => float.TryParse(value, NumberStyles.Float,
|
|
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)
|
|
{
|
|
var normalized = value.EndsWith('%') ? value[..^1] : value;
|
|
var result = Number(normalized);
|
|
if (result <= 0 || result > 1000)
|
|
throw new FormatException("scale muss zwischen 0 und 1000 Prozent liegen.");
|
|
return result / 100f;
|
|
}
|
|
|
|
private static Dictionary<string, string> Attributes(IReadOnlyList<string> tokens, int start)
|
|
{
|
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
for (var i = start; i < tokens.Count; i++)
|
|
{
|
|
var separator = tokens[i].IndexOf('=');
|
|
if (separator <= 0 || separator == tokens[i].Length - 1)
|
|
throw new FormatException($"Ungültiges Attribut „{tokens[i]}“.");
|
|
result[tokens[i][..separator]] = tokens[i][(separator + 1)..];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static void Require(IReadOnlyCollection<string> tokens, int minimum)
|
|
{
|
|
if (tokens.Count < minimum) throw new FormatException("Zu wenige Argumente.");
|
|
}
|
|
|
|
internal static List<string> Tokenize(string line)
|
|
{
|
|
var tokens = new List<string>();
|
|
var current = new StringBuilder();
|
|
var quoted = false;
|
|
var escaped = false;
|
|
foreach (var character in line)
|
|
{
|
|
if (escaped) { current.Append(character); escaped = false; continue; }
|
|
if (character == '\\' && quoted) { escaped = true; continue; }
|
|
if (character == '"') { quoted = !quoted; continue; }
|
|
if (char.IsWhiteSpace(character) && !quoted)
|
|
{
|
|
if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); }
|
|
}
|
|
else current.Append(character);
|
|
}
|
|
if (quoted) throw new FormatException("Nicht abgeschlossenes Anführungszeichen.");
|
|
if (current.Length > 0) tokens.Add(current.ToString());
|
|
return tokens;
|
|
}
|
|
}
|