using System.Globalization; using System.Text; namespace LehrerApp.Templating; public sealed class LayoutParser { public TemplateLayout Parse(string source) { var issues = new List(); var elements = new List(); var pageTemplates = new List(); var contentFlows = new List(); string? currentPageName = null, currentFlowName = null; List? currentPageElements = null, currentFlowElements = null; List? 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 "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 (!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 attributes, string name) => attributes.TryGetValue(name, out var value) ? Number(value) : 0; 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 Attributes(IReadOnlyList tokens, int start) { var result = new Dictionary(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 tokens, int minimum) { if (tokens.Count < minimum) throw new FormatException("Zu wenige Argumente."); } internal static List Tokenize(string line) { var tokens = new List(); 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; } }