Skip to content

Commit bfb22fc

Browse files
authored
refactor: remove sync pipeline API, make entire render chain async (#6)
Remove TemplatePipeline.Process() and TemplateExpander.Expand() sync methods. All rendering now goes through ProcessAsync/ExpandAsync, fixing ContentElement expansion error "Content element expansion requires async processing". Also eliminates 3-4x redundant template processing in render methods — each async render method now calls ProcessAsync once and passes the pre-processed template through the entire chain. Additional fixes: - Fix missing _defaultRenderOptions in async Render(SKBitmap) overloads - Fix ExprValue.ToString() culture sensitivity (InvariantCulture) - Add regression test for ContentElement through async pipeline - Update 38 files: production code, CLI, and 170+ test methods
1 parent 9b95eb7 commit bfb22fc

38 files changed

Lines changed: 877 additions & 887 deletions

src/FlexRender.Cli/Commands/DebugLayoutCommand.cs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public static Command Create()
6363
/// <param name="verbose">Whether to enable verbose output.</param>
6464
/// <param name="fontsDir">Optional fonts directory.</param>
6565
/// <returns>Exit code (0 for success, non-zero for failure).</returns>
66-
private static Task<int> Execute(
66+
private static async Task<int> Execute(
6767
FileInfo templateFile,
6868
FileInfo? dataFile,
6969
FileInfo? outputFile,
@@ -74,21 +74,21 @@ private static Task<int> Execute(
7474
if (!templateFile.Exists)
7575
{
7676
Console.Error.WriteLine($"Error: Template file not found: {templateFile.FullName}");
77-
return Task.FromResult(1);
77+
return 1;
7878
}
7979

8080
// Validate data file if specified
8181
if (dataFile is not null && !dataFile.Exists)
8282
{
8383
Console.Error.WriteLine($"Error: Data file not found: {dataFile.FullName}");
84-
return Task.FromResult(1);
84+
return 1;
8585
}
8686

8787
// Validate fonts directory if specified
8888
if (fontsDir is not null && !fontsDir.Exists)
8989
{
9090
Console.Error.WriteLine($"Error: Fonts directory not found: {fontsDir.FullName}");
91-
return Task.FromResult(1);
91+
return 1;
9292
}
9393

9494
try
@@ -137,7 +137,7 @@ private static Task<int> Execute(
137137
}
138138

139139
// Compute layout using the same renderer as actual rendering
140-
var root = skiaRender.ComputeLayout(template, templateData);
140+
var root = await skiaRender.ComputeLayout(template, templateData);
141141

142142
// Print registered fonts
143143
Console.WriteLine("Fonts:");
@@ -158,17 +158,17 @@ private static Task<int> Execute(
158158
// Optionally render debug image
159159
if (outputFile is not null)
160160
{
161-
RenderDebugImage(template, root, templateData, outputFile.FullName, skiaRender);
161+
await RenderDebugImage(template, root, templateData, outputFile.FullName, skiaRender);
162162
Console.WriteLine();
163163
Console.WriteLine($"Debug image: {outputFile.FullName}");
164164
}
165165

166-
return Task.FromResult(0);
166+
return 0;
167167
}
168168
catch (TemplateParseException ex)
169169
{
170170
Console.Error.WriteLine($"Template error: {ex.Message}");
171-
return Task.FromResult(1);
171+
return 1;
172172
}
173173
catch (Exception ex)
174174
{
@@ -177,7 +177,7 @@ private static Task<int> Execute(
177177
{
178178
Console.Error.WriteLine(ex.StackTrace);
179179
}
180-
return Task.FromResult(1);
180+
return 1;
181181
}
182182
}
183183

@@ -281,20 +281,22 @@ private static string GetComputedExtra(LayoutNode node)
281281
/// <param name="data">The template data.</param>
282282
/// <param name="outputPath">The output file path.</param>
283283
/// <param name="skiaRender">The SkiaRender instance with fonts already registered.</param>
284-
private static void RenderDebugImage(
284+
private static async Task RenderDebugImage(
285285
Template template,
286286
LayoutNode root,
287287
ObjectValue data,
288288
string outputPath,
289289
FlexRender.Skia.SkiaRender skiaRender)
290290
{
291-
var size = skiaRender.Measure(template, data);
291+
var size = await skiaRender.Measure(template, data);
292292

293293
using var bitmap = new SKBitmap((int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height));
294294
using var canvas = new SKCanvas(bitmap);
295295

296-
// Render template normally
297-
skiaRender.Render(canvas, template, data);
296+
// Render template to PNG and decode back to draw onto debug canvas
297+
var pngBytes = await skiaRender.RenderToPng(template, data);
298+
using var rendered = SKBitmap.Decode(pngBytes);
299+
canvas.DrawBitmap(rendered, 0, 0);
298300

299301
// Draw debug overlay
300302
DrawDebugOverlay(canvas, root, 0, 0, skiaRender.FontManager);

src/FlexRender.Core/Parsing/Ast/ExprValue.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ public override string ToString()
245245
if (IsExpression)
246246
return $"Expr({RawValue})";
247247
if (RawValue is not null)
248-
return $"Raw({RawValue})={Value}";
249-
return $"{Value}";
248+
return string.Create(CultureInfo.InvariantCulture, $"Raw({RawValue})={Value}");
249+
return string.Create(CultureInfo.InvariantCulture, $"{Value}");
250250
}
251251
}

src/FlexRender.Core/TemplateEngine/TemplateExpander.cs

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -77,40 +77,6 @@ public TemplateExpander(ResourceLimits limits, FilterRegistry filterRegistry, Cu
7777
_resourceLoaders = resourceLoaders;
7878
}
7979

80-
/// <summary>
81-
/// Expands EachElement and IfElement instances into concrete elements based on data.
82-
/// Returns a new Template with all control flow elements resolved.
83-
/// </summary>
84-
/// <param name="template">The template containing control flow elements.</param>
85-
/// <param name="data">The data for evaluating conditions and iterating arrays.</param>
86-
/// <returns>A new Template with expanded elements.</returns>
87-
/// <exception cref="ArgumentNullException">Thrown when template or data is null.</exception>
88-
/// <exception cref="TemplateEngineException">Thrown when maximum expansion depth is exceeded.</exception>
89-
public Template Expand(Template template, ObjectValue data)
90-
{
91-
ArgumentNullException.ThrowIfNull(template);
92-
ArgumentNullException.ThrowIfNull(data);
93-
94-
var context = new TemplateContext(data);
95-
var expandedElements = ExpandElements(template.Elements, context, 0, template);
96-
97-
var result = new Template
98-
{
99-
Name = template.Name,
100-
Version = template.Version,
101-
Canvas = template.Canvas,
102-
Elements = expandedElements
103-
};
104-
105-
// Copy fonts
106-
foreach (var font in template.Fonts)
107-
{
108-
result.Fonts[font.Key] = font.Value;
109-
}
110-
111-
return result;
112-
}
113-
11480
/// <summary>
11581
/// Asynchronously expands EachElement and IfElement instances into concrete elements based on data.
11682
/// Returns a new Template with all control flow elements resolved.

src/FlexRender.Core/TemplateEngine/TemplatePipeline.cs

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,6 @@ public TemplatePipeline(TemplateExpander expander, TemplateProcessor templatePro
2525
_templateProcessor = templateProcessor;
2626
}
2727

28-
/// <summary>
29-
/// Processes a template through the full pipeline: Expand, Resolve, Materialize.
30-
/// </summary>
31-
/// <param name="template">The parsed template to process.</param>
32-
/// <param name="data">The data context for expression evaluation.</param>
33-
/// <returns>The expanded and resolved template with all expressions materialized.</returns>
34-
/// <exception cref="ArgumentNullException">Thrown when <paramref name="template"/> or <paramref name="data"/> is null.</exception>
35-
public Template Process(Template template, ObjectValue data)
36-
{
37-
ArgumentNullException.ThrowIfNull(template);
38-
ArgumentNullException.ThrowIfNull(data);
39-
40-
// Phase 1: Expand control flow (#if, #each, table)
41-
var expanded = _expander.Expand(template, data);
42-
43-
// Phase 2: Resolve expressions in all ExprValue properties
44-
ResolveAll(expanded, data);
45-
46-
// Phase 3: Materialize resolved strings into typed values
47-
MaterializeAll(expanded);
48-
49-
return expanded;
50-
}
51-
5228
/// <summary>
5329
/// Asynchronously processes a template through the full pipeline: Expand, Resolve, Materialize.
5430
/// Uses <c>await</c> for the expansion phase to support async content source resolution.

src/FlexRender.ImageSharp.Render/ImageSharpRender.cs

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,7 @@ public async Task RenderToPng(
190190

191191
try
192192
{
193-
using var image = _engine.RenderToImage(
194-
layoutTemplate, effectiveData, _filterRegistry, imageCache, processedTemplate, _contentParserRegistry);
193+
using var image = _engine.RenderToImage(processedTemplate, imageCache);
195194

196195
var encoder = new PngEncoder();
197196
await image.SaveAsync(output, encoder, cancellationToken).ConfigureAwait(false);
@@ -241,8 +240,7 @@ public async Task RenderToJpeg(
241240

242241
try
243242
{
244-
using var image = _engine.RenderToImage(
245-
layoutTemplate, effectiveData, _filterRegistry, imageCache, processedTemplate, _contentParserRegistry);
243+
using var image = _engine.RenderToImage(processedTemplate, imageCache);
246244

247245
var encoder = new JpegEncoder { Quality = effectiveOptions.Quality };
248246
await image.SaveAsync(output, encoder, cancellationToken).ConfigureAwait(false);
@@ -291,8 +289,7 @@ public async Task RenderToBmp(
291289

292290
try
293291
{
294-
using var image = _engine.RenderToImage(
295-
layoutTemplate, effectiveData, _filterRegistry, imageCache, processedTemplate, _contentParserRegistry);
292+
using var image = _engine.RenderToImage(processedTemplate, imageCache);
296293

297294
var encoder = new BmpEncoder { BitsPerPixel = BmpBitsPerPixel.Pixel32 };
298295
await image.SaveAsync(output, encoder, cancellationToken).ConfigureAwait(false);
@@ -339,8 +336,7 @@ public async Task RenderToRaw(
339336

340337
try
341338
{
342-
using var image = _engine.RenderToImage(
343-
layoutTemplate, effectiveData, _filterRegistry, imageCache, processedTemplate, _contentParserRegistry);
339+
using var image = _engine.RenderToImage(processedTemplate, imageCache);
344340

345341
// Write raw RGBA pixel data
346342
var pixelCount = checked(image.Width * image.Height);
@@ -360,26 +356,23 @@ public async Task RenderToRaw(
360356
// ========================================================================
361357

362358
/// <summary>
363-
/// Pre-loads all images referenced in the template using the configured resource loaders.
364-
/// Also returns the processed template so callers can skip redundant expand+preprocess steps.
359+
/// Expands and processes the template asynchronously, then pre-loads all images
360+
/// referenced in it using the configured resource loaders.
365361
/// </summary>
366-
/// <param name="template">The template to scan for image references.</param>
362+
/// <param name="template">The template to process and scan for image references.</param>
367363
/// <param name="data">The data context for expression evaluation.</param>
368364
/// <param name="cancellationToken">Cancellation token for async operations.</param>
369365
/// <returns>
370-
/// A tuple of the processed template and an image cache. The processed template is non-null
371-
/// when resource loaders are configured (since expand+preprocess was already performed).
372-
/// The image cache maps URIs to pre-loaded images, or is <c>null</c> when no images were found.
366+
/// A tuple of the fully processed template and an image cache. The processed template
367+
/// is always non-null. The image cache maps URIs to pre-loaded images, or is <c>null</c>
368+
/// when no images were found or no resource loaders are configured.
373369
/// Caller is responsible for disposing images via <see cref="DisposeImageCache"/>.
374370
/// </returns>
375-
private async Task<(Template? processedTemplate, Dictionary<string, Image<Rgba32>>? imageCache)> PreloadImages(
371+
private async Task<(Template processedTemplate, Dictionary<string, Image<Rgba32>>? imageCache)> PreloadImages(
376372
Template template,
377373
ObjectValue data,
378374
CancellationToken cancellationToken)
379375
{
380-
if (_resourceLoaders.Count == 0)
381-
return (null, null);
382-
383376
// Expand, resolve, and materialize template to resolve expressions in image src attributes
384377
var expander = _filterRegistry is not null
385378
? new TemplateExpander(_limits, _filterRegistry, _contentParserRegistry, _resourceLoaders)
@@ -391,6 +384,9 @@ public async Task RenderToRaw(
391384
var pipeline = new TemplatePipeline(expander, templateProcessor);
392385
var processedTemplate = await pipeline.ProcessAsync(template, data).ConfigureAwait(false);
393386

387+
if (_resourceLoaders.Count == 0)
388+
return (processedTemplate, null);
389+
394390
var uris = ImageSharpRenderingEngine.CollectImageUris(processedTemplate);
395391
if (uris.Count == 0)
396392
return (processedTemplate, null);

src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs

Lines changed: 10 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using FlexRender.Parsing.Ast;
77
using FlexRender.Providers;
88
using FlexRender.Rendering;
9-
using FlexRender.TemplateEngine;
109
using SixLabors.ImageSharp;
1110
using SixLabors.ImageSharp.Drawing;
1211
using SixLabors.ImageSharp.Drawing.Processing;
@@ -67,52 +66,25 @@ internal ImageSharpRenderingEngine(
6766
}
6867

6968
/// <summary>
70-
/// Renders a template to a new Image&lt;Rgba32&gt;.
69+
/// Renders a pre-processed template to a new Image&lt;Rgba32&gt;.
70+
/// The caller must run the template through <c>TemplatePipeline.ProcessAsync</c>
71+
/// before invoking this method.
7172
/// </summary>
72-
/// <param name="template">The template to render.</param>
73-
/// <param name="data">The data context for expression evaluation.</param>
74-
/// <param name="filterRegistry">Optional filter registry.</param>
73+
/// <param name="processedTemplate">
74+
/// A fully expanded and processed template. Must have all expressions resolved
75+
/// and content materialised via <c>TemplatePipeline.ProcessAsync</c>.
76+
/// </param>
7577
/// <param name="imageCache">
7678
/// Optional pre-loaded image cache for HTTP and other async sources.
7779
/// When provided, images are resolved from the cache before falling back to
7880
/// inline loading (file and base64 only).
7981
/// </param>
80-
/// <param name="preprocessedTemplate">
81-
/// Optional pre-processed template from image preloading. When provided, the
82-
/// expand+preprocess steps are skipped to avoid redundant work.
83-
/// </param>
84-
/// <param name="contentParserRegistry">Optional content parser registry for custom content type parsing.</param>
8582
/// <returns>A new image containing the rendered template. Caller owns disposal.</returns>
8683
internal Image<Rgba32> RenderToImage(
87-
Template template,
88-
ObjectValue data,
89-
FilterRegistry? filterRegistry = null,
90-
IReadOnlyDictionary<string, Image<Rgba32>>? imageCache = null,
91-
Template? preprocessedTemplate = null,
92-
ContentParserRegistry? contentParserRegistry = null)
84+
Template processedTemplate,
85+
IReadOnlyDictionary<string, Image<Rgba32>>? imageCache = null)
9386
{
94-
ArgumentNullException.ThrowIfNull(template);
95-
ArgumentNullException.ThrowIfNull(data);
96-
97-
Template processedTemplate;
98-
99-
if (preprocessedTemplate is not null)
100-
{
101-
processedTemplate = preprocessedTemplate;
102-
}
103-
else
104-
{
105-
// Expand, resolve, and materialize template via the Core pipeline
106-
var expander = filterRegistry is not null
107-
? new TemplateExpander(_limits, filterRegistry, contentParserRegistry, _resourceLoaders)
108-
: new TemplateExpander(_limits, contentParserRegistry, _resourceLoaders);
109-
var templateProcessor = filterRegistry is not null
110-
? new TemplateProcessor(_limits, filterRegistry)
111-
: new TemplateProcessor(_limits);
112-
113-
var pipeline = new TemplatePipeline(expander, templateProcessor);
114-
processedTemplate = pipeline.Process(template, data);
115-
}
87+
ArgumentNullException.ThrowIfNull(processedTemplate);
11688

11789
// Register fonts from the processed template (backend-specific)
11890
_preprocessor.RegisterFonts(processedTemplate);

0 commit comments

Comments
 (0)