@richardmcquiston01/html-to-lvgl
v0.2.0
Published
Convert plain HTML or React-rendered pages into LVGL XML for embedded devices
Readme
HTML to LVGL
Overview
Transform plain HTML or React based HTML pages into LVGL code for use on devices like Arduino or ESP32.
The converter renders your page in headless Chromium at the target display resolution, extracts the resolved layout and styles, and emits LVGL XML (v9.4 lv_xml runtime syntax), plain C, or C++ — see Output formats. CSS flexbox maps onto LVGL's flex layout; everything else uses exact coordinates from the browser's layout engine.
See ROADMAP.md for project status and what's planned next.
Prerequisites
- Node.js >= 20
- Playwright downloads its own Chromium build on install (no separate browser install needed)
Installation
Clone this repository to run the examples, use the golden-file test suite, or contribute:
git clone https://github.com/RichardMcQuiston01/html-to-lvgl.git
cd html-to-lvgl
npm installOr install the CLI from npm to use it in your own project:
npm install -g @richardmcquiston01/html-to-lvgl
html2lvgl your-page.html --size 320x240 -o out/Usage
# Convert a page for a 320x240 display
node src/cli.mjs examples/dashboard.html --size 320x240 -o out/
# Emit plain C instead of (or alongside) XML — works on any LVGL 9.x,
# no lv_xml runtime needed
node src/cli.mjs examples/monitor.html -o out/ --format both
# Emit C++ (.cpp/.hpp) for a project that only compiles C++
node src/cli.mjs examples/monitor.html -o out/ --format cpp
# Convert a React app: each function component becomes a reusable,
# parameterized LVGL XML component
node src/cli.mjs examples/react/panel.jsx -o out/
# Also save the Chromium reference screenshot and the extracted UI tree
node src/cli.mjs examples/dashboard.html -o out/ --screenshot --debug
# Watch mode: rebuild on save and serve a live side-by-side preview
node src/cli.mjs examples/dashboard.html -o out/ --watch
# Several pages become several screens, with links between them wired up
node src/cli.mjs examples/nav/home.html examples/nav/settings.html -o out/This produces out/dashboard.xml, ready to load with LVGL v9.4's XML runtime:
lv_xml_init();
/* register the montserrat fonts the page uses, then: */
lv_xml_register_component_from_data("dashboard", xml_string);
lv_xml_create(lv_screen_active(), "dashboard", NULL);spike/render/ contains a complete headless example (virtual display, font registration, framebuffer capture) that CI uses to validate every generated XML file against real LVGL.
What maps today
| HTML/CSS | LVGL |
|---|---|
| display: flex (direction, wrap, justify-content, align-items, gap, flex-grow) | LVGL flex (flex_flow, style_flex_*, pad_row/pad_column) |
| display: grid (tracks, gaps, grid-column/row spans) | LVGL grid (style_layout="grid", track descriptor arrays, cell pos/span) |
| Block/absolute layout | Exact coordinates from Chromium |
| Two-stop linear-gradient | bg_color + bg_grad_color + bg_grad_dir |
| box-shadow | style_shadow_* (color, blur, offsets, spread) |
| :hover / :active declarations | pressed-state styles (style_bg_color:pressed) |
| overflow: auto/scroll | scrollable containers |
| button | lv_button + centered lv_label |
| Text elements | lv_label, sized with real LVGL font metrics |
| input type=range | lv_slider (min/max/value) |
| input type=checkbox | lv_switch (wide) or lv_checkbox |
| input type=text/password | lv_textarea (placeholder, password mode) |
| select | lv_dropdown |
| progress | lv_bar |
| table | lv_table (column widths, cell values) |
| img | lv_image + asset manifest |
| Colors, borders (incl. side-specific), radius, padding, opacity, text styles | LVGL style properties |
An optimizer pass collapses visually inert single-child wrapper divs (framework "wrapper soup") so they never become on-device widgets.
Multi-screen navigation
Pass more than one page and each becomes a <screen> rather than a <component>, with <a href> links between them turned into navigation buttons:
<!-- home.xml -->
<screen>
<view ...>
<lv_button name="to_settings" width="160" height="34" ...>
<lv_label align="center" text="Settings" .../>
<screen_create_event screen="settings" />
</lv_button>
</view>
</screen>screen_create_event instantiates the target on demand, so navigation works before a screen has ever been visited — no start-up ordering to manage. Links to external URLs or to pages outside the converted set stay plain text. Load a screen with lv_xml_create(NULL, "home", NULL) followed by lv_screen_load(); the render harness does exactly this and can simulate a click with --click=<widget-name> to verify a transition.
In C output each screen exports ui_<screen>_create instead of ui_create so the units can be linked together; a navigation button's click handler calls the target screen's ui_<screen>_create (declared extern) and lv_screen_loads it — link every screen a UI can navigate to into the same binary.
Custom fonts
By default text renders in LVGL's built-in Montserrat at the nearest available size. Point a CSS family at a font file and the page renders in its real typeface instead:
node src/cli.mjs examples/typography.html -o out/ \
--font-map "Liberation Sans=/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf" \
--font-map "Liberation Sans:700=/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"Every distinct family/weight/size the page uses is converted with lv_font_conv at that exact pixel size — no rounding to a built-in size — and subset to the characters the page actually renders, so the fonts stay small. @font-face rules with local url(...) sources are picked up automatically; Family:700 maps a single weight. Anything left unmapped falls back to Montserrat with one warning per family.
Each font is emitted twice, into out/fonts/:
<name>.c— compile alongside the generated C; it is declaredexternfor you<name>.bin— load at runtime withlv_binfont_create(), which is how the XML path and the render harness use it:
./spike/build/render out/typography.xml out.bmp --font=liberation_sans_700_28=out/fonts/liberation_sans_700_28.binText measurement uses the generated font's real advance widths, so labels are sized from the typeface that will actually draw them. Generated C includes "lvgl.h"; pass --lv-include lvgl/lvgl.h if your project puts LVGL's parent directory on the include path.
Watch mode and live preview
--watch rebuilds whenever you save and serves a preview at http://localhost:8377 (override with --port): the Chromium reference next to the LVGL render, a live fidelity score, converter warnings, and the generated XML. The page refreshes itself over SSE after each build, so the edit → see-it-on-LVGL loop is a browser tab away.
The LVGL pane needs the render harness — spike/build/render by default, or point at another build with --render-bin. Without it the preview still serves the reference and the XML and tells you how to build one. Broken saves surface the error in the page instead of killing the server, and a failed render is reported rather than leaving the previous frame on screen.
Watch scope: the input file's own directory tree is watched (recursively), so page, CSS and co-located JSX imports all trigger rebuilds. Edits to files imported from outside that directory won't — keep a React app's sources under one root, or re-run the CLI.
Annotation attributes
When the automatic mapping needs a nudge, annotate the source HTML (React works too — put them on the JSX element):
| Attribute | Effect |
|---|---|
| data-lv-widget="switch\|checkbox\|slider\|bar\|button\|label\|textarea\|dropdown\|table\|image" | Force the widget, overriding tag and heuristic classification — e.g. a hand-rolled <div> toggle becomes an lv_switch. textarea produces a multi-line box; single-line inputs come from <input type=text> |
| data-lv-name="pump_enable" | Set the widget's name, instead of deriving it from id/class — useful for looking the widget up with lv_obj_get_child_by_name() |
| data-lv-ignore | Drop the element and its subtree — design-time guides, print-only content, debug overlays |
| data-lv-component="Name" | Mark a component boundary by hand, giving plain HTML the same componentization React gets automatically |
examples/annotations.html exercises all of them.
React input
Point the CLI at a .jsx/.tsx file whose default export is a component; it is bundled with esbuild, mounted in headless Chromium, and each function component's DOM subtree is identified by walking the React fiber tree. The componentizer then turns repeated components into reusable LVGL XML components with props:
<!-- stat_card.xml — generated from function StatCard({label, value, unit}) -->
<component>
<api>
<prop type="string" name="label" default="311"/>
...
</api>
<view style_bg_color="0x1c2430" ...>
<lv_label text="$label" .../>
</view>
</component>
<!-- panel.xml references it -->
<stat_card flex_grow="1" width="92" height="78" label="311" label_2="CORE K"/>Differing label texts become string props, differing colors become color props, and placement stays on the instances. Structurally inconsistent instances fall back to inline output with a warning. Plain HTML can opt into the same mechanism with data-lv-component="Name" attributes. C/C++ output (--format c/cpp/both) stays flattened and works for React input too.
Output formats
--format accepts a comma-separated list (xml, c, cpp, py); both is a back-compat alias for xml,c.
- XML (default): for LVGL v9.4's
lv_xmlruntime and the LVGL Editor toolchain. - C (
--format corboth): self-containedui_<name>.c/.hexportinglv_obj_t * ui_create(lv_obj_t * parent)— no XML runtime dependency, works on any LVGL 9.x. A single-page conversion exportsui_create; converting several pages together (multi-screen) exports oneui_<screen>_createper screen instead. - C++ (
--format cpp): the same generated UI code asui_<name>.cpp/.hpp, for a project that only compiles C++. LVGL's public headers already carryextern "C"guards, so the plain LVGL calls inside the generated function compile unchanged under C++17 — what differs from the C output is linkage: the header wrapsui_createinextern "C" { ... }and font/imageexterndeclarations sayextern "C", so the generated.cpplinks correctly againstlv_font_conv's font.cfiles. Those asset files must still be compiled as C (or otherwise given C linkage) regardless of--format— only the generated UI sources are C++; a C++ translation unit that omitsextern "C"when referencing a C-compiled asset symbol asks the linker for a mangled name the C compiler never produced. - MicroPython (
--format py): idiomaticui_<name>.pyexportingdef ui_create(parent):— plainlv.*calls againstlv_binding_micropython's auto-generated API, nolv_xmlruntime dependency. See Using with MicroPython below for the full picture, including how this compares to shipping XML directly.
All four backends emit from the same widget tree; the XML/C/C++ renders are pixel-identical (the Python backend targets a different, older vendored LVGL inside the pinned binding — see below).
Using with MicroPython
The XML output loads unmodified on lv_micropython via lv_binding_micropython — no separate emitter or flag needed, just --format xml (the default):
import lvgl as lv
lv.xml_init()
scope = lv.xml_component_get_scope("globals")
scope.register_font("montserrat_16", lv.font_montserrat_16) # any built-in sizes the page uses
# Register every generated XML file before the one that references it —
# a multi-screen or React/componentized conversion emits one file per
# screen/component (e.g. stat_card.xml referenced by panel.xml), and each
# has to be registered first or lv_xml_create() below can't resolve it.
with open("out/dashboard.xml") as f:
lv.xml_component_register_from_data("dashboard", f.read())
# <component>-rooted output: instantiate into a parent
page = lv.obj.__cast__(lv.xml_create(lv.screen_active(), "dashboard", None))
# <screen>-rooted output (multi-page conversions): create with no parent, then load
page = lv.obj.__cast__(lv.xml_create(None, "dashboard", None))
lv.screen_load(page)lv.xml_create() returns an untyped Blob (the binding can't know it's an lv_obj_t), so cast it with lv.obj.__cast__(...) as above.
Custom fonts (see Custom fonts) need no separate MicroPython step — --font-map already emits a .bin next to every .c in out/fonts/, under the same name the XML's style_text_font attribute references. Ship out/fonts/*.bin alongside your .xml and register each on boot:
# "A:" is the POSIX filesystem drive letter the unix port's example config
# mounts (LV_USE_FS_POSIX); swap it for whatever drive your target mounts.
font = lv.binfont_create("A:out/fonts/liberation_sans_700_28.bin")
scope.register_font("liberation_sans_700_28", font) # same name as the .bin's filenameThis requires LV_USE_XML enabled in the binding's lv_conf.h; at spike time two small patches to the binding's vendored LVGL copy were also needed (lv_binfont_create and the XML component scope's user_data field — neither changes behavior, both just widen what the binding generator's auto-scan sees). See spike/micropython/README.md for the full patches, a working reference script (render_xml.py), and reproduction steps — worth re-checking whether a newer lv_binding_micropython commit already includes them upstream.
--format py: idiomatic Python instead of XML
If you'd rather not carry LV_USE_XML or ship .xml files at all, --format py emits the same UI as plain lv.* calls instead:
import ui_dashboard
page = ui_dashboard.ui_create(None)
lv.screen_load(page)A multi-screen conversion works the same way as the C backend: each screen exports its own ui_<screen>_create(parent) from ui_<screen>.py, and a generated navigation button's click handler does import ui_<target-screen> — lazily, inside the callback, so two screens that link to each other don't form a circular import at load time, and the cost is only paid on an actual navigation.
Custom fonts and images have no compile-time linkage for this backend to borrow from (unlike C's extern declarations), so a page that uses either takes them as runtime dict arguments instead:
fonts = {"liberation_sans_700_28": lv.binfont_create("A:out/fonts/liberation_sans_700_28.bin")}
page = ui_typography.ui_create(None, fonts=fonts) # only present if the page actually uses a custom fontWhich format to reach for:
| | --format xml | --format py |
|---|---|---|
| Runtime dependency | LV_USE_XML (+ lv_xml support code) | none |
| Multi-file UI | one lv_xml_component_register_from_data() call per file | one import per screen |
| Custom fonts/images | registered into the XML scope by name | passed as a fonts/images dict argument |
| Editing after generation | plain-text markup | plain-text Python |
The mapping (lv_obj_set_width(o, v) → o.set_width(v), LV_FLEX_FLOW_ROW_WRAP → lv.FLEX_FLOW.ROW_WRAP, …) was verified against a real generated binding rather than assumed from the C names — see src/emit-py.mjs's module doc comment for two real spots it diverges from a naive 1:1 C-call translation (lv_slider/lv_bar's set_range vs. separate min/max setters, and LV_ANIM_OFF being a plain Python False). CI (validate-micropython-xml in .github/workflows/ci.yml) calls every golden's generated ui_create() directly on a real lv_micropython build, including a real click-driven cross-screen navigation test — not just a syntax check.
Fidelity
node scripts/fidelity.mjs <web.png> <lvgl.png|bmp> pixel-scores a conversion. Current examples score 79–94% (tolerance 32, mostly font antialiasing differences).
CSS font-size maps onto the built-in Montserrat sizes using per-glyph advance widths extracted from LVGL's font sources (scripts/gen-font-metrics.mjs), so labels never clip or wrap unexpectedly — or supply the real typeface and skip the approximation entirely (see Custom fonts).
Project layout
src/— thehtml2lvglCLI:extract.mjs/extract-react.mjs(Playwright),convert.mjs(mapping),componentize.mjs,optimize.mjs,emit-xml.mjs,emit-c.mjs,emit-py.mjs,fonts.mjs,preview.mjs(watch mode)examples/— sample pages to convert, includingreact/panel.jsxandannotations.htmltests/— golden-file tests (npm test); fixtures are committed extraction snapshots, so tests need no browserspike/— the proof-of-concept and the C/C++ render harness used for CI validation;spike/micropython/holds the MicroPython feasibility spike and the--format pyemitter's verification harness,spike/esp32/the ESP32 boot-under-QEMU spikescripts/— font-metrics generator (run against an LVGL v9.4.0 checkout), the fidelity scorer, andconvert-fixture.mjsfor browser-free conversion from a committed fixture
Development
npm test # unit + golden-file tests
npm run golden:update # refresh goldens after an intentional converter changeCI runs the test suite and additionally builds the spike render harness against LVGL v9.4.0: every golden XML loads through the stock lv_xml runtime, and every golden C and C++ file is compiled (gcc / g++ -std=c++17) and linked against real LVGL and, for the font-linkage case, real lv_font_conv output.
Note: the project pins LVGL v9.4.0 — the last release with the in-tree lv_xml runtime (XML tooling is consolidating into LVGL Pro upstream).
License
Copyright
(c)2026 Richard McQuiston.
Buy Me a Coffee
If this app, code, or repository has helped you or someone you know, please consider donating. I appreciate any help to offset the costs of development and/or AI Credits.
Donate via Stripe, or scan:
