TemplateDesigner: Redesign des TemplateSkripts

This commit is contained in:
2026-09-01 10:07:25 +02:00
parent 95e3f677e4
commit b2d0ccd30d
12 changed files with 807 additions and 55 deletions
+140 -23
View File
@@ -9,6 +9,12 @@ public sealed class LayoutParser
{
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;
@@ -18,15 +24,75 @@ public sealed class LayoutParser
{
var lineNumber = index + 1;
var raw = lines[index].Trim();
if (raw.Length == 0 || raw.StartsWith('#')) continue;
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":
@@ -39,40 +105,64 @@ public sealed class LayoutParser
pageSeen = true;
break;
case "BG":
Require(tokens, 2); elements.Add(new BackgroundElement(lineNumber, tokens[1])); break;
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":
Require(tokens, 6);
var imageAttributes = Attributes(tokens, 6);
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);
elements.Add(new ImageElement(lineNumber, tokens[1],
Number(tokens[2]), Number(tokens[3]), Number(tokens[4]), Number(tokens[5]), imageAttributes));
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":
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;
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":
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;
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":
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)));
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":
Require(tokens, 6);
var attributes = Attributes(tokens, 6);
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.");
elements.Add(new ChartElement(lineNumber, Number(tokens[1]), Number(tokens[2]),
Number(tokens[3]), Number(tokens[4]), RequiredReference(tokens[5]), chartType, attributes));
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)
{
@@ -81,8 +171,32 @@ public sealed class LayoutParser
}
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);
return new(width, height, unit, elements)
{
FormatVersion = formatVersion,
PageTemplates = pageTemplates,
ContentFlows = contentFlows,
};
}
private static (string? Name, string? Format) Reference(string value)
@@ -99,6 +213,9 @@ public sealed class LayoutParser
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;
public static float Percentage(string value)
{
var normalized = value.EndsWith('%') ? value[..^1] : value;
+12 -1
View File
@@ -61,6 +61,11 @@ public sealed record ChartElement(int Line, float X, float Y, float Width, float
string Placeholder, string ChartType, IReadOnlyDictionary<string, string> Attributes)
: TemplateElement(Line, X, Y, Width, Height, Attributes);
public sealed record FlowSlotDefinition(int Line, string Name, float X, float Y, float Width, float Height);
public sealed record PageTemplateDefinition(string Name, IReadOnlyList<TemplateElement> Elements,
IReadOnlyList<FlowSlotDefinition> FlowSlots);
public sealed record ContentFlowDefinition(string Name, IReadOnlyList<TemplateElement> Elements);
internal static class EmptyAttributes
{
public static readonly IReadOnlyDictionary<string, string> Value =
@@ -68,7 +73,13 @@ internal static class EmptyAttributes
}
public sealed record TemplateLayout(float Width, float Height, string Unit,
IReadOnlyList<TemplateElement> Elements);
IReadOnlyList<TemplateElement> Elements)
{
public int FormatVersion { get; init; } = 1;
public IReadOnlyList<PageTemplateDefinition> PageTemplates { get; init; } = [];
public IReadOnlyList<ContentFlowDefinition> ContentFlows { get; init; } = [];
public bool UsesPageTemplates => PageTemplates.Count > 0 || ContentFlows.Count > 0;
}
public sealed record LoadedTemplate(TemplateManifest Manifest, TemplateLayout Layout,
IReadOnlyDictionary<string, byte[]> Assets, string SourceName = "");
+152 -1
View File
@@ -15,9 +15,14 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
BuildDocument(template, ValidateData(template, data)).GeneratePdf();
public byte[] RenderFirstPageToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
{
return RenderPagesToPng(template, data, dpi).First();
}
public IReadOnlyList<byte[]> RenderPagesToPng(LoadedTemplate template, ITemplateDataProvider data, int dpi = 120)
{
var settings = new ImageGenerationSettings { ImageFormat = ImageFormat.Png, RasterDpi = dpi };
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).First();
return BuildDocument(template, ValidateData(template, data)).GenerateImages(settings).ToList();
}
private static IReadOnlyDictionary<string, PlaceholderValue> ValidateData(LoadedTemplate template,
@@ -30,6 +35,11 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
}
private static IDocument BuildDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => template.Layout.UsesPageTemplates
? BuildFlowDocument(template, values)
: BuildLegacyDocument(template, values);
private static IDocument BuildLegacyDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values) => Document.Create(document =>
{
document.Page(page =>
@@ -50,6 +60,147 @@ public sealed class QuestTemplateRenderer : ITemplateRenderer, ITemplatePreviewR
});
});
private static IDocument BuildFlowDocument(LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
var firstPage = template.Layout.PageTemplates.First(x =>
x.Name.Equals("first", StringComparison.OrdinalIgnoreCase));
var continuationPage = template.Layout.PageTemplates.FirstOrDefault(x =>
x.Name.Equals("continuation", StringComparison.OrdinalIgnoreCase)) ?? firstPage;
var populatedFlows = template.Layout.ContentFlows.Where(x => x.Elements.Count > 0).ToList();
if (populatedFlows.Count > 1)
throw new InvalidDataException("Aktuell darf genau ein Content-Flow Inhalt enthalten. Weitere Flow-Slots können bereits gestaltet werden, parallele paginierende Flows folgen in einer späteren Formatstufe.");
var primaryFlow = populatedFlows.FirstOrDefault() ?? template.Layout.ContentFlows.FirstOrDefault();
return Document.Create(document =>
{
document.Page(page =>
{
var pageWidth = UnitConverter.Points(template.Layout.Width, template.Layout.Unit);
var pageHeight = UnitConverter.Points(template.Layout.Height, template.Layout.Unit);
page.Size(pageWidth, pageHeight);
page.Margin(0);
ConfigurePageCanvas(page.Background(), firstPage, continuationPage, template, values);
if (primaryFlow is null)
{
page.Content().Height(1);
return;
}
var firstSlot = FindSlot(firstPage, primaryFlow.Name);
var continuationSlot = FindSlot(continuationPage, primaryFlow.Name) ?? firstSlot;
if (firstSlot is null)
throw new InvalidDataException($"Für content-flow „{primaryFlow.Name}“ fehlt ein flow-slot auf der ersten Seite.");
if (Math.Abs(firstSlot.X - continuationSlot!.X) > 0.01f ||
Math.Abs(firstSlot.Width - continuationSlot.Width) > 0.01f)
throw new InvalidDataException($"Der primäre Flow „{primaryFlow.Name}“ muss vorerst auf erster und Folgeseite dieselbe X-Position und Breite besitzen.");
var left = UnitConverter.Points(firstSlot.X, template.Layout.Unit);
var right = UnitConverter.Points(template.Layout.Width - firstSlot.X - firstSlot.Width, template.Layout.Unit);
page.MarginLeft(left);
page.MarginRight(right);
ConfigureVariableVerticalSlot(page, firstSlot, continuationSlot, template.Layout);
page.Content().Column(column => RenderFlowColumn(column, primaryFlow.Elements, template, values));
});
});
}
private static void ConfigureVariableVerticalSlot(PageDescriptor page, FlowSlotDefinition first,
FlowSlotDefinition continuation, TemplateLayout layout)
{
var firstTop = UnitConverter.Points(first.Y, layout.Unit);
var continuationTop = UnitConverter.Points(continuation.Y, layout.Unit);
var firstBottom = UnitConverter.Points(layout.Height - first.Y - first.Height, layout.Unit);
var continuationBottom = UnitConverter.Points(layout.Height - continuation.Y - continuation.Height, layout.Unit);
page.Header().Column(column =>
{
column.Item().ShowOnce().Height(firstTop);
column.Item().SkipOnce().Height(continuationTop);
});
page.Footer().Column(column =>
{
column.Item().ShowOnce().Height(firstBottom);
column.Item().SkipOnce().Height(continuationBottom);
});
}
private static FlowSlotDefinition? FindSlot(PageTemplateDefinition page, string name) =>
page.FlowSlots.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
private static void ConfigurePageCanvas(IContainer canvas, PageTemplateDefinition first,
PageTemplateDefinition continuation, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
canvas.Layers(layers =>
{
layers.PrimaryLayer().Background(Colors.White);
foreach (var element in first.Elements)
{
var current = element;
layers.Layer().ShowOnce().Element(root => RenderElement(root, current, template, values));
}
foreach (var element in continuation.Elements)
{
var current = element;
layers.Layer().SkipOnce().Element(root => RenderElement(root, current, template, values));
}
});
}
private static void RenderFlowColumn(ColumnDescriptor column, IReadOnlyList<TemplateElement> elements,
LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderValue> values)
{
for (var index = 0; index < elements.Count; index++)
{
var element = elements[index];
var gap = UnitConverter.Points(ParseFloat(element.Attributes, "gap", 0), template.Layout.Unit);
var keepWithNext = ParseBool(element.Attributes, "keep-with-next") && index + 1 < elements.Count;
if (keepWithNext)
{
var next = elements[++index];
column.Item().PaddingTop(gap).PreventPageBreak().Column(group =>
{
group.Item().Element(container => RenderFlowElement(container, element, template, values));
var nextGap = UnitConverter.Points(ParseFloat(next.Attributes, "gap", 0), template.Layout.Unit);
group.Item().PaddingTop(nextGap).Element(container => RenderFlowElement(container, next, template, values));
});
}
else
column.Item().PaddingTop(gap).Element(container => RenderFlowElement(container, element, template, values));
}
}
private static void RenderFlowElement(IContainer container, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{
switch (element)
{
case TextElement text:
RenderResolvedText(container, text.Content, text.Placeholder, text.Format,
values, template.Manifest, text.Attributes);
break;
case TextBoxElement box:
RenderResolvedText(container, box.Content, box.Placeholder, box.Format,
values, template.Manifest, box.Attributes);
break;
case ImageElement image:
var imageContainer = container;
if (image.Width > 0) imageContainer = imageContainer.Width(UnitConverter.Points(image.Width, template.Layout.Unit));
if (image.Height > 0) imageContainer = imageContainer.Height(UnitConverter.Points(image.Height, template.Layout.Unit));
imageContainer.Image(GetAsset(template, image.Path)).FitArea();
break;
case TableElement table when values.GetValueOrDefault(table.Placeholder) is TableValue tableValue:
TableElementRenderer.Render(container, tableValue, table.Attributes);
break;
case ChartElement chart when values.GetValueOrDefault(chart.Placeholder) is ChartValue chartValue:
var chartHeight = UnitConverter.Points(ParseFloat(chart.Attributes, "h", 50), template.Layout.Unit);
ChartElementRenderer.Render(container.Height(chartHeight), chartValue, chart.ChartType, chart.Attributes);
break;
}
}
private static void RenderElement(IContainer root, TemplateElement element, LoadedTemplate template,
IReadOnlyDictionary<string, PlaceholderValue> values)
{