151 lines
7.1 KiB
C#
151 lines
7.1 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>();
|
|
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;
|
|
}
|
|
}
|