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; let fifth_height: u32 = height / 5; let almost_bottom: u32 = height * 8 / 10; 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)
}