Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SVG Templates

SVG templates define the visual layout of your screens. They use Tera templating syntax to insert data from your Lua scripts.

Template Basics

A Byonk SVG template is a standard SVG file with Tera expressions:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <rect width="800" height="480" fill="white"/>

  <text x="400" y="240" text-anchor="middle" font-size="24">
    {{ message }}
  </text>
</svg>

Key points:

  • Set viewBox to 0 0 800 480 for TRMNL OG (or 0 0 1872 1404 for TRMNL X)
  • Always include width and height attributes
  • Use {{ variable }} to insert values from Lua

Display Dimensions

DeviceWidthHeightAspect Ratio
TRMNL OG8004805:3
TRMNL X187214044:3

Byonk automatically scales your SVG to fit the display, but matching the aspect ratio gives the best results.

Variables

Template Namespaces

Variables in templates are organized into four namespaces:

NamespaceSourceExample
data.*Lua script return valuedata.title, data.items
device.*Device info (battery, signal)device.battery_voltage, device.rssi
params.*Config params from config.yamlparams.station, params.limit
layout.*Pre-computed layout valueslayout.width, layout.grey_count

Device Variables

These are automatically available under device.*:

VariableTypeDescription
device.macstringDevice MAC address (e.g., “AC:15:18:D4:7B:E2”)
device.battery_voltagefloat or nilBattery voltage (e.g., 4.12)
device.rssiinteger or nilWiFi signal strength in dBm (e.g., -65)
device.modelstring or nilDevice model (“og” or “x”)
device.firmware_versionstring or nilFirmware version string
device.widthinteger or nilDisplay width in pixels (800 or 1872)
device.heightinteger or nilDisplay height in pixels (480 or 1404)
<!-- Display battery and signal in header -->
<text class="status" x="780" y="25" text-anchor="end">
  {% if device.battery_voltage %}{{ device.battery_voltage | round(precision=2) }}V{% endif %}
  {% if device.rssi %} · {{ device.rssi }}dBm{% endif %}
</text>

<!-- Responsive layout based on device dimensions -->
{% if device.width == 1872 %}
  <!-- TRMNL X layout (1872x1404) -->
{% else %}
  <!-- TRMNL OG layout (800x480) -->
{% endif %}

Note: Some device variables may be nil if the device doesn’t report them. Always use {% if device.variable %} to check before using.

Layout Variables

Pre-computed layout values are available under layout.*. These mirror the layout table available in Lua scripts:

VariableTypeDescription
layout.widthintegerDisplay width in pixels (default 800)
layout.heightintegerDisplay height in pixels (default 480)
layout.scalefloatScale factor relative to 800×480 base
layout.center_xintegerHorizontal center (width / 2)
layout.center_yintegerVertical center (height / 2)
layout.marginintegerStandard margin (20px × scale)
layout.margin_smintegerSmall margin (10px × scale)
layout.margin_lgintegerLarge margin (40px × scale)
layout.colorsarrayDisplay color palette (hex strings)
layout.color_countintegerNumber of colors in palette (default 4)
layout.grey_countintegerNumber of grey levels in palette (default 4)

This is useful for conditional logic in SVG templates without needing Lua to pass the values through — for example, the components/hinting.svg include uses layout.grey_count to switch between mono and smooth font hinting.

Basic Interpolation

<text>{{ data.title }}</text>
<text>{{ data.user.name }}</text>
<text>{{ data.items[0].label }}</text>

Filters

Apply filters to modify values:

<!-- Truncate long text -->
<text>{{ data.description | truncate(length=50) }}</text>

<!-- Format timestamp (uses UTC) -->
<text>{{ data.updated_at | format_time(format="%H:%M") }}</text>

<!-- Get length -->
<text>{{ data.items | length }} items</text>

Tip: The format_time template filter uses UTC timezone. For local time formatting, use time_format() in your Lua script and pass the pre-formatted string to the template.

Default Values

<text>{{ data.title | default(value="Untitled") }}</text>

Control Flow

Conditionals

{% if data.error %}
  <text fill="red">Error: {{ data.error }}</text>
{% else %}
  <text>All systems operational</text>
{% endif %}

Comparisons

{% if data.count > 0 %}
  <text>{{ data.count }} items</text>
{% elif data.count == 0 %}
  <text>No items</text>
{% endif %}

{% if data.status == "active" %}
  <circle fill="green" r="10"/>
{% endif %}

Boolean Checks

{% if data.is_online %}
  <text fill="green">Online</text>
{% endif %}

{% if not data.items %}
  <text>No data available</text>
{% endif %}

Loops

Basic Loop

{% for item in data.items %}
  <text y="{{ 100 + loop.index0 * 30 }}">{{ item.name }}</text>
{% endfor %}

Loop Variables

VariableDescription
loop.indexCurrent iteration (1-indexed)
loop.index0Current iteration (0-indexed)
loop.firstTrue on first iteration
loop.lastTrue on last iteration

Positioning with Loops

{% for dep in data.departures %}
  <!-- Calculate Y position based on index -->
  <text y="{{ 80 + loop.index0 * 40 }}">
    {{ dep.time }} - {{ dep.destination }}
  </text>
{% endfor %}

Conditional Styling in Loops

{% for item in data.items %}
  <!-- Alternating row backgrounds -->
  {% if loop.index0 is odd %}
    <rect y="{{ 100 + loop.index0 * 40 }}" width="800" height="40" fill="#f5f5f5"/>
  {% endif %}

  <text y="{{ 125 + loop.index0 * 40 }}">{{ item.name }}</text>
{% endfor %}

Empty State

{% if data.items | length > 0 %}
  {% for item in data.items %}
    <text>{{ item.name }}</text>
  {% endfor %}
{% else %}
  <text fill="#999">No items found</text>
{% endif %}

Styling

Inline Styles

<text x="20" y="40"
      font-family="sans-serif"
      font-size="24"
      font-weight="bold"
      fill="black">
  {{ data.title }}
</text>

CSS in Style Block

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
  <style>
    .title { font-family: sans-serif; font-size: 32px; font-weight: bold; }
    .subtitle { font-family: sans-serif; font-size: 18px; fill: #666; }
    .highlight { fill: #333; font-weight: bold; }
  </style>

  <text class="title" x="20" y="40">{{ data.title }}</text>
  <text class="subtitle" x="20" y="70">{{ data.subtitle }}</text>
</svg>

Variable Fonts

Byonk supports variable fonts via CSS font-variation-settings:

<style>
  .light { font-family: Outfit; font-variation-settings: "wght" 300; }
  .regular { font-family: Outfit; font-variation-settings: "wght" 400; }
  .bold { font-family: Outfit; font-variation-settings: "wght" 700; }
</style>

Note: Place custom font files (e.g., Outfit-Variable.ttf) in the fonts/ directory.

Colors and Palettes

E-ink displays support a limited color palette. The default 4-grey OG palette is #000000, #555555, #AAAAAA, #FFFFFF, but color displays may have palettes like #000000, #FFFFFF, #FF0000, #FFFF00. The display palette is available in Lua via layout.colors.

The palette follows a priority chain: Lua script colors return > device config colors > firmware Colors header > system default. You can override the palette per-device in config.yaml:

devices:
  "ABCDE-FGHJK":
    screen: transit
    colors: "#000000,#FFFFFF,#FF0000"

Or per-script by returning colors in the Lua result table (see Lua API: colors).

Dithering Mode

Byonk supports two dithering modes via the dither option:

  • graphics (default) — Blue noise ordered dithering, best for UI content
  • photo — Atkinson error diffusion, best for photographs

Set per-device in config.yaml or per-script in the Lua return table (see Lua API: dither and Content Pipeline: Rendering Intents).

Using Palette Colors

For the cleanest output, use colors from the display palette directly. Byonk will dither any color to the nearest palette color, but exact palette matches are preserved without dithering.

-- In your Lua script
local colors = layout.colors  -- e.g., {"#000000", "#555555", "#AAAAAA", "#FFFFFF"}
<!-- Use palette colors for crisp rendering -->
{% for swatch in data.swatches %}
<rect x="{{ swatch.x }}" width="{{ swatch.width }}" height="100" fill="{{ swatch.color }}"/>
{% endfor %}

Testing Display Colors

The included graytest screen adapts to the device palette and shows all available colors as swatches with gradient and dithering tests.

4-grey palette (TRMNL OG default):

Display color test — 4 grey

6-color palette (color e-ink display):

Display color test — 6 color

The default screen also adapts to the palette:

Default screen — 6 color

Avoid

  • Gradients - Convert to dithered patterns (may look noisy)
  • Subtle color differences - May become indistinguishable on limited palettes
  • Colors not in palette - Will be dithered to nearest match

Font Rendering for E-ink

Byonk uses a patched version of resvg for SVG rendering, which adds font hinting support with custom CSS properties for fine-tuning. Getting these right makes a big difference on e-ink displays, where there are few (or no) gray levels to smooth out font edges.

Available Properties

PropertyValuesDefaultDescription
-resvg-hinting-targetsmooth, monosmoothmono for 1-bit displays, smooth for displays with gray levels
-resvg-hinting-modenormal, light, lcd, vertical-lcdnormalHinting strength. normal = strongest grid-fitting, light = softer
-resvg-hinting-engineauto-fallback, auto, nativeauto-fallbackauto uses FreeType’s auto-hinter (more consistent), native uses the font’s built-in hints
-resvg-hinting-symmetrictrue, falsetrueSymmetric rasterization. false can improve consistency at small sizes
-resvg-hinting-preserve-linear-metricstrue, falsefalsetrue forces uniform glyph spacing
shape-renderingauto, crispEdges, geometricPrecisionautocrispEdges disables anti-aliasing on shapes and lines
text-renderingauto, optimizeSpeed, optimizeLegibility, geometricPrecisionautoHint for text rendering quality

1-bit display (black & white only):

text {
  -resvg-hinting-target: mono;
  -resvg-hinting-mode: normal;
  -resvg-hinting-engine: auto;
  -resvg-hinting-symmetric: false;
  -resvg-hinting-preserve-linear-metrics: true;
  shape-rendering: crispEdges;
}

4 gray levels:

text {
  -resvg-hinting-target: smooth;
  -resvg-hinting-mode: normal;
  -resvg-hinting-engine: auto;
  shape-rendering: crispEdges;
}

16 gray levels:

text {
  -resvg-hinting-target: smooth;
  -resvg-hinting-mode: light;
  -resvg-hinting-engine: auto;
}

Adaptive Hinting

The components/hinting.svg include automatically applies the right hinting settings based on the display’s grey levels:

<style>
  text {
    {% include "components/hinting.svg" %}
  }
</style>

On black-and-white displays (grey_count <= 2) it enables mono hinting with crispEdges; on displays with more grey levels it uses smooth hinting. All built-in screens use this component.

Hinting Demo Screen

The built-in hintdemo screen provides a visual comparison of all hinting engine and target combinations in a 3×3 grid:

  • Columns: mono, normal (smooth), light (smooth)
  • Rows: auto engine, native engine, no hinting

Enable it in your config.yaml to see how different settings affect text rendering at various font sizes on your actual display.

Hinting demo screen

Tips

  • Use the auto engine. The FreeType auto-hinter applies a consistent algorithm to all glyphs. Native font hints vary in quality and can produce inconsistent letterforms (e.g., the same letter rendering differently at the same size).
  • Choose font sizes that land on whole pixel boundaries. Fractional pixel heights cause glyphs to snap to the grid differently, producing inconsistent shapes.
  • shape-rendering: crispEdges eliminates anti-aliased edges on lines and rectangles — important when there are few gray levels to work with.
  • Test on your actual display. Optimal settings depend on the font, font size, and display capabilities. The presets above are starting points.

Bitmap Fonts

Byonk ships with X11 bitmap fonts converted to TTF files. These contain embedded bitmap strikes — pre-rendered glyphs at specific pixel sizes — which produce perfectly crisp text on e-ink displays without any hinting artifacts.

Available Families

Proportional fonts:

FamilyStylesPixel Sizes
X11HelvRegular, Bold, Oblique, BoldOblique8, 10, 11, 12, 14, 17, 18, 20, 24, 25, 34
X11LuSansRegular, Bold, Oblique, BoldOblique8–34 (13 sizes)
X11LuTypeRegular, Bold8–34 (13 sizes)
X11TermRegular, Bold14, 18

Fixed-width fonts (grouped by cell width):

FamilyStylesPixel Sizes
X11Misc5xRegular6, 7, 8
X11Misc6xRegular, Bold, Oblique9, 10, 12, 13
X11Misc7xRegular, Bold, Oblique13, 14
X11Misc8xRegular, Bold, Oblique13, 16
X11Misc9xRegular, Bold15, 18
X11Misc10xRegular20
X11Misc12xRegular24

Usage

Set font-family to the family name and font-size to a pixel size that matches a bitmap strike. The renderer automatically selects the closest strike:

<text font-family="X11Helv" font-size="14">Proportional text</text>
<text font-family="X11Helv" font-size="14" font-weight="700">Bold</text>
<text font-family="X11Misc7x" font-size="13">Fixed width</text>
<text font-family="X11Misc7x" font-size="13" font-style="oblique">Fixed oblique</text>

For sizes without an exact bitmap strike, autotraced scalable outlines are used as fallback — but these won’t look as clean as the native bitmap sizes.

Bitmap Font Demo Screen

The built-in fontdemo-bitmap screen showcases all sizes and styles for a given font family. Configure it with the font_prefix parameter:

screens:
  fontdemo-bitmap:
    params:
      font_prefix: X11Helv   # or X11LuSans, X11LuType, X11Term, X11Misc

This renders each available size and style combination as a labeled line, useful for picking the right font and size for your screen.

Bitmap font demo - X11Helv

Bitmap font demo - X11Misc

Layout Patterns

Header + Content

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
  <!-- Header bar -->
  <rect width="800" height="70" fill="black"/>
  <text x="30" y="48" fill="white" font-size="28">{{ data.title }}</text>
  <text x="770" y="48" fill="#aaa" font-size="14" text-anchor="end">{{ data.time }}</text>

  <!-- Content area -->
  <g transform="translate(0, 70)">
    <!-- Your content here, Y coordinates start at 0 -->
  </g>
</svg>

Grid Layout

{% for item in data.items %}
  {% set col = loop.index0 % 3 %}
  {% set row = loop.index0 // 3 %}

  <rect x="{{ col * 266 }}" y="{{ 80 + row * 100 }}"
        width="260" height="90" fill="#f0f0f0" rx="5"/>
  <text x="{{ col * 266 + 130 }}" y="{{ 130 + row * 100 }}"
        text-anchor="middle">{{ item.name }}</text>
{% endfor %}

Two Columns

<!-- Left column -->
<text x="30" y="100">Left content</text>

<!-- Divider -->
<line x1="400" y1="80" x2="400" y2="450" stroke="#ccc"/>

<!-- Right column -->
<text x="430" y="100">Right content</text>

Dynamic Styling

Conditional Colors

{% for item in data.items %}
  <text fill="{% if item.is_urgent %}red{% else %}black{% endif %}">
    {{ item.name }}
  </text>
{% endfor %}

Dynamic Classes

<text class="{% if data.count > 100 %}highlight{% else %}normal{% endif %}">
  {{ data.count }}
</text>

Status Indicators

{% if data.status == "online" %}
  <circle cx="20" cy="20" r="8" fill="green"/>
{% elif data.status == "warning" %}
  <circle cx="20" cy="20" r="8" fill="orange"/>
{% else %}
  <circle cx="20" cy="20" r="8" fill="red"/>
{% endif %}

Common Patterns

Truncating Long Text

<text>
  {% if data.title | length > 30 %}
    {{ data.title | truncate(length=30) }}
  {% else %}
    {{ data.title }}
  {% endif %}
</text>

Formatted Numbers

Use Lua to format numbers before passing to template:

-- In Lua script
return {
  data = {
    temperature = string.format("%.1f°C", temp),
    price = string.format("$%.2f", amount)
  }
}

Time-Based Styling

-- In Lua script
local hour = tonumber(time_format(time_now(), "%H"))
return {
  data = {
    is_night = hour < 6 or hour > 20
  }
}
<rect width="800" height="480" fill="{% if is_night %}#333{% else %}white{% endif %}"/>

Debugging Templates

Show Raw Data

<!-- Temporarily add this to see all data -->
<text x="10" y="460" font-size="10" fill="#999">
  Debug: {{ data.items | length }} items
</text>

Check for Missing Data

{% if not data.title %}
  <text fill="red">ERROR: title is missing!</text>
{% endif %}

Template Errors

If your template has a syntax error, Byonk will display an error screen with the message. Check the server logs for details.

Embedding Images

Byonk supports embedding images in your SVG templates. You can include PNG, JPEG, GIF, WebP, and SVG files.

Asset Directory Structure

Place your screen assets in a subdirectory matching your screen name:

screens/
├── hello.lua         # Script at top level
├── hello.svg         # Template at top level
└── hello/            # Assets for "hello" screen
    ├── logo.png
    ├── icon.svg
    └── background.jpg

Method 1: Direct in SVG (Automatic Resolution)

Simply reference images by filename in your SVG template. Byonk automatically resolves relative paths to the screen’s asset directory and embeds them as data URIs:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
  <!-- This automatically loads screens/hello/logo.png -->
  <image x="10" y="10" width="64" height="64" href="logo.png"/>

  <text x="100" y="50">{{ data.greeting }}</text>
</svg>

Supported image formats:

  • PNG (.png)
  • JPEG (.jpg, .jpeg)
  • GIF (.gif)
  • WebP (.webp)
  • SVG (.svg)

Notes:

  • Paths are relative to the screen’s asset directory
  • URLs starting with data:, http://, or https:// are left unchanged
  • Missing images log a warning but don’t break rendering

Method 2: Via Lua (For Dynamic Images)

For more control, use read_asset() and base64_encode() in your Lua script:

screens/hello.lua:

local icon = read_asset("icon.png")

return {
    data = {
        greeting = "Hello World!",
        icon_src = "data:image/png;base64," .. base64_encode(icon)
    },
    refresh_rate = 3600
}

screens/hello.svg:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
  <image x="10" y="10" width="64" height="64" href="{{ data.icon_src }}"/>
  <text x="100" y="50">{{ data.greeting }}</text>
</svg>

This method is useful when you need to:

  • Conditionally include images
  • Fetch images from external URLs
  • Process or transform image data

Background Images

To use a full-screen background image:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <!-- Background image -->
  <image x="0" y="0" width="800" height="480" href="background.png" preserveAspectRatio="xMidYMid slice"/>

  <!-- Content on top -->
  <text x="400" y="240" text-anchor="middle" fill="white" font-size="32">
    {{ data.title }}
  </text>
</svg>

Tips for background images:

  • Use preserveAspectRatio="xMidYMid slice" to cover the entire area
  • Consider e-ink limitations: high-contrast images work best
  • Keep file sizes reasonable for fast rendering

Template Reusability

Byonk supports Tera’s template inheritance and includes for reusable components.

Directory Structure

Place reusable templates in special directories:

screens/
├── layouts/              # Base templates for {% extends %}
│   └── base.svg
├── components/           # Reusable snippets for {% include %}
│   ├── header.svg
│   ├── footer.svg
│   ├── hinting.svg
│   └── status_bar.svg
├── myscreen.lua
└── myscreen.svg

Template Inheritance (extends)

Create base layouts that define the overall structure with replaceable blocks:

screens/layouts/base.svg:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <rect width="100%" height="100%" fill="white"/>

  <!-- Header -->
  <rect x="0" y="0" width="100%" height="60" fill="black"/>
  <text x="20" y="40" fill="white" font-size="24" font-weight="bold">
    {% block title %}BYONK{% endblock %}
  </text>
  {% block header_extra %}{% endblock %}

  <!-- Content area -->
  <g transform="translate(0, 60)">
    {% block content %}{% endblock %}
  </g>

  {% block footer %}{% endblock %}
</svg>

screens/myscreen.svg:

{% extends "layouts/base.svg" %}

{% block title %}My Screen{% endblock %}

{% block content %}
<text x="400" y="200" text-anchor="middle" font-size="32">
  {{ data.message }}
</text>
{% endblock %}

{% block footer %}
<text x="400" y="460" text-anchor="middle" fill="#999" font-size="12">
  Updated: {{ data.updated_at }}
</text>
{% endblock %}

Key points:

  • Use {% extends "layouts/filename.svg" %} at the start of your template
  • Define blocks with {% block name %}...{% endblock %}
  • Child templates override parent blocks
  • Unoverridden blocks use the parent’s default content

Template Includes

Include reusable components in your templates:

screens/components/header.svg:

<rect x="0" y="0" width="100%" height="60" fill="black"/>
<text x="20" y="40" fill="white" font-size="24" font-weight="bold">
  {{ title | default(value="BYONK") }}
</text>
{% if updated_at %}
<text x="780" y="40" text-anchor="end" fill="#aaa" font-size="16">
  {{ updated_at }}
</text>
{% endif %}

screens/myscreen.svg:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <rect width="800" height="480" fill="white"/>

  <!-- Include the header component -->
  {% include "components/header.svg" %}

  <!-- Main content -->
  <g transform="translate(0, 60)">
    <text x="400" y="200" text-anchor="middle">{{ data.message }}</text>
  </g>

  <!-- Include the footer component -->
  {% include "components/footer.svg" %}
</svg>

Key points:

  • Use {% include "components/filename.svg" %} to insert a component
  • Included templates have access to all variables in the current context
  • Components work well for headers, footers, status bars, and other repeated elements

Built-in Components

Byonk includes several ready-to-use components:

ComponentDescription
components/header.svgHeader bar with title and optional timestamp
components/footer.svgFooter with timestamp and optional text
components/hinting.svgAdaptive font hinting (mono for BW, smooth for greyscale)
components/status_bar.svgWiFi and battery indicators

Combining Extends and Includes

You can use both in the same template:

{% extends "layouts/base.svg" %}

{% block title %}Dashboard{% endblock %}

{% block header_extra %}
{% include "components/status_bar.svg" %}
{% endblock %}

{% block content %}
<text x="400" y="200" text-anchor="middle">{{ data.message }}</text>
{% endblock %}

Next Steps