diff --git a/AGENTS.md b/AGENTS.md index 5f6b86a..3879120 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,7 +169,7 @@ byte[] png = await render.Render(_templates["receipt"], data); | Configuration | `FlexRenderBuilder`, `SkiaBuilder`, `FlexRenderOptions`, `ResourceLimits` | | Abstractions | `IFlexRender`, `IResourceLoader` | | Parsing | `TemplateParser`, `Template`, `CanvasSettings`, `TextElement`, `FlexElement`, `QrElement`, `BarcodeElement`, `ImageElement`, `SeparatorElement`, `TableElement`, `TableColumn`, `TableRow`, `EachElement`, `IfElement`, `ContentElement` | -| Template Engine | `TemplateExpander`, `TemplateProcessor`, `ExpressionLexer`, `ExpressionEvaluator`, `TemplateContext`, `InlineExpressionParser`, `InlineExpressionEvaluator`, `FilterRegistry`, `ITemplateFilter`, `ContentSourceResolver` | +| Template Engine | `TemplateExpander`, `TemplateProcessor`, `ExpressionLexer`, `ExpressionEvaluator`, `TemplateContext`, `InlineExpressionParser`, `InlineExpressionEvaluator`, `FilterRegistry`, `ITemplateFilter` | | Layout | `LayoutEngine`, `LayoutNode`, `LayoutContext`, `LayoutSize`, `IntrinsicSize`, `Unit`, `UnitParser`, `MarginValue`, `MarginValues`, `PaddingParser.ParseMargin` | | Rendering (Skia) | `SkiaRender` (IFlexRender impl), `SkiaRenderer`, `TextRenderer`, `FontManager`, `ColorParser`, `RotationHelper`, `BmpEncoder`, `BoxShadowParser`, `GradientParser` | | Rendering (ImageSharp) | `ImageSharpRender` (IFlexRender impl), `ImageSharpRenderingEngine`, `ImageSharpTextRenderer`, `ImageSharpFontManager` | @@ -177,7 +177,7 @@ byte[] png = await render.Render(_templates["receipt"], data); | Loaders | `FileResourceLoader`, `Base64ResourceLoader`, `EmbeddedResourceLoader`, `HttpResourceLoader` | | DI | `ServiceCollectionExtensions.AddFlexRender()` | | Values | `TemplateValue` (abstract), `StringValue`, `NumberValue`, `BoolValue`, `NullValue`, `ArrayValue`, `ObjectValue` | -| Content Parsers | `IContentParser`, `IBinaryContentParser`, `ContentParserRegistry`, `ContentSourceResolver`, `NdcContentParser` | +| Content Parsers | `IContentParser`, `IBinaryContentParser`, `ContentParserRegistry`, `NdcContentParser` | ## Coding Conventions @@ -409,9 +409,10 @@ var sb = new StringBuilder(estimatedCapacity); 1. Create AST model in `Parsing/Ast/` (sealed class extending `TemplateElement`) 2. Add parser function in `TemplateParser.cs` -- register in `_elementParsers` dictionary -3. Add flex-item property support via `switch` pattern matching in layout engine -4. Add rendering in `SkiaRenderer.RenderNode()` or create a provider -5. Write tests for each step +3. Register all YAML properties in `KnownProperties.cs` (for YAML validation and typo suggestions) +4. Add flex-item property support via `switch` pattern matching in layout engine +5. Add rendering in `SkiaRenderer.RenderNode()` or create a provider +6. Write tests for each step ### Add new template expression @@ -424,9 +425,10 @@ var sb = new StringBuilder(estimatedCapacity); 1. Add property to `TemplateElement` in `Parsing/Ast/TemplateElement.cs` 2. Parse the property in `ElementParsers.cs` -3. Add the property to `TemplateElement.CopyBaseProperties()` (single source of truth in `Parsing/Ast/TemplateElement.cs`) -4. If the property contains expressions (e.g., `{{variable}}`), also add `ProcessExpression()` calls in the preprocessors -5. Write tests to verify the property survives the full rendering pipeline +3. Add the property name to `KnownProperties.cs` for the corresponding element type (YAML validation) +4. Add the property to `TemplateElement.CopyBaseProperties()` (single source of truth in `Parsing/Ast/TemplateElement.cs`) +5. If the property contains expressions (e.g., `{{variable}}`), also add `ProcessExpression()` calls in the preprocessors +6. Write tests to verify the property survives the full rendering pipeline ## Important Patterns diff --git a/CLAUDE.md b/CLAUDE.md index a9f6417..6d62db3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,8 @@ When reviewing code changes, verify: - [ ] Null checks via `ArgumentNullException.ThrowIfNull()` -- not manual `if (x == null)` - [ ] Resource limits preserved -- never remove or weaken `MaxFileSize`, `MaxNestingDepth`, `MaxRenderDepth` - [ ] New element types follow the switch-based dispatch pattern (not base class properties) +- [ ] New YAML properties registered in `KnownProperties.cs` for validation and typo suggestions +- [ ] Tests using shared static state use `[Collection(..., DisableParallelization = true)]` - [ ] XML docs on all public API surface - [ ] Snapshot tests added/updated for visual changes (`UPDATE_SNAPSHOTS=true`) - [ ] Wiki pages updated -- if code changes affect public API, element properties, YAML syntax, builder methods, or CLI options, update the corresponding `docs/wiki/` pages diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index 3544a2b..0000000 --- a/docs/TODO.md +++ /dev/null @@ -1,41 +0,0 @@ -# TODO - -## Padding: поддержка раздельных отступов (non-uniform) - -Сейчас `padding` принимает одно значение, применяемое ко всем сторонам одинаково. - -**Текущее поведение:** -```yaml -padding: 20 # → 20px со всех сторон -``` - -**Ожидаемое поведение:** -Поддержать CSS-подобный синтаксис с раздельными значениями: -```yaml -padding: "20 40" # → top/bottom=20, left/right=40 -padding: "20 40 30" # → top=20, left/right=40, bottom=30 -padding: "20 40 30 10" # → top=20, right=40, bottom=30, left=10 -``` - -**Требуемые изменения:** -- `UnitParser` — парсинг строки с несколькими значениями -- `LayoutEngine` — раздельный учёт padding по сторонам при расчёте размеров и позиционировании -- Тесты для всех вариантов формата - -## Конфигурируемые лимиты ресурсов - -Сейчас все лимиты безопасности захардкожены как константы в разных классах: - -| Константа | Значение | Класс | -|-----------|----------|-------| -| `MaxFileSize` | 1 MB | `TemplateParser` | -| `MaxFileSize` | 10 MB | `DataLoader` | -| `MaxNestingDepth` | 50 | `YamlPreprocessor` | -| `MaxInputSizeBytes` | 1 MB | `YamlPreprocessor` | -| `MaxNestingDepth` | 100 | `TemplateProcessor` | -| `MaxRenderDepth` | 100 | `SkiaLayoutRenderer` | -| `MaxImageSize` | 10 MB | `SkiaLayoutOptions` | -| `HttpTimeout` | 30s | `SkiaLayoutOptions` | - -**Ожидаемое поведение:** -Перенести все лимиты в единую конфигурацию (например, в `SkiaLayoutOptions` или отдельный класс `ResourceLimits`), чтобы пользователь мог переопределить значения по умолчанию через DI/builder. Дефолтные значения должны оставаться такими же (безопасные по умолчанию). diff --git a/docs/known-issues/layout-bugs.md b/docs/known-issues/layout-bugs.md deleted file mode 100644 index 5b72183..0000000 --- a/docs/known-issues/layout-bugs.md +++ /dev/null @@ -1,400 +0,0 @@ -# Layout Bug History - -This document tracks layout bugs that have been identified and fixed in the -FlexRender layout engine. All bugs listed here are now **RESOLVED**. - -Each bug had corresponding unit tests in `tests/FlexRender.Tests/Layout/FlexLayoutBugTests.cs` -that were initially skipped and are now passing. - ---- - -## Bug #1: align-items: end is ignored when container height is auto - -**Status:** ✅ Fixed in commit XXXX -**Test:** `AlignItemsEnd_AutoHeightRow_ChildrenAlignedToBottom`, `AlignItemsEnd_AutoHeightRow_LayoutPositions` - -### Description - -When a row flex container has no explicit `height` (auto-sized from content), -the `align: end` property is completely ignored. All children are placed at -Y=0 (top-aligned), regardless of the `align` setting. - -Per the CSS Flexbox specification, an auto-height row container should: -1. Determine its height from the tallest child. -2. Apply `align-items` to position each child relative to that computed height. - -### Reproduction (YAML) - -```yaml -canvas: - fixed: both - width: 180 - height: 80 - background: "#FFFFFF" -layout: - - type: flex - direction: row - align: end - width: 180 - # No height -- auto-sized - children: - - type: flex - width: 60 - height: 40 - background: "#FF0000" - - type: flex - width: 60 - height: 60 - background: "#00FF00" - - type: flex - width: 60 - height: 80 - background: "#0000FF" -``` - -### Expected Behavior - -Container auto-height resolves to 80px (tallest child). Children are aligned -to the bottom: - -| Child | Height | Expected Y | -|-------|--------|------------| -| Red | 40px | 40 | -| Green | 60px | 20 | -| Blue | 80px | 0 | - -### Actual Behavior - -All children have Y=0. The layout engine does not apply cross-axis alignment -when the container height is auto-computed. - -### Root Cause - -The layout engine computed the auto-height in a first pass, but did not -perform a second pass to re-position children based on the resolved container -height. The cross-axis alignment logic only ran when an explicit height was set. - -The `hasExplicitHeight` guard prevented alignment logic from executing for -auto-sized containers. - -### Fix - -Modified `RowFlexLayoutStrategy.cs` to compute `crossAxisSize` from the tallest -child when `hasExplicitHeight` is false. This allows the alignment logic to -execute correctly even for auto-height containers. - -The fix involved 6 changes: -1. Compute crossAxisSize from children after layout -2. Remove hasExplicitHeight guard from align-items logic -3. Remove hasExplicitHeight guard from stretch logic -4. Remove hasExplicitHeight guard from auto margin logic -5-6. Apply same fixes to ApplyRowCrossAxisMargins method - ---- - -## Bug #2: align-items: center is ignored when container height is auto - -**Status:** ✅ Fixed in commit XXXX (same fix as Bug #1) -**Test:** `AlignItemsCenter_AutoHeightRow_ChildrenVerticallyCentered`, `AlignItemsCenter_AutoHeightRow_LayoutPositions` - -### Description - -Same root cause as Bug #1. When a row flex container had auto height, -`align-items: center` was ignored and all children were placed at Y=0 -(top-aligned). - -### Reproduction (YAML) - -```yaml -canvas: - fixed: both - width: 180 - height: 80 - background: "#FFFFFF" -layout: - - type: flex - direction: row - align: center - width: 180 - # No height -- auto-sized - children: - - type: flex - width: 60 - height: 40 - background: "#FF0000" - - type: flex - width: 60 - height: 60 - background: "#00FF00" - - type: flex - width: 60 - height: 80 - background: "#0000FF" -``` - -### Expected Behavior - -Container auto-height resolves to 80px. Children are vertically centered: - -| Child | Height | Expected Y | -|-------|--------|---------------------| -| Red | 40px | (80 - 40) / 2 = 20 | -| Green | 60px | (80 - 60) / 2 = 10 | -| Blue | 80px | (80 - 80) / 2 = 0 | - -### Actual Behavior (Before Fix) - -All children had Y=0. Same as Bug #1. - -### Fix - -Fixed by the same changes as Bug #1 in `RowFlexLayoutStrategy.cs`. - ---- - -## Bug #3: Vertical separator without explicit height renders as tiny dot - -**Status:** ✅ Fixed in commit XXXX (same fix as Bug #1) -**Test:** `VerticalSeparator_InRowWithExplicitHeight_StretchesToContainerHeight`, `VerticalSeparator_InAutoHeightRow_StretchesToContentHeight`, `VerticalSeparator_InRowWithExplicitHeight_LayoutStretchesHeight`, `VerticalSeparator_InAutoHeightRow_LayoutStretchesToSiblingHeight` - -### Description - -A vertical `SeparatorElement` placed inside a row flex container does not -stretch to the container height when it has no explicit `height`. Instead, it -renders as a tiny dot or minimal-height line (using its thickness as height). - -The default `align-items` value for flex containers is `stretch`, which means -items without an explicit cross-axis dimension should stretch to fill the -container. This works for `FlexElement` children but not for `SeparatorElement`. - -### Reproduction (YAML) - -```yaml -canvas: - fixed: both - width: 200 - height: 100 - background: "#FFFFFF" -layout: - - type: flex - direction: row - height: 100 - width: 200 - children: - - type: flex - width: 80 - height: 80 - background: "#FF0000" - - type: separator - orientation: vertical - thickness: 2 - color: "#000000" - style: solid - # No height -- should stretch to 100px - - type: flex - width: 80 - height: 80 - background: "#0000FF" -``` - -### Expected Behavior - -The separator should stretch to the container cross-axis size (100px height in -a row container), rendering as a visible 2px-wide by 100px-tall black line. - -### Actual Behavior (Before Fix) - -The separator had a height of approximately 2px (its thickness value), rendering -as a barely visible dot at the top of the container. - -### Root Cause - -The separator was properly requesting to stretch, but the stretch logic was -disabled by the `hasExplicitHeight` guard. This affected all elements including -separators. - -### Fix - -Fixed by the same changes as Bug #1 in `RowFlexLayoutStrategy.cs`. Removing the -`hasExplicitHeight` guards from the stretch logic allows separators to stretch -correctly to the container's computed cross-axis size. - ---- - ---- - -## Bug #4: Image without explicit size does not inherit container dimensions - -**Status:** ✅ Fixed in commit XXXX -**Test:** `ComputeLayout_ImageWithoutSize_UsesContainerDimensions`, `ComputeLayout_ImageWithoutSize_InSizedContainer` - -### Description - -When an image element has no explicit `ImageWidth`/`ImageHeight` (width/height attributes), -but is placed inside a flex container with explicit dimensions (e.g., 200×200), -the image should use the container dimensions for layout and apply the `fit` mode correctly. - -### Reproduction (YAML) - -```yaml -layout: - - type: flex - width: "200" - height: "200" - children: - - type: image - src: "test.png" # 300×200 image - fit: contain - # NO width or height specified -``` - -### Expected Behavior - -The image should: -1. Use container dimensions (200×200) from layout engine -2. Apply `fit: contain` to scale the 300×200 image to fit within 200×200 with letterboxing - -### Actual Behavior (Before Fix) - -The image used its intrinsic size (300×200), ignoring: -- Container dimensions -- The `fit` parameter - -The image overflowed the 200×200 container. - -### Root Cause - -1. `LayoutEngine` computed dimensions with fallback: `Width ?? ImageWidth ?? ContainerWidth` -2. But `ImageProvider.Generate()` only used: `ImageWidth ?? intrinsicWidth` -3. The layout-computed dimensions were not passed to the rendering layer - -### Fix - -Modified three files to pass layout-computed dimensions to ImageProvider: - -1. **LayoutEngine.cs (line 534)** - Use `ContainerHeight` instead of `DefaultTextHeight`: - ```csharp - var contentHeight = context.ResolveHeight(image.Height) ?? image.ImageHeight ?? context.ContainerHeight; - ``` - -2. **ImageProvider.cs (lines 53, 185-188)** - Added optional `layoutWidth`/`layoutHeight` parameters: - ```csharp - public static SKBitmap Generate(..., int? layoutWidth = null, int? layoutHeight = null) - { - var targetWidth = layoutWidth ?? element.ImageWidth ?? source.Width; - var targetHeight = layoutHeight ?? element.ImageHeight ?? source.Height; - } - ``` - -3. **RenderingEngine.cs (lines 269-275)** - Pass computed dimensions from layout: - ```csharp - using (var bitmap = ImageProvider.Generate( - image, imageCache, renderOptions.Antialiasing, - layoutWidth: (int)width, - layoutHeight: (int)height)) - ``` - -**Priority order for image dimensions:** -1. Layout-computed dimensions (from LayoutNode) -2. Explicit ImageWidth/ImageHeight -3. Intrinsic image size - ---- - -## Bug #5: QR and Barcode without explicit size do not inherit container dimensions - -**Status:** ✅ Fixed -**Test:** `ComputeLayout_QrWithoutSize_UsesContainerDimensions`, `ComputeLayout_BarcodeWithoutSize_UsesContainerDimensions` - -### Description - -Similar to Bug #4, QR and Barcode elements without explicit size properties did not inherit -container dimensions. Instead, they used hardcoded defaults (QR: 100×100, Barcode: 200×80), -preventing them from automatically filling sized containers. - -### Reproduction (YAML) - -```yaml -layout: - - type: flex - width: "150" - height: "150" - background: "#f0f0f0" - children: - - type: qr - data: "https://example.com" - # NO size specified -``` - -### Expected Behavior - -The QR code should use container dimensions (150×150) when `size` is not specified. - -### Actual Behavior (Before Fix) - -The QR code used its default size (100×100), leaving white space in the 150×150 container. - -### Root Cause - -1. QR `Size` property had a non-nullable default: `int Size { get; set; } = 100;` -2. Barcode `BarcodeWidth/Height` had non-nullable defaults: `int BarcodeWidth { get; set; } = 200;` -3. The layout engine couldn't distinguish between "user set Size=100" and "default Size=100" -4. Container dimension fallbacks in `LayoutEngine.cs` were never reached - -### Fix - -Applied the same pattern as Bug #4: - -1. **Changed AST properties to nullable:** - ```csharp - // QrElement.cs - public int? Size { get; set; } // was: int Size { get; set; } = 100; - - // BarcodeElement.cs - public int? BarcodeWidth { get; set; } // was: int BarcodeWidth { get; set; } = 200; - public int? BarcodeHeight { get; set; } // was: int BarcodeHeight { get; set; } = 80; - ``` - -2. **Updated LayoutEngine.cs** to use container dimensions as fallbacks: - ```csharp - // QR: Priority: flex Width/Height > Size > container defaults - var contentWidth = context.ResolveWidth(qr.Width) ?? (float?)qr.Size ?? context.ContainerWidth; - - // Barcode: Priority: flex Width/Height > BarcodeWidth/Height > container defaults - var contentWidth = context.ResolveWidth(barcode.Width) ?? (float?)barcode.BarcodeWidth ?? context.ContainerWidth; - ``` - -3. **Updated providers** to add sensible defaults when properties are null: - ```csharp - // QrProvider.cs - var targetSize = layoutWidth ?? element.Size ?? 100; - - // BarcodeProvider.cs - var targetWidth = layoutWidth ?? element.BarcodeWidth ?? 200; - var targetHeight = layoutHeight ?? element.BarcodeHeight ?? 80; - ``` - -4. **Updated IntrinsicMeasurer.cs** to handle nullable sizes - -**Priority order:** -1. Layout-computed dimensions (from container) -2. Explicit Size/BarcodeWidth/BarcodeHeight -3. Provider-level defaults (100px for QR, 200×80 for Barcode) - ---- - -## Summary Table - -| Bug | Issue | Container Dims | Status | -|-----|-------|----------------|--------| -| #1 | align-items: end ignored | auto height | ✅ Fixed | -| #2 | align-items: center ignored | auto height | ✅ Fixed | -| #3 | Vertical separator too small | explicit or auto | ✅ Fixed | -| #4 | Image without size doesn't inherit | explicit | ✅ Fixed | -| #5 | QR/Barcode without size don't inherit | explicit | ✅ Fixed | - -**Bugs #1-3** were resolved by a single fix to `RowFlexLayoutStrategy.cs` that enables -cross-axis alignment and stretching to work correctly with auto-height containers. - -**Bugs #4-5** were resolved by making size properties nullable and adding container dimension -fallbacks in `LayoutEngine.cs`, enabling automatic dimension inheritance from parent containers. diff --git a/docs/plans/2026-02-08-provider-restructuring-design.md b/docs/plans/2026-02-08-provider-restructuring-design.md deleted file mode 100644 index 5e075a3..0000000 --- a/docs/plans/2026-02-08-provider-restructuring-design.md +++ /dev/null @@ -1,459 +0,0 @@ -# Provider Restructuring Design - -**Date:** 2026-02-08 -**Status:** Draft - -## Problem - -Provider interfaces (`IContentProvider`, `ISvgContentProvider`) live in `FlexRender.Skia` and return `SKBitmap`. This forces every element provider project to depend on SkiaSharp, even when the rendering logic has nothing to do with Skia. Consequences: - -1. **Hidden coupling.** `FlexRender.Svg` (SVG output renderer) depends on `FlexRender.Skia` solely to access `IContentProvider` and `SKBitmap`-to-base64 conversion. This makes the dependency graph misleading. -2. **Code duplication.** The ImageSharp backend duplicates Code128 encoding tables (~95 entries), QR data capacity maps, ECC-level mapping, and barcode checksum logic because it cannot reference the Skia-dependent originals. -3. **No shared core.** Adding a new backend (e.g., PDF) would require copying the same encoding tables a third time. - -## Goals - -- Eliminate code duplication for encoding logic (Code128, QR data generation). -- Make dependencies explicit: each project depends only on what it actually uses. -- Allow `FlexRender.Svg` to operate without `FlexRender.Skia` when only SVG-native providers are configured. -- Keep the builder API ergonomic; avoid forcing users to register six packages where two sufficed. - -## Non-Goals - -- Changing the AST element types (they remain in `FlexRender.Core`). -- Rewriting the layout engine or rendering engines. -- Supporting new barcode formats (that is a separate effort). - ---- - -## Architecture - -### Layer 1: Backend-Neutral Provider Abstractions (in `FlexRender.Core`) - -Move the provider interfaces out of `FlexRender.Skia` into `FlexRender.Core`, making them backend-neutral by using `byte[]` instead of `SKBitmap`: - -``` -namespace FlexRender.Providers; - -// Raster content: returns raw RGBA pixel data or PNG bytes -public interface IContentProvider -{ - ContentResult Generate(TElement element, int width, int height); -} - -public readonly record struct ContentResult(byte[] PngBytes, int Width, int Height); - -// SVG-native content: returns SVG markup string (unchanged) -public interface ISvgContentProvider -{ - string GenerateSvgContent(TElement element, float width, float height); -} -``` - -**Rationale:** `ContentResult` wraps PNG-encoded bytes. This is the simplest cross-backend contract. Every backend can decode PNG, and encoding to PNG is a one-liner in both SkiaSharp and ImageSharp. The width/height metadata allows backends to position the image without decoding. - -**Alternative considered:** Returning `Stream` or `ReadOnlyMemory`. Rejected because `byte[]` is simpler, AOT-safe, and PNG encoding is already happening in the existing code (see `DrawBitmapElement` in `SvgRenderingEngine` line 507). - -### Layer 2: Encoding Core Projects (Pure .NET, No Rendering) - -**`FlexRender.QrCode.Core`** -- depends on `FlexRender.Core` + `QRCoder` only. - -Contains: -- `QrEncoder` -- wraps `QRCodeGenerator`, returns a `bool[][]` module matrix plus module count. -- `QrDataValidator` -- `MaxDataCapacity` dictionary, `ValidateDataCapacity()`, `MapEccLevel()`. -- Shared SVG path builder (`QrSvgPathBuilder`) used by both the SVG-native provider and a future PDF backend. - -**`FlexRender.Barcode.Core`** -- depends on `FlexRender.Core` only (no external NuGet packages). - -Contains: -- `Code128Encoder` -- the Code128B pattern table, start/stop patterns, checksum calculation. Returns `string` (the "10110..." bit pattern). -- `Code128Validator` -- character validation. -- Shared SVG bar builder for SVG-native barcode rendering. - -**`FlexRender.SvgElement.Core`** -- depends on `FlexRender.Core` only (no external NuGet packages). - -Contains: -- `SvgContentParser` -- SVG content parsing and validation logic shared across backends. - -**No Core project for Image** -- image loading/decoding is inherently backend-specific. No shareable encoding logic exists. - -### Layer 3: Per-Backend Provider Projects - -Each encoding Core gets one project per rendering backend that implements `IContentProvider` and/or `ISvgContentProvider`: - -| Project | Depends On | Provides | -|---------|-----------|----------| -| `FlexRender.QrCode.Core` | Core, QRCoder | `QrEncoder`, `QrSvgPathBuilder` | -| `FlexRender.QrCode.Skia` | QrCode.Core, Skia | `IContentProvider` via SKBitmap | -| `FlexRender.QrCode.Svg` | QrCode.Core, Core | `ISvgContentProvider` | -| `FlexRender.QrCode.ImageSharp` | QrCode.Core, ImageSharp(NuGet) | `IContentProvider` via ImageSharp | -| `FlexRender.Barcode.Core` | Core | `Code128Encoder` | -| `FlexRender.Barcode.Skia` | Barcode.Core, Skia | `IContentProvider` | -| `FlexRender.Barcode.Svg` | Barcode.Core, Core | `ISvgContentProvider` | -| `FlexRender.Barcode.ImageSharp` | Barcode.Core, ImageSharp(NuGet) | `IContentProvider` | -| `FlexRender.SvgElement.Core` | Core | SVG content parsing, validation | -| `FlexRender.SvgElement.Skia` | SvgElement.Core, Skia, Svg.Skia | `IContentProvider` via SKBitmap | -| `FlexRender.SvgElement.Svg` | SvgElement.Core, Core | `ISvgContentProvider` (embeds SVG directly) | - -**Total new projects: 9** (QrCode.Core, QrCode.Svg, QrCode.ImageSharp, Barcode.Core, Barcode.Svg, Barcode.ImageSharp, SvgElement.Core, SvgElement.Svg, + SvgElement.Skia is a rename). -**Renamed projects: 3** (QrCode -> QrCode.Skia, Barcode -> Barcode.Skia, SvgElement -> SvgElement.Skia). - -**Note:** `FlexRender.SvgElement.ImageSharp` is NOT supported in this phase. SVG rasterization via ImageSharp would require vendoring ~40 ShimSkiaSharp classes from the Svg library. This can be added in a future phase if demand justifies it. - -### What About Image Providers? - -`ImageProvider` (Skia) and `ImageSharpImageProvider` stay where they are -- inside `FlexRender.Skia` and `FlexRender.ImageSharp` respectively. They have no shareable encoding logic; image loading/decoding is inherently backend-specific. No `.Core` project needed. - -### What About FlexRender.ImageSharp? - -`FlexRender.ImageSharp` becomes **thinner**: its inline `ImageSharpQrProvider` and `ImageSharpBarcodeProvider` classes get deleted. Instead, users add `FlexRender.QrCode.ImageSharp` and `FlexRender.Barcode.ImageSharp` as separate packages. The `ImageSharpImageProvider` and the rendering engine stay in `FlexRender.ImageSharp`. - ---- - -## FlexRender.Svg Independence from FlexRender.Skia - -The current `FlexRender.Svg` project depends on `FlexRender.Skia` for two reasons: - -1. `IContentProvider` / `ISvgContentProvider` interfaces (will move to Core). -2. `DrawBitmapElement` uses `SKBitmap` / `SKImage` / `SKData` to PNG-encode bitmaps for base64 embedding. - -After this restructuring: -- Reason 1 is resolved: interfaces move to Core. -- Reason 2 is resolved: `IContentProvider.Generate()` now returns `ContentResult` with PNG bytes. `DrawBitmapElement` becomes a simple base64-encode of those bytes. No SkiaSharp types needed. - -**Result:** `FlexRender.Svg` drops its dependency on `FlexRender.Skia`. It depends only on `FlexRender.Core`. - -The SVG renderer accepts `IContentProvider?` and `ISvgContentProvider?` through its constructor. When the user configures `FlexRender.QrCode.Svg`, the SVG-native provider is used. When they configure `FlexRender.QrCode.Skia`, the raster provider generates PNG bytes that get base64-embedded. Both paths work without the SVG project knowing which backend provided them. - ---- - -## Builder API Changes - -### Current API (unchanged for Skia users) - -```csharp -var render = new FlexRenderBuilder() - .WithSkia(skia => skia - .WithQr() // FlexRender.QrCode.Skia - .WithBarcode() // FlexRender.Barcode.Skia - .WithSvgElement()) // FlexRender.SvgElement.Skia - .Build(); -``` - -The `WithQr()` and `WithBarcode()` extension methods move from the old projects to the new `.Skia` projects. Same method names, same namespace. Binary-breaking but source-compatible for most users (just swap the NuGet package). - -### New SVG-specific API - -```csharp -var render = new FlexRenderBuilder() - .WithSvg(svg => svg - .WithQr() // FlexRender.QrCode.Svg provides ISvgContentProvider - .WithBarcode()) // FlexRender.Barcode.Svg provides ISvgContentProvider - .Build(); -``` - -This gives SVG-native QR codes and barcodes without any SkiaSharp dependency. - -### Mixed SVG + Raster API - -```csharp -var render = new FlexRenderBuilder() - .WithSvg(svg => svg - .WithQr() // SVG-native QR - .WithBarcode() // SVG-native barcode - .WithSkia(skia => skia - .WithQr() // Raster QR for PNG fallback - .WithBarcode() // Raster barcode for PNG fallback - .WithSvgElement())) - .Build(); -``` - -### ImageSharp API - -```csharp -var render = new FlexRenderBuilder() - .WithImageSharp(is => is - .WithQr() // FlexRender.QrCode.ImageSharp - .WithBarcode()) // FlexRender.Barcode.ImageSharp - .Build(); -``` - -Same method names as before, but now they come from separate NuGet packages rather than being built into `FlexRender.ImageSharp`. - ---- - -## SvgBuilder Changes - -The `SvgBuilder` needs new provider slots for SVG-native content providers: - -```csharp -public sealed class SvgBuilder -{ - // Existing - internal bool IsSkiaEnabled { get; private set; } - internal Action? SkiaConfigureAction { get; private set; } - - // New: SVG-native providers - internal ISvgContentProvider? QrSvgProvider { get; private set; } - internal ISvgContentProvider? BarcodeSvgProvider { get; private set; } - internal ISvgContentProvider? SvgElementSvgProvider { get; private set; } - - // New: raster providers (backend-neutral) - internal IContentProvider? QrRasterProvider { get; private set; } - internal IContentProvider? BarcodeRasterProvider { get; private set; } - - internal void SetQrSvgProvider(ISvgContentProvider provider) { ... } - internal void SetBarcodeSvgProvider(ISvgContentProvider provider) { ... } - internal void SetSvgElementSvgProvider(ISvgContentProvider provider) { ... } -} -``` - -The `SvgBuilderExtensions.WithSvg()` factory method assembles providers with this priority: -1. Use `ISvgContentProvider` if registered (vector-native output). -2. Fall back to `IContentProvider` with PNG-to-base64 embedding. -3. Fall back to extracting providers from the Skia sub-builder (backward compatible). - ---- - -## SkiaBuilder Changes - -`SkiaBuilder.SetQrProvider` and `SetBarcodeProvider` change their parameter types from backend-specific to the new Core interfaces: - -```csharp -internal void SetQrProvider(IContentProvider provider) { ... } -``` - -Since the interface signature changes (returns `ContentResult` instead of `SKBitmap`), the Skia-specific provider implementations need a thin adapter or can implement the new interface directly. The Skia providers will internally generate `SKBitmap`, then PNG-encode it to produce `ContentResult`. - -**Alternative considered:** Keep `IContentProvider` returning `SKBitmap` and add a separate `IBitmaplessContentProvider` in Core. Rejected because it creates two parallel hierarchies and the Skia rendering engine would need to check both interfaces. - ---- - -## Migration Path - -### Phase 1: Move interfaces to Core (breaking change, do first) - -1. Move `IContentProvider`, `ISvgContentProvider`, and `IResourceLoaderAware` from `FlexRender.Skia` to `FlexRender.Core`. -2. Change `IContentProvider.Generate()` signature to return `ContentResult` instead of `SKBitmap`. -3. Update `FlexRender.Skia`'s `RenderingEngine` to decode `ContentResult.PngBytes` back to `SKBitmap` for canvas drawing. -4. Update `FlexRender.Svg`'s `SvgRenderingEngine` to base64-encode `ContentResult.PngBytes` directly (removing `SKImage`/`SKData` usage). -5. Remove `FlexRender.Svg`'s dependency on `FlexRender.Skia`. -6. Update all existing providers to implement the new interface. - -### Phase 2: Extract encoding Core projects - -7. Create `FlexRender.QrCode.Core` with `QrEncoder`, `QrDataValidator`, `QrSvgPathBuilder`. -8. Create `FlexRender.Barcode.Core` with `Code128Encoder`, `Code128Validator`. -9. Create `FlexRender.SvgElement.Core` with `SvgContentParser` (SVG content parsing/validation). -10. Update existing QR/Barcode/SvgElement providers to use the Core classes. -11. Delete duplicated code from `FlexRender.ImageSharp`. - -### Phase 3: Create per-backend projects - -12. Rename `FlexRender.QrCode` to `FlexRender.QrCode.Skia`. -13. Rename `FlexRender.Barcode` to `FlexRender.Barcode.Skia`. -14. Rename `FlexRender.SvgElement` to `FlexRender.SvgElement.Skia`. -15. Create `FlexRender.QrCode.Svg` with `QrSvgProvider : ISvgContentProvider`. -16. Create `FlexRender.Barcode.Svg` with `BarcodeSvgProvider : ISvgContentProvider`. -17. Create `FlexRender.SvgElement.Svg` with `SvgSvgElementProvider : ISvgContentProvider`. -18. Create `FlexRender.QrCode.ImageSharp` (extract from `FlexRender.ImageSharp`). -19. Create `FlexRender.Barcode.ImageSharp` (extract from `FlexRender.ImageSharp`). - -**Note:** `SvgSvgElementProvider` is the simplest provider in the entire system -- it is essentially a pass-through that takes the SVG content from the element and returns it directly for embedding in SVG output. This is the most natural way to include SVG elements in SVG output, requiring no rasterization. - -### Phase 4: Update builders and wiring - -20. Update `SvgBuilder` with SVG-native provider slots (including `ISvgContentProvider?`). -21. Add `WithQr()`/`WithBarcode()`/`WithSvgElement()` extension methods on `SvgBuilder` from the `.Svg` projects. -22. Update `ImageSharpBuilder` extensions to come from the `.ImageSharp` projects. -23. Update `FlexRender.MetaPackage` to reference the new project names. -24. Update CLI project references. -25. Update all tests. - ---- - -## Project Count Analysis - -**Before:** 14 projects in src/ -**After:** 20 projects in src/ (+9 new, -3 renamed = net +6) - -New projects: -- `FlexRender.QrCode.Core` -- `FlexRender.QrCode.Svg` -- `FlexRender.QrCode.ImageSharp` -- `FlexRender.Barcode.Core` -- `FlexRender.Barcode.Svg` -- `FlexRender.Barcode.ImageSharp` -- `FlexRender.SvgElement.Core` -- `FlexRender.SvgElement.Svg` - -Renamed projects: -- `FlexRender.QrCode` -> `FlexRender.QrCode.Skia` -- `FlexRender.Barcode` -> `FlexRender.Barcode.Skia` -- `FlexRender.SvgElement` -> `FlexRender.SvgElement.Skia` - -This is reasonable for a library with three rendering backends. Each project is small and focused. The Core projects contain only encoding/parsing logic (one or two files each). The per-backend projects contain a single provider class plus a builder extension method. - ---- - -## Dependency Graph (After) - -``` -FlexRender.Core (AST, layout, interfaces -- no external deps) - | - +-- FlexRender.QrCode.Core (+ QRCoder) - | +-- FlexRender.QrCode.Skia (+ FlexRender.Skia) - | +-- FlexRender.QrCode.Svg (standalone) - | +-- FlexRender.QrCode.ImageSharp (+ SixLabors.ImageSharp) - | - +-- FlexRender.Barcode.Core (no external deps) - | +-- FlexRender.Barcode.Skia (+ FlexRender.Skia) - | +-- FlexRender.Barcode.Svg (standalone) - | +-- FlexRender.Barcode.ImageSharp (+ SixLabors.ImageSharp) - | - +-- FlexRender.SvgElement.Core (no external deps) - | +-- FlexRender.SvgElement.Skia (+ FlexRender.Skia, Svg.Skia) - | +-- FlexRender.SvgElement.Svg (standalone, embeds SVG directly) - | - +-- FlexRender.Skia (+ SkiaSharp) - | +-- FlexRender.HarfBuzz (+ HarfBuzzSharp) - | - +-- FlexRender.Svg (SVG output -- depends ONLY on Core now) - | - +-- FlexRender.ImageSharp (+ SixLabors.ImageSharp, SixLabors.Fonts) - | - +-- FlexRender.Yaml (+ YamlDotNet) - +-- FlexRender.Http (+ System.Net.Http) -``` - -Key improvement: `FlexRender.Svg` no longer depends on `FlexRender.Skia`. Users who only need SVG output can avoid pulling in SkiaSharp entirely. - ---- - -## Risks and Mitigations - -| Risk | Impact | Mitigation | -|------|--------|------------| -| PNG encode/decode overhead in Skia backend | Performance regression for raster rendering | Benchmark before/after. If measurable, add an internal `ISkiaContentProvider` that returns `SKBitmap` directly, checked first in `RenderingEngine`. | -| Breaking change for existing NuGet consumers | Package rename breaks `` | Publish old package IDs as empty shells that forward-reference new names. | -| Too many small NuGet packages confuse users | Adoption friction | MetaPackage already bundles everything. Document "quick start" with MetaPackage vs "a-la-carte" setup. | -| ImageSharp QR/Barcode builder extensions move to new packages | Source-breaking for ImageSharp users | The method names stay identical; only the NuGet package changes. Document in migration guide. | - -## Performance Note on ContentResult - -The `ContentResult` approach introduces a PNG encode in the provider and a PNG decode in the Skia rendering engine. For the SVG renderer this is free (it was already encoding to PNG base64). For the Skia renderer this is new overhead. - -**Mitigation:** Introduce an internal marker interface `ISkiaNativeProvider` in `FlexRender.Skia` that returns `SKBitmap` directly. The Skia rendering engine checks for this interface first. The `.Skia` provider projects implement both `IContentProvider` (for cross-backend use) and `ISkiaNativeProvider` (for zero-copy Skia rendering). This keeps the public API clean while avoiding the encode/decode round-trip in the hot path. - -```csharp -// In FlexRender.Skia (internal) -internal interface ISkiaNativeProvider -{ - SKBitmap GenerateBitmap(TElement element, int width, int height); -} - -// In FlexRender.QrCode.Skia -internal sealed class SkiaQrProvider - : IContentProvider, - ISvgContentProvider, - ISkiaNativeProvider -{ - // Skia engine uses GenerateBitmap() directly - // SVG engine uses Generate() which PNG-encodes - // ISvgContentProvider used by SVG engine for vector output -} -``` - -This way: -- **Skia raster path:** zero overhead (uses `ISkiaNativeProvider` -> `SKBitmap` directly). -- **SVG output path:** prefers `ISvgContentProvider` for vector output, falls back to `IContentProvider` -> PNG base64. -- **ImageSharp path:** uses `IContentProvider` -> decodes PNG to ImageSharp Image. -- **Cross-backend:** any provider works with any renderer through `IContentProvider`. - ---- - -## Files to Create / Modify / Delete - -### New files (Phase 2-3) - -``` -src/FlexRender.QrCode.Core/ - FlexRender.QrCode.Core.csproj - QrEncoder.cs - QrDataValidator.cs - QrSvgPathBuilder.cs - -src/FlexRender.Barcode.Core/ - FlexRender.Barcode.Core.csproj - Code128Encoder.cs - Code128Validator.cs - -src/FlexRender.QrCode.Skia/ (renamed from FlexRender.QrCode/) - FlexRender.QrCode.Skia.csproj - Providers/SkiaQrProvider.cs (renamed from QrProvider.cs) - SkiaBuilderExtensions.cs - -src/FlexRender.QrCode.Svg/ - FlexRender.QrCode.Svg.csproj - Providers/SvgQrProvider.cs - SvgBuilderExtensions.cs - -src/FlexRender.QrCode.ImageSharp/ - FlexRender.QrCode.ImageSharp.csproj - Providers/ImageSharpQrProvider.cs - ImageSharpBuilderExtensions.cs - -src/FlexRender.Barcode.Skia/ (renamed from FlexRender.Barcode/) - FlexRender.Barcode.Skia.csproj - Providers/SkiaBarcodeProvider.cs (renamed from BarcodeProvider.cs) - SkiaBuilderExtensions.cs - -src/FlexRender.Barcode.Svg/ - FlexRender.Barcode.Svg.csproj - Providers/SvgBarcodeProvider.cs - SvgBuilderExtensions.cs - -src/FlexRender.Barcode.ImageSharp/ - FlexRender.Barcode.ImageSharp.csproj - Providers/ImageSharpBarcodeProvider.cs - ImageSharpBuilderExtensions.cs - -src/FlexRender.SvgElement.Core/ - FlexRender.SvgElement.Core.csproj - SvgContentParser.cs -- SVG content parsing/validation - -src/FlexRender.SvgElement.Skia/ (renamed from FlexRender.SvgElement/) - FlexRender.SvgElement.Skia.csproj - Providers/SkiaSvgElementProvider.cs (renamed from SvgElementProvider.cs) - SkiaBuilderExtensions.cs - -src/FlexRender.SvgElement.Svg/ - FlexRender.SvgElement.Svg.csproj - Providers/SvgSvgElementProvider.cs -- embeds SVG directly into SVG output - SvgBuilderExtensions.cs -``` - -### Modified files (Phase 1) - -``` -src/FlexRender.Core/FlexRender.Core.csproj -- add IContentProvider, ISvgContentProvider, IResourceLoaderAware -src/FlexRender.Skia/FlexRender.Skia.csproj -- remove provider interfaces, add ISkiaNativeProvider -src/FlexRender.Skia/SkiaBuilder.cs -- update provider types -src/FlexRender.Skia/Rendering/RenderingEngine.cs -- check ISkiaNativeProvider first, then IContentProvider -src/FlexRender.Svg/FlexRender.Svg.csproj -- remove Skia dependency -src/FlexRender.Svg/Rendering/SvgRenderingEngine.cs -- use ContentResult.PngBytes for base64 -src/FlexRender.Svg/SvgRender.cs -- update constructor -src/FlexRender.Svg/SvgBuilderExtensions.cs -- update provider assembly -src/FlexRender.ImageSharp/ImageSharpRender.cs -- use IContentProvider instead of inline providers -src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj -- update references -``` - -### Deleted files (Phase 2-3) - -``` -src/FlexRender.Skia/Providers/IContentProvider.cs -- moved to Core -src/FlexRender.Skia/Providers/ISvgContentProvider.cs -- moved to Core -src/FlexRender.Skia/Providers/IResourceLoaderAware.cs -- moved to Core -src/FlexRender.ImageSharp/Providers/ImageSharpQrProvider.cs -- moved to QrCode.ImageSharp -src/FlexRender.ImageSharp/Providers/ImageSharpBarcodeProvider.cs -- moved to Barcode.ImageSharp -``` diff --git a/docs/plans/2026-02-08-provider-restructuring-plan.md b/docs/plans/2026-02-08-provider-restructuring-plan.md deleted file mode 100644 index 49845ae..0000000 --- a/docs/plans/2026-02-08-provider-restructuring-plan.md +++ /dev/null @@ -1,2368 +0,0 @@ -# Provider Restructuring Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Eliminate code duplication for encoding logic (Code128, QR), break `FlexRender.Svg`'s dependency on `FlexRender.Skia`, and create backend-neutral provider abstractions in `FlexRender.Core`. - -**Architecture:** Move `IContentProvider`, `ISvgContentProvider`, and `IResourceLoaderAware` from `FlexRender.Skia` to `FlexRender.Core` with a new `ContentResult` return type (PNG bytes instead of `SKBitmap`). Extract shared encoding logic into `.Core` projects. Create per-backend provider projects (`.Skia`, `.Svg`, `.ImageSharp`). Add `ISkiaNativeProvider` for zero-copy Skia rendering. - -**Tech Stack:** .NET 10 + net8.0 multi-targeting, C# latest, xUnit, SkiaSharp, QRCoder, SixLabors.ImageSharp, Svg.Skia - ---- - -## Prerequisites - -Before starting, ensure the solution builds and all tests pass: - -```bash -cd /Users/robonet/Projects/SkiaLayout -dotnet build FlexRender.slnx -dotnet test FlexRender.slnx -``` - -Create a feature branch: - -```bash -git checkout -b refactor/provider-restructuring -``` - ---- - -## Phase 1: Move Interfaces to Core and Break Svg-to-Skia Dependency - -### Task 1: Add ContentResult and Backend-Neutral Interfaces to FlexRender.Core - -**Files:** -- Create: `src/FlexRender.Core/Providers/ContentResult.cs` -- Create: `src/FlexRender.Core/Providers/IContentProvider.cs` -- Create: `src/FlexRender.Core/Providers/ISvgContentProvider.cs` -- Create: `src/FlexRender.Core/Providers/IResourceLoaderAware.cs` -- Test: `tests/FlexRender.Tests/Providers/ContentResultTests.cs` - -**Step 1: Write failing test** - -Create `tests/FlexRender.Tests/Providers/ContentResultTests.cs`: - -```csharp -using FlexRender.Providers; -using Xunit; - -namespace FlexRender.Tests.Providers; - -/// -/// Tests for the record struct. -/// -public sealed class ContentResultTests -{ - [Fact] - public void Constructor_WithValidArgs_StoresValues() - { - var pngBytes = new byte[] { 137, 80, 78, 71 }; // PNG magic bytes - var result = new ContentResult(pngBytes, 100, 200); - - Assert.Same(pngBytes, result.PngBytes); - Assert.Equal(100, result.Width); - Assert.Equal(200, result.Height); - } - - [Fact] - public void Equality_SameValues_AreEqual() - { - var bytes = new byte[] { 1, 2, 3 }; - var a = new ContentResult(bytes, 50, 50); - var b = new ContentResult(bytes, 50, 50); - - Assert.Equal(a, b); - } - - [Fact] - public void Equality_DifferentDimensions_AreNotEqual() - { - var bytes = new byte[] { 1, 2, 3 }; - var a = new ContentResult(bytes, 50, 50); - var b = new ContentResult(bytes, 100, 100); - - Assert.NotEqual(a, b); - } -} -``` - -**Step 2: Run test (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "ContentResultTests" --no-restore -``` - -Expected: FAIL - `ContentResult` type not found. - -**Step 3: Implement** - -Create `src/FlexRender.Core/Providers/ContentResult.cs`: - -```csharp -namespace FlexRender.Providers; - -/// -/// Contains the result of a content provider's raster generation: PNG-encoded image bytes -/// along with the image dimensions. This is the backend-neutral exchange format between -/// content providers and rendering engines. -/// -/// The PNG-encoded image bytes. -/// The image width in pixels. -/// The image height in pixels. -public readonly record struct ContentResult(byte[] PngBytes, int Width, int Height); -``` - -Create `src/FlexRender.Core/Providers/IContentProvider.cs`: - -```csharp -namespace FlexRender.Providers; - -/// -/// Provides raster content generation for template elements. -/// Returns PNG-encoded bytes for cross-backend compatibility. -/// -/// The type of template element this provider handles. -public interface IContentProvider -{ - /// - /// Generates a PNG-encoded bitmap representation of the element. - /// - /// The element to generate content for. - /// The allocated width in pixels. - /// The allocated height in pixels. - /// A containing PNG bytes and dimensions. - ContentResult Generate(TElement element, int width, int height); -} -``` - -Create `src/FlexRender.Core/Providers/ISvgContentProvider.cs`: - -```csharp -namespace FlexRender.Providers; - -/// -/// Provides SVG-native content generation for template elements. -/// -/// -/// -/// Content providers that implement this interface can generate native SVG markup -/// instead of rasterized bitmaps. The SVG rendering engine checks for this interface -/// and uses it when available, falling back to bitmap rasterization via -/// otherwise. -/// -/// -/// The returned SVG markup is inserted directly into the SVG document at the -/// specified position and dimensions. It should not include an outer wrapping element -/// -- the rendering engine handles positioning via a nested <svg> element. -/// -/// -/// The type of template element this provider handles. -public interface ISvgContentProvider -{ - /// - /// Generates SVG markup for the specified element. - /// - /// The element to generate SVG content for. - /// The allocated width in SVG user units. - /// The allocated height in SVG user units. - /// A string containing SVG markup (e.g., path, rect, or group elements). - string GenerateSvgContent(TElement element, float width, float height); -} -``` - -Create `src/FlexRender.Core/Providers/IResourceLoaderAware.cs`: - -```csharp -using FlexRender.Abstractions; - -namespace FlexRender.Providers; - -/// -/// Allows content providers to receive resource loaders for loading external assets. -/// -/// -/// Content providers that need to load resources from URIs (files, HTTP, base64, embedded) -/// can implement this interface to receive the configured resource loader chain. -/// The loaders are injected after construction by the rendering infrastructure. -/// -public interface IResourceLoaderAware -{ - /// - /// Sets the resource loaders for this provider. - /// - /// The ordered collection of resource loaders. - void SetResourceLoaders(IReadOnlyList loaders); -} -``` - -**Step 4: Run test (expect success)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "ContentResultTests" --no-restore -``` - -Expected: PASS (3 tests) - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add src/FlexRender.Core/Providers/ContentResult.cs src/FlexRender.Core/Providers/IContentProvider.cs src/FlexRender.Core/Providers/ISvgContentProvider.cs src/FlexRender.Core/Providers/IResourceLoaderAware.cs tests/FlexRender.Tests/Providers/ContentResultTests.cs && git commit -m "feat(core): add backend-neutral provider interfaces and ContentResult to Core" -``` - ---- - -### Task 2: Add ISkiaNativeProvider to FlexRender.Skia - -This internal interface allows Skia providers to return `SKBitmap` directly for zero-copy rendering, avoiding the PNG encode/decode overhead in the raster hot path. - -**Files:** -- Create: `src/FlexRender.Skia/Providers/ISkiaNativeProvider.cs` -- Test: `tests/FlexRender.Tests/Providers/SkiaNativeProviderInterfaceTests.cs` - -**Step 1: Write failing test** - -Create `tests/FlexRender.Tests/Providers/SkiaNativeProviderInterfaceTests.cs`: - -```csharp -using FlexRender.Providers; -using SkiaSharp; -using Xunit; - -namespace FlexRender.Tests.Providers; - -/// -/// Tests for . -/// -public sealed class SkiaNativeProviderInterfaceTests -{ - [Fact] - public void ISkiaNativeProvider_IsGenericInterface() - { - var type = typeof(ISkiaNativeProvider<>); - Assert.True(type.IsInterface); - Assert.True(type.IsGenericTypeDefinition); - } - - [Fact] - public void ISkiaNativeProvider_HasGenerateBitmapMethod() - { - var type = typeof(ISkiaNativeProvider); - var method = type.GetMethod("GenerateBitmap"); - - Assert.NotNull(method); - Assert.Equal(typeof(SKBitmap), method!.ReturnType); - } -} -``` - -**Step 2: Run test (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "SkiaNativeProviderInterfaceTests" --no-restore -``` - -Expected: FAIL - `ISkiaNativeProvider` not found. - -**Step 3: Implement** - -Create `src/FlexRender.Skia/Providers/ISkiaNativeProvider.cs`: - -```csharp -using SkiaSharp; - -namespace FlexRender.Providers; - -/// -/// Internal optimization interface for Skia content providers. -/// Allows providers to return directly to the Skia rendering engine, -/// avoiding the PNG encode/decode overhead that requires. -/// -/// -/// -/// The Skia rendering engine checks for this interface first. If a provider implements it, -/// is called instead of . -/// This keeps the public API clean (all providers implement ) -/// while avoiding unnecessary serialization in the hot path. -/// -/// -/// The type of template element this provider handles. -internal interface ISkiaNativeProvider -{ - /// - /// Generates a bitmap representation of the element for direct Skia canvas drawing. - /// - /// The element to generate content for. - /// The allocated width in pixels. - /// The allocated height in pixels. - /// An containing the rendered content. Caller is responsible for disposal. - SKBitmap GenerateBitmap(TElement element, int width, int height); -} -``` - -**Step 4: Run test (expect success)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "SkiaNativeProviderInterfaceTests" --no-restore -``` - -Expected: PASS (2 tests) - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add src/FlexRender.Skia/Providers/ISkiaNativeProvider.cs tests/FlexRender.Tests/Providers/SkiaNativeProviderInterfaceTests.cs && git commit -m "feat(skia): add ISkiaNativeProvider for zero-copy bitmap rendering" -``` - ---- - -### Task 3: Update QrProvider to Implement New Interfaces - -The existing `QrProvider` currently implements the old `IContentProvider` (returns `SKBitmap` via `Generate(element)` with no width/height). Update it to implement the new Core `IContentProvider` (returns `ContentResult`) and `ISkiaNativeProvider` (returns `SKBitmap` directly). - -**Files:** -- Modify: `src/FlexRender.QrCode/Providers/QrProvider.cs` -- Modify: `tests/FlexRender.Tests/Providers/QrProviderTests.cs` - -**Step 1: Write failing test** - -Update `tests/FlexRender.Tests/Providers/QrProviderTests.cs` -- add these tests (append to the existing test class): - -```csharp - /// - /// Verifies QrProvider implements the new Core IContentProvider with ContentResult. - /// - [Fact] - public void Generate_NewInterface_ReturnsContentResult() - { - var element = new QrElement - { - Data = "Hello, World!", - Size = 100 - }; - - IContentProvider provider = _provider; - var result = provider.Generate(element, 100, 100); - - Assert.NotNull(result.PngBytes); - Assert.True(result.PngBytes.Length > 0); - Assert.Equal(100, result.Width); - Assert.Equal(100, result.Height); - // Verify PNG magic bytes - Assert.Equal(137, result.PngBytes[0]); - Assert.Equal(80, result.PngBytes[1]); // 'P' - Assert.Equal(78, result.PngBytes[2]); // 'N' - Assert.Equal(71, result.PngBytes[3]); // 'G' - } - - /// - /// Verifies QrProvider implements ISkiaNativeProvider for zero-copy Skia rendering. - /// - [Fact] - public void QrProvider_ImplementsISkiaNativeProvider() - { - Assert.IsAssignableFrom>(_provider); - } - - /// - /// Verifies GenerateBitmap returns correct-sized SKBitmap. - /// - [Fact] - public void GenerateBitmap_ReturnsCorrectSizedBitmap() - { - var element = new QrElement - { - Data = "Test", - Size = 150 - }; - - var nativeProvider = (ISkiaNativeProvider)_provider; - using var bitmap = nativeProvider.GenerateBitmap(element, 150, 150); - - Assert.NotNull(bitmap); - Assert.Equal(150, bitmap.Width); - Assert.Equal(150, bitmap.Height); - } -``` - -**Step 2: Run test (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "QrProviderTests" --no-restore -``` - -Expected: FAIL - compilation errors because `IContentProvider.Generate` signature changed. - -**Step 3: Implement** - -First, delete the OLD interfaces from `FlexRender.Skia`: -- Delete `src/FlexRender.Skia/Providers/IContentProvider.cs` -- Delete `src/FlexRender.Skia/Providers/ISvgContentProvider.cs` -- Delete `src/FlexRender.Skia/Providers/IResourceLoaderAware.cs` - -Then update `src/FlexRender.QrCode/Providers/QrProvider.cs`: - -```csharp -using System.Globalization; -using System.Text; -using FlexRender.Parsing.Ast; -using FlexRender.Providers; -using FlexRender.Rendering; -using QRCoder; -using SkiaSharp; - -namespace FlexRender.QrCode.Providers; - -/// -/// Provides QR code generation as both raster bitmaps and native SVG markup. -/// -/// -/// -/// For raster output (PNG, JPEG), generates PNG bytes via . -/// For Skia-native rendering, returns directly via . -/// For SVG output, generates native SVG path elements via , -/// producing smaller, scalable, pixel-perfect vector QR codes. -/// -/// -/// The SVG output uses horizontal run-length encoding to minimize path data size. -/// Adjacent dark modules on the same row are merged into a single rectangle sub-path. -/// -/// -public sealed class QrProvider : IContentProvider, ISvgContentProvider, ISkiaNativeProvider -{ - /// - /// Maximum data capacity in bytes for each error correction level. - /// - private static readonly Dictionary MaxDataCapacity = new() - { - { ErrorCorrectionLevel.L, 2953 }, - { ErrorCorrectionLevel.M, 2331 }, - { ErrorCorrectionLevel.Q, 1663 }, - { ErrorCorrectionLevel.H, 1273 } - }; - - /// - /// Generates a PNG-encoded QR code from the specified element configuration. - /// - /// The QR code element configuration. - /// The target width in pixels. - /// The target height in pixels. - /// A containing PNG bytes. - public ContentResult Generate(QrElement element, int width, int height) - { - var targetSize = Math.Min(width, height); - if (targetSize <= 0) targetSize = element.Size ?? 100; - - using var bitmap = GenerateSkBitmap(element, targetSize); - using var image = SKImage.FromBitmap(bitmap); - using var data = image.Encode(SKEncodedImageFormat.Png, 100); - - return new ContentResult(data.ToArray(), bitmap.Width, bitmap.Height); - } - - /// - /// Generates a bitmap for direct Skia canvas drawing (zero-copy hot path). - /// - /// The QR code element configuration. - /// The target width in pixels. - /// The target height in pixels. - /// An containing the rendered QR code. - SKBitmap ISkiaNativeProvider.GenerateBitmap(QrElement element, int width, int height) - { - var targetSize = Math.Min(width, height); - if (targetSize <= 0) targetSize = element.Size ?? 100; - - return GenerateSkBitmap(element, targetSize); - } - - /// - /// Core SKBitmap generation shared by both Generate and GenerateBitmap. - /// - private static SKBitmap GenerateSkBitmap(QrElement element, int targetSize) - { - ArgumentNullException.ThrowIfNull(element); - - if (string.IsNullOrEmpty(element.Data)) - { - throw new ArgumentException("QR code data cannot be empty.", nameof(element)); - } - - if (targetSize <= 0) - { - throw new ArgumentException("QR code size must be positive.", nameof(element)); - } - - var eccLevel = MapEccLevel(element.ErrorCorrection); - ValidateDataCapacity(element); - - using var qrGenerator = new QRCodeGenerator(); - using var qrCodeData = qrGenerator.CreateQrCode(element.Data, eccLevel); - - var moduleCount = qrCodeData.ModuleMatrix.Count; - var moduleSize = targetSize / (float)moduleCount; - - var bitmap = new SKBitmap(targetSize, targetSize); - using var canvas = new SKCanvas(bitmap); - - var foreground = ColorParser.Parse(element.Foreground); - var background = element.Background is not null - ? ColorParser.Parse(element.Background) - : SKColors.Transparent; - - canvas.Clear(background); - - using var paint = new SKPaint - { - Color = foreground, - IsAntialias = false, - Style = SKPaintStyle.Fill - }; - - for (var y = 0; y < moduleCount; y++) - { - for (var x = 0; x < moduleCount; x++) - { - if (qrCodeData.ModuleMatrix[y][x]) - { - var rect = new SKRect( - x * moduleSize, - y * moduleSize, - (x + 1) * moduleSize, - (y + 1) * moduleSize); - canvas.DrawRect(rect, paint); - } - } - } - - return bitmap; - } - - /// - /// Generates native SVG markup for a QR code element. - /// - /// The QR code element configuration. - /// The allocated width in SVG user units. - /// The allocated height in SVG user units. - /// SVG markup containing the QR code as vector paths. - public string GenerateSvgContent(QrElement element, float width, float height) - { - ArgumentNullException.ThrowIfNull(element); - - if (string.IsNullOrEmpty(element.Data)) - { - throw new ArgumentException("QR code data cannot be empty.", nameof(element)); - } - - var eccLevel = MapEccLevel(element.ErrorCorrection); - ValidateDataCapacity(element); - - using var qrGenerator = new QRCodeGenerator(); - using var qrCodeData = qrGenerator.CreateQrCode(element.Data, eccLevel); - - var moduleCount = qrCodeData.ModuleMatrix.Count; - var moduleWidth = width / moduleCount; - var moduleHeight = height / moduleCount; - - var sb = new StringBuilder(1024); - sb.Append(""); - - if (element.Background is not null) - { - sb.Append(""); - } - - var pathData = BuildPathData(qrCodeData, moduleCount, moduleWidth, moduleHeight); - - if (pathData.Length > 0) - { - sb.Append(""); - } - - sb.Append(""); - return sb.ToString(); - } - - private static string BuildPathData( - QRCodeData qrCodeData, - int moduleCount, - float moduleWidth, - float moduleHeight) - { - var sb = new StringBuilder(moduleCount * moduleCount / 2); - - for (var row = 0; row < moduleCount; row++) - { - var col = 0; - while (col < moduleCount) - { - if (!qrCodeData.ModuleMatrix[row][col]) - { - col++; - continue; - } - - var runStart = col; - while (col < moduleCount && qrCodeData.ModuleMatrix[row][col]) - { - col++; - } - - var runLength = col - runStart; - - var x = runStart * moduleWidth; - var y = row * moduleHeight; - var w = runLength * moduleWidth; - - sb.Append('M').Append(F(x)).Append(' ').Append(F(y)); - sb.Append('h').Append(F(w)); - sb.Append('v').Append(F(moduleHeight)); - sb.Append('h').Append(F(-w)); - sb.Append('z'); - } - } - - return sb.ToString(); - } - - private static QRCodeGenerator.ECCLevel MapEccLevel(ErrorCorrectionLevel level) - { - return level switch - { - ErrorCorrectionLevel.L => QRCodeGenerator.ECCLevel.L, - ErrorCorrectionLevel.M => QRCodeGenerator.ECCLevel.M, - ErrorCorrectionLevel.Q => QRCodeGenerator.ECCLevel.Q, - ErrorCorrectionLevel.H => QRCodeGenerator.ECCLevel.H, - _ => QRCodeGenerator.ECCLevel.M - }; - } - - private static void ValidateDataCapacity(QrElement element) - { - var dataBytes = Encoding.UTF8.GetByteCount(element.Data); - var maxCapacity = MaxDataCapacity[element.ErrorCorrection]; - if (dataBytes > maxCapacity) - { - throw new ArgumentException( - $"QR code data ({dataBytes} bytes) exceeds maximum capacity for error correction level " + - $"{element.ErrorCorrection} ({maxCapacity} bytes).", - nameof(element)); - } - } - - private static string EscapeXml(string value) - { - if (value.AsSpan().IndexOfAny("&<>\"'") < 0) - return value; - return value.Replace("&", "&") - .Replace("<", "<") - .Replace(">", ">") - .Replace("\"", """) - .Replace("'", "'"); - } - - private static string F(float value) - { - return value.ToString("G", CultureInfo.InvariantCulture); - } -} -``` - -**Important:** The existing `QrProviderTests` call `_provider.Generate(element)` which is the old signature with a single argument returning `SKBitmap`. These tests need to be updated to call the static helper or use `ISkiaNativeProvider`. Update the existing tests in `QrProviderTests` to change: -- `_provider.Generate(element)` becomes `((ISkiaNativeProvider)_provider).GenerateBitmap(element, element.Size ?? 100, element.Size ?? 100)` -- `QrProvider.Generate(element, null, null)` becomes `((ISkiaNativeProvider)_provider).GenerateBitmap(element, element.Size ?? 100, element.Size ?? 100)` (the static method is removed). - -This is a breaking change to the existing test file. Rewrite the entire test file to use the new interfaces. - -**Step 4: Run test (expect success)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "QrProviderTests" --no-restore -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "refactor(qr): update QrProvider to implement new Core interfaces" -``` - ---- - -### Task 4: Update BarcodeProvider to Implement New Interfaces - -**Files:** -- Modify: `src/FlexRender.Barcode/Providers/BarcodeProvider.cs` -- Modify: `tests/FlexRender.Tests/Providers/BarcodeProviderTests.cs` - -**Step 1: Write failing test** - -Add to `tests/FlexRender.Tests/Providers/BarcodeProviderTests.cs`: - -```csharp - /// - /// Verifies BarcodeProvider implements the new Core IContentProvider with ContentResult. - /// - [Fact] - public void Generate_NewInterface_ReturnsContentResult() - { - var element = new BarcodeElement - { - Data = "ABC123", - Format = BarcodeFormat.Code128 - }; - - IContentProvider provider = _provider; - var result = provider.Generate(element, 200, 80); - - Assert.NotNull(result.PngBytes); - Assert.True(result.PngBytes.Length > 0); - Assert.Equal(200, result.Width); - Assert.Equal(80, result.Height); - // Verify PNG magic bytes - Assert.Equal(137, result.PngBytes[0]); - } - - /// - /// Verifies BarcodeProvider implements ISkiaNativeProvider. - /// - [Fact] - public void BarcodeProvider_ImplementsISkiaNativeProvider() - { - Assert.IsAssignableFrom>(_provider); - } -``` - -**Step 2: Run test (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "BarcodeProviderTests" --no-restore -``` - -Expected: FAIL - compilation errors because `Generate(element)` signature mismatch. - -**Step 3: Implement** - -Update `src/FlexRender.Barcode/Providers/BarcodeProvider.cs` to implement `IContentProvider` (new signature), `ISkiaNativeProvider`: - -The pattern is the same as QrProvider: -- `IContentProvider.Generate(element, width, height)` generates `SKBitmap`, PNG-encodes it, returns `ContentResult` -- `ISkiaNativeProvider.GenerateBitmap(element, width, height)` returns `SKBitmap` directly -- Remove the old `Generate(BarcodeElement element)` single-arg method -- Keep the static `Generate(element, layoutWidth, layoutHeight)` as a private `GenerateSkBitmap` method - -Update existing tests in `BarcodeProviderTests` to use `ISkiaNativeProvider` for bitmap-returning tests, similar to QrProvider changes. - -**Step 4: Run test (expect success)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "BarcodeProviderTests" --no-restore -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "refactor(barcode): update BarcodeProvider to implement new Core interfaces" -``` - ---- - -### Task 5: Update SvgElementProvider to Implement New Interfaces - -**Files:** -- Modify: `src/FlexRender.SvgElement/Providers/SvgElementProvider.cs` -- Modify: `tests/FlexRender.Tests/Providers/SvgElementProviderTests.cs` - -**Step 1: Write failing test** - -Add to `tests/FlexRender.Tests/Providers/SvgElementProviderTests.cs`: - -```csharp - /// - /// Verifies SvgElementProvider implements the new Core IContentProvider with ContentResult. - /// - [Fact] - public void Generate_NewInterface_ReturnsContentResult() - { - var provider = CreateProviderWithoutLoaders(); - var element = new SvgAstElement - { - Content = MinimalSvg, - SvgWidth = 50, - SvgHeight = 50 - }; - - IContentProvider contentProvider = provider; - var result = contentProvider.Generate(element, 50, 50); - - result.PngBytes.Should().NotBeNull(); - result.PngBytes.Length.Should().BeGreaterThan(0); - result.Width.Should().Be(50); - result.Height.Should().Be(50); - } - - /// - /// Verifies SvgElementProvider implements ISkiaNativeProvider. - /// - [Fact] - public void SvgElementProvider_ImplementsISkiaNativeProvider() - { - var provider = CreateProviderWithoutLoaders(); - provider.Should().BeAssignableTo>(); - } -``` - -**Step 2: Run test (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "SvgElementProviderTests" --no-restore -``` - -Expected: FAIL - -**Step 3: Implement** - -Update `src/FlexRender.SvgElement/Providers/SvgElementProvider.cs`: -- Implement `IContentProvider` (new signature with `int width, int height`, returns `ContentResult`) -- Implement `ISkiaNativeProvider` (returns `SKBitmap` directly) -- The old `Generate(SvgElement element)` becomes `GenerateBitmap(SvgElement element, int width, int height)` for the native provider -- `Generate(SvgElement element, int width, int height)` for ContentResult PNG-encodes the bitmap - -Update existing tests that called `provider.Generate(element)` (old single-arg signature) to use `((ISkiaNativeProvider)provider).GenerateBitmap(element, width, height)`. - -**Step 4: Run test (expect success)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "SvgElementProviderTests" --no-restore -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "refactor(svg-element): update SvgElementProvider to implement new Core interfaces" -``` - ---- - -### Task 6: Update SkiaBuilder and Skia RenderingEngine to Use New Interfaces - -The Skia rendering engine currently calls `_qrProvider.Generate(qr)` which returns `SKBitmap`. It needs to check for `ISkiaNativeProvider` first (zero-copy), then fall back to `IContentProvider` (PNG decode). - -**Files:** -- Modify: `src/FlexRender.Skia/SkiaBuilder.cs` -- Modify: `src/FlexRender.Skia/Rendering/RenderingEngine.cs` -- Modify: `src/FlexRender.Skia/Rendering/SkiaRenderer.cs` -- Modify: `src/FlexRender.Skia/SkiaRender.cs` - -**Step 1: Write failing test** - -No new test file needed. The existing snapshot/integration tests will validate this. But we need the solution to compile first. - -**Step 2: Run build (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet build FlexRender.slnx -``` - -Expected: FAIL - compilation errors in RenderingEngine, SkiaRenderer because they reference the old `Generate(element)` method. - -**Step 3: Implement** - -Update `src/FlexRender.Skia/Rendering/RenderingEngine.cs`: - -In the `DrawElement` method, change the QR/Barcode/Svg provider dispatch: - -```csharp -case QrElement qr when _qrProvider is ISkiaNativeProvider nativeQr: - using (var bitmap = nativeQr.GenerateBitmap(qr, (int)width, (int)height)) - { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); - } - break; - -case QrElement qr when _qrProvider is not null: - var qrResult = _qrProvider.Generate(qr, (int)width, (int)height); - using (var bitmap = SKBitmap.Decode(qrResult.PngBytes)) - { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); - } - break; - -case BarcodeElement barcode when _barcodeProvider is ISkiaNativeProvider nativeBarcode: - using (var bitmap = nativeBarcode.GenerateBitmap(barcode, (int)width, (int)height)) - { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); - } - break; - -case BarcodeElement barcode when _barcodeProvider is not null: - var barcodeResult = _barcodeProvider.Generate(barcode, (int)width, (int)height); - using (var bitmap = SKBitmap.Decode(barcodeResult.PngBytes)) - { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); - } - break; - -case SvgElement svg when _svgProvider is ISkiaNativeProvider nativeSvg: - using (var bitmap = nativeSvg.GenerateBitmap(svg, (int)width, (int)height)) - { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); - } - break; - -case SvgElement svg when _svgProvider is not null: - var svgResult = _svgProvider.Generate(svg, (int)width, (int)height); - using (var bitmap = SKBitmap.Decode(svgResult.PngBytes)) - { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); - } - break; -``` - -Update `src/FlexRender.Skia/SkiaBuilder.cs`: No signature changes needed -- the property types already use `IContentProvider` from `FlexRender.Providers` namespace, which now resolves to the Core version. - -Update `src/FlexRender.Skia/Rendering/SkiaRenderer.cs`: The constructor takes `IContentProvider?` etc. These should still compile since the namespace hasn't changed. Verify and fix if needed. - -**Step 4: Run build and tests (expect success)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet build FlexRender.slnx && dotnet test tests/FlexRender.Tests --no-restore -``` - -Expected: PASS (all existing tests pass) - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "refactor(skia): update rendering engine to use ISkiaNativeProvider with ContentResult fallback" -``` - ---- - -### Task 7: Update SvgRenderingEngine to Use ContentResult (Remove SKBitmap Dependency) - -The SVG rendering engine currently uses `SKBitmap`, `SKImage`, and `SKData` to encode bitmaps for base64 embedding. Replace this with `ContentResult.PngBytes` which are already PNG-encoded. - -**Files:** -- Modify: `src/FlexRender.Svg/Rendering/SvgRenderingEngine.cs` -- Modify: `src/FlexRender.Svg/SvgRender.cs` -- Modify: `src/FlexRender.Svg/SvgBuilderExtensions.cs` -- Modify: `src/FlexRender.Svg/FlexRender.Svg.csproj` (remove Skia dependency) -- Test: existing SVG rendering tests - -**Step 1: Write failing test** - -No new test file. We need the build to pass with the Skia dependency removed. - -**Step 2: Implement** - -Update `src/FlexRender.Svg/Rendering/SvgRenderingEngine.cs`: - -1. Remove `using SkiaSharp;` from the top. -2. Remove the `DrawBitmapElement(SKBitmap ...)` method. -3. Add a new `DrawBitmapElement(ContentResult ...)` method that takes `ContentResult` and base64-encodes `PngBytes` directly: - -```csharp - private static void DrawBitmapElement( - StringBuilder sb, - ContentResult content, - float x, - float y, - float width, - float height) - { - var base64 = Convert.ToBase64String(content.PngBytes); - - sb.Append(""); - } -``` - -4. Update the QR/Barcode dispatch in `DrawElement`: - -```csharp - case QrElement qr when _qrProvider is ISvgContentProvider svgQrProvider: - DrawSvgContentProvider(sb, svgQrProvider, qr, x, y, width, height); - break; - - case QrElement qr when _qrProvider is not null: - { - var content = _qrProvider.Generate(qr, (int)width, (int)height); - DrawBitmapElement(sb, content, x, y, width, height); - break; - } - - case BarcodeElement barcode when _barcodeProvider is ISvgContentProvider svgBarcodeProvider: - DrawSvgContentProvider(sb, svgBarcodeProvider, barcode, x, y, width, height); - break; - - case BarcodeElement barcode when _barcodeProvider is not null: - { - var content = _barcodeProvider.Generate(barcode, (int)width, (int)height); - DrawBitmapElement(sb, content, x, y, width, height); - break; - } -``` - -5. The `BuildFontMap` method uses `SKData`, `SKTypeface` from SkiaSharp to extract font family names. Since we are removing the Skia dependency, replace this with simple file name extraction (use the font definition's name or fallback). The `BuildFontMap` method should: - - Read font bytes from file - - Use the file name (without extension) as the font family name instead of `SKTypeface.FromData` - - Skip `SKData.CreateCopy` entirely - -Replace the font family extraction block: -```csharp -// Before: using (var skData = SKData.CreateCopy(fontBytes)) -// using (var typeface = SKTypeface.FromData(skData)) -// { familyName = typeface?.FamilyName ?? ... } - -// After: Use the font definition name or the file stem as fallback -familyName = fontName; -``` - -This means SVG font-face declarations will use the template font name as the CSS family name. This is actually more predictable than relying on the typeface's internal name. - -6. Update `SvgRender.cs` constructor to accept `ISvgContentProvider?` as well. - -7. Update `SvgBuilderExtensions.cs` to pass SVG content providers from the SvgBuilder. - -8. Update `FlexRender.Svg.csproj` to remove the Skia project reference: - -```xml - - - FlexRender.Svg - SVG vector output renderer for FlexRender. Generates scalable SVG from the same layout tree. - true - - - - - - - - - - - -``` - -**Step 3: Run build and tests** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet build FlexRender.slnx && dotnet test FlexRender.slnx --no-restore -``` - -Expected: PASS (all existing tests pass) - -**Step 4: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "refactor(svg): remove FlexRender.Svg dependency on FlexRender.Skia" -``` - ---- - -### Task 8: Update SvgBuilder With SVG-Native Provider Slots - -Update the SvgBuilder to accept SVG-native providers directly, not just through the Skia sub-builder. - -**Files:** -- Modify: `src/FlexRender.Svg/SvgBuilder.cs` -- Modify: `src/FlexRender.Svg/SvgBuilderExtensions.cs` -- Modify: `src/FlexRender.Svg/SvgRender.cs` -- Modify: `src/FlexRender.Svg/Rendering/SvgRenderingEngine.cs` -- Test: `tests/FlexRender.Tests/Rendering/SvgBuilderTests.cs` - -**Step 1: Write failing test** - -Create `tests/FlexRender.Tests/Rendering/SvgBuilderTests.cs`: - -```csharp -using FlexRender.Parsing.Ast; -using FlexRender.Providers; -using FlexRender.Svg; -using Xunit; - -namespace FlexRender.Tests.Rendering; - -/// -/// Tests for the updated SvgBuilder with SVG-native provider slots. -/// -public sealed class SvgBuilderTests -{ - [Fact] - public void SvgBuilder_SetQrSvgProvider_StoresProvider() - { - var builder = new SvgBuilder(); - var provider = new FakeQrSvgProvider(); - - builder.SetQrSvgProvider(provider); - - Assert.Same(provider, builder.QrSvgProvider); - } - - [Fact] - public void SvgBuilder_SetBarcodeSvgProvider_StoresProvider() - { - var builder = new SvgBuilder(); - var provider = new FakeBarcodeSvgProvider(); - - builder.SetBarcodeSvgProvider(provider); - - Assert.Same(provider, builder.BarcodeSvgProvider); - } - - [Fact] - public void SvgBuilder_SetQrRasterProvider_StoresProvider() - { - var builder = new SvgBuilder(); - var provider = new FakeQrRasterProvider(); - - builder.SetQrRasterProvider(provider); - - Assert.Same(provider, builder.QrRasterProvider); - } - - [Fact] - public void SvgBuilder_SetBarcodeRasterProvider_StoresProvider() - { - var builder = new SvgBuilder(); - var provider = new FakeBarcodeRasterProvider(); - - builder.SetBarcodeRasterProvider(provider); - - Assert.Same(provider, builder.BarcodeRasterProvider); - } - - private sealed class FakeQrSvgProvider : ISvgContentProvider - { - public string GenerateSvgContent(QrElement element, float width, float height) => ""; - } - - private sealed class FakeBarcodeSvgProvider : ISvgContentProvider - { - public string GenerateSvgContent(BarcodeElement element, float width, float height) => ""; - } - - private sealed class FakeQrRasterProvider : IContentProvider - { - public ContentResult Generate(QrElement element, int width, int height) => - new(Array.Empty(), width, height); - } - - private sealed class FakeBarcodeRasterProvider : IContentProvider - { - public ContentResult Generate(BarcodeElement element, int width, int height) => - new(Array.Empty(), width, height); - } -} -``` - -**Step 2: Run test (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "SvgBuilderTests" --no-restore -``` - -Expected: FAIL - `SetQrSvgProvider`, `QrSvgProvider` etc. do not exist. - -**Step 3: Implement** - -Update `src/FlexRender.Svg/SvgBuilder.cs` to add provider slots: - -```csharp -using FlexRender.Abstractions; -using FlexRender.Configuration; -using FlexRender.Parsing.Ast; -using FlexRender.Providers; - -namespace FlexRender.Svg; - -/// -/// Builder for configuring SVG rendering options, SVG-native providers, and optional raster backend. -/// -public sealed class SvgBuilder -{ - private Action? _skiaConfigureAction; - private bool _skiaEnabled; - private Func? _rasterFactory; - - internal bool IsSkiaEnabled => _skiaEnabled; - internal Action? SkiaConfigureAction => _skiaConfigureAction; - internal Func? RasterFactory => _rasterFactory; - - // SVG-native providers - internal ISvgContentProvider? QrSvgProvider { get; private set; } - internal ISvgContentProvider? BarcodeSvgProvider { get; private set; } - internal ISvgContentProvider? SvgElementSvgProvider { get; private set; } - - // Raster providers (backend-neutral, used when no SVG-native provider is available) - internal IContentProvider? QrRasterProvider { get; private set; } - internal IContentProvider? BarcodeRasterProvider { get; private set; } - - /// - /// Sets the SVG-native QR code content provider. - /// - internal void SetQrSvgProvider(ISvgContentProvider provider) - { - ArgumentNullException.ThrowIfNull(provider); - QrSvgProvider = provider; - } - - /// - /// Sets the SVG-native barcode content provider. - /// - internal void SetBarcodeSvgProvider(ISvgContentProvider provider) - { - ArgumentNullException.ThrowIfNull(provider); - BarcodeSvgProvider = provider; - } - - /// - /// Sets the SVG-native SVG element content provider. - /// - internal void SetSvgElementSvgProvider(ISvgContentProvider provider) - { - ArgumentNullException.ThrowIfNull(provider); - SvgElementSvgProvider = provider; - } - - /// - /// Sets the raster QR code content provider (for PNG-to-base64 embedding). - /// - internal void SetQrRasterProvider(IContentProvider provider) - { - ArgumentNullException.ThrowIfNull(provider); - QrRasterProvider = provider; - } - - /// - /// Sets the raster barcode content provider (for PNG-to-base64 embedding). - /// - internal void SetBarcodeRasterProvider(IContentProvider provider) - { - ArgumentNullException.ThrowIfNull(provider); - BarcodeRasterProvider = provider; - } - - /// - /// Enables the Skia raster backend for PNG, JPEG, BMP, and Raw output alongside SVG. - /// - public SvgBuilder WithSkia(Action? configure = null) - { - if (_rasterFactory is not null) - { - throw new InvalidOperationException( - "A raster backend is already configured via WithRasterBackend(). Cannot also use WithSkia()."); - } - _skiaEnabled = true; - _skiaConfigureAction = configure; - return this; - } - - /// - /// Sets a custom raster rendering backend for PNG, JPEG, BMP, and Raw output alongside SVG. - /// - public SvgBuilder WithRasterBackend(Func factory) - { - ArgumentNullException.ThrowIfNull(factory); - if (_skiaEnabled) - { - throw new InvalidOperationException( - "Skia raster backend is already configured via WithSkia(). Cannot also use WithRasterBackend()."); - } - _rasterFactory = factory; - return this; - } -} -``` - -Note: The `SvgBuilder.WithSkia` method references `SkiaBuilder` which is in `FlexRender.Skia`. Since we removed the Skia dependency from `FlexRender.Svg`, this will not compile. We need to make `WithSkia` available only when `FlexRender.Skia` is referenced. The solution: move `WithSkia` to an extension method in `FlexRender.Skia` instead of being a method on `SvgBuilder`. - -Actually, looking more carefully: `SvgBuilder` currently references `SkiaBuilder` directly. After removing the Skia dependency, `SvgBuilder` cannot reference `SkiaBuilder`. The solution is: - -1. `SvgBuilder` stores a generic `Action?` configure action and a type discriminator -2. OR better: `SvgBuilder.WithSkia()` and `SkiaConfigureAction` stay but are gated by a `dynamic` or delegate approach - -The simplest approach is to use `Func?` for ALL raster backends including Skia. The `WithSkia()` extension method on `SvgBuilder` would be defined in `FlexRender.Skia` (not in `FlexRender.Svg`): - -Remove the `WithSkia` method from `SvgBuilder`. Make `SvgBuilder` only have the raster factory approach. Then in `FlexRender.Skia`, add a `SvgBuilderSkiaExtensions` class with `WithSkia(this SvgBuilder builder, Action? configure = null)` that calls `builder.WithRasterBackend(...)`. - -Update `src/FlexRender.Svg/SvgBuilder.cs` accordingly -- remove `WithSkia`, `_skiaEnabled`, `_skiaConfigureAction`, `IsSkiaEnabled`, `SkiaConfigureAction`. Keep only `WithRasterBackend` and the provider slots. - -Then add to `FlexRender.Skia` a new extension method file for `SvgBuilder`: - -Create `src/FlexRender.Skia/SvgBuilderSkiaExtensions.cs`: - -```csharp -using FlexRender.Configuration; -using FlexRender.Svg; - -namespace FlexRender; - -/// -/// Extension methods for configuring Skia as the raster backend for SVG rendering. -/// -public static class SvgBuilderSkiaExtensions -{ - /// - /// Enables the Skia raster backend for PNG, JPEG, BMP, and Raw output alongside SVG. - /// Also extracts Skia providers (QR, Barcode, SVG element) for SVG embedding. - /// - public static SvgBuilder WithSkia(this SvgBuilder builder, Action? configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - - var skiaBuilder = new SkiaBuilder(); - configure?.Invoke(skiaBuilder); - - // Extract providers for SVG embedding - if (skiaBuilder.QrProvider is not null) - { - builder.SetQrRasterProvider(skiaBuilder.QrProvider); - if (skiaBuilder.QrProvider is Providers.ISvgContentProvider svgQr) - { - builder.SetQrSvgProvider(svgQr); - } - } - - if (skiaBuilder.BarcodeProvider is not null) - { - builder.SetBarcodeRasterProvider(skiaBuilder.BarcodeProvider); - if (skiaBuilder.BarcodeProvider is Providers.ISvgContentProvider svgBarcode) - { - builder.SetBarcodeSvgProvider(svgBarcode); - } - } - - builder.WithRasterBackend(b => new Skia.SkiaRender( - b.Limits, - b.Options, - b.ResourceLoaders, - skiaBuilder, - b.FilterRegistry)); - - return builder; - } -} -``` - -Then update `SvgBuilderExtensions.cs` to wire the providers from `SvgBuilder` into `SvgRender`: - -```csharp -builder.SetRendererFactory(b => -{ - Abstractions.IFlexRender? rasterRenderer = null; - - if (svgBuilder.RasterFactory is not null) - { - rasterRenderer = svgBuilder.RasterFactory(b); - } - - return new SvgRender( - b.Limits, - b.Options, - rasterRenderer, - svgBuilder.QrSvgProvider, - svgBuilder.QrRasterProvider, - svgBuilder.BarcodeSvgProvider, - svgBuilder.BarcodeRasterProvider, - svgBuilder.SvgElementSvgProvider); -}); -``` - -Update `SvgRender` constructor to accept the new provider parameters: - -```csharp -internal SvgRender( - ResourceLimits limits, - FlexRenderOptions options, - IFlexRender? rasterRenderer = null, - ISvgContentProvider? qrSvgProvider = null, - IContentProvider? qrRasterProvider = null, - ISvgContentProvider? barcodeSvgProvider = null, - IContentProvider? barcodeRasterProvider = null, - ISvgContentProvider? svgElementSvgProvider = null) -``` - -Update `SvgRenderingEngine` constructor to accept both SVG-native and raster providers, with priority: SVG-native > raster > null. - -**Step 4: Run tests** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet build FlexRender.slnx && dotnet test FlexRender.slnx --no-restore -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "refactor(svg): add SVG-native provider slots to SvgBuilder, move WithSkia to extension" -``` - ---- - -### Task 9: Update FlexRender.Skia InternalsVisibleTo and Fix Remaining Build Issues - -After the interface changes, some `InternalsVisibleTo` entries may need updating. The `FlexRender.Skia.csproj` has `InternalsVisibleTo` for `FlexRender.Barcode`, `FlexRender.QrCode`, `FlexRender.SvgElement`, `FlexRender.Svg`. Since `ISkiaNativeProvider` is internal, these projects need to keep their `InternalsVisibleTo`. Also, `FlexRender.Core.csproj` needs `InternalsVisibleTo` for the provider projects that access internal interfaces through it. - -**Files:** -- Modify: `src/FlexRender.Core/FlexRender.Core.csproj` (add InternalsVisibleTo for new projects if needed) -- Modify: `src/FlexRender.Skia/FlexRender.Skia.csproj` - -**Step 1: Build the entire solution** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet build FlexRender.slnx -``` - -Fix any remaining build errors. - -**Step 2: Run all tests** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test FlexRender.slnx -``` - -Expected: ALL tests pass. - -**Step 3: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "build: fix InternalsVisibleTo and remaining build issues after interface migration" -``` - ---- - -## Phase 2: Extract Encoding Core Projects - -### Task 10: Create FlexRender.Barcode.Core with Code128Encoder - -Extract the Code128 encoding table and checksum logic from `BarcodeProvider` into a shared project. - -**Files:** -- Create: `src/FlexRender.Barcode.Core/FlexRender.Barcode.Core.csproj` -- Create: `src/FlexRender.Barcode.Core/Code128Encoder.cs` -- Create: `tests/FlexRender.Tests/Encoding/Code128EncoderTests.cs` -- Modify: `FlexRender.slnx` (add new project) - -**Step 1: Write failing test** - -Create `tests/FlexRender.Tests/Encoding/Code128EncoderTests.cs`: - -```csharp -using FlexRender.Barcode.Core; -using Xunit; - -namespace FlexRender.Tests.Encoding; - -/// -/// Tests for . -/// -public sealed class Code128EncoderTests -{ - [Fact] - public void Encode_SimpleData_ReturnsPattern() - { - var result = Code128Encoder.Encode("ABC"); - - Assert.NotNull(result); - Assert.True(result.Length > 0); - // Pattern should only contain '0' and '1' - Assert.All(result, c => Assert.True(c == '0' || c == '1')); - } - - [Fact] - public void Encode_IncludesStartPattern() - { - var result = Code128Encoder.Encode("A"); - - // Code128 Start B pattern - Assert.StartsWith("11010010000", result); - } - - [Fact] - public void Encode_IncludesStopPattern() - { - var result = Code128Encoder.Encode("A"); - - Assert.EndsWith("1100011101011", result); - } - - [Fact] - public void Encode_SameInput_SameOutput() - { - var result1 = Code128Encoder.Encode("Hello"); - var result2 = Code128Encoder.Encode("Hello"); - - Assert.Equal(result1, result2); - } - - [Fact] - public void IsValidCode128B_ValidChars_ReturnsTrue() - { - Assert.True(Code128Encoder.IsValidCode128B("ABC123")); - Assert.True(Code128Encoder.IsValidCode128B("Hello World")); - Assert.True(Code128Encoder.IsValidCode128B("test-data")); - } - - [Fact] - public void IsValidCode128B_InvalidChars_ReturnsFalse() - { - Assert.False(Code128Encoder.IsValidCode128B("Test\x00Invalid")); - Assert.False(Code128Encoder.IsValidCode128B("\x01")); - } - - [Fact] - public void IsValidCode128B_EmptyString_ReturnsFalse() - { - Assert.False(Code128Encoder.IsValidCode128B("")); - Assert.False(Code128Encoder.IsValidCode128B(null!)); - } - - [Theory] - [InlineData("ABC123")] - [InlineData("Hello World")] - [InlineData("!@#$%^")] - public void Encode_VariousInputs_ProducesValidPatterns(string input) - { - var result = Code128Encoder.Encode(input); - - Assert.NotNull(result); - Assert.True(result.Length > 0); - } -} -``` - -**Step 2: Run test (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "Code128EncoderTests" --no-restore -``` - -Expected: FAIL - `FlexRender.Barcode.Core` namespace not found. - -**Step 3: Implement** - -Create directory: `src/FlexRender.Barcode.Core/` - -Create `src/FlexRender.Barcode.Core/FlexRender.Barcode.Core.csproj`: - -```xml - - - - FlexRender.Barcode.Core - Backend-neutral barcode encoding logic for FlexRender. No rendering dependencies. - true - - - - - - - - - - - -``` - -Create `src/FlexRender.Barcode.Core/Code128Encoder.cs`: - -```csharp -using System.Text; - -namespace FlexRender.Barcode.Core; - -/// -/// Encodes data into Code128 barcode patterns (character set B). -/// Returns a binary string of '0' and '1' representing bar/space patterns. -/// -/// -/// This class contains the shared encoding logic used by all rendering backends -/// (Skia, ImageSharp, SVG). It has no rendering dependencies. -/// -public static class Code128Encoder -{ - /// - /// Code 128 encoding table for character set B (ASCII 32-127). - /// - private static readonly Dictionary Code128BPatterns = new() - { - { ' ', "11011001100" }, { '!', "11001101100" }, { '"', "11001100110" }, - { '#', "10010011000" }, { '$', "10010001100" }, { '%', "10001001100" }, - { '&', "10011001000" }, { '\'', "10011000100" }, { '(', "10001100100" }, - { ')', "11001001000" }, { '*', "11001000100" }, { '+', "11000100100" }, - { ',', "10110011100" }, { '-', "10011011100" }, { '.', "10011001110" }, - { '/', "10111001100" }, { '0', "10011101100" }, { '1', "10011100110" }, - { '2', "11001110010" }, { '3', "11001011100" }, { '4', "11001001110" }, - { '5', "11011100100" }, { '6', "11001110100" }, { '7', "11101101110" }, - { '8', "11101001100" }, { '9', "11100101100" }, { ':', "11100100110" }, - { ';', "11101100100" }, { '<', "11100110100" }, { '=', "11100110010" }, - { '>', "11011011000" }, { '?', "11011000110" }, { '@', "11000110110" }, - { 'A', "10100011000" }, { 'B', "10001011000" }, { 'C', "10001000110" }, - { 'D', "10110001000" }, { 'E', "10001101000" }, { 'F', "10001100010" }, - { 'G', "11010001000" }, { 'H', "11000101000" }, { 'I', "11000100010" }, - { 'J', "10110111000" }, { 'K', "10110001110" }, { 'L', "10001101110" }, - { 'M', "10111011000" }, { 'N', "10111000110" }, { 'O', "10001110110" }, - { 'P', "11101110110" }, { 'Q', "11010001110" }, { 'R', "11000101110" }, - { 'S', "11011101000" }, { 'T', "11011100010" }, { 'U', "11011101110" }, - { 'V', "11101011000" }, { 'W', "11101000110" }, { 'X', "11100010110" }, - { 'Y', "11101101000" }, { 'Z', "11101100010" }, { '[', "11100011010" }, - { '\\', "11101111010" }, { ']', "11001000010" }, { '^', "11110001010" }, - { '_', "10100110000" }, { '`', "10100001100" }, { 'a', "10010110000" }, - { 'b', "10010000110" }, { 'c', "10000101100" }, { 'd', "10000100110" }, - { 'e', "10110010000" }, { 'f', "10110000100" }, { 'g', "10011010000" }, - { 'h', "10011000010" }, { 'i', "10000110100" }, { 'j', "10000110010" }, - { 'k', "11000010010" }, { 'l', "11001010000" }, { 'm', "11110111010" }, - { 'n', "11000010100" }, { 'o', "10001111010" }, { 'p', "10100111100" }, - { 'q', "10010111100" }, { 'r', "10010011110" }, { 's', "10111100100" }, - { 't', "10011110100" }, { 'u', "10011110010" }, { 'v', "11110100100" }, - { 'w', "11110010100" }, { 'x', "11110010010" }, { 'y', "11011011110" }, - { 'z', "11011110110" }, { '{', "11110110110" }, { '|', "10101111000" }, - { '}', "10100011110" }, { '~', "10001011110" } - }; - - /// - /// Code 128 start pattern for code set B. - /// - private const string Code128StartB = "11010010000"; - - /// - /// Code 128 stop pattern. - /// - private const string Code128Stop = "1100011101011"; - - /// - /// Encodes the specified data into a Code128B bar pattern string. - /// - /// The ASCII text to encode (characters 32-126). - /// A string of '0' and '1' characters representing the barcode pattern. - /// - /// Thrown when is null/empty or contains unsupported characters. - /// - public static string Encode(string data) - { - if (string.IsNullOrEmpty(data)) - { - throw new ArgumentException("Barcode data cannot be empty.", nameof(data)); - } - - foreach (var c in data) - { - if (!Code128BPatterns.ContainsKey(c)) - { - throw new ArgumentException( - $"Character '{c}' (ASCII {(int)c}) is not supported in Code 128B. Supported range: ASCII 32-126.", - nameof(data)); - } - } - - var patternBuilder = new StringBuilder(Code128StartB); - - var checksum = 104; // Start B code value - var position = 1; - - foreach (var c in data) - { - patternBuilder.Append(Code128BPatterns[c]); - var codeValue = c - 32; - checksum += codeValue * position; - position++; - } - - checksum %= 103; - var checksumChar = (char)(checksum + 32); - if (Code128BPatterns.TryGetValue(checksumChar, out var checksumPattern)) - { - patternBuilder.Append(checksumPattern); - } - - patternBuilder.Append(Code128Stop); - return patternBuilder.ToString(); - } - - /// - /// Validates whether all characters in the data are valid Code128B characters. - /// - /// The data to validate. - /// true if all characters are valid Code128B characters; otherwise false. - public static bool IsValidCode128B(string data) - { - if (string.IsNullOrEmpty(data)) - { - return false; - } - - foreach (var c in data) - { - if (!Code128BPatterns.ContainsKey(c)) - { - return false; - } - } - - return true; - } -} -``` - -Add to `FlexRender.slnx`: - -```xml - -``` - -Add project reference to test project `tests/FlexRender.Tests/FlexRender.Tests.csproj`: - -```xml - -``` - -**Step 4: Run test (expect success)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "Code128EncoderTests" --no-restore -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "feat(barcode-core): extract Code128Encoder to FlexRender.Barcode.Core" -``` - ---- - -### Task 11: Create FlexRender.QrCode.Core with QrEncoder and QrSvgPathBuilder - -Extract QR encoding logic, data validation, and SVG path building from QrProvider into a shared project. - -**Files:** -- Create: `src/FlexRender.QrCode.Core/FlexRender.QrCode.Core.csproj` -- Create: `src/FlexRender.QrCode.Core/QrEncoder.cs` -- Create: `src/FlexRender.QrCode.Core/QrDataValidator.cs` -- Create: `src/FlexRender.QrCode.Core/QrSvgPathBuilder.cs` -- Create: `tests/FlexRender.Tests/Encoding/QrEncoderTests.cs` -- Modify: `FlexRender.slnx` - -**Step 1: Write failing test** - -Create `tests/FlexRender.Tests/Encoding/QrEncoderTests.cs`: - -```csharp -using FlexRender.QrCode.Core; -using FlexRender.Parsing.Ast; -using Xunit; - -namespace FlexRender.Tests.Encoding; - -/// -/// Tests for QR encoding core logic. -/// -public sealed class QrEncoderTests -{ - [Fact] - public void Encode_SimpleData_ReturnsModuleMatrix() - { - var result = QrEncoder.Encode("Hello", ErrorCorrectionLevel.M); - - Assert.NotNull(result.ModuleMatrix); - Assert.True(result.ModuleCount > 0); - Assert.Equal(result.ModuleCount, result.ModuleMatrix.Count); - } - - [Theory] - [InlineData(ErrorCorrectionLevel.L)] - [InlineData(ErrorCorrectionLevel.M)] - [InlineData(ErrorCorrectionLevel.Q)] - [InlineData(ErrorCorrectionLevel.H)] - public void Encode_AllEccLevels_Succeeds(ErrorCorrectionLevel level) - { - var result = QrEncoder.Encode("Test", level); - Assert.True(result.ModuleCount > 0); - } - - [Fact] - public void ValidateDataCapacity_WithinCapacity_DoesNotThrow() - { - var exception = Record.Exception(() => - QrDataValidator.ValidateDataCapacity("Hello", ErrorCorrectionLevel.M)); - - Assert.Null(exception); - } - - [Fact] - public void ValidateDataCapacity_ExceedsCapacity_ThrowsArgumentException() - { - var longData = new string('A', 3000); - - Assert.Throws(() => - QrDataValidator.ValidateDataCapacity(longData, ErrorCorrectionLevel.L)); - } - - [Fact] - public void BuildSvgPathData_ReturnsPathCommands() - { - var result = QrEncoder.Encode("Hello", ErrorCorrectionLevel.M); - var pathData = QrSvgPathBuilder.BuildPathData( - result.ModuleMatrix, result.ModuleCount, 10f, 10f); - - Assert.Contains("M", pathData); - Assert.Contains("h", pathData); - Assert.Contains("v", pathData); - Assert.Contains("z", pathData); - } -} -``` - -**Step 2: Run test (expect failure)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "QrEncoderTests" --no-restore -``` - -Expected: FAIL - -**Step 3: Implement** - -Create `src/FlexRender.QrCode.Core/FlexRender.QrCode.Core.csproj`: - -```xml - - - - FlexRender.QrCode.Core - Backend-neutral QR code encoding logic for FlexRender. No rendering dependencies. - true - - - - - - - - - - - - - - - -``` - -Create `src/FlexRender.QrCode.Core/QrEncoder.cs`: - -```csharp -using FlexRender.Parsing.Ast; -using QRCoder; - -namespace FlexRender.QrCode.Core; - -/// -/// Encodes data into a QR code module matrix using QRCoder. -/// -public static class QrEncoder -{ - /// - /// Encodes the specified data into a QR code module matrix. - /// - /// The data to encode. - /// The error correction level. - /// A containing the module matrix and count. - /// Thrown when data is null or empty. - public static QrEncodeResult Encode(string data, ErrorCorrectionLevel errorCorrectionLevel) - { - if (string.IsNullOrEmpty(data)) - { - throw new ArgumentException("QR code data cannot be empty.", nameof(data)); - } - - QrDataValidator.ValidateDataCapacity(data, errorCorrectionLevel); - - var eccLevel = MapEccLevel(errorCorrectionLevel); - - using var qrGenerator = new QRCodeGenerator(); - using var qrCodeData = qrGenerator.CreateQrCode(data, eccLevel); - - var moduleCount = qrCodeData.ModuleMatrix.Count; - - // Copy the module matrix to avoid holding onto QRCodeData - var matrix = new List(moduleCount); - for (var i = 0; i < moduleCount; i++) - { - var row = new bool[moduleCount]; - Array.Copy(qrCodeData.ModuleMatrix[i], row, moduleCount); - matrix.Add(row); - } - - return new QrEncodeResult(matrix, moduleCount); - } - - /// - /// Maps the FlexRender error correction level to QRCoder's enum. - /// - internal static QRCodeGenerator.ECCLevel MapEccLevel(ErrorCorrectionLevel level) - { - return level switch - { - ErrorCorrectionLevel.L => QRCodeGenerator.ECCLevel.L, - ErrorCorrectionLevel.M => QRCodeGenerator.ECCLevel.M, - ErrorCorrectionLevel.Q => QRCodeGenerator.ECCLevel.Q, - ErrorCorrectionLevel.H => QRCodeGenerator.ECCLevel.H, - _ => QRCodeGenerator.ECCLevel.M - }; - } -} - -/// -/// Result of QR code encoding containing the module matrix and dimensions. -/// -/// The QR code module matrix (true = dark module). -/// The number of modules per side. -public sealed record QrEncodeResult(IReadOnlyList ModuleMatrix, int ModuleCount); -``` - -Create `src/FlexRender.QrCode.Core/QrDataValidator.cs`: - -```csharp -using System.Text; -using FlexRender.Parsing.Ast; - -namespace FlexRender.QrCode.Core; - -/// -/// Validates QR code data against capacity limits for each error correction level. -/// -public static class QrDataValidator -{ - /// - /// Maximum data capacity in bytes for each error correction level. - /// - private static readonly Dictionary MaxDataCapacity = new() - { - { ErrorCorrectionLevel.L, 2953 }, - { ErrorCorrectionLevel.M, 2331 }, - { ErrorCorrectionLevel.Q, 1663 }, - { ErrorCorrectionLevel.H, 1273 } - }; - - /// - /// Validates that the data fits within the QR code capacity for the given error correction level. - /// - /// The data to validate. - /// The error correction level. - /// Thrown when data exceeds capacity. - public static void ValidateDataCapacity(string data, ErrorCorrectionLevel errorCorrectionLevel) - { - var dataBytes = Encoding.UTF8.GetByteCount(data); - var maxCapacity = MaxDataCapacity[errorCorrectionLevel]; - if (dataBytes > maxCapacity) - { - throw new ArgumentException( - $"QR code data ({dataBytes} bytes) exceeds maximum capacity for error correction level " + - $"{errorCorrectionLevel} ({maxCapacity} bytes).", - nameof(data)); - } - } -} -``` - -Create `src/FlexRender.QrCode.Core/QrSvgPathBuilder.cs`: - -```csharp -using System.Globalization; -using System.Text; - -namespace FlexRender.QrCode.Core; - -/// -/// Builds optimized SVG path data from a QR code module matrix. -/// Uses horizontal run-length encoding for compact output. -/// -public static class QrSvgPathBuilder -{ - /// - /// Builds SVG path data from a module matrix using horizontal run-length encoding. - /// - /// The QR code module matrix. - /// The number of modules per side. - /// The width of each module in SVG user units. - /// The height of each module in SVG user units. - /// SVG path data string with M, h, v, z commands. - public static string BuildPathData( - IReadOnlyList moduleMatrix, - int moduleCount, - float moduleWidth, - float moduleHeight) - { - ArgumentNullException.ThrowIfNull(moduleMatrix); - - var sb = new StringBuilder(moduleCount * moduleCount / 2); - - for (var row = 0; row < moduleCount; row++) - { - var col = 0; - while (col < moduleCount) - { - if (!moduleMatrix[row][col]) - { - col++; - continue; - } - - var runStart = col; - while (col < moduleCount && moduleMatrix[row][col]) - { - col++; - } - - var runLength = col - runStart; - - var x = runStart * moduleWidth; - var y = row * moduleHeight; - var w = runLength * moduleWidth; - - sb.Append('M').Append(F(x)).Append(' ').Append(F(y)); - sb.Append('h').Append(F(w)); - sb.Append('v').Append(F(moduleHeight)); - sb.Append('h').Append(F(-w)); - sb.Append('z'); - } - } - - return sb.ToString(); - } - - /// - /// Escapes XML special characters in attribute values. - /// - public static string EscapeXml(string value) - { - if (value.AsSpan().IndexOfAny("&<>\"'") < 0) - return value; - return value.Replace("&", "&") - .Replace("<", "<") - .Replace(">", ">") - .Replace("\"", """) - .Replace("'", "'"); - } - - /// - /// Formats a float using invariant culture with no trailing zeros. - /// - public static string F(float value) - { - return value.ToString("G", CultureInfo.InvariantCulture); - } -} -``` - -Add to `FlexRender.slnx`, test project references, etc. - -**Step 4: Run test (expect success)** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test tests/FlexRender.Tests --filter "QrEncoderTests" --no-restore -``` - -Expected: PASS - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "feat(qr-core): extract QrEncoder, QrDataValidator, QrSvgPathBuilder to FlexRender.QrCode.Core" -``` - ---- - -### Task 12: Update Existing Providers to Use Core Encoding Projects - -Update `BarcodeProvider` to delegate to `Code128Encoder` and `QrProvider` to delegate to `QrEncoder`/`QrSvgPathBuilder`. Delete the duplicated code from `FlexRender.ImageSharp`. - -**Files:** -- Modify: `src/FlexRender.Barcode/FlexRender.Barcode.csproj` (add Barcode.Core dependency) -- Modify: `src/FlexRender.Barcode/Providers/BarcodeProvider.cs` -- Modify: `src/FlexRender.QrCode/FlexRender.QrCode.csproj` (add QrCode.Core dependency) -- Modify: `src/FlexRender.QrCode/Providers/QrProvider.cs` - -**Step 1: Update BarcodeProvider to use Code128Encoder** - -In `src/FlexRender.Barcode/FlexRender.Barcode.csproj`, add: -```xml - -``` - -In `BarcodeProvider.cs`, replace the inline `Code128BPatterns` dictionary, `Code128StartB`, `Code128Stop`, checksum calculation, and character validation with a single call to `Code128Encoder.Encode(element.Data)`. Delete those members from BarcodeProvider. - -**Step 2: Update QrProvider to use QrEncoder and QrSvgPathBuilder** - -In `src/FlexRender.QrCode/FlexRender.QrCode.csproj`, add: -```xml - -``` - -In `QrProvider.cs`, replace the inline `MaxDataCapacity`, `MapEccLevel`, `ValidateDataCapacity`, `BuildPathData`, `EscapeXml`, and `F` methods with calls to `QrEncoder`, `QrDataValidator`, `QrSvgPathBuilder`. - -**Step 3: Run all tests** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test FlexRender.slnx -``` - -Expected: ALL tests pass. - -**Step 4: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "refactor: update QR and barcode providers to use shared Core encoding projects" -``` - ---- - -### Task 13: Delete Duplicated Code from FlexRender.ImageSharp - -Now that encoding logic lives in Core projects, update the ImageSharp providers to use them, then delete the duplicated encoding tables. - -**Files:** -- Modify: `src/FlexRender.ImageSharp/FlexRender.ImageSharp.csproj` (add Core project dependencies, remove QRCoder) -- Modify: `src/FlexRender.ImageSharp/Providers/ImageSharpQrProvider.cs` -- Modify: `src/FlexRender.ImageSharp/Providers/ImageSharpBarcodeProvider.cs` -- Modify: `src/FlexRender.ImageSharp/Rendering/ImageSharpRenderingEngine.cs` - -**Step 1: Update ImageSharp project** - -In `FlexRender.ImageSharp.csproj`, add: -```xml - - -``` - -Remove: -```xml - -``` - -(QRCoder is now transitively referenced through QrCode.Core.) - -**Step 2: Update ImageSharpQrProvider to use QrEncoder** - -Replace the inline `MaxDataCapacity`, `MapEccLevel`, `ValidateDataCapacity` with calls to `QrEncoder.Encode()` and `QrDataValidator`. The QR module matrix iteration stays but uses `QrEncodeResult.ModuleMatrix` instead of `QRCodeData.ModuleMatrix` directly. - -**Step 3: Update ImageSharpBarcodeProvider to use Code128Encoder** - -Replace the inline `Code128BPatterns`, `Code128StartB`, `Code128Stop`, character validation, and checksum logic with a single `Code128Encoder.Encode(element.Data)` call. The rendering (drawing bars using the pattern string) stays in ImageSharpBarcodeProvider. - -**Step 4: Run all tests** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test FlexRender.slnx -``` - -Expected: ALL tests pass. - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "refactor(imagesharp): eliminate duplicated encoding tables, use Core projects" -``` - ---- - -## Phase 3: Update Solution Structure and CLI - -### Task 14: Update Solution File and MetaPackage - -**Files:** -- Modify: `FlexRender.slnx` -- Modify: `src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj` -- Modify: `src/FlexRender.Cli/FlexRender.Cli.csproj` - -**Step 1: Update solution file** - -Ensure `FlexRender.slnx` includes all new projects: -- `src/FlexRender.Barcode.Core/FlexRender.Barcode.Core.csproj` -- `src/FlexRender.QrCode.Core/FlexRender.QrCode.Core.csproj` - -**Step 2: Update MetaPackage** - -Add Core project references to MetaPackage: -```xml - - -``` - -**Step 3: Update CLI project references** - -Ensure CLI has references to all needed projects. Add Core projects: -```xml - - -``` - -**Step 4: Build and test** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet build FlexRender.slnx && dotnet test FlexRender.slnx -``` - -Expected: ALL pass. - -**Step 5: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "build: update solution, metapackage, and CLI with Core project references" -``` - ---- - -### Task 15: Add InternalsVisibleTo for New Projects and Final Cleanup - -**Files:** -- Modify: `src/FlexRender.Core/FlexRender.Core.csproj` -- Modify: `src/FlexRender.Skia/FlexRender.Skia.csproj` - -**Step 1: Add InternalsVisibleTo entries** - -In `FlexRender.Core.csproj`, add any new projects that need internal access: -```xml - - -``` - -In `FlexRender.Skia.csproj`, ensure `InternalsVisibleTo` includes all Skia-dependent projects. - -**Step 2: Build and run all tests** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet build FlexRender.slnx && dotnet test FlexRender.slnx -``` - -Expected: ALL pass. - -**Step 3: Commit** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "build: update InternalsVisibleTo for provider restructuring" -``` - ---- - -## Phase 4: Integration Verification - -### Task 16: Full Integration Test - -Run the entire test suite and verify the CLI works. - -**Step 1: Run all tests** - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet test FlexRender.slnx -v normal -``` - -Expected: ALL tests pass. The test count should be the same or higher than before restructuring. - -**Step 2: Verify CLI renders correctly** - -```bash -cd /Users/robonet/Projects/SkiaLayout/examples && dotnet run --project ../src/FlexRender.Cli -- render receipt.yaml -d receipt-data.json -o /tmp/restructuring-test-receipt.png -``` - -```bash -cd /Users/robonet/Projects/SkiaLayout/examples && dotnet run --project ../src/FlexRender.Cli -- render receipt.yaml -d receipt-data.json -o /tmp/restructuring-test-receipt.svg -f svg -``` - -Expected: Both files are generated without errors. - -**Step 3: Verify dependency graph** - -Verify `FlexRender.Svg` no longer depends on `FlexRender.Skia`: - -```bash -cd /Users/robonet/Projects/SkiaLayout && dotnet list src/FlexRender.Svg/FlexRender.Svg.csproj reference -``` - -Expected: Only `FlexRender.Core` listed, NOT `FlexRender.Skia`. - -**Step 4: Commit any final fixes** - -```bash -cd /Users/robonet/Projects/SkiaLayout && git add -A && git commit -m "test: verify provider restructuring integration" -``` - ---- - -## Summary of Changes - -### New Files Created -| File | Purpose | -|------|---------| -| `src/FlexRender.Core/Providers/ContentResult.cs` | Backend-neutral PNG bytes container | -| `src/FlexRender.Core/Providers/IContentProvider.cs` | New interface returning ContentResult | -| `src/FlexRender.Core/Providers/ISvgContentProvider.cs` | Moved from Skia (unchanged) | -| `src/FlexRender.Core/Providers/IResourceLoaderAware.cs` | Moved from Skia (unchanged) | -| `src/FlexRender.Skia/Providers/ISkiaNativeProvider.cs` | Zero-copy SKBitmap interface for Skia | -| `src/FlexRender.Skia/SvgBuilderSkiaExtensions.cs` | SvgBuilder.WithSkia() extension (moved from SvgBuilder) | -| `src/FlexRender.Barcode.Core/FlexRender.Barcode.Core.csproj` | New project for barcode encoding | -| `src/FlexRender.Barcode.Core/Code128Encoder.cs` | Shared Code128 encoding logic | -| `src/FlexRender.QrCode.Core/FlexRender.QrCode.Core.csproj` | New project for QR encoding | -| `src/FlexRender.QrCode.Core/QrEncoder.cs` | Shared QR encoding via QRCoder | -| `src/FlexRender.QrCode.Core/QrDataValidator.cs` | Shared QR capacity validation | -| `src/FlexRender.QrCode.Core/QrSvgPathBuilder.cs` | Shared SVG path generation | - -### Files Deleted -| File | Reason | -|------|--------| -| `src/FlexRender.Skia/Providers/IContentProvider.cs` | Moved to Core | -| `src/FlexRender.Skia/Providers/ISvgContentProvider.cs` | Moved to Core | -| `src/FlexRender.Skia/Providers/IResourceLoaderAware.cs` | Moved to Core | - -### Files Modified -| File | Changes | -|------|---------| -| `src/FlexRender.QrCode/Providers/QrProvider.cs` | New interface impl, delegates to QrCode.Core | -| `src/FlexRender.Barcode/Providers/BarcodeProvider.cs` | New interface impl, delegates to Barcode.Core | -| `src/FlexRender.SvgElement/Providers/SvgElementProvider.cs` | New interface impl | -| `src/FlexRender.Skia/Rendering/RenderingEngine.cs` | ISkiaNativeProvider dispatch | -| `src/FlexRender.Skia/Rendering/SkiaRenderer.cs` | Constructor param types | -| `src/FlexRender.Svg/Rendering/SvgRenderingEngine.cs` | Remove SkiaSharp, use ContentResult | -| `src/FlexRender.Svg/SvgRender.cs` | New constructor with provider slots | -| `src/FlexRender.Svg/SvgBuilder.cs` | Provider slots, remove WithSkia | -| `src/FlexRender.Svg/SvgBuilderExtensions.cs` | Wire provider slots | -| `src/FlexRender.Svg/FlexRender.Svg.csproj` | Remove Skia dependency | -| `src/FlexRender.ImageSharp/Providers/ImageSharpQrProvider.cs` | Use QrEncoder | -| `src/FlexRender.ImageSharp/Providers/ImageSharpBarcodeProvider.cs` | Use Code128Encoder | -| `src/FlexRender.ImageSharp/FlexRender.ImageSharp.csproj` | Add Core deps, remove QRCoder | - -### Test Files -| File | Changes | -|------|---------| -| `tests/FlexRender.Tests/Providers/ContentResultTests.cs` | NEW | -| `tests/FlexRender.Tests/Providers/SkiaNativeProviderInterfaceTests.cs` | NEW | -| `tests/FlexRender.Tests/Encoding/Code128EncoderTests.cs` | NEW | -| `tests/FlexRender.Tests/Encoding/QrEncoderTests.cs` | NEW | -| `tests/FlexRender.Tests/Rendering/SvgBuilderTests.cs` | NEW | -| `tests/FlexRender.Tests/Providers/QrProviderTests.cs` | Updated for new interfaces | -| `tests/FlexRender.Tests/Providers/BarcodeProviderTests.cs` | Updated for new interfaces | -| `tests/FlexRender.Tests/Providers/SvgElementProviderTests.cs` | Updated for new interfaces | - -### Dependency Graph Change -``` -BEFORE: FlexRender.Svg --> FlexRender.Skia --> FlexRender.Core -AFTER: FlexRender.Svg --> FlexRender.Core (direct, no Skia) -``` diff --git a/docs/wiki/API-Reference.md b/docs/wiki/API-Reference.md index e27efcf..70f3961 100644 --- a/docs/wiki/API-Reference.md +++ b/docs/wiki/API-Reference.md @@ -140,8 +140,8 @@ var data = new ObjectValue { ["receipt"] = new BytesValue(rawBytes) }; // From Stream var data = new ObjectValue { ["receipt"] = BytesValue.FromStream(fileStream) }; -// From base64 in YAML source -// source: "base64:SGVsbG8gV29ybGQ=" +// From data URI in YAML source +// source: "data:application/octet-stream;base64,SGVsbG8gV29ybGQ=" ``` `BytesValue` wraps `ReadOnlyMemory` and is passed directly to `IBinaryContentParser` without encoding conversion. When only `IContentParser` is registered, binary data is decoded as UTF-8. @@ -583,15 +583,17 @@ Caching works because `type: each` and `type: if` elements are expanded at rende Resources (images, fonts) are loaded through `IResourceLoader` implementations using chain of responsibility: -| Loader | URI Scheme | Priority | Description | -|--------|------------|----------|-------------| -| `Base64ResourceLoader` | `data:` | High (0-99) | Base64-encoded data | +| Loader | URI Schemes | Priority | Description | +|--------|-------------|----------|-------------| +| `Base64ResourceLoader` | `data:`, `data://`, `base64:`, `base64://` | High (0-99) | Base64-encoded data (all normalized to `data:` URI internally) | | `EmbeddedResourceLoader` | `embedded://` | High (0-99) | Assembly embedded resources | -| `FileResourceLoader` | File paths | Normal (100-199) | Local file system | +| `FileResourceLoader` | `file:`, `file://`, `file:///`, relative paths | Normal (100-199) | Local file system (relative paths only) | | `HttpResourceLoader` | `http://`, `https://` | Low (200+) | Remote resources | File and Base64 loaders are included by default. Use `WithoutDefaultLoaders()` for sandboxed operation. HTTP loader requires `WithHttpLoader()`. +> **Security:** `FileResourceLoader` only allows relative paths resolved against `BasePath`. Absolute paths are rejected with `ArgumentException`. Any unrecognized URI scheme (e.g., `ftp://`) is also rejected. + ## See Also - [[Render-Options]] -- detailed per-call options documentation diff --git a/docs/wiki/Cookbook.md b/docs/wiki/Cookbook.md new file mode 100644 index 0000000..b10cf74 --- /dev/null +++ b/docs/wiki/Cookbook.md @@ -0,0 +1,1612 @@ +# Cookbook + +Practical recipes for common FlexRender use cases. Each recipe is a complete, copy-paste-ready YAML template with the data object needed to render it. + +For element properties reference, see [[Element-Reference]]. +For expression syntax, see [[Template-Expressions]]. +For flexbox layout, see [[Flexbox-Layout]]. +For CLI usage, see [[CLI-Reference]]. + +## Quick Start: CLI Rendering + +Every recipe below can be rendered using the `flexrender` CLI. Save the template as a `.yaml` file and the data as a `.json` file, then run: + +```bash +# Render to PNG +flexrender render template.yaml -d data.json -o output.png + +# Render to JPEG (85% quality) +flexrender render template.yaml -d data.json -o output.jpg --quality 85 + +# BMP monochrome for thermal printers +flexrender render template.yaml -d data.json -o output.bmp --bmp-color monochrome1 + +# Validate template without rendering +flexrender validate template.yaml + +# Watch for changes and re-render automatically +flexrender watch template.yaml -d data.json -o preview.png + +# Debug layout (shows element bounds) +flexrender debug-layout template.yaml -d data.json + +# With custom fonts directory +flexrender render template.yaml -d data.json -o output.png --fonts ./assets/fonts + +# Scale 2x (retina) +flexrender render template.yaml -d data.json -o output.png --scale 2.0 +``` + +If running from source (without installing the dotnet tool): + +```bash +dotnet run --project src/FlexRender.Cli -- render template.yaml -d data.json -o output.png +``` + +--- + +## Receipts + +### Simple Receipt with Header and Footer + +A minimal thermal receipt with a shop header, static line items, a total row, and a footer. Uses a 380px canvas width, which is typical for 80mm thermal printers at ~203 DPI. + +**Template:** + +```yaml +template: + name: "simple-receipt" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + # Header + - type: flex + gap: 4 + align: center + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + color: "#1a1a1a" + + - type: text + content: "{{address}}" + size: 0.85em + align: center + color: "#888888" + + - type: separator + style: dashed + color: "#cccccc" + + # Line items + - type: flex + gap: 6 + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Espresso" + color: "#333333" + - type: text + content: "3.50 $" + color: "#333333" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Croissant" + color: "#333333" + - type: text + content: "4.00 $" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + # Total + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + + - type: separator + style: dotted + color: "#cccccc" + + # Footer + - type: flex + gap: 2 + children: + - type: text + content: "Thank you for your purchase!" + size: 0.85em + align: center + color: "#666666" + + - type: text + content: "{{date}}" + size: 0.75em + align: center + color: "#999999" +``` + +**Data:** + +```json +{ + "shopName": "Corner Cafe", + "address": "123 Main St, Springfield", + "total": "7.50", + "date": "2026-03-08 14:30" +} +``` + +**CLI:** + +```bash +flexrender render simple-receipt.yaml -d receipt-data.json -o receipt.png + +# For thermal printer (monochrome BMP) +flexrender render simple-receipt.yaml -d receipt-data.json -o receipt.bmp --bmp-color monochrome1 +``` + +--- + +### Receipt with Dynamic Items + +Uses `type: each` to loop over a line-items array, so the same template works regardless of how many items are in the order. Each row is a flex container with `space-between` to push names and prices to opposite edges. + +**Template:** + +```yaml +template: + name: "dynamic-receipt" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + + - type: separator + style: dashed + color: "#cccccc" + + # Dynamic line items + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: flex + gap: 2 + shrink: 1 + children: + - type: text + content: "{{item.name}}" + color: "#333333" + - type: if + condition: item.qty + greaterThan: 1 + then: + - type: text + content: "x{{item.qty}}" + size: 0.8em + color: "#888888" + - type: text + content: "{{item.price}} $" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + # Subtotal, discount, total + - type: if + condition: discount + greaterThan: 0 + then: + - type: flex + gap: 4 + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Subtotal" + size: 0.9em + color: "#666666" + - type: text + content: "{{subtotal}} $" + size: 0.9em + color: "#666666" + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Discount" + size: 0.9em + color: "#22c55e" + - type: text + content: "-{{discount}} $" + size: 0.9em + color: "#22c55e" + - type: separator + style: dashed + color: "#cccccc" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + + - type: separator + style: dotted + color: "#cccccc" + + - type: text + content: "{{date}}" + size: 0.75em + align: center + color: "#999999" +``` + +**Data:** + +```json +{ + "shopName": "Corner Cafe", + "items": [ + { "name": "Espresso", "qty": 2, "price": "7.00" }, + { "name": "Croissant", "qty": 1, "price": "4.00" }, + { "name": "Orange Juice", "qty": 1, "price": "5.50" } + ], + "subtotal": "16.50", + "discount": "1.50", + "total": "15.00", + "date": "2026-03-08 14:30" +} +``` + +**CLI:** + +```bash +flexrender render dynamic-receipt.yaml -d order-data.json -o receipt.png +``` + +--- + +### Receipt with Table + +Uses `type: table` for a cleaner column-aligned layout. The table element is expanded into flex rows at render time, so no additional packages are needed. Includes a static summary table for subtotal, tax, and total. + +**Template:** + +```yaml +template: + name: "table-receipt" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 14 + children: + - type: flex + gap: 4 + align: center + children: + - type: text + content: "{{companyName}}" + fontWeight: bold + size: 1.4em + align: center + - type: text + content: "Invoice #{{invoiceNumber}}" + size: 0.9em + align: center + color: "#666666" + + - type: separator + style: solid + color: "#e0e0e0" + + # Dynamic line items table + - type: table + array: items + as: item + size: 0.9em + color: "#333333" + row-gap: "2" + column-gap: "8" + header-fontWeight: semi-bold + header-color: "#1a1a1a" + header-size: 0.85em + header-border-bottom: dashed + columns: + - key: description + label: "Item" + grow: 1 + - key: qty + label: "Qty" + width: "36" + align: center + - key: price + label: "Price" + width: "64" + align: right + + - type: separator + style: solid + color: "#e0e0e0" + + # Summary (static rows) + - type: table + size: 0.9em + color: "#555555" + row-gap: "4" + columns: + - key: label + grow: 1 + - key: value + width: "80" + align: right + rows: + - label: "Subtotal" + value: "{{subtotal}}" + - label: "Tax ({{taxRate}})" + value: "{{tax}}" + - label: "TOTAL" + value: "{{total}}" + fontWeight: bold + color: "#1a1a1a" + size: "1.1em" + + - type: text + content: "Thank you for your business!" + size: 0.8em + align: center + color: "#999999" +``` + +**Data:** + +```json +{ + "companyName": "Acme Corp", + "invoiceNumber": "INV-2026-0042", + "items": [ + { "description": "Widget A", "qty": "3", "price": "$29.97" }, + { "description": "Gadget B", "qty": "1", "price": "$24.99" }, + { "description": "Cable C", "qty": "5", "price": "$14.95" } + ], + "subtotal": "$69.91", + "taxRate": "8%", + "tax": "$5.59", + "total": "$75.50" +} +``` + +**CLI:** + +```bash +flexrender render table-receipt.yaml -d invoice-data.json -o invoice.png + +# JPEG for email attachment +flexrender render table-receipt.yaml -d invoice-data.json -o invoice.jpg --quality 90 +``` + +--- + +### Receipt with NDC Content + +NDC (NCR Direct Connect) is a binary protocol used by ATM terminals to format printer output. The `content` element with `format: ndc` parses these binary data streams into FlexRender elements. This is useful when rendering ATM receipt images from raw transaction data captured by banking middleware. + +The NDC parser requires `FlexRender.Content.Ndc` and `.WithNdc()` on the builder. A monospaced font (such as JetBrains Mono or Courier) is recommended for accurate column alignment. + +**Template:** + +```yaml +fonts: + default: "assets/fonts/JetBrainsMono-Regular.ttf" + bold: "assets/fonts/JetBrainsMono-Bold.ttf" + +canvas: + fixed: width + width: 576 + background: "#ffffff" + +layout: + # Bank header (static) + - type: flex + padding: "16 20" + gap: 4 + align: center + children: + - type: text + content: "{{bankName}}" + fontWeight: bold + size: 1.2em + align: center + - type: text + content: "ATM #{{atmId}}" + size: 0.8em + color: "#666666" + align: center + + - type: separator + style: solid + color: "#cccccc" + + # NDC receipt body (parsed from binary data) + - type: content + source: "{{receiptData}}" + format: ndc + options: + columns: 40 + input_encoding: latin1 + font_family: "JetBrains Mono" + charsets: + "1": + encoding: "qwerty-jcuken" + font_style: bold + "I": + font: bold + uppercase: true + + - type: separator + style: solid + color: "#cccccc" + + # Footer (static) + - type: flex + padding: "12 20" + gap: 2 + children: + - type: text + content: "{{date}}" + size: 0.75em + align: center + color: "#999999" + - type: text + content: "Please retain this receipt" + size: 0.75em + align: center + color: "#999999" +``` + +**Data (C#):** + +```csharp +var data = new ObjectValue +{ + ["bankName"] = "First National Bank", + ["atmId"] = "ATM-0042", + ["receiptData"] = new BytesValue(ndcBinaryBytes), + ["date"] = "2026-03-08 09:15:33" +}; +``` + +**Builder setup:** + +```csharp +var render = new FlexRenderBuilder() + .WithNdc() + .WithSkia() + .Build(); +``` + +> **Note:** NDC receipts use binary data (`BytesValue`), so they must be rendered through the C# API. The CLI does not support binary data inputs. For text-based NDC content, you can use `data:` URI format in the JSON data: +> +> ```json +> { "receiptData": "data:application/octet-stream;base64,PFN0YXJ0PjxOREMgZGF0YT4..." } +> ``` +> +> ```bash +> flexrender render ndc-receipt.yaml -d ndc-data.json -o atm-receipt.png +> ``` + +--- + +### Receipt with QR Code + +Adds a scannable QR code at the bottom for a payment link or digital receipt URL. The QR code is centered using a flex container with `align: center`. + +**Template:** + +```yaml +template: + name: "receipt-qr" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + + - type: separator + style: dashed + color: "#cccccc" + + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "{{item.name}}" + color: "#333333" + - type: text + content: "{{item.price}} $" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + + - type: separator + style: dotted + color: "#cccccc" + + # QR code centered + - type: flex + align: center + gap: 6 + children: + - type: qr + data: "{{paymentUrl}}" + size: 140 + errorCorrection: M + + - type: text + content: "Scan to pay" + size: 0.75em + color: "#999999" + align: center + + - type: separator + style: dotted + color: "#cccccc" + + - type: text + content: "Thank you!" + size: 0.85em + align: center + color: "#666666" +``` + +**Data:** + +```json +{ + "shopName": "Corner Cafe", + "items": [ + { "name": "Espresso", "price": "3.50" }, + { "name": "Croissant", "price": "4.00" } + ], + "total": "7.50", + "paymentUrl": "https://pay.example.com/invoice/abc123" +} +``` + +**Builder setup** (QR requires `FlexRender.QrCode`): + +```csharp +var render = new FlexRenderBuilder() + .WithSkia(skia => skia.WithQr()) + .Build(); +``` + +**CLI:** + +```bash +flexrender render receipt-qr.yaml -d payment-data.json -o receipt-qr.png +``` + +--- + +## Labels and Tickets + +### Product Label with Barcode + +A compact product label with name, description, price, and a Code128 barcode. Uses `margin: "0 auto"` to center the barcode horizontally. The barcode requires `FlexRender.Barcode` and `.WithBarcode()`. + +**Template:** + +```yaml +template: + name: "product-label" + version: 1 + +canvas: + fixed: width + width: 200 + background: "#ffffff" + +layout: + - type: flex + padding: "12 10" + gap: 6 + children: + - type: text + content: "{{productName}}" + fontWeight: bold + size: 1.1em + align: center + maxLines: 2 + overflow: ellipsis + + - type: text + content: "{{description}}" + size: 0.85em + color: "#666666" + align: center + maxLines: 2 + + - type: text + content: "{{price}}" + fontWeight: bold + size: 1.3em + color: "#cc0000" + align: center + + - type: barcode + data: "{{sku}}" + format: code128 + width: 180 + height: 40 + showText: true + margin: "0 auto" +``` + +**Data:** + +```json +{ + "productName": "Organic Green Tea", + "description": "Premium loose leaf, 100g", + "price": "$12.99", + "sku": "TEA-GRN-100" +} +``` + +**CLI:** + +```bash +flexrender render product-label.yaml -d product-data.json -o label.png + +# Scale 2x for high-DPI label printers +flexrender render product-label.yaml -d product-data.json -o label.png --scale 2.0 +``` + +--- + +### Event Ticket + +A two-section ticket with a dark header for the event name, a white body for date/time/seat details, and a QR code section separated by a dashed tear line. Section/row/seat info uses small info cards with a label-value pattern. + +**Template:** + +```yaml +template: + name: "event-ticket" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 360 + background: "#f0f0f5" + +layout: + # Event header + - type: flex + background: "#1a1a2e" + padding: "24 28 20 28" + gap: 6 + children: + - type: text + content: "{{eventName}}" + fontWeight: bold + size: 1.5em + align: center + color: "#ffffff" + maxLines: 2 + + - type: text + content: "{{venue}}" + size: 0.95em + align: center + color: "#8888aa" + + # Details + - type: flex + padding: "16 28" + gap: 12 + background: "#ffffff" + children: + - type: flex + direction: row + gap: 12 + children: + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + children: + - type: text + content: "DATE" + size: 0.7em + color: "#888888" + - type: text + content: "{{date}}" + fontWeight: semi-bold + size: 1.05em + + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + children: + - type: text + content: "TIME" + size: 0.7em + color: "#888888" + - type: text + content: "{{time}}" + fontWeight: semi-bold + size: 1.05em + + - type: flex + direction: row + gap: 8 + children: + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + align: center + children: + - type: text + content: "SECTION" + size: 0.7em + color: "#888888" + - type: text + content: "{{section}}" + fontWeight: bold + size: 1.3em + + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + align: center + children: + - type: text + content: "ROW" + size: 0.7em + color: "#888888" + - type: text + content: "{{row}}" + fontWeight: bold + size: 1.3em + + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + align: center + children: + - type: text + content: "SEAT" + size: 0.7em + color: "#888888" + - type: text + content: "{{seat}}" + fontWeight: bold + size: 1.3em + + # Tear line + - type: separator + style: dashed + color: "#cccccc" + + # QR code + - type: flex + padding: "16 28 20 28" + gap: 8 + align: center + background: "#ffffff" + children: + - type: qr + data: "{{ticketId}}" + size: 140 + errorCorrection: H + + - type: text + content: "{{ticketId}}" + size: 0.7em + color: "#aaaaaa" + align: center + + - type: separator + style: dotted + color: "#dddddd" + + - type: text + content: "Present this ticket at the entrance" + size: 0.75em + align: center + color: "#888888" +``` + +**Data:** + +```json +{ + "eventName": "Symphony Orchestra: Beethoven's 9th", + "venue": "Grand Concert Hall", + "date": "Mar 15, 2026", + "time": "7:30 PM", + "section": "A", + "row": "12", + "seat": "7", + "ticketId": "TKT-2026-0315-A12S07" +} +``` + +**CLI:** + +```bash +flexrender render event-ticket.yaml -d ticket-data.json -o ticket.png + +# JPEG for web/email +flexrender render event-ticket.yaml -d ticket-data.json -o ticket.jpg --quality 95 +``` + +--- + +## Advanced Patterns + +### Conditional Content + +Uses `type: if` with `elseIf` to render different content based on data values. This example shows a payment status indicator that changes appearance depending on whether the payment is complete, pending, or failed. + +**Template:** + +```yaml +template: + name: "conditional-receipt" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + - type: text + content: "Order #{{orderId}}" + fontWeight: bold + size: 1.3em + align: center + + - type: separator + style: dashed + color: "#cccccc" + + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "{{item.name}}" + color: "#333333" + - type: text + content: "{{item.price}} $" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + + - type: separator + style: dotted + color: "#cccccc" + + # Payment status -- changes based on data + - type: if + condition: status + equals: "paid" + then: + - type: flex + padding: "10" + background: "#d4edda" + border-radius: "6" + align: center + children: + - type: text + content: "PAID" + fontWeight: bold + color: "#155724" + align: center + elseIf: + condition: status + equals: "pending" + then: + - type: flex + padding: "10" + align: center + gap: 8 + children: + - type: flex + padding: "8" + background: "#fff3cd" + border-radius: "6" + align: center + children: + - type: text + content: "Payment Pending" + fontWeight: bold + color: "#856404" + align: center + - type: qr + data: "{{paymentUrl}}" + size: 120 + errorCorrection: M + - type: text + content: "Scan to complete payment" + size: 0.75em + color: "#999999" + align: center + else: + - type: flex + padding: "10" + background: "#f8d7da" + border-radius: "6" + align: center + children: + - type: text + content: "Payment Failed" + fontWeight: bold + color: "#721c24" + align: center + - type: text + content: "Please contact support" + size: 0.85em + color: "#721c24" + align: center +``` + +**Data (paid):** + +```json +{ + "orderId": "ORD-9876", + "items": [ + { "name": "Widget", "price": "19.99" }, + { "name": "Gadget", "price": "34.99" } + ], + "total": "54.98", + "status": "paid" +} +``` + +**Data (pending):** + +```json +{ + "orderId": "ORD-9877", + "items": [ + { "name": "Widget", "price": "19.99" } + ], + "total": "19.99", + "status": "pending", + "paymentUrl": "https://pay.example.com/ORD-9877" +} +``` + +**CLI:** + +```bash +# Render with "paid" status +flexrender render conditional-receipt.yaml -d paid-order.json -o receipt-paid.png + +# Render with "pending" status (includes QR code) +flexrender render conditional-receipt.yaml -d pending-order.json -o receipt-pending.png +``` + +--- + +### Markdown Content in a Receipt + +Uses the `content` element with `format: markdown` to render a free-form body from data. This is useful when the receipt body is authored elsewhere (CMS, API, database) and arrives as Markdown text. Requires `FlexRender.Content.Markdown` and `.WithMarkdown()`. + +**Template:** + +```yaml +template: + name: "markdown-receipt" + version: 1 + +canvas: + fixed: width + width: 400 + background: "#ffffff" + +layout: + - type: text + content: "{{title}}" + fontWeight: bold + size: 1.5em + align: center + padding: "20 16 8 16" + + - type: separator + color: "#e0e0e0" + + # Dynamic markdown body + - type: content + source: "{{body}}" + format: markdown + padding: "12 16" + + - type: separator + color: "#e0e0e0" + + - type: text + content: "Generated by FlexRender" + size: 0.8em + color: "#999999" + align: center + padding: "8 16 16 16" +``` + +**Data:** + +```json +{ + "title": "Order Summary", + "body": "## Items\n\n- Espresso x2 -- $7.00\n- **Croissant** -- $4.00\n\n---\n\n> **Total: $11.00**\n\nThank you for your order!" +} +``` + +**CLI:** + +```bash +flexrender render markdown-receipt.yaml -d markdown-data.json -o receipt-md.png +``` + +--- + +### Multi-language Receipt + +Sets the `culture` property on the template metadata to control how numbers and dates are formatted by expression filters. The `currency` and `number` filters respect the active culture, so `{{amount | currency}}` produces locale-appropriate output without template changes. + +**Template:** + +```yaml +template: + name: "multi-lang-receipt" + version: 1 + culture: "de-DE" + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + + - type: separator + style: dashed + color: "#cccccc" + + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "{{item.name}}" + color: "#333333" + - type: text + content: "{{item.price | currency}}" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "GESAMT" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total | currency}}" + fontWeight: bold + size: 1.2em + + - type: text + content: "Vielen Dank!" + size: 0.85em + align: center + color: "#666666" +``` + +**Data:** + +```json +{ + "shopName": "Berliner Kaffeehaus", + "items": [ + { "name": "Espresso", "price": 3.50 }, + { "name": "Berliner", "price": 2.80 } + ], + "total": 6.30 +} +``` + +With `culture: "de-DE"`, the `currency` filter formats `3.50` as `3,50 EUR` (locale-dependent). Changing `culture` to `"en-US"` would produce `$3.50` instead -- no template edits needed. + +**CLI:** + +```bash +flexrender render multi-lang-receipt.yaml -d german-data.json -o receipt-de.png +``` + +--- + +## Images + +### Image Sources Overview + +FlexRender supports four ways to load images in `type: image` elements. All sources are resolved through a chain of loaders in priority order. + +| Source | Syntax | Requires | +|--------|--------|----------| +| Local file | `src: "logo.png"` or `src: "file:logo.png"` | Default (FileResourceLoader) | +| HTTP/HTTPS | `src: "https://example.com/logo.png"` | `.WithHttpLoader()` on builder | +| Base64 data URL | `src: "data:image/png;base64,iVBOR..."` | Default (Base64ResourceLoader) | +| Embedded resource | `src: "embedded://MyApp.Assets.logo.png"` | `.WithEmbeddedLoader(assembly)` | + +> **Important:** For image `data:` URIs, the MIME type is **required** (e.g., `data:image/png;base64,...`). For content source `data:` URIs, the MIME type is **optional** (e.g., `data:;base64,SGVsbG8=` or `data:application/octet-stream;base64,SGVsbG8=`). + +--- + +### Local File Image + +The simplest case -- image file relative to the template base path. + +**Template:** + +```yaml +canvas: + fixed: width + width: 300 + background: "#ffffff" + +layout: + - type: flex + padding: "16" + gap: 12 + align: center + children: + # Relative path (resolved from base path) + - type: image + src: "assets/images/logo.png" + width: 200 + fit: contain + + - type: text + content: "Company Name" + fontWeight: bold + size: 1.2em + align: center +``` + +**CLI:** + +```bash +# --base-path tells the renderer where to find relative file references +flexrender render card.yaml -d data.json -o card.png --base-path ./templates +``` + +--- + +### HTTP Image + +Load images from URLs at render time. Requires `.WithHttpLoader()` in the builder or the CLI (enabled by default in CLI). + +**Template:** + +```yaml +canvas: + fixed: width + width: 400 + background: "#f5f5f5" + +layout: + - type: flex + padding: "20" + gap: 16 + children: + - type: flex + direction: row + gap: 16 + children: + # Image loaded from HTTPS URL + - type: image + src: "https://avatars.githubusercontent.com/u/12345" + width: 80 + height: 80 + fit: cover + border-radius: "40" + + - type: flex + gap: 4 + justify: center + children: + - type: text + content: "{{userName}}" + fontWeight: bold + size: 1.1em + - type: text + content: "{{bio}}" + size: 0.85em + color: "#666666" + maxLines: 2 + overflow: ellipsis + + - type: separator + color: "#e0e0e0" + + - type: text + content: "{{stats.repos}} repos · {{stats.followers}} followers" + size: 0.85em + color: "#888888" + align: center +``` + +**Data:** + +```json +{ + "userName": "Jane Developer", + "bio": "Full-stack engineer, open source contributor", + "stats": { "repos": 42, "followers": 1200 } +} +``` + +**CLI:** + +```bash +flexrender render profile-card.yaml -d profile.json -o profile.png +``` + +**C# builder** (HTTP loader must be explicitly registered): + +```csharp +var render = new FlexRenderBuilder() + .WithHttpLoader(opts => { + opts.Timeout = TimeSpan.FromSeconds(30); + opts.MaxResourceSize = 10 * 1024 * 1024; + }) + .WithSkia() + .Build(); +``` + +--- + +### Base64 Inline Image + +Embed images directly in the template or data as base64 data URLs. Useful when images come from a database or API, or when you want a self-contained template with no external dependencies. + +**Template:** + +```yaml +canvas: + fixed: width + width: 300 + background: "#ffffff" + +layout: + - type: flex + padding: "16" + gap: 12 + align: center + children: + # Static base64 image (inline in template) + - type: image + src: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + width: 100 + height: 100 + + # Dynamic base64 image (from data) + - type: image + src: "{{logoBase64}}" + width: 200 + fit: contain + + - type: text + content: "{{title}}" + fontWeight: bold + align: center +``` + +**Data:** + +```json +{ + "title": "Product Card", + "logoBase64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" +} +``` + +> **Syntax:** `data:image/;base64,` — the MIME type (`image/png`, `image/jpeg`, `image/webp`, etc.) is **required**. The comma after `base64` is the mandatory separator. + +**CLI:** + +```bash +flexrender render inline-image.yaml -d image-data.json -o output.png +``` + +--- + +### Image Fit Modes + +The `fit` property controls how an image is scaled within its container: + +**Template:** + +```yaml +canvas: + fixed: width + width: 500 + background: "#f0f0f0" + +layout: + - type: flex + padding: "16" + gap: 16 + children: + - type: text + content: "Image Fit Modes" + fontWeight: bold + size: 1.2em + align: center + + - type: flex + direction: row + gap: 12 + wrap: wrap + children: + # contain -- scales to fit, preserves aspect ratio + - type: flex + gap: 4 + align: center + children: + - type: image + src: "{{imageUrl}}" + width: 100 + height: 100 + fit: contain + background: "#dddddd" + - type: text + content: "contain" + size: 0.8em + color: "#666666" + + # cover -- scales to fill, crops excess + - type: flex + gap: 4 + align: center + children: + - type: image + src: "{{imageUrl}}" + width: 100 + height: 100 + fit: cover + - type: text + content: "cover" + size: 0.8em + color: "#666666" + + # fill -- stretches to fill (may distort) + - type: flex + gap: 4 + align: center + children: + - type: image + src: "{{imageUrl}}" + width: 100 + height: 100 + fit: fill + - type: text + content: "fill" + size: 0.8em + color: "#666666" + + # none -- original size, no scaling + - type: flex + gap: 4 + align: center + children: + - type: image + src: "{{imageUrl}}" + width: 100 + height: 100 + fit: none + - type: text + content: "none" + size: 0.8em + color: "#666666" +``` + +**Data:** + +```json +{ + "imageUrl": "assets/images/sample.png" +} +``` + +**CLI:** + +```bash +flexrender render fit-modes.yaml -d data.json -o fit-modes.png --base-path ./templates +``` + +--- + +## See Also + +- [[Template-Syntax]] -- canvas, element types, units +- [[Element-Reference]] -- complete property reference +- [[Template-Expressions]] -- variables, filters, loops, conditionals +- [[Flexbox-Layout]] -- layout engine details +- [[Render-Options]] -- output formats and rendering settings +- [[CLI-Reference]] -- render templates from the command line diff --git a/docs/wiki/Element-Reference.md b/docs/wiki/Element-Reference.md index 12d9eda..d462cce 100644 --- a/docs/wiki/Element-Reference.md +++ b/docs/wiki/Element-Reference.md @@ -1451,7 +1451,7 @@ Renders tabular data with configurable columns, optional header row, and support ## Content Element (Control Flow) -Embeds dynamically formatted content (Markdown, HTML, NDC binary data, etc.) from template data. The `source` supports multiple input types: plain text, `base64:`-encoded binary data, `file:` URIs, `text:` prefixed strings, and template variables bound to `string` or `byte[]` (`BytesValue`). The source is parsed at render time into a subtree of FlexRender elements using pluggable content parsers. +Embeds dynamically formatted content (Markdown, HTML, NDC binary data, etc.) from template data. The `source` supports multiple input types: plain text, `data:` URI-encoded binary data, `file:` URIs, `text:` prefixed strings, and template variables bound to `string` or `byte[]` (`BytesValue`). The source is parsed at render time into a subtree of FlexRender elements using pluggable content parsers. This is a **control-flow element** — like `each` and `if`, it is expanded during template processing and does not appear in the final render tree. @@ -1465,18 +1465,18 @@ This is a **control-flow element** — like `each` and `if`, it is expanded duri | Property | YAML Name | Type | Default | Valid Values | Expression | Description | |----------|-----------|------|---------|--------------|-----------|-------------| -| Source | `source` | string | `""` | Any string, typically `{{variable}}` | Yes | The content to parse. Supports plain text, `base64:` binary, `file:` URIs, `text:` prefix, and `{{variable}}` expressions resolving to `string` or `BytesValue` (`byte[]`). See [Content Source Resolution](#content-source-resolution) below. | +| Source | `source` | string | `""` | Any string, typically `{{variable}}` | Yes | The content to parse. Supports plain text, `data:` URI binary, `file:` URIs, `text:` prefix, and `{{variable}}` expressions resolving to `string` or `BytesValue` (`byte[]`). See [Content Source Resolution](#content-source-resolution) below. | | Format | `format` | string | `""` | `markdown`, `html`, or any registered parser name | Yes | The content format. Must match a registered `IContentParser.FormatName`. | | Options | `options` | dict? | `null` | Key-value dictionary | No | Parser-specific options (e.g., NDC `columns`, `charsets`). Passed to the content parser. | ### Content Source Resolution -The `source` property is resolved at render time through `ContentSourceResolver`, which supports multiple input types: +The `source` property is resolved at render time by `TemplateExpander`, which supports multiple input types: | Source Format | Example | Resolved As | Description | |---------------|---------|-------------|-------------| | Template variable (`BytesValue`) | `source: "{{rawData}}"` | Binary (`byte[]`) | When `{{variable}}` resolves to `BytesValue` in the data context, binary data is passed directly to `IBinaryContentParser`. No string encoding overhead. | -| `base64:` prefix | `source: "base64:SGVsbG8="` | Binary (`byte[]`) | Base64-encoded payload decoded into bytes. Useful for embedding binary data in JSON/YAML. | +| `data:` URI | `source: "data:;base64,SGVsbG8="` | Binary (`byte[]`) | Data URI with base64-encoded payload decoded into bytes. MIME type is optional (e.g., `data:application/octet-stream;base64,...`). Useful for embedding binary data in JSON/YAML. | | `file:` scheme | `source: "file:receipt.bin"` | Binary (`byte[]`) | Loads content via registered resource loaders (file system, HTTP, embedded). Also supports `file:///` URIs. Throws if file not found. | | `text:` prefix | `source: "text:# Hello"` | Text (`string`) | Forces text interpretation, skipping file path detection. | | File path heuristic | `source: "receipt.md"` | Binary (`byte[]`) | If source looks like a file path (contains `/`, `\`, or a file extension), tries resource loaders first. Falls back to text if no loader matches. | @@ -1585,10 +1585,10 @@ layout: font_style: bold ``` -Data (JSON with base64-encoded NDC binary): +Data (JSON with data URI-encoded NDC binary): ```json { - "receiptData": "base64:G1sxfjQwHSgxHQ==" + "receiptData": "data:application/octet-stream;base64,G1sxfjQwHSgxHQ==" } ``` diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 0eee7cb..a992e4f 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -97,6 +97,7 @@ byte[] png = await render.RenderFile("template.yaml", data); | [[Template-Expressions]] | Variables, loops, conditionals with 13 operators | | [[Flexbox-Layout]] | Direction, justify, align, wrapping, grow/shrink, positioning | | [[Render-Options]] | Per-call options: antialiasing, font hinting, format-specific settings | +| [[Cookbook]] | Copy-paste recipes: receipts, labels, tickets, NDC, conditional content | | [[CLI-Reference]] | Commands, options, AOT publishing | | [[API-Reference]] | IFlexRender, builder, DI, extension methods, types | | [[Contributing]] | Build, test, architecture, coding conventions | diff --git a/docs/wiki/Template-Syntax.md b/docs/wiki/Template-Syntax.md index 4b0d8ab..576a764 100644 --- a/docs/wiki/Template-Syntax.md +++ b/docs/wiki/Template-Syntax.md @@ -344,7 +344,7 @@ Renders an image from a file, URL, base64 data, or embedded resource. | Property | Type | Default | Description | |----------|------|---------|-------------| -| `src` | string | `""` | Image source: file path, `http://`, `embedded://`, base64 data URL | +| `src` | string | `""` | Image source: relative file path, `file:` URI, `http://`, `embedded://`, or base64 data URL. Only relative paths are allowed (absolute paths rejected for security). | | `width` | int? | `null` | Image container width (null = natural width) | | `height` | int? | `null` | Image container height (null = natural height) | | `fit` | ImageFit | `contain` | How the image fits within bounds | diff --git a/llms-full.txt b/llms-full.txt index dfee7bb..74814b0 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -28,6 +28,7 @@ src/FlexRender.Core/ # Core library (0 external dependencies) src/FlexRender.Yaml/ # YAML template parser (-> Core + YamlDotNet) Parsing/ TemplateParser.cs # YAML to AST parser (includes EachElement, IfElement) + KnownProperties.cs # YAML property validation with Levenshtein "Did you mean?" suggestions src/FlexRender.Http/ # HTTP resource loader (-> Core) HttpResourceLoader.cs # Load images/fonts from HTTP/HTTPS URLs @@ -152,13 +153,16 @@ FlexRender.Yaml FlexRender.Http FlexRender.Skia.Render FlexRender.ImageSharp. ``` YAML Template -> TemplateParser (YAML -> AST: Template with CanvasSettings + TemplateElement tree, including EachElement/IfElement) - -> TemplateExpander (expand EachElement/IfElement to concrete elements based on data -- enables template caching) - -> TemplateProcessor (resolve {{variable}} expressions in element properties -- inline substitution) + + KnownProperties (validate YAML keys, warn on unknown properties with "Did you mean?" suggestions) + -> TemplateExpander (expand EachElement/IfElement to concrete elements based on data) [async only] + -> TemplateProcessor (resolve {{variable}} expressions in element properties) [async only] -> LayoutEngine (two-pass: MeasureAllIntrinsics -> ComputeLayout -> LayoutNode tree) -> SkiaRenderer (traverse LayoutNode tree -> draw to SKBitmap via SkiaSharp) OR ImageSharpRenderer (traverse LayoutNode tree -> draw via SixLabors.ImageSharp) ``` +**Async-only API:** The entire pipeline from expansion through rendering is async. There are no synchronous `Expand()`, `Process()`, `Measure()`, `ComputeLayout()`, or `Render()` methods. All public API methods return `Task` or `Task`. This is required because `ContentElement` expansion involves async I/O (loading external content sources). + ### Template Caching Templates can be parsed once and cached, then rendered with different data: @@ -852,9 +856,11 @@ Renders SVG vector graphics. Supports external files (`src`) or inline markup (` Embeds formatted text (Markdown, HTML, or NDC binary data) that is parsed into an AST subtree at render time. Requires a content parser to be registered. +**Important:** Content element expansion is async-only (`ExpandAsync()`). There is no synchronous expansion path. This is why the entire pipeline is async. + | Property | Type | Default | Description | |----------|------|---------|-------------| -| source | string | "" | Content to parse. Supports plain text, `base64:` binary, `file:` URIs, `text:` prefix, `{{variable}}` bound to `string` or `BytesValue` (`byte[]`) | +| source | string | "" | Content to parse. `text:` prefix for plain text, `{{variable}}` for `string`/`BytesValue`, everything else delegates to IResourceLoader chain | | format | string | "markdown" | Content format: `markdown`, `html`, or `ndc` -- must match a registered parser | | options | dict? | null | Parser-specific options (e.g., NDC `columns`, `charsets`). Passed to the content parser. | @@ -864,13 +870,11 @@ Embeds formatted text (Markdown, HTML, or NDC binary data) that is parsed into a format: markdown # or "html" -- must match registered parser ``` -Content source resolution (via ContentSourceResolver): +Content source resolution (inlined in TemplateExpander): - `{{variable}}` -> if variable is BytesValue (byte[]), passes binary directly to IBinaryContentParser -- `base64:...` -> decodes base64 payload to byte[] -- `file:path` or `file:///path` -> loads via resource loaders (throws on failure) -- `text:...` -> forces text interpretation, skips file heuristic -- File-like strings (paths, extensions) -> tries resource loaders, falls back to text -- Plain text -> literal string content +- `text:...` -> plain text (bypasses loaders) +- All other sources delegate to IResourceLoader chain (`data:`, `base64:`, `file:`, `http://`, `embedded://`) +- If no loader handles the source -> falls back to plain text Binary data binding: ```csharp @@ -918,6 +922,17 @@ Colors are specified in hex format: Parsed by `ColorParser` in the Rendering namespace. +## YAML Validation + +The parser validates all YAML property names against known properties for each element type. Unknown properties trigger a `TemplateParseException` with a "Did you mean?" suggestion using Levenshtein distance: + +``` +Unknown property 'colour' for element type 'text'. Did you mean 'color'? +Unknown property 'backgrund' for element type 'flex'. Did you mean 'background'? +``` + +Known properties are maintained per element type in `KnownProperties` (src/FlexRender.Yaml/Parsing/KnownProperties.cs). This covers all 11 element types and their specific + common properties. + ## Supported Units | Unit | Syntax | Resolution | @@ -1158,11 +1173,13 @@ Resources (images, fonts) are loaded through `IResourceLoader` implementations u | Loader | Priority Range | Handles | URI Scheme | |--------|---------------|---------|------------| -| Base64ResourceLoader | 0-99 (High) | Base64-encoded data | `data:` | +| Base64ResourceLoader | 0-99 (High) | Base64-encoded data | `data:`, `data://`, `base64:`, `base64://` | | EmbeddedResourceLoader | 0-99 (High) | Assembly embedded resources | `embedded://` | -| FileResourceLoader | 100-199 (Normal) | Local file system | File paths | +| FileResourceLoader | 100-199 (Normal) | Local file system (relative paths only) | `file:`, `file://`, `file:///`, relative paths | | HttpResourceLoader | 200+ (Low) | Remote HTTP/HTTPS resources | `http://`, `https://` | +> **Security:** FileResourceLoader rejects absolute paths with `ArgumentException`. Only relative paths resolved against `BasePath` are allowed. Unrecognized URI schemes are also rejected. + Custom loaders can be added via `builder.ResourceLoaders.Add(new CustomLoader())` (internal API). Implement `IResourceLoader` with `CanHandle()`, `Load()`, and `Priority`. ## Builder API @@ -1553,6 +1570,16 @@ Reference these YAML files for real-world template patterns. Each file is a comp | `examples/markdown-content.yaml` | Markdown content demo | `type: content` with `format: markdown`, dynamic body | | `examples/html-content.yaml` | HTML content demo | `type: content` with `format: html`, inline styles | +### Cookbook (docs/wiki/Cookbook.md) + +9 practical recipes for common use cases: +- Simple receipt, dynamic receipt with loops, table invoice +- NDC ATM receipt (binary content parsing) +- Receipt with QR code, shipping label, event ticket +- Conditional content, multi-language template + +Each recipe is a complete, copy-paste-ready YAML template with sample JSON data. + ### Per-Feature Examples (examples/visual-docs/) Minimal, focused templates -- one feature per file: diff --git a/llms.txt b/llms.txt index 91d2732..9f64b41 100644 --- a/llms.txt +++ b/llms.txt @@ -60,18 +60,21 @@ examples/ # Example YAML templates (see "Example Templates ``` YAML Template -> TemplateParser (YAML -> AST: Template with CanvasSettings + element tree) - -> TemplateExpander (expand EachElement/IfElement to concrete elements based on data) - -> TemplateProcessor (resolve {{variable}} expressions in element properties) + + KnownProperties (validate YAML keys, warn on unknown properties with "Did you mean?" suggestions) + -> TemplateExpander (expand EachElement/IfElement to concrete elements based on data) [async only] + -> TemplateProcessor (resolve {{variable}} expressions in element properties) [async only] -> LayoutEngine (two-pass: MeasureAllIntrinsics -> ComputeLayout -> LayoutNode tree) -> SkiaRenderer (traverse LayoutNode tree -> draw to SKBitmap via SkiaSharp) OR ImageSharpRenderer (traverse LayoutNode tree -> draw via SixLabors.ImageSharp) ``` +The entire pipeline is **async-only**. There are no synchronous `Expand()`, `Process()`, `Measure()`, or `Render()` methods. All public API methods return `Task` or `Task`. + Templates can be parsed once and cached for reuse with different data. ## Element Types -Ten element types, each a sealed class extending `TemplateElement`: +Eleven element types, each a sealed class extending `TemplateElement`: | Type | Key Properties | |------|---------------| @@ -274,9 +277,11 @@ Operators: truthy (no key), `equals`, `notEquals`, `in`, `notIn`, `contains`, `g Requires content parser registration: `.WithMarkdown()` (Markdig) or `.WithHtml()` (HtmlAgilityPack). Converts formatted text into FlexRender AST elements at render time (bold → FontWeight.Bold, italic → FontStyle.Italic, headings, lists, etc.). -Content source supports: plain text, `base64:` binary, `file:` URI, `text:` prefix, `{{variable}}` bound to `string` or `BytesValue` (`byte[]`/`Stream`). +Content source resolution: `text:` prefix → plain text; `{{variable}}` → `string` or `BytesValue`; everything else delegates to IResourceLoader chain (`data:`, `base64:`, `file:`, `http://`, `embedded://`). Binary sources pass `ReadOnlyMemory` directly to `IBinaryContentParser` implementations (e.g., NDC parser). +**Important:** Content element expansion is async-only. The `ExpandAsync()` method must be used; there is no synchronous `Expand()` alternative. + #### NDC Content ```yaml - type: content @@ -289,6 +294,16 @@ Binary sources pass `ReadOnlyMemory` directly to `IBinaryContentParser` im Requires NDC parser registration: `.WithNdc()` (FlexRender.Content.Ndc). Parses binary NDC ATM printer data streams. Supports charset switching, QWERTY→JCUKEN encoding, embedded barcodes, auto font sizing. +## YAML Validation + +The parser validates all YAML property names against known properties for each element type. Unknown properties trigger a `TemplateParseException` with a "Did you mean?" suggestion using Levenshtein distance: + +``` +Unknown property 'colour' for element type 'text'. Did you mean 'color'? +``` + +This catches typos like `colour` → `color`, `backgrund` → `background`, `dierction` → `direction`. + ## Supported Units - `px` -- pixels (default when no unit specified) @@ -500,6 +515,10 @@ Reference these YAML files for real-world template patterns. Each file is a comp | `examples/markdown-content.yaml` | Markdown content demo | `type: content` with `format: markdown`, dynamic body | | `examples/html-content.yaml` | HTML content demo | `type: content` with `format: html`, inline styles | +### Cookbook (docs/wiki/Cookbook.md) + +9 practical recipes for common use cases: simple receipt, dynamic receipt with loops, table invoice, NDC ATM receipt, receipt with QR code, shipping label, event ticket, conditional content, and multi-language template. Each recipe is a complete, copy-paste-ready YAML template with sample data. + ### Per-Feature Examples (examples/visual-docs/) Minimal, focused templates -- one feature per file: diff --git a/src/FlexRender.Core/Loaders/Base64ResourceLoader.cs b/src/FlexRender.Core/Loaders/Base64ResourceLoader.cs index 8e94d6f..36e5481 100644 --- a/src/FlexRender.Core/Loaders/Base64ResourceLoader.cs +++ b/src/FlexRender.Core/Loaders/Base64ResourceLoader.cs @@ -13,6 +13,9 @@ namespace FlexRender.Loaders; public sealed class Base64ResourceLoader : IResourceLoader { private const string DataPrefix = "data:"; + private const string DataUriPrefix = "data://"; + private const string Base64Shorthand = "base64:"; + private const string Base64UriPrefix = "base64://"; private readonly FlexRenderOptions _options; @@ -36,7 +39,8 @@ public Base64ResourceLoader(FlexRenderOptions options) /// /// - /// Returns true for URIs that start with "data:". + /// Returns true for URIs that start with "data:", "data://", "base64:", or "base64://". + /// All shorthand forms are normalized to standard "data:" URI format internally. /// public bool CanHandle(string uri) { @@ -45,7 +49,8 @@ public bool CanHandle(string uri) return false; } - return uri.StartsWith(DataPrefix, StringComparison.OrdinalIgnoreCase); + return uri.StartsWith(DataPrefix, StringComparison.OrdinalIgnoreCase) + || uri.StartsWith(Base64Shorthand, StringComparison.OrdinalIgnoreCase); } /// @@ -64,6 +69,21 @@ public bool CanHandle(string uri) cancellationToken.ThrowIfCancellationRequested(); + // Normalize all variants to standard "data:" URI format + if (uri.StartsWith(Base64UriPrefix, StringComparison.OrdinalIgnoreCase)) + { + uri = "data:application/octet-stream;base64," + uri[Base64UriPrefix.Length..]; + } + else if (uri.StartsWith(Base64Shorthand, StringComparison.OrdinalIgnoreCase)) + { + uri = "data:application/octet-stream;base64," + uri[Base64Shorthand.Length..]; + } + else if (uri.StartsWith(DataUriPrefix, StringComparison.OrdinalIgnoreCase)) + { + // "data://mime;base64,payload" → "data:mime;base64,payload" + uri = DataPrefix + uri[DataUriPrefix.Length..]; + } + var base64Data = ExtractBase64Data(uri); ValidateDataSize(base64Data); diff --git a/src/FlexRender.Core/Loaders/FileResourceLoader.cs b/src/FlexRender.Core/Loaders/FileResourceLoader.cs index 15dd94c..e269ba1 100644 --- a/src/FlexRender.Core/Loaders/FileResourceLoader.cs +++ b/src/FlexRender.Core/Loaders/FileResourceLoader.cs @@ -13,8 +13,6 @@ namespace FlexRender.Loaders; /// public sealed class FileResourceLoader : IResourceLoader { - private static readonly string[] UrlPrefixes = ["http://", "https://", "data:", "embedded://"]; - private readonly FlexRenderOptions _options; /// @@ -37,7 +35,8 @@ public FileResourceLoader(FlexRenderOptions options) /// /// - /// Returns true for URIs that do not start with http://, https://, data:, or embedded://. + /// Returns true for URIs that look like file paths or use the "file:" / "file://" scheme. + /// Rejects anything with other URI schemes (e.g., "http://", "data:", "base64:", "embedded://"). /// public bool CanHandle(string uri) { @@ -46,9 +45,41 @@ public bool CanHandle(string uri) return false; } - foreach (var prefix in UrlPrefixes) + // Allow "file:" and "file://" scheme — this loader handles local files + if (uri.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Reject any URI with a "://" scheme (e.g., "http://", "data://", "custom://") + if (uri.Contains("://", StringComparison.Ordinal)) + { + return false; + } + + // Reject scheme-like prefixes without "://" (e.g., "data:", "base64:") + // A URI scheme is [a-zA-Z][a-zA-Z0-9+.-]*: (min 2 chars to exclude Windows drive letters like "C:") + var colonIndex = uri.IndexOf(':'); + if (colonIndex > 1 && colonIndex < 20) + { + var scheme = uri.AsSpan(0, colonIndex); + if (char.IsLetter(scheme[0]) && IsValidScheme(scheme)) + { + return false; + } + } + + return true; + } + + /// + /// Checks whether a span represents a valid URI scheme (letters, digits, +, ., -). + /// + private static bool IsValidScheme(ReadOnlySpan scheme) + { + foreach (var c in scheme) { - if (uri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + if (!char.IsLetterOrDigit(c) && c != '+' && c != '.' && c != '-') { return false; } @@ -70,9 +101,19 @@ public bool CanHandle(string uri) return Task.FromResult(null); } - ValidatePathSecurity(uri); + // Strip file: scheme prefix if present + // Supports: "file:///path" (RFC 8089), "file://path", "file:path" + var path = uri; + if (path.StartsWith("file:///", StringComparison.OrdinalIgnoreCase)) + path = path["file:///".Length..]; + else if (path.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + path = path["file://".Length..]; + else if (path.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + path = path["file:".Length..]; + + ValidatePathSecurity(path); - var fullPath = ResolvePath(uri); + var fullPath = ResolvePath(path); if (!File.Exists(fullPath)) { @@ -86,37 +127,57 @@ public bool CanHandle(string uri) } /// - /// Validates the path for security issues such as path traversal attacks. + /// Validates the path for security issues: path traversal and absolute paths. /// /// The path to validate. - /// Thrown when path traversal is detected. + /// Thrown when the path is absolute or contains traversal sequences. private static void ValidatePathSecurity(string path) { + // Reject URL-encoded characters that could hide traversal sequences + if (path.Contains('%')) + { + throw new ArgumentException( + $"URL-encoded characters are not allowed in file paths: {path}", + nameof(path)); + } + if (path.Contains("..")) { throw new ArgumentException( $"Invalid path (path traversal detected): {path}", nameof(path)); } + + if (Path.IsPathRooted(path)) + { + throw new ArgumentException( + $"Absolute paths are not allowed for security reasons: {path}. Use relative paths resolved against BasePath.", + nameof(path)); + } } /// - /// Resolves a relative or absolute path to a full file system path. + /// Resolves a relative path against BasePath and validates the result stays within bounds. /// - /// The path to resolve. + /// The relative path to resolve. /// The fully resolved absolute path. + /// Thrown when the resolved path escapes the base directory. private string ResolvePath(string path) { - if (Path.IsPathRooted(path)) - { - return Path.GetFullPath(path); - } + var basePath = !string.IsNullOrEmpty(_options.BasePath) + ? Path.GetFullPath(_options.BasePath) + : Path.GetFullPath("."); + + var fullPath = Path.GetFullPath(Path.Combine(basePath, path)); - if (!string.IsNullOrEmpty(_options.BasePath)) + // Ensure the resolved path is still within the base directory + if (!fullPath.StartsWith(basePath, StringComparison.Ordinal)) { - return Path.GetFullPath(Path.Combine(_options.BasePath, path)); + throw new ArgumentException( + $"Path '{path}' resolves outside the base directory.", + nameof(path)); } - return Path.GetFullPath(path); + return fullPath; } } diff --git a/src/FlexRender.Core/Parsing/Ast/BarcodeElement.cs b/src/FlexRender.Core/Parsing/Ast/BarcodeElement.cs index 8eaa38c..50836c5 100644 --- a/src/FlexRender.Core/Parsing/Ast/BarcodeElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/BarcodeElement.cs @@ -73,6 +73,24 @@ public sealed class BarcodeElement : TemplateElement /// public ExprValue Foreground { get; set; } = "#000000"; + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new BarcodeElement + { + Data = substitutor(Data.Value) ?? "", + Format = Format, + BarcodeWidth = BarcodeWidth, + BarcodeHeight = BarcodeHeight, + ShowText = ShowText, + Foreground = Foreground + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + /// public override void ResolveExpressions(Func resolver, ObjectValue data) { diff --git a/src/FlexRender.Core/Parsing/Ast/ContentElement.cs b/src/FlexRender.Core/Parsing/Ast/ContentElement.cs index ef34fda..4ef7d15 100644 --- a/src/FlexRender.Core/Parsing/Ast/ContentElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/ContentElement.cs @@ -32,6 +32,23 @@ public sealed class ContentElement : TemplateElement /// public IReadOnlyDictionary? Options { get; set; } + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new ContentElement + { + Source = substitutor(Source.RawValue ?? Source.Value) ?? "", + Format = substitutor(Format.RawValue ?? Format.Value) ?? "", + Options = Options + }; + CopyBasePropertiesTo(clone, substitutor); + // ContentElement uses RawValue ?? Value for Background to preserve expression context + clone.Background = substitutor(Background.RawValue ?? Background.Value)!; + return clone; + } + /// public override void ResolveExpressions(Func resolver, ObjectValue data) { diff --git a/src/FlexRender.Core/Parsing/Ast/EachElement.cs b/src/FlexRender.Core/Parsing/Ast/EachElement.cs index 07c7ded..9d504b2 100644 --- a/src/FlexRender.Core/Parsing/Ast/EachElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/EachElement.cs @@ -10,6 +10,11 @@ public sealed class EachElement : TemplateElement /// public override ElementType Type => ElementType.Each; + /// + /// Always thrown. EachElement is expanded, not cloned. + public override TemplateElement CloneWithSubstitution(Func substitutor) + => throw new NotSupportedException("EachElement should be expanded, not cloned."); + /// /// Gets or sets the path to the array in the data context. /// Example: "items" or "order.lines". diff --git a/src/FlexRender.Core/Parsing/Ast/ExprValue.cs b/src/FlexRender.Core/Parsing/Ast/ExprValue.cs index ad0770d..bde6f55 100644 --- a/src/FlexRender.Core/Parsing/Ast/ExprValue.cs +++ b/src/FlexRender.Core/Parsing/Ast/ExprValue.cs @@ -48,6 +48,12 @@ public readonly struct ExprValue /// public bool IsResolved { get; private init; } + /// + /// Gets the resolved binary data when the source expression resolved to a . + /// Allows binary data to flow through the pipeline without base64 encoding/decoding overhead. + /// + public BytesValue? Bytes { get; private init; } + /// /// Initializes a new instance of the struct from a typed literal. /// No expression, value is set directly. @@ -120,7 +126,8 @@ public ExprValue Resolve(Func resolver, ObjectVa RawValue = resolved, Value = default!, IsExpression = false, - IsResolved = false + IsResolved = false, + Bytes = Bytes }; } @@ -156,10 +163,21 @@ public ExprValue Materialize(string propertyName, ValueKind kind = ValueKind. RawValue = RawValue, Value = parsed, IsExpression = false, - IsResolved = true + IsResolved = true, + Bytes = Bytes }; } + /// + /// Creates a copy of this with the specified attached. + /// + /// The binary data to attach. + /// A new with set. + public ExprValue WithBytes(BytesValue bytes) + { + return this with { Bytes = bytes }; + } + private static T ParseRawValue(string? raw, string propertyName) { var targetType = typeof(T); diff --git a/src/FlexRender.Core/Parsing/Ast/FlexElement.cs b/src/FlexRender.Core/Parsing/Ast/FlexElement.cs index 8d38946..b4ec556 100644 --- a/src/FlexRender.Core/Parsing/Ast/FlexElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/FlexElement.cs @@ -81,6 +81,44 @@ public void AddChild(TemplateElement child) _children.Add(child); } + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new FlexElement + { + Direction = Direction, + Wrap = Wrap, + Gap = Gap, + Justify = Justify, + Align = Align, + AlignContent = AlignContent, + Overflow = Overflow, + RowGap = RowGap, + ColumnGap = ColumnGap, + FontSize = FontSize, + Children = [] + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + /// Creates a clone with substituted variables and the specified children. + /// + /// The expanded child elements for the clone. + /// Function to substitute template variables in string values. + /// A new flex element with the specified children and substituted properties. + public FlexElement CloneWithChildren(IReadOnlyList children, Func substitutor) + { + ArgumentNullException.ThrowIfNull(children); + + var clone = (FlexElement)CloneWithSubstitution(substitutor); + clone.Children = children; + return clone; + } + /// public override void ResolveExpressions(Func resolver, ObjectValue data) { diff --git a/src/FlexRender.Core/Parsing/Ast/IfElement.cs b/src/FlexRender.Core/Parsing/Ast/IfElement.cs index 320dff5..65d7f82 100644 --- a/src/FlexRender.Core/Parsing/Ast/IfElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/IfElement.cs @@ -11,6 +11,11 @@ public sealed class IfElement : TemplateElement /// public override ElementType Type => ElementType.If; + /// + /// Always thrown. IfElement is expanded, not cloned. + public override TemplateElement CloneWithSubstitution(Func substitutor) + => throw new NotSupportedException("IfElement should be expanded, not cloned."); + /// /// Gets or sets the path to the value used for condition evaluation. /// Example: "isPremium" or "order.status". diff --git a/src/FlexRender.Core/Parsing/Ast/ImageElement.cs b/src/FlexRender.Core/Parsing/Ast/ImageElement.cs index 422e81d..0e8814e 100644 --- a/src/FlexRender.Core/Parsing/Ast/ImageElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/ImageElement.cs @@ -56,6 +56,36 @@ public sealed class ImageElement : TemplateElement /// public ExprValue Fit { get; set; } = ImageFit.Contain; + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new ImageElement + { + Src = Src, + ImageWidth = ImageWidth, + ImageHeight = ImageHeight, + Fit = Fit + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + /// Creates a clone of this element with a different value. + /// Used by the expansion pipeline to attach resolved binary data. + /// + /// The resolved source expression with optional bytes attached. + /// Function to substitute template variables in string values. + /// A new image element with the specified source. + public ImageElement WithSrc(ExprValue src, Func substitutor) + { + var clone = (ImageElement)CloneWithSubstitution(substitutor); + clone.Src = src; + return clone; + } + /// public override void ResolveExpressions(Func resolver, ObjectValue data) { diff --git a/src/FlexRender.Core/Parsing/Ast/QrElement.cs b/src/FlexRender.Core/Parsing/Ast/QrElement.cs index aa44d06..847e000 100644 --- a/src/FlexRender.Core/Parsing/Ast/QrElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/QrElement.cs @@ -58,6 +58,22 @@ public sealed class QrElement : TemplateElement /// public ExprValue Foreground { get; set; } = "#000000"; + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new QrElement + { + Data = substitutor(Data.Value) ?? "", + Size = Size, + ErrorCorrection = ErrorCorrection, + Foreground = Foreground + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + /// public override void ResolveExpressions(Func resolver, ObjectValue data) { diff --git a/src/FlexRender.Core/Parsing/Ast/SeparatorElement.cs b/src/FlexRender.Core/Parsing/Ast/SeparatorElement.cs index 9e4daca..582c54b 100644 --- a/src/FlexRender.Core/Parsing/Ast/SeparatorElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/SeparatorElement.cs @@ -69,6 +69,22 @@ public sealed class SeparatorElement : TemplateElement /// public ExprValue Color { get; set; } = "#000000"; + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new SeparatorElement + { + Orientation = Orientation, + Style = Style, + Thickness = Thickness, + Color = Color + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + /// public override void ResolveExpressions(Func resolver, ObjectValue data) { diff --git a/src/FlexRender.Core/Parsing/Ast/SvgElement.cs b/src/FlexRender.Core/Parsing/Ast/SvgElement.cs index a549aee..961b2c8 100644 --- a/src/FlexRender.Core/Parsing/Ast/SvgElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/SvgElement.cs @@ -50,6 +50,37 @@ public sealed class SvgElement : TemplateElement /// public ExprValue Fit { get; set; } = ImageFit.Contain; + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new SvgElement + { + Src = Src, + Content = substitutor(Content.Value)!, + SvgWidth = SvgWidth, + SvgHeight = SvgHeight, + Fit = Fit + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + /// Creates a clone of this element with a different value. + /// Used by the expansion pipeline to attach resolved binary data. + /// + /// The resolved source expression with optional bytes attached. + /// Function to substitute template variables in string values. + /// A new SVG element with the specified source. + public SvgElement WithSrc(ExprValue src, Func substitutor) + { + var clone = (SvgElement)CloneWithSubstitution(substitutor); + clone.Src = src; + return clone; + } + /// public override void ResolveExpressions(Func resolver, ObjectValue data) { diff --git a/src/FlexRender.Core/Parsing/Ast/TableElement.cs b/src/FlexRender.Core/Parsing/Ast/TableElement.cs index 9deff3b..3fe9181 100644 --- a/src/FlexRender.Core/Parsing/Ast/TableElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/TableElement.cs @@ -12,6 +12,11 @@ public sealed class TableElement : TemplateElement /// public override ElementType Type => ElementType.Table; + /// + /// Always thrown. TableElement is expanded, not cloned. + public override TemplateElement CloneWithSubstitution(Func substitutor) + => throw new NotSupportedException("TableElement should be expanded, not cloned."); + /// /// Gets or sets the path to the array in the data context for dynamic tables. /// Mutually exclusive with . diff --git a/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs b/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs index a4b9c2e..d33184e 100644 --- a/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs @@ -287,6 +287,74 @@ public virtual void Materialize() BorderRadius = BorderRadius.Materialize(nameof(BorderRadius)); } + /// + /// Creates a shallow clone with string-valued properties substituted via the provided function. + /// Override in derived classes to handle subclass-specific properties. + /// + /// Function to substitute template variables in string values. + /// A new element with variables substituted. + public abstract TemplateElement CloneWithSubstitution(Func substitutor); + + /// + /// Copies base properties from this element to the target, substituting string values where needed. + /// Called by implementations. + /// + /// The target element to copy properties to. + /// Function to substitute template variables in string values. + protected void CopyBasePropertiesTo(TemplateElement target, Func substitutor) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(substitutor); + + // Properties requiring per-element substitution + target.Rotate = Rotate; + target.Background = substitutor(Background.Value)!; + target.Padding = Padding; + target.Margin = Margin; + + // Flex-item properties (copied as-is) + target.Grow = Grow; + target.Shrink = Shrink; + target.Basis = Basis; + target.AlignSelf = AlignSelf; + target.Order = Order; + target.Width = Width; + target.Height = Height; + target.MinWidth = MinWidth; + target.MaxWidth = MaxWidth; + target.MinHeight = MinHeight; + target.MaxHeight = MaxHeight; + + // Position properties + target.Position = Position; + target.Top = Top; + target.Right = Right; + target.Bottom = Bottom; + target.Left = Left; + + // Other base properties + target.Display = Display; + target.AspectRatio = AspectRatio; + + // Border properties + target.Border = Border; + target.BorderWidth = BorderWidth; + target.BorderColor = BorderColor; + target.BorderStyle = BorderStyle; + target.BorderTop = BorderTop; + target.BorderRight = BorderRight; + target.BorderBottom = BorderBottom; + target.BorderLeft = BorderLeft; + target.BorderRadius = BorderRadius; + + // Text direction + target.TextDirection = TextDirection; + + // Visual effects + target.Opacity = Opacity; + target.BoxShadow = BoxShadow; + } + /// /// Copies all base flex-item and positioning properties from source to target element. /// Properties that require per-element transformation (Background, Rotate, Padding, Margin) diff --git a/src/FlexRender.Core/Parsing/Ast/TextElement.cs b/src/FlexRender.Core/Parsing/Ast/TextElement.cs index 13f6b2c..212bcec 100644 --- a/src/FlexRender.Core/Parsing/Ast/TextElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/TextElement.cs @@ -164,6 +164,30 @@ public sealed class TextElement : TemplateElement /// public ExprValue LineHeight { get; set; } = ""; + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new TextElement + { + Content = substitutor(Content.Value) ?? "", + Font = Font, + FontFamily = FontFamily, + FontWeight = FontWeight, + FontStyle = FontStyle, + Size = Size, + Color = Color, + Align = Align, + Wrap = Wrap, + MaxLines = MaxLines, + Overflow = Overflow, + LineHeight = LineHeight + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + /// public override void ResolveExpressions(Func resolver, ObjectValue data) { diff --git a/src/FlexRender.Core/TemplateEngine/ContentSourceResolver.cs b/src/FlexRender.Core/TemplateEngine/ContentSourceResolver.cs deleted file mode 100644 index 48ea215..0000000 --- a/src/FlexRender.Core/TemplateEngine/ContentSourceResolver.cs +++ /dev/null @@ -1,247 +0,0 @@ -using FlexRender.Abstractions; -using FlexRender.Parsing.Ast; - -namespace FlexRender.TemplateEngine; - -/// -/// Resolves a content element source into either text or binary data. -/// Handles variable expressions, base64-encoded payloads, and URI-based resource loading. -/// -public sealed class ContentSourceResolver -{ - /// - /// Asynchronously resolves a content source expression into a . - /// Uses await for resource loader calls instead of blocking synchronously. - /// - /// The source expression to resolve. - /// The template context for variable resolution. - /// Optional resource loaders for URI-based content. - /// Optional function to substitute template variables in strings. - /// - /// A if the source resolves to binary data (bytes variable, base64, or loaded resource), - /// or a for plain text sources. - /// - /// Thrown when is null. - /// Thrown when base64 data is invalid. - public static async ValueTask ResolveAsync( - ExprValue source, - TemplateContext context, - IReadOnlyList? loaders, - Func? substituteVariables = null) - { - ArgumentNullException.ThrowIfNull(context); - - // Step 1: Check if pure {{variable}} resolves to BytesValue - var bytesValue = TryResolveBytes(source, context); - if (bytesValue is not null) - { - return new BinaryContent(bytesValue.Memory, bytesValue.MimeType); - } - - // Step 2: Resolve source as string - var rawText = source.RawValue ?? source.Value; - var resolvedSource = substituteVariables?.Invoke(rawText, context) ?? rawText; - - if (resolvedSource is null) - { - return new TextContent(string.Empty); - } - - // Step 3: Check "base64:" prefix - if (resolvedSource.StartsWith("base64:", StringComparison.Ordinal)) - { - var base64Payload = resolvedSource["base64:".Length..]; - byte[] decodedBytes; - try - { - decodedBytes = Convert.FromBase64String(base64Payload); - } - catch (FormatException ex) - { - throw new TemplateEngineException( - $"Invalid base64 data in content source: {ex.Message}", ex); - } - - return new BinaryContent(decodedBytes); - } - - // Step 4: Explicit "text:" prefix — always treat as text, skip file heuristic - if (resolvedSource.StartsWith("text:", StringComparison.Ordinal)) - { - return new TextContent(resolvedSource["text:".Length..]); - } - - // Step 5: Explicit "file:" scheme — strict, throws on failure - if (resolvedSource.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) - { - var filePath = resolvedSource.StartsWith("file:///", StringComparison.OrdinalIgnoreCase) - ? resolvedSource["file:///".Length..] - : resolvedSource["file:".Length..]; - - ValidateFilePath(filePath); - return await LoadFromLoadersAsync(filePath, loaders).ConfigureAwait(false); - } - - // Step 6: Try resource loaders opportunistically (no scheme — best effort) - if (loaders is not null) - { - var loaded = await TryLoadFromLoadersAsync(resolvedSource, loaders).ConfigureAwait(false); - if (loaded is not null) - { - return loaded; - } - } - - // Step 7: Fall back to text - return new TextContent(resolvedSource); - } - - /// - /// Asynchronously loads binary content from resource loaders in strict mode. - /// Throws if no loader can handle the path or the file is not found. - /// - private static async Task LoadFromLoadersAsync(string path, IReadOnlyList? loaders) - { - if (loaders is not null) - { - foreach (var loader in loaders) - { - if (loader.CanHandle(path)) - { - var stream = await loader.Load(path).ConfigureAwait(false); - if (stream is not null) - { - using (stream) - { - var loaded = BytesValue.FromStream(stream); - return new BinaryContent(loaded.Memory, loaded.MimeType); - } - } - } - } - } - - throw new TemplateEngineException( - $"Cannot load content from 'file:{path}': file not found or no suitable resource loader registered."); - } - - /// - /// Asynchronously attempts to load binary content from resource loaders opportunistically. - /// Only tries loading if the source looks like a file path (contains path separators or a file extension). - /// Returns null for plain text sources. - /// - private static async ValueTask TryLoadFromLoadersAsync(string source, IReadOnlyList loaders) - { - if (!LooksLikeFilePath(source)) - { - return null; - } - - foreach (var loader in loaders) - { - if (loader.CanHandle(source)) - { - var stream = await loader.Load(source).ConfigureAwait(false); - if (stream is not null) - { - using (stream) - { - var loaded = BytesValue.FromStream(stream); - return new BinaryContent(loaded.Memory, loaded.MimeType); - } - } - } - } - - return null; - } - - /// - /// Heuristic: determines if a string looks like a file path rather than plain text content. - /// Returns true if the source contains path separators or ends with a file extension. - /// - private static bool LooksLikeFilePath(string source) - { - // Multiline content is never a file path - if (source.Contains('\n', StringComparison.Ordinal) || source.Contains('\r', StringComparison.Ordinal)) - { - return false; - } - - // Very long strings are unlikely to be file paths - if (source.Length > 260) - { - return false; - } - - // Contains whitespace (other than in file names with spaces) — likely text content - // File paths may have spaces, but combined with other indicators we skip them - if (source.Contains(" ", StringComparison.Ordinal)) - { - return false; - } - - // Contains path separator - if (source.Contains('/', StringComparison.Ordinal) || source.Contains('\\', StringComparison.Ordinal)) - { - return true; - } - - // Ends with a file extension (e.g., ".txt", ".bin", ".dat") - var lastDot = source.LastIndexOf('.'); - if (lastDot > 0 && lastDot < source.Length - 1) - { - var ext = source[lastDot..]; - // Extension is short (1-5 chars) and contains no spaces - if (ext.Length is >= 2 and <= 6 && !ext.Contains(' ', StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - /// - /// Validates that a file path does not contain path traversal sequences. - /// - /// Thrown when the path contains directory traversal. - private static void ValidateFilePath(string path) - { - if (path.Contains("..", StringComparison.Ordinal)) - { - throw new TemplateEngineException( - $"Path traversal detected in content source: '{path}'. Directory traversal ('..') is not allowed."); - } - } - - /// - /// Attempts to resolve a source expression directly to a . - /// Only matches pure {{variable}} expressions (no mixed text or nested expressions). - /// - /// The source expression value. - /// The template context for variable resolution. - /// The if the expression resolves to one; otherwise, null. - private static BytesValue? TryResolveBytes(ExprValue source, TemplateContext context) - { - var raw = source.RawValue ?? source.Value; - if (raw is null) - { - return null; - } - - if (!raw.StartsWith("{{", StringComparison.Ordinal) || !raw.EndsWith("}}", StringComparison.Ordinal)) - { - return null; - } - - var inner = raw[2..^2].Trim(); - if (inner.Contains("{{", StringComparison.Ordinal)) - { - return null; - } - - var resolved = ExpressionEvaluator.Resolve(inner, context); - return resolved as BytesValue; - } -} diff --git a/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs b/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs index c6b3741..15245a8 100644 --- a/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs +++ b/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs @@ -112,133 +112,6 @@ public async Task