docs/reference/print-templates-compliance.md
Rama main · réplica de lectura del repositorio original.
# Compliance print templates (`escpos-line-template-v1`)
A signed country tax pack can ship a declarative receipt template that renders through FloCafe's
built-in ESC/POS line renderer, `escposLineTemplateV1Lines` in `main/printers/thermal.ts`, under
the renderer id `flocafe-thermal-receipt-template`. The template is data: FloCafe validates it,
selects a width profile, and renders it with its own code. Nothing in a pack is executed. See
[data-only tax packs](../decisions/0004-data-only-tax-packs.md) for why that boundary exists.
This is the contract for pack authors. If you are building a merchant-facing template, you want
[merchant print templates](merchant-print-templates.md), which is a different trust model over
different data. Do not model merchant features on this contract.
## Payload fields
| Field | Type | Required | Behaviour |
| --- | --- | --- | --- |
| `format` | `"escpos-line-template-v1"` | yes | Contract identifier. Must match exactly. |
| `widthProfiles` | array | yes | Per-width column layouts. Each profile's `columns` must be an integer from 32 to 48. At least one profile is required, and the set must cover each width a declaring pack names. |
| `header` | object | no | Author strings: `businessNameTransform` (`uppercase`), `taxTitleWhenTaxPresent`, `titleWhenTaxAbsent`. |
| `fields.taxRegistrationNumberLabel` | string | no | Author label for the tax registration line. |
| `totals.grandTotalLabel` | string | no | Author label for the bold grand-total row. |
| `totals.chargeRows` | array of `serviceCharge`, `deliveryCharge`, `packagingCharge` | no | Opts into persisted nonzero charge rows. Output stays in service, delivery, packaging order, and zero values stay absent. Duplicates and unknown ids are rejected. |
| `totals.showSubtotal`, `totals.showDiscount` | boolean | no | Toggle the subtotal and discount rows. Both default on. |
| `totals.showTaxRegistrationNumber` | string | no | `when_tax_present_or_enabled`, or the default visibility rule. |
| `footer.defaultMessage` | string | no | Author footer, used when no configured footer note applies. |
| `footer.useConfiguredFooterNote` | boolean | no | Prefer the merchant's configured footer note. Defaults on. |
| `footer.includePoweredByFloPOS` | boolean | no | Append the FloPOS branding footer. Defaults on. |
| `labels` | object | no | Optional map of semantic label id to override string. See below. |
Unknown fields are tolerated at render time, so a pack that carries a newer field still renders on
an app that does not know it.
## The optional `labels` map
A pack may ship a payload-root `labels` map to override the built-in fallback labels with
jurisdiction-specific copy:
```json
{
"format": "escpos-line-template-v1",
"widthProfiles": [{ "columns": 48, "layout": {} }],
"labels": {
"total": "SUMA TOTAL",
"footerThanks": "¡Gracias por su visita!"
}
}
```
### Label ids
These eleven ids are stable public identifiers. They are not internal i18n keys, and once shipped
an id never changes meaning.
| Id | Catalog concept | Used for |
| --- | --- | --- |
| `invoice` | `print.invoiceTitle` | Receipt title when no tax applies. |
| `taxInvoice` | `print.taxInvoiceTitle` | Receipt title when tax applies. |
| `subtotal` | `pos.subtotal` | Subtotal row. |
| `discount` | `pos.discount` | Discount row. |
| `tax` | `pos.tax` | Tax row. |
| `total` | `print.grandTotal` | Bold grand-total row. |
| `serviceCharge` | `receipt.serviceCharge` | Persisted service-charge row. |
| `deliveryCharge` | `pos.delivery` | Persisted delivery-charge row. |
| `packagingCharge` | `pos.packaging` | Persisted packaging-charge row. |
| `taxIncluded` | `receipt.taxIncluded` | Tax-inclusive pricing note. |
| `footerThanks` | `print.thankYouShort` | Default footer when no configured footer note applies. |
Each id maps to a concept in the generated print-label catalog, so an override replaces the
localized default for that concept rather than inventing a new string that nothing else can
translate.
### Resolution order
Each label resolves in three steps, first hit wins:
1. The pack's structural author string, for example `totals.grandTotalLabel`.
2. The matching entry in the payload's `labels` map.
3. The built-in default, localized through the print-label catalog in the receipt's language.
### Install-time validation
`validateTemplateLabelsMap()` in `main/print/template-labels.ts` fails closed. It rejects the pack
install when:
- `labels` is present and is not a plain object. An array is rejected.
- The map has more than `TEMPLATE_LABELS_MAX_ENTRIES`, which is 64, entries.
- A key is not one of the eleven ids above.
- A value is not a non-empty string, or is longer than `TEMPLATE_LABELS_MAX_VALUE_LENGTH`, which is
120, characters.
`validateTemplateChargeRows()` applies the same fail-closed treatment to `totals.chargeRows`: a
non-array, an unknown id, or a duplicate id rejects the install.
### Render-time handling
The install-time caps do not replace render-time hardening, because a row stored before a cap
existed, or edited directly in a database, can still hold arbitrary text. At render time
`resolveTemplateLabel()` and `fitTemplateLabel()`:
- Strip reserved printer control tokens. `sanitizeTemplateLabelText()` removes anything matching
`\{[A-Z_/]+\}`, which covers `{CUT}`, `{FEED}`, `{INIT}`, and the styling braces.
- Clamp to the selected width profile's column count. Below 4 columns the text is sliced to exactly
that many characters; above that it keeps `columns - 2` characters and appends `..`, so the
truncation is visible. Shipped profiles have column counts from 32 to 48.
The renderer version stays 1. The `labels` map is additive and optional, so a pack without it
renders identically on old and new app versions.
## Width profile selection
`collectTemplateWidthProfiles()` keeps only profiles whose `columns` is an integer from 32 to 48 and
sorts them ascending. `selectTemplateWidthProfile()` then picks, in order:
1. A profile whose `columns` exactly equals the printer's column count.
2. The largest profile narrower than the printer, so a template authored at 42 columns still
renders on 48-column hardware without being stretched.
3. No profile match: render with the printer's own width and an empty layout, and push a
`bill_template_width` warning naming the requested width. FloCafe does not squeeze a wider
authored profile into a narrower printer.
## Renderer gate
`renderPluginReceipt()` refuses any installed template whose `renderer.id` is not
`flocafe-thermal-receipt-template`, whose `renderer.version` is not 1, or whose payload `format` is
not `escpos-line-template-v1`. Anything else throws rather than falling through to a different
renderer, so a malformed pack fails loudly instead of printing a receipt with a layout nobody
reviewed.