Skip to content

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

uml diagram

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

  1. Exact match: If requested size is in the available list, use it directly (scale = 1)
  2. 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)
  3. 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) and ne (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

  1. Page Context Computation: Before rendering, the renderer scans for paginating table elements (type="table" with overflow="paginate")
  2. Row Distribution: Available height is calculated from element dimensions and row height, then rows are distributed across pages
  3. Multiple Outputs: RenderPages() returns [][]byte with one PNG per page
  4. Page Limits: max_pages in 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:

  1. Calculate available rows per page: (element.height - header_height) / row_height
  2. Minimum 1 row per page
  3. Split rows: Distribute all rows from data map evenly across pages
  4. Page count: ceil(total_rows / rows_per_page), capped by max_pages
  5. 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:

  1. For each pixel in the rendered image, calculate the Euclidean distance (in RGB space) to each palette color
  2. Replace the pixel with the closest palette color
  3. 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:

  1. Unsharp mask: Enhances edges and details before color reduction
  2. Gaussian blur: Smooths high-frequency noise (sigma=0.5, radius=3)
  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.