Files
2026-02-03 16:40:25 +01:00

686 lines
19 KiB
Markdown

# XML Receipt Template Syntax
## Overview
Templates are XML documents that combine static text, data binding expressions, and control flow to produce a list of `PrintCommand` objects for Epson thermal receipt printers.
```csharp
var engine = new ReceiptTemplateEngine();
List<PrintCommand> commands = engine.Render(
xmlTemplate, // XML template string
jsonData, // JSON data string
lineWidth: 42, // characters per line (normal font)
bigFontLineWidth: 22 // characters per line (big font)
);
```
---
## Root Element
### `<receipt>`
Required root element. All other elements must be nested inside it.
```xml
<receipt>
<!-- template content here -->
</receipt>
```
---
## Content Elements
### `<line>`
Outputs a single line of text. The most common element.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `align` | `left` / `center` / `right` | `left` | Text alignment within the line width |
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font (uses `bigFontLineWidth` for alignment) |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text (on supported printers) |
| `lineSpacing` | int | _(none)_ | Override line spacing in dots |
| `wrap` | `true` / `false` | `false` | Word-wrap text that exceeds line width |
| `wrapIndent` | int | `0` | Indent (in characters) for continuation lines when wrapping |
**Examples:**
```xml
<!-- Simple text -->
<line>Hello World</line>
<!-- Empty line -->
<line />
<!-- Centered bold header -->
<line align="center" bold="true">RECEIPT</line>
<!-- Big red title -->
<line align="center" big="true" tall="true" red="true">{{Title}}</line>
<!-- Right-aligned -->
<line align="right">---------</line>
<!-- Long text with word wrap -->
<line wrap="true">{{dish.Number}}x {{dish.Name}}</line>
<!-- Wrap with indent for continuation lines -->
<line wrap="true" wrapIndent="4">1x Very Long Dish Name That Will Wrap To Next Line</line>
<!-- Output:
1x Very Long Dish Name That
Will Wrap To Next Line -->
<!-- Custom line spacing -->
<line lineSpacing="50">Spaced out text</line>
```
When `big="true"`, alignment uses `bigFontLineWidth` instead of `lineWidth`.
---
### `<columns>`
Two-column layout: left-aligned left text, right-aligned right text. The line width is split in half.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `left` | string | `""` | Left column content (supports `{{}}` expressions) |
| `right` | string | `""` | Right column content (supports `{{}}` expressions) |
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text |
| `lineSpacing` | int | _(none)_ | Override line spacing |
| `wrap` | `true` / `false` | `false` | Wrap left column if combined text exceeds line width |
| `wrapIndent` | int | `0` | Indent for wrapped continuation lines |
**Examples:**
```xml
<!-- Label-value pair -->
<columns left="Tisch:" right="{{TableNumber}}" />
<!-- Bold receipt info -->
<columns left="Rechnung Nr. {{ReceiptNumber}}" right="{{DateTime:HH:mm dd.MM.yyyy}}" bold="true" />
<!-- Price line with wrapping for long descriptions -->
<columns left="{{item.Quantity}}x {{item.Description}}" right="{{item.PriceDisplay}}" wrap="true" />
<!-- Payment line -->
<columns left="{{PaymentMethod}}" right="{{PaymentAmount:F2}} {{Currency}}" bold="true" />
```
Without `wrap`, the left column is padded to half the line width and the right column is right-padded to fill the remaining space. With `wrap="true"`, if the combined text exceeds the line width, the left column wraps and the right column appears right-aligned on the first line.
---
### `<row>`
Multi-column layout with explicit column definitions. Each column is defined by a nested `<col>` element.
**Row attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `bold` | `true` / `false` | `false` | Bold text |
| `big` | `true` / `false` | `false` | Double-width font |
| `tall` | `true` / `false` | `false` | Double-height font |
| `red` | `true` / `false` | `false` | Red text |
| `lineSpacing` | int | _(none)_ | Override line spacing |
**`<col>` attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `width` | int or string | _(auto)_ | Column width as a character count (`"10"`) or percentage of the row width (`"33.3%"`). Unspecified columns share remaining space equally. |
| `align` | `left` / `center` / `right` | `left` | Text alignment within the column |
| `wrap` | `true` / `false` | `false` | Word-wrap text that exceeds column width. All columns expand vertically to match the tallest cell. |
**Examples:**
```xml
<!-- Equal-width columns using percentages -->
<row>
<col width="33.3%" align="left">Name</col>
<col width="33.3%" align="right">Qty</col>
<col width="33.3%" align="right">Price</col>
</row>
<!-- Tax breakdown header -->
<row>
<col width="10" align="left">MwSt %</col>
<col width="10" align="right">Brutto</col>
<col width="10" align="right">Netto</col>
<col align="right">MwSt</col>
</row>
<!-- Tax breakdown data row -->
<row>
<col width="10" align="left">{{tax.Category}}:{{tax.Rate}}%</col>
<col width="10" align="right">{{tax.Gross:F2}} {{tax.Currency}}</col>
<col width="10" align="right">{{tax.Net:F2}} {{tax.Currency}}</col>
<col align="right">{{tax.TaxAmount:F2}} {{tax.Currency}}</col>
</row>
<!-- Row with word wrap on the first column -->
<row>
<col width="10" align="left" wrap="true">Very Long Product Name Here</col>
<col align="right">12.50</col>
</row>
<!-- Output (42-char width, col2 auto-fills remaining 32 chars):
Very Long 12.50
Product
Name Here -->
<!-- Both columns wrapping -->
<row>
<col width="20" align="left" wrap="true">{{item.Name}}</col>
<col width="22" align="left" wrap="true">{{item.Description}}</col>
</row>
```
In this example, the first three columns are 10 characters wide. The fourth column gets all remaining space (`lineWidth - 30`).
When `wrap="true"` is set on a column, text that exceeds the column width wraps to multiple lines. All columns in the row expand vertically to match the tallest cell. Each wrapped line respects the column's `align` attribute.
---
### `<separator>`
Outputs a line of repeated characters spanning the full line width.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `char` | single char | `-` | Character to repeat |
**Examples:**
```xml
<!-- Default dashed line: ------------------------------------------ -->
<separator />
<!-- Asterisk line: ****************************************** -->
<separator char="*" />
<!-- Equals line: ========================================== -->
<separator char="=" />
```
Always uses `lineWidth` (not `bigFontLineWidth`).
---
### `<cut />`
Triggers a paper cut. Produces a `PrintCommand` with `IsCut = true`.
```xml
<cut />
```
---
### `<feed>`
Outputs one or more empty lines.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `lines` | int | `1` | Number of empty lines |
**Examples:**
```xml
<!-- Single empty line (same as <line />) -->
<feed />
<!-- Three empty lines -->
<feed lines="3" />
```
---
### `<table>`
Renders an ASCII box table with borders (`+`, `-`, `|`). Column definitions are provided via nested `<col>` elements.
**Attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `items` | string | _(none)_ | JSON array path for data rows |
| `var` | string | `"item"` | Loop variable name for each data row |
| `headerItems` | string | _(none)_ | JSON array path for header rows (optional) |
If `headerItems` is not set, the text content of `<col>` elements is used as the header row. Columns without text produce no header.
**`<col>` attributes:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `width` | int or string | _(auto)_ | Column width as a character count (`"10"`) or percentage of the content area (`"50%"`). Percentages are relative to the content width (line width minus border characters). Unspecified columns share remaining space equally. |
| `align` | `left` / `center` / `right` | `left` | Cell content alignment |
| `wrap` | `true` / `false` | `false` | Word-wrap cell text that exceeds column width. All columns in the row expand vertically to match the tallest cell. |
Column widths exclude border characters. With 3 columns, 4 border characters (`|`) are used, so the available content width is `lineWidth - 4`. Percentage widths are calculated against this content width.
**Examples:**
```xml
<!-- Simple header-only table -->
<table>
<col width="8" align="center">Order:</col>
<col align="center">QR Info</col>
<col width="12" align="center">Date</col>
</table>
<!-- Output:
+--------+------------------+------------+
| Order: | QR Info | Date |
+--------+------------------+------------+ -->
<!-- Table with data rows -->
<table items="Products" var="p">
<col width="20" align="left">Name</col>
<col width="10" align="right">Price</col>
</table>
<!-- Output:
+--------------------+----------+
|Name | Price|
+--------------------+----------+
|Margherita | 12.50|
|Tiramisu | 8.00|
+--------------------+----------+ -->
<!-- Table with word wrap -->
<table items="Products" var="p">
<col width="12" align="left" wrap="true">Name</col>
<col width="8" align="right">Price</col>
</table>
<!-- Output (cell text wraps, all columns expand vertically):
+------------+--------+
|Name | Price|
+------------+--------+
|Margherita | 12.50|
|Wiener | 8.00|
|Schnitzel | |
+------------+--------+ -->
```
When `wrap="true"` is set on a table `<col>`, cell text that exceeds the column width wraps to multiple lines. All columns in the row expand vertically to match the tallest cell, with empty cells padded with spaces. Each wrapped line respects the column's `align` attribute.
---
## Control Flow
### `<foreach>`
Iterates over a JSON array. For each item, the child elements are rendered with the loop variable available in the data context.
**Attributes (both required):**
| Attribute | Type | Description |
|-----------|------|-------------|
| `items` | string | Path to the JSON array (supports dot notation for nested arrays) |
| `var` | string | Variable name to bind each array element to |
**Loop variables** (available inside `<foreach>`):
| Variable | Type | Description |
|----------|------|-------------|
| `{{$index}}` | int | Zero-based index of the current item |
| `{{$first}}` | bool | `true` for the first item |
| `{{$last}}` | bool | `true` for the last item |
**Examples:**
```xml
<!-- Simple list -->
<foreach items="Items" var="item">
<line>{{item.Name}}: {{item.Price:F2}}</line>
</foreach>
<!-- Nested loops -->
<foreach items="Gangs" var="gang">
<line big="true" red="true">{{gang.Id}}. {{gang.Name}}</line>
<foreach items="gang.Dishes" var="dish">
<line tall="true">{{dish.Number}}x {{dish.Name}}</line>
</foreach>
</foreach>
<!-- Using $index -->
<foreach items="Items" var="item">
<line>{{$index}}: {{item.Name}}</line>
</foreach>
<!-- Output:
0: Pizza
1: Pasta
2: Salad -->
<!-- Cut between gangs, but not after the last one -->
<foreach items="Gangs" var="gang">
<line>{{gang.Name}}</line>
<if test="!$last">
<cut />
</if>
</foreach>
```
If `items` resolves to `null`, an empty array, or a non-array value, the loop body is skipped entirely.
---
### `<if>` / `<else>`
Conditionally renders child elements. An `<else>` block is optional and must immediately follow its corresponding `<if>`.
**Attributes:**
| Attribute | Type | Description |
|-----------|------|-------------|
| `test` | string | Condition expression (see Condition Expressions below) |
**Examples:**
```xml
<!-- Simple truthiness check -->
<if test="SpecialInstruction">
<line bold="true">{{SpecialInstruction}}</line>
</if>
<!-- With else branch -->
<if test="DiscountInfo">
<line>Discount: {{DiscountInfo.Description}}</line>
</if>
<else>
<line>No discount applied</line>
</else>
<!-- Negated condition -->
<if test="!$last">
<cut />
</if>
<!-- Comparison -->
<if test="Total>100">
<line>Large order!</line>
</if>
<!-- String comparison -->
<if test="Status=='active'">
<line>Active order</line>
</if>
<!-- Check nested property exists -->
<if test="dish.Modifications.HasModifications">
<line>Modified</line>
</if>
<!-- Loop variable condition -->
<if test="$first">
<line bold="true">First item header</line>
</if>
```
---
## Data Binding
### Expression Syntax `{{}}`
Expressions are enclosed in double curly braces and can appear in text content, `left`/`right` attributes of `<columns>`, and `<col>` text content.
**Property access:**
```xml
<!-- Simple property -->
<line>{{Name}}</line>
<!-- Nested property -->
<line>{{Person.Address.City}}</line>
<!-- Multiple expressions in one line -->
<line>{{FirstName}} {{LastName}}</line>
<!-- Mixed static text and expressions -->
<line>Order #{{OrderNumber}} - Table {{TableNumber}}</line>
<!-- Loop variable access -->
<foreach items="Items" var="item">
<line>{{item.Name}} - {{item.Price}}</line>
</foreach>
```
Property lookup is **case-insensitive**. Both `{{name}}` and `{{Name}}` resolve the same JSON property.
If a property is missing or `null`, the expression resolves to an empty string.
### Format Strings
Use a colon after the property name to apply a format string:
```
{{PropertyName:format}}
```
**DateTime formatting** (the JSON value must be an ISO 8601 date string):
```xml
<line>{{DateTime:dd.MM.yyyy}}</line> <!-- 15.03.2024 -->
<line>{{DateTime:HH:mm:ss}}</line> <!-- 14:30:00 -->
<line>{{DateTime:dd-MMM-yy HH:mm}}</line> <!-- 15-Mar-24 14:30 -->
```
**Number formatting** (standard .NET format strings):
```xml
<line>{{Price:F2}}</line> <!-- 9.50 -->
<line>{{Amount:N0}}</line> <!-- 1,234 -->
```
### Loop Variables
Available only inside `<foreach>` blocks:
```xml
<foreach items="Items" var="item">
<line>{{$index}}: {{item.Name}}</line> <!-- 0: Pizza -->
</foreach>
```
| Variable | Type | Description |
|----------|------|-------------|
| `{{$index}}` | int | Zero-based index |
| `{{$first}}` | bool | `true` on first iteration |
| `{{$last}}` | bool | `true` on last iteration |
`$first` and `$last` are primarily useful in `<if>` conditions rather than text output.
---
## Condition Expressions
Used in the `test` attribute of `<if>`. The following forms are supported:
### Truthiness
A property path by itself checks whether the value is "truthy":
```xml
<if test="PropertyName">
```
| JSON value | Truthy? |
|------------|---------|
| `"hello"` | yes |
| `""` | no |
| `42` | yes |
| `0` | no |
| `true` | yes |
| `false` | no |
| `[1, 2]` | yes |
| `[]` | no |
| `null` | no |
| _(missing)_ | no |
| `{ ... }` | yes |
### Negation
Prefix with `!` to negate:
```xml
<if test="!PropertyName"> <!-- true when property is falsy or missing -->
<if test="!$last"> <!-- true when NOT the last loop iteration -->
```
### Comparisons
Six comparison operators are supported. Operands can be property paths, numeric literals, or quoted string literals:
```xml
<if test="Count==5"> <!-- numeric equality -->
<if test="Count!=0"> <!-- numeric inequality -->
<if test="Count>3"> <!-- greater than -->
<if test="Count<10"> <!-- less than -->
<if test="Count>=1"> <!-- greater or equal -->
<if test="Count<=100"> <!-- less or equal -->
<if test="Status=='active'"> <!-- string equality (single quotes) -->
```
If both operands parse as numbers, numeric comparison is used. Otherwise, case-insensitive string comparison is used.
Comparisons can also be negated:
```xml
<if test="!Status=='inactive'">
```
### Loop Variables in Conditions
```xml
<if test="$first"> <!-- first iteration -->
<if test="$last"> <!-- last iteration -->
<if test="!$last"> <!-- not the last iteration -->
```
---
## Complete Example
### Kitchen Receipt Template
```xml
<receipt>
<line align="center" big="true" tall="true" red="true">{{Title}}</line>
<separator />
<line />
<line align="center">{{TransactionDateTime:dd-MMM-yy HH:mm}} Nr.:{{ReceiptNumber}}</line>
<line align="center">{{WaiterName}}</line>
<line align="center" big="true" bold="true">Tisch: {{TableNumber}}</line>
<if test="SpecialInstruction">
<line />
<line align="center" big="true" bold="true">{{SpecialInstruction}}</line>
<line />
</if>
<separator />
<foreach items="Gangs" var="gang">
<line align="center" big="true" tall="true" red="true">{{gang.Id}}. {{gang.Name}}</line>
<foreach items="gang.Dishes" var="dish">
<line tall="true" wrap="true">{{dish.Number}}x {{dish.Name}}</line>
<if test="dish.Comment">
<line tall="true" bold="true">Comment: {{dish.Comment}}</line>
</if>
</foreach>
<if test="!$last">
<cut />
<line />
<line />
</if>
</foreach>
<separator />
</receipt>
```
### Sample JSON Data
```json
{
"Title": "Ristorante Bella",
"TransactionDateTime": "2024-03-15T14:30:00Z",
"ReceiptNumber": "42",
"WaiterName": "Max Mustermann",
"TableNumber": "5",
"SpecialInstruction": null,
"Gangs": [
{
"Id": 1,
"Name": "Vorspeise",
"Dishes": [
{ "Number": 2, "Name": "Caesar Salad", "Comment": null }
]
},
{
"Id": 2,
"Name": "Hauptgang",
"Dishes": [
{ "Number": 1, "Name": "Wiener Schnitzel", "Comment": "Well done" }
]
}
]
}
```
### Printed Output (42 char width)
```
Ristorante Bella
------------------------------------------
15-Mar-24 14:30 Nr.:42
Max Mustermann
Tisch: 5
------------------------------------------
1. Vorspeise
2x Caesar Salad
------------------------------------------
2. Hauptgang
1x Wiener Schnitzel
Comment: Well done
------------------------------------------
```
---
## Quick Reference
| Element | Produces | Key Attributes |
|---------|----------|----------------|
| `<receipt>` | _(root)_ | - |
| `<line>` | 1 PrintCommand (or N if wrapping) | `align`, `bold`, `big`, `tall`, `red`, `wrap`, `wrapIndent`, `lineSpacing` |
| `<columns>` | 1 PrintCommand (or N if wrapping) | `left`, `right`, `bold`, `big`, `tall`, `red`, `wrap`, `wrapIndent`, `lineSpacing` |
| `<row>` | 1+ PrintCommand(s) (N if wrapping) | `bold`, `big`, `tall`, `red`, `lineSpacing` + nested `<col>` |
| `<separator>` | 1 PrintCommand | `char` |
| `<cut />` | 1 PrintCommand (IsCut=true) | - |
| `<feed />` | N PrintCommands (empty lines) | `lines` |
| `<foreach>` | N x children | `items` (required), `var` (required) |
| `<if>` | 0 or children | `test` (required) |
| `<else>` | 0 or children | _(must follow `<if>`)_ |
| `<table>` | Multiple PrintCommands (bordered) | `items`, `var`, `headerItems` + nested `<col>` |