lesspub 1.0.2

CLI tool for downloading Sequences from LessWrong and exporting them as EPUB format ebooks
Documentation
use std::{
    error::Error,
    fmt::Write,
    fs::File,
    io::{BufReader, Read},
};

use anyhow::anyhow;
use base64::{self, Engine};
use bytes::{Buf, Bytes};
use epub_builder::{EpubBuilder, ZipLibrary};
use image::{EncodableLayout, GenericImageView};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{from_reader, to_string_pretty};

use crate::{cache::cache_data, cli::CacheOptions};

const FONTS: &[&[u8]] = &[include_bytes!("../fonts/et-book-roman.ttf")];

#[derive(PartialEq, Eq, Hash, Clone, Debug)]
pub struct ImageData {
    pub bytes: Bytes,
    pub mime: String,
    pub dimensions: Dimensions,
    pub slug: String,
    pub extension: String,
}

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SerializableImageData {
    pub mime: String,
    pub dimensions: Dimensions,
    pub slug: String,
    pub extension: String,
}
impl From<ImageData> for SerializableImageData {
    fn from(value: ImageData) -> Self {
        Self {
            mime: value.mime,
            dimensions: value.dimensions,
            slug: value.slug,
            extension: value.extension,
        }
    }
}

#[derive(PartialEq, Eq, Hash, Serialize, Deserialize, Clone, Debug)]
pub struct Dimensions {
    pub width: u32,
    pub height: u32,
}

pub async fn cover_image(
    subject: &str,
    authors: &[String],
    client: &Client,
    url: &str,
    cache_options: &CacheOptions,
    cache_cover: bool,
) -> Result<ImageData, Box<dyn Error>> {
    let slug: String = slug::slugify(url)
        .chars()
        .rev()
        .take(16)
        .collect::<String>()
        .chars()
        .rev()
        .collect();
    let subject_slug = slug::slugify(subject);

    match uncache_image(&format!("cover_{subject_slug}_{slug}"), cache_options)? {
        Some(cached_image) => Ok(cached_image),
        None => {
            let mut image = download_or_uncache_image(client, url, cache_options).await?;
            let dimensions = Dimensions {
                width: image.dimensions.width.clamp(320, 1600),
                height: image.dimensions.height.clamp(512, 2560),
            };
            image.dimensions = dimensions.clone();

            let svg = svg(subject, authors, image);

            let rendered_image = render_svg(&svg, dimensions.clone())?;

            let image_data = ImageData {
                bytes: bytes::Bytes::copy_from_slice(rendered_image.as_bytes()),
                mime: String::from("image/png"),
                dimensions,
                slug: format!("cover_{subject_slug}_{slug}"),
                extension: String::from(".png"),
            };

            if cache_cover {
                cache_image(image_data.clone(), cache_options)?;
            }

            Ok(image_data)
        }
    }
}

fn svg(subject: &str, authors: &[String], background: ImageData) -> String {
    let width: u32 = background.dimensions.width;
    let height: u32 = background.dimensions.height;

    let half_width: u32 = width / 2; // 800
    let fifth_height: u32 = height / 5; // 530
    let almost_bottom: u32 = height * 8 / 10; // 2120
    let title_text_size: u32 = (150.0 / 1600.0 * (width as f64)) as u32;
    let authors_text_size: u32 = (100.0 / 1600.0 * (width as f64)) as u32;
    let stroke_width: u32 = (15.0 / 1600.0 * (width as f64)).ceil() as u32;
    let style: &str = &format!(
        r##"margin="20px" max-width="100%" text-anchor="middle" font-family="serif" stroke="white" stroke-width="{stroke_width}px" paint-order="stroke""##
    );

    let subject = html_escape::decode_html_entities(
        &html_escape::encode_quoted_attribute(subject).to_string(),
    )
    .to_string();
    let subject: String = textwrap::wrap(&subject, 25).iter().enumerate().fold(
        String::new(),
        |mut subject, (i, part)| {
            let i: u32 = i.try_into().unwrap();
            write!(
                subject,
                r##"<tspan x="{half_width}" y="{}">{}</tspan>"##,
                fifth_height + (title_text_size * (i + 1)),
                html_escape::encode_quoted_attribute(part)
            )
            .unwrap();
            subject
        },
    );
    let data = base64::engine::general_purpose::STANDARD.encode(&background.bytes);
    let authors: String = textwrap::wrap(&authors.join(", \n"), 25)
        .iter()
        .enumerate()
        .fold(String::new(), |mut authors, (i, part)| {
            let i: u32 = i.try_into().unwrap();
            write!(
                authors,
                r##"<tspan x="{half_width}" y="{}">{}</tspan>"##,
                almost_bottom + (authors_text_size * (i + 1)),
                html_escape::encode_quoted_attribute(part)
            )
            .unwrap();
            authors
        });
    let mime = background.mime;

    format!(
        r##"<svg viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">
        <rect width="100%" height="100%" fill="#F5F5DF"/>
        <image width="100%" height="100%" href="data:{mime};base64,{data}"/>
        <text x="{half_width}" y="{fifth_height}" font-size="{title_text_size}px" {style} class="title">
            {subject}
        </text>
        <text x="{half_width}" y="{almost_bottom}" font-size="{authors_text_size}px" {style} class="authors">
            {authors}
        </text>
      </svg>
      "##
    )
}

fn render_svg(svg: &str, dimensions: Dimensions) -> Result<Vec<u8>, Box<dyn Error>> {
    let width: u32 = dimensions.width;
    let height: u32 = dimensions.height;

    let db = {
        let mut db = fontdb::Database::new();
        for font in FONTS {
            db.load_font_data(font.to_vec());
        }
        db.set_serif_family("ETBembo");
        db
    };

    let tree = usvg::Tree::from_str(
        svg,
        &usvg::Options {
            font_family: "ETBembo".to_string(),
            ..Default::default()
        },
        &db,
    )?;

    let pixmap = {
        let mut pixmap =
            tiny_skia::Pixmap::new(width, height).ok_or(anyhow!("failed to create pixmap"))?;
        resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
        pixmap
    };

    Ok(pixmap.encode_png()?)
}

pub async fn download_image(client: &Client, url: &str) -> Result<ImageData, Box<dyn Error>> {
    let bytes = client.get(url).send().await?.bytes().await?;

    let format = image::guess_format(&bytes)?;
    let dims = image::load_from_memory(&bytes)?.dimensions();
    let mime = format.to_mime_type().to_string();
    let extension = format
        .extensions_str()
        .first()
        .map(|ext| format!(".{ext}"))
        .unwrap_or("".to_string());

    Ok(ImageData {
        bytes,
        mime,
        dimensions: Dimensions {
            width: dims.0,
            height: dims.1,
        },
        slug: slug::slugify(url),
        extension,
    })
}

pub fn uncache_image(
    slug: &str,
    cache_options: &CacheOptions,
) -> Result<Option<ImageData>, Box<dyn Error>> {
    if cache_options.ignore_cache {
        Ok(None)
    } else if let (Ok(data_file), Ok(json_file)) = (
        File::open(cache_options.cache_dir.join(slug)),
        File::open(cache_options.cache_dir.join(format!("{slug}.json"))),
    ) {
        let data = BufReader::new(data_file).bytes().filter_map(|b| b.ok());
        let wrapper: SerializableImageData = from_reader(BufReader::new(json_file))?;

        Ok(Some(ImageData {
            bytes: Bytes::from_iter(data),
            mime: wrapper.mime,
            dimensions: wrapper.dimensions,
            slug: wrapper.slug,
            extension: wrapper.extension,
        }))
    } else {
        Ok(None)
    }
}

pub async fn download_or_uncache_image(
    client: &Client,
    url: &str,
    cache_options: &CacheOptions,
) -> Result<ImageData, Box<dyn Error>> {
    match uncache_image(&slug::slugify(url), cache_options) {
        Ok(Some(cached_image)) => Ok(cached_image),
        _ => {
            let image = download_image(client, url).await?;
            cache_image(image.clone(), cache_options)?;
            Ok(image)
        }
    }
}

pub fn cache_image(image: ImageData, cache_options: &CacheOptions) -> Result<(), Box<dyn Error>> {
    cache_data(
        None,
        &format!("{}.json", image.slug),
        to_string_pretty(&SerializableImageData::from(image.clone()))?.as_bytes(),
        cache_options,
    )?;
    cache_data(None, &image.slug, image.bytes, cache_options)
}

pub async fn intern_image(
    builder: &mut EpubBuilder<ZipLibrary>,
    client: &Client,
    url: &str,
    cache_options: &CacheOptions,
) -> Result<String, Box<dyn Error>> {
    let mut image = download_or_uncache_image(client, url, cache_options).await?;
    if image.dimensions.width > 1600 || image.dimensions.height > 1600 {
        let image_data = image::load_from_memory(&image.bytes)?;
        let image_data = image_data.resize(1600, 1600, image::imageops::FilterType::Lanczos3);
        image.bytes = Bytes::copy_from_slice(image_data.as_bytes());
        (image.dimensions.width, image.dimensions.height) = image_data.dimensions();
    }
    save_image(builder, image)
}

pub fn save_image(
    builder: &mut EpubBuilder<ZipLibrary>,
    image: ImageData,
) -> Result<String, Box<dyn Error>> {
    let path = format!("{}{}", image.slug, image.extension);

    if let Err(e) = builder.add_resource(&path, image.bytes.reader(), image.mime) {
        log::warn!("Notice: Failed to add image: {e}");
    }

    Ok(path)
}