A LaTeX rendering engine for the TI-84 Plus CE calculator. Renders mixed text and math expressions - fractions, integrals, matrices, Greek letters, and more, directly on the calculator's hardware.
libtexce is written in C, targets the ez80 via the CE C/C++ Toolchain, and can also be built natively (via PortCE + SDL2) or for the browser (via Emscripten) for development and testing.
The full rendering pipeline is five steps: load fonts, configure, format, create a renderer, draw.
#include <graphx.h>
#include <keypadc.h>
#include <tice.h>
#include "tex/tex.h"
int main(void)
{
gfx_Begin();
gfx_SetDrawBuffer();
gfx_SetTransparentColor(255);
TeX_Font font_main;
TeX_Font font_script;
if (!tex_font_open(&font_main, "TeXFonts") ||
!tex_font_open(&font_script, "TeXScrpt"))
{
gfx_PrintStringXY("Missing font packs!", 10, 10);
gfx_SwapDraw();
while (!os_GetCSC()) {}
gfx_End();
return 1;
}
// Source is borrowed read-only and must outlive the layout.
const char* source =
"Quadratic Formula\n"
"$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n"
"\n"
"Taylor Series\n"
"$$f(x) \\approx f(a) + f'(a)(x-a) + \\frac{f''(a)}{2}(x-a)^2$$";
// format parses the document and computes layout metrics
TeX_Config cfg = {
.palette = { 255, 254, 253, 0 }, // background, two reserved shades, ink
.justify = true, // optional: fill automatically wrapped text lines
.font_main = font_main,
.font_script = font_script,
};
int margin = 10;
TeX_Layout* layout = tex_format(source, GFX_LCD_WIDTH - margin * 2, &cfg);
// create a renderer (manages a transient memory pool for drawing)
TeX_Renderer* renderer = tex_renderer_create();
int scroll_y = 0;
int total_h = tex_get_total_height(layout);
int max_scroll = total_h > GFX_LCD_HEIGHT ? total_h - GFX_LCD_HEIGHT : 0;
// draw loop
while (true) {
kb_Scan();
if (kb_Data[6] & kb_Clear) break;
if (kb_Data[7] & kb_Up) scroll_y -= 10;
if (kb_Data[7] & kb_Down) scroll_y += 10;
if (scroll_y < 0) scroll_y = 0;
if (scroll_y > max_scroll) scroll_y = max_scroll;
gfx_FillScreen(255);
tex_draw(renderer, layout, margin, 0, scroll_y);
gfx_SwapDraw();
}
// Renderer and layout may be destroyed in either order.
tex_renderer_destroy(renderer);
tex_free(layout);
gfx_End();
return 0;
}If you are building a notes app (the most common use case), start with
libtexce_notes_template. It gives you a ready to use
TI-84 Plus CE notes workflow with formatting and CI-built transfer artifacts
For a larger production example, see
matrix, a full linear algebra app that uses libtexce to render
step by step formatted math on device
The public API is declared in tex/tex.h. All functions use C linkage.
| Function | Description |
|---|---|
TeX_Layout* tex_format(const char* input, int width, const TeX_Config* config) |
Measure a mixed text/math document without modifying its source. Returns NULL for invalid arguments or initialization OOM. Check tex_get_last_error() on the returned layout. |
int tex_get_total_height(TeX_Layout* layout) |
Total rendered height in pixels. Use for scroll bounds. |
void tex_free(TeX_Layout* layout) |
Free all resources associated with a layout |
| Function | Description |
|---|---|
TeX_Renderer* tex_renderer_create(void) |
Create a renderer with the default 40 KB slab |
TeX_Renderer* tex_renderer_create_sized(size_t slab_size) |
Create a renderer with a custom slab size |
void tex_renderer_destroy(TeX_Renderer* r) |
Destroy the renderer and free its slab. |
void tex_draw(TeX_Renderer* r, TeX_Layout* layout, int x, int y, int scroll_y) |
Draw visible portion of the document to the current draw buffer |
| Function | Description |
|---|---|
TeX_Error tex_get_last_error(TeX_Layout* layout) |
Error code from last operation (TEX_OK, TEX_ERR_OOM, TEX_ERR_FONT, TEX_ERR_PARSE, TEX_ERR_INPUT, TEX_ERR_DEPTH). |
const char* tex_get_error_message(TeX_Layout* layout) |
Human readable error string (static, never NULL). |
int tex_get_error_value(TeX_Layout* layout) |
Detail value (byte offset, nesting depth, etc.) |
| Function | Description |
|---|---|
void tex_renderer_get_stats(TeX_Renderer* r, size_t* peak_used, size_t* capacity, size_t* alloc_count, size_t* reset_count) |
Query pool statistics. Pass NULL for stats you dont need. Useful for tuning tex_renderer_create_sized() |
typedef struct
{
uint8_t palette[4]; // Background, 1/3 ink, 2/3 ink, foreground
bool justify; // Expand word spaces on automatic wraps (default false)
TeX_Font font_main; // Borrowed validated main-font view
TeX_Font font_script; // Borrowed validated script-font view
TeX_ErrorLogFn error_callback; // Optional error/warning callback
void* error_userdata; // Passed to callback
} TeX_Config;Colors are 8 bit palette indices matching the graphx palette. Use four distinct indices: the renderer uses them to distinguish background, partial coverage and full ink when strokes overlap. tex_draw() sets the two intermediate colors from your background and foreground. Clear to palette[0] before drawing, and don't change the intermediate colors while text using them remains visible.
Set justify = true to fill automatically wrapped text lines. Paragraph endings and explicit newlines are not stretched.
The error callback receives a severity level (0 = info, 1 = warning, 2 = error), a message string, and in debug builds, the source file and line number where the error occurred.
Understanding buffer ownership is critical for correct usage.
tex_format() reads the input without modifying it, so string literals are valid inputs.
The source and font bytes must remain unchanged and at the same address for the entire lifetime of the TeX_Layout. The layout borrows them rather than making copies, and tex_draw() reparses the source on cache misses.
Free the layout before releasing its source or font storage. The renderer can be destroyed before or after the layout.
A TeX_Renderer owns a slab of memory used as a transient pool. Each call to tex_draw() may reset and reuse this pool. A single renderer can be shared across multiple layouts.
| Object | Owns | Must outlive |
|---|---|---|
| Source text | The caller's immutable bytes | TeX_Layout |
| Font data | The caller's validated, stationary font bytes | TeX_Layout |
TeX_Layout |
Checkpoint index, config/font context, error state | Neither renderer nor source storage |
TeX_Renderer |
Transient slab pool and line array | Neither layout nor source storage |
libtexce uses a streaming two pass architecture designed for the calculator's constrained memory.
When you call tex_format(), the engine tokenizes and parses the entire document, measuring each lines height and accumulating the total document height. No nodes or render trees are retained, only the total height and a sparse checkpoint index are stored in the TeX_Layout
Checkpoints record (y_position, source_pointer) pairs at regular pixel intervals (~200px). These allow tex_draw() to jump into the middle of a long document without reparsing from the beginning
Each time tex_draw() is called, the renderer:
- Checks its cache. If the visible region falls within the previously hydrated window, the existing render tree is reused without reparsing
- Otherwise, rehydrates. The renderer finds the nearest checkpoint before
scroll_y - padding, reparses from that point forward, and builds a render tree covering the viewport plus 240px of padding in each direction - Draws the visible lines from the render tree to the current graphx draw buffer
This means the renderer only ever holds nodes for ~3 screens of content, regardless of total document length. the tradeoff is that scrolling to a completely new region triggers a reparse, but checkpoint indexing keeps this fast
- Render tree storage does not grow with document length the existing document height limit is 20000 pixels.
- The input buffer must stay alive because
tex_draw()reads from it on every cache miss. - Renderer slab sizing (
tex_renderer_create_sized()) controls the upper bound on how much visible content can be rendered. The default 40 KB is too generous for most documents and you should consider using the sized init function. Usetex_renderer_get_stats()to measure actual usage.
libtexce uses two Latin Modern fonts stored as AppVars:
| AppVar | Description | Nominal Size |
|---|---|---|
TeXFonts.8xv |
Main text and math font | 16 px |
TeXScrpt.8xv |
Script/subscript/superscript font | 11 px |
Both must be transferred to the calculator before running any program that uses libtexce. Prebuilt copies are in the assets/ directory. Keep them archived to read the font data directly from flash without a font-sized RAM allocation.
Each font provides ASCII text, Greek letters, calculus symbols, operators, arrows and delimiters, plus italic Latin letters and larger display operators.
The header include/texfont.h defines named constants for custom glyphs, though you generally won't need these. The LaTeX parser maps \alpha, \int, etc. automatically.
Install Pillow and fontTools on the build host, then run:
python3 tools/process_fonts.pyThe generator reads the Latin Modern fonts in tools/fonts/ and writes both .8xv files to assets/. Use --outdir PATH to write elsewhere. The fonts use the included GUST Font License.
libtexce supports a substantial subset of LaTeX math mode. The full list is maintained in LATEX_COMMANDS_SUPPORTED.md.
Highlights:
- Fractions:
\frac{a}{b},\tfrac,\binom{n}{k} - Roots:
\sqrt{x},\sqrt[3]{x} - Scripts:
x^2,x_n,x_i^2 - Greek:
\alphathrough\omega,\Gammathrough\Omega - Big operators:
\int,\sum,\prodwith limits, plus\iint,\iiint,\ointvariants - Functions:
\sin,\cos,\lim,\log,\exp,\det,\gcd, and many more - Boolean logic:
\land/\wedge,\lor/\vee,\neg/\lnot,\Rightarrow/\implies,\Leftrightarrow/\iff,\supset(horseshoe/superset),\top, and\bot - Accents:
\hat,\bar,\vec,\dot,\tilde,\overline,\underline - Decorations:
\overbrace{...}^{label},\underbrace{...}_{label} - Delimiters:
\left( ... \right)with auto-sizing for(),[],\{\},||,\langle\rangle,\lfloor\rfloor,\lceil\rceil - Matrices:
pmatrix,bmatrix,Bmatrix,vmatrix,matrix,array(with column separators via|). Leading, interior, and trailing empty cells retain their column positions. - Spacing:
\,,\:,\;,\!,\quad,\qquad - Text mode:
\text{...}for roman text within math
Input uses standard LaTeX delimiters: $...$ for inline math, $$...$$ for display math (centered). everything outside $ delimiters is rendered as plain text with automatic word wrapping
Boolean logic symbols require the current TeXFonts.8xv and TeXScrpt.8xv AppVars
The Quick Start example covers this pattern. Key points:
- Use
tex_get_total_height()to compute scroll bounds - Clamp
scroll_ybetween0andtotal_height - viewport_height - Pass
scroll_ytotex_draw()the renderer handles windowed rendering automatically
A single TeX_Renderer can draw different layouts on different frames. This is useful for chat style UIs:
TeX_Renderer* renderer = tex_renderer_create();
// String literals already have sufficient lifetime; no source copies needed.
TeX_Layout* msg1 = tex_format("What is $E = mc^2$?", width, &cfg);
TeX_Layout* msg2 = tex_format("Einstein's mass-energy equivalence:\n$$E = mc^2$$", width, &cfg);
// draw them at different positions using the same renderer
// note: scroll_y=0 since we position each message manually via the y parameter
tex_draw(renderer, msg1, x, y1, 0);
tex_draw(renderer, msg2, x, y2, 0);
// cleanup
tex_renderer_destroy(renderer);
tex_free(msg1); tex_free(msg2);Note: When switching between layouts, the renderer invalidates its cache and reparses. For a scrolling view of a single layout, the cache avoids redundant work
TeX_Layout* layout = tex_format(buf, width, &cfg);
if (!layout) {
// catastrophic failure (OOM during initialization)
// cannot proceed
}
if (tex_get_last_error(layout) != TEX_OK) {
// parse error, font error, etc.
// the layout may still be partially renderable
dbg_printf("TeX error: %s (code %d, detail %d)\n",
tex_get_error_message(layout),
tex_get_last_error(layout),
tex_get_error_value(layout));
}for richer diagnostics, use the error callback in TeX_Config:
void my_error_handler(void* userdata, int level, const char* msg,
const char* file, int line) {
(void)userdata;
const char* prefix = level == 2 ? "ERROR" : level == 1 ? "WARN" : "INFO";
dbg_printf("[%s] %s\n", prefix, msg);
}
TeX_Config cfg = {
.palette = {255, 254, 253, 0},
.font_main = font_main,
.font_script = font_script,
.error_callback = my_error_handler,
.error_userdata = NULL,
};If you're rendering complex expressions (deeply nested fractions, large matrices) and suspect the renderer pool is too small, measure it:
size_t peak, cap;
tex_renderer_get_stats(renderer, &peak, &cap, NULL, NULL);
dbg_printf("Pool: %u / %u bytes\n", (unsigned)peak, (unsigned)cap);Though from real world testing, this is basically never a problem unless the input is maliciously nested.
libtexce uses CMake with presets. There are two independent build systems: the native/WASM host build (for development and testing), and the CE build (for the actual calculator)
| Target | Requirements |
|---|---|
| Native | Clang with C23 support, CMake >= 3.21, Ninja, SDL2, SDL2_mixer |
| CE | CE C/C++ Toolchain (provides ez80-clang, fasmg, convbin) |
| WASM | Emscripten SDK, CMake, Ninja |
cmake --preset native
cmake --build build/nativethis builds the core engine, all host unit tests, and the PortCE SDL2 demo. variants:
| Preset | Description |
|---|---|
native |
Default debug build (system clang) |
native-clang20 |
Explicit clang-20 |
native-asan-clang20 |
AddressSanitizer + LeakSanitizer enabled |
Run tests:
cd build/native && ctest
# or
cmake --build build/native --target run_testsRun the SDL2 demo:
./build/native/bin/demo_text_portcethe CE build lives in demo/ce/ and uses the CEdev toolchain:
cd demo/ce
cmake --preset ce
cmake --build ../../build/ceThis produces .8xp files in build/ce/<target>/bin/. Transfer the .8xp program along with TeXFonts.8xv, TeXScrpt.8xv, and clibs.8xg to the calculator.
source /path/to/emsdk/emsdk_env.sh
cmake --preset wasm
cmake --build build/wasmProduces an HTML file you can serve locally... for whatever reason
libtexce has two test tiers:
Located in tests/, these test individual pipeline stages against the internal API:
| Test | What it covers |
|---|---|
test_token |
Tokenizer (text, math delimiters, escaping) |
test_parse |
Parser, required operands, balanced groups, and positional empty matrix cells |
test_measure |
Measurement pass (node dimensions) |
test_layout |
Checkpoint replay, cache lifetime, font switching, antialiasing, palette ownership, clipping, delimiter clearance, text ink bounds, and justification |
test_fonts |
Borrowed font/glyph views and malformed payload bounds |
test_symbols |
Symbol table lookup |
test_pool |
Pool allocator (nodes, strings, lists, OOM) |
Run with:
cd build/native && ctest --output-on-failureThe autotests/ directory contains a regression test suite that runs on CEmu via its autotester
Each test case renders a LaTeX expression on the calculator, then validates the LCD framebuffer against an expected CRC32 hash. Test cases are defined in autotests/casegen/cases.c:
{ "quadratic", NULL, "$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$", "025A9B2B", 10, 5 },Running autotests:
cd autotests
make generate # Generate test case directories from cases.c
make build # Build all test .8xp programs
AUTOTESTER_ROM=/path/to/rom make test-all # Run all tests in parallelAdding a new test case:
- Add an entry to the appropriate suite in
autotests/casegen/cases.c make generate && make build- Run with
--dry-runor set the CRC to"00000000", then use./update_hashes.pyto capture the actual CRC
To use libtexce in your own CE project:
Copy (or git submodule) the src/tex/ directory and include/texfont.h into your project. The core engine is these files:
src/tex/tex_util.c src/tex/tex_pool.c
src/tex/tex_symbols.c src/tex/tex_metrics.c
src/tex/tex_fonts.c
src/tex/tex_token.c src/tex/tex_parse.c
src/tex/tex_measure.c src/tex/tex_layout.c
src/tex/tex_renderer.c src/tex/tex_draw.c
Your build must be able to find:
src/andsrc/tex/(internal headers)include/(publictexfont.h)
Transfer these to the calculator alongside your .8xp:
TeXFonts.8xvandTeXScrpt.8xv(fromassets/; keep archived for minimal RAM use)clibs.8xgmatching your CE toolchain's library versions (the bundled copy may be older than a current toolchain)
if your CE project uses the provided CEdevToolchain.cmake, see demo/ce/CMakeLists.txt for a complete working example of cedev_add_program() with libtexce sources
The repository includes two demo programs that build for both the CE and PortCE (native SDL2):
| Demo | Description |
|---|---|
demo_text |
Scrollable document renderer. Showcases fractions, integrals, Taylor series, matrices, and more in a paginated view. |
demo_thread |
Chat-style threaded conversation. Multiple independent TeX_Layout objects drawn with a shared renderer, demonstrating the multi-layout pattern. |
Build and run natively:
cmake --preset native && cmake --build build/native
./build/native/bin/demo_text_portcelibtexce is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only).
See LICENSE for the full text.
See CONTRIBUTING.md for SPDX header guidance on new files.
The Latin Modern source fonts and renamed derived AppVars use the GUST Font License, with its incorporated LPPL.
