initial commit

This commit is contained in:
Chris W 2023-10-08 13:43:40 -06:00
commit 71d91f02e6
10 changed files with 3801 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

3017
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "inkify"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4"
silicon = { git = "https://github.com/Aloxaf/silicon.git" }
lazy_static = "1.4.0"
serde = { version = "1.0.130", features = ["derive"] }
structopt = "0.3.26"
image = "0.24.7"
anyhow = "1.0.75"
thiserror = "1.0.49"
syntect = "5.1.0"
font-kit = "0.11.0"
reqwest = "0.11.22"

59
Dockerfile Normal file
View File

@ -0,0 +1,59 @@
FROM rust:1.73.0-buster as builder
WORKDIR /usr/src/app
# Install dependencies
RUN apt-get update && apt-get install -y \
libssl-dev \
pkg-config \
libharfbuzz-dev
# Copy files
COPY . .
# Build
RUN cargo build --release
FROM debian:buster-slim as fonts
RUN apt-get update && apt-get install -y \
wget \
xz-utils
WORKDIR /data/fonts
COPY ./docker/download_nerd_fonts.sh .
RUN ls -la
RUN chmod +x download_nerd_fonts.sh
RUN bash ./download_nerd_fonts.sh
RUN mkdir -p /usr/share/fonts/truetype
RUN mv *.ttf /usr/share/fonts/truetype
FROM debian:buster-slim
# Install dependencies
RUN apt-get update && apt-get install -y \
libssl-dev \
libharfbuzz-dev \
libfontconfig1 \
fontconfig
# Copy fonts
COPY --from=fonts /usr/share/fonts/truetype /usr/share/fonts/truetype/
RUN fc-cache -fv
# Copy binary
COPY --from=builder /usr/src/app/target/release/inkify /usr/local/bin/inkify
ARG PORT=8080
ARG HOST=0.0.0.0
ENV PORT=$PORT
ENV HOST=$HOST
EXPOSE $PORT
# Run
ENTRYPOINT ["/usr/local/bin/inkify"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Chris Watson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

62
README.md Normal file
View File

@ -0,0 +1,62 @@
# Inkify
Unfortunately [Carbon](https://carbon.now.sh) has been without an API for too long, and I've run into a few cases where one would be useful for a project. So I present to you, Inkify, an API for generating beautiful pictures of your code.
## Usage
Inkify relies on the [silicon](https://github.com/Aloxaf/silicon) library for generating photos, and takes much the same arguments as the silicon CLI does. Arguments are passed as query parameters to the `/generate` route, and are as follows:
- code: The code to generate an image from. Required.
- language: The language to use for syntax highlighting. Optional, will attempt to guess if not provided.
- theme: The theme to use for syntax highlighting. Optional, defaults to Dracula.
- font: The font to use. Optional, defaults to Fira Code.
- shadow_color: The color of the shadow. Optional, defaults to transparent.
- background: The background color. Optional, defaults to transparent.
- tab_width: The tab width. Optional, defaults to 4.
- line_pad: The line padding. Optional, defaults to 2.
- line_offset: The line offset. Optional, defaults to 1.
- window_title: The window title. Optional, defaults to \"Inkify\".
- no_line_number: Whether to hide the line numbers. Optional, defaults to false.
- no_round_corner: Whether to round the corners. Optional, defaults to false.
- no_window_controls: Whether to hide the window controls. Optional, defaults to false.
- shadow_blur_radius: The shadow blur radius. Optional, defaults to 0.
- shadow_offset_x: The shadow offset x. Optional, defaults to 0.
- shadow_offset_y: The shadow offset y. Optional, defaults to 0.
- pad_horiz: The horizontal padding. Optional, defaults to 80.
- pad_vert: The vertical padding. Optional, defaults to 100.
- highlight_lines: The lines to highlight. Optional, defaults to none.
- background_image: The background image for the padding area as a URL. Optional, defaults to none.
### Routes
#### `GET /`
The index route is used as a help/ping route. It will always return a 200 response if the API is live, and the body is a JSON object containing a message and a list of routes.
#### `GET /generate`
The generate route is used to generate images. It takes the arguments listed above as query parameters, and returns a PNG image.
#### `GET /themes`
The themes route is used to get a list of available themes. It takes no arguments, and returns a JSON object containing a list of themes.
#### `GET /fonts`
The fonts route is used to get a list of available fonts. It takes no arguments, and returns a JSON object containing a list of fonts.
#### `GET /languages`
The languages route is used to get a list of available languages. It takes no arguments, and returns a JSON object containing a list of languages supported by the [syntect](https://github.com/trishume/syntect) library (which is used by silicon under the hood).
## Deployment
Inkify is written in Rust using the [actix-web](https://actix.rs) framework, and can be deployed as a standalone binary. It can also be deployed as a Docker container, and a Dockerfile is provided for this purpose. The Dockerfile also installs all nerd fonts by default, allowing you to use any of them as the font for your code.
## Contributing
Contributions are welcome, and can be made by opening a pull request. Please make sure to lint your code using `cargo clippy` before submitting a pull request.
## License
Inkify is licensed under the MIT license. See the [LICENSE](LICENSE) file for more information.

68
docker/download_nerd_fonts.sh Executable file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env bash
all_nerd_fonts=(
"3270"
"Agave"
"AnonymousPro"
"Arimo"
"AurulentSansMono"
"BigBlueTerminal"
"BitstreamVeraSansMono"
"CascadiaCode"
"CodeNewRoman"
"ComicShannsMono"
"Cousine"
"DaddyTimeMono"
"DejaVuSansMono"
"DroidSansMono"
"EnvyCodeR"
"FantasqueSansMono"
"FiraCode"
"FiraMono"
"Go-Mono"
"Gohu"
"Hack"
"Hasklig"
"HeavyData"
"Hermit"
"iA-Writer"
"IBMPlexMono"
"Inconsolata"
"InconsolataGo"
"InconsolataLGC"
"IntelOneMono"
"Iosevka"
"IosevkaTerm"
"JetBrainsMono"
"Lekton"
"LiberationMono"
"Lilex"
"Meslo"
"Monofur"
"Monoid"
"Mononoki"
"MPlus"
"NerdFontsSymbolsOnly"
"Noto"
"OpenDyslexic"
"Overpass"
"ProFont"
"ProggyClean"
"RobotoMono"
"ShareTechMono"
"SourceCodePro"
"SpaceMono"
"Terminus"
"Tinos"
"Ubuntu"
"UbuntuMono"
"VictorMono"
)
# Download each font, un-tar it, and install it
for font in "${all_nerd_fonts[@]}"; do
echo "Downloading $font..."
wget "https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/$font.tar.xz"
tar -xf "./$font.tar.xz"
rm "$font.tar.xz"
done

222
src/config.rs Normal file
View File

@ -0,0 +1,222 @@
use silicon::formatter::{ImageFormatter, ImageFormatterBuilder};
use silicon::utils::{Background, ShadowAdder};
use std::path::PathBuf;
use anyhow::Error;
use syntect::highlighting::{Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use crate::rgba::{Rgba, ImageRgba};
type FontList = Vec<(String, f32)>;
type Lines = Vec<u32>;
#[derive(Debug, Clone)]
#[derive(serde::Deserialize)]
pub struct Config {
/// Background image URL
pub background_image: Option<Vec<u8>>,
/// Background color of the image
pub background: Rgba,
/// The code to highlight.
pub code: String,
/// The fallback font list. eg. 'Hack; SimSun=31'
pub font: Option<FontList>,
/// Lines to high light. rg. '1-3; 4'
pub highlight_lines: Option<Lines>,
/// The language for syntax highlighting. You can use full name ("Rust") or file extension ("rs").
pub language: Option<String>,
/// Pad between lines
pub line_pad: u32,
/// Line number offset
pub line_offset: u32,
/// Hide the window controls.
pub no_window_controls: bool,
/// Show window title
pub window_title: Option<String>,
/// Hide the line number.
pub no_line_number: bool,
/// Don't round the corner
pub no_round_corner: bool,
/// Pad horiz
pub pad_horiz: u32,
/// Pad vert
pub pad_vert: u32,
/// Color of shadow
pub shadow_color: Rgba,
/// Blur radius of the shadow. (set it to 0 to hide shadow)
pub shadow_blur_radius: f32,
/// Shadow's offset in Y axis
pub shadow_offset_y: i32,
/// Shadow's offset in X axis
pub shadow_offset_x: i32,
/// Tab width
pub tab_width: u8,
/// The syntax highlight theme. It can be a theme name or path to a .tmTheme file.
pub theme: String
}
impl Config {
pub fn default() -> Self {
Config {
background_image: None,
background: Rgba(ImageRgba([0, 0, 0, 0])),
code: "".to_owned(),
font: None,
highlight_lines: None,
language: None,
line_pad: 2,
line_offset: 1,
no_window_controls: false,
window_title: None,
no_line_number: false,
no_round_corner: false,
pad_horiz: 80,
pad_vert: 100,
shadow_color: Rgba(ImageRgba([0, 0, 0, 0])),
shadow_blur_radius: 0.0,
shadow_offset_y: 0,
shadow_offset_x: 0,
tab_width: 4,
theme: "Dracula".to_owned()
}
}
pub fn language<'a>(&self, ps: &'a SyntaxSet) -> Result<&'a SyntaxReference, Error> {
let possible_language = self.language.as_ref().map(|language| {
ps.find_syntax_by_token(language)
.ok_or_else(|| format_err!("Unable to determine language, please provide one explicitly"))
});
let language = possible_language.unwrap_or_else(|| {
ps.find_syntax_by_first_line(self.code.as_ref())
.ok_or_else(|| format_err!("Unable to determine language, please provide one explicitly"))
})?;
Ok(language)
}
pub fn theme(&self, ts: &ThemeSet) -> Result<Theme, Error> {
if let Some(theme) = ts.themes.get(&self.theme) {
Ok(theme.clone())
} else {
ThemeSet::get_theme(PathBuf::from(&self.theme))
.map_err(|e| Error::msg(format!("Invalid theme: {}", e)))
}
}
pub fn get_formatter(&self) -> Result<ImageFormatter, Error> {
let formatter = ImageFormatterBuilder::new()
.line_pad(self.line_pad)
.window_controls(!self.no_window_controls)
.window_title(self.window_title.clone())
.line_number(!self.no_line_number)
.font(self.font.clone().unwrap_or_default())
.round_corner(!self.no_round_corner)
.shadow_adder(self.get_shadow_adder()?)
.tab_width(self.tab_width)
.highlight_lines(self.highlight_lines.clone().unwrap_or_default())
.line_offset(self.line_offset);
Ok(formatter.build()?)
}
pub fn get_shadow_adder(&self) -> Result<ShadowAdder, Error> {
Ok(ShadowAdder::new()
.background(match &self.background_image {
Some(path) => Background::Image(image::load_from_memory(path)?.to_rgba8()),
None => Background::Solid(self.background.to_rgba()),
})
.shadow_color(self.shadow_color.to_rgba())
.blur_radius(self.shadow_blur_radius)
.pad_horiz(self.pad_horiz)
.pad_vert(self.pad_vert)
.offset_x(self.shadow_offset_x)
.offset_y(self.shadow_offset_y))
}
}
/// Query parameters for the /generate endpoint, using Option to make all options
/// with defaults optional.
#[derive(Debug, Clone)]
#[derive(serde::Deserialize)]
pub struct ConfigQuery {
/// Background image URL
pub background_image: Option<String>,
/// Background color of the image
pub background: Option<String>,
/// The code to highlight.
pub code: String,
/// The fallback font list. eg. 'Hack; SimSun=31'
pub font: Option<String>,
/// Lines to high light. rg. '1-3; 4'
pub highlight_lines: Option<String>,
/// The language for syntax highlighting. You can use full name ("Rust") or file extension ("rs").
pub language: Option<String>,
/// Pad between lines
pub line_pad: Option<u32>,
/// Line number offset
pub line_offset: Option<u32>,
/// Hide the window controls.
pub no_window_controls: Option<bool>,
/// Show window title
pub window_title: Option<String>,
/// Hide the line number.
pub no_line_number: Option<bool>,
/// Don't round the corner
pub no_round_corner: Option<bool>,
/// Pad horiz
pub pad_horiz: Option<u32>,
/// Pad vert
pub pad_vert: Option<u32>,
/// Color of shadow
pub shadow_color: Option<String>,
/// Blur radius of the shadow. (set it to 0 to hide shadow)
pub shadow_blur_radius: Option<f32>,
/// Shadow's offset in Y axis
pub shadow_offset_y: Option<i32>,
/// Shadow's offset in X axis
pub shadow_offset_x: Option<i32>,
/// Tab width
pub tab_width: Option<u8>,
/// The syntax highlight theme. It can be a theme name or path to a .tmTheme file.
pub theme: Option<String>
}

287
src/main.rs Normal file
View File

@ -0,0 +1,287 @@
#[macro_use]
extern crate anyhow;
use actix_web::{get, web, App, HttpResponse, HttpServer, Responder};
use anyhow::Error;
use lazy_static::lazy_static;
use silicon as si;
use silicon::utils::ToRgba;
use std::collections::HashSet;
use std::io::Cursor;
use std::num::ParseIntError;
use syntect::easy::HighlightLines;
use syntect::util::LinesWithEndings;
mod config;
mod rgba;
lazy_static! {
static ref HIGHLIGHTING_ASSETS: si::assets::HighlightingAssets =
silicon::assets::HighlightingAssets::new();
}
macro_rules! unwrap_or_return {
( $e:expr, $r:expr ) => {
match $e {
Ok(x) => x,
Err(_) => return $r,
}
};
}
fn parse_font_str(s: &str) -> Vec<(String, f32)> {
let mut result = vec![];
for font in s.split(';') {
let tmp = font.split('=').collect::<Vec<_>>();
let font_name = tmp[0].to_owned();
let font_size = tmp
.get(1)
.map(|s| s.parse::<f32>().unwrap())
.unwrap_or(26.0);
result.push((font_name, font_size));
}
result
}
fn parse_line_range(s: &str) -> Result<Vec<u32>, ParseIntError> {
let mut result = vec![];
for range in s.split(';') {
let range: Vec<u32> = range
.split('-')
.map(|s| s.parse::<u32>())
.collect::<Result<Vec<_>, _>>()?;
if range.len() == 1 {
result.push(range[0])
} else {
for i in range[0]..=range[1] {
result.push(i);
}
}
}
Ok(result)
}
fn parse_str_color(s: &str) -> Result<rgba::Rgba, Error> {
let res = s
.to_rgba()
.map_err(|_| format_err!("Invalid color: `{}`", s));
Ok(rgba::Rgba(res?))
}
#[get("/")]
async fn help() -> impl Responder {
// Respond with some help text for how to use the API,
// formatted as JSON since this is an API.
let json = r#"
{
"message": "Hello, world! Welcome to Inkify, a simple API for generating images from code. Think of it like Carbon in API form.",
"routes": {
"GET /": "This help text. Will always return 200, so you can use it to check if the server is up.",
"GET /themes": "Return a list of available syntax themes.",
"GET /languages": "Retuns a list of languages which can be parsed.",
"GET /fonts": "Returns a list of available fonts.",
"GET /generate": {
"description": "Generate an image from the given code.",
"parameters": {
"code": "The code to generate an image from. Required.",
"language": "The language to use for syntax highlighting. Optional, will attempt to guess if not provided.",
"theme": "The theme to use for syntax highlighting. Optional, defaults to Dracula.",
"font": "The font to use. Optional, defaults to Fira Code.",
"shadow_color": "The color of the shadow. Optional, defaults to transparent.",
"background": "The background color. Optional, defaults to transparent.",
"tab_width": "The tab width. Optional, defaults to 4.",
"line_pad": "The line padding. Optional, defaults to 2.",
"line_offset": "The line offset. Optional, defaults to 1.",
"window_title": "The window title. Optional, defaults to \"Inkify\".",
"no_line_number": "Whether to hide the line numbers. Optional, defaults to false.",
"no_round_corner": "Whether to round the corners. Optional, defaults to false.",
"no_window_controls": "Whether to hide the window controls. Optional, defaults to false.",
"shadow_blur_radius": "The shadow blur radius. Optional, defaults to 0.",
"shadow_offset_x": "The shadow offset x. Optional, defaults to 0.",
"shadow_offset_y": "The shadow offset y. Optional, defaults to 0.",
"pad_horiz": "The horizontal padding. Optional, defaults to 80.",
"pad_vert": "The vertical padding. Optional, defaults to 100.",
"highlight_lines": "The lines to highlight. Optional, defaults to none.",
"background_image": "The background image for the padding area as a URL. Optional, defaults to none."
}
}
}
}
"#;
HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(json)
}
#[get("/themes")]
async fn themes() -> impl Responder {
let ha = &*HIGHLIGHTING_ASSETS;
let themes = &ha.theme_set.themes;
let theme_keys: Vec<String> = themes.keys().map(|s| s.to_string()).collect();
HttpResponse::Ok().json(theme_keys)
}
#[get("/languages")]
async fn languages() -> impl Responder {
let ha = &*HIGHLIGHTING_ASSETS;
let syntaxes = &ha.syntax_set.syntaxes();
let mut languages = syntaxes
.iter()
.map(|s| s.name.to_string())
.collect::<Vec<String>>();
let unique_languages: HashSet<String> = languages.drain(..).collect();
let mut unique_languages: Vec<String> = unique_languages.into_iter().collect();
unique_languages.sort();
HttpResponse::Ok().json(unique_languages)
}
#[get("/fonts")]
async fn fonts() -> impl Responder {
let source = font_kit::source::SystemSource::new();
let fonts = source.all_families().unwrap_or_default();
HttpResponse::Ok().json(fonts)
}
#[get("/generate")]
async fn generate(info: web::Query<config::ConfigQuery>) -> impl Responder {
let ha = &*HIGHLIGHTING_ASSETS;
let (ps, ts) = (&ha.syntax_set, &ha.theme_set);
let mut conf = config::Config::default();
conf.code = info.code.clone();
if conf.code.is_empty() {
return HttpResponse::BadRequest()
.append_header(("Content-Type", "application/json"))
.body(r#"{"error": "code parameter is required"}"#);
}
conf.language = info.language.clone();
if let Some(theme) = info.theme.clone() {
conf.theme = theme;
}
if let Some(font) = info.font.clone() {
conf.font = Some(parse_font_str(&font));
}
if let Some(shadow_color) = info.shadow_color.clone() {
conf.shadow_color = parse_str_color(shadow_color.as_str()).unwrap();
}
if let Some(background) = info.background.clone() {
conf.background = parse_str_color(background.as_str()).unwrap();
}
if let Some(tab_width) = info.tab_width {
conf.tab_width = tab_width;
}
if let Some(line_pad) = info.line_pad {
conf.line_pad = line_pad;
}
if let Some(line_offset) = info.line_offset {
conf.line_offset = line_offset;
}
if let Some(window_title) = info.window_title.clone() {
conf.window_title = Some(window_title);
}
if let Some(no_line_number) = info.no_line_number {
conf.no_line_number = no_line_number;
}
if let Some(no_round_corner) = info.no_round_corner {
conf.no_round_corner = no_round_corner;
}
if let Some(no_window_controls) = info.no_window_controls {
conf.no_window_controls = no_window_controls;
}
if let Some(shadow_blur_radius) = info.shadow_blur_radius {
conf.shadow_blur_radius = shadow_blur_radius;
}
if let Some(shadow_offset_x) = info.shadow_offset_x {
conf.shadow_offset_x = shadow_offset_x;
}
if let Some(shadow_offset_y) = info.shadow_offset_y {
conf.shadow_offset_y = shadow_offset_y;
}
if let Some(pad_horiz) = info.pad_horiz {
conf.pad_horiz = pad_horiz;
}
if let Some(pad_vert) = info.pad_vert {
conf.pad_vert = pad_vert;
}
if let Some(highlight_lines) = info.highlight_lines.clone() {
conf.highlight_lines = Some(parse_line_range(highlight_lines.as_str()).unwrap());
}
if let Some(background_image) = info.background_image.clone() {
// If a background image is provided, it will be as a URL. We need
// to download it and add it to the config as a Vec<u8>.
let res = reqwest::get(background_image.as_str()).await;
if let Ok(mut res) = res {
let mut buf = vec![];
while let Ok(Some(chunk)) = res.chunk().await {
buf.extend_from_slice(&chunk);
}
conf.background_image = Some(buf);
}
}
let syntax = unwrap_or_return!(
conf.language(ps),
HttpResponse::BadRequest()
.append_header(("Content-Type", "application/json"))
.body(r#"{"error": "Unable to determine language, please provide one explicitly"}"#)
);
let theme = unwrap_or_return!(
conf.theme(ts),
HttpResponse::BadRequest()
.append_header(("Content-Type", "application/json"))
.body(r#"{"error": "Invalid theme"}"#)
);
let mut h = HighlightLines::new(syntax, &theme);
let highlight = unwrap_or_return!(
LinesWithEndings::from(conf.code.as_ref())
.map(|line| h.highlight_line(line, ps))
.collect::<Result<Vec<_>, _>>(),
HttpResponse::InternalServerError()
.append_header(("Content-Type", "application/json"))
.body(r#"{"error": "Failed to highlight code"}"#)
);
let mut formatter = unwrap_or_return!(
conf.get_formatter(),
HttpResponse::InternalServerError()
.append_header(("Content-Type", "application/json"))
.body(r#"{"error": "Failed to get formatter"}"#)
);
let image = formatter.format(&highlight, &theme);
let mut buffer: Vec<u8> = Vec::new();
unwrap_or_return!(
image.write_to(&mut Cursor::new(&mut buffer), image::ImageOutputFormat::Png),
HttpResponse::InternalServerError()
.append_header(("Content-Type", "application/json"))
.body(r#"{"error": "Failed to write image"}"#)
);
// Return the image as a PNG.
HttpResponse::Ok()
.append_header(("Content-Type", "image/png"))
.body(buffer)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_owned());
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_owned());
let server = HttpServer::new(|| {
App::new()
.service(help)
.service(themes)
.service(languages)
.service(fonts)
.service(generate)
})
.bind((host.clone(), port.parse::<u16>().unwrap()))?
.run();
println!("Inkify listening on {}:{}", host, port);
println!("Visit http://{}:{}/ to get started.", host, port);
server.await
}

45
src/rgba.rs Normal file
View File

@ -0,0 +1,45 @@
use silicon::utils::ToRgba;
use anyhow::Error;
pub use image::{Rgba as ImageRgba, Pixel};
#[derive(Debug, Clone)]
pub struct Rgba(pub ImageRgba<u8>);
impl Rgba {
pub fn to_rgba(&self) -> ImageRgba<u8> {
self.0
}
}
impl From<ImageRgba<u8>> for Rgba {
fn from(rgba: ImageRgba<u8>) -> Self {
Rgba(rgba)
}
}
impl<'de> serde::Deserialize<'de> for Rgba {
fn deserialize<D>(deserializer: D) -> Result<Rgba, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_str_color(&s).map_err(serde::de::Error::custom)
}
}
impl std::fmt::Display for Rgba {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let channels = self.0.channels();
let r = channels[0];
let g = channels[1];
let b = channels[2];
let a = channels[3];
write!(f, "#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
}
}
fn parse_str_color(s: &str) -> Result<Rgba, Error> {
let rgba = s.to_rgba()
.map_err(|e| Error::msg(format!("Invalid color: {}", e)))?;
Ok(Rgba(rgba))
}