templates tested

This commit is contained in:
EugeneTes
2026-02-03 16:40:25 +01:00
parent ec5decb12e
commit fe817b5211
6 changed files with 310 additions and 117 deletions

View File

@@ -1,115 +1,116 @@
<receipt>
<line align="center">{{CompanyName}}</line>
<line align="center">{{Address1}}</line>
<line align="center">{{Address2}}</line>
<line align="center">{{Phone}}</line>
<line />
<line />
<line align="center">{{CompanyName}}</line>
<line align="center">{{Address1}}</line>
<line align="center">{{Address2}}</line>
<line align="center">{{Phone}}</line>
<line />
<line />
<if test="IsDebtor">
<line>Debitorenrechnung</line>
</if>
<if test="IsDebtor">
<line>Debitorenrechnung</line>
</if>
<columns left="{{ReceiptLabel}}" right="{{DateTime:HH:mm dd.MM.yyyy}}" bold="true" />
<line>Guests: {{Guests}}</line>
<line />
<columns left="{{ReceiptLabel}}" right="{{DateTime:HH:mm dd.MM.yyyy}}" bold="true" />
<line>Guests: {{Guests}}</line>
<line />
<foreach items="Items" var="item">
<columns left="{{item.Quantity}}x {{item.Description}}" right="{{item.PriceDisplay}}" wrap="true" />
<foreach items="item.SubItems" var="sub">
<line> - {{sub}}</line>
</foreach>
</foreach>
<foreach items="Items" var="item">
<columns left="{{item.Quantity}}x {{item.Description}}" right="{{item.PriceDisplay}}" wrap="true" />
<foreach items="item.SubItems" var="sub">
<line> - {{sub}}</line>
</foreach>
</foreach>
<line />
<line align="right">---------</line>
<line />
<line />
<line align="right">---------</line>
<line />
<line align="center" big="true" bold="true">Summe: {{Total:F2}} {{Currency}}</line>
<line />
<line align="center" big="true" bold="true">Summe: {{Total:F2}} {{Currency}}</line>
<line />
<if test="TotalInAlternateCurrency">
<line align="right">{{TotalInAlternateCurrency:F2}} {{AlternateCurrency}}</line>
<line />
</if>
<if test="TotalInAlternateCurrency">
<line align="right">{{TotalInAlternateCurrency:F2}} {{AlternateCurrency}}</line>
<line />
</if>
<if test="DiscountInfo">
<columns left="{{DiscountInfo.Description}}" right="{{DiscountInfo.Amount:F2}} {{DiscountInfo.Currency}}" wrap="true" />
<line />
</if>
<if test="DiscountInfo">
<columns left="{{DiscountInfo.Description}}" right="{{DiscountInfo.Amount:F2}} {{DiscountInfo.Currency}}" wrap="true" />
<line />
</if>
<foreach items="SplitPayments" var="sp">
<line align="right">{{sp.PaymentMethod}}: {{sp.Amount:F2}} {{sp.Currency}}</line>
</foreach>
<if test="HasSplitPayments">
<line />
</if>
<foreach items="SplitPayments" var="sp">
<line align="right">{{sp.PaymentMethod}}: {{sp.Amount:F2}} {{sp.Currency}}</line>
</foreach>
<if test="HasSplitPayments">
<line />
</if>
<columns left="{{PaymentMethod}}" right="{{PaymentAmount:F2}} {{Currency}}" bold="true" />
<line />
<columns left="{{PaymentMethod}}" right="{{PaymentAmount:F2}} {{Currency}}" bold="true" />
<line />
<foreach items="TaxBreakdown" var="tax">
<if test="$first">
<row>
<col width="11" align="left">MwSt %</col>
<col width="11" align="right">Brutto</col>
<col width="11" align="right">Netto</col>
<col align="right">MwSt</col>
</row>
</if>
<row>
<col width="11" align="left">{{tax.Category}}:{{tax.Rate}}%</col>
<col width="11" align="right"> {{tax.Gross:F2}} {{tax.Currency}}</col>
<col width="11" align="right"> {{tax.Net:F2}} {{tax.Currency}}</col>
<col align="right"> {{tax.TaxAmount:F2}} {{tax.Currency}}</col>
</row>
</foreach>
<foreach items="TaxBreakdown" var="tax">
<if test="$first">
<row>
<col width="25%" align="left">MwSt %</col>
<col width="25%" align="right" wrap="true" >Brutto</col>
<col width="25%" align="right" wrap="true">Netto</col>
<col width="25%" align="right">MwSt</col>
</row>
</if>
<row>
<col width="25%" align="left">{{tax.Category}}:{{tax.Rate}}%</col>
<col width="25%" align="right"> {{tax.Gross:F2}} {{tax.Currency}}</col>
<col width="25%" align="right"> {{tax.Net:F2}} {{tax.Currency}}</col>
<col width="25%" align="right"> {{tax.TaxAmount:F2}} {{tax.Currency}}</col>
</row>
</foreach>
<if test="!HasTaxableCategories">
<line>Nicht mehrwertsteuerpflichtig</line>
</if>
<if test="!HasTaxableCategories">
<line>Nicht mehrwertsteuerpflichtig</line>
</if>
<line />
<line />
<columns left="Bedient von:" right="{{WaiterName}}" />
<if test="Terminal">
<columns left="Terminal:" right="{{Terminal}}" />
</if>
<columns left="Tisch:" right="{{TableNumber}}" />
<line />
<line />
<columns left="Bedient von:" right="{{WaiterName}}" />
<if test="Terminal">
<columns left="Terminal:" right="{{Terminal}}" />
</if>
<columns left="Tisch:" right="{{TableNumber}}" />
<line />
<line />
<line align="center">{{VatNumber}}</line>
<line />
<line align="center">{{VatNumber}}</line>
<line />
<foreach items="TerminalReceipts" var="tr">
<line align="center">{{tr.ReceiptType}}</line>
<line align="center">{{tr.BookingType}}</line>
<line align="center">{{tr.PaymentSystem}}</line>
<line>{{tr.TransactionNumber}}</line>
<columns left="{{tr.TransactionDateTime:dd.MM.yyyy}}" right="{{tr.TransactionDateTime:HH:mm:ss}}" />
<columns left="Trm-Id:" right="{{tr.TerminalId}}" />
<columns left="AID:" right="{{tr.AID}}" />
<columns left="Trx. Seq-Cnt:" right="{{tr.TransactionSeqCount}}" />
<columns left="Trx. Ref-No:" right="{{tr.TransactionRefNo}}" />
<columns left="Auth. Code:" right="{{tr.AuthCode}}" />
<columns left="Acq-Id:" right="{{tr.AcquirerId}}" />
<columns left="EFT {{tr.Currency}}:" right="{{tr.EftAmount:F2}}" />
<columns left="Trinkgeld {{tr.Currency}}:" right="{{tr.TipAmount:F2}}" />
<columns left="Total-EFT {{tr.Currency}}:" right="{{tr.TotalEftAmount:F2}}" />
<separator />
<line />
</foreach>
<foreach items="TerminalReceipts" var="tr">
<line align="center">{{tr.ReceiptType}}</line>
<line align="center">{{tr.BookingType}}</line>
<line align="center">{{tr.PaymentSystem}}</line>
<line>{{tr.TransactionNumber}}</line>
<columns left="{{tr.TransactionDateTime:dd.MM.yyyy}}" right="{{tr.TransactionDateTime:HH:mm:ss}}" />
<columns left="Trm-Id:" right="{{tr.TerminalId}}" />
<columns left="AID:" right="{{tr.AID}}" />
<columns left="Trx. Seq-Cnt:" right="{{tr.TransactionSeqCount}}" />
<columns left="Trx. Ref-No:" right="{{tr.TransactionRefNo}}" />
<columns left="Auth. Code:" right="{{tr.AuthCode}}" />
<columns left="Acq-Id:" right="{{tr.AcquirerId}}" />
<columns left="EFT {{tr.Currency}}:" right="{{tr.EftAmount:F2}}" />
<columns left="Trinkgeld {{tr.Currency}}:" right="{{tr.TipAmount:F2}}" />
<columns left="Total-EFT {{tr.Currency}}:" right="{{tr.TotalEftAmount:F2}}" />
<separator />
<line />
</foreach>
<line align="center">{{ThankYouMessage}}</line>
<line align="center">{{GoodbyeMessageLine1}}</line>
<line align="center">{{GoodbyeMessageLine2}}</line>
<line align="center">{{ThankYouMessage}}</line>
<line align="center">{{GoodbyeMessageLine1}}</line>
<line align="center">{{GoodbyeMessageLine2}}</line>
<if test="IsDebtor">
<line />
<line />
<line />
<separator />
<line>Unterschrift</line>
</if>
<if test="IsDebtor">
<line />
<line />
<line />
<separator />
<line>Unterschrift</line>
</if>
</receipt>

View File

@@ -47,7 +47,9 @@ public class ColumnDef
{
public string? Text { get; set; }
public int? Width { get; set; }
public double? WidthPercent { get; set; }
public string Align { get; set; } = "left";
public bool Wrap { get; set; }
}
public class SeparatorNode : TemplateNode

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Xml.Linq;
namespace Inspectron.Epson.TemplateEngine.Parsing;
@@ -92,9 +93,10 @@ public class TemplateParser
var col = new ColumnDef
{
Text = GetTextContent(colElement),
Width = GetNullableIntAttr(colElement, "width"),
Align = GetAttr(colElement, "align", "left")
Align = GetAttr(colElement, "align", "left"),
Wrap = GetBoolAttr(colElement, "wrap")
};
ParseColumnWidth(colElement, col);
node.Columns.Add(col);
}
@@ -158,9 +160,10 @@ public class TemplateParser
var col = new ColumnDef
{
Text = GetTextContent(colElement),
Width = GetNullableIntAttr(colElement, "width"),
Align = GetAttr(colElement, "align", "left")
Align = GetAttr(colElement, "align", "left"),
Wrap = GetBoolAttr(colElement, "wrap")
};
ParseColumnWidth(colElement, col);
node.Columns.Add(col);
}
@@ -247,4 +250,22 @@ public class TemplateParser
if (attr == null) return null;
return int.TryParse(attr.Value, out var v) ? v : null;
}
private static void ParseColumnWidth(XElement element, ColumnDef col)
{
var attr = element.Attribute("width");
if (attr == null) return;
var value = attr.Value.Trim();
if (value.EndsWith('%'))
{
if (double.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct))
col.WidthPercent = pct;
}
else
{
if (int.TryParse(value, out var w))
col.Width = w;
}
}
}

View File

@@ -109,6 +109,59 @@ public class LayoutEngine
return string.Concat(parts);
}
public List<string> FormatMultiColumnWithWrap(
List<(string text, int width, string align, bool wrap)> columns, int lineWidth)
{
// Calculate widths
int totalExplicit = columns.Where(c => c.width > 0).Sum(c => c.width);
int unspecifiedCount = columns.Count(c => c.width <= 0);
int remaining = lineWidth - totalExplicit;
int defaultWidth = unspecifiedCount > 0 ? remaining / unspecifiedCount : 0;
var colWidths = columns.Select(c => c.width > 0 ? c.width : Math.Max(defaultWidth, 1)).ToList();
// Wrap or truncate each column's text into lines
var allColLines = new List<List<string>>();
for (int i = 0; i < columns.Count; i++)
{
var (text, _, _, wrap) = columns[i];
text ??= "";
int w = colWidths[i];
if (wrap)
allColLines.Add(WrapText(text, w));
else
allColLines.Add(new List<string> { text.Length > w ? text[..w] : text });
}
// Find the tallest column
int maxLines = allColLines.Max(l => l.Count);
// Build each output line
var result = new List<string>();
for (int lineIdx = 0; lineIdx < maxLines; lineIdx++)
{
var parts = new List<string>();
for (int colIdx = 0; colIdx < columns.Count; colIdx++)
{
string cellText = lineIdx < allColLines[colIdx].Count ? allColLines[colIdx][lineIdx] : "";
int w = colWidths[colIdx];
string formatted = columns[colIdx].align.ToLowerInvariant() switch
{
"center" => CenterInWidth(cellText, w),
"right" => cellText.PadLeft(w),
_ => cellText.PadRight(w)
};
if (formatted.Length > w)
formatted = formatted[..w];
parts.Add(formatted);
}
result.Add(string.Concat(parts));
}
return result;
}
public List<string> WrapText(string text, int maxWidth, int indent = 0)
{
var lines = new List<string>();
@@ -177,20 +230,26 @@ public class LayoutEngine
List<(string text, int width, string align)> columns,
List<List<string>> headerRows,
List<List<string>> dataRows,
int lineWidth)
int lineWidth,
List<bool>? wrapFlags = null)
{
var result = new List<string>();
// Calculate column widths
var colWidths = CalculateTableColumnWidths(columns, lineWidth);
bool hasWrap = wrapFlags != null && wrapFlags.Any(w => w);
// Top border
result.Add(FormatTableBorder(colWidths));
// Header rows
foreach (var row in headerRows)
{
result.Add(FormatTableRow(row, colWidths, columns));
if (hasWrap)
result.AddRange(FormatTableRows(row, colWidths, columns, wrapFlags!));
else
result.Add(FormatTableRow(row, colWidths, columns));
}
// Separator between header and data
@@ -202,7 +261,10 @@ public class LayoutEngine
// Data rows
foreach (var row in dataRows)
{
result.Add(FormatTableRow(row, colWidths, columns));
if (hasWrap)
result.AddRange(FormatTableRows(row, colWidths, columns, wrapFlags!));
else
result.Add(FormatTableRow(row, colWidths, columns));
}
// Bottom border
@@ -245,6 +307,40 @@ public class LayoutEngine
return "|" + string.Join("|", parts) + "|";
}
private List<string> FormatTableRows(List<string> cells, List<int> colWidths,
List<(string text, int width, string align)> columns, List<bool> wrapFlags)
{
// Wrap each cell that has wrap enabled
var allColLines = new List<List<string>>();
for (int i = 0; i < colWidths.Count; i++)
{
string cell = i < cells.Count ? cells[i] : "";
bool wrap = i < wrapFlags.Count && wrapFlags[i];
if (wrap)
allColLines.Add(WrapText(cell, colWidths[i]));
else
allColLines.Add(new List<string> { cell.Length > colWidths[i] ? cell[..colWidths[i]] : cell });
}
int maxLines = allColLines.Max(l => l.Count);
var result = new List<string>();
for (int lineIdx = 0; lineIdx < maxLines; lineIdx++)
{
var parts = new List<string>();
for (int colIdx = 0; colIdx < colWidths.Count; colIdx++)
{
string cellText = lineIdx < allColLines[colIdx].Count ? allColLines[colIdx][lineIdx] : "";
string align = colIdx < columns.Count ? columns[colIdx].align : "left";
parts.Add(FormatCellContent(cellText, colWidths[colIdx], align));
}
result.Add("|" + string.Join("|", parts) + "|");
}
return result;
}
private string FormatCellContent(string text, int width, string align)
{
if (text.Length > width)

View File

@@ -145,15 +145,36 @@ public class TemplateRenderer
var evaluator = new ExpressionEvaluator(context);
int effectiveWidth = row.Big ? _bigFontLineWidth : _lineWidth;
var columnData = row.Columns.Select(c => (
text: evaluator.Evaluate(c.Text ?? ""),
width: c.Width ?? 0,
align: c.Align
)).ToList();
bool anyWrap = row.Columns.Any(c => c.Wrap);
string text = _layout.FormatMultiColumn(columnData, effectiveWidth);
var cmd = CreateCommand(text, row.Bold, row.Big, row.Tall, row.Red, row.LineSpacing);
commands.Add(cmd);
if (anyWrap)
{
var columnData = row.Columns.Select(c => (
text: evaluator.Evaluate(c.Text ?? ""),
width: ResolveColumnWidth(c, effectiveWidth),
align: c.Align,
wrap: c.Wrap
)).ToList();
var lines = _layout.FormatMultiColumnWithWrap(columnData, effectiveWidth);
foreach (var line in lines)
{
var cmd = CreateCommand(line, row.Bold, row.Big, row.Tall, row.Red, row.LineSpacing);
commands.Add(cmd);
}
}
else
{
var columnData = row.Columns.Select(c => (
text: evaluator.Evaluate(c.Text ?? ""),
width: ResolveColumnWidth(c, effectiveWidth),
align: c.Align
)).ToList();
string text = _layout.FormatMultiColumn(columnData, effectiveWidth);
var cmd = CreateCommand(text, row.Bold, row.Big, row.Tall, row.Red, row.LineSpacing);
commands.Add(cmd);
}
}
private void RenderSeparator(SeparatorNode separator, List<PrintCommand> commands)
@@ -199,9 +220,10 @@ public class TemplateRenderer
{
var evaluator = new ExpressionEvaluator(context);
int tableContentWidth = _lineWidth - table.Columns.Count - 1;
var columnDefs = table.Columns.Select(c => (
text: evaluator.Evaluate(c.Text ?? ""),
width: c.Width ?? 0,
width: ResolveColumnWidth(c, tableContentWidth),
align: c.Align
)).ToList();
@@ -267,13 +289,21 @@ public class TemplateRenderer
}
}
var lines = _layout.FormatTable(columnDefs, headerRows, dataRows, _lineWidth);
var wrapFlags = table.Columns.Select(c => c.Wrap).ToList();
var lines = _layout.FormatTable(columnDefs, headerRows, dataRows, _lineWidth, wrapFlags);
foreach (var line in lines)
{
commands.Add(new PrintCommand(line));
}
}
private static int ResolveColumnWidth(ColumnDef col, int availableWidth)
{
if (col.Width.HasValue) return col.Width.Value;
if (col.WidthPercent.HasValue) return (int)(availableWidth * col.WidthPercent.Value / 100.0);
return 0;
}
private static PrintCommand CreateCommand(string text, bool bold, bool big, bool tall, bool red, int? lineSpacing)
{
return new PrintCommand(text, isBig: big, isBold: bold)

View File

@@ -140,12 +140,20 @@ Multi-column layout with explicit column definitions. Each column is defined by
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `width` | int | _(auto)_ | Column width in characters. Unspecified columns share remaining space equally. |
| `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>
@@ -161,10 +169,28 @@ Multi-column layout with explicit column definitions. Each column is defined by
<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>`
@@ -244,10 +270,11 @@ If `headerItems` is not set, the text content of `<col>` elements is used as the
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `width` | int | _(auto)_ | Column width in characters (excluding border characters) |
| `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`.
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:**
@@ -275,8 +302,24 @@ Column widths exclude border characters. With 3 columns, 4 border characters (`|
|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
@@ -632,7 +675,7 @@ Comment: Well done
| `<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 | `bold`, `big`, `tall`, `red`, `lineSpacing` + nested `<col>` |
| `<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` |