maimai 0.1.1

Markup-based meme generator
Documentation
use std::sync::{LazyLock, Mutex};

use camino::Utf8Path;

use tiny_skia::{Pixmap, PixmapMut, Transform};

use crate::{
    files::FileProviderExt,
    meme::{Meme, MemeBase, TextBox, VAlign},
    text::render_layout,
};

impl Meme {
    pub fn render<'f>(
        self,
        file_provider: &'f impl crate::FileProvider<'f>,
        debug: bool,
    ) -> crate::Result<image::RgbaImage> {
        let mut pixmap = match self.base {
            MemeBase::Canvas {
                color,
                size: (width, height),
            } => {
                let mut pixmap = Pixmap::new(width, height).unwrap();
                pixmap.fill(color.into());
                pixmap
            }
            MemeBase::Image(ref path) => {
                let image = file_provider.load_image(path)?;

                let width = image.width();
                let height = image.height();

                Pixmap::from_vec(
                    premultiply_slice(image.into_vec()),
                    tiny_skia::IntSize::from_wh(width, height).unwrap(),
                )
                .unwrap()
            }
        };

        render_text(pixmap.as_mut(), self, debug)?;

        Ok(image::RgbaImage::from_vec(
            pixmap.width(),
            pixmap.height(),
            demultiply_slice(pixmap.take()),
        )
        .unwrap())
    }
    pub fn render_to_file(self, output_path: &Utf8Path, debug: bool) -> crate::Result<()> {
        self.render(&crate::FsFileProvider, debug)?
            .save(output_path)?;

        Ok(())
    }
}

fn render_text(mut pixmap: PixmapMut<'_>, meme: Meme, debug: bool) -> crate::Result<()> {
    static FONT_CONTEXT: LazyLock<Mutex<parley::FontContext>> = LazyLock::new(Default::default);
    static LAYOUT_CONTEXT: LazyLock<Mutex<parley::LayoutContext<()>>> =
        LazyLock::new(Default::default);

    let mut font_context = FONT_CONTEXT.lock().unwrap();
    let mut layout_context = LAYOUT_CONTEXT.lock().unwrap();

    let debug_stroke = tiny_skia::Stroke {
        width: 2.0,
        ..Default::default()
    };
    let center_paint = {
        let mut paint = tiny_skia::Paint::default();
        paint.set_color_rgba8(0, 0, 255, 255);
        paint
    };
    let border_paint = {
        let mut paint = tiny_skia::Paint::default();
        paint.set_color_rgba8(255, 0, 0, 255);
        paint
    };
    let rotated_paint = {
        let mut paint = tiny_skia::Paint::default();
        paint.set_color_rgba8(0, 255, 0, 255);
        paint
    };

    for TextBox {
        text,
        position: (x, y),
        size: (width, height),
        rotate,
        font,
        caps,
        color,
        outline,
        font_size: max_font_size,
        line_height,
        halign,
        valign,
    } in meme.text
    {
        if text.is_empty() {
            continue;
        };

        let center = tiny_skia::Point {
            x: x + width / 2.0,
            y: y + height / 2.0,
        };

        let content = if caps { text.to_uppercase() } else { text };

        let mut font_size = max_font_size;

        let layout = loop {
            let max_width = outline
                .map(|outline| width - 2.0 * outline.width_for_font_size(font_size))
                .unwrap_or(width);

            let mut builder = layout_context.ranged_builder(&mut font_context, &content, 1.0, true);
            builder.push_default(parley::StyleProperty::FontStack(parley::FontStack::Single(
                parley::FontFamily::parse(&font).unwrap(),
            )));
            builder.push_default(parley::StyleProperty::FontSize(font_size));
            builder.push_default(parley::StyleProperty::LineHeight(
                parley::LineHeight::FontSizeRelative(line_height),
            ));

            let mut layout = builder.build(&*content);
            layout.break_all_lines(Some(max_width));

            if layout.height() <= height && layout.width() <= width {
                layout.align(
                    Some(max_width),
                    halign.into(),
                    parley::AlignmentOptions {
                        align_when_overflowing: true,
                    },
                );
                break layout;
            } else {
                font_size -= 0.02 * max_font_size;
            }
        };

        let mut transform = Transform::from_translate(x, y);

        match valign {
            VAlign::Top => {}
            VAlign::Center => {
                transform = transform.post_translate(0.0, (height - layout.height()) / 2.0)
            }
            VAlign::Bottom => transform = transform.post_translate(0.0, height - layout.height()),
        };

        if let Some(angle) = rotate {
            if angle != 0.0 {
                transform = transform.post_rotate_at(angle, center.x, center.y);
            }
        }

        render_layout(
            layout,
            &mut pixmap,
            transform,
            color,
            outline.map(|mut outline| {
                outline.width = outline.width_for_font_size(font_size);
                outline
            }),
        );

        if debug {
            let mut builder = tiny_skia::PathBuilder::new();
            builder.move_to(center.x, center.y - 10.0);
            builder.line_to(center.x, center.y + 10.0);
            builder.move_to(center.x - 10.0, center.y);
            builder.line_to(center.x + 10.0, center.y);
            let path = builder.finish().unwrap();

            pixmap.stroke_path(
                &path,
                &center_paint,
                &debug_stroke,
                Transform::identity(),
                None,
            );

            let mut builder = path.clear();
            builder.push_rect(tiny_skia::Rect::from_xywh(x, y, width, height).unwrap());
            let path = builder.finish().unwrap();

            pixmap.stroke_path(
                &path,
                &border_paint,
                &debug_stroke,
                Transform::identity(),
                None,
            );
            if let Some(angle) = rotate {
                if angle != 0.0 {
                    pixmap.stroke_path(
                        &path,
                        &rotated_paint,
                        &debug_stroke,
                        Transform::from_rotate_at(angle, center.x, center.y),
                        None,
                    );
                }
            }
        }
    }

    Ok(())
}

fn premultiply_slice<B: AsMut<[u8]>>(mut bytes: B) -> B {
    for pixel in bytes.as_mut().chunks_exact_mut(4) {
        let [r, g, b, a] = pixel.try_into().expect("slice must have a length of 4");
        let premultiplied = tiny_skia::ColorU8::from_rgba(r, g, b, a).premultiply();
        pixel[0] = premultiplied.red();
        pixel[1] = premultiplied.green();
        pixel[2] = premultiplied.blue();
        pixel[3] = premultiplied.alpha();
    }
    bytes
}

fn demultiply_slice<B: AsMut<[u8]>>(mut bytes: B) -> B {
    for pixel in bytes.as_mut().chunks_exact_mut(4) {
        let [r, g, b, a] = pixel.try_into().expect("slice must have a length of 4");
        let demultiplied = tiny_skia::PremultipliedColorU8::from_rgba(r, g, b, a)
            .unwrap()
            .demultiply();
        pixel[0] = demultiplied.red();
        pixel[1] = demultiplied.green();
        pixel[2] = demultiplied.blue();
        pixel[3] = demultiplied.alpha();
    }
    bytes
}