Contributing

Thanks for your interest! This guide explains how to get the project running, how it is organized, and how to propose a change. You don't need a printer: an emulator and a preview in the terminal are enough for almost everything.

Getting started#

You need a stable Rust toolchain (rustup). For the emulator and the screenshots you also need uv, and Chromium for the web interface screenshots.

git clone https://github.com/XNinety9/Printr && cd Printr
cargo test                                               # all tests, no network or printer needed
cargo run -- --preview print examples/complet.json       # preview of the full ticket in the terminal

A few useful environment variables:

Variable What it does
ANTHROPIC_API_KEY Claude blocks (horoscope, word_of_the_day). Optional: without it, these blocks show an error and the rest of the ticket prints normally.
PRINTR_BARNUM Command for Barnum used by the barnum block, for example python3 ../Barnum/main.py.
PRINTR_DATA_DIR Accounts, presets and history of the web interface. Use a throwaway folder for your tests.
PRINTR_GLITCH How often glitches happen (one print in N, 20 by default, 0: never).
PRINTR_CACHE_DIR Cache of the day's content.
PRINTR_WEB_DIR=web Serves the interface from disk: edit web/ without recompiling.
PRINTR_DEBUG=1 Shows Claude's raw responses and the news ranking.

Testing without a printer#

export PRINTR_DATA_DIR=/tmp/printr-dev PRINTR_WEB_DIR=web
echo motdepasse | cargo run -- user add Test
cargo run -- --dump /tmp/sortie.bin serve --listen 127.0.0.1:8080

Claude blocks cost money on every generation. To iterate, prefer the web interface preview, which never calls Claude, or the day's cache: without --refresh, a horoscope already generated today is reused as is.

Code layout#

File Role
src/main.rs Command line (clap), help text in French
src/blocks/mod.rs The ticket JSON format, the Block enum, parallel construction
src/blocks/*.rs One file per block with some substance (weather, sudoku, Barnum…)
src/doc.rs Intermediate representation of a ticket: styled lines, images, QR codes, previews
src/cp858.rs Text encoding for the printer (PC858 code page)
src/raster.rs, src/draw.rs Images: dithering, drawings (sudoku, maze, side-by-side QR codes)
src/output.rs Sending to the USB printer, over TCP, or to a file
src/server.rs, src/store.rs, src/scheduler.rs Web server, data (accounts, presets, history), scheduler
src/claude.rs, src/cache.rs Claude API client, cache of the day's content
src/ui.rs Console output: progress, summary, errors, server log
web/ Web interface (Preact + htm, no build step), embedded in the binary
docs/ Showcase site (GitHub Pages) and its illustrations

Developing a new block#

A block is an entry in a ticket's JSON ({ "type": "…", … }) that turns into a piece of ticket. To develop one, we always follow the same path, illustrated here with an example block, "Dice", which rolls a few dice and shows their total:

{ "type": "dice", "count": 3 }

1. Declare the block and its parameters#

In src/blocks/mod.rs, add a variant to the Block enum. Its name, in snake_case, gives the JSON type; its fields are the parameters.

    /// Dice roll.
    Dice {
        /// Number of dice, from 1 to 6.
        #[serde(default = "two")]
        count: u8,
    },

2. Write the rendering#

Create src/blocks/dice.rs. Its build function receives the parameters and returns a Doc, the representation of the ticket before printing:

//! Dice roll: one or more six-sided dice, and their total.

use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};

use crate::doc::{Doc, Style};

/// Rolls `count` dice (from 1 to 6 dice).
fn roll(count: u8, rng: &mut StdRng) -> Vec<u8> {
    (0..count.clamp(1, 6)).map(|_| rng.random_range(1..=6)).collect()
}

pub fn build(count: u8) -> Doc {
    let rolls = roll(count, &mut StdRng::from_rng(&mut rand::rng()));
    let faces: Vec<String> = rolls.iter().map(u8::to_string).collect();
    let total: u32 = rolls.iter().map(|&r| u32::from(r)).sum();

    let mut doc = Doc::new();
    doc.header("Dice roll");
    doc.feed(1);
    doc.text(&faces.join("  "), Style::default().bold().center().size(2));
    doc.text(&format!("Total: {total}"), Style::default().center());
    doc
}

Toolbox of Doc (src/doc.rs):

Method Effect
header("Title") Reversed banner (white on black) across the full width
text(text, style) Word-wrapped text; an empty line skips a line
hanging("Prefix: ", text, style) Text whose following lines align after the prefix
line(text, style) One line as is, spaces kept: ideal for columns
rule('─'), feed(n) Separator line, blank lines
image(img), qr(data, size) Image 512 dots wide, QR code

And for Style: bold(), underline(), reverse(), small() (font B), size(1..=8), center(), align(…), upside_down(). The paper is 42 columns wide in normal font, 56 in small font, and 42 divided by the size when enlarged (21 columns at size 2).

For an image (drawing, grid, photo), see draw.rs and raster.rs: draw::canvas(height) gives a white canvas 512 dots wide, draw::fill_rect draws on it, and raster::prepare dithers a photo.

3. Wire up the block#

Still in src/blocks/mod.rs, three additions:

mod dice;                                          // at the top of the file

Block::Dice { .. } => "dice",                      // in Block::name()

Block::Dice { count } => doc = dice::build(*count), // in Block::build()

If a parameter usefully identifies the block (a city, a sign…), also add it to Block::label(): it then appears in the console ("weather · Lyon"), in the history and in errors.

4. Try it#

echo '{"blocks":[{"type":"dice","count":3}]}' | cargo run -- --preview print
╭──────────────────────────────────────────────────────────╮
│                        DICE ROLL                         │
│                                                          │
│                        1   4   2                         │
│                         Total: 7                         │
╰╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ✂ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╯

Then the real rendering, pixel for pixel, in the emulator:

echo '{"blocks":[{"type":"dice","count":3}]}' > /tmp/des.json && scripts/demo.sh /tmp/des.json

5. Test#

Add tests at the bottom of the file. Isolate whatever is random or networked so that it can be tested deterministically, here with a fixed seed:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rolls_are_valid_dice() {
        let mut rng = StdRng::seed_from_u64(42);
        for count in [0, 1, 3, 6, 9] {
            let rolls = roll(count, &mut rng);
            assert_eq!(rolls.len(), count.clamp(1, 6) as usize);
            assert!(rolls.iter().all(|r| (1..=6).contains(r)));
        }
    }
}

6. Add it to the web interface#

In web/app.js, add an entry to the BLOCKS catalog. The form fields are generated from fields:

  {
    type: 'dice', label: 'Dice', emoji: '🎲', group: G.fun, desc: 'A dice roll and its total',
    defaults: { count: 2 },
    fields: [{ key: 'count', label: 'Number of dice', kind: 'number', min: 1, max: 6 }],
    summary: (b) => `${b.count} die${b.count > 1 ? 's' : ''}`,
  },

Available field types (kind): text, textarea, number, select, segmented (side-by-side buttons), toggle, date, list (a list of texts), icons (choice by emoji) and image (a photo sent from the phone). Add ai: true if the block calls Claude: it then gets the "Claude" badge in the catalog. A parameter with no field in the form is kept as is when saved.

With PRINTR_WEB_DIR=web, simply reload the page to see your block in the catalog.

7. Document it#

Special cases#

A block that queries an API receives the context (ctx: &Ctx) and uses its HTTP client, which already has a timeout:

let data: Reponse = ctx.http.get("https://…").query("ville", ville).call()?.body_mut().read_json()?;

Prefer free APIs that need no key, and handle their outages with a clear error (anyhow). To test without the network, separate the decoding and the rendering from the call, and test them on a sample response (see barnum.rs).

A block that calls Claude (see horoscope.rs and word.rs):

The rules that apply to every block:

Conventions#

Web interface and site#

Versions#

The version number (Cargo.toml, shown by printr --version and in the app) follows semantic versioning:

Every published version has its git tag (v1.0.0), created on master once the version has been pushed: git tag -a v1.1.0 -m "printr 1.1.0" && git push origin v1.1.0. Installations see it within the day ("My account" of the administrator): only push a tag for a version that is ready to be installed.

Proposing a change#

  1. Create a branch from master.
  2. Check that cargo test passes and that cargo build produces no warnings.
  3. If you touch the rendering, attach a screenshot of the preview or of the emulator output to your pull request.
  4. Open a pull request describing the why as much as the how.

Got a question or an idea for a block? Open an issue.

Generated from CONTRIBUTING.md.