ESL Rendering
ESL rendering converts HCL label templates into PNG images optimized for e-ink displays. The process involves template parsing, element rendering with conditional logic, and a specialized post-processing pipeline that dithers colors to a 4-color palette for SoluM label compatibility.
Rendering Pipeline
HCL Template Format
ESL label templates are defined in HCL (HashiCorp Configuration Language). The root structure contains a screen block, optional post_process block, and one or more label blocks.
Structure
screen {
width = <int> # Label canvas width in pixels
height = <int> # Label canvas height in pixels
dpi = <int> # Display dots-per-inch (informational)
max_pages = <int> # Maximum number of pages (default: 7)
}
post_process {
unsharp_radius = <int> # Radius for unsharp mask enhancement
unsharp_amount = <float> # Strength of unsharp mask (0.0-2.0+)
}
label "name" {
background_color = <hex> # Canvas background, e.g. "#FFFFFF"
element "name" {
type = "text" | "rect" | "icon" | "table" | "page_indicator"
x = <int>
y = <int>
width = <int>
height = <int>
# For text elements:
text = <string> # Static text (overrides value)
value = <string> # Go template string (see Go Templates section)
align = "left" | "center" | "right"
max_lines = <int>
font {
name = "Terminus" # Font family (current default)
variation = "Regular" | "Bold" # Font weight
size = <int> # Font size in pixels
color = <hex> # Text color, e.g. "#000000"
}
# For rect elements:
fill = <hex> # Fill color
stroke = <hex> # Stroke color
stroke_width = <int>
filled = true | false
rounded = true | false # Enable rounded corners
radius = <int> # Corner radius if rounded=true
# For icon elements:
icon = <string> # Icon name (without .svg extension)
fill = <hex> # Color override for fill and stroke
# For table elements:
overflow = "clip" | "paginate" # "clip": truncate rows; "paginate": split across pages
value = <string> # Data map key containing row array
table {
row_height = <int> # Height of each data row in pixels
header_height = <int> # Height of header row (optional, defaults to row_height)
show_header = true | false # Display header row
border_color = <hex> # Table border color (optional)
stripe_color = <hex> # Alternating row background color (optional)
header_font {
name = "Terminus"
variation = "Regular" | "Bold"
size = <int>
color = <hex>
}
column "name" {
header = <string> # Column header text
value = <string> # Go template string for cell content
width = <int> # Column width in pixels
align = "left" | "center" | "right"
font {
name = "Terminus"
variation = "Regular" | "Bold"
size = <int>
color = <hex>
}
}
}
# For page_indicator elements:
format = <string> # Format string, e.g. "{page}/{pages}" or "Page {page}"
# Uses font block for text styling
# Optional: conditional rendering
condition {
field = <string> # Data map field name
value = <string> # Expected value (string comparison)
operator = "eq" | "ne"
# Boolean logic (optional):
and {
field = <string>
value = <string>
operator = "eq" | "ne"
}
or {
field = <string>
value = <string>
operator = "eq" | "ne"
}
}
}
}
Example Template
[PLACEHOLDER: Include an example from default_200x200.hcl or a minimal synthetic example showing screen, post_process, and a few element types]
Font Scaling Strategy
Fonts are embedded as BDF (Bitmap Distribution Format) files at fixed sizes: 12, 14, 16, 18, 20, 22, 24, 28, 32px. When a template requests a size not in this list, the renderer applies an integer scale factor to the nearest suitable native size.
Algorithm
- Exact match: If requested size is in the available list, use it directly (scale = 1)
- Clean multiple: Find the largest native size where
requested % native == 0- Example: requested=24 (matches 24px native, scale=1)
- Example: requested=48 (matches 2x24px native, scale=2)
- Fallback: If no clean multiple, use the largest available native size and round up scale
- Example: requested=50, largest native=32, scale=2 (ceil(50/32))
Rendering with Scale
- Scale = 1: Render text directly at requested dimensions
- Scale > 1:
- Render text at native size to a temporary context
- Scale up using nearest-neighbor interpolation
- Composite scaled image onto canvas
This approach preserves the bitmap-quality grid structure of BDF fonts while allowing arbitrary sizes.
Condition Evaluation
Element visibility is controlled by conditions evaluated against the data map. Conditions support boolean logic with AND/OR chains.
Basic Condition
condition {
field = "product_type"
value = "book"
operator = "eq"
}
Evaluates: data["product_type"] == "book"
Boolean Logic
AND chain (all must be true):
condition {
field = "category"
value = "electronics"
operator = "eq"
and {
field = "in_stock"
value = "true"
operator = "eq"
}
}
OR chain (at least one must be true):
condition {
field = "price_tier"
value = "premium"
operator = "eq"
or {
field = "sale_active"
value = "true"
operator = "eq"
}
}
Semantics
- Conditions always compare string values (data values are formatted as strings internally)
- AND/OR conditions support short-circuit evaluation
- If a data field is missing, the condition is false
- Operators:
eq(equal) andne(not equal)
Go Templates & Localization
The value field in text elements accepts Go text/template syntax. Templates are executed in a context that includes:
- Data map: Product attributes and computed fields passed to the renderer
- Localization context: Message translations and timezone-aware formatters
Template Functions
The i18n localizer provides:
- Message lookup:
{{ t "MessageID" }}— translates text for the given language and timezone
Messages can include Go template syntax themselves, with access to template data passed to the renderer.
Example
Given:
- Language:
"en" - Timezone:
"America/New_York" - Data:
{"ProductName": "Widget", "Price": "19.99"}
Template:
{{ t "Default.Label.Item" }}: {{ .ProductName }}
Message definition in en.yaml:
Default:
Label:
Item: "Item"
Output:
Item: Widget
Message Translations
Translations are stored in YAML files by language code (en.yaml, de.yaml, etc.). Message keys use dot notation for hierarchical organization:
# en.yaml
Default:
Label:
ItemNumber: "Item #"
Price: "Price"
# de.yaml
Default:
Label:
ItemNumber: "Artikel-Nr."
Price: "Preis"
Template values in HCL can reference these keys:
value = "{{ t \"Default.Label.Price\" }}: {{ .Price }}"
Messages can also include date formatting using the date function with template data:
# en.yaml
Default:
Label:
Updated: "Updated: {{ date .UpdatedAt \"medium\" }}"
Pagination & Multi-page Rendering
The renderer supports multi-page output when elements use the overflow = "paginate" setting. This is primarily used for displaying large datasets in table elements.
How Pagination Works
- Page Context Computation: Before rendering, the renderer scans for paginating table elements (type="table" with overflow="paginate")
- Row Distribution: Available height is calculated from element dimensions and row height, then rows are distributed across pages
- Multiple Outputs:
RenderPages()returns[][]bytewith one PNG per page - Page Limits:
max_pagesin the screen block caps the number of generated pages (default: 7)
Table with Pagination
element "products_table" {
type = "table"
x = 10
y = 30
width = 280
height = 160
overflow = "paginate" # Split rows across pages if needed
value = "products" # Data map key containing row array
table {
row_height = 20
header_height = 25
show_header = true
border_color = "#000000"
stripe_color = "#F0F0F0"
header_font {
name = "Terminus"
variation = "Bold"
size = 12
color = "#000000"
}
column "sku" {
header = "SKU"
value = "{{ .SKU }}"
width = 80
align = "left"
font {
name = "Terminus"
variation = "Regular"
size = 10
color = "#000000"
}
}
column "name" {
header = "Product"
value = "{{ .Name }}"
width = 120
align = "left"
font {
name = "Terminus"
variation = "Regular"
size = 10
color = "#000000"
}
}
column "price" {
header = "Price"
value = "{{ .Price }}"
width = 80
align = "right"
font {
name = "Terminus"
variation = "Regular"
size = 10
color = "#000000"
}
}
}
}
Page Indicator
Display current page number on multi-page output:
element "page_number" {
type = "page_indicator"
x = 240
y = 155
width = 60
height = 20
format = "{page}/{pages}" # Placeholder values: {page} (current page 1-based), {pages} (total pages)
align = "center"
font {
name = "Terminus"
variation = "Regular"
size = 10
color = "#000000"
}
}
Pagination Algorithm
For a paginating table:
- Calculate available rows per page:
(element.height - header_height) / row_height - Minimum 1 row per page
- Split rows: Distribute all rows from data map evenly across pages
- Page count:
ceil(total_rows / rows_per_page), capped bymax_pages - Render: For each page computed, render a full canvas with all elements, providing the appropriate row subset to the table element
Data Format for Tables
Table data must be an array of objects in the data map:
data := map[string]any{
"products": []map[string]any{
{"SKU": "A001", "Name": "Widget", "Price": "$19.99"},
{"SKU": "A002", "Name": "Gadget", "Price": "$29.99"},
// ... more rows
},
}
Table column value fields are Go templates executed for each row with the row data as context.
Table Rendering
Table elements display structured data with optional headers, row striping, and borders.
Table Features
- Headers: Show column names with custom font styling
- Row Height: Fixed height per row; header can have different height
- Striping: Alternate row background colors (every other row)
- Borders: Vertical dividers between columns and horizontal lines between rows
- Pagination: If overflow="paginate", rows are split across pages
Table Cell Content
Column value fields support Go template syntax, with access to row data:
column "price" {
header = "Price"
value = "{{ t \"Default.Label.Price\" }}: {{ .Price }}"
width = 80
font {
size = 10
}
}
Dithering & E-ink Compatibility
The final post-processing step quantizes the rendered image to a 4-color palette optimized for SoluM electronic price labels. This is not a limitation but a design requirement for e-ink display compatibility.
Four-Color Palette
| Color | Hex Code |
|---|---|
| Black | #000000 |
| White | #FFFFFF |
| Red | #FF0000 |
| Yellow | #FFFF00 |
Dithering Algorithm
The renderer uses nearest-neighbor color quantization:
- For each pixel in the rendered image, calculate the Euclidean distance (in RGB space) to each palette color
- Replace the pixel with the closest palette color
- No error diffusion or sophisticated dithering patterns — maximizes legibility on e-ink
This approach minimizes halftoning artifacts that would reduce readability on monochrome e-ink displays.
Post-Processing Order
The order of post-processing steps is critical:
- Unsharp mask: Enhances edges and details before color reduction
- Gaussian blur: Smooths high-frequency noise (sigma=0.5, radius=3)
- Dither to 4-color: Quantizes final result
This order ensures that edge sharpening happens in true-color space before quantization, reducing visible banding and improving contrast on e-ink.