tiny-skia

Draw diagrams into bitmaps and PNGs with tiny-skia, in fonts your program carries.

View Markdown

The limu-tiny-skia crate draws limu’s scenes into tiny-skia pixmaps, which it can save as PNGs. It needs no GPU and no system libraries, so it runs on servers, in command-line tools and anywhere else Rust builds. Text is shaped with rustybuzz in font files your program gives it, so a diagram comes out the same on every machine.

limu-rs is young, and its API will change in any 0.x release.

Installing

limu-tiny-skia is a crate of its own, which keeps limu and limu-scene free of anything beyond Rust’s standard library. Add both crates.

cargo add limu limu-tiny-skia

limu-tiny-skia re-exports the tiny-skia it draws with as limu_tiny_skia::tiny_skia, so you don’t need to match its version. The examples below use limu::scene, which is the limu-scene crate as limu re-exports it.

Drawing a PNG

limu_tiny_skia::render draws a scene into a new pixmap. Lay out the diagram with the same fonts you draw it in, as explained under Fonts.

use limu::scene::style::{Look, Theme};use limu_tiny_skia::{Fonts, Options};fn main() -> Result<(), Box<dyn std::error::Error>> {    let fonts = Fonts::new(std::fs::read("Inter-Regular.ttf")?)?;    let layout = limu::layout::Options {        measurer: &fonts,        ..Default::default()    };    let scene = limu::layout(        "flowchart LR  text[Mermaid text] --> layout[Layout] --> scene[Scene]",        &layout,    )?;    let o = Options {        look: Look::new(Theme::KOHU),        fonts: Some(&fonts),        scale: 2.0,        ..Options::default()    };    let pixmap = limu_tiny_skia::render(&scene, &o).ok_or("the diagram is too large to draw")?;    pixmap.save_png("diagram.png")?;    Ok(())}

The pixmap is as large as the scene times Options.scale, rounded up to whole pixels, and is filled with the theme’s background. A scale of zero or less counts as 1. render returns None when the scene is too small or too large for a pixmap.

Options.look sets the theme and the overrides. See Theming. When the look changes, draw the scene again. It doesn’t need to be laid out again.

Drawing into a pixmap of your own

limu_tiny_skia::draw draws a scene into a pixmap you already have. A Transform places the scene’s units in the pixmap’s pixels, so it decides where the diagram goes and how large it is. draw doesn’t fill a background, and it ignores Options.scale.

use limu_tiny_skia::tiny_skia::{Color, Pixmap, Transform};let mut pixmap = Pixmap::new(1200, 800).ok_or("no pixmap")?;pixmap.fill(Color::WHITE);// Each unit of the scene is 2 pixels, and its top left corner is at (40, 40).let at = Transform::from_row(2.0, 0.0, 0.0, 2.0, 40.0, 40.0);limu_tiny_skia::draw(&scene, &o, &mut pixmap.as_mut(), at);

limu-tiny-skia draws the same picture as the SVG writer, with one difference. When a corner radius is too large for a box, it uses the smaller of half the width and half the height. SVG uses elliptical corners instead.

As in SVG, an item’s opacity is drawn as a layer, a pixmap of its own laid over what is under it, so where an item’s parts overlap, the lower part doesn’t show through.

Fonts

A diagram is laid out with a measurer and drawn with fonts. If the two don’t match, text won’t fit the space layout gave it. limu_tiny_skia::Fonts does both jobs. It implements limu’s Measurer, and it measures text by shaping it with rustybuzz exactly as it’s shaped for drawing.

Fonts::new takes the data of a TrueType or OpenType file as the regular font, and set adds a font for a face. The faces are bold, italic and mono, and any mix of them.

use limu::scene::text::Face;let mut fonts = Fonts::new(std::fs::read("Inter-Regular.ttf")?)?;fonts    .set(Face::BOLD, std::fs::read("Inter-Bold.ttf")?)?    .set(Face::ITALIC, std::fs::read("Inter-Italic.ttf")?)?    .set(Face::BOLD | Face::ITALIC, std::fs::read("Inter-BoldItalic.ttf")?)?    .set(Face::MONO, std::fs::read("JetBrainsMono-Regular.ttf")?)?;

Both return a FontError for data that isn’t a font. Text in a face you gave no font for is set in the nearest one you did. limu-tiny-skia tries the face without italic, then without bold, and then takes the regular font. Without Options.fonts, text is left out.

Each face is set in its one font, with no fallback to others. A character the font doesn’t have is drawn as the font’s missing glyph. To draw Chinese, Japanese or Korean text, give fonts that cover those scripts. If you don’t give layout a measurer, it measures text as Arial, so a diagram drawn in another font only fits when that font has Arial’s widths, as Liberation Sans does.

Glyphs are drawn from their outlines, so a halo is stroked the way any path is. An underline follows the font’s own position and thickness. Subscripts are lowered a fifth of the label’s size and superscripts raised a third, about where a browser puts them.

Icons and images

limu-tiny-skia never loads images or follows links on its own. Your program knows where its images come from, so it makes those decisions.

Options.icons gives an icon’s parts by its name, such as fa:fa-car. Every backend of limu-rs takes icons in this one form, limu::scene::Icons, so you write an icon set once for all of them. The parts sit on a box of 24 by 24. A filled part is filled in the color of the item’s text, and a stroked part is stroked in it, 2 wide with round ends and corners. Layout leaves room in labels only for icons you report through layout::Options::icons, so give both the same set.

Options.images gives a pixmap for an image’s URL. When an image’s fit is set, it keeps its proportions and is centered in its box. Otherwise it’s stretched to fill the box. Layout only passes through URLs whose scheme layout::Options::image_schemes allows, and loading and caching the images is up to you. Without icons or images, icons and images are left out.

use limu::scene::{Painting, Part, Path};// Icons on a box of 24 by 24, stroked 2 wide, as Lucide's are.fn icon(name: &str) -> Option<Vec<Part>> {    let d = match name {        "lucide:plus" => "M5 12h14M12 5v14",        "lucide:minus" => "M5 12h14",        _ => return None,    };    Some(vec![Part::path(Path::parse(d).ok()?, Painting::StrokeOnly)])}let layout = limu::layout::Options {    measurer: &fonts,    icons: Some(&|name| icon(name).is_some()),    ..Default::default()};let scene = limu::layout(src, &layout)?;let o = Options {    fonts: Some(&fonts),    icons: Some(Box::new(icon)),    // pixmaps is a HashMap<String, Pixmap> of the images loaded already.    images: Some(Box::new(|src| pixmaps.get(src).cloned())),    ..Options::default()};

Path::parse reads SVG path data, so an icon set drawn on a box of 24 by 24 can be used as it is.

Custom drawing

limu_tiny_skia::draw_with draws each item with a hook in place of the default. The hook receives a Painter, the item and its style as a Resolved, with every color already worked out. The painter’s item draws the item the default way, and parts, ends, picture and label draw the default’s pieces. Its canvas is a PixmapCanvas, whose pixmap and transform let the hook draw with tiny-skia directly.

use limu::scene::Kind;use limu_tiny_skia::tiny_skia::{Paint, Rect, Transform};limu_tiny_skia::draw_with(&scene, &o, &mut pixmap.as_mut(), Transform::identity(), |p, it, s| {    if !matches!(it.kind, Kind::Node) {        p.item(it, s);        return;    }    // Draw the node as a box of our own with square corners, and its label the default way.    let b = it.bounds;    if let Some(r) = Rect::from_xywh(b.x as f32, b.y as f32, b.w as f32, b.h as f32) {        let mut paint = Paint::default();        let c = s.fill.color;        paint.set_color_rgba8(c.r, c.g, c.b, c.a);        let at = p.canvas().transform();        p.canvas().pixmap().fill_rect(r, &paint, at, None);    }    p.label(it, s);});

The hook draws inside the layer of the item’s opacity, so the style it receives is always opaque. pixmap gives what the hook draws on, which is that layer when the item has one, and transform places the scene’s units in it.