From c0b2e7bea120dbe0afdad06d43e649e2cbb31df6 Mon Sep 17 00:00:00 2001 From: J/A Date: Fri, 2 Jun 2017 14:58:53 -0500 Subject: [PATCH] Do all the things ...seriously, though, I made too many changes to know what they are. --- Cargo.lock | 83 ++++++++++++++++- Cargo.toml | 5 +- src/annotation.rs | 133 +++++++++++++++++++++++++++ src/application.rs | 114 +++++++++++++++++++++++ src/error.rs | 1 + src/main.rs | 161 ++++---------------------------- src/options.rs | 224 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 571 insertions(+), 150 deletions(-) create mode 100644 src/annotation.rs create mode 100644 src/application.rs create mode 100644 src/error.rs create mode 100644 src/options.rs diff --git a/Cargo.lock b/Cargo.lock index f6e3740..7324de6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,9 +1,10 @@ [root] name = "annatar" -version = "0.1.0" +version = "0.1.1" dependencies = [ + "clap 2.24.2 (registry+https://github.com/rust-lang/crates.io-index)", "image 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", - "imageproc 0.8.0 (git+https://github.com/archer884/imageproc?branch=feature/new-rectangle)", + "imageproc 0.8.0 (git+https://github.com/archer884/imageproc?branch=annatar)", "rusttype 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -30,6 +31,11 @@ dependencies = [ "num-traits 0.1.37 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "ansi_term" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "approx" version = "0.1.1" @@ -44,11 +50,26 @@ dependencies = [ "odds 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "atty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.23 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "bitflags" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "bitflags" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "byteorder" version = "0.4.2" @@ -59,6 +80,21 @@ name = "byteorder" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "clap" +version = "2.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "ansi_term 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", + "atty 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "bitflags 0.8.2 (registry+https://github.com/rust-lang/crates.io-index)", + "strsim 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", + "term_size 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-segmentation 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "vec_map 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "color_quant" version = "1.0.0" @@ -144,7 +180,7 @@ dependencies = [ [[package]] name = "imageproc" version = "0.8.0" -source = "git+https://github.com/archer884/imageproc?branch=feature/new-rectangle#3ac57fc3e5b55baa2551570a1ddb76df2fc5bd4d" +source = "git+https://github.com/archer884/imageproc?branch=annatar#d0c5da28a1d49c8f6ea47fca5c56cd18f5cb781f" dependencies = [ "conv 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)", "image 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -413,6 +449,21 @@ dependencies = [ "byteorder 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "strsim" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "term_size" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.23 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "thread-id" version = "2.0.0" @@ -435,11 +486,26 @@ name = "typenum" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "unicode-segmentation" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "unicode-width" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "utf8-ranges" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "vec_map" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "winapi" version = "0.2.8" @@ -454,11 +520,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum adler32 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3ff33fe13a08dbce05bcefa2c68eea4844941437e33d6f808240b54d7157b9cd" "checksum aho-corasick 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ca972c2ea5f742bfce5687b9aef75506a764f61d37f8f649047846a9686ddb66" "checksum alga 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ac1e4aec8a55e9150c6941e3c1059272b8e607c0503f9b61ca12289e56efb87b" +"checksum ansi_term 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "23ac7c30002a5accbf7e8987d0632fa6de155b7c3d39d0067317a391e00a2ef6" "checksum approx 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "08abcc3b4e9339e33a3d0a5ed15d84a687350c05689d825e0f6655eef9e76a94" "checksum arrayvec 0.3.23 (registry+https://github.com/rust-lang/crates.io-index)" = "699e63a93b79d717e8c3b5eb1b28b7780d0d6d9e59a72eb769291c83b0c8dc67" +"checksum atty 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d912da0db7fa85514874458ca3651fe2cddace8d0b0505571dbdcd41ab490159" "checksum bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d" +"checksum bitflags 0.8.2 (registry+https://github.com/rust-lang/crates.io-index)" = "1370e9fc2a6ae53aea8b7a5110edbd08836ed87c88736dfabccade1c2b44bff4" "checksum byteorder 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "96c8b41881888cc08af32d47ac4edd52bc7fa27fef774be47a92443756451304" "checksum byteorder 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c40977b0ee6b9885c9013cd41d9feffdd22deb3bb4dc3a71d901cc7a77de18c8" +"checksum clap 2.24.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6b8f69e518f967224e628896b54e41ff6acfb4dcfefc5076325c36525dac900f" "checksum color_quant 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a475fc4af42d83d28adf72968d9bcfaf035a1a9381642d8e85d8a04957767b0d" "checksum conv 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "78ff10625fd0ac447827aa30ea8b861fead473bb60aeb73af6c1c58caf0d1299" "checksum custom_derive 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "ef8ae57c4978a2acd8b869ce6b9ca1dfe817bff704c220209fdef2c0b75a01b9" @@ -469,7 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum generic-array 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "3406a3975bc944fdd85b7964d53296a0ff11f4b6c4704fa4972c9a7c8ba27367" "checksum gif 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "8a80d6fe9e52f637df9afd4779449a7be17c39cc9c35b01589bb833f956ba596" "checksum image 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1c3f4f5ea213ed9899eca760a8a14091d4b82d33e27cf8ced336ff730e9f6da8" -"checksum imageproc 0.8.0 (git+https://github.com/archer884/imageproc?branch=feature/new-rectangle)" = "" +"checksum imageproc 0.8.0 (git+https://github.com/archer884/imageproc?branch=annatar)" = "" "checksum inflate 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d1238524675af3938a7c74980899535854b88ba07907bb1c944abe5b8fc437e5" "checksum itertools 0.4.19 (registry+https://github.com/rust-lang/crates.io-index)" = "c4a9b56eb56058f43dc66e58f40a214b2ccbc9f3df51861b63d51dec7b65bc3f" "checksum jpeg-decoder 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)" = "919d49b634cde303392353c5dd51153ec005a1a981c6f4b8277692a51e9d260d" @@ -502,9 +572,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum rusttype 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "c3c64ffc93b0cc5a6f5e5e84da2a4082b0271e0a1dd76e821bdac570bda7797e" "checksum scoped_threadpool 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "3ef399c8893e8cb7aa9696e895427fab3a6bf265977bb96e126f24ddd2cda85a" "checksum stb_truetype 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "21b5c3b588a493a477e0d99769ee091b3627625f9ba4bdd882e6b4b0b0958805" +"checksum strsim 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b4d15c810519a91cf877e7e36e63fe068815c678181439f2f29e2562147c3694" +"checksum term_size 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e2b6b55df3198cc93372e85dd2ed817f0e38ce8cc0f22eb32391bfad9c4bf209" "checksum thread-id 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a9539db560102d1cef46b8b78ce737ff0bb64e7e18d35b2a5688f7d097d0ff03" "checksum thread_local 0.2.7 (registry+https://github.com/rust-lang/crates.io-index)" = "8576dbbfcaef9641452d5cf0df9b0e7eeab7694956dd33bb61515fb8f18cfdd5" "checksum typenum 1.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "13a99dc6780ef33c78780b826cf9d2a78840b72cae9474de4bcaf9051e60ebbd" +"checksum unicode-segmentation 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a8083c594e02b8ae1654ae26f0ade5158b119bd88ad0e8227a5d8fcd72407946" +"checksum unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "bf3a113775714a22dcb774d8ea3655c53a32debae63a063acc00a91cc586245f" "checksum utf8-ranges 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "a1ca13c08c41c9c3e04224ed9ff80461d97e121589ff27c753a16cb10830ae0f" +"checksum vec_map 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "887b5b631c2ad01628bbbaa7dd4c869f80d3186688f8d0b6f58774fbe324988c" "checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" "checksum winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" diff --git a/Cargo.toml b/Cargo.toml index 80f4195..d917371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "annatar" -version = "0.1.0" +version = "0.1.1" authors = ["J/A "] [dependencies] +clap = "*" image = "*" -imageproc = { git = "https://github.com/archer884/imageproc", branch = "feature/new-rectangle" } +imageproc = { git = "https://github.com/archer884/imageproc", branch = "annatar" } rusttype = "*" diff --git a/src/annotation.rs b/src/annotation.rs new file mode 100644 index 0000000..28715d9 --- /dev/null +++ b/src/annotation.rs @@ -0,0 +1,133 @@ +use application::AppRunError; +use image::{DynamicImage, GenericImage, ImageBuffer, imageops, Luma, Rgba, RgbaImage}; +use imageproc::{drawing, edges}; +use imageproc::rect::Rect; +use rusttype::{Font, Scale}; + +pub enum Annotation { + CaptionBottom(Text) +} + +// Apparently, rendering cannot produce any errors? +pub struct AnnotationRenderError; + +impl From for AppRunError { + fn from(_: AnnotationRenderError) -> Self { + unimplemented!() + } +} + +pub struct Text { + content: String, + + // At some point, this will be used for something. I think. + #[allow(unused)] + style: TextStyle, +} + +pub enum TextStyle { + /// Default font with standard issue black outline. + Default, +} + +impl> From for Text { + fn from(content: T) -> Self { + Text { + content: content.into(), + style: TextStyle::Default, + } + } +} + +impl Annotation { + pub fn render<'a>(&'a self, pixels: &'a mut DynamicImage, font: &'a Font<'a>, scale_factor: f32) -> Result<(), AnnotationRenderError> { + match *self { + Annotation::CaptionBottom(ref text) => { + let _ = render_caption_bottom(text, pixels, font, scale_factor); + Ok(()) + } + } + } + + pub fn render_and_debug<'a>(&'a self, pixels: &'a mut DynamicImage, font: &'a Font<'a>, scale_factor: f32) -> Result { + match *self { + Annotation::CaptionBottom(ref text) => { + let debug = render_caption_bottom(text, pixels, font, scale_factor); + debug.map(|image| DynamicImage::ImageRgba8(image)) + } + } + } +} + +fn render_caption_bottom<'a>(text: &'a Text, pixels: &'a mut DynamicImage, font: &'a Font<'a>, scale_factor: f32) -> Result { + // The final value in the array here is the *opacity* of the pixel. Not the transparency. + // Apparently, this is not CSS... + let white_pixel = Rgba([255, 255, 255, 255]); + let black_pixel = Rgba([0, 0, 0, 255]); + + let scale = Scale::uniform(scale_factor); + let (width, height) = pixels.dimensions(); + let (text_width, text_height) = text_size(&text.content, font, scale); + + // What follows is a little fourth grade math that attempts to stick the text at the center + // of the bottom fifth of the image. This, by the way, is the closest I have ever come to + // using anything I learned in Mrs. Vye's 9th grade keyboarding class. Thank God for the + // IBM Selectric III, huh? + let x = (width / 2) - (text_width / 2); + let y = height - ((height / 5) - (text_height / 2)); + + let mut edge_rendering = ImageBuffer::from_pixel(text_width, text_height, black_pixel); + drawing::draw_text_with_font_mut( + &mut edge_rendering, white_pixel, 0, 0, scale, &font, &text.content + ); + + // These thresholds are black magic to me. + // + // A further note: this step is independent of text style, with the exception that, + // obviously, we'll skip it if the user has requested no border. + for (idx, &pixel) in edges::canny(&imageops::grayscale(&edge_rendering), 255.0, 255.0).pixels().enumerate() { + if Luma([255u8]) == pixel { + let idx = idx as u32; + let x = idx % text_width + x; + let y = idx / text_width + y; + + // I bet this isn't cheap, but... meh. + let rect_size = (0.1 * scale_factor) as u32; + let offset = (rect_size / 2) as i32; + let rect = Rect::at(x as i32 - offset, y as i32 - offset) + .of_size(rect_size, rect_size); + + drawing::draw_filled_rect_mut(pixels, rect, Rgba([0, 0, 0, 255])); + } + } + + drawing::draw_text_with_font_mut(pixels, white_pixel, x, y, scale, &font, &text.content); + Ok(edge_rendering) +} + +/// Calculate the dimensions of the bounding box for a given string, font, and scale. +/// +/// This works by summing the "advance width" of each glyph in the text, entirely ignoring +/// kerning as each character is considered in isolation. Because this is used just to center +/// text in the image, it's close enough for government work. +fn text_size<'a>(s: &'a str, font: &'a Font<'a>, scale: Scale) -> (u32, u32) { + use rusttype::VMetrics; + + let text_width = font.glyphs_for(s.chars()) + .map(|glyph| glyph.scaled(scale).h_metrics().advance_width) + .sum::(); + + // The "v-metrics" for any given letter in a font are the same for a given scale, so we don't + // need to check this for each glyph. + let text_height = { + let VMetrics { ascent, descent, ..} = font.v_metrics(scale); + (ascent - descent) as u32 + }; + + // I know I'm truncating the length and this is probably wrong, but it's not wrong by enough + // to be noticeable when you print it to an image. + // + // The padding you see below is added to aid in edge detection, specifically because the + // exclamation point doesn't seem to have enough advance width. -.- + (text_width as u32 + 2, text_height) +} diff --git a/src/application.rs b/src/application.rs new file mode 100644 index 0000000..5d0b4ba --- /dev/null +++ b/src/application.rs @@ -0,0 +1,114 @@ +use error::Cause; +use image::{self, DynamicImage, GenericImage}; +use options::Options; +use rusttype::{Font, FontCollection}; +use std::borrow::Cow; +use std::error; +use std::fmt; +use std::path::Path; + +pub struct App; + +#[derive(Debug)] +pub struct AppRunError { + kind: AppRunErrorKind, + description: Cow<'static, str>, + cause: Cause, +} + +#[derive(Debug)] +pub enum AppRunErrorKind { + IO, + NotFound, +} + +impl AppRunError { + fn io>>(desc: D, cause: Cause) -> AppRunError { + AppRunError { + kind: AppRunErrorKind::IO, + description: desc.into(), + cause, + } + } + + fn not_found>>(desc: D, cause: Cause) -> AppRunError { + AppRunError { + kind: AppRunErrorKind::NotFound, + description: desc.into(), + cause, + } + } +} + +impl fmt::Display for AppRunError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.description) + } +} + +impl error::Error for AppRunError { + fn description(&self) -> &str { + &self.description + } + + fn cause(&self) -> Option<&error::Error> { + match self.cause { + Some(ref error) => Some(error.as_ref()), + None => None, + } + } +} + +impl App { + pub fn run(&self, options: &Options) -> Result<(), AppRunError> { + let font = build_font(options.font_path())?; + let mut pixels = load_pixels(options.base_image())?; + let scale_factor = (pixels.height() as f32 / 10.0) * options.scale_multiplier(); + + if options.debug() { + let debug_image = options.annotation().render_and_debug(&mut pixels, &font, scale_factor)?; + save_pixels("edge.ann.png", &debug_image)?; + } else { + options.annotation().render(&mut pixels, &font, scale_factor)?; + } + + Ok(save_pixels(options.output_path(), &pixels)?) + } +} + +fn build_font(path: &Path) -> Result, AppRunError> { + use std::fs::File; + use std::io::{BufReader, Read}; + + let mut font_file = File::open(path) + .map(|file| BufReader::new(file)) + .map_err(|e| AppRunError::not_found("Font not found", Some(Box::new(e))))?; + + let mut data = Vec::new(); + font_file.read_to_end(&mut data) + .map_err(|e| AppRunError::io("Unable to read font", Some(Box::new(e))))?; + + FontCollection::from_bytes(data) + .font_at(0) + .ok_or_else(|| AppRunError::not_found("Unable to locate valid font in file", None)) +} + +fn load_pixels(path: &Path) -> Result { + image::open(path) + .map_err(|e| AppRunError::not_found("Base image not found", Some(Box::new(e)))) +} + +fn save_pixels>(path: P, pixels: &DynamicImage) -> Result<(), AppRunError> { + use std::fs::OpenOptions; + use image::ImageFormat; + + let mut out = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path.as_ref()) + .map_err(|e| AppRunError::io("Unable to write to output", Some(Box::new(e))))?; + + pixels.save(&mut out, ImageFormat::PNG) + .map_err(|e| AppRunError::io("Unable to save image to output", Some(Box::new(e)))) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..bbdf5d1 --- /dev/null +++ b/src/error.rs @@ -0,0 +1 @@ +pub type Cause = Option>; diff --git a/src/main.rs b/src/main.rs index ec1d1c9..5f51e68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,157 +1,30 @@ -macro_rules! opt { - ($opt:expr) => { - match $opt { - Some(item) => item, - None => { - return None; - } - } - } -} +#[macro_use] +extern crate clap; extern crate image; extern crate imageproc; extern crate rusttype; -use image::{DynamicImage, GenericImage, ImageFormat, Luma, Rgba}; -use imageproc::drawing; -use imageproc::rect::Rect; -use rusttype::Scale; - -#[cfg(windows)] -const DEFAULT_FONT: Option<&str> = Some("C:/Windows/Fonts/Impact.ttf"); +mod annotation; +mod application; +mod error; +mod options; -#[cfg(macos)] -const DEFAULT_FONT: Option<&str> = Some("/Library/Fonts/Impact.ttf"); - -#[cfg(not(any(windows, macos)))] -const DEFAULT_FONT: Option<&str> = None; +use application::App; +use options::Options; +use std::process; fn main() { - // The final value in the array here is the *opacity* of the pixel. Not the transparency. - // Apparently, this is not CSS... - let white_pixel = Rgba([255, 255, 255, 255]); - let black_pixel = Rgba([0, 0, 0, 255]); - - let (image_path, text) = match read_command() { - Some(command) => command, - None => { - println!("Try `annatar "); - std::process::exit(1); - } - }; - - let mut pixels = match image::open(image_path) { - Ok(pixels) => pixels, - _ => { - println!("Unable to open image file"); - std::process::exit(2); + match Options::from_args() { + Ok(options) => { + if let Err(e) = App.run(&options) { + println!("{}", e); + process::exit(1); + } } - }; - - let font = read_font(DEFAULT_FONT); - let scale_factor = pixels.height() as f32 / 10.0; - let scale = Scale::uniform(scale_factor); - - // Apparently, `Scale` is copy. - let (text_width, text_height) = text_size(&text, &font, scale); - - // Seems like the coordinates x and y designate the top left corner of the region being drawn. - // In order to center this, I'm going to have to figure out how to determine the size of the - // region being drawn. - let (width, height) = pixels.dimensions(); - // What follows is a little fourth grade math that attempts to stick the text at the center - // of the bottom fifth of the image. This, by the way, is the closest I have ever come to - // using anything I learned in Mrs. Vye's 9th grade keyboarding class. Thank God for the - // IBM Selectric III, huh? - let x = (width / 2) - (text_width / 2); - let y = height - ((height / 5) - (text_height / 2)); - - let mut scratch = image::ImageBuffer::from_pixel(text_width, text_height, black_pixel); - drawing::draw_text_mut(&mut scratch, white_pixel, 0, 0, scale, &font, &text); - - // These thresholds are black magic to me. - for (idx, &pixel) in imageproc::edges::canny(&image::imageops::grayscale(&scratch), 255.0, 255.0).pixels().enumerate() { - if Luma([255u8]) == pixel { - let idx = idx as u32; - let x = idx % text_width + x; - let y = idx / text_width + y; - - // I bet this isn't cheap, but... meh. - let rect = Rect::square(x as i32, y as i32, (0.1 * scale_factor) as u32); - drawing::draw_filled_rect_mut(&mut pixels, rect, Rgba([0, 0, 0, 255])); + Err(e) => { + println!("{}", e); } } - - // Each call to `draw_text_mut` rebuilds the font, which I already built once to determine the - // size of the text field. This is a waste of time. I bet I can improve on draw_text_whatever - // such that it accepts a realized font rather than a bag of bits. - drawing::draw_text_mut(&mut pixels, white_pixel, x, y, scale, &font, &text); - - // Here I'm printing the scratch image used for edge detection. This is pretty much just as - // a smoke test; I'll get around to pulling it out of here eventually. - save("scratch.png", &DynamicImage::ImageRgba8(scratch)); - save("output.png", &pixels); -} - -/// Calculate the dimensions of the bounding box for a given string, font, and scale. -/// -/// This works by summing the "advance width" of each glyph in the text, entirely ignoring -/// kerning as each character is considered in isolation. Because this is used primarily to -/// center text in the image, it's close enough for government work. -fn text_size(s: &str, font: &[u8], scale: Scale) -> (u32, u32) { - use rusttype::{FontCollection, VMetrics}; - - // Font collections apparently consist of a collection of fonts. That is, more than one will - // be defined in any given bag of bytes. Life's imperfect. The common case, however, is that - // a given bag of bytes will contain a single font, in which case this will not explode. - let font = FontCollection::from_bytes(font).into_font().expect("Font collection contains multiple fonts."); - - let text_width = font.glyphs_for(s.chars()) - .map(|glyph| glyph.scaled(scale).h_metrics().advance_width) - .sum::(); - - // The "v-metrics" for any given letter in a font are the same for a given scale, so we don't - // need to check this for each glyph. - let text_height = { - let VMetrics { ascent, descent, ..} = font.v_metrics(scale); - (ascent - descent) as u32 - }; - - // I know I'm truncating the length and this is probably wrong, but it's not wrong by enough - // to be noticeable when you print it to an image. - (text_width as u32, text_height) -} - -fn save(path: &str, pixels: &DynamicImage) { - use std::fs::OpenOptions; - - let mut out = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(path).unwrap(); - - pixels.save(&mut out, ImageFormat::PNG).unwrap(); -} - -fn read_font(path: Option<&'static str>) -> Vec { - use std::fs::File; - use std::io::Read; - - let path = path.expect("Unsupported platform--please annoy the maintainer until this is fixed"); - let mut file = File::open(path).expect("Default font not found"); - let mut buf = Vec::new(); - - file.read_to_end(&mut buf).expect("Unable to read file"); - buf -} - -fn read_command() -> Option<(String, String)> { - let mut args = std::env::args().skip(1); - Some(( - opt!(args.next()), - opt!(args.next()), - )) } diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..5b2cf1c --- /dev/null +++ b/src/options.rs @@ -0,0 +1,224 @@ +use annotation::Annotation; +use error::Cause; +use std::borrow::Cow; +use std::error; +use std::fmt; +use std::path::{Path, PathBuf}; + +// How the hell do you make a path buffer from command line input if command line input is a +// string but a path buffer itself is technically not because it isn't validated UTF8? + +pub struct Options { + base_image: PathBuf, + annotation: Annotation, + output_path: PathBuf, + scale_mult: f32, + font_path: PathBuf, + debug: bool, +} + +impl Options { + pub fn from_args() -> Result { + read_command() + } + + pub fn base_image(&self) -> &Path { + &self.base_image + } + + pub fn font_path(&self) -> &Path { + &self.font_path + } + + pub fn scale_multiplier(&self) -> f32 { + self.scale_mult + } + + pub fn annotation(&self) -> &Annotation { + &self.annotation + } + + pub fn output_path(&self) -> &Path { + &self.output_path + } + + pub fn debug(&self) -> bool { + self.debug + } +} + +pub struct OptionsBuilder { + base_image: Option, + annotation: Option, + output_path: Option, + scale_mult: f32, + font_path: Cow<'static, str>, + debug: bool, +} + +impl OptionsBuilder { + fn new() -> OptionsBuilder { + OptionsBuilder { + base_image: None, + annotation: None, + output_path: None, + scale_mult: 1.0, + font_path: default_font(), + debug: false, + } + } +} + +impl OptionsBuilder { + fn set_base_image(&mut self, s: String) { + self.base_image = Some(s.into()); + } + + fn set_annotation(&mut self, annotation: Annotation) { + self.annotation = Some(annotation); + } + + fn set_output_path>(&mut self, s: T) { + self.output_path = Some(s.into()); + } + + fn set_scale_mult(&mut self, scale: f32) { + self.scale_mult = scale; + } + + fn set_font_path(&mut self, s: String) { + self.font_path = Cow::from(s); + } + + fn set_debug(&mut self, debug: bool) { + self.debug = debug; + } + + fn build(self) -> Result { + let input_path = self.base_image.unwrap(); + if input_path.file_name().is_none() { + return Err(BuildOptionsError { + kind: BuildOptionsErrorKind::ImagePath, + description: Cow::from("The provided image path does not appear to have a filename"), + cause: None, + }); + } + + let output_path = self.output_path.unwrap_or_else(|| create_output_file_path(&input_path)); + + Ok(Options { + base_image: input_path, + annotation: self.annotation.unwrap(), + output_path, + scale_mult: self.scale_mult, + font_path: self.font_path.to_string().into(), + debug: self.debug, + }) + } +} + +#[derive(Debug)] +pub struct BuildOptionsError { + kind: BuildOptionsErrorKind, + description: Cow<'static, str>, + cause: Cause, +} + +#[derive(Debug)] +enum BuildOptionsErrorKind { + ImagePath, + ScalingMultiplier, +} + +impl fmt::Display for BuildOptionsError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.description) + } +} + +impl error::Error for BuildOptionsError { + fn description(&self) -> &str { + &self.description + } + + fn cause(&self) -> Option<&error::Error> { + match self.cause { + Some(ref error) => Some(error.as_ref()), + None => None, + } + } +} + +fn read_command() -> Result { + // For right now, at least, I have decided to make different annotation styles as their own + // subcommands. This will probably mean a lot of repetition, but I guess I'm willing to pay + // that price at the moment--particularly as we only have one annotation type implemented. + let matches = clap_app!(annatar => + (version: "0.1.1") + (author: "J/A ") + (about: "Memecrafter") + (@subcommand caption => + (about: "Adds a caption to the bottom of the image") + (@arg IMAGE: +required "Sets the image to be annotated") + (@arg CAPTION: +required "Sets the caption to be added") + (@arg OUTPUT: -o --output +takes_value "Sets an output path for the new image (default: /.ann.png)") + (@arg SCALE: -s --scale +takes_value "Sets the scale multiplier for annotations") + (@arg FONT: -f --font +takes_value "Sets the path of the font to be used (default: Impact)") + (@arg DEBUG: -d --debug "Save edge detection ... thing to disk") + ) + ).get_matches(); + + let mut options = OptionsBuilder::new(); + + if let Some(matches) = matches.subcommand_matches("caption") { + options.set_base_image(matches.value_of("IMAGE").unwrap().to_string()); + options.set_annotation(Annotation::CaptionBottom(matches.value_of("CAPTION").unwrap().into())); + + if let Some(output_path) = matches.value_of("OUTPUT") { + options.set_output_path(output_path); + } + + if let Some(scale_multiplier) = matches.value_of("SCALE") { + let multiplier = scale_multiplier.parse::() + .map_err(|e| BuildOptionsError { + kind: BuildOptionsErrorKind::ScalingMultiplier, + description: Cow::from("Scaling multiplier must be a decimal value"), + cause: Some(Box::new(e)), + })?; + options.set_scale_mult(multiplier); + } + + if let Some(font_path) = matches.value_of("FONT") { + options.set_font_path(font_path.to_string()); + } + + options.set_debug(matches.is_present("DEBUG")); + } + + options.build() +} + +#[cfg(target_os = "windows")] +fn default_font() -> Cow<'static, str> { + Cow::from("C:/Windows/Fonts/Impact.ttf") +} + +#[cfg(target_os = "macos")] +fn default_font() -> Cow<'static, str> { + Cow::from("/Library/Fonts/Impact.ttf") +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn default_font() -> Cow<'static, str> { + panic!("Honestly, getting a font on Linux is going to be an adventure."); +} + +fn create_output_file_path(input_path: &Path) -> PathBuf { + // I unwrap this because clap already converted it to a string, implying it's valid utf-8. + let mut file_name = input_path.file_name().unwrap().to_str().unwrap().to_string(); + if let Some(last_segment_idx) = file_name.rfind('.') { + file_name.truncate(last_segment_idx); + } + file_name.push_str(".ann.png"); + file_name.into() +}