This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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>();
|
||||
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 "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 (!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.");
|
||||
|
||||
internal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user