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(); 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 || raw.StartsWith('#')) continue; try { 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."); 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": Require(tokens, 2); elements.Add(new BackgroundElement(lineNumber, tokens[1])); break; case "IMG": Require(tokens, 6); var imageAttributes = Attributes(tokens, 6); if (imageAttributes.TryGetValue("scale", out var scale)) Percentage(scale); elements.Add(new ImageElement(lineNumber, tokens[1], Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), Number(tokens[5]), imageAttributes)); break; case "TEXT": Require(tokens, 4); var textRef = Reference(tokens[3]); elements.Add(new TextElement(lineNumber, Number(tokens[1]), Number(tokens[2]), tokens[3], textRef.Name, textRef.Format, Attributes(tokens, 4))); break; case "TEXTBOX": Require(tokens, 6); var boxRef = Reference(tokens[5]); elements.Add(new TextBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), tokens[5], boxRef.Name, boxRef.Format, Attributes(tokens, 6))); break; case "FLOWBOX": Require(tokens, 6); var flowRef = Reference(tokens[5]); elements.Add(new FlowBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), tokens[5], flowRef.Name, flowRef.Format, Attributes(tokens, 6))); break; case "DRAWBOX": Require(tokens, 6); elements.Add(new DrawBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6))); break; case "FLOWDRAWBOX": Require(tokens, 6); elements.Add(new FlowDrawBoxElement(lineNumber, Number(tokens[1]), Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6))); break; case "TABLE": Require(tokens, 6); elements.Add(new TableElement(lineNumber, Number(tokens[1]), Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), Attributes(tokens, 6))); break; case "CHART": Require(tokens, 6); var attributes = Attributes(tokens, 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."); elements.Add(new ChartElement(lineNumber, Number(tokens[1]), Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), chartType, attributes)); break; default: throw new FormatException($"Unbekanntes Element „{tokens[0]}“."); } } catch (FormatException ex) { issues.Add(new(ValidationSeverity.Error, $"Zeile {lineNumber}: {ex.Message}", lineNumber)); } } if (!pageSeen) issues.Add(new(ValidationSeverity.Error, "PAGE fehlt.")); if (issues.Count > 0) throw new TemplateValidationException(new(issues)); return new(width, height, unit, elements); } 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."); 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; } }