diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f193504..9dfebc5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -77,6 +77,7 @@ jobs:
src/FlexRender.SvgElement/FlexRender.SvgElement.csproj \
src/FlexRender.Content.Markdown/FlexRender.Content.Markdown.csproj \
src/FlexRender.Content.Html/FlexRender.Content.Html.csproj \
+ src/FlexRender.Content.Ndc/FlexRender.Content.Ndc.csproj \
src/FlexRender.DependencyInjection/FlexRender.DependencyInjection.csproj \
src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj; do
dotnet pack "$project" \
diff --git a/AGENTS.md b/AGENTS.md
index be6c878..5f6b86a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,6 +56,7 @@ src/FlexRender.ImageSharp.Render/ # ImageSharp renderer (-> Core + SixLabors.Ima
src/FlexRender.ImageSharp/ # ImageSharp backend meta-package (renderer + providers)
src/FlexRender.Svg.Render/ # SVG output renderer (-> Core)
src/FlexRender.Svg/ # SVG backend meta-package (renderer + providers)
+src/FlexRender.Content.Ndc/ # NDC (ATM receipt) content parser (-> Core)
src/FlexRender.DependencyInjection/ # Microsoft.Extensions.DI integration
src/FlexRender.MetaPackage/ # Meta-package (core + all backends + DI)
@@ -167,8 +168,8 @@ 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` |
-| Template Engine | `TemplateExpander`, `TemplateProcessor`, `ExpressionLexer`, `ExpressionEvaluator`, `TemplateContext`, `InlineExpressionParser`, `InlineExpressionEvaluator`, `FilterRegistry`, `ITemplateFilter` |
+| 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` |
| 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` |
@@ -176,6 +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` |
## Coding Conventions
@@ -317,6 +319,7 @@ The release workflow (`.github/workflows/release.yml`) publishes all packages to
| QR providers | `FlexRender.QrCode.Skia.Render`, `FlexRender.QrCode.ImageSharp.Render`, `FlexRender.QrCode.Svg.Render` |
| Barcode providers | `FlexRender.Barcode.Skia.Render`, `FlexRender.Barcode.ImageSharp.Render`, `FlexRender.Barcode.Svg.Render` |
| SvgElement providers | `FlexRender.SvgElement.Skia.Render`, `FlexRender.SvgElement.Svg.Render` |
+| Content parsers | `FlexRender.Content.Markdown`, `FlexRender.Content.Html`, `FlexRender.Content.Ndc` |
| Extensions | `FlexRender.HarfBuzz` |
| Meta (backend) | `FlexRender.Skia`, `FlexRender.ImageSharp`, `FlexRender.Svg` |
| Meta (feature) | `FlexRender.QrCode`, `FlexRender.Barcode`, `FlexRender.SvgElement` |
diff --git a/FlexRender.slnx b/FlexRender.slnx
index 642398b..75e63f0 100644
--- a/FlexRender.slnx
+++ b/FlexRender.slnx
@@ -35,6 +35,7 @@
+
diff --git a/docs/wiki/API-Reference.md b/docs/wiki/API-Reference.md
index ace2079..e27efcf 100644
--- a/docs/wiki/API-Reference.md
+++ b/docs/wiki/API-Reference.md
@@ -80,8 +80,10 @@ Builder for configuring and creating `IFlexRender` instances. Defined in `FlexRe
| `WithoutDefaultLoaders()` | Remove default File and Base64 loaders (sandboxed mode) |
| `WithoutDefaultFilters()` | Remove all 8 built-in filters (enabled by default), leaving only custom-registered filters |
| `WithContentParser(IContentParser)` | Register a content parser for `type: content` elements |
+| `WithBinaryContentParser(IBinaryContentParser)` | Register a binary content parser for `type: content` elements |
| `WithMarkdown()` | Enable Markdown content parsing (`format: markdown`) |
| `WithHtml()` | Enable HTML content parsing (`format: html`) |
+| `WithNdc()` | Enable NDC content parsing (`format: ndc`) |
| `Build()` | Create the configured `IFlexRender` instance |
### Usage
@@ -124,6 +126,26 @@ var render = new FlexRenderBuilder()
The builder can only be built once. Creating a second instance requires a new `FlexRenderBuilder`.
+### Data Binding with Binary Content
+
+Content elements support binary data via `BytesValue`:
+
+```csharp
+// String content (for Markdown, HTML parsers)
+var data = new ObjectValue { ["body"] = new StringValue("# Hello") };
+
+// Binary content (for NDC and other IBinaryContentParser implementations)
+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="
+```
+
+`BytesValue` wraps `ReadOnlyMemory` and is passed directly to `IBinaryContentParser` without encoding conversion. When only `IContentParser` is registered, binary data is decoded as UTF-8.
+
---
## SkiaBuilder
@@ -514,6 +536,11 @@ var obj = new ObjectValue
["zip"] = "123456"
}
};
+
+// Binary data
+var bytes = new BytesValue(binaryData); // from byte[]
+var bytes = new BytesValue(readOnlyMemory); // from ReadOnlyMemory
+var bytes = BytesValue.FromStream(stream); // from Stream
```
| Type | C# Class | Description |
@@ -524,6 +551,7 @@ var obj = new ObjectValue
| Null | `NullValue` | Null sentinel (`NullValue.Instance`) |
| Array | `ArrayValue` | Implements `IReadOnlyList` |
| Object | `ObjectValue` | Dictionary-like, `StringComparer.OrdinalIgnoreCase` |
+| Binary | `BytesValue` | Binary data with optional MIME type |
---
diff --git a/docs/wiki/Element-Reference.md b/docs/wiki/Element-Reference.md
index 5c97802..12d9eda 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 text (Markdown, HTML, etc.) from template data. The `source` text 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, `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.
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,8 +1465,46 @@ 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 formatted text to parse. Usually bound to a data variable. |
+| 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. |
| 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:
+
+| 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. |
+| `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. |
+| Plain text | `source: "**bold text**"` | Text (`string`) | Default fallback -- treated as literal text content. |
+
+**Resolution order:** The resolver processes sources in the order listed above. The first matching rule wins.
+
+**Binary vs Text parsers:**
+- `IContentParser` receives text (`string`) -- used by Markdown, HTML parsers
+- `IBinaryContentParser` receives binary data (`ReadOnlyMemory`) -- used by NDC parser
+- When source resolves to binary and the parser implements `IBinaryContentParser`, bytes are passed directly without encoding conversion
+- When source resolves to binary but the parser only implements `IContentParser`, bytes are decoded as UTF-8 text
+
+**Data binding examples:**
+
+```csharp
+// String data -- parsed as text
+var data = new ObjectValue { ["body"] = new StringValue("# Hello World") };
+
+// Binary data -- passed directly to IBinaryContentParser
+var data = new ObjectValue { ["receiptData"] = new BytesValue(ndcBytes) };
+
+// Binary data with MIME type
+var data = new ObjectValue { ["receiptData"] = new BytesValue(ndcBytes, "application/octet-stream") };
+
+// From Stream
+var data = new ObjectValue { ["receiptData"] = BytesValue.FromStream(stream) };
+```
### Supported Formats
@@ -1474,6 +1512,7 @@ This is a **control-flow element** — like `each` and `if`, it is expanded duri
|--------|---------|----------------|---------|
| `markdown` | `FlexRender.Content.Markdown` | `.WithMarkdown()` | Markdig |
| `html` | `FlexRender.Content.Html` | `.WithHtml()` | HtmlAgilityPack |
+| `ndc` | `FlexRender.Content.Ndc` | `.WithNdc()` | (none) |
### Element Mapping
@@ -1490,6 +1529,71 @@ Content parsers convert formatted text into standard FlexRender elements:
| Image (`` or `
`) | `ImageElement` |
| Code (`` `code` `` or ``) | `TextElement { Background = "#f0f0f0" }` |
+### NDC Format Options
+
+The `ndc` format parses binary NDC (NCR ATM protocol) printer data streams from ATM/banking terminals. It supports both text and binary input via `IContentParser` and `IBinaryContentParser`.
+
+#### Properties
+
+The `content` element with `format: ndc` supports an `options` block:
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `columns` | int | 40 | Maximum characters per line (auto-wrapping) |
+| `input_encoding` | string | `"latin1"` | Input byte encoding (`latin1`, `utf-8`, `iso-8859-1`, `ascii`) |
+| `font_family` | string | null | Global font family for all text |
+| `char_width_ratio` | double | 0.6 | Character width as fraction of font size |
+| `charsets` | dict | `{}` | Per-charset style overrides (see below) |
+
+#### Charset Style Properties
+
+Each charset designator (e.g., `"1"`, `"I"`, `">"`) can have individual styling:
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `font` | string | null | Font registration name |
+| `font_family` | string | null | Font family (overrides global) |
+| `font_style` | string | null | `"bold"`, `"italic"`, `"bold-italic"`, `"regular"` |
+| `font_size` | int | null | Explicit font size in pixels |
+| `color` | string | null | Hex color (e.g., `"#333333"`) |
+| `encoding` | string | null | Character encoding: `"qwerty-jcuken"`, `"none"`, `"ascii"` |
+| `uppercase` | bool | false | Convert text to uppercase |
+
+#### Example: NDC Receipt
+
+```yaml
+canvas:
+ fixed: width
+ width: 384
+ background: "#ffffff"
+
+fonts:
+ - "assets/fonts/JetBrainsMono-Regular.ttf"
+ - "assets/fonts/JetBrainsMono-Bold.ttf"
+
+layout:
+ - type: content
+ source: "{{receiptData}}"
+ format: ndc
+ options:
+ columns: 40
+ input_encoding: latin1
+ font_family: "JetBrains Mono"
+ charsets:
+ "1":
+ encoding: "qwerty-jcuken"
+ font_style: bold
+```
+
+Data (JSON with base64-encoded NDC binary):
+```json
+{
+ "receiptData": "base64:G1sxfjQwHSgxHQ=="
+}
+```
+
+The NDC parser produces `TextElement`, `BarcodeElement`, and `SeparatorElement` nodes with auto-calculated font sizes based on the parent element width.
+
### Example: Markdown Content
```yaml
diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md
index 9ea3d73..0051ad3 100644
--- a/docs/wiki/Getting-Started.md
+++ b/docs/wiki/Getting-Started.md
@@ -48,6 +48,7 @@ Install only what you need:
| `FlexRender.SvgElement` | SvgElement meta-package (all renderers) | Svg.Skia |
| `FlexRender.Content.Markdown` | Markdown content parsing for `type: content` | Markdig |
| `FlexRender.Content.Html` | HTML content parsing for `type: content` | HtmlAgilityPack |
+| `FlexRender.Content.Ndc` | NDC (ATM receipt) content parsing for `type: content` | (none) |
| `FlexRender.HarfBuzz` | HarfBuzz text shaping for Arabic/Hebrew | SkiaSharp.HarfBuzz |
| `FlexRender.Http` | HTTP/HTTPS resource loading | None |
| `FlexRender.DependencyInjection` | Microsoft DI integration | Microsoft.Extensions.DI |
@@ -196,6 +197,7 @@ Native rendering via SkiaSharp. Best quality, widest feature set.
var render = new FlexRenderBuilder()
.WithMarkdown() // Markdown content parsing
.WithHtml() // HTML content parsing
+ .WithNdc() // NDC ATM receipt parsing
.WithSkia(skia => skia
.WithQr() // QR code support
.WithBarcode() // Barcode support
@@ -205,7 +207,7 @@ var render = new FlexRenderBuilder()
- **Formats:** PNG, JPEG, BMP, Raw
- **Requires:** `SkiaSharp.NativeAssets.Linux` on Linux/Docker
-- **Optional:** `.WithHarfBuzz()` for Arabic/Hebrew text shaping, `.WithMarkdown()` / `.WithHtml()` for content parsing
+- **Optional:** `.WithHarfBuzz()` for Arabic/Hebrew text shaping, `.WithMarkdown()` / `.WithHtml()` / `.WithNdc()` for content parsing
- **Best for:** Desktop apps, servers with native library support
### ImageSharp Backend
diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md
index f944425..0eee7cb 100644
--- a/docs/wiki/Home.md
+++ b/docs/wiki/Home.md
@@ -14,7 +14,7 @@ A modular .NET library for rendering images from YAML templates with a full CSS
- **RTL Support** -- right-to-left layout with `text-direction: rtl`, logical alignment (`start`/`end`), HarfBuzz text shaping for Arabic/Hebrew
- **Template engine** -- variables (`{{name}}`), inline expressions (`{{price * qty | currency}}`), loops (`type: each`), conditionals (`type: if` with 13 operators)
- **Inline expressions** -- arithmetic (`+`, `-`, `*`, `/`), null coalescing (`??`), 8 built-in filters enabled by default (`currency`, `currencySymbol`, `number`, `upper`, `lower`, `trim`, `truncate`, `format`)
-- **Rich content types** -- text, images, SVG, QR codes, barcodes, separators, tables
+- **Rich content types** -- text, images, SVG, QR codes, barcodes, separators, tables, NDC receipts
- **Visual effects** -- opacity, box-shadow, linear and radial gradient backgrounds
- **Multiple output formats** -- PNG, JPEG, BMP (6 color modes), Raw pixels, with per-call format options
- **AOT-ready** -- no reflection, no `dynamic`, works with Native AOT publishing
@@ -91,8 +91,8 @@ byte[] png = await render.RenderFile("template.yaml", data);
| Page | Description |
|------|-------------|
| [[Getting-Started]] | Installation, first template, rendering approaches |
-| [[Template-Syntax]] | Canvas, all 10 element types, common properties, units |
-| [[Element-Reference]] | Complete property reference for all 10 element types with examples |
+| [[Template-Syntax]] | Canvas, all 11 element types, common properties, units |
+| [[Element-Reference]] | Complete property reference for all 11 element types with examples |
| [[Visual-Reference]] | Interactive visual examples for all properties and elements |
| [[Template-Expressions]] | Variables, loops, conditionals with 13 operators |
| [[Flexbox-Layout]] | Direction, justify, align, wrapping, grow/shrink, positioning |
diff --git a/docs/wiki/Template-Syntax.md b/docs/wiki/Template-Syntax.md
index de69465..4b0d8ab 100644
--- a/docs/wiki/Template-Syntax.md
+++ b/docs/wiki/Template-Syntax.md
@@ -507,7 +507,7 @@ Renders tabular data with configurable columns, optional headers, and support fo
### content
-Embeds dynamically formatted text (Markdown, HTML, etc.) from template data using pluggable content parsers. Like `each` and `if`, this is a control-flow element expanded at render time.
+Embeds dynamically formatted text (Markdown, HTML, NDC, etc.) from template data using pluggable content parsers. Like `each` and `if`, this is a control-flow element expanded at render time.
```yaml
- type: content
@@ -518,7 +518,8 @@ Embeds dynamically formatted text (Markdown, HTML, etc.) from template data usin
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `source` | string | `""` | The formatted text to parse. Usually bound to a `{{variable}}`. |
-| `format` | string | `""` | Content format: `markdown`, `html`, or any registered parser name. |
+| `format` | string | `""` | Content format: `markdown`, `html`, `ndc`, or any registered parser name. |
+| `options` | dict? | `null` | Parser-specific options dictionary (used by NDC and custom parsers). |
See [[Element-Reference#content-element-control-flow]] for full details, element mapping, and examples.
diff --git a/examples/assets/fonts/JetBrainsMono-Bold.ttf b/examples/assets/fonts/JetBrainsMono-Bold.ttf
new file mode 100644
index 0000000..97179f7
--- /dev/null
+++ b/examples/assets/fonts/JetBrainsMono-Bold.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb
+size 277828
diff --git a/examples/assets/fonts/JetBrainsMono-Regular.ttf b/examples/assets/fonts/JetBrainsMono-Regular.ttf
new file mode 100644
index 0000000..87165a0
--- /dev/null
+++ b/examples/assets/fonts/JetBrainsMono-Regular.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f
+size 273900
diff --git a/examples/ndc-data/bank-a-balance-b64.json b/examples/ndc-data/bank-a-balance-b64.json
new file mode 100644
index 0000000..fa57940
--- /dev/null
+++ b/examples/ndc-data/bank-a-balance-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GygxICAgICAgICAgICAgICAbKEludGNuamRzcSB+ZnlyIGYbKDEKICAgICAgICAbKEludGsbKDEuIDggKDgwMCkgMDAwLTAwLTAwChsoSWZsaHRjGygxOgpNT1NDT1csIFRFU1RPVkFZQSBVTC4sIDEKICAbKElsZm5mGygxICAgICAgICAbKElkaHR2ehsoMSAgICAgICAgIBsoSX5meXJqdmZuGygxCjAxLjAxLjI1ICAgICAxMDowMDowMCAgICAgICBBVE0wMDAwMQogICAgGyhJeWp2dGgbKDEgGyhJcmZobnMbKDEgKioqKioqKioqKioqMDAwMAoKICAgICAbKElqZ3RoZndiehsoMSAbKEljGygxIBsoSWRkamxqdhsoMSAbKElnYnkbKDEtGyhJcmpsZhsoMVwhCkFJRDogQTAwMDAwMDAwMDAwMDAgVEVTVENBUkQKCiAgICAgICAgICBURVNUQ0FSRCAwMSBWMS4wCgobKElkc2xmeGYbKDEgGyhJeWZrYnh5c3sbKDEKGyhJY2V2dmYbKDE6ICAgICAxMjM0NS42NyAbKEloZX4bKDEK"}
diff --git a/examples/ndc-data/bank-a-balance-receipt.json b/examples/ndc-data/bank-a-balance-receipt.json
new file mode 100644
index 0000000..8f184bb
--- /dev/null
+++ b/examples/ndc-data/bank-a-balance-receipt.json
@@ -0,0 +1,3 @@
+{
+ "body": "\u001b(1 \u001b(Intcnjdsq ~fyr f\u001b(1\n \u001b(Intk\u001b(1. 8 (800) 000-00-00\n\u001b(Iflhtc\u001b(1:\nMOSCOW, TESTOVAYA UL., 1\n \u001b(Ilfnf\u001b(1 \u001b(Idhtvz\u001b(1 \u001b(I~fyrjvfn\u001b(1\n01.01.25 10:00:00 ATM00001\n \u001b(Iyjvth\u001b(1 \u001b(Irfhns\u001b(1 ************0000\n\n \u001b(Ijgthfwbz\u001b(1 \u001b(Ic\u001b(1 \u001b(Iddjljv\u001b(1 \u001b(Igby\u001b(1-\u001b(Irjlf\u001b(1!\n\n \nAID: A0000000000000 TESTCARD\n\n\u001b(Ipfghjc\u001b(1 \u001b(I~fkfycf\u001b(1\n\u001b(Iljcnegyst\u001b(1 \u001b(Ichtlcndf\u001b(1:\n 12345.67 \u001b(Ihe~\u001b(1\n\n\n\u001b(Irjl\u001b(1 \u001b(Igjlndth|ltybz\u001b(1: 000000\n"
+}
diff --git a/examples/ndc-data/bank-a-balance.txt b/examples/ndc-data/bank-a-balance.txt
new file mode 100644
index 0000000..5955eac
--- /dev/null
+++ b/examples/ndc-data/bank-a-balance.txt
@@ -0,0 +1,19 @@
+(1 (Intcnjdsq ~fyr f(1
+ (Intk(1. 8 (800) 000-00-00
+(Iflhtc(1:
+MOSCOW, TESTOVAYA UL., 1
+ (Ilfnf(1 (Idhtvz(1 (I~fyrjvfn(1
+01.01.25 10:00:00 ATM00001
+ (Iyjvth(1 (Irfhns(1 ************0000
+
+ (Ijgthfwbz(1 (Ic(1 (Iddjljv(1 (Igby(1-(Irjlf(1!
+
+
+AID: A0000000000000 TESTCARD
+
+(Ipfghjc(1 (I~fkfycf(1
+(Iljcnegyst(1 (Ichtlcndf(1:
+ 12345.67 (Ihe~(1
+
+
+(Irjl(1 (Igjlndth|ltybz(1: 000000
diff --git a/examples/ndc-data/bank-a-cashout-b64.json b/examples/ndc-data/bank-a-cashout-b64.json
new file mode 100644
index 0000000..fa57940
--- /dev/null
+++ b/examples/ndc-data/bank-a-cashout-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GygxICAgICAgICAgICAgICAbKEludGNuamRzcSB+ZnlyIGYbKDEKICAgICAgICAbKEludGsbKDEuIDggKDgwMCkgMDAwLTAwLTAwChsoSWZsaHRjGygxOgpNT1NDT1csIFRFU1RPVkFZQSBVTC4sIDEKICAbKElsZm5mGygxICAgICAgICAbKElkaHR2ehsoMSAgICAgICAgIBsoSX5meXJqdmZuGygxCjAxLjAxLjI1ICAgICAxMDowMDowMCAgICAgICBBVE0wMDAwMQogICAgGyhJeWp2dGgbKDEgGyhJcmZobnMbKDEgKioqKioqKioqKioqMDAwMAoKICAgICAbKElqZ3RoZndiehsoMSAbKEljGygxIBsoSWRkamxqdhsoMSAbKElnYnkbKDEtGyhJcmpsZhsoMVwhCkFJRDogQTAwMDAwMDAwMDAwMDAgVEVTVENBUkQKCiAgICAgICAgICBURVNUQ0FSRCAwMSBWMS4wCgobKElkc2xmeGYbKDEgGyhJeWZrYnh5c3sbKDEKGyhJY2V2dmYbKDE6ICAgICAxMjM0NS42NyAbKEloZX4bKDEK"}
diff --git a/examples/ndc-data/bank-a-cashout-receipt.json b/examples/ndc-data/bank-a-cashout-receipt.json
new file mode 100644
index 0000000..a4cfbe2
--- /dev/null
+++ b/examples/ndc-data/bank-a-cashout-receipt.json
@@ -0,0 +1,3 @@
+{
+ "body": "\u001b(1 \u001b(Intcnjdsq ~fyr f\u001b(1\n \u001b(Intk\u001b(1. 8 (800) 000-00-00\n\u001b(Iflhtc\u001b(1:\nMOSCOW, TESTOVAYA UL., 1\n \u001b(Ilfnf\u001b(1 \u001b(Idhtvz\u001b(1 \u001b(I~fyrjvfn\u001b(1\n01.01.25 10:00:00 ATM00001\n \u001b(Iyjvth\u001b(1 \u001b(Irfhns\u001b(1 ************0000\n\n \u001b(Ijgthfwbz\u001b(1 \u001b(Ic\u001b(1 \u001b(Iddjljv\u001b(1 \u001b(Igby\u001b(1-\u001b(Irjlf\u001b(1!\nAID: A0000000000000 TESTCARD\n\n TESTCARD 01 V1.0\n\n\u001b(Idslfxf\u001b(1 \u001b(Iyfkbxys{\u001b(1\n\u001b(Icevvf\u001b(1: 12345.67 \u001b(Ihe~\u001b(1\n"
+}
diff --git a/examples/ndc-data/bank-a-mini-statement-b64.json b/examples/ndc-data/bank-a-mini-statement-b64.json
new file mode 100644
index 0000000..6564d61
--- /dev/null
+++ b/examples/ndc-data/bank-a-mini-statement-b64.json
@@ -0,0 +1 @@
+{"body": "base64:OjAyGygxICAgICAgICAgICAgICAbKEludGNuamRzcSB+ZnlyIGYbKDENCiAgICAgICAgGyhJbnRrGygxLiA4ICg4MDApIDAwMC0wMC0wMA0KGyhJZmxodGMbKDE6DQpNT1NDT1csIFRFU1RPVkFZQSBVTC4sIDENCiAgGyhJbGZuZhsoMSAgICAgICAgGyhJZGh0dnobKDEgICAgICAgICAbKEl+ZnlyanZmbhsoMQ0KMDEuMDEuMjUgICAgIDEwOjAwOjAwICAgICAgIEFUTTAwMDAxDQogICAgGyhJeWp2dGgbKDEgGyhJcmZobnMbKDEgKioqKioqKioqKioqMDAwMA0KDQogICAgIBsoSWpndGhmd2J6GygxIBsoSWMbKDEgGyhJZGRqbGp2GygxIBsoSWdieRsoMS0bKElyamxmGygxXCENCg0KICAgICAgICAgICANCkFJRDogQTAwMDAwMDAwMDAwMDAgVEVTVENBUkQNCg0KGyhJZ2pja3RseWJ0GygxIBsoSWpndGhmd2JiGygxOg0KMDEvMDEgIDAwMSAgLSAgICAgICAxMDAuMDANCjAxLzAxICAwMDIgICsgICAgICAgIDUwLjAwDQowMS8wMSAgMDAxICAtICAgICAgIDIwMC4wMA0KHTIbKDEwMS8wMSAgMDAyICArICAgICAgICA3NS4wMA0KMDEvMDEgIDAwMyAgLSAgICAgICAgMTAuMDANCjAxLzAxICAwMDQgICsgICAgICAgNTAwLjAwDQowMS8wMSAgMDAzICAtICAgICAgICAxMC4wMA0KMDEvMDEgIDAwMyAgLSAgICAgICAgMTUuMDANCjAxLzAxICAwMDMgIC0gICAgICAgIDYwLjAwDQowMS8wMSAgMDA1ICArICAgICAgMTAwMC4wMA0KMDEvMDEgIDAwNSAgKyAgICAgICA4MDAuMDANCjAxLzAxICAwMDMgIC0gICAgICAgIDUwLjAwDQobKElgcmRmcWhieXVqZGZ6GygxIBsoSXJqdmJjY2J6GygxOiAgICAgICAgMC4wMCAbKEloZX4bKDENChsoSWB2YmNjYmp5eWZ6GygxIBsoSXJqdmJjY2J6GygxOiAgICAgICAxMC4wMCAbKEloZX4bKDENCg0KGyhJcmpsGygxIBsoSWdqbG5kdGh8bHR5YnobKDE6IDAwMDAwMA0KUlJOOiAtLzAwMDAwMDAwMDAwMA0KDQoNCiAgICAgICAgICAgICAgIBsoSWpsan5odHlqGygxXCEM"}
diff --git a/examples/ndc-data/bank-a-mini-statement.json b/examples/ndc-data/bank-a-mini-statement.json
new file mode 100644
index 0000000..7c62027
--- /dev/null
+++ b/examples/ndc-data/bank-a-mini-statement.json
@@ -0,0 +1,3 @@
+{
+ "body": ":02\u001b(1 \u001b(Intcnjdsq ~fyr f\u001b(1\r\n \u001b(Intk\u001b(1. 8 (800) 000-00-00\r\n\u001b(Iflhtc\u001b(1:\r\nMOSCOW, TESTOVAYA UL., 1\r\n \u001b(Ilfnf\u001b(1 \u001b(Idhtvz\u001b(1 \u001b(I~fyrjvfn\u001b(1\r\n01.01.25 10:00:00 ATM00001\r\n \u001b(Iyjvth\u001b(1 \u001b(Irfhns\u001b(1 ************0000\r\n\r\n \u001b(Ijgthfwbz\u001b(1 \u001b(Ic\u001b(1 \u001b(Iddjljv\u001b(1 \u001b(Igby\u001b(1-\u001b(Irjlf\u001b(1!\r\n\r\n \r\nAID: A0000000000000 TESTCARD\r\n\r\n\u001b(Igjcktlybt\u001b(1 \u001b(Ijgthfwbb\u001b(1:\r\n01/01 001 - 100.00\r\n01/01 002 + 50.00\r\n01/01 001 - 200.00\r\n\u001d2\u001b(101/01 002 + 75.00\r\n01/01 003 - 10.00\r\n01/01 004 + 500.00\r\n01/01 003 - 10.00\r\n01/01 003 - 15.00\r\n01/01 003 - 60.00\r\n01/01 005 + 1000.00\r\n01/01 005 + 800.00\r\n01/01 003 - 50.00\r\n\u001b(I`rdfqhbyujdfz\u001b(1 \u001b(Irjvbccbz\u001b(1: 0.00 \u001b(Ihe~\u001b(1\r\n\u001b(I`vbccbjyyfz\u001b(1 \u001b(Irjvbccbz\u001b(1: 10.00 \u001b(Ihe~\u001b(1\r\n\r\n\u001b(Irjl\u001b(1 \u001b(Igjlndth|ltybz\u001b(1: 000000\r\nRRN: -/000000000000\r\n\r\n\r\n \u001b(Ijlj~htyj\u001b(1!\u000c"
+}
diff --git a/examples/ndc-data/bank-a-mini-statement.txt b/examples/ndc-data/bank-a-mini-statement.txt
new file mode 100644
index 0000000..73358bc
--- /dev/null
+++ b/examples/ndc-data/bank-a-mini-statement.txt
@@ -0,0 +1,17 @@
+(1 (Intcnjdsq ~fyr f(1
+ (Intk(1. 8 (800) 000-00-00
+(Iflhtc(1:
+MOSCOW, TESTOVAYA UL., 1
+ (Ilfnf(1 (Idhtvz(1 (I~fyrjvfn(1
+01.01.25 10:00:00 ATM00001
+ (Iyjvth(1 (Irfhns(1 ************0000
+
+ (Ijgthfwbz(1 (Ic(1 (Iddjljv(1 (Igby(1-(Irjlf(1!
+
+
+AID: A0000000000000 TESTCARD
+
+(Igjcktlybt(1 (Ijgthfwbb(1:
+01/01 001 - 100.00
+01/01 002 + 50.00
+01/01 001 - 200.00
diff --git a/examples/ndc-data/bank-a-p2p-b64.json b/examples/ndc-data/bank-a-p2p-b64.json
new file mode 100644
index 0000000..67812b7
--- /dev/null
+++ b/examples/ndc-data/bank-a-p2p-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GygxICAgICAgICAgICAgICAbKEludGNuamRzcSB+ZnlyIGYbKDEKICAgICAgICAbKEludGsbKDEuIDggKDgwMCkgMDAwLTAwLTAwChsoSWZsaHRjGygxOgpNT1NDT1csIFRFU1RPVkFZQSBVTC4sIDEKICAbKElsZm5mGygxICAgICAgICAbKElkaHR2ehsoMSAgICAgICAgIBsoSX5meXJqdmZuGygxCjAxLjAxLjI1ICAgICAxMDowMDowMCAgICAgICBBVE0wMDAwMQogICAgGyhJeWp2dGgbKDEgGyhJcmZobnMbKDEgKioqKioqKioqKioqMDAwMAoKICAgICAbKElqZ3RoZndiehsoMSAbKEljGygxIBsoSWRkamxqdhsoMSAbKElnYnkbKDEtGyhJcmpsZhsoMVwhCkFJRDogQTAwMDAwMDAwMDAwMDAgVEVTVENBUkQKCiAgICAgICAgICAtCgobKElndGh0ZGpsGygxIBsoSWNodGxjbmQbKDEgGyhJeWYbKDEgGyhJcmZobmUbKDEKGyhJeWp2dGgbKDE6IDIqKioqKioqKioqKiowMDAwCg=="}
diff --git a/examples/ndc-data/bank-b-balance-b64.json b/examples/ndc-data/bank-b-balance-b64.json
new file mode 100644
index 0000000..3fedce3
--- /dev/null
+++ b/examples/ndc-data/bank-b-balance-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GygxICAg0JDQniAi0KLQldCh0KLQntCS0KvQmSDQkdCQ0J3QmiDQkSIKINCi0JXQm9CV0KTQntCdINCh0JvQo9CW0JHQqyDQn9Ce0JTQlNCV0KDQltCa0JgKICAgICAgINCa0JvQmNCV0J3QotCe0JI6CiAgICA4LTgwMC0wMDAtMDAtMDAK0JDQlNCg0JXQoToK0LMu0JzQvtGB0LrQstCwLCDRg9C7LiDQotC10YHRgtC+0LLQsNGPLCAxCgrQndCe0JzQldCgINCn0JXQmtCQOiAwMDAwMDAwMDEK0JHQkNCd0JrQntCc0JDQojogQVRNMDAwMDIK0JTQkNCi0JAv0JLQoNCV0JzQrzogMDEuMDEuMjUgIDEwOjAwCgrQndCe0JzQldCgINCa0JDQoNCi0Ks6ICMjIyMjIyMjIyMjIzAwMDAK0JrQntCUINCQ0JLQotCe0KDQmNCX0JDQptCY0Jg6IDAwMDAwMApBSUQgQTAwMDAwMDAwMDAwMDAgVEVTVENBUkQKVFZSOjAwMDAwMDAwMDAgS1ZSOi0K0J7Qn9CV0KDQkNCm0JjQrzog0JfQkNCf0KDQntChINCR0JDQm9CQ0J3QodCQCgrQlNCe0KHQotCj0J/QndCeOiAgMTIzNDUuNjcg0YDRg9CxCgrQkdCQ0JvQkNCd0KE6ICAgMTIzNDUuNjcg0YDRg9CxCtCh0J/QkNCh0JjQkdCeXCEKDA=="}
diff --git a/examples/ndc-data/bank-b-balance-receipt.json b/examples/ndc-data/bank-b-balance-receipt.json
new file mode 100644
index 0000000..f5e0f0f
--- /dev/null
+++ b/examples/ndc-data/bank-b-balance-receipt.json
@@ -0,0 +1,3 @@
+{
+ "body": "\u001b(1 \u0410\u041e \"\u0422\u0415\u0421\u0422\u041e\u0412\u042b\u0419 \u0411\u0410\u041d\u041a \u0411\"\n \u0422\u0415\u041b\u0415\u0424\u041e\u041d \u0421\u041b\u0423\u0416\u0411\u042b \u041f\u041e\u0414\u0414\u0415\u0420\u0416\u041a\u0418\n \u041a\u041b\u0418\u0415\u041d\u0422\u041e\u0412:\n 8-800-000-00-00\n\u0410\u0414\u0420\u0415\u0421:\n\u0433.\u041c\u043e\u0441\u043a\u0432\u0430, \u0443\u043b. \u0422\u0435\u0441\u0442\u043e\u0432\u0430\u044f, 1\n\n\u041d\u041e\u041c\u0415\u0420 \u0427\u0415\u041a\u0410: 000000001\n\u0411\u0410\u041d\u041a\u041e\u041c\u0410\u0422: ATM00002\n\u0414\u0410\u0422\u0410/\u0412\u0420\u0415\u041c\u042f: 01.01.25 10:00\n\n\u041d\u041e\u041c\u0415\u0420 \u041a\u0410\u0420\u0422\u042b: ############0000\n\u041a\u041e\u0414 \u0410\u0412\u0422\u041e\u0420\u0418\u0417\u0410\u0426\u0418\u0418: 000000\nAID A0000000000000 TESTCARD\nTVR:0000000000 KVR:-\n\u041e\u041f\u0415\u0420\u0410\u0426\u0418\u042f: \u0417\u0410\u041f\u0420\u041e\u0421 \u0411\u0410\u041b\u0410\u041d\u0421\u0410\n\n\u0414\u041e\u0421\u0422\u0423\u041f\u041d\u041e: 12345.67 \u0440\u0443\u0431\n\n\u0411\u0410\u041b\u0410\u041d\u0421: 12345.67 \u0440\u0443\u0431\n\u0421\u041f\u0410\u0421\u0418\u0411\u041e!\n\u000c"
+}
diff --git a/examples/ndc-data/bank-b-balance.txt b/examples/ndc-data/bank-b-balance.txt
new file mode 100644
index 0000000..d50cef4
--- /dev/null
+++ b/examples/ndc-data/bank-b-balance.txt
@@ -0,0 +1,21 @@
+(1 АО "ТЕСТОВЫЙ БАНК Б"
+ ТЕЛЕФОН СЛУЖБЫ ПОДДЕРЖКИ
+ КЛИЕНТОВ:
+ 8-800-000-00-00
+АДРЕС:
+г.Москва, ул. Тестовая, 1
+
+НОМЕР ЧЕКА: 000000001
+БАНКОМАТ: ATM00002
+ДАТА/ВРЕМЯ: 01.01.25 10:00
+
+НОМЕР КАРТЫ: ############0000
+КОД АВТОРИЗАЦИИ: 000000
+AID A0000000000000 TESTCARD
+TVR:0000000000 KVR:-
+ОПЕРАЦИЯ: ЗАПРОС БАЛАНСА
+
+ДОСТУПНО: 12345.67 руб
+
+БАЛАНС: 12345.67 руб
+СПАСИБО!
diff --git a/examples/ndc-data/bank-c-balance-b64.json b/examples/ndc-data/bank-c-balance-b64.json
new file mode 100644
index 0000000..7570e3c
--- /dev/null
+++ b/examples/ndc-data/bank-c-balance-b64.json
@@ -0,0 +1 @@
+{"body": "base64:DjkgGyhJbnRjbmpkc3EgfmZ5ciBkCm50ay44KDgwMCkwMDAtMDAtMDAsKzcoNDk1KTAwMC0wMC0wMAoqMDAwMC15anZ0aCBsa3ogdmp+YmtteXN7IH50Y2drZm55aiAKGyhJMDEtMDEtMjUgMTA6MDA6MDAgZmRuLnJqbDogGygyMDAwMDAwChsoSWZudjogQVRNMDAwMDMgcmZobmY6WFhYWFhYWFhYWFhYMDAwMApSUk46IDAwMDAwMDAwMDAwMApBSUQ6IEEwMDAwMDAwMDAwMDAwChsoMlRFU1RDQVJEIERFQklUCgobKElieWFqaHZmd2J6IGp+IGpjbmZucnQKChsoSWxqY25lZ3lqGygyOgorMTIzNDUuNjcgUlVCChsoSWQgbmp2IHhiY2t0IHJodGxibnlzcSBrYnZibhsoMjoKKzAuMDAgUlVCCg4xChsoSXBmIGNqZHRoaXR5YnQgamd0aGZ3YmIKZHBidmZ0bmN6IHJqdmJjY2J6IGQgCmNqam5kdG5jbmRiYiBjIG5maGJhZnZiIH5meXJmXCEKDjEgCgw="}
diff --git a/examples/ndc-data/bank-c-balance-receipt.json b/examples/ndc-data/bank-c-balance-receipt.json
new file mode 100644
index 0000000..b7f8144
--- /dev/null
+++ b/examples/ndc-data/bank-c-balance-receipt.json
@@ -0,0 +1,3 @@
+{
+ "body": "\u000e9 \u001b(Intcnjdsq ~fyr d\nntk.8(800)000-00-00,+7(495)000-00-00\n*0000-yjvth lkz vj~bkmys{ ~tcgkfnyj \n\u001b(I01-01-25 10:00:00 fdn.rjl: \u001b(2000000\n\u001b(Ifnv: ATM00003 rfhnf:XXXXXXXXXXXX0000\nRRN: 000000000000\nAID: A0000000000000\n\u001b(2TESTCARD DEBIT\n\n\u001b(Ibyajhvfwbz j~ jcnfnrt\n\n\u001b(Iljcnegyj\u001b(2:\n+12345.67 RUB\n\u001b(Id njv xbckt rhtlbnysq kbvbn\u001b(2:\n+0.00 RUB\n\u000e1\n\u001b(Ipf cjdthitybt jgthfwbb\ndpbvftncz rjvbccbz d \ncjjndtncndbb c nfhbafvb ~fyrf!\n\u000e1 \n\u000c"
+}
diff --git a/examples/ndc-data/bank-c-balance.txt b/examples/ndc-data/bank-c-balance.txt
new file mode 100644
index 0000000..b377871
--- /dev/null
+++ b/examples/ndc-data/bank-c-balance.txt
@@ -0,0 +1,20 @@
+(I ntcnjdsq ~fyr d
+ntk.8(800)000-00-00,+7(495)000-00-00
+*0000-yjvth lkz vj~bkmys{ ~tcgkfnyj
+(I01-01-25 10:00:00 fdn.rjl: (2000000
+(Ifnv: ATM00003 rfhnf:XXXXXXXXXXXX0000
+RRN: 000000000000
+AID: A0000000000000
+(2TESTCARD DEBIT
+
+(Ibyajhvfwbz j~ jcnfnrt
+
+(Iljcnegyj(2:
++12345.67 RUB
+(Id njv xbckt rhtlbnysq kbvbn(2:
++0.00 RUB
+1
+(Ipf cjdthitybt jgthfwbb
+dpbvftncz rjvbccbz d
+cjjndtncndbb c nfhbafvb ~fyrf!
+1
diff --git a/examples/ndc-data/bank-c-deposit-b64.json b/examples/ndc-data/bank-c-deposit-b64.json
new file mode 100644
index 0000000..5783f9f
--- /dev/null
+++ b/examples/ndc-data/bank-c-deposit-b64.json
@@ -0,0 +1 @@
+{"body": "base64:DjkgGyhJbnRjbmpkc3EgfmZ5ciBkCm50ay44KDgwMCkwMDAtMDAtMDAsKzcoNDk1KTAwMC0wMC0wMAoqMDAwMC15anZ0aCBsa3ogdmp+YmtteXN7IH50Y2drZm55aiAKGyhJMDEtMDEtMjUgMTA6MDA6MDAgZmRuLnJqbDogGygyMDAwMDAwChsoSWZudjogQVRNMDAwMDMgcmZobmY6WFhYWFhYWFhYWFhYMDAwMApSUk46IDAwMDAwMDAwMDAwMApBSUQ6IEEwMDAwMDAwMDAwMDAwChsoMlRFU1RDQVJEIERFQklUCgobKElnaGJ0diB5ZmtieHlzewoKChsoSWNldnZmGygyOiAgICAgICAgICAgICAgIDUwLjAwIFJVQgoOMQoKGyhJcGYgY2pkdGhpdHlidCBqZ3RoZndiYgpkcGJ2ZnRuY3ogcmp2YmNjYnogZCAKY2pqbmR0bmNuZGJiIGMgbmZoYmFmdmIgfmZ5cmZcIQoOMSAK"}
diff --git a/examples/ndc-data/bank-c-p2p-b64.json b/examples/ndc-data/bank-c-p2p-b64.json
new file mode 100644
index 0000000..d06a88a
--- /dev/null
+++ b/examples/ndc-data/bank-c-p2p-b64.json
@@ -0,0 +1 @@
+{"body": "base64:DjkgGyhJbnRjbmpkc3EgfmZ5ciBkCm50ay44KDgwMCkwMDAtMDAtMDAsKzcoNDk1KTAwMC0wMC0wMAoqMDAwMC15anZ0aCBsa3ogdmp+YmtteXN7IH50Y2drZm55aiAKGyhJMDEtMDEtMjUgMTA6MDA6MDAgZmRuLnJqbDogGygyMDAwMDAwChsoSWZudjogQVRNMDAwMDMgcmZobmY6WFhYWFhYWFhYWFhYMDAwMApSUk46IDAwMDAwMDAwMDAwMApBSUQ6IEEwMDAwMDAwMDAwMDAwChsoMlRFU1RDQVJEIERFQklUCgobKElndGh0ZGpsIGdqIHlqdnRoZSByZmhucwoKChsoSWhmd3JnYnBibnlzGygxOiBYWFhYWFhYWFhYWFgwMDAwCgobKEljZXZ2ZhsoMjogICAgICAgMTAwLjAwIFJVQgoOMQoKGyhJcGYgY2pkdGhpdHlidCBqZ3RoZndiYgpkcGJ2ZnRuY3ogcmp2YmNjYnogZCAKY2pqbmR0bmNuZGJiIGMgbmZoYmFmdmIgfmZ5cmZcIQoOMSAK"}
diff --git a/examples/ndc-data/bank-c-statement-b64.json b/examples/ndc-data/bank-c-statement-b64.json
new file mode 100644
index 0000000..50eafca
--- /dev/null
+++ b/examples/ndc-data/bank-c-statement-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GyhJdmJ5Yi1kc2diY3JmOgowMS0wMS0yNSAgICAgIC0xMDAuMDAgUlVSCjAxLTAxLTI1ICAgICAgICs1MC4wMCBSVVIKMDEtMDEtMjUgICAgICAtMTAwLjAwIFJVUgowMS0wMS0yNSAgICAgIC0yMDAuMDAgUlVSCjAxLTAxLTI1ICAgICAgKzEwMC4wMCBSVVIKMDEtMDEtMjUgICAgICAtMTAwLjAwIFJVUgowMS0wMS0yNSAgICAgICsxMDAuMDAgUlVSCjAxLTAxLTI1ICAgICAgLTEwMC4wMCBSVVIKMDEtMDEtMjUgICAgICAtMTUwLjAwIFJVUgowMS0wMS0yNSAgICAgICszMDAuMDAgUlVSChsoSWxqY25lZ3lqOgorMTIzNDUuNjcgUlVSChsoSWQgbmp2IHhiY2t0IHJodGxibnlzcSBrYnZibjoKKzAuMDAgUlVSCg=="}
diff --git a/examples/ndc-data/bank-c-statement-receipt.json b/examples/ndc-data/bank-c-statement-receipt.json
new file mode 100644
index 0000000..48f5a42
--- /dev/null
+++ b/examples/ndc-data/bank-c-statement-receipt.json
@@ -0,0 +1,3 @@
+{
+ "body": "\u001b(Ivbyb-dsgbcrf:\n01-01-25 -100.00 RUR\n01-01-25 +50.00 RUR\n01-01-25 -100.00 RUR\n01-01-25 -200.00 RUR\n01-01-25 +100.00 RUR\n01-01-25 -100.00 RUR\n01-01-25 +100.00 RUR\n01-01-25 -100.00 RUR\n01-01-25 -150.00 RUR\n01-01-25 +300.00 RUR\n\u001b(Iljcnegyj:\n+12345.67 RUR\n\u001b(Id njv xbckt rhtlbnysq kbvbn:\n+0.00 RUR\n"
+}
diff --git a/examples/ndc-data/bank-c-withdrawal-b64.json b/examples/ndc-data/bank-c-withdrawal-b64.json
new file mode 100644
index 0000000..c94a289
--- /dev/null
+++ b/examples/ndc-data/bank-c-withdrawal-b64.json
@@ -0,0 +1 @@
+{"body": "base64:DjkgGyhJbnRjbmpkc3EgfmZ5ciBkCm50ay44KDgwMCkwMDAtMDAtMDAsKzcoNDk1KTAwMC0wMC0wMAoqMDAwMC15anZ0aCBsa3ogdmp+YmtteXN7IH50Y2drZm55aiAKGyhJMDEtMDEtMjUgMTA6MDA6MDAgZmRuLnJqbDogGygyMDAwMDAwChsoSWZudjogQVRNMDAwMDMgcmZobmY6WFhYWFhYWFhYWFhYMDAwMApSUk46IDAwMDAwMDAwMDAwMApBSUQ6IEEwMDAwMDAwMDAwMDAwChsoMlRFU1RDQVJEIERFQklUCgobKElkc2xmeGYgeWZrYnh5c3sKCmNldnZmGygyOiAgICAxMDAuMDAgUlVCCg4xChsoSXBmIGNqZHRoaXR5YnQgamd0aGZ3YmIKZHBidmZ0bmN6IHJqdmJjY2J6IGQgCmNqam5kdG5jbmRiYiBjIG5maGJhZnZiIH5meXJmXCEKDjEgCg=="}
diff --git a/examples/ndc-data/bank-d-balance-2currencies-b64.json b/examples/ndc-data/bank-d-balance-2currencies-b64.json
new file mode 100644
index 0000000..8f18941
--- /dev/null
+++ b/examples/ndc-data/bank-d-balance-2currencies-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GygyGyhJdS4gdmpjcmRmLCAbKEllay4gbnRjbmpkZnosIGwuIDEsIHIuMQoKGyhJbBsoSmZuZg42GyhJZBsoSmh0dnoONRsoSX4bKEpmeXJqdmZuCjAxLjAxLjI1ICAxMDowMDowMCAgQVRNMDAwMDQKChsoSXIbKEpmaG5mOhsoMjUuLjAwMDAoT3VyIFRFU1RDQVJEKQpBSUQ6ICBBMDAwMDAwMDAwMDAwMCBURVNUQ0FSRApUVlI6ICAwMDAwMDAwMDAwChsoSXIbKEpqbCBqZ3RoZndiYjogMDAwMDAwLzAwMDAwMDAwMDAwMAoKGyhJImIbKEp5YWpodmZ3YnogaiB+ZmtmeWN0IgoKGyhJZBsoSmN0dWogbGpjbmVneWo6Cg46DjcbKEkgICAgICs0Ny45NiBVU0QKGyhJZBsoSmN0dWogbGpjbmVneWo6Cg46DjcbKEkgICArMzk2MS45MCBSVVI="}
diff --git a/examples/ndc-data/bank-d-balance-b64.json b/examples/ndc-data/bank-d-balance-b64.json
new file mode 100644
index 0000000..9ffeef0
--- /dev/null
+++ b/examples/ndc-data/bank-d-balance-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GygyGyhJdS4gdmpjcmRmLCAbKEllay4gbnRjbmpkZnosIGwuIDEsIHJqaGcuIDEgfgoKGyhJbBsoSmZuZg42GyhJZBsoSmh0dnoONRsoSX4bKEpmeXJqdmZuCjAxLjAxLjI1ICAxMDowMDowMCAgQVRNMDAwMDQKChsoSXIbKEpmaG5mOhsoMjQuLjAwMDAoT3VyIFRFU1RDQVJEKQpBSUQ6ICBBMDAwMDAwMDAwMDAwMCBURVNUQ0FSRCBERUJJVApUVlI6ICAwMDAwMDAwMDAwChsoSXIbKEpqbCBqZ3RoZndiYjogMDAwMDAwLzAwMDAwMDAwMDAwMAoKGyhJImIbKEp5YWpodmZ3YnogaiB+ZmtmeWN0IgoKGyhJZBsoSmN0dWogbGpjbmVneWo6Cg46DjcbKEkgICsxMjM0NS42NyBSVVIKGyhKYnAgeWJ7OgpsamNuZWd5c3Egcmh0bGJueXNxIGtidmJuOgoOOg43GyhJICAgICAgICswLjAwIFJVQg=="}
diff --git a/examples/ndc-data/bank-d-balance.txt b/examples/ndc-data/bank-d-balance.txt
new file mode 100644
index 0000000..e356677
--- /dev/null
+++ b/examples/ndc-data/bank-d-balance.txt
@@ -0,0 +1,17 @@
+(2(Iu. vjcrdf, (Iek. ntcnjdfz, l. 1, rjhg. 1 ~
+
+(Il(Jfnf6(Id(Jhtvz5(I~(Jfyrjvfn
+01.01.25 10:00:00 ATM00004
+
+(Ir(Jfhnf:(24..0000(Our TESTCARD)
+AID: A0000000000000 TESTCARD DEBIT
+TVR: 0000000000
+(Ir(Jjl jgthfwbb: 000000/000000000000
+
+(I"b(Jyajhvfwbz j ~fkfyct"
+
+(Id(Jctuj ljcnegyj:
+:7(I +12345.67 RUR
+(Jbp yb{:
+ljcnegysq rhtlbnysq kbvbn:
+:7(I +0.00 RUB
diff --git a/examples/ndc-data/bank-d-deposit-b64.json b/examples/ndc-data/bank-d-deposit-b64.json
new file mode 100644
index 0000000..8eb03e4
--- /dev/null
+++ b/examples/ndc-data/bank-d-deposit-b64.json
@@ -0,0 +1 @@
+{"body": "base64:ChsoSXIbKEpmaG5mOhsoMioqMDAwMChPdXIgVEVTVENBUkQpCkFJRDogIEEwMDAwMDAwMDAwMDAwIFRFU1RDQVJEClRWUjogIDAwMDAwMDAwMDAKGyhJchsoSmpsIGpndGhmd2JiOiAwMDAwMDAvMDAwMDAwMDAwMDAwCgobKEl+GyhKdGNnaGp3dHlueWZybnlzZSBqZ3RoZndiegobKEkiZBsoSmtmeGYgeWZrYnh5c3siCg=="}
diff --git a/examples/ndc-data/bank-e-balance-b64.json b/examples/ndc-data/bank-e-balance-b64.json
new file mode 100644
index 0000000..084a507
--- /dev/null
+++ b/examples/ndc-data/bank-e-balance-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GyhJICAgICAgbnRjbmpkc3EgfmZ5ciBsKGZqKQ0KdmpjcmRmLCBlay4gbnRjbmpkZnogMSBjbmggMQ0KICAgICBudGsuKDQ5NSkwMDAtMDAwMA0KGyhJIGxmbmYgICAgICBkaHR2eiAgIEFUTQ0KGygyMDEtMDEtMjAyNSAxMDowMCBBVE0wMDAwNQ0KGyhJcmZobmYbKDI6IDk5OTk5OSowMDAwDQoONBsoSX5ma2Z5YyB+ZnlyanZmbmYNCh0zDjgbKEl4dHIgMSBicCAyDQobKEl3YnJrIGxiY2d0eWN0aGYbKDI6IDEwMA0KGyhJY2V2dnMgZ2ogcmZjY3RuZnYgZHNsZnhiGygyOg0KGyhJcmZjIHBmdWhlfHR5aiBkc2xmeWogamNuZm5qcg0KMTogICAgIDEwMCwgICAgICA1MCwgICAgMTAwDQoyOiAgICAgNTAwLCAgICAgMjAwLCAgICAzMDANCjM6ICAgIDEwMDAsICAgICA1MDAsICAgIDUwMA0KNDogICAgNTAwMCwgICAgMTAwMCwgICA0MDAwDQobKElibmp1aiBqY25mbmpyGygyOiAgICAgICAgICA5MDAgUlVSDQoMHTMbKEkgbGZuZiAgICAgIGRodHZ6ICAgQVRNDQobKDIwMS0wMS0yMDI1IDEwOjAwIEFUTTAwMDA1DQobKElyZmhuZhsoMjogOTk5OTk5KjAwMDANCg40GyhJfmZrZnljIH5meXJqdmZuZg0KDQoOOBsoSXh0ciAyIGJwIDINChsoSXdicmsgbGJjZ3R5Y3RoZhsoMjogMTAwDQobKElyZmNjICAbKDI6ICAgMSAgICAyICAgIDMgICAgNCANChsoSWNuZm5lYxsoMjogRVJSLCBFUlIsIEVSUiwgRVJSDQobKElsdHlqdi4bKDI6IDEwMCwgNTAwLDEwMDAsNTAwMA0KGyhJZGZrLiAgGygyOiBSVVIsIFJVUiwgUlVSLCBSVVINChsoSXBmdWhlfBsoMjogICAzLCAgIDUsICAgNiwgICAxDQobKElkc2xmeWobKDI6ICAgMSwgICAyLCAgIDIsICAgMQ0KGyhJfmhmciAgGygyOiAgIDEsICAgMSwgICAxLCAgIDENChsoSXBmbHRofBsoMjogICAwLCAgIDAsICAgMCwgICAwDQoNChsoSXBmbHRofGZ5aiByZmhuGygyOiAwIA0K"}
diff --git a/examples/ndc-data/bank-e-balance.json b/examples/ndc-data/bank-e-balance.json
new file mode 100644
index 0000000..4299b61
--- /dev/null
+++ b/examples/ndc-data/bank-e-balance.json
@@ -0,0 +1,3 @@
+{
+ "body": "\u001b(I ntcnjdsq ~fyr l(fj)\r\nvjcrdf, ek. ntcnjdfz 1 cnh 1\r\n ntk.(495)000-0000\r\n\u001b(I lfnf dhtvz ATM\r\n\u001b(201-01-2025 10:00 ATM00005\r\n\u001b(Irfhnf\u001b(2: 999999*0000\r\n\u000e4\u001b(I~fkfyc ~fyrjvfnf\r\n\u001d3\u000e8\u001b(Ixtr 1 bp 2\r\n\u001b(Iwbrk lbcgtycthf\u001b(2: 100\r\n\u001b(Icevvs gj rfcctnfv dslfxb\u001b(2:\r\n\u001b(Irfc pfuhe|tyj dslfyj jcnfnjr\r\n1: 100, 50, 100\r\n2: 500, 200, 300\r\n3: 1000, 500, 500\r\n4: 5000, 1000, 4000\r\n\u001b(Ibnjuj jcnfnjr\u001b(2: 900 RUR\r\n\u000c\u001d3\u001b(I lfnf dhtvz ATM\r\n\u001b(201-01-2025 10:00 ATM00005\r\n\u001b(Irfhnf\u001b(2: 999999*0000\r\n\u000e4\u001b(I~fkfyc ~fyrjvfnf\r\n\r\n\u000e8\u001b(Ixtr 2 bp 2\r\n\u001b(Iwbrk lbcgtycthf\u001b(2: 100\r\n\u001b(Irfcc \u001b(2: 1 2 3 4 \r\n\u001b(Icnfnec\u001b(2: ERR, ERR, ERR, ERR\r\n\u001b(Iltyjv.\u001b(2: 100, 500,1000,5000\r\n\u001b(Idfk. \u001b(2: RUR, RUR, RUR, RUR\r\n\u001b(Ipfuhe|\u001b(2: 3, 5, 6, 1\r\n\u001b(Idslfyj\u001b(2: 1, 2, 2, 1\r\n\u001b(I~hfr \u001b(2: 1, 1, 1, 1\r\n\u001b(Ipflth|\u001b(2: 0, 0, 0, 0\r\n\r\n\u001b(Ipflth|fyj rfhn\u001b(2: 0 \r\n"
+}
diff --git a/examples/ndc-data/bank-f-balance-b64.json b/examples/ndc-data/bank-f-balance-b64.json
new file mode 100644
index 0000000..fcede81
--- /dev/null
+++ b/examples/ndc-data/bank-f-balance-b64.json
@@ -0,0 +1 @@
+{"body": "base64:GyhJIGxmbmYgICAgICBkaHR2eiAgIEFUTQobKDIwMS0wMS0yMDI1IDEwOjAwIEFUTTAwMDA2ChsoSXJmaG5mGygyOiA5OTk5OTkqMDAwMAoONBsoSX5ma2Z5YyB+ZnlyanZmbmYKCg44GyhJeHRyIDIgYnAgMgobKEl3YnJrIGxiY2d0eWN0aGYbKDI6IDEwMAobKElyZmNjICAbKDI6ICAgMSAgICAyICAgIDMgICAgNAobKEljbmZuZWMbKDI6IEVSUiwgRVJSLCBFUlIsIEVSUgobKElsdHlqdi4bKDI6IDEwMCwgNTAwLDEwMDAsNTAwMAobKElkZmsuICAbKDI6IFJVUiwgUlVSLCBSVVIsIFJVUgobKElwZnVoZXwbKDI6ICAgMywgICA1LCAgIDYsICAgMQobKElkc2xmeWobKDI6ICAgMSwgICAyLCAgIDIsICAgMQobKEl+aGZyICAbKDI6ICAgMSwgICAxLCAgIDEsICAgMQobKElwZmx0aHwbKDI6ICAgMCwgICAwLCAgIDAsICAgMAoKGyhJcGZsdGh8ZnlqIHJmaG4bKDI6IDAK"}
diff --git a/examples/ndc-data/bank-f-balance.txt b/examples/ndc-data/bank-f-balance.txt
new file mode 100644
index 0000000..40b3be3
--- /dev/null
+++ b/examples/ndc-data/bank-f-balance.txt
@@ -0,0 +1,17 @@
+(I lfnf dhtvz ATM
+(201-01-2025 10:00 ATM00006
+(Irfhnf(2: 999999*0000
+4(I~fkfyc ~fyrjvfnf
+
+8(Ixtr 2 bp 2
+(Iwbrk lbcgtycthf(2: 100
+(Irfcc (2: 1 2 3 4
+(Icnfnec(2: ERR, ERR, ERR, ERR
+(Iltyjv.(2: 100, 500,1000,5000
+(Idfk. (2: RUR, RUR, RUR, RUR
+(Ipfuhe|(2: 3, 5, 6, 1
+(Idslfyj(2: 1, 2, 2, 1
+(I~hfr (2: 1, 1, 1, 1
+(Ipflth|(2: 0, 0, 0, 0
+
+(Ipflth|fyj rfhn(2: 0
diff --git a/examples/ndc-receipt-bank-a.yaml b/examples/ndc-receipt-bank-a.yaml
new file mode 100644
index 0000000..176b532
--- /dev/null
+++ b/examples/ndc-receipt-bank-a.yaml
@@ -0,0 +1,27 @@
+## NDC Receipt -- Test Bank A (balance)
+## Usage: flexrender render ndc-receipt-bank-a.yaml -o bank-a.png
+
+fonts:
+ default: "assets/fonts/JetBrainsMono-Regular.ttf"
+ bold: "assets/fonts/JetBrainsMono-Bold.ttf"
+
+canvas:
+ width: 576
+ background: "#ffffff"
+ fixed: width
+
+layout:
+ - type: content
+ source: "file:ndc-data/bank-a-balance.txt"
+ format: ndc
+ options:
+ columns: 40
+ font_family: JetBrains Mono
+ charsets:
+ I:
+ font: bold
+ font_style: bold
+ encoding: qwerty-jcuken
+ uppercase: true
+ "1":
+ font: default
diff --git a/examples/ndc-receipt-bank-b.yaml b/examples/ndc-receipt-bank-b.yaml
new file mode 100644
index 0000000..95fcf27
--- /dev/null
+++ b/examples/ndc-receipt-bank-b.yaml
@@ -0,0 +1,26 @@
+## NDC Receipt -- Test Bank B (balance)
+## Usage: flexrender render ndc-receipt-bank-b.yaml -o bank-b.png
+
+fonts:
+ default: "assets/fonts/JetBrainsMono-Regular.ttf"
+ bold: "assets/fonts/JetBrainsMono-Bold.ttf"
+
+canvas:
+ width: 576
+ background: "#ffffff"
+ fixed: width
+
+layout:
+ - type: content
+ source: "file:ndc-data/bank-b-balance.txt"
+ format: ndc
+ options:
+ columns: 40
+ input_encoding: utf-8
+ font_family: JetBrains Mono
+ charsets:
+ I:
+ font: bold
+ font_style: bold
+ "1":
+ font: default
diff --git a/examples/ndc-receipt-bank-c.yaml b/examples/ndc-receipt-bank-c.yaml
new file mode 100644
index 0000000..a8d1f33
--- /dev/null
+++ b/examples/ndc-receipt-bank-c.yaml
@@ -0,0 +1,27 @@
+## NDC Receipt -- Test Bank C (balance)
+## Usage: flexrender render ndc-receipt-bank-c.yaml -o bank-c.png
+
+fonts:
+ default: "assets/fonts/JetBrainsMono-Regular.ttf"
+ bold: "assets/fonts/JetBrainsMono-Bold.ttf"
+
+canvas:
+ width: 576
+ background: "#ffffff"
+ fixed: width
+
+layout:
+ - type: content
+ source: "file:ndc-data/bank-c-balance.txt"
+ format: ndc
+ options:
+ columns: 40
+ font_family: JetBrains Mono
+ charsets:
+ I:
+ font: bold
+ font_style: bold
+ encoding: qwerty-jcuken
+ uppercase: true
+ "2":
+ font: default
diff --git a/examples/ndc-receipt-bank-d.yaml b/examples/ndc-receipt-bank-d.yaml
new file mode 100644
index 0000000..fe34e9a
--- /dev/null
+++ b/examples/ndc-receipt-bank-d.yaml
@@ -0,0 +1,29 @@
+## NDC Receipt -- Test Bank D (balance)
+## Usage: flexrender render ndc-receipt-bank-d.yaml -o bank-d.png
+
+fonts:
+ default: "assets/fonts/JetBrainsMono-Regular.ttf"
+ bold: "assets/fonts/JetBrainsMono-Bold.ttf"
+
+canvas:
+ width: 576
+ background: "#ffffff"
+ fixed: width
+
+layout:
+ - type: content
+ source: "file:ndc-data/bank-d-balance.txt"
+ format: ndc
+ options:
+ columns: 40
+ font_family: JetBrains Mono
+ charsets:
+ I:
+ font: bold
+ font_style: bold
+ encoding: qwerty-jcuken
+ uppercase: true
+ J:
+ encoding: qwerty-jcuken
+ "2":
+ font: default
diff --git a/examples/ndc-receipt-bank-f.yaml b/examples/ndc-receipt-bank-f.yaml
new file mode 100644
index 0000000..3dc10e7
--- /dev/null
+++ b/examples/ndc-receipt-bank-f.yaml
@@ -0,0 +1,27 @@
+## NDC Receipt -- Test Bank F (balance)
+## Usage: flexrender render ndc-receipt-bank-f.yaml -o bank-f.png
+
+fonts:
+ default: "assets/fonts/JetBrainsMono-Regular.ttf"
+ bold: "assets/fonts/JetBrainsMono-Bold.ttf"
+
+canvas:
+ width: 576
+ background: "#ffffff"
+ fixed: width
+
+layout:
+ - type: content
+ source: "file:ndc-data/bank-f-balance.txt"
+ format: ndc
+ options:
+ columns: 40
+ font_family: JetBrains Mono
+ charsets:
+ I:
+ font: bold
+ font_style: bold
+ encoding: qwerty-jcuken
+ uppercase: true
+ "2":
+ font: default
diff --git a/examples/output/finservice-balance.png b/examples/output/finservice-balance.png
new file mode 100644
index 0000000..30834e6
--- /dev/null
+++ b/examples/output/finservice-balance.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dec5f18aeb30c227b97f0a17eeb361ff4b8614240b12cb3a7d2f5f8b4a0ac815
+size 80079
diff --git a/examples/output/finservice.png b/examples/output/finservice.png
new file mode 100644
index 0000000..30834e6
--- /dev/null
+++ b/examples/output/finservice.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dec5f18aeb30c227b97f0a17eeb361ff4b8614240b12cb3a7d2f5f8b4a0ac815
+size 80079
diff --git a/examples/output/haba-balance.png b/examples/output/haba-balance.png
new file mode 100644
index 0000000..06685dd
--- /dev/null
+++ b/examples/output/haba-balance.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:531b8d2924bba3c05ebb3c1bc924da971e8de32ebf220074abbba22f5ca4ae04
+size 119568
diff --git a/examples/output/ndc-simple.png b/examples/output/ndc-simple.png
new file mode 100644
index 0000000..aff381f
--- /dev/null
+++ b/examples/output/ndc-simple.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bebfdb62e89fee192217f1be0fd8014b203467e696799ae9a71a20be4be5c2b0
+size 17922
diff --git a/examples/output/novikom-mini-statement.png b/examples/output/novikom-mini-statement.png
new file mode 100644
index 0000000..92fcd91
--- /dev/null
+++ b/examples/output/novikom-mini-statement.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8da080c3149b477521479ade2ae09960fbffc28d0fbbf2ab0230632614f37be1
+size 117247
diff --git a/examples/output/novikom.png b/examples/output/novikom.png
new file mode 100644
index 0000000..2b23cf7
--- /dev/null
+++ b/examples/output/novikom.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:82a90acd016c5ff919e2e524a8a5970b980e4a2a1b80d9ee8d3e799dde75a387
+size 64093
diff --git a/examples/output/psb-balance.png b/examples/output/psb-balance.png
new file mode 100644
index 0000000..ef56235
--- /dev/null
+++ b/examples/output/psb-balance.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f91426dc0b4a07253f0f98cf357d40f17d046bb95adb1f5150f3c25e0eeff0bd
+size 55857
diff --git a/examples/output/psb.png b/examples/output/psb.png
new file mode 100644
index 0000000..ef56235
--- /dev/null
+++ b/examples/output/psb.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f91426dc0b4a07253f0f98cf357d40f17d046bb95adb1f5150f3c25e0eeff0bd
+size 55857
diff --git a/examples/output/vbrr-balance-b64.png b/examples/output/vbrr-balance-b64.png
new file mode 100644
index 0000000..d8ddc23
--- /dev/null
+++ b/examples/output/vbrr-balance-b64.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3cc38d6e2b256830a409dbd8c43e48cefa47e6635d2c09a57fd551675824b4ae
+size 61381
diff --git a/examples/output/vbrr.png b/examples/output/vbrr.png
new file mode 100644
index 0000000..d8ddc23
--- /dev/null
+++ b/examples/output/vbrr.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3cc38d6e2b256830a409dbd8c43e48cefa47e6635d2c09a57fd551675824b4ae
+size 61381
diff --git a/examples/output/zenit-balance.png b/examples/output/zenit-balance.png
new file mode 100644
index 0000000..ad4e500
--- /dev/null
+++ b/examples/output/zenit-balance.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e7ed256e6bbfb6a3819760be43f866a436712a23544977ec93a691242992c924
+size 81244
diff --git a/examples/output/zenit.png b/examples/output/zenit.png
new file mode 100644
index 0000000..ad4e500
--- /dev/null
+++ b/examples/output/zenit.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e7ed256e6bbfb6a3819760be43f866a436712a23544977ec93a691242992c924
+size 81244
diff --git a/llms-full.txt b/llms-full.txt
index f12f1d1..dfee7bb 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -79,6 +79,7 @@ src/FlexRender.Svg/ # SVG backend meta-package (renderer + providers
src/FlexRender.Content.Markdown/ # Markdown content parser (-> Core + Markdig)
src/FlexRender.Content.Html/ # HTML content parser (-> Core + HtmlAgilityPack)
+src/FlexRender.Content.Ndc/ # NDC (ATM receipt) content parser (-> Core)
src/FlexRender.DependencyInjection/ # Microsoft.Extensions.DI integration
ServiceCollectionExtensions.cs # AddFlexRender() extension method
@@ -140,6 +141,7 @@ FlexRender.Yaml FlexRender.Http FlexRender.Skia.Render FlexRender.ImageSharp.
| FlexRender.Svg | Svg.Render + providers | (none) |
| FlexRender.Content.Markdown | Core | Markdig 1.1.1 |
| FlexRender.Content.Html | Core | HtmlAgilityPack 1.12.4 |
+| FlexRender.Content.Ndc | Core | (none) |
| FlexRender.MetaPackage | All | -- |
| flexrender-cli | All | System.CommandLine |
@@ -848,12 +850,13 @@ Renders SVG vector graphics. Supports external files (`src`) or inline markup (`
## Element Type: content (ContentElement)
-Embeds formatted text (Markdown or HTML) that is parsed into an AST subtree at render time. Requires a content parser to be registered.
+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.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
-| source | string | "" | Formatted text content, may contain `{{variable}}` expressions |
-| format | string | "markdown" | Content format: `markdown` or `html` -- must match a registered parser |
+| source | string | "" | Content to parse. Supports plain text, `base64:` binary, `file:` URIs, `text:` prefix, `{{variable}}` bound to `string` or `BytesValue` (`byte[]`) |
+| 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. |
```yaml
- type: content
@@ -861,9 +864,51 @@ Embeds formatted text (Markdown or HTML) that is parsed into an AST subtree at r
format: markdown # or "html" -- must match registered parser
```
-Requires content parser registration: `.WithMarkdown()` (Markdig) or `.WithHtml()` (HtmlAgilityPack).
+Content source resolution (via ContentSourceResolver):
+- `{{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
+
+Binary data binding:
+```csharp
+// Pass byte[] directly to binary parsers (e.g., NDC)
+var data = new ObjectValue { ["receipt"] = new BytesValue(bytes) };
+var data = new ObjectValue { ["receipt"] = BytesValue.FromStream(stream) };
+```
+
+Requires content parser registration: `.WithMarkdown()` (Markdig), `.WithHtml()` (HtmlAgilityPack), or `.WithNdc()` (built-in).
Converts formatted text into FlexRender AST elements at render time (bold -> FontWeight.Bold, italic -> FontStyle.Italic, headings, lists, etc.).
+### NDC Format
+
+The `ndc` format parses binary NDC (NCR ATM protocol) printer data streams. Supports both text (`IContentParser`) and binary (`IBinaryContentParser`) input.
+
+Key features:
+- Charset switching with per-charset styling (font, color, encoding)
+- QWERTY→JCUKEN character encoding for Russian
+- Embedded barcode support (UPC-A, UPC-E, EAN-13, EAN-8, Code39, Code128, Codabar)
+- Auto font size calculation based on parent width
+- Form feed, line spacing, tab controls
+
+```yaml
+- type: content
+ source: "{{receiptData}}"
+ format: ndc
+ options:
+ columns: 40
+ input_encoding: latin1
+ font_family: "JetBrains Mono"
+ charsets:
+ "1":
+ encoding: "qwerty-jcuken"
+ font_style: bold
+```
+
+Register via `.WithNdc()` (FlexRender.Content.Ndc package, 0 external dependencies).
+
## Color Format
Colors are specified in hex format:
@@ -1099,7 +1144,7 @@ Conditional rendering based on data values. Supports 13 operators.
format: markdown # or "html" -- must match registered parser
```
-Requires content parser registration: `.WithMarkdown()` (Markdig) or `.WithHtml()` (HtmlAgilityPack).
+Requires content parser registration: `.WithMarkdown()` (Markdig), `.WithHtml()` (HtmlAgilityPack), or `.WithNdc()` (built-in).
Converts formatted text into FlexRender AST elements at render time (bold -> FontWeight.Bold, italic -> FontStyle.Italic, headings, lists, etc.).
### Processing Layers
@@ -1144,6 +1189,7 @@ var render = new FlexRenderBuilder()
.WithLimits(limits => limits.MaxRenderDepth = 200)
.WithMarkdown() // optional: Markdown content parsing
.WithHtml() // optional: HTML content parsing
+ .WithNdc() // optional: NDC ATM receipt parsing
.WithSkia(skia => skia
.WithQr() // QR code support
.WithBarcode()) // Barcode support
@@ -1221,6 +1267,8 @@ byte[] png = await render.Render(_templates["receipt"], data);
| `WithFilter(ITemplateFilter)` | Register a custom template filter for inline expressions (works alongside built-in filters) |
| `WithMarkdown()` | Enable Markdown content parsing (requires FlexRender.Content.Markdown) |
| `WithHtml()` | Enable HTML content parsing (requires FlexRender.Content.Html) |
+| `WithNdc()` | Enable NDC content parsing (requires FlexRender.Content.Ndc) |
+| `WithBinaryContentParser(IBinaryContentParser)` | Register a binary content parser |
| `WithoutDefaultLoaders()` | Remove File and Base64 loaders |
| `WithoutDefaultFilters()` | Remove all 8 built-in filters (enabled by default), leaving only custom-registered filters |
@@ -1378,6 +1426,7 @@ AOT-compatible, no reflection. All are sealed classes with implicit conversions.
| Null | NullValue | Null sentinel |
| Array | ArrayValue | Implements `IReadOnlyList` |
| Object | ObjectValue | Dictionary-like with indexer, `StringComparer.OrdinalIgnoreCase` |
+| Bytes | BytesValue | Binary data with optional MIME type |
### Creating Data
diff --git a/llms.txt b/llms.txt
index f445665..91d2732 100644
--- a/llms.txt
+++ b/llms.txt
@@ -45,6 +45,7 @@ src/FlexRender.Svg.Render/ # SVG output renderer (-> Core)
src/FlexRender.Svg/ # SVG backend meta-package (renderer + providers)
src/FlexRender.Content.Markdown/ # Markdown content parser (-> Core + Markdig)
src/FlexRender.Content.Html/ # HTML content parser (-> Core + HtmlAgilityPack)
+src/FlexRender.Content.Ndc/ # NDC (ATM receipt) content parser (-> Core)
src/FlexRender.DependencyInjection/ # Microsoft.Extensions.DI integration
src/FlexRender.MetaPackage/ # Meta-package (core + all backends + DI)
@@ -84,7 +85,7 @@ Ten element types, each a sealed class extending `TemplateElement`:
| **table** | array, as, columns (key/label/width/grow/align/format), rows, headerFont/headerFontWeight/headerFontStyle/headerFontFamily/headerColor/headerSize/headerBackground -- expands to flex tree |
| **each** | array, as, children -- iteration over data arrays |
| **if** | condition, equals/notEquals, then, elseIf, else -- conditional rendering |
-| **content** | source, format -- embeds formatted text (Markdown/HTML) parsed into AST subtree at render time. Requires content parser: `.WithMarkdown()` or `.WithHtml()` |
+| **content** | source, format, options -- embeds formatted text (Markdown/HTML/NDC) parsed into AST subtree at render time. Requires content parser: `.WithMarkdown()` or `.WithHtml()` or `.WithNdc()` |
All elements share common properties from `TemplateElement`: padding, margin, background, opacity, box-shadow, rotate, display (flex/none), position, top, right, bottom, left, aspectRatio, minWidth, maxWidth, minHeight, maxHeight, text-direction, border, border-top, border-right, border-bottom, border-left, border-width, border-color, border-style, border-radius.
@@ -273,6 +274,21 @@ 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`).
+Binary sources pass `ReadOnlyMemory` directly to `IBinaryContentParser` implementations (e.g., NDC parser).
+
+#### NDC Content
+```yaml
+- type: content
+ source: "{{receiptData}}"
+ format: ndc
+ options:
+ columns: 40
+ font_family: "JetBrains Mono"
+```
+
+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.
+
## Supported Units
- `px` -- pixels (default when no unit specified)
@@ -353,6 +369,7 @@ var render = new FlexRenderBuilder()
.WithLimits(limits => limits.MaxRenderDepth = 200)
.WithMarkdown() // optional: Markdown content parsing
.WithHtml() // optional: HTML content parsing
+ .WithNdc() // optional: NDC ATM receipt parsing
.WithSkia(skia => skia
.WithQr()
.WithBarcode()
@@ -559,6 +576,7 @@ Minimal, focused templates -- one feature per file:
| FlexRender.MetaPackage | All | -- |
| FlexRender.Content.Markdown | Core | Markdig 1.1.1 |
| FlexRender.Content.Html | Core | HtmlAgilityPack 1.12.4 |
+| FlexRender.Content.Ndc | Core | (none) |
| flexrender-cli | All | System.CommandLine |
**Linux/Docker:** SkiaSharp requires native libraries. Add `SkiaSharp.NativeAssets.Linux` (or `.NoDependencies` for minimal containers) to your executable project to avoid `DllNotFoundException: libSkiaSharp`. When using `FlexRender.HarfBuzz`, also add `HarfBuzzSharp.NativeAssets.Linux` to avoid `DllNotFoundException: libHarfBuzzSharp`.
diff --git a/src/FlexRender.Cli/Commands/DebugLayoutCommand.cs b/src/FlexRender.Cli/Commands/DebugLayoutCommand.cs
index bd72ae4..720f9ce 100644
--- a/src/FlexRender.Cli/Commands/DebugLayoutCommand.cs
+++ b/src/FlexRender.Cli/Commands/DebugLayoutCommand.cs
@@ -117,8 +117,10 @@ private static Task Execute(
var templateData = data ?? new ObjectValue();
- // Create renderer (has TextMeasurer configured)
- using var renderer = Program.CreateSkiaRenderer();
+ // Use the same builder as the render command for consistent configuration
+ using var flexRender = Program.CreateRenderBuilder(templateFile.DirectoryName).Build();
+ var skiaRender = flexRender as FlexRender.Skia.SkiaRender
+ ?? throw new InvalidOperationException("Debug layout requires Skia backend");
// Register extra fonts from --fonts dir
if (fontsDir is not null)
@@ -130,12 +132,24 @@ private static Task Execute(
foreach (var fontPath in fontFiles)
{
var fontName = Path.GetFileNameWithoutExtension(fontPath);
- renderer.FontManager.RegisterFont(fontName, fontPath);
+ skiaRender.FontManager.RegisterFont(fontName, fontPath);
}
}
- // Compute layout using the renderer (same as actual rendering)
- var root = renderer.ComputeLayout(template, templateData);
+ // Compute layout using the same renderer as actual rendering
+ var root = skiaRender.ComputeLayout(template, templateData);
+
+ // Print registered fonts
+ Console.WriteLine("Fonts:");
+ foreach (var (fontName, fontPath) in skiaRender.FontManager.RegisteredFontPaths)
+ {
+ var info = skiaRender.FontManager.GetTypefaceInfo(fontName);
+ var typefaceDesc = info.HasValue
+ ? $"{info.Value.FamilyName}, fixedPitch={info.Value.IsFixedPitch}"
+ : "not loaded";
+ Console.WriteLine($" {fontName}: {fontPath} -> {typefaceDesc}");
+ }
+ Console.WriteLine();
// Print layout tree
Console.WriteLine("Layout Tree:");
@@ -144,7 +158,7 @@ private static Task Execute(
// Optionally render debug image
if (outputFile is not null)
{
- RenderDebugImage(template, root, templateData, outputFile.FullName, renderer);
+ RenderDebugImage(template, root, templateData, outputFile.FullName, skiaRender);
Console.WriteLine();
Console.WriteLine($"Debug image: {outputFile.FullName}");
}
@@ -178,8 +192,9 @@ private static void PrintLayoutNode(LayoutNode node, int indent, bool verbose)
var prefix = new string(' ', indent * 2);
var elementType = node.Element.GetType().Name;
var extra = GetElementExtra(node.Element);
+ var computed = GetComputedExtra(node);
- Console.WriteLine($"{prefix}{elementType}: X={node.X:F1}, Y={node.Y:F1}, W={node.Width:F1}, H={node.Height:F1}{extra}");
+ Console.WriteLine($"{prefix}{elementType}: X={node.X:F1}, Y={node.Y:F1}, W={node.Width:F1}, H={node.Height:F1}{extra}{computed}");
foreach (var child in node.Children)
{
@@ -194,15 +209,68 @@ private static void PrintLayoutNode(LayoutNode node, int indent, bool verbose)
/// A string with element-specific information.
private static string GetElementExtra(TemplateElement element)
{
- return element switch
+ var parts = new List();
+
+ switch (element)
{
- FlexElement f => $" [{f.Direction.ToString().ToLowerInvariant()}]",
- TextElement t => $" \"{Truncate(t.Content.Value, 30)}\"",
- QrElement => " [qr]",
- BarcodeElement => " [barcode]",
- ImageElement => " [image]",
- _ => ""
- };
+ case FlexElement f:
+ parts.Add(f.Direction.ToString().ToLowerInvariant());
+ if (!string.IsNullOrEmpty(f.FontSize.Value))
+ parts.Add($"font-size={f.FontSize.Value}");
+ if (f.Align.Value != FlexRender.Layout.AlignItems.Stretch)
+ parts.Add($"align={f.Align.Value}");
+ if (f.Justify.Value != FlexRender.Layout.JustifyContent.Start)
+ parts.Add($"justify={f.Justify.Value}");
+ break;
+ case TextElement t:
+ parts.Add($"\"{Truncate(t.Content.Value, 30)}\"");
+ if (!string.IsNullOrEmpty(t.Size.Value))
+ parts.Add($"size={t.Size.Value}");
+ if (!string.IsNullOrEmpty(t.Font.Value))
+ parts.Add($"font={t.Font.Value}");
+ if (!string.IsNullOrEmpty(t.FontFamily.Value))
+ parts.Add($"family={t.FontFamily.Value}");
+ if (t.FontWeight.Value != FlexRender.Parsing.Ast.FontWeight.Normal)
+ parts.Add($"weight={t.FontWeight.Value}");
+ if (t.FontStyle.Value != FlexRender.Parsing.Ast.FontStyle.Normal)
+ parts.Add($"style={t.FontStyle.Value}");
+ if (!string.IsNullOrEmpty(t.Color.Value))
+ parts.Add($"color={t.Color.Value}");
+ break;
+ case QrElement:
+ parts.Add("qr");
+ break;
+ case BarcodeElement:
+ parts.Add("barcode");
+ break;
+ case ImageElement:
+ parts.Add("image");
+ break;
+ }
+
+ return parts.Count > 0 ? $" [{string.Join(", ", parts)}]" : "";
+ }
+
+ private static string GetComputedExtra(LayoutNode node)
+ {
+ var parts = new List();
+
+ if (node.ComputedFontSize > 0)
+ parts.Add($"fontSize={node.ComputedFontSize:F1}px");
+
+ if (node.Baseline > 0)
+ parts.Add($"baseline={node.Baseline:F1}");
+
+ if (node.ComputedLineHeight > 0)
+ parts.Add($"lineH={node.ComputedLineHeight:F1}");
+
+ if (node.TextLines != null)
+ parts.Add($"lines={node.TextLines.Count}");
+
+ if (node.Direction != TextDirection.Ltr)
+ parts.Add($"dir={node.Direction}");
+
+ return parts.Count > 0 ? $" {{{string.Join(", ", parts)}}}" : "";
}
///
@@ -212,24 +280,24 @@ private static string GetElementExtra(TemplateElement element)
/// The root layout node.
/// The template data.
/// The output file path.
- /// The renderer with fonts already registered.
+ /// The SkiaRender instance with fonts already registered.
private static void RenderDebugImage(
Template template,
LayoutNode root,
ObjectValue data,
string outputPath,
- SkiaRenderer renderer)
+ FlexRender.Skia.SkiaRender skiaRender)
{
- var size = renderer.Measure(template, data);
+ var size = skiaRender.Measure(template, data);
using var bitmap = new SKBitmap((int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height));
using var canvas = new SKCanvas(bitmap);
// Render template normally
- renderer.Render(canvas, template, data);
+ skiaRender.Render(canvas, template, data);
// Draw debug overlay
- DrawDebugOverlay(canvas, root, 0, 0);
+ DrawDebugOverlay(canvas, root, 0, 0, skiaRender.FontManager);
// Ensure output directory exists
var directory = Path.GetDirectoryName(outputPath);
@@ -257,7 +325,8 @@ private static void RenderDebugImage(
/// The current layout node.
/// The X offset from parent.
/// The Y offset from parent.
- private static void DrawDebugOverlay(SKCanvas canvas, LayoutNode node, float offsetX, float offsetY)
+ /// The font manager for creating fonts to measure glyph widths.
+ private static void DrawDebugOverlay(SKCanvas canvas, LayoutNode node, float offsetX, float offsetY, FontManager fontManager)
{
var x = node.X + offsetX;
var y = node.Y + offsetY;
@@ -294,10 +363,74 @@ private static void DrawDebugOverlay(SKCanvas canvas, LayoutNode node, float off
canvas.DrawRect(x, y, node.Width, node.Height, strokePaint);
+ // Draw per-glyph boundaries for text elements
+ if (node.Element is TextElement text && !string.IsNullOrEmpty(text.Content.Value))
+ {
+ DrawGlyphBoundaries(canvas, text, x, y, node, fontManager);
+ }
+
// Recursively draw children
foreach (var child in node.Children)
{
- DrawDebugOverlay(canvas, child, x, y);
+ DrawDebugOverlay(canvas, child, x, y, fontManager);
+ }
+ }
+
+ ///
+ /// Draws per-glyph boundary rectangles for a text element.
+ /// Spaces are highlighted with a distinct color to make whitespace visible.
+ ///
+ private static void DrawGlyphBoundaries(
+ SKCanvas canvas,
+ TextElement text,
+ float x,
+ float y,
+ LayoutNode node,
+ FontManager fontManager)
+ {
+ var fontSize = node.ComputedFontSize > 0 ? node.ComputedFontSize : 16f;
+ var typeface = fontManager.GetTypeface(text.Font.Value, text.FontFamily.Value, text.FontWeight.Value, text.FontStyle.Value);
+ using var font = new SKFont(typeface, FontSizeResolver.Resolve(text.Size.Value, fontSize));
+
+ var content = text.Content.Value;
+
+ // Compute per-character advance widths using cumulative measurement.
+ // GetGlyphWidths returns ink bounds (visual shape width), not advance width,
+ // so spaces would appear zero-width. Cumulative MeasureText gives correct advances.
+ var advances = new float[content.Length];
+ float prevWidth = 0;
+ for (var i = 0; i < content.Length; i++)
+ {
+ var cumWidth = font.MeasureText(content.AsSpan(0, i + 1));
+ advances[i] = cumWidth - prevWidth;
+ prevWidth = cumWidth;
+ }
+
+ using var glyphStroke = new SKPaint
+ {
+ Color = SKColors.Red.WithAlpha(100),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 0.5f
+ };
+ using var spaceFill = new SKPaint
+ {
+ Color = SKColors.Yellow.WithAlpha(60),
+ Style = SKPaintStyle.Fill
+ };
+
+ var glyphX = x;
+ for (var i = 0; i < advances.Length; i++)
+ {
+ var w = advances[i];
+
+ // Highlight spaces with yellow fill
+ if (content[i] == ' ')
+ {
+ canvas.DrawRect(glyphX, y, w, node.Height, spaceFill);
+ }
+
+ canvas.DrawRect(glyphX, y, w, node.Height, glyphStroke);
+ glyphX += w;
}
}
diff --git a/src/FlexRender.Cli/FlexRender.Cli.csproj b/src/FlexRender.Cli/FlexRender.Cli.csproj
index 42eafb2..ef77103 100644
--- a/src/FlexRender.Cli/FlexRender.Cli.csproj
+++ b/src/FlexRender.Cli/FlexRender.Cli.csproj
@@ -27,6 +27,7 @@
+
diff --git a/src/FlexRender.Cli/Program.cs b/src/FlexRender.Cli/Program.cs
index c4edfc7..99cd4ed 100644
--- a/src/FlexRender.Cli/Program.cs
+++ b/src/FlexRender.Cli/Program.cs
@@ -8,6 +8,7 @@
using FlexRender.QrCode.ImageSharp;
using FlexRender.Content.Html;
using FlexRender.Content.Markdown;
+using FlexRender.Content.Ndc;
using FlexRender.Rendering;
using FlexRender.SvgElement;
using FlexRender.TemplateEngine;
@@ -77,7 +78,8 @@ public static FlexRenderBuilder CreateRenderBuilder(
var builder = new FlexRenderBuilder()
.WithHttpLoader()
.WithMarkdown()
- .WithHtml();
+ .WithHtml()
+ .WithNdc();
switch (backend)
{
@@ -120,21 +122,31 @@ public static FlexRenderBuilder CreateRenderBuilder(
}
///
- /// Creates a configured with content parsers registered.
+ /// Creates a configured with content parsers and resource loaders registered.
/// Used by commands that need direct Skia API access (e.g., debug-layout).
///
+ /// Optional base path for resolving relative file paths.
/// A configured instance. The caller is responsible for disposing it.
- internal static SkiaRenderer CreateSkiaRenderer()
+ internal static SkiaRenderer CreateSkiaRenderer(string? basePath = null)
{
var registry = new ContentParserRegistry();
registry.Register(new MarkdownContentParser());
registry.Register(new HtmlContentParser());
+ var ndcParser = new NdcContentParser();
+ registry.Register(ndcParser);
+ registry.RegisterBinary(ndcParser);
+ var options = basePath is not null
+ ? new FlexRenderOptions { BasePath = basePath }
+ : new FlexRenderOptions();
+ var loaders = new Abstractions.IResourceLoader[] { new Loaders.FileResourceLoader(options) };
return new SkiaRenderer(
new ResourceLimits(),
qrProvider: null,
barcodeProvider: null,
imageLoader: null,
- contentParserRegistry: registry);
+ options: options,
+ contentParserRegistry: registry,
+ resourceLoaders: loaders);
}
///
diff --git a/src/FlexRender.Content.Html/HtmlContentParser.cs b/src/FlexRender.Content.Html/HtmlContentParser.cs
index 77803d8..d608a29 100644
--- a/src/FlexRender.Content.Html/HtmlContentParser.cs
+++ b/src/FlexRender.Content.Html/HtmlContentParser.cs
@@ -41,7 +41,7 @@ public sealed class HtmlContentParser : IContentParser
public string FormatName => "html";
///
- public IReadOnlyList Parse(string text)
+ public IReadOnlyList Parse(string text, ContentParserContext context, IReadOnlyDictionary? options = null)
{
ArgumentNullException.ThrowIfNull(text);
if (string.IsNullOrWhiteSpace(text)) return [];
@@ -49,9 +49,9 @@ public IReadOnlyList Parse(string text)
var doc = new HtmlDocument();
doc.LoadHtml(text);
- var context = new InlineContext();
+ var inlineContext = new InlineContext();
var elements = new List();
- ProcessNodes(doc.DocumentNode.ChildNodes, elements, context, depth: 0);
+ ProcessNodes(doc.DocumentNode.ChildNodes, elements, inlineContext, depth: 0);
return elements;
}
diff --git a/src/FlexRender.Content.Markdown/MarkdownContentParser.cs b/src/FlexRender.Content.Markdown/MarkdownContentParser.cs
index 41d00bf..14d5143 100644
--- a/src/FlexRender.Content.Markdown/MarkdownContentParser.cs
+++ b/src/FlexRender.Content.Markdown/MarkdownContentParser.cs
@@ -26,7 +26,7 @@ public sealed class MarkdownContentParser : IContentParser
public string FormatName => "markdown";
///
- public IReadOnlyList Parse(string text)
+ public IReadOnlyList Parse(string text, ContentParserContext context, IReadOnlyDictionary? options = null)
{
ArgumentNullException.ThrowIfNull(text);
if (string.IsNullOrWhiteSpace(text)) return [];
diff --git a/src/FlexRender.Content.Ndc/CharsetStyle.cs b/src/FlexRender.Content.Ndc/CharsetStyle.cs
new file mode 100644
index 0000000..3e3ed30
--- /dev/null
+++ b/src/FlexRender.Content.Ndc/CharsetStyle.cs
@@ -0,0 +1,20 @@
+namespace FlexRender.Content.Ndc;
+
+///
+/// Style properties for an NDC character set designator.
+///
+/// Font registration name (e.g., "bold", "default"). When set, used as the Font property on the text element.
+/// Explicit font family name (e.g., "JetBrains Mono"). Overrides the global font family for this charset.
+/// Font style string (e.g., "bold", "italic", "regular"). Maps to font weight and style on the text element.
+/// Explicit font size in pixels. When set, overrides auto-calculated font size.
+/// Text color in hex format (e.g., "#333").
+/// Character encoding for this charset (e.g., "qwerty-jcuken", "none").
+/// Whether to convert text to uppercase.
+internal sealed record CharsetStyle(
+ string? Font = null,
+ string? FontFamily = null,
+ string? FontStyle = null,
+ int? FontSize = null,
+ string? Color = null,
+ string Encoding = "none",
+ bool Uppercase = false);
diff --git a/src/FlexRender.Content.Ndc/FlexRender.Content.Ndc.csproj b/src/FlexRender.Content.Ndc/FlexRender.Content.Ndc.csproj
new file mode 100644
index 0000000..507cd0b
--- /dev/null
+++ b/src/FlexRender.Content.Ndc/FlexRender.Content.Ndc.csproj
@@ -0,0 +1,17 @@
+
+
+
+ FlexRender.Content.Ndc
+ NDC (NCR ATM protocol) content parser for FlexRender. Converts NDC printer data streams into template elements.
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FlexRender.Content.Ndc/FlexRenderBuilderExtensions.cs b/src/FlexRender.Content.Ndc/FlexRenderBuilderExtensions.cs
new file mode 100644
index 0000000..1856c36
--- /dev/null
+++ b/src/FlexRender.Content.Ndc/FlexRenderBuilderExtensions.cs
@@ -0,0 +1,23 @@
+using FlexRender.Configuration;
+
+namespace FlexRender.Content.Ndc;
+
+///
+/// Extension methods for configuring NDC content parsing in FlexRender.
+///
+public static class FlexRenderBuilderExtensions
+{
+ ///
+ /// Adds NDC content parsing support. Enables type: content elements with format: ndc.
+ ///
+ /// The builder to configure.
+ /// The same builder instance for method chaining.
+ /// Thrown when is null.
+ public static FlexRenderBuilder WithNdc(this FlexRenderBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var parser = new NdcContentParser();
+ builder.WithContentParser(parser);
+ return builder.WithBinaryContentParser(parser);
+ }
+}
diff --git a/src/FlexRender.Content.Ndc/NdcContentParser.cs b/src/FlexRender.Content.Ndc/NdcContentParser.cs
new file mode 100644
index 0000000..8c8cf54
--- /dev/null
+++ b/src/FlexRender.Content.Ndc/NdcContentParser.cs
@@ -0,0 +1,338 @@
+using System.Text;
+using FlexRender.Abstractions;
+using FlexRender.Layout;
+using FlexRender.Parsing.Ast;
+
+namespace FlexRender.Content.Ndc;
+
+///
+/// Parses NDC (NCR ATM protocol) printer data streams into FlexRender template elements.
+/// Supports character set switching, spacing controls, form feeds, and barcodes.
+/// Implements both string and binary parsing; binary data is decoded using a configurable
+/// input encoding (default: Latin-1) before being handed to the string parser.
+///
+public sealed class NdcContentParser : IContentParser, IBinaryContentParser
+{
+ ///
+ public string FormatName => "ndc";
+
+ ///
+ public IReadOnlyList Parse(string text, ContentParserContext context, IReadOnlyDictionary? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ ArgumentNullException.ThrowIfNull(context);
+ if (string.IsNullOrWhiteSpace(text))
+ return [];
+
+ var effectiveWidth = context.ParentWidth ?? context.Canvas?.Width;
+ var ndcOptions = NdcOptions.FromDictionary(options, effectiveWidth);
+ var tokens = NdcTokenizer.Tokenize(text);
+
+ // Measure actual max line width and use it for auto font size
+ var measuredColumns = CalculateMaxLineWidth(tokens);
+ ndcOptions = ndcOptions.WithMeasuredColumns(measuredColumns);
+
+ return ConvertTokensToAst(tokens, ndcOptions);
+ }
+
+ ///
+ public IReadOnlyList Parse(ReadOnlyMemory data, ContentParserContext context, IReadOnlyDictionary? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ if (data.IsEmpty)
+ return [];
+
+ var encodingName = "latin1";
+ if (options is not null && options.TryGetValue("input_encoding", out var enc) && enc is string encStr)
+ encodingName = encStr;
+
+ var encoding = ResolveEncoding(encodingName);
+ var textContent = encoding.GetString(data.Span);
+ return Parse(textContent, context, options);
+ }
+
+ ///
+ /// Resolves a human-friendly encoding name to a instance.
+ ///
+ ///
+ /// The encoding name. Supported values: latin1, iso-8859-1, utf-8,
+ /// utf8, ascii. Unrecognized values default to Latin-1.
+ ///
+ /// The resolved .
+ internal static Encoding ResolveEncoding(string name) =>
+ name.ToLowerInvariant() switch
+ {
+ "latin1" or "iso-8859-1" => Encoding.Latin1,
+ "utf-8" or "utf8" => Encoding.UTF8,
+ "ascii" => Encoding.ASCII,
+ _ => Encoding.Latin1
+ };
+
+ private static int CalculateMaxLineWidth(List tokens, int tabWidth = 8)
+ {
+ var maxWidth = 0;
+ var currentWidth = 0;
+
+ foreach (var token in tokens)
+ {
+ switch (token.Type)
+ {
+ case NdcTokenType.Text:
+ currentWidth += token.Value.Length;
+ break;
+
+ case NdcTokenType.Spaces:
+ if (int.TryParse(token.Value, out var count))
+ currentWidth += count;
+ break;
+
+ case NdcTokenType.HorizontalTab:
+ {
+ var spacesNeeded = tabWidth - (currentWidth % tabWidth);
+ if (spacesNeeded == 0) spacesNeeded = tabWidth;
+ currentWidth += spacesNeeded;
+ break;
+ }
+
+ case NdcTokenType.LineFeed:
+ case NdcTokenType.FormFeed:
+ if (currentWidth > maxWidth)
+ maxWidth = currentWidth;
+ currentWidth = 0;
+ break;
+
+ // Other token types don't affect line width
+ }
+ }
+
+ // Don't forget the last line (no trailing LF)
+ if (currentWidth > maxWidth)
+ maxWidth = currentWidth;
+
+ return maxWidth > 0 ? maxWidth : 1; // Avoid division by zero
+ }
+
+ private static IReadOnlyList ConvertTokensToAst(
+ IEnumerable tokens,
+ NdcOptions options)
+ {
+ var root = new FlexElement { Direction = FlexDirection.Column };
+ if (options.CanvasWidth.HasValue)
+ root.FontSize = "fit-content";
+ var currentLine = new List();
+ var currentCharset = "1"; // Default charset per NDC spec
+ var currentColumn = 0;
+ var columns = options.Columns;
+ var tabWidth = 8;
+
+ foreach (var token in tokens)
+ {
+ switch (token.Type)
+ {
+ case NdcTokenType.Text:
+ var style = options.GetStyleForCharset(currentCharset);
+ var decoded = NdcEncodings.Decode(token.Value, style.Encoding, style.Uppercase);
+ AddTextWithWrapping(root, currentLine, decoded, style, ref currentColumn, columns, options);
+ break;
+
+ case NdcTokenType.CharsetSwitch:
+ currentCharset = token.Value;
+ break;
+
+ case NdcTokenType.Spaces:
+ if (int.TryParse(token.Value, out var count))
+ {
+ var spaceStyle = options.GetStyleForCharset(currentCharset);
+ var spaceText = new string(' ', count);
+ AddTextWithWrapping(root, currentLine, spaceText, spaceStyle, ref currentColumn, columns, options);
+ }
+ break;
+
+ case NdcTokenType.LineFeed:
+ FlushLine(root, currentLine, options.GetStyleForCharset(currentCharset), options);
+ currentLine = [];
+ currentColumn = 0;
+ break;
+
+ case NdcTokenType.FormFeed:
+ FlushLine(root, currentLine, options.GetStyleForCharset(currentCharset), options);
+ currentLine = [];
+ currentColumn = 0;
+ root.AddChild(new SeparatorElement());
+ break;
+
+ case NdcTokenType.FieldSeparator:
+ // GS + digit -- printer field separator, no visual output
+ break;
+
+ case NdcTokenType.Barcode:
+ var barcode = ParseBarcodeToken(token.Value);
+ if (barcode is not null)
+ currentLine.Add(barcode);
+ break;
+
+ case NdcTokenType.HorizontalTab:
+ {
+ var spacesNeeded = tabWidth - (currentColumn % tabWidth);
+ if (spacesNeeded == 0) spacesNeeded = tabWidth;
+ var tabStyle = options.GetStyleForCharset(currentCharset);
+ var tabText = new string(' ', spacesNeeded);
+ AddTextWithWrapping(root, currentLine, tabText, tabStyle, ref currentColumn, columns, options);
+ break;
+ }
+
+ case NdcTokenType.SetLeftMargin:
+ // Left margin positioning not yet implemented
+ break;
+
+ case NdcTokenType.SetRightMargin:
+ if (int.TryParse(token.Value, out var rm) && rm > 0)
+ columns = Math.Clamp(rm, 1, 132);
+ break;
+
+ case NdcTokenType.SetLinesPerInch:
+ case NdcTokenType.SelectCodePage:
+ case NdcTokenType.SelectInternationalCharset:
+ case NdcTokenType.SelectArabicCharset:
+ case NdcTokenType.BarcodeHriPosition:
+ case NdcTokenType.BarcodeWidth:
+ case NdcTokenType.BarcodeHeight:
+ case NdcTokenType.PrintGraphics:
+ case NdcTokenType.PrintBitImage:
+ case NdcTokenType.PrintChequeImage:
+ case NdcTokenType.DefineCharset:
+ case NdcTokenType.DefineBitImage:
+ case NdcTokenType.DualSidedPrinting:
+ // These token types have no visual representation in the rendered output
+ break;
+ }
+ }
+
+ // Flush remaining line only if there's content
+ if (currentLine.Count > 0)
+ FlushLine(root, currentLine, options.GetStyleForCharset(currentCharset), options);
+
+ return root.Children.Count > 0 ? [root] : [];
+ }
+
+ private static void AddTextWithWrapping(
+ FlexElement root,
+ List currentLine,
+ string text,
+ CharsetStyle style,
+ ref int currentColumn,
+ int columns,
+ NdcOptions options)
+ {
+ var remaining = text.AsSpan();
+ while (remaining.Length > 0)
+ {
+ var available = columns - currentColumn;
+ if (available <= 0)
+ {
+ // Line full — wrap
+ FlushLine(root, currentLine, style, options);
+ currentLine.Clear();
+ currentColumn = 0;
+ available = columns;
+ }
+
+ if (remaining.Length <= available)
+ {
+ currentLine.Add(CreateTextElement(remaining.ToString(), style, options));
+ currentColumn += remaining.Length;
+ break;
+ }
+
+ // Split at column boundary
+ currentLine.Add(CreateTextElement(remaining[..available].ToString(), style, options));
+ remaining = remaining[available..];
+ FlushLine(root, currentLine, style, options);
+ currentLine.Clear();
+ currentColumn = 0;
+ }
+ }
+
+ private static void FlushLine(FlexElement root, List lineElements, CharsetStyle currentStyle, NdcOptions options)
+ {
+ var row = new FlexElement { Direction = FlexDirection.Row, Align = AlignItems.Baseline };
+ if (lineElements.Count == 0)
+ {
+ // Empty line — add a space to preserve line height
+ row.AddChild(CreateTextElement(" ", currentStyle, options));
+ }
+ else
+ {
+ foreach (var el in lineElements)
+ row.AddChild(el);
+ }
+ root.AddChild(row);
+ }
+
+ private static TextElement CreateTextElement(string content, CharsetStyle style, NdcOptions options)
+ {
+ var text = new TextElement { Content = content, Wrap = false };
+
+ // Font registration name (e.g., "bold", "default")
+ if (style.Font is not null)
+ text.Font = style.Font;
+
+ // Font family: charset-specific overrides global
+ if (style.FontFamily is not null)
+ text.FontFamily = style.FontFamily;
+ else if (options.FontFamily is not null)
+ text.FontFamily = options.FontFamily;
+
+ // Font style string maps to FontWeight and FontStyle on the text element
+ if (style.FontStyle is not null)
+ {
+ switch (style.FontStyle.ToLowerInvariant())
+ {
+ case "bold":
+ text.FontWeight = FontWeight.Bold;
+ break;
+ case "italic":
+ text.FontStyle = Parsing.Ast.FontStyle.Italic;
+ break;
+ case "bold-italic" or "bolditalic":
+ text.FontWeight = FontWeight.Bold;
+ text.FontStyle = Parsing.Ast.FontStyle.Italic;
+ break;
+ // "regular" / "normal" / unknown -> keep defaults
+ }
+ }
+
+ if (style.FontSize.HasValue)
+ text.Size = style.FontSize.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ if (style.Color is not null)
+ text.Color = style.Color;
+
+ return text;
+ }
+
+ private static BarcodeElement? ParseBarcodeToken(string value)
+ {
+ // Format: "type:data"
+ var colonIndex = value.IndexOf(':');
+ if (colonIndex < 0 || colonIndex >= value.Length - 1)
+ return null;
+
+ var typeChar = value[0];
+ var data = value[(colonIndex + 1)..];
+
+ var format = typeChar switch
+ {
+ '0' => BarcodeFormat.Upc, // UPC-A
+ '1' => BarcodeFormat.Upc, // UPC-E -> mapped to UPC
+ '2' => BarcodeFormat.Ean13, // JAN13/EAN-13
+ '3' => BarcodeFormat.Ean8, // JAN8/EAN-8
+ '4' => BarcodeFormat.Code39, // Code 39
+ '5' => BarcodeFormat.Code128, // Interleaved 2 of 5 -> closest: Code128
+ '6' => BarcodeFormat.Code128, // Codabar -> closest: Code128
+ _ => BarcodeFormat.Code128 // Default fallback
+ };
+
+ return new BarcodeElement { Data = data, Format = format };
+ }
+}
diff --git a/src/FlexRender.Content.Ndc/NdcEncodings.cs b/src/FlexRender.Content.Ndc/NdcEncodings.cs
new file mode 100644
index 0000000..fb2f988
--- /dev/null
+++ b/src/FlexRender.Content.Ndc/NdcEncodings.cs
@@ -0,0 +1,90 @@
+using System.Globalization;
+using System.Text;
+
+namespace FlexRender.Content.Ndc;
+
+///
+/// Character encoding/decoding for NDC printer data character sets.
+///
+internal static class NdcEncodings
+{
+ // QWERTY->JCUKEN mapping table (lowercase ASCII -> lowercase Cyrillic).
+ // Source: Bfs.Integration.Ndc/Utils/NdcDisplayControls.RussianUppercaseLettersDict
+ // Verified in production for years across multiple banks.
+ // Uppercase is derived via char.ToUpper() on the mapped result.
+ private static readonly Dictionary QwertyToJcukenMap = new()
+ {
+ ['q'] = 'й', ['w'] = 'ц', ['e'] = 'у', ['r'] = 'к', ['t'] = 'е',
+ ['y'] = 'н', ['u'] = 'г', ['i'] = 'ш', ['o'] = 'щ', ['p'] = 'з',
+ ['{'] = 'х', ['}'] = 'ъ',
+ ['a'] = 'ф', ['s'] = 'ы', ['d'] = 'в', ['f'] = 'а', ['g'] = 'п',
+ ['h'] = 'р', ['j'] = 'о', ['k'] = 'л', ['l'] = 'д',
+ ['|'] = 'ж', ['`'] = 'э',
+ ['z'] = 'я', ['x'] = 'ч', ['c'] = 'с', ['v'] = 'м', ['b'] = 'и',
+ ['n'] = 'т', ['m'] = 'ь',
+ ['~'] = 'б', ['\x7f'] = 'ю',
+ };
+
+ ///
+ /// Decodes text using the specified encoding name.
+ ///
+ /// The text to decode.
+ ///
+ /// Encoding name: "qwerty-jcuken", "none", "ascii".
+ /// Unknown encodings return input unchanged.
+ ///
+ /// When true, converts mapped Cyrillic characters to uppercase.
+ /// The decoded text.
+ internal static string Decode(string text, string encodingName, bool uppercase = false)
+ {
+ if (string.Equals(encodingName, "qwerty-jcuken", StringComparison.OrdinalIgnoreCase))
+ return DecodeQwertyJcuken(text, uppercase);
+
+ // "none", "ascii", or unknown -> passthrough
+ return text;
+ }
+
+ private static string DecodeQwertyJcuken(string text, bool uppercase)
+ {
+ // Fast path: check if any character needs mapping
+ var needsMapping = false;
+ foreach (var ch in text)
+ {
+ if (QwertyToJcukenMap.ContainsKey(ch) ||
+ (char.IsAsciiLetterUpper(ch) && QwertyToJcukenMap.ContainsKey(char.ToLower(ch, CultureInfo.InvariantCulture))))
+ {
+ needsMapping = true;
+ break;
+ }
+
+ if (uppercase && !char.IsUpper(ch))
+ {
+ needsMapping = true;
+ break;
+ }
+ }
+
+ if (!needsMapping)
+ return text;
+
+ var sb = new StringBuilder(text.Length);
+ foreach (var ch in text)
+ {
+ // Try lowercase key first
+ if (QwertyToJcukenMap.TryGetValue(ch, out var mapped))
+ {
+ sb.Append(uppercase ? char.ToUpper(mapped, CultureInfo.InvariantCulture) : mapped);
+ }
+ // Try converting uppercase ASCII to lowercase for lookup
+ else if (char.IsAsciiLetterUpper(ch) && QwertyToJcukenMap.TryGetValue(char.ToLower(ch, CultureInfo.InvariantCulture), out var mappedFromUpper))
+ {
+ sb.Append(uppercase ? char.ToUpper(mappedFromUpper, CultureInfo.InvariantCulture) : mappedFromUpper);
+ }
+ else
+ {
+ sb.Append(uppercase ? char.ToUpper(ch, CultureInfo.InvariantCulture) : ch);
+ }
+ }
+ return sb.ToString();
+ }
+}
diff --git a/src/FlexRender.Content.Ndc/NdcOptions.cs b/src/FlexRender.Content.Ndc/NdcOptions.cs
new file mode 100644
index 0000000..31a12fc
--- /dev/null
+++ b/src/FlexRender.Content.Ndc/NdcOptions.cs
@@ -0,0 +1,161 @@
+namespace FlexRender.Content.Ndc;
+
+///
+/// Parsed options for the NDC content parser, extracted from the YAML options block.
+///
+internal sealed class NdcOptions
+{
+ private static readonly CharsetStyle DefaultStyle = new();
+
+ ///
+ /// Input byte encoding (e.g., "latin1", "cp866", "utf-8"). Default: "latin1".
+ ///
+ public string InputEncoding { get; private init; } = "latin1";
+
+ ///
+ /// Maximum number of characters per line (typical receipt width: 40-44). Default: 40.
+ /// Used for auto-wrapping lines that exceed this width.
+ ///
+ public int Columns { get; private init; } = 40;
+
+ ///
+ /// Font family for all text in the receipt (e.g., "JetBrains Mono", "Courier New").
+ /// When set, all generated text elements will use this font family.
+ ///
+ public string? FontFamily { get; private init; }
+
+ ///
+ /// Character width as a fraction of font size for monospace fonts. Default: 0.6.
+ ///
+ public double CharWidthRatio { get; private init; } = 0.6;
+
+ ///
+ /// Canvas width in pixels, sourced from .
+ /// Used for auto font size calculation.
+ ///
+ public int? CanvasWidth { get; private init; }
+
+ ///
+ /// Measured maximum line width from actual data. Set by the parser after scanning tokens.
+ /// When set, used instead of for calculation.
+ ///
+ internal int? MeasuredColumns { get; private init; }
+
+ ///
+ /// Auto-calculated font size based on canvas width, columns, and char width ratio.
+ /// Uses when available, otherwise falls back to .
+ /// Returns null when canvas width is not available.
+ ///
+ internal int? AutoFontSize => CanvasWidth.HasValue
+ ? (int)(CanvasWidth.Value / ((MeasuredColumns ?? Columns) * CharWidthRatio))
+ : null;
+
+ ///
+ /// Per-charset style mappings keyed by designator character (e.g., "I", "1", ">").
+ ///
+ public IReadOnlyDictionary Charsets { get; private init; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ ///
+ /// Creates a copy of these options with the specified measured columns value.
+ ///
+ internal NdcOptions WithMeasuredColumns(int measuredColumns) => new()
+ {
+ InputEncoding = InputEncoding,
+ Columns = Columns,
+ FontFamily = FontFamily,
+ CharWidthRatio = CharWidthRatio,
+ CanvasWidth = CanvasWidth,
+ Charsets = Charsets,
+ MeasuredColumns = measuredColumns
+ };
+
+ ///
+ /// Gets the style for a charset designator, or the default style if not configured.
+ ///
+ internal CharsetStyle GetStyleForCharset(string designator) =>
+ Charsets.TryGetValue(designator, out var style) ? style : DefaultStyle;
+
+ ///
+ /// Creates from the raw YAML options dictionary and canvas width from context.
+ ///
+ /// The raw options dictionary from the YAML content element.
+ /// Canvas width in pixels from .
+ internal static NdcOptions FromDictionary(IReadOnlyDictionary? dict, int? canvasWidth = null)
+ {
+ if (dict is null)
+ return new NdcOptions { CanvasWidth = canvasWidth > 0 ? canvasWidth : null };
+
+ var inputEncoding = dict.TryGetValue("input_encoding", out var enc)
+ ? enc?.ToString() ?? "latin1"
+ : "latin1";
+
+ var columns = dict.TryGetValue("columns", out var col) && int.TryParse(col?.ToString(), out var colVal) && colVal > 0
+ ? colVal
+ : 40;
+
+ var fontFamily = dict.TryGetValue("font_family", out var ff) ? ff?.ToString() : null;
+
+ var charWidthRatio = dict.TryGetValue("char_width_ratio", out var cwr)
+ && double.TryParse(cwr?.ToString(), System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var cwrVal)
+ && cwrVal > 0
+ ? cwrVal
+ : 0.6;
+
+ var charsets = new Dictionary(StringComparer.Ordinal);
+ if (dict.TryGetValue("charsets", out var charsetsObj) && charsetsObj is IReadOnlyDictionary charsetsDict)
+ {
+ foreach (var (key, value) in charsetsDict)
+ {
+ if (value is IReadOnlyDictionary styleDict)
+ {
+ charsets[key] = ParseCharsetStyle(styleDict);
+ }
+ }
+ }
+
+ return new NdcOptions
+ {
+ InputEncoding = inputEncoding,
+ Columns = columns,
+ FontFamily = fontFamily,
+ CharWidthRatio = charWidthRatio,
+ CanvasWidth = canvasWidth > 0 ? canvasWidth : null,
+ Charsets = charsets
+ };
+ }
+
+ private static CharsetStyle ParseCharsetStyle(IReadOnlyDictionary dict)
+ {
+ var font = dict.TryGetValue("font", out var f) ? f?.ToString() : null;
+ var fontFamily = dict.TryGetValue("font_family", out var ff) ? ff?.ToString() : null;
+
+ // Support both new "font_style" and legacy "bold" for backward compatibility.
+ string? fontStyle = null;
+ if (dict.TryGetValue("font_style", out var fst))
+ {
+ fontStyle = fst?.ToString();
+ }
+ else if (dict.TryGetValue("bold", out var b) &&
+ string.Equals(b?.ToString(), "true", StringComparison.OrdinalIgnoreCase))
+ {
+ // Legacy: bold=true maps to font_style="bold" and font="bold"
+ fontStyle = "bold";
+ font ??= "bold";
+ }
+
+ int? fontSize = dict.TryGetValue("font_size", out var fs) && int.TryParse(fs?.ToString(), out var fsVal)
+ ? fsVal
+ : null;
+
+ var color = dict.TryGetValue("color", out var c) ? c?.ToString() : null;
+
+ var encoding = dict.TryGetValue("encoding", out var e) ? e?.ToString() ?? "none" : "none";
+
+ var uppercase = dict.TryGetValue("uppercase", out var u) &&
+ string.Equals(u?.ToString(), "true", StringComparison.OrdinalIgnoreCase);
+
+ return new CharsetStyle(font, fontFamily, fontStyle, fontSize, color, encoding, uppercase);
+ }
+}
diff --git a/src/FlexRender.Content.Ndc/NdcToken.cs b/src/FlexRender.Content.Ndc/NdcToken.cs
new file mode 100644
index 0000000..b079419
--- /dev/null
+++ b/src/FlexRender.Content.Ndc/NdcToken.cs
@@ -0,0 +1,38 @@
+namespace FlexRender.Content.Ndc;
+
+///
+/// Types of tokens produced by the NDC tokenizer.
+///
+internal enum NdcTokenType
+{
+ Text,
+ LineFeed,
+ FormFeed,
+ CharsetSwitch,
+ Spaces,
+ FieldSeparator,
+ Barcode,
+ HorizontalTab,
+ SetLeftMargin,
+ SetRightMargin,
+ SetLinesPerInch,
+ SelectCodePage,
+ SelectInternationalCharset,
+ SelectArabicCharset,
+ BarcodeHriPosition,
+ BarcodeWidth,
+ BarcodeHeight,
+ PrintGraphics,
+ PrintBitImage,
+ PrintChequeImage,
+ DefineCharset,
+ DefineBitImage,
+ DualSidedPrinting
+}
+
+///
+/// A single token from the NDC data stream.
+///
+/// The token type.
+/// The token value (text content, charset designator, space count, barcode type:data).
+internal readonly record struct NdcToken(NdcTokenType Type, string Value);
diff --git a/src/FlexRender.Content.Ndc/NdcTokenizer.cs b/src/FlexRender.Content.Ndc/NdcTokenizer.cs
new file mode 100644
index 0000000..7f78baf
--- /dev/null
+++ b/src/FlexRender.Content.Ndc/NdcTokenizer.cs
@@ -0,0 +1,343 @@
+using System.Globalization;
+using System.Text;
+
+namespace FlexRender.Content.Ndc;
+
+///
+/// Tokenizes NDC printer data streams into a sequence of values.
+///
+internal static class NdcTokenizer
+{
+ private const char ESC = '\x1B';
+ private const char LF = '\n';
+ private const char CR = '\r';
+ private const char FF = '\x0C';
+ private const char SO = '\x0E';
+ private const char GS = '\x1D';
+ private const char HT = '\x09';
+
+ ///
+ /// Tokenizes the input NDC data stream.
+ ///
+ internal static List Tokenize(string input)
+ {
+ var tokens = new List();
+ if (input.Length == 0) return tokens;
+
+ var textBuffer = new StringBuilder();
+ var i = 0;
+
+ while (i < input.Length)
+ {
+ var ch = input[i];
+
+ switch (ch)
+ {
+ case ESC:
+ FlushText(tokens, textBuffer);
+ i++;
+ ParseEscSequence(input, ref i, tokens);
+ break;
+
+ case LF:
+ FlushText(tokens, textBuffer);
+ tokens.Add(new NdcToken(NdcTokenType.LineFeed, ""));
+ i++;
+ break;
+
+ case CR:
+ FlushText(tokens, textBuffer);
+ i++;
+ if (i < input.Length && input[i] == LF)
+ i++; // consume LF after CR
+ tokens.Add(new NdcToken(NdcTokenType.LineFeed, ""));
+ break;
+
+ case FF:
+ FlushText(tokens, textBuffer);
+ tokens.Add(new NdcToken(NdcTokenType.FormFeed, ""));
+ i++;
+ break;
+
+ case SO:
+ FlushText(tokens, textBuffer);
+ i++;
+ if (i < input.Length)
+ {
+ var spaceCount = GetSpaceCount(input[i]);
+ tokens.Add(new NdcToken(NdcTokenType.Spaces, spaceCount.ToString(CultureInfo.InvariantCulture)));
+ i++;
+ }
+ break;
+
+ case GS:
+ FlushText(tokens, textBuffer);
+ i++;
+ var fieldId = i < input.Length ? input[i].ToString() : "";
+ if (i < input.Length) i++;
+ tokens.Add(new NdcToken(NdcTokenType.FieldSeparator, fieldId));
+ break;
+
+ case HT:
+ FlushText(tokens, textBuffer);
+ tokens.Add(new NdcToken(NdcTokenType.HorizontalTab, ""));
+ i++;
+ break;
+
+ default:
+ textBuffer.Append(ch);
+ i++;
+ break;
+ }
+ }
+
+ FlushText(tokens, textBuffer);
+ return tokens;
+ }
+
+ private static void FlushText(List tokens, StringBuilder textBuffer)
+ {
+ if (textBuffer.Length > 0)
+ {
+ tokens.Add(new NdcToken(NdcTokenType.Text, textBuffer.ToString()));
+ textBuffer.Clear();
+ }
+ }
+
+ private static void ParseEscSequence(string input, ref int i, List tokens)
+ {
+ if (i >= input.Length) return;
+
+ var seqId = input[i];
+ switch (seqId)
+ {
+ case '(': // ESC ( X -- Select Primary Print Page
+ i++;
+ if (i < input.Length)
+ {
+ tokens.Add(new NdcToken(NdcTokenType.CharsetSwitch, input[i].ToString()));
+ i++;
+ }
+ break;
+
+ case ')': // ESC ) X -- Select Secondary Print Page
+ i++;
+ if (i < input.Length)
+ {
+ tokens.Add(new NdcToken(NdcTokenType.CharsetSwitch, input[i].ToString()));
+ i++;
+ }
+ break;
+
+ case 'k': // ESC k ESC \ -- Print Barcode
+ i++;
+ if (i < input.Length)
+ {
+ var barcodeType = input[i];
+ i++;
+ var dataBuf = new StringBuilder();
+ while (i < input.Length)
+ {
+ if (input[i] == ESC && i + 1 < input.Length && input[i + 1] == '\\')
+ {
+ i += 2; // skip ESC backslash
+ break;
+ }
+ dataBuf.Append(input[i]);
+ i++;
+ }
+ tokens.Add(new NdcToken(NdcTokenType.Barcode, $"{barcodeType}:{dataBuf}"));
+ }
+ break;
+
+ case '/': // ESC / -- Print Downloadable Bit Image
+ i++;
+ var imgX = i < input.Length ? input[i].ToString() : "";
+ if (i < input.Length) i++;
+ var imgY = i < input.Length ? input[i].ToString() : "";
+ if (i < input.Length) i++;
+ tokens.Add(new NdcToken(NdcTokenType.PrintBitImage, $"{imgX}:{imgY}"));
+ break;
+
+ case 'G': // ESC G ESC \ -- Print Graphics
+ {
+ i++;
+ var filenameBuf = new StringBuilder();
+ while (i < input.Length)
+ {
+ if (input[i] == ESC && i + 1 < input.Length && input[i + 1] == '\\')
+ {
+ i += 2;
+ break;
+ }
+ filenameBuf.Append(input[i]);
+ i++;
+ }
+ tokens.Add(new NdcToken(NdcTokenType.PrintGraphics, filenameBuf.ToString()));
+ break;
+ }
+
+ case '%': // ESC % <3-digit-codepage> -- Select OS/2 Code Page
+ {
+ i++;
+ var cpBuf = new StringBuilder();
+ for (var j = 0; j < 3 && i < input.Length; j++)
+ {
+ cpBuf.Append(input[i]);
+ i++;
+ }
+ tokens.Add(new NdcToken(NdcTokenType.SelectCodePage, cpBuf.ToString()));
+ break;
+ }
+
+ case '2': // ESC 2 -- Select International Character Sets
+ i++;
+ tokens.Add(new NdcToken(NdcTokenType.SelectInternationalCharset, ""));
+ break;
+
+ case '3': // ESC 3 -- Select Arabic Character Sets
+ i++;
+ tokens.Add(new NdcToken(NdcTokenType.SelectArabicCharset, ""));
+ break;
+
+ case '[': // ESC [ p/q/r -- Statement printer controls
+ {
+ i++;
+ var valueBuf = new StringBuilder();
+ while (i < input.Length)
+ {
+ var c = input[i];
+ i++;
+ if (c == 'p')
+ {
+ tokens.Add(new NdcToken(NdcTokenType.SetLeftMargin, valueBuf.ToString()));
+ break;
+ }
+ if (c == 'q')
+ {
+ tokens.Add(new NdcToken(NdcTokenType.SetRightMargin, valueBuf.ToString()));
+ break;
+ }
+ if (c == 'r')
+ {
+ tokens.Add(new NdcToken(NdcTokenType.SetLinesPerInch, valueBuf.ToString()));
+ break;
+ }
+ valueBuf.Append(c);
+ }
+ break;
+ }
+
+ case 'p': // ESC p [] [] ESC \ -- Print Cheque Image
+ {
+ i++;
+ var chequeBuf = new StringBuilder();
+ while (i < input.Length)
+ {
+ if (input[i] == ESC && i + 1 < input.Length && input[i + 1] == '\\')
+ {
+ i += 2;
+ break;
+ }
+ chequeBuf.Append(input[i]);
+ i++;
+ }
+ tokens.Add(new NdcToken(NdcTokenType.PrintChequeImage, chequeBuf.ToString()));
+ break;
+ }
+
+ case '&': // ESC & ESC \ -- Define Downloadable Character Set
+ {
+ i++;
+ var defBuf = new StringBuilder();
+ while (i < input.Length)
+ {
+ if (input[i] == ESC && i + 1 < input.Length && input[i + 1] == '\\')
+ {
+ i += 2;
+ break;
+ }
+ defBuf.Append(input[i]);
+ i++;
+ }
+ tokens.Add(new NdcToken(NdcTokenType.DefineCharset, defBuf.ToString()));
+ break;
+ }
+
+ case '*': // ESC * <1/2> ESC \ -- Define Downloadable Bit Image
+ {
+ i++;
+ var defImgBuf = new StringBuilder();
+ while (i < input.Length)
+ {
+ if (input[i] == ESC && i + 1 < input.Length && input[i + 1] == '\\')
+ {
+ i += 2;
+ break;
+ }
+ defImgBuf.Append(input[i]);
+ i++;
+ }
+ tokens.Add(new NdcToken(NdcTokenType.DefineBitImage, defImgBuf.ToString()));
+ break;
+ }
+
+ case 'e': // ESC e -- Select HRI Character Printing Position
+ i++;
+ if (i < input.Length)
+ {
+ tokens.Add(new NdcToken(NdcTokenType.BarcodeHriPosition, input[i].ToString()));
+ i++;
+ }
+ break;
+
+ case 'w': // ESC w -- Select Width of Barcode
+ i++;
+ if (i < input.Length)
+ {
+ tokens.Add(new NdcToken(NdcTokenType.BarcodeWidth, input[i].ToString()));
+ i++;
+ }
+ break;
+
+ case 'h': // ESC h <3-digit-height> -- Select Horizontal Height of Barcode
+ {
+ i++;
+ var heightBuf = new StringBuilder();
+ for (var j = 0; j < 3 && i < input.Length; j++)
+ {
+ heightBuf.Append(input[i]);
+ i++;
+ }
+ tokens.Add(new NdcToken(NdcTokenType.BarcodeHeight, heightBuf.ToString()));
+ break;
+ }
+
+ case 'q': // ESC q <0/1> -- Select Dual-sided Printing
+ i++;
+ if (i < input.Length)
+ {
+ tokens.Add(new NdcToken(NdcTokenType.DualSidedPrinting, input[i].ToString()));
+ i++;
+ }
+ break;
+
+ default:
+ // Unknown ESC sequence -- skip the sequence ID character
+ i++;
+ break;
+ }
+ }
+
+ private static int GetSpaceCount(char ch) => ch switch
+ {
+ >= '1' and <= '9' => ch - '0',
+ ':' => 10,
+ ';' => 11,
+ '<' => 12,
+ '=' => 13,
+ '>' => 14,
+ '?' => 15,
+ _ => 1
+ };
+}
diff --git a/src/FlexRender.Core/Abstractions/ContentParserContext.cs b/src/FlexRender.Core/Abstractions/ContentParserContext.cs
new file mode 100644
index 0000000..d8d2adf
--- /dev/null
+++ b/src/FlexRender.Core/Abstractions/ContentParserContext.cs
@@ -0,0 +1,29 @@
+using FlexRender.Parsing.Ast;
+
+namespace FlexRender.Abstractions;
+
+///
+/// Provides template metadata and tree context to content parsers.
+/// Passed by the template expander so parsers can access canvas settings,
+/// parent elements, and other metadata without special injection hacks.
+///
+public sealed record ContentParserContext
+{
+ ///
+ /// Canvas settings from the template (width, height, background, etc.).
+ ///
+ public CanvasSettings? Canvas { get; init; }
+
+ ///
+ /// The template being rendered.
+ ///
+ public Template? Template { get; init; }
+
+ ///
+ /// The computed effective width (in pixels) of the parent element containing
+ /// this content element. Used by content parsers for auto-sizing calculations
+ /// (e.g., fitting N characters into the available width).
+ /// Null if the parent's width cannot be determined.
+ ///
+ public int? ParentWidth { get; init; }
+}
diff --git a/src/FlexRender.Core/Abstractions/IBinaryContentParser.cs b/src/FlexRender.Core/Abstractions/IBinaryContentParser.cs
new file mode 100644
index 0000000..844d383
--- /dev/null
+++ b/src/FlexRender.Core/Abstractions/IBinaryContentParser.cs
@@ -0,0 +1,34 @@
+using FlexRender.Parsing.Ast;
+
+namespace FlexRender.Abstractions;
+
+///
+/// Parses binary content into a subtree of template elements.
+///
+public interface IBinaryContentParser
+{
+ ///
+ /// Gets the format name this parser handles (e.g., "ndc", "escpos").
+ ///
+ string FormatName { get; }
+
+ ///
+ /// Parses the binary data into template elements.
+ ///
+ /// The binary data to parse.
+ ///
+ /// Template metadata and tree context provided by the template expander.
+ /// Gives parsers typed access to canvas settings, template metadata, and parent elements.
+ ///
+ ///
+ /// Optional key-value options from the options: block of the content element.
+ /// Parsers may use these to customize behavior (e.g., column widths, formatting hints).
+ /// When null, the parser should use its default behavior.
+ ///
+ ///
+ /// A list of renderable template elements (e.g., , ,
+ /// ). Must not contain control-flow elements
+ /// (EachElement, IfElement, ContentElement) as they will not be expanded.
+ ///
+ IReadOnlyList Parse(ReadOnlyMemory data, ContentParserContext context, IReadOnlyDictionary? options = null);
+}
diff --git a/src/FlexRender.Core/Abstractions/IContentParser.cs b/src/FlexRender.Core/Abstractions/IContentParser.cs
index c1d156d..317b558 100644
--- a/src/FlexRender.Core/Abstractions/IContentParser.cs
+++ b/src/FlexRender.Core/Abstractions/IContentParser.cs
@@ -16,11 +16,20 @@ public interface IContentParser
/// Parses the formatted text into template elements.
///
/// The formatted text to parse.
+ ///
+ /// Template metadata and tree context provided by the template expander.
+ /// Gives parsers typed access to canvas settings, template metadata, and parent elements.
+ ///
+ ///
+ /// Optional key-value options from the options: block of the content element.
+ /// Parsers may use these to customize behavior (e.g., column widths, formatting hints).
+ /// When null, the parser should use its default behavior.
+ ///
///
/// A list of renderable template elements (e.g., , ,
/// ). Must not contain control-flow elements
/// (EachElement, IfElement, ContentElement) as they will not be expanded.
///
/// Thrown when is null.
- IReadOnlyList Parse(string text);
+ IReadOnlyList Parse(string text, ContentParserContext context, IReadOnlyDictionary? options = null);
}
diff --git a/src/FlexRender.Core/Configuration/FlexRenderBuilder.cs b/src/FlexRender.Core/Configuration/FlexRenderBuilder.cs
index 276f7f1..e8d9710 100644
--- a/src/FlexRender.Core/Configuration/FlexRenderBuilder.cs
+++ b/src/FlexRender.Core/Configuration/FlexRenderBuilder.cs
@@ -220,6 +220,21 @@ public FlexRenderBuilder WithContentParser(IContentParser parser)
return this;
}
+ ///
+ /// Registers a binary content parser for expanding type: content elements with binary data.
+ ///
+ /// The binary content parser to register.
+ /// This builder instance for method chaining.
+ /// Thrown when is null.
+ /// Thrown when a parser for this format is already registered.
+ public FlexRenderBuilder WithBinaryContentParser(IBinaryContentParser parser)
+ {
+ ArgumentNullException.ThrowIfNull(parser);
+ _contentParserRegistry ??= new ContentParserRegistry();
+ _contentParserRegistry.RegisterBinary(parser);
+ return this;
+ }
+
///
/// Clears all built-in filters, allowing only explicitly registered custom filters.
///
diff --git a/src/FlexRender.Core/Layout/ApproximateTextShaper.cs b/src/FlexRender.Core/Layout/ApproximateTextShaper.cs
index 4298fd2..5034d25 100644
--- a/src/FlexRender.Core/Layout/ApproximateTextShaper.cs
+++ b/src/FlexRender.Core/Layout/ApproximateTextShaper.cs
@@ -38,6 +38,7 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
return new TextShapingResult(
Array.Empty(),
new LayoutSize(0f, 0f),
+ 0f,
0f);
}
@@ -59,7 +60,8 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
return new TextShapingResult(
Array.Empty(),
new LayoutSize(0f, 0f),
- lineHeight);
+ lineHeight,
+ 0f);
}
var maxLineWidth = 0f;
@@ -70,8 +72,9 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
}
var totalHeight = lines.Count * lineHeight;
+ var baseline = fontSize * 0.85f;
- return new TextShapingResult(lines, new LayoutSize(maxLineWidth, totalHeight), lineHeight);
+ return new TextShapingResult(lines, new LayoutSize(maxLineWidth, totalHeight), lineHeight, baseline);
}
private static List GetLines(string text, bool wrap, float maxWidth, float charWidth,
diff --git a/src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs b/src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs
index 7d4fd45..b5fe38f 100644
--- a/src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs
+++ b/src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs
@@ -295,6 +295,7 @@ internal static void LayoutColumnFlex(LayoutNode node, FlexElement flex, LayoutC
AlignItems.Center => padding.Left + (crossAxisSize - child.Width - mLeft - mRight) / 2,
AlignItems.End => padding.Left + crossAxisSize - child.Width - mLeft - mRight,
AlignItems.Stretch => padding.Left,
+ AlignItems.Baseline => padding.Left,
_ => padding.Left
};
@@ -367,6 +368,7 @@ internal static void ApplyColumnCrossAxisMargins(
AlignItems.Center => padding.Left + (crossAxisSize - child.Width) / 2,
AlignItems.End => padding.Left + crossAxisSize - child.Width,
AlignItems.Stretch => padding.Left,
+ AlignItems.Baseline => padding.Left,
_ => padding.Left
};
@@ -432,6 +434,7 @@ private static void RealignRowChildren(LayoutNode node, Span heightChanged
AlignItems.Center => childPadding.Top + (newCrossAxisSize - grandchild.Height - mTop - mBottom) / 2,
AlignItems.End => childPadding.Top + newCrossAxisSize - grandchild.Height - mTop - mBottom,
AlignItems.Stretch => childPadding.Top,
+ AlignItems.Baseline => childPadding.Top,
_ => childPadding.Top
} : childPadding.Top);
diff --git a/src/FlexRender.Core/Layout/FontSizeResolver.cs b/src/FlexRender.Core/Layout/FontSizeResolver.cs
index 6f624b5..f730e9a 100644
--- a/src/FlexRender.Core/Layout/FontSizeResolver.cs
+++ b/src/FlexRender.Core/Layout/FontSizeResolver.cs
@@ -10,12 +10,26 @@ namespace FlexRender.Layout;
///
public static class FontSizeResolver
{
+ ///
+ /// Sentinel value returned when font size is "fit-content".
+ /// Callers must detect this value and compute the actual size based on available width.
+ ///
+ public const float FitContent = float.NaN;
+
+ ///
+ /// Returns true if the resolved font size is the fit-content sentinel.
+ ///
+ /// The resolved font size to check.
+ /// true if the value represents fit-content; otherwise, false.
+ public static bool IsFitContent(float fontSize) => float.IsNaN(fontSize);
+
///
/// Resolves a font size specification to an absolute pixel value.
+ /// Returns when the value is "fit-content".
///
- /// The font size string (e.g., "16", "48px", "1.5em", "150%"). Null or empty means use default.
+ /// The font size string (e.g., "16", "48px", "1.5em", "150%", "fit-content"). Null or empty means use default.
/// The parent/inherited font size in pixels, used for em and percentage resolution and as fallback.
- /// The resolved font size in pixels.
+ /// The resolved font size in pixels, or for fit-content.
public static float Resolve(string? size, float baseFontSize)
{
if (string.IsNullOrWhiteSpace(size))
@@ -23,6 +37,10 @@ public static float Resolve(string? size, float baseFontSize)
var value = size.Trim();
+ // fit-content — caller must compute actual size from available width
+ if (string.Equals(value, "fit-content", StringComparison.OrdinalIgnoreCase))
+ return FitContent;
+
// px units — absolute value
if (value.EndsWith("px", StringComparison.OrdinalIgnoreCase))
{
diff --git a/src/FlexRender.Core/Layout/ITextShaper.cs b/src/FlexRender.Core/Layout/ITextShaper.cs
index b06136b..ed3474c 100644
--- a/src/FlexRender.Core/Layout/ITextShaper.cs
+++ b/src/FlexRender.Core/Layout/ITextShaper.cs
@@ -3,15 +3,21 @@
namespace FlexRender.Layout;
///
-/// Result of text shaping: pre-computed line breaks, total size, and line height.
+/// Result of text shaping: pre-computed line breaks, total size, line height, and baseline.
///
/// The text split into individual lines after wrapping, max-lines, and ellipsis processing.
/// The bounding box size of all lines combined.
/// The computed line height in pixels used for vertical spacing.
+///
+/// Distance from the top of the text content area to the first text baseline, in pixels.
+/// Used for alignment.
+/// Zero when no text is present.
+///
public readonly record struct TextShapingResult(
IReadOnlyList Lines,
LayoutSize TotalSize,
- float LineHeight);
+ float LineHeight,
+ float Baseline);
///
/// Abstraction for measuring text and computing line breaks.
diff --git a/src/FlexRender.Core/Layout/LayoutEngine.cs b/src/FlexRender.Core/Layout/LayoutEngine.cs
index d32bce9..60f4631 100644
--- a/src/FlexRender.Core/Layout/LayoutEngine.cs
+++ b/src/FlexRender.Core/Layout/LayoutEngine.cs
@@ -145,10 +145,14 @@ public LayoutNode ComputeLayout(Template template)
var context = new LayoutContext(width, contextHeight, BaseFontSize, intrinsicSizes, canvas.TextDirection);
- // Process top-level elements
+ // Process top-level elements.
+ // IMPORTANT: must call PreAdjustStretchWidth for each child so that margin-reduced
+ // width is used during internal layout (e.g. fit-content). See the matching call
+ // inside LayoutFlexElement for nested children — both MUST stay in sync.
foreach (var element in template.Elements)
{
- var childNode = LayoutElement(element, context);
+ var childContext = PreAdjustStretchWidth(element, rootElement, context, isColumn: true);
+ var childNode = LayoutElement(element, childContext);
root.AddChild(childNode);
}
@@ -237,6 +241,27 @@ private LayoutNode LayoutFlexElement(FlexElement flex, LayoutContext context)
var isColumn = flex.Direction.Value is FlexDirection.Column or FlexDirection.ColumnReverse;
+ // Resolve font-size on container: propagate to children if explicitly set
+ if (!string.IsNullOrEmpty(flex.FontSize.Value))
+ {
+ var flexFontSize = FontSizeResolver.Resolve(flex.FontSize.Value, context.FontSize);
+ if (FontSizeResolver.IsFitContent(flexFontSize))
+ {
+ flexFontSize = ComputeFitContentFontSize(flex, innerContext, effectivePadding, gap);
+ // Scale intrinsic sizes proportionally for the new font size so that
+ // row children get correct relative widths instead of equal shares.
+ var scaledIntrinsics = ScaleIntrinsicSizes(context.IntrinsicSizes, flexFontSize, context.FontSize);
+ innerContext = new LayoutContext(innerContext.ContainerWidth, innerContext.ContainerHeight,
+ flexFontSize, intrinsicSizes: scaledIntrinsics, innerContext.Direction);
+ }
+ else
+ {
+ innerContext = innerContext.WithFontSize(flexFontSize);
+ }
+ }
+
+ node.ComputedFontSize = innerContext.FontSize;
+
// Layout children
foreach (var child in flex.Children)
{
@@ -298,6 +323,11 @@ private LayoutNode LayoutFlexElement(FlexElement flex, LayoutContext context)
}
}
+ // Pre-adjust available width for column children that will be stretched.
+ // IMPORTANT: must match the PreAdjustStretchWidth call in ComputeLayout for
+ // top-level elements — both call sites MUST stay in sync.
+ childContext = PreAdjustStretchWidth(child, flex, childContext, isColumn);
+
var flowChildNode = LayoutElement(child, childContext);
// Apply aspect ratio for flow children (only when one dimension is explicitly set)
@@ -469,6 +499,8 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
float contentHeight;
IReadOnlyList? textLines = null;
float computedLineHeight = 0f;
+ float textBaseline = 0f;
+ float resolvedFontSize = context.FontSize;
// If height is explicitly specified, use it (but still compute lines if shaper available)
if (!string.IsNullOrEmpty(text.Height.Value))
@@ -479,18 +511,25 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
if (TextShaper != null && !string.IsNullOrEmpty(text.Content.Value))
{
var fontSize = FontSizeResolver.Resolve(text.Size.Value, context.FontSize);
+ if (FontSizeResolver.IsFitContent(fontSize))
+ fontSize = ResolveFitContent(text, contentWidth, context);
+ resolvedFontSize = fontSize;
var measureWidth = MayWrapOrContainsNewlines(text, contentWidth, context)
? Math.Min(contentWidth, context.ContainerWidth)
: float.MaxValue;
var shaped = TextShaper.ShapeText(text, fontSize, measureWidth);
textLines = shaped.Lines;
computedLineHeight = shaped.LineHeight;
+ textBaseline = shaped.Baseline;
}
}
else if (TextShaper != null && !string.IsNullOrEmpty(text.Content.Value))
{
// Use TextShaper for accurate height and pre-computed lines
var fontSize = FontSizeResolver.Resolve(text.Size.Value, context.FontSize);
+ if (FontSizeResolver.IsFitContent(fontSize))
+ fontSize = ResolveFitContent(text, contentWidth, context);
+ resolvedFontSize = fontSize;
var measureWidth = MayWrapOrContainsNewlines(text, contentWidth, context)
? Math.Min(contentWidth, context.ContainerWidth)
: float.MaxValue;
@@ -498,24 +537,33 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
contentHeight = shaped.TotalSize.Height;
textLines = shaped.Lines;
computedLineHeight = shaped.LineHeight;
+ textBaseline = shaped.Baseline;
}
#pragma warning disable CS0618 // TextMeasurer is obsolete but still supported for backward compatibility
else if (TextMeasurer != null && !string.IsNullOrEmpty(text.Content.Value))
{
// Backward compatibility: use TextMeasurer delegate for height only
var fontSize = FontSizeResolver.Resolve(text.Size.Value, context.FontSize);
+ if (FontSizeResolver.IsFitContent(fontSize))
+ fontSize = ResolveFitContent(text, contentWidth, context);
+ resolvedFontSize = fontSize;
var measureWidth = MayWrapOrContainsNewlines(text, contentWidth, context)
? Math.Min(contentWidth, context.ContainerWidth)
: float.MaxValue;
var measured = TextMeasurer(text, fontSize, measureWidth);
contentHeight = measured.Height;
+ textBaseline = fontSize * 0.85f;
}
#pragma warning restore CS0618
else
{
// Fallback when no TextShaper or TextMeasurer available
var fontSize = FontSizeResolver.Resolve(text.Size.Value, context.FontSize);
+ if (FontSizeResolver.IsFitContent(fontSize))
+ fontSize = ResolveFitContent(text, contentWidth, context);
+ resolvedFontSize = fontSize;
contentHeight = LineHeightResolver.Resolve(text.LineHeight.Value, fontSize, fontSize * LineHeightResolver.DefaultMultiplier);
+ textBaseline = fontSize * 0.85f;
}
// Handle empty content with shaper: set empty lines
@@ -532,9 +580,24 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
var node = new LayoutNode(text, 0, 0, totalWidth, totalHeight);
node.TextLines = textLines;
node.ComputedLineHeight = computedLineHeight;
+ node.Baseline = padding.Top + border.Top.Width + textBaseline;
+ node.ComputedFontSize = resolvedFontSize;
return node;
}
+ private float ResolveFitContent(TextElement text, float contentWidth, LayoutContext context)
+ {
+ if (TextShaper != null && !string.IsNullOrEmpty(text.Content.Value))
+ {
+ const float refSize = 100f;
+ var refShaped = TextShaper.ShapeText(text, refSize, float.MaxValue);
+ var measuredWidth = refShaped.TotalSize.Width;
+ var availableWidth = Math.Min(contentWidth, context.ContainerWidth);
+ return measuredWidth > 0 ? refSize * availableWidth / measuredWidth : context.FontSize;
+ }
+ return context.FontSize;
+ }
+
///
/// Determines whether a text element may produce multiple lines due to wrapping
/// or embedded newline characters.
@@ -580,7 +643,7 @@ private static LayoutNode LayoutQrElement(QrElement qr, LayoutContext context)
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
- return new LayoutNode(qr, 0, 0, totalWidth, totalHeight);
+ return new LayoutNode(qr, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
}
///
@@ -600,7 +663,7 @@ private static LayoutNode LayoutBarcodeElement(BarcodeElement barcode, LayoutCon
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
- return new LayoutNode(barcode, 0, 0, totalWidth, totalHeight);
+ return new LayoutNode(barcode, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
}
///
@@ -619,7 +682,7 @@ private static LayoutNode LayoutImageElement(ImageElement image, LayoutContext c
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
- return new LayoutNode(image, 0, 0, totalWidth, totalHeight);
+ return new LayoutNode(image, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
}
///
@@ -637,7 +700,7 @@ private static LayoutNode LayoutSvgElement(SvgElement svg, LayoutContext context
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
- return new LayoutNode(svg, 0, 0, totalWidth, totalHeight);
+ return new LayoutNode(svg, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
}
///
@@ -667,7 +730,7 @@ private static LayoutNode LayoutSeparatorElement(SeparatorElement separator, Lay
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
- return new LayoutNode(separator, 0, 0, totalWidth, totalHeight);
+ return new LayoutNode(separator, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
}
///
@@ -701,4 +764,172 @@ private static void MirrorRowXPositions(LayoutNode node, PaddingValues padding)
child.X = node.Width - padding.Right - (child.X - padding.Left) - child.Width;
}
}
+
+ private static Dictionary? ScaleIntrinsicSizes(
+ IReadOnlyDictionary? original,
+ float newFontSize,
+ float oldFontSize)
+ {
+ if (original is null || oldFontSize <= 0f)
+ return null;
+
+ var scale = newFontSize / oldFontSize;
+ var scaled = new Dictionary(original.Count, ReferenceEqualityComparer.Instance);
+ foreach (var kvp in original)
+ {
+ scaled[kvp.Key] = new IntrinsicSize(
+ kvp.Value.MinWidth * scale,
+ kvp.Value.MaxWidth * scale,
+ kvp.Value.MinHeight * scale,
+ kvp.Value.MaxHeight * scale);
+ }
+ return scaled;
+ }
+
+ ///
+ /// Pre-adjusts the layout context width for a child that will be stretched on the cross axis.
+ /// In CSS flexbox, a stretched child's cross-axis size equals the container's inner size minus
+ /// the child's margins. This adjustment must happen BEFORE the child's internal layout so that
+ /// width-dependent calculations (e.g. font-size: fit-content) use the correct width.
+ /// Without this, the child is laid out at full container width, then post-hoc shrunk by the
+ /// flex strategy — but internal measurements already used the wider value.
+ ///
+ ///
+ /// Called from two places that MUST stay in sync:
+ ///
+ /// - — for top-level template elements
+ /// - — for nested flex children
+ ///
+ ///
+ private static LayoutContext PreAdjustStretchWidth(
+ TemplateElement child, FlexElement parentFlex, LayoutContext childContext, bool isColumn)
+ {
+ if (!isColumn || LayoutHelpers.HasExplicitWidth(child))
+ return childContext;
+
+ var effectiveAlign = LayoutHelpers.GetEffectiveAlign(child, parentFlex.Align.Value);
+ if (effectiveAlign != AlignItems.Stretch)
+ return childContext;
+
+ var childMargin = PaddingParser.Parse(child.Margin.Value, childContext.ContainerWidth, childContext.FontSize).ClampNegatives();
+ var stretchWidth = childContext.ContainerWidth - childMargin.Left - childMargin.Right;
+ if (stretchWidth > 0 && stretchWidth < childContext.ContainerWidth)
+ {
+ return childContext.WithSize(stretchWidth, childContext.ContainerHeight);
+ }
+
+ return childContext;
+ }
+
+ private float ComputeFitContentFontSize(FlexElement flex, LayoutContext innerContext, PaddingValues effectivePadding, float gap)
+ {
+ const float refSize = 100f;
+ // Create a fresh context without intrinsic sizes — those were computed at the default
+ // font size and would give incorrect widths at the reference font size
+ var measureContext = new LayoutContext(float.MaxValue, innerContext.ContainerHeight, refSize,
+ intrinsicSizes: null, innerContext.Direction);
+
+ var isColumn = flex.Direction.Value is FlexDirection.Column or FlexDirection.ColumnReverse;
+
+ var maxWidth = 0f;
+ var sumWidth = 0f;
+ var childCount = 0;
+ foreach (var child in flex.Children)
+ {
+ if (child.Display.Value == Display.None)
+ continue;
+ var contentWidth = MeasureContentWidth(child, measureContext);
+ if (contentWidth > maxWidth)
+ maxWidth = contentWidth;
+ sumWidth += contentWidth;
+ childCount++;
+ }
+
+ if (childCount == 0)
+ return innerContext.FontSize;
+
+ float totalMeasured;
+ if (isColumn)
+ {
+ // Column: widest child must fit
+ totalMeasured = maxWidth;
+ }
+ else
+ {
+ // Row: sum of all children + gaps must fit
+ totalMeasured = sumWidth + gap * (childCount - 1);
+ }
+
+ var availableWidth = innerContext.ContainerWidth;
+ if (totalMeasured <= 0)
+ return innerContext.FontSize;
+
+ // Floor to 0.1px to avoid rounding errors where text barely exceeds container width
+ // due to non-linear font scaling (hinting, glyph rounding)
+ var computed = refSize * availableWidth / totalMeasured;
+ return MathF.Floor(computed * 10f) / 10f;
+ }
+
+ private float MeasureContentWidth(TemplateElement element, LayoutContext context)
+ {
+ // For flex containers: measure children and compute content width based on direction
+ if (element is FlexElement flexChild)
+ {
+ var isChildRow = flexChild.Direction.Value is FlexDirection.Row or FlexDirection.RowReverse;
+ var childGap = UnitParser.Parse(flexChild.Gap.Value).Resolve(context.ContainerWidth, context.FontSize) ?? 0f;
+
+ var maxW = 0f;
+ var sumW = 0f;
+ var count = 0;
+ foreach (var grandchild in flexChild.Children)
+ {
+ if (grandchild.Display.Value == Display.None)
+ continue;
+ var w = MeasureContentWidth(grandchild, context);
+ if (w > maxW)
+ maxW = w;
+ sumW += w;
+ count++;
+ }
+
+ if (count == 0)
+ return 0f;
+
+ return isChildRow
+ ? sumW + childGap * (count - 1)
+ : maxW;
+ }
+
+ // Separators stretch to fill — they don't constrain the fit-content calculation
+ if (element is SeparatorElement)
+ return 0f;
+
+ // For text elements: measure natural width using shaper/measurer
+ if (element is TextElement text && !string.IsNullOrEmpty(text.Content.Value))
+ {
+ var fontSize = FontSizeResolver.Resolve(text.Size.Value, context.FontSize);
+ if (FontSizeResolver.IsFitContent(fontSize))
+ fontSize = context.FontSize;
+
+ if (TextShaper != null)
+ {
+ var shaped = TextShaper.ShapeText(text, fontSize, float.MaxValue);
+ return shaped.TotalSize.Width;
+ }
+#pragma warning disable CS0618
+ if (TextMeasurer != null)
+ {
+ var measured = TextMeasurer(text, fontSize, float.MaxValue);
+ return measured.Width;
+ }
+#pragma warning restore CS0618
+
+ // Fallback: estimate from char count and font size
+ return text.Content.Value.Length * fontSize * 0.6f;
+ }
+
+ // For other elements: layout and return width
+ var node = LayoutElement(element, context);
+ return node.Width;
+ }
}
diff --git a/src/FlexRender.Core/Layout/LayoutHelpers.cs b/src/FlexRender.Core/Layout/LayoutHelpers.cs
index 4054c4c..5db6536 100644
--- a/src/FlexRender.Core/Layout/LayoutHelpers.cs
+++ b/src/FlexRender.Core/Layout/LayoutHelpers.cs
@@ -62,6 +62,7 @@ internal static AlignItems GetEffectiveAlign(TemplateElement element, AlignItems
AlignSelf.Center => AlignItems.Center,
AlignSelf.End => AlignItems.End,
AlignSelf.Stretch => AlignItems.Stretch,
+ AlignSelf.Baseline => AlignItems.Baseline,
_ => parentAlign
};
}
diff --git a/src/FlexRender.Core/Layout/LayoutNode.cs b/src/FlexRender.Core/Layout/LayoutNode.cs
index 1e8331c..3b580a0 100644
--- a/src/FlexRender.Core/Layout/LayoutNode.cs
+++ b/src/FlexRender.Core/Layout/LayoutNode.cs
@@ -47,6 +47,20 @@ public sealed class LayoutNode
///
public float ComputedLineHeight { get; set; }
+ ///
+ /// Distance from the top of this node to the first text baseline.
+ /// Used for alignment.
+ /// Zero for non-text elements (they use their height as baseline fallback).
+ ///
+ public float Baseline { get; set; }
+
+ ///
+ /// The effective font size in pixels for this node, as resolved during layout.
+ /// Includes inherited font-size from parent containers (e.g., fit-content on FlexElement).
+ /// Used by renderers to draw text at the correct size instead of the global base font size.
+ ///
+ public float ComputedFontSize { get; set; }
+
/// Right edge (X + Width).
public float Right => X + Width;
diff --git a/src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs b/src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs
index 1e9504d..85d9192 100644
--- a/src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs
+++ b/src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs
@@ -274,6 +274,22 @@ internal static void LayoutRowFlex(LayoutNode node, FlexElement flex, LayoutCont
break;
}
+ // Compute max baseline for AlignItems.Baseline alignment
+ var maxBaseline = 0f;
+ if (flex.Align.Value == AlignItems.Baseline)
+ {
+ foreach (var child in node.Children)
+ {
+ if (child.Element.Display.Value == Display.None) continue;
+ if (child.Element.Position.Value == Position.Absolute) continue;
+ var effectiveAlign = LayoutHelpers.GetEffectiveAlign(child.Element, flex.Align.Value);
+ if (effectiveAlign != AlignItems.Baseline) continue;
+
+ var childBaseline = child.Baseline > 0 ? child.Baseline : child.Height;
+ if (childBaseline > maxBaseline) maxBaseline = childBaseline;
+ }
+ }
+
foreach (var child in node.Children)
{
if (child.Element.Display.Value == Display.None) continue;
@@ -304,6 +320,7 @@ internal static void LayoutRowFlex(LayoutNode node, FlexElement flex, LayoutCont
AlignItems.Center => padding.Top + (crossAxisSize - child.Height - mTop - mBottom) / 2,
AlignItems.End => padding.Top + crossAxisSize - child.Height - mTop - mBottom,
AlignItems.Stretch => padding.Top,
+ AlignItems.Baseline => padding.Top + (maxBaseline - (child.Baseline > 0 ? child.Baseline : child.Height)),
_ => padding.Top
} : padding.Top);
@@ -365,7 +382,7 @@ internal static void ApplyRowCrossAxisMargins(
}
else
{
- // Normal align-items / align-self logic
+ // Normal align-items / align-self logic (Baseline falls back to Start when auto margins are present)
var effectiveAlign = LayoutHelpers.GetEffectiveAlign(child.Element, flex.Align.Value);
child.Y = margin.Top.ResolvedPixels + (crossAxisSize > 0 ? effectiveAlign switch
{
@@ -373,6 +390,7 @@ internal static void ApplyRowCrossAxisMargins(
AlignItems.Center => padding.Top + (crossAxisSize - child.Height) / 2,
AlignItems.End => padding.Top + crossAxisSize - child.Height,
AlignItems.Stretch => padding.Top,
+ AlignItems.Baseline => padding.Top,
_ => padding.Top
} : padding.Top);
@@ -437,6 +455,7 @@ private static void RealignColumnChildren(LayoutNode node, Span widthChang
AlignItems.Center => childPadding.Left + (newCrossAxisSize - grandchild.Width - mLeft - mRight) / 2,
AlignItems.End => childPadding.Left + newCrossAxisSize - grandchild.Width - mLeft - mRight,
AlignItems.Stretch => childPadding.Left,
+ AlignItems.Baseline => childPadding.Left,
_ => childPadding.Left
};
diff --git a/src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs b/src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs
index 8f69bdf..a32b7a9 100644
--- a/src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs
+++ b/src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs
@@ -164,6 +164,7 @@ internal void LayoutWrappedFlex(LayoutNode node, FlexElement flex, LayoutContext
AlignItems.Center => crossLead + (lineHeight - child.Width - childMargin.Left - childMargin.Right) / 2,
AlignItems.End => crossLead + lineHeight - child.Width - childMargin.Left - childMargin.Right,
AlignItems.Stretch => crossLead,
+ AlignItems.Baseline => crossLead,
_ => crossLead
};
@@ -182,6 +183,7 @@ internal void LayoutWrappedFlex(LayoutNode node, FlexElement flex, LayoutContext
AlignItems.Center => crossLead + (lineHeight - child.Height - childMargin.Top - childMargin.Bottom) / 2,
AlignItems.End => crossLead + lineHeight - child.Height - childMargin.Top - childMargin.Bottom,
AlignItems.Stretch => crossLead,
+ AlignItems.Baseline => crossLead,
_ => crossLead
};
diff --git a/src/FlexRender.Core/Loaders/SvgContentLoader.cs b/src/FlexRender.Core/Loaders/SvgContentLoader.cs
index e5760d0..746b4b1 100644
--- a/src/FlexRender.Core/Loaders/SvgContentLoader.cs
+++ b/src/FlexRender.Core/Loaders/SvgContentLoader.cs
@@ -1,5 +1,6 @@
using System.Text;
using FlexRender.Abstractions;
+using FlexRender.Parsing.Ast;
using FlexRender.Rendering;
namespace FlexRender.Loaders;
@@ -15,14 +16,14 @@ public static class SvgContentLoader
public const int MaxSvgContentSize = 10 * 1024 * 1024;
///
- /// Attempts to load SVG content using the configured resource loaders.
+ /// Asynchronously loads SVG content using the configured resource loaders.
/// Returns null when no loader can handle the URI.
///
/// The resource loader chain.
/// The URI to load.
/// SVG content string or null if not handled.
/// Thrown when content exceeds size limit.
- public static string? LoadFromLoaders(IReadOnlyList? loaders, string uri)
+ public static async ValueTask LoadFromLoaders(IReadOnlyList? loaders, string uri)
{
if (loaders is null || loaders.Count == 0)
{
@@ -36,7 +37,7 @@ public static class SvgContentLoader
continue;
}
- var stream = loader.Load(uri).GetAwaiter().GetResult();
+ var stream = await loader.Load(uri).ConfigureAwait(false);
if (stream is null)
{
continue;
@@ -52,6 +53,41 @@ public static class SvgContentLoader
return null;
}
+ ///
+ /// Collects all unique SVG source URIs from the processed template element tree.
+ /// Only collects URIs where has a value and
+ /// is empty (content-based SVGs don't need loading).
+ ///
+ /// The template to collect URIs from.
+ /// A set of unique SVG source URIs.
+ public static HashSet CollectSvgUris(Template template)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+ var uris = new HashSet(StringComparer.Ordinal);
+ CollectSvgUrisFromElements(template.Elements, uris);
+ return uris;
+ }
+
+ ///
+ /// Recursively collects SVG source URIs from a list of elements.
+ ///
+ private static void CollectSvgUrisFromElements(IReadOnlyList elements, HashSet uris)
+ {
+ foreach (var element in elements)
+ {
+ if (element is SvgElement svg &&
+ !string.IsNullOrEmpty(svg.Src.Value) &&
+ string.IsNullOrEmpty(svg.Content.Value))
+ {
+ uris.Add(svg.Src.Value);
+ }
+ else if (element is FlexElement flex)
+ {
+ CollectSvgUrisFromElements(flex.Children, uris);
+ }
+ }
+ }
+
///
/// Reads a file from disk with size validation.
///
diff --git a/src/FlexRender.Core/Parsing/Ast/ContentElement.cs b/src/FlexRender.Core/Parsing/Ast/ContentElement.cs
index fba6d2b..ef34fda 100644
--- a/src/FlexRender.Core/Parsing/Ast/ContentElement.cs
+++ b/src/FlexRender.Core/Parsing/Ast/ContentElement.cs
@@ -20,6 +20,18 @@ public sealed class ContentElement : TemplateElement
///
public ExprValue Format { get; set; } = "";
+ ///
+ /// Gets or sets parser-specific options (e.g., charset mappings, encoding settings).
+ /// Parsed from the YAML options block. May be null if no options specified.
+ ///
+ ///
+ /// This dictionary is passed through directly to
+ /// as the options parameter.
+ /// Each content parser defines its own supported keys and value types. Refer to
+ /// the specific parser documentation for the available options.
+ ///
+ public IReadOnlyDictionary? Options { get; set; }
+
///
public override void ResolveExpressions(Func resolver, ObjectValue data)
{
diff --git a/src/FlexRender.Core/Parsing/Ast/FlexElement.cs b/src/FlexRender.Core/Parsing/Ast/FlexElement.cs
index f6be9b9..8d38946 100644
--- a/src/FlexRender.Core/Parsing/Ast/FlexElement.cs
+++ b/src/FlexRender.Core/Parsing/Ast/FlexElement.cs
@@ -42,6 +42,20 @@ public sealed class FlexElement : TemplateElement
/// Overflow behavior for content exceeding container bounds.
public ExprValue Overflow { get; set; } = Layout.Overflow.Visible;
+ /// Font size for this container and its children (px, em, %, fit-content).
+ ///
+ ///
+ /// When set to "fit-content", the layout engine auto-calculates the font size so that
+ /// all children fit within the container width. This is particularly useful for dynamic text
+ /// that must not overflow its parent bounds.
+ ///
+ ///
+ /// Used internally by the NDC content parser for receipt rendering, where line widths are
+ /// constrained to a fixed paper width.
+ ///
+ ///
+ public ExprValue FontSize { get; set; } = "";
+
///
/// Gets or sets the child elements.
/// The getter returns a read-only view; use setter for bulk assignment.
@@ -80,6 +94,7 @@ public override void ResolveExpressions(Func resolv
Align = Align.Resolve(resolver, data);
AlignContent = AlignContent.Resolve(resolver, data);
Overflow = Overflow.Resolve(resolver, data);
+ FontSize = FontSize.Resolve(resolver, data);
foreach (var child in Children)
child.ResolveExpressions(resolver, data);
}
@@ -97,6 +112,7 @@ public override void Materialize()
Align = Align.Materialize(nameof(Align));
AlignContent = AlignContent.Materialize(nameof(AlignContent));
Overflow = Overflow.Materialize(nameof(Overflow));
+ FontSize = FontSize.Materialize(nameof(FontSize), ValueKind.Size);
foreach (var child in Children)
child.Materialize();
}
diff --git a/src/FlexRender.Core/Providers/ISvgContentCacheAware.cs b/src/FlexRender.Core/Providers/ISvgContentCacheAware.cs
new file mode 100644
index 0000000..fdf35fd
--- /dev/null
+++ b/src/FlexRender.Core/Providers/ISvgContentCacheAware.cs
@@ -0,0 +1,19 @@
+namespace FlexRender.Providers;
+
+///
+/// Allows SVG content providers to receive a pre-loaded SVG content cache.
+///
+///
+/// Providers that load SVG content from URIs during the sync rendering phase
+/// implement this interface to receive a cache of pre-loaded content from the
+/// async phase, eliminating the need for synchronous blocking on async loaders.
+///
+public interface ISvgContentCacheAware
+{
+ ///
+ /// Sets the pre-loaded SVG content cache for the current render pass.
+ /// Pass null to clear the cache after rendering completes.
+ ///
+ /// The SVG content cache mapping URIs to sanitized content, or null to clear.
+ void SetSvgContentCache(IReadOnlyDictionary? cache);
+}
diff --git a/src/FlexRender.Core/TemplateEngine/ContentParserRegistry.cs b/src/FlexRender.Core/TemplateEngine/ContentParserRegistry.cs
index 61eac31..7c1d8dc 100644
--- a/src/FlexRender.Core/TemplateEngine/ContentParserRegistry.cs
+++ b/src/FlexRender.Core/TemplateEngine/ContentParserRegistry.cs
@@ -8,6 +8,7 @@ namespace FlexRender.TemplateEngine;
public sealed class ContentParserRegistry
{
private readonly Dictionary _parsers = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _binaryParsers = new(StringComparer.OrdinalIgnoreCase);
///
/// Registers a content parser for its format name.
@@ -26,6 +27,23 @@ public void Register(IContentParser parser)
}
}
+ ///
+ /// Registers a binary content parser for its format name.
+ ///
+ /// The binary content parser to register.
+ /// Thrown when is null.
+ /// Thrown when a binary parser for the same format is already registered.
+ public void RegisterBinary(IBinaryContentParser parser)
+ {
+ ArgumentNullException.ThrowIfNull(parser);
+ ArgumentException.ThrowIfNullOrWhiteSpace(parser.FormatName, nameof(parser));
+ if (!_binaryParsers.TryAdd(parser.FormatName, parser))
+ {
+ throw new InvalidOperationException(
+ $"A binary content parser for format '{parser.FormatName}' is already registered.");
+ }
+ }
+
///
/// Gets the content parser for the specified format name.
///
@@ -37,7 +55,17 @@ public void Register(IContentParser parser)
}
///
- /// Gets whether any content parsers are registered.
+ /// Gets the binary content parser for the specified format name.
+ ///
+ /// The format name to look up.
+ /// The binary content parser if found; otherwise, null.
+ public IBinaryContentParser? GetBinaryParser(string formatName)
+ {
+ return _binaryParsers.GetValueOrDefault(formatName);
+ }
+
+ ///
+ /// Gets whether any content parsers (text or binary) are registered.
///
- internal bool HasParsers => _parsers.Count > 0;
+ internal bool HasParsers => _parsers.Count > 0 || _binaryParsers.Count > 0;
}
diff --git a/src/FlexRender.Core/TemplateEngine/ContentSource.cs b/src/FlexRender.Core/TemplateEngine/ContentSource.cs
new file mode 100644
index 0000000..0c34414
--- /dev/null
+++ b/src/FlexRender.Core/TemplateEngine/ContentSource.cs
@@ -0,0 +1,21 @@
+namespace FlexRender.TemplateEngine;
+
+///
+/// Represents a resolved content source — either text or binary data.
+/// Used as the result of content source resolution to dispatch
+/// to the appropriate content parser.
+///
+public abstract record ContentSource;
+
+///
+/// Text content to be parsed by an .
+///
+/// The text content.
+public sealed record TextContent(string Text) : ContentSource;
+
+///
+/// Binary content to be parsed by an .
+///
+/// The binary data.
+/// Optional MIME type of the data (e.g., "image/png").
+public sealed record BinaryContent(ReadOnlyMemory Data, string? MimeType = null) : ContentSource;
diff --git a/src/FlexRender.Core/TemplateEngine/ContentSourceResolver.cs b/src/FlexRender.Core/TemplateEngine/ContentSourceResolver.cs
new file mode 100644
index 0000000..48ea215
--- /dev/null
+++ b/src/FlexRender.Core/TemplateEngine/ContentSourceResolver.cs
@@ -0,0 +1,247 @@
+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 dcbd55e..4db9072 100644
--- a/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs
+++ b/src/FlexRender.Core/TemplateEngine/TemplateExpander.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using FlexRender.Abstractions;
using FlexRender.Configuration;
using FlexRender.Parsing.Ast;
@@ -13,6 +14,7 @@ public sealed class TemplateExpander
private readonly ResourceLimits _limits;
private readonly InlineExpressionEvaluator? _expressionEvaluator;
private readonly ContentParserRegistry? _contentParserRegistry;
+ private readonly IReadOnlyList? _resourceLoaders;
///
/// Creates a new TemplateExpander with default resource limits.
@@ -26,13 +28,16 @@ public TemplateExpander() : this(new ResourceLimits())
///
/// Resource limits for expansion depth protection.
/// Optional content parser registry for ContentElement expansion.
+ /// Optional resource loaders for resolving file-based content sources.
/// Thrown when limits is null.
- public TemplateExpander(ResourceLimits limits, ContentParserRegistry? contentParserRegistry = null)
+ public TemplateExpander(ResourceLimits limits, ContentParserRegistry? contentParserRegistry = null,
+ IReadOnlyList? resourceLoaders = null)
{
ArgumentNullException.ThrowIfNull(limits);
_limits = limits;
_expressionEvaluator = new InlineExpressionEvaluator();
_contentParserRegistry = contentParserRegistry;
+ _resourceLoaders = resourceLoaders;
}
///
@@ -41,10 +46,12 @@ public TemplateExpander(ResourceLimits limits, ContentParserRegistry? contentPar
/// Resource limits for expansion depth protection.
/// The filter registry for expression evaluation.
/// Optional content parser registry for ContentElement expansion.
+ /// Optional resource loaders for resolving file-based content sources.
/// Thrown when limits or filterRegistry is null.
public TemplateExpander(ResourceLimits limits, FilterRegistry filterRegistry,
- ContentParserRegistry? contentParserRegistry = null)
- : this(limits, filterRegistry, CultureInfo.InvariantCulture, contentParserRegistry)
+ ContentParserRegistry? contentParserRegistry = null,
+ IReadOnlyList? resourceLoaders = null)
+ : this(limits, filterRegistry, CultureInfo.InvariantCulture, contentParserRegistry, resourceLoaders)
{
}
@@ -55,9 +62,11 @@ public TemplateExpander(ResourceLimits limits, FilterRegistry filterRegistry,
/// The filter registry for expression evaluation.
/// The culture to use for culture-aware filter formatting.
/// Optional content parser registry for ContentElement expansion.
+ /// Optional resource loaders for resolving file-based content sources.
/// Thrown when any parameter is null.
public TemplateExpander(ResourceLimits limits, FilterRegistry filterRegistry, CultureInfo culture,
- ContentParserRegistry? contentParserRegistry = null)
+ ContentParserRegistry? contentParserRegistry = null,
+ IReadOnlyList? resourceLoaders = null)
{
ArgumentNullException.ThrowIfNull(limits);
ArgumentNullException.ThrowIfNull(filterRegistry);
@@ -65,6 +74,7 @@ public TemplateExpander(ResourceLimits limits, FilterRegistry filterRegistry, Cu
_limits = limits;
_expressionEvaluator = new InlineExpressionEvaluator(filterRegistry, culture);
_contentParserRegistry = contentParserRegistry;
+ _resourceLoaders = resourceLoaders;
}
///
@@ -82,7 +92,7 @@ public Template Expand(Template template, ObjectValue data)
ArgumentNullException.ThrowIfNull(data);
var context = new TemplateContext(data);
- var expandedElements = ExpandElements(template.Elements, context, 0);
+ var expandedElements = ExpandElements(template.Elements, context, 0, template);
var result = new Template
{
@@ -101,7 +111,42 @@ public Template Expand(Template template, ObjectValue data)
return result;
}
- private List ExpandElements(IReadOnlyList elements, TemplateContext context, int depth)
+ ///
+ /// Asynchronously expands EachElement and IfElement instances into concrete elements based on data.
+ /// Returns a new Template with all control flow elements resolved.
+ /// Uses await for content source resolution instead of blocking synchronously.
+ ///
+ /// The template containing control flow elements.
+ /// The data for evaluating conditions and iterating arrays.
+ /// A new Template with expanded elements.
+ /// Thrown when template or data is null.
+ /// Thrown when maximum expansion depth is exceeded.
+ public async Task ExpandAsync(Template template, ObjectValue data)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+ ArgumentNullException.ThrowIfNull(data);
+
+ var context = new TemplateContext(data);
+ var expandedElements = await ExpandElementsAsync(template.Elements, context, 0, template).ConfigureAwait(false);
+
+ var result = new Template
+ {
+ Name = template.Name,
+ Version = template.Version,
+ Canvas = template.Canvas,
+ Elements = expandedElements
+ };
+
+ // Copy fonts
+ foreach (var font in template.Fonts)
+ {
+ result.Fonts[font.Key] = font.Value;
+ }
+
+ return result;
+ }
+
+ private List ExpandElements(IReadOnlyList elements, TemplateContext context, int depth, Template template, int? parentWidth = null)
{
if (depth > _limits.MaxRenderDepth)
{
@@ -112,27 +157,28 @@ private List ExpandElements(IReadOnlyList elem
foreach (var element in elements)
{
- var expanded = ExpandElement(element, context, depth);
+ var expanded = ExpandElement(element, context, depth, template, parentWidth);
result.AddRange(expanded);
}
return result;
}
- private IEnumerable ExpandElement(TemplateElement element, TemplateContext context, int depth)
+ private IEnumerable ExpandElement(TemplateElement element, TemplateContext context, int depth, Template template, int? parentWidth = null)
{
return element switch
{
- EachElement each => ExpandEach(each, context, depth),
- IfElement ifEl => ExpandIf(ifEl, context, depth),
- TableElement table => [ExpandTable(table, context, depth)],
- FlexElement flex => [ExpandFlex(flex, context, depth)],
- ContentElement content => ExpandContent(content, context, depth),
+ EachElement each => ExpandEach(each, context, depth, template, parentWidth),
+ IfElement ifEl => ExpandIf(ifEl, context, depth, template, parentWidth),
+ TableElement table => [ExpandTable(table, context, depth, template)],
+ FlexElement flex => [ExpandFlex(flex, context, depth, template)],
+ ContentElement => throw new TemplateEngineException(
+ "Content element expansion requires async processing. Use ExpandAsync() instead of Expand()."),
_ => [CloneWithVariableSubstitution(element, context)]
};
}
- private IEnumerable ExpandEach(EachElement each, TemplateContext context, int depth)
+ private IEnumerable ExpandEach(EachElement each, TemplateContext context, int depth, Template template, int? parentWidth = null)
{
// Check depth limit - Each element increases nesting depth
var childDepth = depth + 1;
@@ -172,7 +218,7 @@ private IEnumerable ExpandEach(EachElement each, TemplateContex
context.SetLoopVariables(i, count);
// Expand children
- var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth);
+ var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth, template, parentWidth);
// Pop scope
context.ClearLoopVariables();
@@ -212,7 +258,7 @@ private IEnumerable ExpandEach(EachElement each, TemplateContex
context.SetLoopKey(key);
// Expand children
- var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth);
+ var expandedChildren = ExpandElements(each.ItemTemplate, context, childDepth, template, parentWidth);
// Pop scope
context.ClearLoopVariables();
@@ -256,7 +302,7 @@ private void ValidateNestedDepth(IReadOnlyList elements, int de
}
}
- private IEnumerable ExpandIf(IfElement ifEl, TemplateContext context, int depth)
+ private IEnumerable ExpandIf(IfElement ifEl, TemplateContext context, int depth, Template template, int? parentWidth = null)
{
// Check depth limit - If element increases nesting depth
var childDepth = depth + 1;
@@ -275,17 +321,17 @@ private IEnumerable ExpandIf(IfElement ifEl, TemplateContext co
if (EvaluateCondition(ifEl, context))
{
- return ExpandElements(ifEl.ThenBranch, context, childDepth);
+ return ExpandElements(ifEl.ThenBranch, context, childDepth, template, parentWidth);
}
// Check else-if chain
if (ifEl.ElseIf != null)
{
- return ExpandIf(ifEl.ElseIf, context, depth + 1);
+ return ExpandIf(ifEl.ElseIf, context, depth + 1, template, parentWidth);
}
// Return else branch
- return ExpandElements(ifEl.ElseBranch, context, childDepth);
+ return ExpandElements(ifEl.ElseBranch, context, childDepth, template, parentWidth);
}
private bool EvaluateCondition(IfElement ifEl, TemplateContext context)
@@ -492,7 +538,7 @@ private static int GetCount(TemplateValue? templateValue)
return -1;
}
- private FlexElement ExpandTable(TableElement table, TemplateContext context, int depth)
+ private FlexElement ExpandTable(TableElement table, TemplateContext context, int depth, Template template)
{
var childDepth = depth + 1;
if (childDepth > _limits.MaxRenderDepth)
@@ -569,7 +615,7 @@ private FlexElement ExpandTable(TableElement table, TemplateContext context, int
};
// Expand the each element inline
- var expandedRows = ExpandEach(each, context, childDepth);
+ var expandedRows = ExpandEach(each, context, childDepth, template);
foreach (var row in expandedRows)
{
outerFlex.AddChild(row);
@@ -726,9 +772,10 @@ private FlexElement BuildStaticRow(TableElement table, TableRow row, TemplateCon
return rowFlex;
}
- private FlexElement ExpandFlex(FlexElement flex, TemplateContext context, int depth)
+ private FlexElement ExpandFlex(FlexElement flex, TemplateContext context, int depth, Template template)
{
- var expandedChildren = ExpandElements(flex.Children, context, depth + 1);
+ var childParentWidth = TryParsePixelWidth(flex.Width.Value);
+ var expandedChildren = ExpandElements(flex.Children, context, depth + 1, template, childParentWidth);
var clone = new FlexElement
{
@@ -742,6 +789,7 @@ private FlexElement ExpandFlex(FlexElement flex, TemplateContext context, int de
Overflow = flex.Overflow,
RowGap = flex.RowGap,
ColumnGap = flex.ColumnGap,
+ FontSize = flex.FontSize,
// Base element properties requiring per-element handling
Rotate = flex.Rotate,
@@ -757,7 +805,202 @@ private FlexElement ExpandFlex(FlexElement flex, TemplateContext context, int de
return clone;
}
- private IEnumerable ExpandContent(ContentElement content, TemplateContext context, int depth)
+ ///
+ /// Attempts to parse a plain pixel width value from a size string.
+ /// Returns the integer pixel value for plain numeric strings (e.g., "200"),
+ /// or null for percentage, em, auto, or empty/null values.
+ ///
+ /// The width string from .
+ /// The pixel width if parseable; otherwise, null.
+ private static int? TryParsePixelWidth(string? widthValue)
+ {
+ if (string.IsNullOrEmpty(widthValue))
+ return null;
+
+ // Skip relative/auto values -- only accept plain numeric pixel widths
+ if (widthValue.Contains('%', StringComparison.Ordinal)
+ || widthValue.Contains("em", StringComparison.OrdinalIgnoreCase)
+ || widthValue.Equals("auto", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return int.TryParse(widthValue, System.Globalization.NumberStyles.None,
+ System.Globalization.CultureInfo.InvariantCulture, out var px) && px > 0
+ ? px
+ : null;
+ }
+
+ private async Task> ExpandElementsAsync(IReadOnlyList elements, TemplateContext context, int depth, Template template, int? parentWidth = null)
+ {
+ if (depth > _limits.MaxRenderDepth)
+ {
+ throw new TemplateEngineException($"Maximum expansion depth ({_limits.MaxRenderDepth}) exceeded");
+ }
+
+ var result = new List(elements.Count);
+
+ foreach (var element in elements)
+ {
+ var expanded = await ExpandElementAsync(element, context, depth, template, parentWidth).ConfigureAwait(false);
+ result.AddRange(expanded);
+ }
+
+ return result;
+ }
+
+ private async ValueTask> ExpandElementAsync(TemplateElement element, TemplateContext context, int depth, Template template, int? parentWidth = null)
+ {
+ return element switch
+ {
+ EachElement each => await ExpandEachAsync(each, context, depth, template, parentWidth).ConfigureAwait(false),
+ IfElement ifEl => await ExpandIfAsync(ifEl, context, depth, template, parentWidth).ConfigureAwait(false),
+ TableElement table => [ExpandTable(table, context, depth, template)],
+ FlexElement flex => [await ExpandFlexAsync(flex, context, depth, template).ConfigureAwait(false)],
+ ContentElement content => await ExpandContentAsync(content, context, depth, template, parentWidth).ConfigureAwait(false),
+ _ => [CloneWithVariableSubstitution(element, context)]
+ };
+ }
+
+ private async Task> ExpandEachAsync(EachElement each, TemplateContext context, int depth, Template template, int? parentWidth = null)
+ {
+ // Check depth limit - Each element increases nesting depth
+ var childDepth = depth + 1;
+ if (childDepth > _limits.MaxRenderDepth)
+ {
+ throw new TemplateEngineException($"Maximum expansion depth ({_limits.MaxRenderDepth}) exceeded");
+ }
+
+ // Pre-validate nested control flow depth before checking data
+ ValidateNestedDepth(each.ItemTemplate, childDepth);
+
+ var arrayValue = ExpressionEvaluator.Resolve(each.ArrayPath, context);
+ var result = new List();
+
+ if (arrayValue is ArrayValue array)
+ {
+ var count = array.Count;
+ for (var i = 0; i < count; i++)
+ {
+ var item = array[i];
+
+ if (each.ItemVariable != null)
+ {
+ var scopeData = new ObjectValue
+ {
+ [each.ItemVariable] = item
+ };
+ context.PushScope(scopeData);
+ }
+ else
+ {
+ context.PushScope(item);
+ }
+
+ context.SetLoopVariables(i, count);
+
+ var expandedChildren = await ExpandElementsAsync(each.ItemTemplate, context, childDepth, template, parentWidth).ConfigureAwait(false);
+
+ context.ClearLoopVariables();
+ context.PopScope();
+
+ result.AddRange(expandedChildren);
+ }
+ }
+ else if (arrayValue is ObjectValue obj)
+ {
+ var keys = obj.Keys.ToList();
+ var count = keys.Count;
+ for (var i = 0; i < count; i++)
+ {
+ var key = keys[i];
+ var value = obj[key];
+
+ if (each.ItemVariable != null)
+ {
+ var scopeData = new ObjectValue
+ {
+ [each.ItemVariable] = value
+ };
+ context.PushScope(scopeData);
+ }
+ else
+ {
+ context.PushScope(value);
+ }
+
+ context.SetLoopVariables(i, count);
+ context.SetLoopKey(key);
+
+ var expandedChildren = await ExpandElementsAsync(each.ItemTemplate, context, childDepth, template, parentWidth).ConfigureAwait(false);
+
+ context.ClearLoopVariables();
+ context.PopScope();
+
+ result.AddRange(expandedChildren);
+ }
+ }
+
+ return result;
+ }
+
+ private async Task> ExpandIfAsync(IfElement ifEl, TemplateContext context, int depth, Template template, int? parentWidth = null)
+ {
+ var childDepth = depth + 1;
+ if (childDepth > _limits.MaxRenderDepth)
+ {
+ throw new TemplateEngineException($"Maximum expansion depth ({_limits.MaxRenderDepth}) exceeded");
+ }
+
+ ValidateNestedDepth(ifEl.ThenBranch, childDepth);
+ ValidateNestedDepth(ifEl.ElseBranch, childDepth);
+ if (ifEl.ElseIf != null)
+ {
+ ValidateNestedDepth(new[] { ifEl.ElseIf }, depth);
+ }
+
+ if (EvaluateCondition(ifEl, context))
+ {
+ return await ExpandElementsAsync(ifEl.ThenBranch, context, childDepth, template, parentWidth).ConfigureAwait(false);
+ }
+
+ if (ifEl.ElseIf != null)
+ {
+ return await ExpandIfAsync(ifEl.ElseIf, context, depth + 1, template, parentWidth).ConfigureAwait(false);
+ }
+
+ return await ExpandElementsAsync(ifEl.ElseBranch, context, childDepth, template, parentWidth).ConfigureAwait(false);
+ }
+
+ private async Task ExpandFlexAsync(FlexElement flex, TemplateContext context, int depth, Template template)
+ {
+ var childParentWidth = TryParsePixelWidth(flex.Width.Value);
+ var expandedChildren = await ExpandElementsAsync(flex.Children, context, depth + 1, template, childParentWidth).ConfigureAwait(false);
+
+ var clone = new FlexElement
+ {
+ Direction = flex.Direction,
+ Wrap = flex.Wrap,
+ Gap = flex.Gap,
+ Justify = flex.Justify,
+ Align = flex.Align,
+ AlignContent = flex.AlignContent,
+ Overflow = flex.Overflow,
+ RowGap = flex.RowGap,
+ ColumnGap = flex.ColumnGap,
+ FontSize = flex.FontSize,
+ Rotate = flex.Rotate,
+ Background = SubstituteVariables(flex.Background.Value, context),
+ Padding = flex.Padding,
+ Margin = flex.Margin,
+ Children = expandedChildren
+ };
+
+ TemplateElement.CopyBaseProperties(flex, clone);
+ return clone;
+ }
+
+ private async Task> ExpandContentAsync(ContentElement content, TemplateContext context, int depth, Template template, int? parentWidth = null)
{
var childDepth = depth + 1;
if (childDepth > _limits.MaxRenderDepth)
@@ -765,7 +1008,6 @@ private IEnumerable ExpandContent(ContentElement content, Templ
throw new TemplateEngineException($"Maximum expansion depth ({_limits.MaxRenderDepth}) exceeded");
}
- var resolvedSource = SubstituteVariables(content.Source.RawValue ?? content.Source.Value, context);
var resolvedFormat = SubstituteVariables(content.Format.RawValue ?? content.Format.Value, context);
if (_contentParserRegistry is null)
@@ -775,12 +1017,41 @@ private IEnumerable ExpandContent(ContentElement content, Templ
"Register a parser via FlexRenderBuilder.WithContentParser().");
}
- var parser = _contentParserRegistry.GetParser(resolvedFormat)
+ var parserContext = new ContentParserContext
+ {
+ Canvas = template.Canvas,
+ Template = template,
+ ParentWidth = parentWidth
+ };
+
+ var resolved = await ContentSourceResolver.ResolveAsync(content.Source, context, loaders: _resourceLoaders, SubstituteVariables).ConfigureAwait(false);
+
+ return resolved switch
+ {
+ BinaryContent binary => DispatchBinary(binary, resolvedFormat, parserContext, content.Options),
+ TextContent text => DispatchText(text, resolvedFormat, parserContext, content.Options),
+ _ => throw new TemplateEngineException($"Unexpected content source type: {resolved.GetType().Name}")
+ };
+ }
+
+ private IReadOnlyList DispatchBinary(BinaryContent binary, string format, ContentParserContext context, IReadOnlyDictionary? options)
+ {
+ var parser = _contentParserRegistry!.GetBinaryParser(format)
+ ?? throw new TemplateEngineException(
+ $"No binary content parser registered for format '{format}'. " +
+ "Register a binary parser via FlexRenderBuilder.WithBinaryContentParser().");
+
+ return parser.Parse(binary.Data, context, options);
+ }
+
+ private IReadOnlyList DispatchText(TextContent text, string format, ContentParserContext context, IReadOnlyDictionary? options)
+ {
+ var parser = _contentParserRegistry!.GetParser(format)
?? throw new TemplateEngineException(
- $"No content parser registered for format '{resolvedFormat}'. " +
+ $"No content parser registered for format '{format}'. " +
"Register a parser via FlexRenderBuilder.WithContentParser().");
- return parser.Parse(resolvedSource ?? string.Empty);
+ return parser.Parse(text.Text, context, options);
}
///
@@ -876,10 +1147,43 @@ private static string ValueToString(TemplateValue value)
StringValue s => s.Value,
NumberValue n => n.Value.ToString("G", CultureInfo.InvariantCulture),
BoolValue b => b.Value ? "true" : "false",
+ BytesValue bv => $"bytes[{bv.Memory.Length}]",
_ => string.Empty
};
}
+ ///
+ /// If the source expression is a single variable that resolves to a ,
+ /// converts the binary data to a data: URI with base64 encoding.
+ /// Returns null when the expression is not a single variable or does not resolve to bytes.
+ ///
+ /// The expression value holding the image source.
+ /// The template context for variable resolution.
+ /// A data: URI string, or null if not applicable.
+ private static string? TryResolveBytesAsDataUri(ExprValue source, TemplateContext context)
+ {
+ var raw = source.RawValue ?? source.Value;
+ if (raw is null)
+ return null;
+
+ // Only handle simple {{variable}} expressions, not mixed content
+ if (!raw.StartsWith("{{", StringComparison.Ordinal) || !raw.EndsWith("}}", StringComparison.Ordinal))
+ return null;
+
+ var inner = raw[2..^2].Trim();
+
+ // Reject if there are nested expressions (e.g. "{{a}} + {{b}}")
+ if (inner.Contains("{{", StringComparison.Ordinal))
+ return null;
+
+ var resolved = ExpressionEvaluator.Resolve(inner, context);
+ if (resolved is not BytesValue bytes)
+ return null;
+
+ var mime = bytes.MimeType ?? "application/octet-stream";
+ return $"data:{mime};base64,{Convert.ToBase64String(bytes.Value)}";
+ }
+
private TextElement CloneTextElement(TextElement text, TemplateContext context)
{
var clone = new TextElement
@@ -907,9 +1211,12 @@ private TextElement CloneTextElement(TextElement text, TemplateContext context)
private ImageElement CloneImageElement(ImageElement image, TemplateContext context)
{
+ var resolvedSrc = TryResolveBytesAsDataUri(image.Src, context)
+ ?? SubstituteVariables(image.Src.Value, context);
+
var clone = new ImageElement
{
- Src = SubstituteVariables(image.Src.Value, context),
+ Src = resolvedSrc,
ImageWidth = image.ImageWidth,
ImageHeight = image.ImageHeight,
Fit = image.Fit,
diff --git a/src/FlexRender.Core/TemplateEngine/TemplatePipeline.cs b/src/FlexRender.Core/TemplateEngine/TemplatePipeline.cs
index 4c2ed32..3bbd1d2 100644
--- a/src/FlexRender.Core/TemplateEngine/TemplatePipeline.cs
+++ b/src/FlexRender.Core/TemplateEngine/TemplatePipeline.cs
@@ -49,6 +49,31 @@ public Template Process(Template template, ObjectValue data)
return expanded;
}
+ ///
+ /// Asynchronously processes a template through the full pipeline: Expand, Resolve, Materialize.
+ /// Uses await for the expansion phase to support async content source resolution.
+ ///
+ /// The parsed template to process.
+ /// The data context for expression evaluation.
+ /// The expanded and resolved template with all expressions materialized.
+ /// Thrown when or is null.
+ public async Task ProcessAsync(Template template, ObjectValue data)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+ ArgumentNullException.ThrowIfNull(data);
+
+ // Phase 1: Expand control flow (#if, #each, table) — async for content loading
+ var expanded = await _expander.ExpandAsync(template, data).ConfigureAwait(false);
+
+ // Phase 2: Resolve expressions in all ExprValue properties
+ ResolveAll(expanded, data);
+
+ // Phase 3: Materialize resolved strings into typed values
+ MaterializeAll(expanded);
+
+ return expanded;
+ }
+
///
/// Resolves template expressions in all properties across the template.
///
diff --git a/src/FlexRender.Core/Values/BytesValue.cs b/src/FlexRender.Core/Values/BytesValue.cs
new file mode 100644
index 0000000..6054599
--- /dev/null
+++ b/src/FlexRender.Core/Values/BytesValue.cs
@@ -0,0 +1,112 @@
+namespace FlexRender;
+
+///
+/// Represents a binary byte array value in template data.
+/// Backed by for efficient access.
+///
+public sealed class BytesValue : TemplateValue
+{
+ ///
+ /// Gets the binary data as .
+ ///
+ public ReadOnlyMemory Memory { get; }
+
+ ///
+ /// Gets the optional MIME type of the binary data (e.g., "image/png", "application/octet-stream").
+ ///
+ public string? MimeType { get; }
+
+ ///
+ /// Gets the byte array value. Creates a copy from .
+ ///
+ public byte[] Value => Memory.ToArray();
+
+ ///
+ /// Initializes a new instance from a byte array.
+ ///
+ /// The byte array value. Cannot be null.
+ /// Optional MIME type of the data.
+ /// Thrown when is null.
+ public BytesValue(byte[] value, string? mimeType = null)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ Memory = value;
+ MimeType = mimeType;
+ }
+
+ ///
+ /// Initializes a new instance from a .
+ ///
+ /// The memory containing binary data.
+ /// Optional MIME type of the data.
+ public BytesValue(ReadOnlyMemory memory, string? mimeType = null)
+ {
+ Memory = memory;
+ MimeType = mimeType;
+ }
+
+ ///
+ /// Creates a by reading the entire stream into memory.
+ ///
+ /// The stream to read. Cannot be null.
+ /// Optional MIME type of the data.
+ /// Maximum allowed size in bytes. Default: 10 MB.
+ /// A new containing the stream data.
+ /// Thrown when is null.
+ /// Thrown when the stream exceeds .
+ public static BytesValue FromStream(Stream stream, string? mimeType = null, int maxSize = 10 * 1024 * 1024)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+
+ if (stream.CanSeek && stream.Length > maxSize)
+ {
+ throw new InvalidOperationException(
+ $"Content source stream size ({stream.Length} bytes) exceeds the maximum allowed size ({maxSize} bytes).");
+ }
+
+ using var ms = new MemoryStream();
+ var buffer = new byte[8192];
+ int totalRead = 0;
+ int bytesRead;
+ while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ totalRead += bytesRead;
+ if (totalRead > maxSize)
+ {
+ throw new InvalidOperationException(
+ $"Content source stream exceeds the maximum allowed size ({maxSize} bytes).");
+ }
+ ms.Write(buffer, 0, bytesRead);
+ }
+ return new BytesValue(ms.ToArray(), mimeType);
+ }
+
+ ///
+ /// Returns the data as a for synchronous hot-path access.
+ ///
+ public ReadOnlySpan AsSpan() => Memory.Span;
+
+ ///
+ /// Creates a new read-only over the data.
+ /// The caller owns the returned stream and must dispose it.
+ /// Each call returns an independent stream at position 0.
+ ///
+ public Stream AsStream() => new MemoryStream(Memory.ToArray(), writable: false);
+
+ ///
+ public override bool Equals(TemplateValue? other)
+ {
+ return other is BytesValue bytesValue && Memory.Span.SequenceEqual(bytesValue.Memory.Span);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ hash.AddBytes(Memory.Span);
+ return hash.ToHashCode();
+ }
+
+ ///
+ public override string ToString() => $"bytes[{Memory.Length}]";
+}
diff --git a/src/FlexRender.Core/Values/TemplateValue.cs b/src/FlexRender.Core/Values/TemplateValue.cs
index e5ce9ed..6b4dd35 100644
--- a/src/FlexRender.Core/Values/TemplateValue.cs
+++ b/src/FlexRender.Core/Values/TemplateValue.cs
@@ -41,6 +41,12 @@ public abstract class TemplateValue : IEquatable
/// The boolean value to convert.
public static implicit operator TemplateValue(bool value) => new BoolValue(value);
+ ///
+ /// Implicitly converts a byte array to a .
+ ///
+ /// The byte array value to convert.
+ public static implicit operator TemplateValue(byte[] value) => new BytesValue(value);
+
///
/// Determines whether this value equals another .
///
diff --git a/src/FlexRender.ImageSharp.Render/ImageSharpRender.cs b/src/FlexRender.ImageSharp.Render/ImageSharpRender.cs
index a48f32b..382c9fb 100644
--- a/src/FlexRender.ImageSharp.Render/ImageSharpRender.cs
+++ b/src/FlexRender.ImageSharp.Render/ImageSharpRender.cs
@@ -86,7 +86,8 @@ internal ImageSharpRender(
options.BaseFontSize,
builder.QrProvider,
builder.BarcodeProvider,
- options);
+ options,
+ resourceLoaders);
}
// ========================================================================
@@ -381,14 +382,14 @@ public async Task RenderToRaw(
// Expand, resolve, and materialize template to resolve expressions in image src attributes
var expander = _filterRegistry is not null
- ? new TemplateExpander(_limits, _filterRegistry, _contentParserRegistry)
- : new TemplateExpander(_limits, _contentParserRegistry);
+ ? new TemplateExpander(_limits, _filterRegistry, _contentParserRegistry, _resourceLoaders)
+ : new TemplateExpander(_limits, _contentParserRegistry, _resourceLoaders);
var templateProcessor = _filterRegistry is not null
? new TemplateProcessor(_limits, _filterRegistry)
: new TemplateProcessor(_limits);
var pipeline = new TemplatePipeline(expander, templateProcessor);
- var processedTemplate = pipeline.Process(template, data);
+ var processedTemplate = await pipeline.ProcessAsync(template, data).ConfigureAwait(false);
var uris = ImageSharpRenderingEngine.CollectImageUris(processedTemplate);
if (uris.Count == 0)
diff --git a/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs b/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs
index e1042ab..01851ba 100644
--- a/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs
+++ b/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs
@@ -1,3 +1,4 @@
+using FlexRender.Abstractions;
using FlexRender.Configuration;
using FlexRender.ImageSharp.Providers;
using FlexRender.Layout;
@@ -28,6 +29,7 @@ internal sealed class ImageSharpRenderingEngine
private readonly float _baseFontSize;
private readonly IImageSharpContentProvider? _qrProvider;
private readonly IImageSharpContentProvider? _barcodeProvider;
+ private readonly IReadOnlyList? _resourceLoaders;
///
/// Initializes a new instance of the rendering engine.
@@ -39,6 +41,7 @@ internal sealed class ImageSharpRenderingEngine
/// Optional QR code provider for ImageSharp rendering.
/// Optional barcode provider for ImageSharp rendering.
/// Optional rendering configuration options for path resolution.
+ /// Optional resource loaders for resolving file-based content sources.
internal ImageSharpRenderingEngine(
ImageSharpTextRenderer textRenderer,
ImageSharpFontManager fontManager,
@@ -46,7 +49,8 @@ internal ImageSharpRenderingEngine(
float baseFontSize,
IImageSharpContentProvider? qrProvider = null,
IImageSharpContentProvider? barcodeProvider = null,
- FlexRenderOptions? options = null)
+ FlexRenderOptions? options = null,
+ IReadOnlyList? resourceLoaders = null)
{
ArgumentNullException.ThrowIfNull(textRenderer);
ArgumentNullException.ThrowIfNull(fontManager);
@@ -59,6 +63,7 @@ internal ImageSharpRenderingEngine(
_baseFontSize = baseFontSize;
_qrProvider = qrProvider;
_barcodeProvider = barcodeProvider;
+ _resourceLoaders = resourceLoaders;
}
///
@@ -99,8 +104,8 @@ internal Image RenderToImage(
{
// Expand, resolve, and materialize template via the Core pipeline
var expander = filterRegistry is not null
- ? new TemplateExpander(_limits, filterRegistry, contentParserRegistry)
- : new TemplateExpander(_limits, contentParserRegistry);
+ ? new TemplateExpander(_limits, filterRegistry, contentParserRegistry, _resourceLoaders)
+ : new TemplateExpander(_limits, contentParserRegistry, _resourceLoaders);
var templateProcessor = filterRegistry is not null
? new TemplateProcessor(_limits, filterRegistry)
: new TemplateProcessor(_limits);
@@ -203,7 +208,7 @@ private void RenderNode(
}
// Draw element-specific content
- DrawElement(ctx, node.Element, x, y, node.Width, node.Height, imageCache);
+ DrawElement(ctx, node, x, y, node.Width, node.Height, imageCache);
// Recursively render children
foreach (var child in node.Children)
@@ -214,25 +219,27 @@ private void RenderNode(
private void DrawElement(
IImageProcessingContext ctx,
- TemplateElement element,
+ LayoutNode node,
float x,
float y,
float width,
float height,
IReadOnlyDictionary>? imageCache)
{
+ var element = node.Element;
+ var effectiveFontSize = node.ComputedFontSize > 0 ? node.ComputedFontSize : _baseFontSize;
var rotation = RotationHelper.ParseRotation(element.Rotate.Value);
if (RotationHelper.HasRotation(rotation))
{
DrawWithRotation(ctx, x, y, width, height, rotation, bufferCtx =>
{
- DrawElementContent(bufferCtx, element, 0, 0, width, height, imageCache);
+ DrawElementContent(bufferCtx, element, 0, 0, width, height, effectiveFontSize, imageCache);
});
}
else
{
- DrawElementContent(ctx, element, x, y, width, height, imageCache);
+ DrawElementContent(ctx, element, x, y, width, height, effectiveFontSize, imageCache);
}
}
@@ -245,6 +252,7 @@ private void DrawElement(
/// Y position of the element.
/// Width of the element.
/// Height of the element.
+ /// The effective font size in pixels for this element.
/// Optional pre-loaded image cache.
private void DrawElementContent(
IImageProcessingContext ctx,
@@ -253,12 +261,13 @@ private void DrawElementContent(
float y,
float width,
float height,
+ float fontSize,
IReadOnlyDictionary>? imageCache)
{
switch (element)
{
case TextElement text:
- _textRenderer.DrawText(ctx, text, x, y, width, height, _baseFontSize);
+ _textRenderer.DrawText(ctx, text, x, y, width, height, fontSize);
break;
case SeparatorElement separator:
diff --git a/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpTextShaper.cs b/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpTextShaper.cs
index 6e89af2..6ecec12 100644
--- a/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpTextShaper.cs
+++ b/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpTextShaper.cs
@@ -37,6 +37,7 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
return new TextShapingResult(
Array.Empty(),
new LayoutSize(0f, 0f),
+ 0f,
0f);
}
@@ -53,6 +54,7 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
return new TextShapingResult(
Array.Empty(),
new LayoutSize(0f, 0f),
+ 0f,
0f);
}
@@ -67,11 +69,13 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
maxLineWidth = MathF.Ceiling(maxLineWidth);
var lineHeight = ResolveLineHeight(element.LineHeight.Value, font);
var totalHeight = lines.Count * lineHeight;
+ var baseline = font.Size * 0.85f;
return new TextShapingResult(
lines,
new LayoutSize(maxLineWidth, totalHeight),
- lineHeight);
+ lineHeight,
+ baseline);
}
private Font CreateFont(TextElement element, float fontSize)
diff --git a/src/FlexRender.Skia.Render/Abstractions/IImageLoader.cs b/src/FlexRender.Skia.Render/Abstractions/IImageLoader.cs
index 73bccd9..cc3bc09 100644
--- a/src/FlexRender.Skia.Render/Abstractions/IImageLoader.cs
+++ b/src/FlexRender.Skia.Render/Abstractions/IImageLoader.cs
@@ -19,4 +19,16 @@ public interface IImageLoader
/// Loaded bitmap or null if the image cannot be loaded.
/// Thrown when is null.
Task Load(string uri, CancellationToken cancellationToken = default);
+
+ ///
+ /// Preloads an image from a stream and returns it for caching under the specified key.
+ /// Used for injecting in-memory image data (e.g., from )
+ /// into the image cache so that rendering can find it by key.
+ ///
+ /// The cache key (e.g., "var://variableName").
+ /// The image data stream.
+ /// Cancellation token for async operation.
+ /// The loaded bitmap, or null if the stream cannot be decoded.
+ /// Thrown when or is null.
+ Task Preload(string key, Stream stream, CancellationToken cancellationToken = default);
}
diff --git a/src/FlexRender.Skia.Render/Loaders/ImageLoader.cs b/src/FlexRender.Skia.Render/Loaders/ImageLoader.cs
index f690032..d4eee16 100644
--- a/src/FlexRender.Skia.Render/Loaders/ImageLoader.cs
+++ b/src/FlexRender.Skia.Render/Loaders/ImageLoader.cs
@@ -74,6 +74,25 @@ public ImageLoader(IEnumerable loaders, FlexRenderOptions optio
return null;
}
+ ///
+ public Task Preload(string key, Stream stream, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+ ArgumentNullException.ThrowIfNull(stream);
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ValidateStreamSize(stream, key);
+
+ var bitmap = SKBitmap.Decode(stream);
+ if (bitmap is null)
+ {
+ throw new InvalidOperationException($"Failed to decode image from preloaded data: {key}");
+ }
+
+ return Task.FromResult(bitmap);
+ }
+
///
/// Validates that the stream size does not exceed the maximum allowed image size.
///
diff --git a/src/FlexRender.Skia.Render/Rendering/FontManager.cs b/src/FlexRender.Skia.Render/Rendering/FontManager.cs
index 6bb62c4..4cc3c00 100644
--- a/src/FlexRender.Skia.Render/Rendering/FontManager.cs
+++ b/src/FlexRender.Skia.Render/Rendering/FontManager.cs
@@ -382,6 +382,24 @@ public bool RegisterFont(string name, string path, string? fallback = null)
return File.Exists(path);
}
+ ///
+ /// Returns the registered font names and their file paths for diagnostic purposes.
+ ///
+ public IReadOnlyDictionary RegisteredFontPaths =>
+ new Dictionary(_fontPaths, StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Gets the resolved typeface info (family name, fixed-pitch) for a registered font.
+ /// Returns null if the font is not registered or cannot be loaded.
+ ///
+ /// The registered font name.
+ /// Tuple of (FamilyName, IsFixedPitch) or null.
+ public (string FamilyName, bool IsFixedPitch)? GetTypefaceInfo(string fontName)
+ {
+ var typeface = GetTypeface(fontName);
+ return (typeface.FamilyName, typeface.IsFixedPitch);
+ }
+
///
/// Sets the default fallback font family name.
///
diff --git a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs
index ae01b2a..0c96458 100644
--- a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs
+++ b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs
@@ -3,6 +3,7 @@
using FlexRender.Configuration;
using FlexRender.Layout;
using FlexRender.Layout.Units;
+using FlexRender.Loaders;
using FlexRender.Parsing.Ast;
using FlexRender.Providers;
using FlexRender.TemplateEngine;
@@ -28,6 +29,7 @@ internal sealed class RenderingEngine
private readonly float _baseFontSize;
private readonly FilterRegistry? _filterRegistry;
private readonly ContentParserRegistry? _contentParserRegistry;
+ private readonly IReadOnlyList? _resourceLoaders;
private readonly FontManager? _fontManager;
private readonly FlexRenderOptions? _renderingOptions;
@@ -48,6 +50,7 @@ internal sealed class RenderingEngine
/// Optional font manager for creating culture-aware pipelines.
/// Optional rendering options for creating culture-aware pipelines.
/// Optional content parser registry for custom content type parsing.
+ /// Optional resource loaders for resolving file-based content sources.
internal RenderingEngine(
TextRenderer textRenderer,
IContentProvider? qrProvider,
@@ -62,7 +65,8 @@ internal RenderingEngine(
FilterRegistry? filterRegistry = null,
FontManager? fontManager = null,
FlexRenderOptions? renderingOptions = null,
- ContentParserRegistry? contentParserRegistry = null)
+ ContentParserRegistry? contentParserRegistry = null,
+ IReadOnlyList? resourceLoaders = null)
{
ArgumentNullException.ThrowIfNull(textRenderer);
ArgumentNullException.ThrowIfNull(pipeline);
@@ -81,6 +85,7 @@ internal RenderingEngine(
_baseFontSize = baseFontSize;
_filterRegistry = filterRegistry;
_contentParserRegistry = contentParserRegistry;
+ _resourceLoaders = resourceLoaders;
_fontManager = fontManager;
_renderingOptions = renderingOptions;
}
@@ -283,8 +288,9 @@ private void DrawElement(
var width = node.Width;
var height = node.Height;
var direction = node.Direction;
+ var effectiveFontSize = node.ComputedFontSize > 0 ? node.ComputedFontSize : _baseFontSize;
// Resolve border-radius for background clipping and border rendering
- var borderRadius = ResolveBorderRadius(element, width, height, _baseFontSize);
+ var borderRadius = ResolveBorderRadius(element, width, height, effectiveFontSize);
// Draw background if specified (supports solid colors and gradients)
if (!string.IsNullOrEmpty(element.Background.Value))
@@ -293,13 +299,13 @@ private void DrawElement(
}
// Draw borders between background and content
- DrawBorders(canvas, element, x, y, width, height, borderRadius, _baseFontSize);
+ DrawBorders(canvas, element, x, y, width, height, borderRadius, effectiveFontSize);
switch (element)
{
case TextElement text:
var bounds = new SKRect(x, y, x + width, y + height);
- _textRenderer.DrawText(canvas, text, bounds, _baseFontSize, renderOptions, direction, node.TextLines, node.ComputedLineHeight);
+ _textRenderer.DrawText(canvas, text, bounds, effectiveFontSize, renderOptions, direction, node.TextLines, node.ComputedLineHeight);
break;
case QrElement qr when _qrProvider is not null:
@@ -649,7 +655,7 @@ internal async Task> PreloadImagesAsync(
ObjectValue data,
CancellationToken cancellationToken)
{
- var processedTemplate = _pipeline.Process(template, data);
+ var processedTemplate = await _pipeline.ProcessAsync(template, data).ConfigureAwait(false);
_preprocessor.RegisterFonts(processedTemplate);
var uris = CollectImageUris(processedTemplate);
var cache = new Dictionary(uris.Count, StringComparer.Ordinal);
@@ -667,6 +673,47 @@ internal async Task> PreloadImagesAsync(
return cache;
}
+ ///
+ /// Sets the SVG content cache on the SVG provider if it implements .
+ ///
+ /// The cache to set, or null to clear.
+ internal void SetSvgContentCache(IReadOnlyDictionary? cache)
+ {
+ if (_svgProvider is ISvgContentCacheAware cacheAware)
+ {
+ cacheAware.SetSvgContentCache(cache);
+ }
+ }
+
+ ///
+ /// Pre-loads all SVG content from the template asynchronously using the resource loaders.
+ ///
+ /// The template containing SVG element references.
+ /// The data context for expression evaluation.
+ /// Cancellation token.
+ /// A dictionary mapping SVG source URIs to loaded and sanitized SVG content.
+ internal async Task> PreloadSvgContentAsync(
+ Template template,
+ ObjectValue data,
+ CancellationToken cancellationToken)
+ {
+ var processedTemplate = await _pipeline.ProcessAsync(template, data).ConfigureAwait(false);
+ var uris = SvgContentLoader.CollectSvgUris(processedTemplate);
+ var cache = new Dictionary(uris.Count, StringComparer.Ordinal);
+
+ foreach (var uri in uris)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var content = await SvgContentLoader.LoadFromLoaders(_resourceLoaders, uri).ConfigureAwait(false);
+ if (content is not null)
+ {
+ cache[uri] = content;
+ }
+ }
+
+ return cache;
+ }
+
///
/// Resolves the border-radius for an element, clamped to half the smaller dimension.
///
@@ -867,7 +914,7 @@ private TemplatePipeline ResolvePipeline(
return _pipeline;
}
- var expander = new TemplateExpander(_limits, _filterRegistry, effectiveCulture, _contentParserRegistry);
+ var expander = new TemplateExpander(_limits, _filterRegistry, effectiveCulture, _contentParserRegistry, _resourceLoaders);
var processor = new TemplateProcessor(_limits, _filterRegistry, effectiveCulture);
return new TemplatePipeline(expander, processor);
diff --git a/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs b/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs
index 349cc65..ee7b3a9 100644
--- a/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs
+++ b/src/FlexRender.Skia.Render/Rendering/SkiaRenderer.cs
@@ -87,6 +87,7 @@ public SkiaRenderer(
/// Optional SVG content provider for rendering SVG elements.
/// Optional filter registry for expression filter evaluation.
/// Optional content parser registry for ContentElement expansion.
+ /// Optional resource loaders for resolving file-based content sources.
/// Thrown when is null.
public SkiaRenderer(
ResourceLimits limits,
@@ -97,7 +98,8 @@ public SkiaRenderer(
FlexRenderOptions? options = null,
IContentProvider? svgProvider = null,
FilterRegistry? filterRegistry = null,
- ContentParserRegistry? contentParserRegistry = null)
+ ContentParserRegistry? contentParserRegistry = null,
+ IReadOnlyList? resourceLoaders = null)
{
ArgumentNullException.ThrowIfNull(limits);
_limits = limits;
@@ -106,8 +108,8 @@ public SkiaRenderer(
? new TemplateProcessor(limits, filterRegistry)
: new TemplateProcessor(limits);
var expander = filterRegistry is not null
- ? new TemplateExpander(limits, filterRegistry, contentParserRegistry)
- : new TemplateExpander(limits, contentParserRegistry);
+ ? new TemplateExpander(limits, filterRegistry, contentParserRegistry, resourceLoaders)
+ : new TemplateExpander(limits, contentParserRegistry, resourceLoaders);
_fontManager = new FontManager();
_defaultRenderOptions = deterministicRendering ? RenderOptions.Deterministic : RenderOptions.Default;
_textRenderer = new TextRenderer(_fontManager);
@@ -132,7 +134,8 @@ public SkiaRenderer(
filterRegistry,
_fontManager,
options,
- contentParserRegistry);
+ contentParserRegistry,
+ resourceLoaders);
}
///
@@ -276,6 +279,9 @@ public async Task Render(
? await _renderingEngine.PreloadImagesAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false)
: null;
+ var svgContentCache = await _renderingEngine.PreloadSvgContentAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false);
+ _renderingEngine.SetSvgContentCache(svgContentCache);
+
try
{
// Measure returns the final size after rotation
@@ -294,6 +300,7 @@ public async Task Render(
}
finally
{
+ _renderingEngine.SetSvgContentCache(null);
if (imageCache is not null)
{
foreach (var bmp in imageCache.Values)
@@ -329,12 +336,16 @@ public async Task Render(
? await _renderingEngine.PreloadImagesAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false)
: null;
+ var svgContentCache = await _renderingEngine.PreloadSvgContentAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false);
+ _renderingEngine.SetSvgContentCache(svgContentCache);
+
try
{
_renderingEngine.RenderToBitmapCore(bitmap, layoutTemplate, data, offset, imageCache);
}
finally
{
+ _renderingEngine.SetSvgContentCache(null);
if (imageCache is not null)
{
foreach (var bmp in imageCache.Values)
@@ -372,6 +383,9 @@ public async Task RenderToPng(
? await _renderingEngine.PreloadImagesAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false)
: null;
+ var svgContentCache = await _renderingEngine.PreloadSvgContentAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false);
+ _renderingEngine.SetSvgContentCache(svgContentCache);
+
try
{
// Measure returns the final size after rotation
@@ -386,6 +400,7 @@ public async Task RenderToPng(
}
finally
{
+ _renderingEngine.SetSvgContentCache(null);
if (imageCache is not null)
{
foreach (var bmp in imageCache.Values)
@@ -430,6 +445,9 @@ public async Task RenderToJpeg(
? await _renderingEngine.PreloadImagesAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false)
: null;
+ var svgContentCache = await _renderingEngine.PreloadSvgContentAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false);
+ _renderingEngine.SetSvgContentCache(svgContentCache);
+
try
{
// Measure returns the final size after rotation
@@ -444,6 +462,7 @@ public async Task RenderToJpeg(
}
finally
{
+ _renderingEngine.SetSvgContentCache(null);
if (imageCache is not null)
{
foreach (var bmp in imageCache.Values)
@@ -481,6 +500,9 @@ public async Task RenderToBmp(
? await _renderingEngine.PreloadImagesAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false)
: null;
+ var svgContentCache = await _renderingEngine.PreloadSvgContentAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false);
+ _renderingEngine.SetSvgContentCache(svgContentCache);
+
try
{
// Measure returns the final size after rotation
@@ -493,6 +515,7 @@ public async Task RenderToBmp(
}
finally
{
+ _renderingEngine.SetSvgContentCache(null);
if (imageCache is not null)
{
foreach (var bmp in imageCache.Values)
@@ -528,6 +551,9 @@ public async Task RenderToRaw(
? await _renderingEngine.PreloadImagesAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false)
: null;
+ var svgContentCache = await _renderingEngine.PreloadSvgContentAsync(layoutTemplate, data, cancellationToken).ConfigureAwait(false);
+ _renderingEngine.SetSvgContentCache(svgContentCache);
+
try
{
// Measure returns the final size after rotation
@@ -542,6 +568,7 @@ public async Task RenderToRaw(
}
finally
{
+ _renderingEngine.SetSvgContentCache(null);
if (imageCache is not null)
{
foreach (var bmp in imageCache.Values)
diff --git a/src/FlexRender.Skia.Render/Rendering/SkiaTextShaper.cs b/src/FlexRender.Skia.Render/Rendering/SkiaTextShaper.cs
index 1bb833d..0682d3a 100644
--- a/src/FlexRender.Skia.Render/Rendering/SkiaTextShaper.cs
+++ b/src/FlexRender.Skia.Render/Rendering/SkiaTextShaper.cs
@@ -43,6 +43,7 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
return new TextShapingResult(
Array.Empty(),
new LayoutSize(0f, 0f),
+ 0f,
0f);
}
@@ -59,6 +60,7 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
return new TextShapingResult(
Array.Empty(),
new LayoutSize(0f, 0f),
+ 0f,
0f);
}
@@ -73,11 +75,13 @@ public TextShapingResult ShapeText(TextElement element, float fontSize, float ma
var defaultLineHeight = Math.Abs(font.Metrics.Top) + font.Metrics.Bottom;
var lineHeight = LineHeightResolver.Resolve(element.LineHeight.Value, font.Size, defaultLineHeight);
var totalHeight = lines.Count * lineHeight;
+ var baseline = Math.Abs(font.Metrics.Top);
return new TextShapingResult(
lines,
new LayoutSize(maxLineWidth, totalHeight),
- lineHeight);
+ lineHeight,
+ baseline);
}
///
diff --git a/src/FlexRender.Skia.Render/SkiaRender.cs b/src/FlexRender.Skia.Render/SkiaRender.cs
index 2800565..e3ce86e 100644
--- a/src/FlexRender.Skia.Render/SkiaRender.cs
+++ b/src/FlexRender.Skia.Render/SkiaRender.cs
@@ -1,5 +1,7 @@
using FlexRender.Abstractions;
using FlexRender.Configuration;
+using FlexRender.Layout;
+using SkiaSharp;
using FlexRender.Loaders;
using FlexRender.Parsing.Ast;
using FlexRender.Providers;
@@ -50,6 +52,61 @@ public sealed class SkiaRender : IFlexRender
///
/// with instead.
///
+ ///
+ /// Gets the font manager for registering and resolving fonts.
+ /// Intended for diagnostic and debugging tools.
+ ///
+ ///
+ /// The underlying font cache is backed by a ,
+ /// making this property safe to access from multiple threads concurrently.
+ ///
+ public FontManager FontManager => _renderer.FontManager;
+
+ ///
+ /// Computes layout for a template without rendering.
+ /// Intended for diagnostic and debugging tools.
+ ///
+ /// The parsed template.
+ /// The template data.
+ /// The root layout node.
+ ///
+ /// This method is intended for debugging and testing only. For production rendering,
+ /// use
+ /// or one of the format-specific RenderTo* methods instead.
+ ///
+ public LayoutNode ComputeLayout(Template template, ObjectValue data) =>
+ _renderer.ComputeLayout(template, data);
+
+ ///
+ /// Measures the size required to render the template.
+ ///
+ /// The parsed template.
+ /// The template data.
+ /// The measured size in pixels.
+ ///
+ /// This method is intended for debugging and testing only. For production rendering,
+ /// use
+ /// or one of the format-specific RenderTo* methods instead.
+ ///
+ public SKSize Measure(Template template, ObjectValue data) =>
+ _renderer.Measure(template, data);
+
+ ///
+ /// Renders the template to an existing canvas.
+ /// Intended for diagnostic and debugging tools.
+ ///
+ /// The canvas to render to.
+ /// The parsed template.
+ /// The template data.
+ ///
+ /// This method is intended for debugging and testing only. For production rendering,
+ /// use
+ /// or one of the format-specific RenderTo* methods instead.
+ ///
+ public void Render(SKCanvas canvas, Template template, ObjectValue data) =>
+ _renderer.Render(canvas, template, data);
+
+ ///
[Obsolete("Use RenderToBmp() with BmpOptions instead. This property will be removed in a future version.")]
public BmpColorMode BmpColorMode { get; set; } = BmpColorMode.Bgra32;
@@ -107,7 +164,8 @@ internal SkiaRender(
options,
svgProvider,
filterRegistry,
- contentParserRegistry);
+ contentParserRegistry,
+ resourceLoaders);
_renderer.BaseFontSize = options.BaseFontSize;
}
diff --git a/src/FlexRender.Svg.Render/Rendering/SvgRenderingEngine.cs b/src/FlexRender.Svg.Render/Rendering/SvgRenderingEngine.cs
index 93d5c71..3db0775 100644
--- a/src/FlexRender.Svg.Render/Rendering/SvgRenderingEngine.cs
+++ b/src/FlexRender.Svg.Render/Rendering/SvgRenderingEngine.cs
@@ -1,8 +1,10 @@
using System.Globalization;
using System.Text;
+using FlexRender.Abstractions;
using FlexRender.Configuration;
using FlexRender.Layout;
using FlexRender.Layout.Units;
+using FlexRender.Loaders;
using FlexRender.Parsing.Ast;
using FlexRender.Providers;
using FlexRender.Rendering;
@@ -27,6 +29,7 @@ internal sealed class SvgRenderingEngine
private readonly ISvgContentProvider? _qrSvgProvider;
private readonly ISvgContentProvider? _barcodeSvgProvider;
private readonly ISvgContentProvider? _svgElementSvgProvider;
+ private readonly IReadOnlyList? _resourceLoaders;
private readonly FlexRenderOptions? _options;
///
@@ -42,6 +45,7 @@ internal sealed class SvgRenderingEngine
/// Optional SVG-native QR code provider for vector QR code embedding.
/// Optional SVG-native barcode provider for vector barcode embedding.
/// Optional SVG-native SVG element provider.
+ /// Optional resource loaders for pre-loading SVG content.
internal SvgRenderingEngine(
ResourceLimits limits,
TemplatePipeline pipeline,
@@ -52,7 +56,8 @@ internal SvgRenderingEngine(
IContentProvider? barcodeProvider = null,
ISvgContentProvider? qrSvgProvider = null,
ISvgContentProvider? barcodeSvgProvider = null,
- ISvgContentProvider? svgElementSvgProvider = null)
+ ISvgContentProvider? svgElementSvgProvider = null,
+ IReadOnlyList? resourceLoaders = null)
{
ArgumentNullException.ThrowIfNull(limits);
ArgumentNullException.ThrowIfNull(pipeline);
@@ -67,6 +72,59 @@ internal SvgRenderingEngine(
_qrSvgProvider = qrSvgProvider;
_barcodeSvgProvider = barcodeSvgProvider;
_svgElementSvgProvider = svgElementSvgProvider;
+ _resourceLoaders = resourceLoaders;
+ }
+
+ ///
+ /// Renders a template with data to SVG markup asynchronously,
+ /// pre-loading SVG content from resource loaders during the async phase.
+ ///
+ /// The template to render.
+ /// The data for variable substitution.
+ /// Cancellation token.
+ /// The SVG markup as a string.
+ internal async Task RenderToSvgAsync(Template template, ObjectValue data, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(template);
+ ArgumentNullException.ThrowIfNull(data);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Pre-load SVG content from resource loaders during async phase
+ var processedTemplate = await _pipeline.ProcessAsync(template, data).ConfigureAwait(false);
+ var svgUris = SvgContentLoader.CollectSvgUris(processedTemplate);
+ Dictionary? svgContentCache = null;
+
+ if (svgUris.Count > 0 && _resourceLoaders is not null && _resourceLoaders.Count > 0)
+ {
+ svgContentCache = new Dictionary(svgUris.Count, StringComparer.Ordinal);
+ foreach (var uri in svgUris)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var content = await SvgContentLoader.LoadFromLoaders(_resourceLoaders, uri).ConfigureAwait(false);
+ if (content is not null)
+ {
+ svgContentCache[uri] = content;
+ }
+ }
+ }
+
+ // Set cache on provider, render sync, then clear
+ if (_svgElementSvgProvider is ISvgContentCacheAware cacheAware)
+ {
+ cacheAware.SetSvgContentCache(svgContentCache);
+ }
+
+ try
+ {
+ return RenderToSvg(template, data);
+ }
+ finally
+ {
+ if (_svgElementSvgProvider is ISvgContentCacheAware cacheAwareCleanup)
+ {
+ cacheAwareCleanup.SetSvgContentCache(null);
+ }
+ }
}
///
diff --git a/src/FlexRender.Svg.Render/SvgBuilderExtensions.cs b/src/FlexRender.Svg.Render/SvgBuilderExtensions.cs
index c1648e4..5f2fdd3 100644
--- a/src/FlexRender.Svg.Render/SvgBuilderExtensions.cs
+++ b/src/FlexRender.Svg.Render/SvgBuilderExtensions.cs
@@ -91,7 +91,8 @@ public static FlexRenderBuilder WithSvg(
svgBuilder.QrSvgProvider,
svgBuilder.BarcodeSvgProvider,
svgBuilder.SvgElementSvgProvider,
- b.ContentParserRegistry);
+ b.ContentParserRegistry,
+ b.ResourceLoaders);
});
return builder;
diff --git a/src/FlexRender.Svg.Render/SvgRender.cs b/src/FlexRender.Svg.Render/SvgRender.cs
index 8e821f3..d372938 100644
--- a/src/FlexRender.Svg.Render/SvgRender.cs
+++ b/src/FlexRender.Svg.Render/SvgRender.cs
@@ -45,6 +45,7 @@ public sealed class SvgRender : IFlexRender
/// Optional SVG-native barcode provider for vector barcode embedding.
/// Optional SVG-native SVG element provider.
/// Optional content parser registry for custom content type parsing.
+ /// Optional resource loaders for resolving file-based content sources.
internal SvgRender(
ResourceLimits limits,
FlexRenderOptions options,
@@ -54,7 +55,8 @@ internal SvgRender(
ISvgContentProvider? qrSvgProvider = null,
ISvgContentProvider? barcodeSvgProvider = null,
ISvgContentProvider? svgElementSvgProvider = null,
- ContentParserRegistry? contentParserRegistry = null)
+ ContentParserRegistry? contentParserRegistry = null,
+ IReadOnlyList? resourceLoaders = null)
{
ArgumentNullException.ThrowIfNull(limits);
ArgumentNullException.ThrowIfNull(options);
@@ -62,7 +64,7 @@ internal SvgRender(
_rasterRenderer = rasterRenderer;
var templateProcessor = new TemplateProcessor(limits);
- var expander = new TemplateExpander(limits, contentParserRegistry);
+ var expander = new TemplateExpander(limits, contentParserRegistry, resourceLoaders);
var pipeline = new TemplatePipeline(expander, templateProcessor);
var layoutEngine = new LayoutEngine(limits);
layoutEngine.TextShaper = new ApproximateTextShaper();
@@ -78,7 +80,8 @@ internal SvgRender(
barcodeProvider,
qrSvgProvider,
barcodeSvgProvider,
- svgElementSvgProvider);
+ svgElementSvgProvider,
+ resourceLoaders);
}
// ========================================================================
@@ -95,7 +98,7 @@ internal SvgRender(
/// The SVG markup as a string.
/// Thrown when the renderer has been disposed.
/// Thrown when is null.
- public Task RenderToSvg(
+ public async Task RenderToSvg(
Template layoutTemplate,
ObjectValue? data = null,
RenderOptions? renderOptions = null,
@@ -107,9 +110,7 @@ public Task RenderToSvg(
cancellationToken.ThrowIfCancellationRequested();
var effectiveData = data ?? new ObjectValue();
- var svg = _svgEngine.RenderToSvg(layoutTemplate, effectiveData);
-
- return Task.FromResult(svg);
+ return await _svgEngine.RenderToSvgAsync(layoutTemplate, effectiveData, cancellationToken).ConfigureAwait(false);
}
///
diff --git a/src/FlexRender.SvgElement.Skia.Render/Providers/SvgElementProvider.cs b/src/FlexRender.SvgElement.Skia.Render/Providers/SvgElementProvider.cs
index 64a6d98..0f415fd 100644
--- a/src/FlexRender.SvgElement.Skia.Render/Providers/SvgElementProvider.cs
+++ b/src/FlexRender.SvgElement.Skia.Render/Providers/SvgElementProvider.cs
@@ -31,7 +31,7 @@ namespace FlexRender.SvgElement.Providers;
/// or the SVG's intrinsic dimensions if not specified.
///
///
-public sealed class SvgElementProvider : IContentProvider, IResourceLoaderAware, ISkiaNativeProvider
+public sealed class SvgElementProvider : IContentProvider, IResourceLoaderAware, ISvgContentCacheAware, ISkiaNativeProvider
{
///
/// Default rendering size when neither the element nor the SVG specifies dimensions.
@@ -49,6 +49,12 @@ public sealed class SvgElementProvider : IContentProvider
private IReadOnlyList? _loaders;
+ ///
+ /// Pre-loaded SVG content cache populated during the async phase.
+ /// Maps source URIs to sanitized SVG content strings.
+ ///
+ private IReadOnlyDictionary? _svgContentCache;
+
///
/// Sets the resource loaders for loading SVG content from URIs.
///
@@ -60,6 +66,12 @@ public void SetResourceLoaders(IReadOnlyList loaders)
_loaders = loaders;
}
+ ///
+ public void SetSvgContentCache(IReadOnlyDictionary? cache)
+ {
+ _svgContentCache = cache;
+ }
+
///
/// Generates a PNG-encoded bitmap from the specified SVG element at the given dimensions.
///
@@ -131,15 +143,21 @@ private SKBitmap GenerateBitmap(Parsing.Ast.SvgElement element, int width, int h
}
else
{
- // Try resource loaders first (supports HTTP, base64, embedded)
- var svgContent = SvgContentLoader.LoadFromLoaders(_loaders, element.Src.Value!);
+ // Try SVG content cache first (pre-loaded during async phase)
+ string? svgContent = null;
+ if (_svgContentCache is not null &&
+ _svgContentCache.TryGetValue(element.Src.Value!, out var cached))
+ {
+ svgContent = cached;
+ }
+
if (svgContent is not null)
{
picture = svg.FromSvg(svgContent);
}
else
{
- // Fallback to direct file loading
+ // Fallback to direct file loading (local files only, no async needed)
picture = svg.Load(element.Src.Value!);
}
}
diff --git a/src/FlexRender.SvgElement.Svg.Render/Providers/SvgElementSvgProvider.cs b/src/FlexRender.SvgElement.Svg.Render/Providers/SvgElementSvgProvider.cs
index 853aa67..8e65293 100644
--- a/src/FlexRender.SvgElement.Svg.Render/Providers/SvgElementSvgProvider.cs
+++ b/src/FlexRender.SvgElement.Svg.Render/Providers/SvgElementSvgProvider.cs
@@ -9,10 +9,16 @@ namespace FlexRender.SvgElement.Svg.Providers;
/// Provides SVG-native rendering for SVG elements.
/// Loads inline SVG content or resolves SVG sources via resource loaders and returns markup.
///
-public sealed class SvgElementSvgProvider : ISvgContentProvider, IResourceLoaderAware
+public sealed class SvgElementSvgProvider : ISvgContentProvider, IResourceLoaderAware, ISvgContentCacheAware
{
private IReadOnlyList? _loaders;
+ ///
+ /// Pre-loaded SVG content cache populated during the async phase.
+ /// Maps source URIs to sanitized SVG content strings.
+ ///
+ private IReadOnlyDictionary? _svgContentCache;
+
///
/// Sets the resource loaders for loading SVG content from URIs.
///
@@ -24,6 +30,12 @@ public void SetResourceLoaders(IReadOnlyList loaders)
_loaders = loaders;
}
+ ///
+ public void SetSvgContentCache(IReadOnlyDictionary? cache)
+ {
+ _svgContentCache = cache;
+ }
+
///
/// Generates SVG markup for the specified SVG element.
///
@@ -57,10 +69,11 @@ public string GenerateSvgContent(Parsing.Ast.SvgElement element, float width, fl
nameof(element));
}
- var svgContent = SvgContentLoader.LoadFromLoaders(_loaders, element.Src.Value!);
- if (svgContent is not null)
+ // Try SVG content cache first (pre-loaded during async phase)
+ if (_svgContentCache is not null &&
+ _svgContentCache.TryGetValue(element.Src.Value!, out var cached))
{
- return SvgFormatting.SanitizeSvgContent(svgContent);
+ return SvgFormatting.SanitizeSvgContent(cached);
}
// Fallback to direct file loading if resource loaders were not available.
diff --git a/src/FlexRender.Yaml/Parsing/ElementParsers.cs b/src/FlexRender.Yaml/Parsing/ElementParsers.cs
index 056694e..56667b3 100644
--- a/src/FlexRender.Yaml/Parsing/ElementParsers.cs
+++ b/src/FlexRender.Yaml/Parsing/ElementParsers.cs
@@ -283,7 +283,8 @@ internal TemplateElement ParseFlexElement(YamlMappingNode node)
Padding = GetExprStringValue(node, "padding", "0"),
Margin = GetExprStringValue(node, "margin", "0"),
Background = GetStringValue(node, "background")!,
- Rotate = GetExprStringValue(node, "rotate", "none")
+ Rotate = GetExprStringValue(node, "rotate", "none"),
+ FontSize = GetExprStringValueOptional(node, "font_size", "font-size")
};
var directionStr = GetStringValue(node, "direction", "column");
@@ -388,6 +389,12 @@ internal static TemplateElement ParseContentElement(YamlMappingNode node)
Margin = GetExprStringValue(node, "margin", "0")
};
+ // Parse optional 'options' block as a nested dictionary
+ if (TryGetMapping(node, "options", out var optionsNode))
+ {
+ content.Options = YamlPropertyHelpers.ConvertMappingToDictionary(optionsNode);
+ }
+
ApplyFlexItemProperties(node, content);
return content;
}
diff --git a/src/FlexRender.Yaml/Parsing/YamlPropertyHelpers.cs b/src/FlexRender.Yaml/Parsing/YamlPropertyHelpers.cs
index 32d7078..24aa31c 100644
--- a/src/FlexRender.Yaml/Parsing/YamlPropertyHelpers.cs
+++ b/src/FlexRender.Yaml/Parsing/YamlPropertyHelpers.cs
@@ -358,4 +358,55 @@ internal static ExprValue GetExprIntValue(YamlMappingNode node, string key,
return defaultValue;
}
+
+ ///
+ /// Recursively converts a YAML mapping node to a string-keyed dictionary.
+ ///
+ /// The YAML mapping node to convert.
+ /// Current recursion depth (max 10).
+ /// A case-insensitive dictionary representing the mapping contents.
+ internal static IReadOnlyDictionary ConvertMappingToDictionary(YamlMappingNode mapping, int depth = 0)
+ {
+ if (depth > 10)
+ throw new InvalidOperationException("Options nesting depth exceeded (max 10).");
+
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (keyNode, valueNode) in mapping.Children)
+ {
+ var key = ((YamlScalarNode)keyNode).Value!;
+ dict[key] = valueNode switch
+ {
+ YamlScalarNode scalar => scalar.Value ?? string.Empty,
+ YamlMappingNode nested => ConvertMappingToDictionary(nested, depth + 1),
+ YamlSequenceNode seq => ConvertSequenceToList(seq, depth + 1),
+ _ => string.Empty
+ };
+ }
+ return dict;
+ }
+
+ ///
+ /// Recursively converts a YAML sequence node to a list of objects.
+ ///
+ /// The YAML sequence node to convert.
+ /// Current recursion depth.
+ /// A list of objects representing the sequence contents.
+ private static List