Skip to content

Commit 4046097

Browse files
committed
feat(ast): add ExprValue<T> struct for universal property type support
feat: introduce ExprValue<T> universal property type and TemplatePipeline Centralize template expression resolution from 3 duplicated backend preprocessors into a single Core pipeline. All element properties now use ExprValue<T> which can hold either a typed literal or a raw expression string, enabling expression support for non-string types (int, float, bool, enum) that were previously silently lost. Key changes: - Add ExprValue<T> readonly struct with Resolve/Materialize pipeline - Add TemplatePipeline orchestrating Expand -> Resolve -> Materialize - Migrate all properties across 9 element types to ExprValue<T> - Wire TemplatePipeline into Skia, ImageSharp, and SVG backends - Strip backend preprocessors to font-only (delete SVG preprocessor) - Add 38 new tests for ExprValue and TemplatePipeline 82 files changed, 5095 tests passing. test: add ExprValue integration tests and expression-aware YAML parsing Add YamlPropertyHelpers with expression detection for typed properties (float, int, bool, enum) — preserves {{expressions}} instead of losing them via TryParse. Update ElementParsers to use new helpers. Add 19 integration tests covering expression support across all types. docs: update wiki for ExprValue expression support in typed properties Update Template-Expressions: add "Expressions in Typed Properties" section, update processing order from 5 to 6 steps (Parse → Expand → Resolve → Materialize → Layout → Render). Update Element-Reference with expression support note on Common Properties section.
1 parent 3c29392 commit 4046097

88 files changed

Lines changed: 2970 additions & 1695 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/wiki/Element-Reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ For rendering options (antialiasing, format settings), see [[Render-Options]].
1414

1515
All 10 element types (`flex`, `text`, `image`, `svg`, `qr`, `barcode`, `separator`, `table`, `each`, `if`) inherit these properties from the base `TemplateElement` class. You can use any of them on any element.
1616

17+
> **Expression support:** All properties on all element types accept `{{expressions}}`. This includes typed properties like `opacity` (float), `grow`/`shrink` (float), `order` (int), `wrap` (bool on text, FlexWrap on flex), and enum properties like `display`, `position`, `align`. See [[Template-Expressions]] for details.
18+
1719
### Size Properties
1820

1921
Control the explicit dimensions and constraints of an element. All size properties accept values in `px`, `%`, `em`, or `auto`. Plain numbers without a suffix are treated as pixels.

docs/wiki/Template-Expressions.md

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Template Expressions
22

3-
FlexRender provides a template engine with variable substitution, loops, and conditionals. Expressions are processed in two layers:
3+
FlexRender provides a template engine with variable substitution, loops, and conditionals. Expressions are processed in three phases:
44

55
1. **AST-level** (`TemplateExpander`) -- expands `type: each` and `type: if` elements into concrete elements based on data. This enables template caching.
6-
2. **Inline** (`TemplateProcessor`) -- resolves `{{variable}}` expressions in element property values after expansion.
6+
2. **Inline** (`TemplatePipeline`) -- resolves `{{variable}}` expressions in all element property values after expansion.
7+
3. **Materialization** -- resolved strings are parsed into their target types (float, int, bool, enum). This allows expressions to work in all property types, not just strings.
78

89
## Variable Substitution
910

@@ -27,7 +28,7 @@ Use `{{variable}}` syntax to insert data values into text and properties:
2728
content: "{{orders[0].items[2].name}}"
2829
```
2930
30-
Variables can be used in most string properties: `content`, `data`, `src`, `color`, and others.
31+
Variables can be used in **all** element properties -- including typed properties like numbers (`opacity`, `maxLines`, `size`), booleans (`wrap`, `showText`), and enums (`align`, `display`, `position`). When a typed property contains `{{`, the value is preserved as an expression during parsing, resolved at render time, and then parsed into the target type.
3132

3233
## Inline Expressions
3334

@@ -762,17 +763,68 @@ The `condition` field supports inline expressions with filters. This enables cas
762763

763764
---
764765

766+
## Expressions in Typed Properties
767+
768+
All element properties accept `{{expressions}}`, including typed properties like floats, integers, booleans, and enums. This enables fully data-driven templates where any aspect of the layout can be controlled by data.
769+
770+
```yaml
771+
# Expressions in numeric properties
772+
- type: text
773+
content: "Dynamic opacity"
774+
opacity: "{{theme.textOpacity}}"
775+
maxLines: "{{layout.maxLines}}"
776+
777+
# Expressions in boolean properties
778+
- type: barcode
779+
data: "{{product.sku}}"
780+
showText: "{{settings.showBarcodeText}}"
781+
782+
# Expressions in enum properties
783+
- type: text
784+
content: "Dynamic alignment"
785+
align: "{{theme.alignment}}"
786+
787+
# Expressions in size properties
788+
- type: qr
789+
data: "{{payment.url}}"
790+
size: "{{layout.qrSize}}"
791+
```
792+
793+
How typed expressions work:
794+
795+
1. When a typed property contains `{{`, the parser preserves the raw string as an `ExprValue<T>` expression instead of parsing it immediately
796+
2. After template expansion, the expression is resolved to a concrete string using the data context
797+
3. The resolved string is then parsed into the target type (e.g., `"0.5"` becomes `float 0.5`, `"true"` becomes `bool true`, `"center"` becomes `TextAlign.Center`)
798+
4. If parsing fails, the default value for that type is used (e.g., `1.0` for opacity, `null` for nullable properties)
799+
800+
This works with all expression features -- arithmetic, filters, conditionals, and null coalescing:
801+
802+
```yaml
803+
# Computed opacity with fallback
804+
- type: text
805+
content: "Styled text"
806+
opacity: "{{theme.opacity ?? 1}}"
807+
808+
# Conditional boolean via expression
809+
- type: barcode
810+
data: "{{sku}}"
811+
showText: "{{#if printMode}}true{{else}}false{{/if}}"
812+
```
813+
814+
---
815+
765816
## Processing Order
766817

767818
Understanding the processing order helps with debugging:
768819

769-
1. **Parse** -- YAML is parsed into an AST (Template with CanvasSettings + TemplateElement tree)
820+
1. **Parse** -- YAML is parsed into an AST. Typed properties containing `{{` are preserved as expressions
770821
2. **Expand** -- `type: each` and `type: if` elements are expanded based on data
771-
3. **Process** -- `{{variable}}` expressions are resolved in element properties
772-
4. **Layout** -- the flexbox engine computes positions and sizes
773-
5. **Render** -- elements are drawn to the output image
822+
3. **Resolve** -- `{{variable}}` expressions are resolved to concrete strings in all properties
823+
4. **Materialize** -- resolved strings are parsed into typed values (float, int, bool, enum)
824+
5. **Layout** -- the flexbox engine computes positions and sizes
825+
6. **Render** -- elements are drawn to the output image
774826

775-
Template caching works because steps 1 (parse) and 2-5 (expand/process/layout/render) are separate. Parse once, then render many times with different data.
827+
Template caching works because step 1 (parse) is separate from steps 2-6 (expand/resolve/materialize/layout/render). Parse once, then process with different data for each render.
776828

777829
## See Also
778830

examples/AstRenderExample/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
Console.WriteLine("=============================");
8888
Console.WriteLine();
8989
Console.WriteLine("Building template from code (no YAML)...");
90-
Console.WriteLine($" Canvas: {template.Canvas.Width}px wide, background {template.Canvas.Background}");
90+
Console.WriteLine($" Canvas: {template.Canvas.Width}px wide, background {template.Canvas.Background.Value}");
9191
Console.WriteLine($" Elements: {template.Elements.Count}");
9292
Console.WriteLine();
9393

src/FlexRender.Barcode.ImageSharp.Render/Providers/BarcodeImageSharpProvider.cs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public Image<Rgba32> GenerateImage(BarcodeElement element, int width, int height
3333
{
3434
ArgumentNullException.ThrowIfNull(element);
3535

36-
if (string.IsNullOrEmpty(element.Data))
36+
if (string.IsNullOrEmpty(element.Data.Value))
3737
{
3838
throw new ArgumentException("Barcode data cannot be empty.", nameof(element));
3939
}
@@ -43,26 +43,26 @@ public Image<Rgba32> GenerateImage(BarcodeElement element, int width, int height
4343
throw new ArgumentException("Barcode dimensions must be positive.");
4444
}
4545

46-
return element.Format switch
46+
return element.Format.Value switch
4747
{
4848
BarcodeFormat.Code128 => GenerateCode128(element, width, height),
49-
_ => throw new NotSupportedException($"Barcode format '{element.Format}' is not yet supported.")
49+
_ => throw new NotSupportedException($"Barcode format '{element.Format.Value}' is not yet supported.")
5050
};
5151
}
5252

5353
private static Image<Rgba32> GenerateCode128(BarcodeElement element, int targetWidth, int targetHeight)
5454
{
55-
var pattern = Code128Encoding.BuildPattern(element.Data);
55+
var pattern = Code128Encoding.BuildPattern(element.Data.Value);
5656

5757
var totalUnits = pattern.Length;
5858
var barWidth = targetWidth / (float)totalUnits;
59-
var barcodeHeight = element.ShowText
59+
var barcodeHeight = element.ShowText.Value
6060
? targetHeight - TextHeight - TextPadding
6161
: targetHeight;
6262

63-
var foreground = ParseColor(element.Foreground, Color.Black);
64-
var background = element.Background is not null
65-
? ParseColor(element.Background, Color.Transparent)
63+
var foreground = ParseColor(element.Foreground.Value, Color.Black);
64+
var background = element.Background.Value is not null
65+
? ParseColor(element.Background.Value, Color.Transparent)
6666
: Color.Transparent;
6767

6868
var image = new Image<Rgba32>(targetWidth, targetHeight);
@@ -80,7 +80,7 @@ private static Image<Rgba32> GenerateCode128(BarcodeElement element, int targetW
8080
x += barWidth;
8181
}
8282

83-
if (element.ShowText)
83+
if (element.ShowText.Value)
8484
{
8585
var font = ResolveFont(TextHeight - 2);
8686
var textY = barcodeHeight + TextPadding;
@@ -90,7 +90,7 @@ private static Image<Rgba32> GenerateCode128(BarcodeElement element, int targetW
9090
HorizontalAlignment = HorizontalAlignment.Center,
9191
VerticalAlignment = VerticalAlignment.Top
9292
};
93-
ctx.DrawText(options, element.Data, foreground);
93+
ctx.DrawText(options, element.Data.Value, foreground);
9494
}
9595
});
9696

src/FlexRender.Barcode.Skia.Render/Providers/BarcodeProvider.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,24 @@ public static SKBitmap Generate(BarcodeElement element, int? layoutWidth, int? l
6464
{
6565
ArgumentNullException.ThrowIfNull(element);
6666

67-
if (string.IsNullOrEmpty(element.Data))
67+
if (string.IsNullOrEmpty(element.Data.Value))
6868
{
6969
throw new ArgumentException("Barcode data cannot be empty.", nameof(element));
7070
}
7171

7272
// Priority order: layout dimensions > element dimensions > defaults (200x80)
73-
var targetWidth = layoutWidth ?? element.BarcodeWidth ?? 200;
74-
var targetHeight = layoutHeight ?? element.BarcodeHeight ?? 80;
73+
var targetWidth = layoutWidth ?? element.BarcodeWidth.Value ?? 200;
74+
var targetHeight = layoutHeight ?? element.BarcodeHeight.Value ?? 80;
7575

7676
if (targetWidth <= 0 || targetHeight <= 0)
7777
{
7878
throw new ArgumentException("Barcode dimensions must be positive.", nameof(element));
7979
}
8080

81-
return element.Format switch
81+
return element.Format.Value switch
8282
{
8383
BarcodeFormat.Code128 => GenerateCode128(element, targetWidth, targetHeight),
84-
_ => throw new NotSupportedException($"Barcode format '{element.Format}' is not yet supported.")
84+
_ => throw new NotSupportedException($"Barcode format '{element.Format.Value}' is not yet supported.")
8585
};
8686
}
8787

@@ -107,21 +107,21 @@ private static SKBitmap GenerateBitmap(BarcodeElement element, int width, int he
107107
/// <returns>A bitmap containing the rendered Code 128 barcode.</returns>
108108
private static SKBitmap GenerateCode128(BarcodeElement element, int targetWidth, int targetHeight)
109109
{
110-
var pattern = Code128Encoding.BuildPattern(element.Data);
110+
var pattern = Code128Encoding.BuildPattern(element.Data.Value);
111111

112112
// Calculate bar dimensions
113113
var totalUnits = pattern.Length;
114114
var barWidth = targetWidth / (float)totalUnits;
115-
var barcodeHeight = element.ShowText
115+
var barcodeHeight = element.ShowText.Value
116116
? targetHeight - TextHeight - TextPadding
117117
: targetHeight;
118118

119119
var bitmap = new SKBitmap(targetWidth, targetHeight);
120120
using var canvas = new SKCanvas(bitmap);
121121

122-
var foreground = ColorParser.Parse(element.Foreground);
123-
var background = element.Background is not null
124-
? ColorParser.Parse(element.Background)
122+
var foreground = ColorParser.Parse(element.Foreground.Value);
123+
var background = element.Background.Value is not null
124+
? ColorParser.Parse(element.Background.Value)
125125
: SKColors.Transparent;
126126

127127
// Fill background
@@ -146,7 +146,7 @@ private static SKBitmap GenerateCode128(BarcodeElement element, int targetWidth,
146146
}
147147

148148
// Draw text if enabled
149-
if (element.ShowText)
149+
if (element.ShowText.Value)
150150
{
151151
using var typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Normal) ?? SKTypeface.Default;
152152
using var textFont = new SKFont(typeface, TextHeight - 2)
@@ -160,7 +160,7 @@ private static SKBitmap GenerateCode128(BarcodeElement element, int targetWidth,
160160
};
161161

162162
var textY = barcodeHeight + TextPadding + TextHeight - 2;
163-
canvas.DrawText(element.Data, targetWidth / 2f, textY, SKTextAlign.Center, textFont, textPaint);
163+
canvas.DrawText(element.Data.Value, targetWidth / 2f, textY, SKTextAlign.Center, textFont, textPaint);
164164
}
165165

166166
return bitmap;

src/FlexRender.Barcode.Svg.Render/Providers/BarcodeSvgProvider.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,27 +36,27 @@ public string GenerateSvgContent(BarcodeElement element, float width, float heig
3636
{
3737
ArgumentNullException.ThrowIfNull(element);
3838

39-
if (string.IsNullOrEmpty(element.Data))
39+
if (string.IsNullOrEmpty(element.Data.Value))
4040
{
4141
throw new ArgumentException("Barcode data cannot be empty.", nameof(element));
4242
}
4343

44-
return element.Format switch
44+
return element.Format.Value switch
4545
{
4646
BarcodeFormat.Code128 => GenerateCode128Svg(element, width, height),
47-
_ => throw new NotSupportedException($"Barcode format '{element.Format}' is not yet supported.")
47+
_ => throw new NotSupportedException($"Barcode format '{element.Format.Value}' is not yet supported.")
4848
};
4949
}
5050

5151
private static string GenerateCode128Svg(BarcodeElement element, float width, float height)
5252
{
53-
var pattern = Code128Encoding.BuildPattern(element.Data);
53+
var pattern = Code128Encoding.BuildPattern(element.Data.Value);
5454

5555
var totalUnits = pattern.Length;
5656
var barWidth = width / totalUnits;
5757

58-
var foreground = element.Foreground;
59-
var background = element.Background;
58+
var foreground = element.Foreground.Value;
59+
var background = element.Background.Value;
6060

6161
var sb = new StringBuilder(512);
6262
sb.Append("<g>");

src/FlexRender.Cli/Commands/DebugLayoutCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ private static string GetElementExtra(TemplateElement element)
197197
return element switch
198198
{
199199
FlexElement f => $" [{f.Direction.ToString().ToLowerInvariant()}]",
200-
TextElement t => $" \"{Truncate(t.Content, 30)}\"",
200+
TextElement t => $" \"{Truncate(t.Content.Value, 30)}\"",
201201
QrElement => " [qr]",
202202
BarcodeElement => " [barcode]",
203203
ImageElement => " [image]",

src/FlexRender.Cli/Commands/InfoCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ private static void PrintTemplateInfo(Template template, FileInfo templateFile)
7575
Console.WriteLine($" Fixed dimension: {template.Canvas.Fixed}");
7676
Console.WriteLine($" Width: {template.Canvas.Width}px");
7777
Console.WriteLine($" Height: {template.Canvas.Height}px");
78-
Console.WriteLine($" Background: {template.Canvas.Background}");
78+
Console.WriteLine($" Background: {template.Canvas.Background.Value}");
7979
Console.WriteLine();
8080

8181
Console.WriteLine("Elements:");
@@ -129,7 +129,7 @@ private static IEnumerable<string> ExtractVariables(Template template)
129129
{
130130
if (element is TextElement textElement)
131131
{
132-
var matches = pattern.Matches(textElement.Content);
132+
var matches = pattern.Matches(textElement.Content.Value);
133133
foreach (Match match in matches)
134134
{
135135
variables.Add(match.Groups[1].Value.Trim());

src/FlexRender.Core/Layout/ApproximateTextShaper.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
3333
{
3434
ArgumentNullException.ThrowIfNull(element);
3535

36-
if (string.IsNullOrEmpty(element.Content))
36+
if (string.IsNullOrEmpty(element.Content.Value))
3737
{
3838
return new TextShapingResult(
3939
Array.Empty<string>(),
@@ -43,16 +43,16 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
4343

4444
var charWidth = fontSize * CharWidthFactor;
4545
var lineHeight = LineHeightResolver.Resolve(
46-
element.LineHeight,
46+
element.LineHeight.Value,
4747
fontSize,
4848
fontSize * DefaultLineHeightMultiplier);
4949

50-
var effectiveMaxWidth = element.Overflow == TextOverflow.Visible && !element.Wrap
50+
var effectiveMaxWidth = element.Overflow.Value == TextOverflow.Visible && !element.Wrap.Value
5151
? float.MaxValue
5252
: maxWidth;
5353

54-
var lines = GetLines(element.Content, element.Wrap, effectiveMaxWidth, charWidth,
55-
element.MaxLines, element.Overflow);
54+
var lines = GetLines(element.Content.Value, element.Wrap.Value, effectiveMaxWidth, charWidth,
55+
element.MaxLines.Value, element.Overflow.Value);
5656

5757
if (lines.Count == 0)
5858
{

0 commit comments

Comments
 (0)