From 360795a2317378fbd7041d5363a2a96b063ae74e Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sun, 8 Mar 2026 20:26:31 +0300 Subject: [PATCH 01/17] feat: YAML validation, NRE fix, Cookbook, and llms updates - Add YAML property validation with Levenshtein "Did you mean?" suggestions - Fix NRE in render pipeline (null-suppression on _imageLoader) - Fix SkiaRenderer passing _defaultRenderOptions to RenderToBitmapCore - Add Cookbook wiki page with 9 practical recipes - Update llms.txt and llms-full.txt with async API, validation, and cookbook info --- docs/wiki/Cookbook.md | 1193 +++++++++++++++++ docs/wiki/Home.md | 1 + llms-full.txt | 31 +- llms.txt | 25 +- .../Rendering/RenderingEngine.cs | 29 +- .../Rendering/SkiaRenderer.cs | 78 +- .../Parsing/KnownProperties.cs | 325 +++++ src/FlexRender.Yaml/Parsing/TemplateParser.cs | 1 + .../TemplateParserUnknownPropertyTests.cs | 545 ++++++++ 9 files changed, 2167 insertions(+), 61 deletions(-) create mode 100644 docs/wiki/Cookbook.md create mode 100644 src/FlexRender.Yaml/Parsing/KnownProperties.cs create mode 100644 tests/FlexRender.Tests/Parsing/TemplateParserUnknownPropertyTests.cs diff --git a/docs/wiki/Cookbook.md b/docs/wiki/Cookbook.md new file mode 100644 index 0000000..2f95b58 --- /dev/null +++ b/docs/wiki/Cookbook.md @@ -0,0 +1,1193 @@ +# Cookbook + +Practical recipes for common FlexRender use cases. Each recipe is a complete, copy-paste-ready YAML template with the data object needed to render it. + +For element properties reference, see [[Element-Reference]]. +For expression syntax, see [[Template-Expressions]]. +For flexbox layout, see [[Flexbox-Layout]]. + +--- + +## Receipts + +### Simple Receipt with Header and Footer + +A minimal thermal receipt with a shop header, static line items, a total row, and a footer. Uses a 380px canvas width, which is typical for 80mm thermal printers at ~203 DPI. + +**Template:** + +```yaml +template: + name: "simple-receipt" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + # Header + - type: flex + gap: 4 + align: center + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + color: "#1a1a1a" + + - type: text + content: "{{address}}" + size: 0.85em + align: center + color: "#888888" + + - type: separator + style: dashed + color: "#cccccc" + + # Line items + - type: flex + gap: 6 + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Espresso" + color: "#333333" + - type: text + content: "3.50 $" + color: "#333333" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Croissant" + color: "#333333" + - type: text + content: "4.00 $" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + # Total + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + + - type: separator + style: dotted + color: "#cccccc" + + # Footer + - type: flex + gap: 2 + children: + - type: text + content: "Thank you for your purchase!" + size: 0.85em + align: center + color: "#666666" + + - type: text + content: "{{date}}" + size: 0.75em + align: center + color: "#999999" +``` + +**Data:** + +```json +{ + "shopName": "Corner Cafe", + "address": "123 Main St, Springfield", + "total": "7.50", + "date": "2026-03-08 14:30" +} +``` + +--- + +### Receipt with Dynamic Items + +Uses `type: each` to loop over a line-items array, so the same template works regardless of how many items are in the order. Each row is a flex container with `space-between` to push names and prices to opposite edges. + +**Template:** + +```yaml +template: + name: "dynamic-receipt" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + + - type: separator + style: dashed + color: "#cccccc" + + # Dynamic line items + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: flex + gap: 2 + shrink: 1 + children: + - type: text + content: "{{item.name}}" + color: "#333333" + - type: if + condition: item.qty + greaterThan: 1 + then: + - type: text + content: "x{{item.qty}}" + size: 0.8em + color: "#888888" + - type: text + content: "{{item.price}} $" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + # Subtotal, discount, total + - type: if + condition: discount + greaterThan: 0 + then: + - type: flex + gap: 4 + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Subtotal" + size: 0.9em + color: "#666666" + - type: text + content: "{{subtotal}} $" + size: 0.9em + color: "#666666" + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "Discount" + size: 0.9em + color: "#22c55e" + - type: text + content: "-{{discount}} $" + size: 0.9em + color: "#22c55e" + - type: separator + style: dashed + color: "#cccccc" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + + - type: separator + style: dotted + color: "#cccccc" + + - type: text + content: "{{date}}" + size: 0.75em + align: center + color: "#999999" +``` + +**Data:** + +```json +{ + "shopName": "Corner Cafe", + "items": [ + { "name": "Espresso", "qty": 2, "price": "7.00" }, + { "name": "Croissant", "qty": 1, "price": "4.00" }, + { "name": "Orange Juice", "qty": 1, "price": "5.50" } + ], + "subtotal": "16.50", + "discount": "1.50", + "total": "15.00", + "date": "2026-03-08 14:30" +} +``` + +--- + +### Receipt with Table + +Uses `type: table` for a cleaner column-aligned layout. The table element is expanded into flex rows at render time, so no additional packages are needed. Includes a static summary table for subtotal, tax, and total. + +**Template:** + +```yaml +template: + name: "table-receipt" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 14 + children: + - type: flex + gap: 4 + align: center + children: + - type: text + content: "{{companyName}}" + fontWeight: bold + size: 1.4em + align: center + - type: text + content: "Invoice #{{invoiceNumber}}" + size: 0.9em + align: center + color: "#666666" + + - type: separator + style: solid + color: "#e0e0e0" + + # Dynamic line items table + - type: table + array: items + as: item + size: 0.9em + color: "#333333" + row-gap: "2" + column-gap: "8" + header-fontWeight: semi-bold + header-color: "#1a1a1a" + header-size: 0.85em + header-border-bottom: dashed + columns: + - key: description + label: "Item" + grow: 1 + - key: qty + label: "Qty" + width: "36" + align: center + - key: price + label: "Price" + width: "64" + align: right + + - type: separator + style: solid + color: "#e0e0e0" + + # Summary (static rows) + - type: table + size: 0.9em + color: "#555555" + row-gap: "4" + columns: + - key: label + grow: 1 + - key: value + width: "80" + align: right + rows: + - label: "Subtotal" + value: "{{subtotal}}" + - label: "Tax ({{taxRate}})" + value: "{{tax}}" + - label: "TOTAL" + value: "{{total}}" + fontWeight: bold + color: "#1a1a1a" + size: "1.1em" + + - type: text + content: "Thank you for your business!" + size: 0.8em + align: center + color: "#999999" +``` + +**Data:** + +```json +{ + "companyName": "Acme Corp", + "invoiceNumber": "INV-2026-0042", + "items": [ + { "description": "Widget A", "qty": "3", "price": "$29.97" }, + { "description": "Gadget B", "qty": "1", "price": "$24.99" }, + { "description": "Cable C", "qty": "5", "price": "$14.95" } + ], + "subtotal": "$69.91", + "taxRate": "8%", + "tax": "$5.59", + "total": "$75.50" +} +``` + +--- + +### Receipt with NDC Content + +NDC (NCR Direct Connect) is a binary protocol used by ATM terminals to format printer output. The `content` element with `format: ndc` parses these binary data streams into FlexRender elements. This is useful when rendering ATM receipt images from raw transaction data captured by banking middleware. + +The NDC parser requires `FlexRender.Content.Ndc` and `.WithNdc()` on the builder. A monospaced font (such as JetBrains Mono or Courier) is recommended for accurate column alignment. + +**Template:** + +```yaml +fonts: + default: "assets/fonts/JetBrainsMono-Regular.ttf" + bold: "assets/fonts/JetBrainsMono-Bold.ttf" + +canvas: + fixed: width + width: 576 + background: "#ffffff" + +layout: + # Bank header (static) + - type: flex + padding: "16 20" + gap: 4 + align: center + children: + - type: text + content: "{{bankName}}" + fontWeight: bold + size: 1.2em + align: center + - type: text + content: "ATM #{{atmId}}" + size: 0.8em + color: "#666666" + align: center + + - type: separator + style: solid + color: "#cccccc" + + # NDC receipt body (parsed from binary data) + - type: content + source: "{{receiptData}}" + format: ndc + options: + columns: 40 + input_encoding: latin1 + font_family: "JetBrains Mono" + charsets: + "1": + encoding: "qwerty-jcuken" + font_style: bold + "I": + font: bold + uppercase: true + + - type: separator + style: solid + color: "#cccccc" + + # Footer (static) + - type: flex + padding: "12 20" + gap: 2 + children: + - type: text + content: "{{date}}" + size: 0.75em + align: center + color: "#999999" + - type: text + content: "Please retain this receipt" + size: 0.75em + align: center + color: "#999999" +``` + +**Data (C#):** + +```csharp +var data = new ObjectValue +{ + ["bankName"] = "First National Bank", + ["atmId"] = "ATM-0042", + ["receiptData"] = new BytesValue(ndcBinaryBytes), + ["date"] = "2026-03-08 09:15:33" +}; +``` + +**Builder setup:** + +```csharp +var render = new FlexRenderBuilder() + .WithNdc() + .WithSkia() + .Build(); +``` + +--- + +### Receipt with QR Code + +Adds a scannable QR code at the bottom for a payment link or digital receipt URL. The QR code is centered using a flex container with `align: center`. + +**Template:** + +```yaml +template: + name: "receipt-qr" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + + - type: separator + style: dashed + color: "#cccccc" + + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "{{item.name}}" + color: "#333333" + - type: text + content: "{{item.price}} $" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + + - type: separator + style: dotted + color: "#cccccc" + + # QR code centered + - type: flex + align: center + gap: 6 + children: + - type: qr + data: "{{paymentUrl}}" + size: 140 + errorCorrection: M + + - type: text + content: "Scan to pay" + size: 0.75em + color: "#999999" + align: center + + - type: separator + style: dotted + color: "#cccccc" + + - type: text + content: "Thank you!" + size: 0.85em + align: center + color: "#666666" +``` + +**Data:** + +```json +{ + "shopName": "Corner Cafe", + "items": [ + { "name": "Espresso", "price": "3.50" }, + { "name": "Croissant", "price": "4.00" } + ], + "total": "7.50", + "paymentUrl": "https://pay.example.com/invoice/abc123" +} +``` + +**Builder setup** (QR requires `FlexRender.QrCode`): + +```csharp +var render = new FlexRenderBuilder() + .WithSkia(skia => skia.WithQr()) + .Build(); +``` + +--- + +## Labels and Tickets + +### Product Label with Barcode + +A compact product label with name, description, price, and a Code128 barcode. Uses `margin: "0 auto"` to center the barcode horizontally. The barcode requires `FlexRender.Barcode` and `.WithBarcode()`. + +**Template:** + +```yaml +template: + name: "product-label" + version: 1 + +canvas: + fixed: width + width: 200 + background: "#ffffff" + +layout: + - type: flex + padding: "12 10" + gap: 6 + children: + - type: text + content: "{{productName}}" + fontWeight: bold + size: 1.1em + align: center + maxLines: 2 + overflow: ellipsis + + - type: text + content: "{{description}}" + size: 0.85em + color: "#666666" + align: center + maxLines: 2 + + - type: text + content: "{{price}}" + fontWeight: bold + size: 1.3em + color: "#cc0000" + align: center + + - type: barcode + data: "{{sku}}" + format: code128 + width: 180 + height: 40 + showText: true + margin: "0 auto" +``` + +**Data:** + +```json +{ + "productName": "Organic Green Tea", + "description": "Premium loose leaf, 100g", + "price": "$12.99", + "sku": "TEA-GRN-100" +} +``` + +--- + +### Event Ticket + +A two-section ticket with a dark header for the event name, a white body for date/time/seat details, and a QR code section separated by a dashed tear line. Section/row/seat info uses small info cards with a label-value pattern. + +**Template:** + +```yaml +template: + name: "event-ticket" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 360 + background: "#f0f0f5" + +layout: + # Event header + - type: flex + background: "#1a1a2e" + padding: "24 28 20 28" + gap: 6 + children: + - type: text + content: "{{eventName}}" + fontWeight: bold + size: 1.5em + align: center + color: "#ffffff" + maxLines: 2 + + - type: text + content: "{{venue}}" + size: 0.95em + align: center + color: "#8888aa" + + # Details + - type: flex + padding: "16 28" + gap: 12 + background: "#ffffff" + children: + - type: flex + direction: row + gap: 12 + children: + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + children: + - type: text + content: "DATE" + size: 0.7em + color: "#888888" + - type: text + content: "{{date}}" + fontWeight: semi-bold + size: 1.05em + + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + children: + - type: text + content: "TIME" + size: 0.7em + color: "#888888" + - type: text + content: "{{time}}" + fontWeight: semi-bold + size: 1.05em + + - type: flex + direction: row + gap: 8 + children: + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + align: center + children: + - type: text + content: "SECTION" + size: 0.7em + color: "#888888" + - type: text + content: "{{section}}" + fontWeight: bold + size: 1.3em + + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + align: center + children: + - type: text + content: "ROW" + size: 0.7em + color: "#888888" + - type: text + content: "{{row}}" + fontWeight: bold + size: 1.3em + + - type: flex + grow: 1 + background: "#f5f5fa" + padding: "10 14" + gap: 2 + align: center + children: + - type: text + content: "SEAT" + size: 0.7em + color: "#888888" + - type: text + content: "{{seat}}" + fontWeight: bold + size: 1.3em + + # Tear line + - type: separator + style: dashed + color: "#cccccc" + + # QR code + - type: flex + padding: "16 28 20 28" + gap: 8 + align: center + background: "#ffffff" + children: + - type: qr + data: "{{ticketId}}" + size: 140 + errorCorrection: H + + - type: text + content: "{{ticketId}}" + size: 0.7em + color: "#aaaaaa" + align: center + + - type: separator + style: dotted + color: "#dddddd" + + - type: text + content: "Present this ticket at the entrance" + size: 0.75em + align: center + color: "#888888" +``` + +**Data:** + +```json +{ + "eventName": "Symphony Orchestra: Beethoven's 9th", + "venue": "Grand Concert Hall", + "date": "Mar 15, 2026", + "time": "7:30 PM", + "section": "A", + "row": "12", + "seat": "7", + "ticketId": "TKT-2026-0315-A12S07" +} +``` + +--- + +## Advanced Patterns + +### Conditional Content + +Uses `type: if` with `elseIf` to render different content based on data values. This example shows a payment status indicator that changes appearance depending on whether the payment is complete, pending, or failed. + +**Template:** + +```yaml +template: + name: "conditional-receipt" + version: 1 + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + - type: text + content: "Order #{{orderId}}" + fontWeight: bold + size: 1.3em + align: center + + - type: separator + style: dashed + color: "#cccccc" + + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "{{item.name}}" + color: "#333333" + - type: text + content: "{{item.price}} $" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "TOTAL" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total}} $" + fontWeight: bold + size: 1.2em + + - type: separator + style: dotted + color: "#cccccc" + + # Payment status -- changes based on data + - type: if + condition: status + equals: "paid" + then: + - type: flex + padding: "10" + background: "#d4edda" + border-radius: "6" + align: center + children: + - type: text + content: "PAID" + fontWeight: bold + color: "#155724" + align: center + elseIf: + condition: status + equals: "pending" + then: + - type: flex + padding: "10" + align: center + gap: 8 + children: + - type: flex + padding: "8" + background: "#fff3cd" + border-radius: "6" + align: center + children: + - type: text + content: "Payment Pending" + fontWeight: bold + color: "#856404" + align: center + - type: qr + data: "{{paymentUrl}}" + size: 120 + errorCorrection: M + - type: text + content: "Scan to complete payment" + size: 0.75em + color: "#999999" + align: center + else: + - type: flex + padding: "10" + background: "#f8d7da" + border-radius: "6" + align: center + children: + - type: text + content: "Payment Failed" + fontWeight: bold + color: "#721c24" + align: center + - type: text + content: "Please contact support" + size: 0.85em + color: "#721c24" + align: center +``` + +**Data (paid):** + +```json +{ + "orderId": "ORD-9876", + "items": [ + { "name": "Widget", "price": "19.99" }, + { "name": "Gadget", "price": "34.99" } + ], + "total": "54.98", + "status": "paid" +} +``` + +**Data (pending):** + +```json +{ + "orderId": "ORD-9877", + "items": [ + { "name": "Widget", "price": "19.99" } + ], + "total": "19.99", + "status": "pending", + "paymentUrl": "https://pay.example.com/ORD-9877" +} +``` + +--- + +### Markdown Content in a Receipt + +Uses the `content` element with `format: markdown` to render a free-form body from data. This is useful when the receipt body is authored elsewhere (CMS, API, database) and arrives as Markdown text. Requires `FlexRender.Content.Markdown` and `.WithMarkdown()`. + +**Template:** + +```yaml +template: + name: "markdown-receipt" + version: 1 + +canvas: + fixed: width + width: 400 + background: "#ffffff" + +layout: + - type: text + content: "{{title}}" + fontWeight: bold + size: 1.5em + align: center + padding: "20 16 8 16" + + - type: separator + color: "#e0e0e0" + + # Dynamic markdown body + - type: content + source: "{{body}}" + format: markdown + padding: "12 16" + + - type: separator + color: "#e0e0e0" + + - type: text + content: "Generated by FlexRender" + size: 0.8em + color: "#999999" + align: center + padding: "8 16 16 16" +``` + +**Data:** + +```json +{ + "title": "Order Summary", + "body": "## Items\n\n- Espresso x2 -- $7.00\n- **Croissant** -- $4.00\n\n---\n\n> **Total: $11.00**\n\nThank you for your order!" +} +``` + +--- + +### Multi-language Receipt + +Sets the `culture` property on the template metadata to control how numbers and dates are formatted by expression filters. The `currency` and `number` filters respect the active culture, so `{{amount | currency}}` produces locale-appropriate output without template changes. + +**Template:** + +```yaml +template: + name: "multi-lang-receipt" + version: 1 + culture: "de-DE" + +fonts: + - "assets/fonts/Inter-Regular.ttf" + +canvas: + fixed: width + width: 380 + background: "#ffffff" + +layout: + - type: flex + padding: "24 20" + gap: 12 + children: + - type: text + content: "{{shopName}}" + fontWeight: bold + size: 1.5em + align: center + + - type: separator + style: dashed + color: "#cccccc" + + - type: each + array: items + as: item + children: + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "{{item.name}}" + color: "#333333" + - type: text + content: "{{item.price | currency}}" + color: "#333333" + + - type: separator + style: solid + color: "#1a1a1a" + + - type: flex + direction: row + justify: space-between + children: + - type: text + content: "GESAMT" + fontWeight: bold + size: 1.2em + - type: text + content: "{{total | currency}}" + fontWeight: bold + size: 1.2em + + - type: text + content: "Vielen Dank!" + size: 0.85em + align: center + color: "#666666" +``` + +**Data:** + +```json +{ + "shopName": "Berliner Kaffeehaus", + "items": [ + { "name": "Espresso", "price": 3.50 }, + { "name": "Berliner", "price": 2.80 } + ], + "total": 6.30 +} +``` + +With `culture: "de-DE"`, the `currency` filter formats `3.50` as `3,50 EUR` (locale-dependent). Changing `culture` to `"en-US"` would produce `$3.50` instead -- no template edits needed. + +--- + +## See Also + +- [[Template-Syntax]] -- canvas, element types, units +- [[Element-Reference]] -- complete property reference +- [[Template-Expressions]] -- variables, filters, loops, conditionals +- [[Flexbox-Layout]] -- layout engine details +- [[Render-Options]] -- output formats and rendering settings +- [[CLI-Reference]] -- render templates from the command line diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 0eee7cb..a992e4f 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -97,6 +97,7 @@ byte[] png = await render.RenderFile("template.yaml", data); | [[Template-Expressions]] | Variables, loops, conditionals with 13 operators | | [[Flexbox-Layout]] | Direction, justify, align, wrapping, grow/shrink, positioning | | [[Render-Options]] | Per-call options: antialiasing, font hinting, format-specific settings | +| [[Cookbook]] | Copy-paste recipes: receipts, labels, tickets, NDC, conditional content | | [[CLI-Reference]] | Commands, options, AOT publishing | | [[API-Reference]] | IFlexRender, builder, DI, extension methods, types | | [[Contributing]] | Build, test, architecture, coding conventions | diff --git a/llms-full.txt b/llms-full.txt index dfee7bb..5775269 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -28,6 +28,7 @@ src/FlexRender.Core/ # Core library (0 external dependencies) src/FlexRender.Yaml/ # YAML template parser (-> Core + YamlDotNet) Parsing/ TemplateParser.cs # YAML to AST parser (includes EachElement, IfElement) + KnownProperties.cs # YAML property validation with Levenshtein "Did you mean?" suggestions src/FlexRender.Http/ # HTTP resource loader (-> Core) HttpResourceLoader.cs # Load images/fonts from HTTP/HTTPS URLs @@ -152,13 +153,16 @@ FlexRender.Yaml FlexRender.Http FlexRender.Skia.Render FlexRender.ImageSharp. ``` YAML Template -> TemplateParser (YAML -> AST: Template with CanvasSettings + TemplateElement tree, including EachElement/IfElement) - -> TemplateExpander (expand EachElement/IfElement to concrete elements based on data -- enables template caching) - -> TemplateProcessor (resolve {{variable}} expressions in element properties -- inline substitution) + + KnownProperties (validate YAML keys, warn on unknown properties with "Did you mean?" suggestions) + -> TemplateExpander (expand EachElement/IfElement to concrete elements based on data) [async only] + -> TemplateProcessor (resolve {{variable}} expressions in element properties) [async only] -> LayoutEngine (two-pass: MeasureAllIntrinsics -> ComputeLayout -> LayoutNode tree) -> SkiaRenderer (traverse LayoutNode tree -> draw to SKBitmap via SkiaSharp) OR ImageSharpRenderer (traverse LayoutNode tree -> draw via SixLabors.ImageSharp) ``` +**Async-only API:** The entire pipeline from expansion through rendering is async. There are no synchronous `Expand()`, `Process()`, `Measure()`, `ComputeLayout()`, or `Render()` methods. All public API methods return `Task` or `Task`. This is required because `ContentElement` expansion involves async I/O (loading external content sources). + ### Template Caching Templates can be parsed once and cached, then rendered with different data: @@ -852,6 +856,8 @@ Renders SVG vector graphics. Supports external files (`src`) or inline markup (` Embeds formatted text (Markdown, HTML, or NDC binary data) that is parsed into an AST subtree at render time. Requires a content parser to be registered. +**Important:** Content element expansion is async-only (`ExpandAsync()`). There is no synchronous expansion path. This is why the entire pipeline is async. + | Property | Type | Default | Description | |----------|------|---------|-------------| | source | string | "" | Content to parse. Supports plain text, `base64:` binary, `file:` URIs, `text:` prefix, `{{variable}}` bound to `string` or `BytesValue` (`byte[]`) | @@ -918,6 +924,17 @@ Colors are specified in hex format: Parsed by `ColorParser` in the Rendering namespace. +## YAML Validation + +The parser validates all YAML property names against known properties for each element type. Unknown properties trigger a `TemplateParseException` with a "Did you mean?" suggestion using Levenshtein distance: + +``` +Unknown property 'colour' for element type 'text'. Did you mean 'color'? +Unknown property 'backgrund' for element type 'flex'. Did you mean 'background'? +``` + +Known properties are maintained per element type in `KnownProperties` (src/FlexRender.Yaml/Parsing/KnownProperties.cs). This covers all 11 element types and their specific + common properties. + ## Supported Units | Unit | Syntax | Resolution | @@ -1553,6 +1570,16 @@ Reference these YAML files for real-world template patterns. Each file is a comp | `examples/markdown-content.yaml` | Markdown content demo | `type: content` with `format: markdown`, dynamic body | | `examples/html-content.yaml` | HTML content demo | `type: content` with `format: html`, inline styles | +### Cookbook (docs/wiki/Cookbook.md) + +9 practical recipes for common use cases: +- Simple receipt, dynamic receipt with loops, table invoice +- NDC ATM receipt (binary content parsing) +- Receipt with QR code, shipping label, event ticket +- Conditional content, multi-language template + +Each recipe is a complete, copy-paste-ready YAML template with sample JSON data. + ### Per-Feature Examples (examples/visual-docs/) Minimal, focused templates -- one feature per file: diff --git a/llms.txt b/llms.txt index 91d2732..2730c8f 100644 --- a/llms.txt +++ b/llms.txt @@ -60,18 +60,21 @@ examples/ # Example YAML templates (see "Example Templates ``` YAML Template -> TemplateParser (YAML -> AST: Template with CanvasSettings + element tree) - -> TemplateExpander (expand EachElement/IfElement to concrete elements based on data) - -> TemplateProcessor (resolve {{variable}} expressions in element properties) + + KnownProperties (validate YAML keys, warn on unknown properties with "Did you mean?" suggestions) + -> TemplateExpander (expand EachElement/IfElement to concrete elements based on data) [async only] + -> TemplateProcessor (resolve {{variable}} expressions in element properties) [async only] -> LayoutEngine (two-pass: MeasureAllIntrinsics -> ComputeLayout -> LayoutNode tree) -> SkiaRenderer (traverse LayoutNode tree -> draw to SKBitmap via SkiaSharp) OR ImageSharpRenderer (traverse LayoutNode tree -> draw via SixLabors.ImageSharp) ``` +The entire pipeline is **async-only**. There are no synchronous `Expand()`, `Process()`, `Measure()`, or `Render()` methods. All public API methods return `Task` or `Task`. + Templates can be parsed once and cached for reuse with different data. ## Element Types -Ten element types, each a sealed class extending `TemplateElement`: +Eleven element types, each a sealed class extending `TemplateElement`: | Type | Key Properties | |------|---------------| @@ -277,6 +280,8 @@ Converts formatted text into FlexRender AST elements at render time (bold → Fo 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). +**Important:** Content element expansion is async-only. The `ExpandAsync()` method must be used; there is no synchronous `Expand()` alternative. + #### NDC Content ```yaml - type: content @@ -289,6 +294,16 @@ Binary sources pass `ReadOnlyMemory` directly to `IBinaryContentParser` im Requires NDC parser registration: `.WithNdc()` (FlexRender.Content.Ndc). Parses binary NDC ATM printer data streams. Supports charset switching, QWERTY→JCUKEN encoding, embedded barcodes, auto font sizing. +## YAML Validation + +The parser validates all YAML property names against known properties for each element type. Unknown properties trigger a `TemplateParseException` with a "Did you mean?" suggestion using Levenshtein distance: + +``` +Unknown property 'colour' for element type 'text'. Did you mean 'color'? +``` + +This catches typos like `colour` → `color`, `backgrund` → `background`, `dierction` → `direction`. + ## Supported Units - `px` -- pixels (default when no unit specified) @@ -500,6 +515,10 @@ Reference these YAML files for real-world template patterns. Each file is a comp | `examples/markdown-content.yaml` | Markdown content demo | `type: content` with `format: markdown`, dynamic body | | `examples/html-content.yaml` | HTML content demo | `type: content` with `format: html`, inline styles | +### Cookbook (docs/wiki/Cookbook.md) + +9 practical recipes for common use cases: simple receipt, dynamic receipt with loops, table invoice, NDC ATM receipt, receipt with QR code, shipping label, event ticket, conditional content, and multi-language template. Each recipe is a complete, copy-paste-ready YAML template with sample data. + ### Per-Feature Examples (examples/visual-docs/) Minimal, focused templates -- one feature per file: diff --git a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs index 8f7529e..2b9c5f6 100644 --- a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs +++ b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs @@ -646,6 +646,9 @@ internal async Task> PreloadImagesAsync( ObjectValue data, CancellationToken cancellationToken) { + if (_imageLoader is null) + return new Dictionary(0, StringComparer.Ordinal); + var processedTemplate = await _pipeline.ProcessAsync(template, data).ConfigureAwait(false); _preprocessor.RegisterFonts(processedTemplate); var uris = CollectImageUris(processedTemplate); @@ -654,7 +657,7 @@ internal async Task> PreloadImagesAsync( foreach (var uri in uris) { cancellationToken.ThrowIfCancellationRequested(); - var bitmap = await _imageLoader!.Load(uri, cancellationToken).ConfigureAwait(false); + var bitmap = await _imageLoader.Load(uri, cancellationToken).ConfigureAwait(false); if (bitmap is not null) { cache[uri] = bitmap; @@ -676,13 +679,16 @@ internal async Task> PreloadImagesFromProcessedAsyn Template processedTemplate, CancellationToken cancellationToken) { + if (_imageLoader is null) + return new Dictionary(0, StringComparer.Ordinal); + var uris = CollectImageUris(processedTemplate); var cache = new Dictionary(uris.Count, StringComparer.Ordinal); foreach (var uri in uris) { cancellationToken.ThrowIfCancellationRequested(); - var bitmap = await _imageLoader!.Load(uri, cancellationToken).ConfigureAwait(false); + var bitmap = await _imageLoader.Load(uri, cancellationToken).ConfigureAwait(false); if (bitmap is not null) { cache[uri] = bitmap; @@ -940,6 +946,25 @@ private static void ApplyBorderDashPattern(SKPaint paint, BorderSide side) } } + /// + /// Processes a template through the culture-aware pipeline. Resolves the effective + /// culture from and the template, creates a + /// culture-specific pipeline when needed, and returns the processed template. + /// + /// The template to process. + /// The data context for expression evaluation. + /// Per-call rendering options (may contain culture override). + /// The processed template with all expressions expanded and resolved. + internal Task