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 (`![](url)` 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