├── .gitignore ├── .travis.yml ├── Cargo.lock ├── Cargo.toml ├── README.md ├── cargo ├── ci └── travis │ └── before_install-osx.sh ├── data ├── fonts │ └── Impact.ttf └── templates │ ├── afraidtoask.jpg │ ├── aintnobodygottime.gif │ ├── anditsgone.jpg │ ├── asianfather.jpg │ ├── badger.gif │ ├── badluckbrian.jpg │ ├── billymadison.jpg │ ├── boromir.jpg │ ├── cat-wine.jpg │ ├── chemistrycat.jpg │ ├── darmok.jpg │ ├── drunkbaby.jpg │ ├── evilairquotes.gif │ ├── firstworldproblems.jpg │ ├── grumpycat.jpg │ ├── inception.png │ ├── insanitywolf.jpg │ ├── lazycollegesenior.jpg │ ├── megynkelly.jpg │ ├── mountainpug.jpg │ ├── office-space-smykowski.jpg │ ├── paranoidparrot.jpg │ ├── philosoraptor.jpg │ ├── shocked.jpg │ ├── shutupandtakemymoney.gif │ ├── slowclap.gif │ ├── slowpoke.jpg │ ├── smart.gif │ ├── suddenclarityclarence.jpg │ ├── tupperwarefail.gif │ └── zoidberg.jpg ├── src ├── cli │ ├── Cargo.toml │ ├── LICENSE │ ├── README.md │ ├── args │ │ ├── image_macro.rs │ │ ├── mod.rs │ │ ├── model.rs │ │ └── tests.rs │ ├── logging.rs │ └── main.rs ├── lib │ ├── Cargo.toml │ ├── LICENSE │ ├── README.md │ ├── caption │ │ ├── engine │ │ │ ├── builder.rs │ │ │ ├── config.rs │ │ │ └── mod.rs │ │ ├── error.rs │ │ ├── mod.rs │ │ ├── output.rs │ │ └── task.rs │ ├── ext │ │ ├── mod.rs │ │ └── rust.rs │ ├── lib.rs │ ├── model │ │ ├── constants.rs │ │ ├── de │ │ │ ├── caption.rs │ │ │ ├── color.rs │ │ │ ├── image_macro.rs │ │ │ ├── mod.rs │ │ │ ├── size.rs │ │ │ └── tests │ │ │ │ ├── json.rs │ │ │ │ ├── mod.rs │ │ │ │ └── qs.rs │ │ ├── mod.rs │ │ └── types │ │ │ ├── align.rs │ │ │ ├── caption.rs │ │ │ ├── color.rs │ │ │ ├── image_macro.rs │ │ │ ├── mod.rs │ │ │ └── size.rs │ ├── resources │ │ ├── filesystem.rs │ │ ├── fonts.rs │ │ ├── mod.rs │ │ └── templates.rs │ └── util │ │ ├── animated_gif.rs │ │ ├── cache.rs │ │ ├── mod.rs │ │ └── text.rs └── server │ ├── Cargo.toml │ ├── LICENSE │ ├── README.md │ ├── args.rs │ ├── build.rs │ ├── ext.rs │ ├── handlers │ ├── captioner.rs │ ├── list.rs │ ├── mod.rs │ └── util.rs │ ├── logging.rs │ ├── main.rs │ └── service.rs └── zoidberg.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | target 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Travis CI configuration file 3 | # 4 | 5 | language: rust 6 | 7 | os: 8 | - linux 9 | - osx 10 | rust: 11 | - stable 12 | - beta 13 | - nightly 14 | 15 | matrix: 16 | # Test on nightly Rust, but failures there won't break the build. 17 | allow_failures: 18 | - rust: nightly 19 | 20 | 21 | # 22 | # Dependencies 23 | # 24 | 25 | # Linux 26 | addons: 27 | apt: 28 | sources: 29 | - kalakris-cmake 30 | packages: 31 | - cmake 32 | 33 | # OSX 34 | before_install: | 35 | if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then 36 | . ./ci/travis/before_install-osx.sh 37 | fi 38 | 39 | 40 | # 41 | # Test script 42 | # 43 | 44 | script: 45 | - ./cargo lib test --no-fail-fast 46 | - ./cargo server test --no-fail-fast 47 | - ./cargo cli test --no-fail-fast 48 | 49 | 50 | # 51 | # Meta 52 | # 53 | 54 | branches: 55 | only: 56 | # Run CI on pushes and PRs to master 57 | - master 58 | # TODO: run also on tags when/if we have some deployment code 59 | # (This regex matches semantic versions like v1.2.3-rc4+2016.02.22) 60 | # - /^\d+\.\d+\.\d+.*$/ 61 | 62 | git: 63 | # Don't set this to 1 64 | # (see note at https://docs.travis-ci.com/user/customizing-the-build#Git-Clone-Depth) 65 | depth: 5 66 | 67 | cache: 68 | - cargo 69 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "src/cli", 4 | "src/lib", 5 | "src/server", 6 | ] 7 | 8 | [profile.release] 9 | lto = true 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rofl 2 | 3 | Lulz on demand 4 | 5 | [![Build Status](https://img.shields.io/travis/Xion/rofld.svg)](https://travis-ci.org/Xion/rofld) 6 | [![Crates.io](https://img.shields.io/crates/v/rofl.svg)](http://crates.io/crates/rofl) 7 | 8 | ## What? 9 | 10 | This is a joint repo for: 11 | 12 | * the _rofld_ meme server (`src/server`) 13 | * the [_rofl_ crate](https://crates.io/crates/rofl) which powers it (`src/lib`) 14 | * [WIP] the _roflsh_ CLI application (`src/cli`) 15 | 16 | ## Why? 17 | 18 | To make memes, not war. 19 | -------------------------------------------------------------------------------- /cargo: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Wrapper script to execute Cargo commands against particular crates 3 | 4 | BIN="$0" 5 | ARG="$1" ; shift 6 | 7 | 8 | # In case of empty or flag-only command line, pass through to Cargo directly 9 | if [ -z "$ARG" ] || case $ARG in -*) ;; *) false;; esac; then 10 | cargo "$ARG" "$@" 11 | exit $? 12 | fi 13 | 14 | # Recognize commands that vanilla Cargo can handle for workspaces 15 | # and pass them directly, too 16 | case "$ARG" in 17 | build|check|clean|test) 18 | cargo "$ARG" "$@" 19 | exit $? 20 | ;; 21 | esac 22 | 23 | 24 | # Otherwise treat the first argument as crate moniker 25 | CRATE="$ARG" 26 | if [ ! -f "./src/$CRATE/Cargo.toml" ]; then 27 | echo >&2 "Usage: $BIN CRATE [CARGO_ARGS]" 28 | exit 2 29 | fi 30 | 31 | # Undertake default actions for some crates if no command is given 32 | CMD="$1" ; shift 33 | if [ -z "$CMD" ] || case $CMD in -*) ;; *) false;; esac; then 34 | # If what followed the crate name was a flag, put it back first. 35 | if [ -n "$CMD" ]; then 36 | set -- "$CMD" "$@" 37 | fi 38 | case "$CRATE" in 39 | cli) CMD='run' ;; 40 | server) CMD='run' ;; 41 | esac 42 | fi 43 | 44 | # For running binaries, use --manifest-path because we want to be in the root 45 | # directory for the correct $CWD. 46 | if [ "$CMD" = 'run' ]; then 47 | MANIFEST="./src/$CRATE/Cargo.toml" 48 | set -x # echo on 49 | cargo "$CMD" --manifest-path="$MANIFEST" "$@" 50 | else 51 | ( 52 | set -x # echo on 53 | cd "./src/$CRATE" && cargo "$CMD" "$@" 54 | ) 55 | fi 56 | -------------------------------------------------------------------------------- /ci/travis/before_install-osx.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # before_install: script for Travis on OSX 4 | 5 | 6 | brew update >/dev/null 7 | 8 | # Install OpenSSL. 9 | # (incantations taken from https://github.com/sfackler/rust-openssl/issues/255) 10 | brew install openssl 11 | export OPENSSL_INCLUDE_DIR=`brew --prefix openssl`/include 12 | export OPENSSL_LIB_DIR=`brew --prefix openssl`/lib 13 | export DEP_OPENSSL_INCLUDE=`brew --prefix openssl`/include 14 | -------------------------------------------------------------------------------- /data/fonts/Impact.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/fonts/Impact.ttf -------------------------------------------------------------------------------- /data/templates/afraidtoask.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/afraidtoask.jpg -------------------------------------------------------------------------------- /data/templates/aintnobodygottime.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/aintnobodygottime.gif -------------------------------------------------------------------------------- /data/templates/anditsgone.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/anditsgone.jpg -------------------------------------------------------------------------------- /data/templates/asianfather.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/asianfather.jpg -------------------------------------------------------------------------------- /data/templates/badger.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/badger.gif -------------------------------------------------------------------------------- /data/templates/badluckbrian.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/badluckbrian.jpg -------------------------------------------------------------------------------- /data/templates/billymadison.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/billymadison.jpg -------------------------------------------------------------------------------- /data/templates/boromir.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/boromir.jpg -------------------------------------------------------------------------------- /data/templates/cat-wine.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/cat-wine.jpg -------------------------------------------------------------------------------- /data/templates/chemistrycat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/chemistrycat.jpg -------------------------------------------------------------------------------- /data/templates/darmok.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/darmok.jpg -------------------------------------------------------------------------------- /data/templates/drunkbaby.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/drunkbaby.jpg -------------------------------------------------------------------------------- /data/templates/evilairquotes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/evilairquotes.gif -------------------------------------------------------------------------------- /data/templates/firstworldproblems.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/firstworldproblems.jpg -------------------------------------------------------------------------------- /data/templates/grumpycat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/grumpycat.jpg -------------------------------------------------------------------------------- /data/templates/inception.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/inception.png -------------------------------------------------------------------------------- /data/templates/insanitywolf.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/insanitywolf.jpg -------------------------------------------------------------------------------- /data/templates/lazycollegesenior.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/lazycollegesenior.jpg -------------------------------------------------------------------------------- /data/templates/megynkelly.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/megynkelly.jpg -------------------------------------------------------------------------------- /data/templates/mountainpug.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/mountainpug.jpg -------------------------------------------------------------------------------- /data/templates/office-space-smykowski.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/office-space-smykowski.jpg -------------------------------------------------------------------------------- /data/templates/paranoidparrot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/paranoidparrot.jpg -------------------------------------------------------------------------------- /data/templates/philosoraptor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/philosoraptor.jpg -------------------------------------------------------------------------------- /data/templates/shocked.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/shocked.jpg -------------------------------------------------------------------------------- /data/templates/shutupandtakemymoney.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/shutupandtakemymoney.gif -------------------------------------------------------------------------------- /data/templates/slowclap.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/slowclap.gif -------------------------------------------------------------------------------- /data/templates/slowpoke.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/slowpoke.jpg -------------------------------------------------------------------------------- /data/templates/smart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/smart.gif -------------------------------------------------------------------------------- /data/templates/suddenclarityclarence.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/suddenclarityclarence.jpg -------------------------------------------------------------------------------- /data/templates/tupperwarefail.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/tupperwarefail.gif -------------------------------------------------------------------------------- /data/templates/zoidberg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/data/templates/zoidberg.jpg -------------------------------------------------------------------------------- /src/cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "roflsh" 3 | version = "0.0.1" 4 | authors = ["Karol Kuczmarski "] 5 | description = "Lulz in the shell" 6 | documentation = "https://github.com/Xion/rofld" 7 | homepage = "https://github.com/Xion/rofld" 8 | repository = "https://github.com/Xion/rofld" 9 | readme = "README.md" 10 | license = "GPLv2" 11 | 12 | [badges] 13 | travis-ci = { repository = "Xion/rofld" } 14 | 15 | [[bin]] 16 | name = "roflsh" 17 | path = "main.rs" 18 | 19 | [dependencies] 20 | ansi_term = "0.9" 21 | clap = { version = "2.19", features = ["suggestions"] } 22 | conv = "0.3" 23 | enum_derive = "0.1" 24 | exitcode = "1.0" 25 | isatty = "0.1.1" 26 | lazy_static = "*" 27 | log = "*" 28 | macro-attr = "0.2" 29 | maplit = "0.1" 30 | nom = "3.1" 31 | rofl = { path = "../lib" } 32 | serde_json = "1.0" 33 | slog = "1.5.2" 34 | slog-envlogger = "0.5" 35 | slog-stdlog = "1.1" 36 | slog-stream = "1.2" 37 | time = "0.1" 38 | 39 | [dev-dependencies] 40 | spectral = "0.6.0" 41 | -------------------------------------------------------------------------------- /src/cli/README.md: -------------------------------------------------------------------------------- 1 | # roflsh 2 | 3 | Lulz in the shell 4 | 5 | [![Build Status](https://img.shields.io/travis/Xion/rofld.svg)](https://travis-ci.org/Xion/rofld) 6 | 7 | TBD 8 | -------------------------------------------------------------------------------- /src/cli/args/image_macro.rs: -------------------------------------------------------------------------------- 1 | //! Module handling the command line argument 2 | //! that specifies the image macro to render. 3 | 4 | use std::error; 5 | use std::fmt; 6 | 7 | use nom::{alphanumeric, IResult, Needed}; 8 | 9 | use rofl::{Caption, CaptionBuilder, HAlign, ImageMacro, ImageMacroBuilder, VAlign}; 10 | 11 | 12 | /// Parse a MACRO command line argument into an `ImageMacro`. 13 | pub fn parse(s: &str) -> Result { 14 | match root(s) { 15 | IResult::Done(remaining, im) => { 16 | if remaining.is_empty() { 17 | Ok(im) 18 | } else { 19 | Err(Error::Excess(remaining.len())) 20 | } 21 | }, 22 | IResult::Incomplete(needed) => { 23 | let expected = match needed { 24 | Needed::Unknown => None, 25 | Needed::Size(n) => Some(n), 26 | }; 27 | Err(Error::Incomplete(expected)) 28 | } 29 | IResult::Error(_) => Err(Error::Parse), 30 | } 31 | } 32 | 33 | 34 | /// Error that can occur when parsing image macro specification. 35 | #[derive(Clone, Debug, Eq, PartialEq)] 36 | pub enum Error { 37 | /// Error during parsing. 38 | // TODO: better error here 39 | Parse, 40 | /// Error for when we expected this many bytes of more input than we've got. 41 | Incomplete(Option), 42 | /// Error for when there is still some input left after parsing has finished. 43 | Excess(usize), 44 | } 45 | 46 | impl error::Error for Error { 47 | fn description(&self) -> &str { "invalid image macro definition" } 48 | fn cause(&self) -> Option<&error::Error> { None } 49 | } 50 | 51 | impl fmt::Display for Error { 52 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 53 | match *self { 54 | Error::Parse => write!(fmt, "parse error"), 55 | Error::Incomplete(needed) => 56 | write!(fmt, "incomplete input ({} needed)", match needed { 57 | Some(n) => format!("{} byte(s)", n), 58 | None => format!("undetermined number of bytes"), 59 | }), 60 | Error::Excess(n) => write!(fmt, "excess {} byte(s) found in input", n), 61 | } 62 | } 63 | } 64 | 65 | 66 | // Syntax definition 67 | 68 | /// Root of the parser hierarchy. 69 | /// Parses the entire `ImageMacro`. 70 | named!(root(&str) -> ImageMacro, do_parse!( 71 | opt!(tag_s!("\\")) >> 72 | template: alphanumeric >> 73 | captions: many0!(caption) >> 74 | ({ 75 | let mut builder = ImageMacroBuilder::new() 76 | .template(template); 77 | for cap in captions { 78 | builder = builder.caption(cap); 79 | } 80 | builder.build().unwrap() // TODO: error handling 81 | }) 82 | )); 83 | 84 | /// Parse a single `Caption`. 85 | named!(caption(&str) -> Caption, do_parse!( 86 | tag_s!("{") >> 87 | align: align >> 88 | text: take_until_s!("}") >> // TODO: escaping of } so it can be included in text 89 | tag_s!("}") >> 90 | ({ 91 | let (valign, halign) = align; 92 | let mut builder = CaptionBuilder::new() 93 | .valign(valign.unwrap_or(VAlign::Bottom)); 94 | // TODO: determine valign (if not given) based on number of captions 95 | if let Some(halign) = halign { 96 | builder = builder.halign(halign); 97 | } 98 | if !text.is_empty() { 99 | builder = builder.text(text.to_owned()); 100 | } 101 | builder.build().unwrap() 102 | }) 103 | )); 104 | 105 | /// Parse the alignment symbol(s). 106 | named!(align(&str) -> (Option, Option), map!(opt!(alt_complete!( 107 | pair!(valign, halign) => { |(v, h)| (Some(v), Some(h)) } | 108 | pair!(halign, valign) => { |(h, v)| (Some(v), Some(h)) } | 109 | valign => { |v| (Some(v), None) } | 110 | halign => { |h| (None, Some(h)) } 111 | )), |a| a.unwrap_or((None, None)))); 112 | 113 | /// Parse the vertical alignment character marker. 114 | named!(valign(&str) -> VAlign, alt!( 115 | tag_s!("^") => { |_| VAlign::Top } | 116 | tag_s!("-") => { |_| VAlign::Middle } | 117 | tag_s!("_") => { |_| VAlign::Bottom } 118 | )); 119 | 120 | /// Parse the horizontal alignment character marker. 121 | named!(halign(&str) -> HAlign, alt!( 122 | tag_s!("<") => { |_| HAlign::Left } | 123 | tag_s!("|") => { |_| HAlign::Center } | 124 | tag_s!(">") => { |_| HAlign::Right } 125 | )); 126 | -------------------------------------------------------------------------------- /src/cli/args/mod.rs: -------------------------------------------------------------------------------- 1 | //! Module for handling command line arguments. 2 | 3 | mod image_macro; 4 | mod model; 5 | 6 | #[cfg(test)] 7 | mod tests; 8 | 9 | 10 | use std::env; 11 | use std::ffi::OsString; 12 | use std::io; 13 | use std::path::PathBuf; 14 | 15 | use conv::TryFrom; 16 | use clap::{self, AppSettings, Arg, ArgGroup, ArgMatches}; 17 | use serde_json; 18 | 19 | use super::{NAME, VERSION}; 20 | pub use self::model::{ArgsError, Options}; 21 | use self::image_macro::parse as parse_image_macro; 22 | 23 | 24 | /// Parse command line arguments and return `Options` object. 25 | #[inline] 26 | pub fn parse() -> Result { 27 | parse_from_argv(env::args_os()) 28 | } 29 | 30 | /// Parse application options from given array of arguments 31 | /// (*all* arguments, including binary name). 32 | #[inline] 33 | pub fn parse_from_argv(argv: I) -> Result 34 | where I: IntoIterator, T: Clone + Into 35 | { 36 | let parser = create_parser(); 37 | let matches = try!(parser.get_matches_from_safe(argv)); 38 | Options::try_from(matches) 39 | } 40 | 41 | 42 | impl<'a> TryFrom> for Options { 43 | type Err = ArgsError; 44 | 45 | fn try_from(matches: ArgMatches<'a>) -> Result { 46 | let verbose_count = matches.occurrences_of(OPT_VERBOSE) as isize; 47 | let quiet_count = matches.occurrences_of(OPT_QUIET) as isize; 48 | let verbosity = verbose_count - quiet_count; 49 | 50 | let image_macro = match matches.value_of(ARG_MACRO) { 51 | Some(im) => parse_image_macro(im.trim())?, 52 | None => { 53 | assert!(matches.is_present(OPT_JSON), 54 | "Command line incorrectly parsed without either `{}` argument or --{} flag", 55 | ARG_MACRO, OPT_JSON); 56 | serde_json::from_reader(&mut io::stdin())? 57 | } 58 | }; 59 | 60 | // Output path can be set explicitly to stdout via `-`. 61 | let output_path = matches.value_of(OPT_OUTPUT) 62 | .map(|p| p.trim()) 63 | .and_then(|p| if p == "-" { None } else { Some(p) }) 64 | .map(|p| PathBuf::from(p)); 65 | 66 | Ok(Options{verbosity, image_macro, output_path}) 67 | } 68 | } 69 | 70 | 71 | // Parser definition 72 | 73 | /// Type of the argument parser object 74 | /// (which is called an "App" in clap's silly nomenclature). 75 | pub type Parser<'p> = clap::App<'p, 'p>; 76 | 77 | 78 | lazy_static! { 79 | static ref ABOUT: &'static str = option_env!("CARGO_PKG_DESCRIPTION").unwrap_or(""); 80 | } 81 | 82 | const ARGGRP_MACRO: &'static str = "image_macro"; 83 | const ARG_MACRO: &'static str = "macro"; 84 | const OPT_JSON: &'static str = "json"; 85 | const OPT_OUTPUT: &'static str = "output"; 86 | const OPT_VERBOSE: &'static str = "verbose"; 87 | const OPT_QUIET: &'static str = "quiet"; 88 | 89 | 90 | /// Create the parser for application's command line. 91 | pub fn create_parser<'p>() -> Parser<'p> { 92 | let mut parser = Parser::new(*NAME); 93 | if let Some(version) = *VERSION { 94 | parser = parser.version(version); 95 | } 96 | parser 97 | .about(*ABOUT) 98 | .author(crate_authors!(", ")) 99 | 100 | .setting(AppSettings::StrictUtf8) 101 | 102 | .setting(AppSettings::UnifiedHelpMessage) 103 | .setting(AppSettings::DontCollapseArgsInUsage) 104 | .setting(AppSettings::DeriveDisplayOrder) 105 | 106 | // Image macro specification. 107 | .group(ArgGroup::with_name(ARGGRP_MACRO) 108 | .args(&[ARG_MACRO, OPT_JSON]) 109 | .required(true)) // TODO: make it optional and add interactive option 110 | .arg(Arg::with_name(ARG_MACRO) 111 | .value_name("MACRO") 112 | .help("Image macro to render") 113 | .long_help(concat!( 114 | "Specification of the image macro to render.\n\n", 115 | "The syntax is: TEMPLATE{CAPTION}{CAPTION}..., where CAPTION is just text ", 116 | "or text preceded by alignment symbols: ^, - (middle), _ (bottom), ", 117 | "<, | (center), >."))) 118 | .arg(Arg::with_name(OPT_JSON) 119 | .conflicts_with(ARG_MACRO) 120 | .long("json").short("j") 121 | .help("Whether to expect image macro as JSON on standard input") 122 | .long_help(concat!( 123 | "If present, the image macro specification will be read as JSON ", 124 | "from the program's standard input."))) 125 | // TODO: some documentation of the JSON format 126 | 127 | // Output flags. 128 | .arg(Arg::with_name(OPT_OUTPUT) 129 | .long("output").short("o") 130 | .required(false) 131 | .help("File to write the rendered image to") 132 | .long_help(concat!( 133 | "What file should the final image be written to.\n\n", 134 | "By default, or when this flag is set to `-` (single dash), the image is written ", 135 | "to standard output so it can be e.g. piped to the ImageMagick `display` program."))) 136 | 137 | // Verbosity flags. 138 | .arg(Arg::with_name(OPT_VERBOSE) 139 | .long("verbose").short("v") 140 | .multiple(true) 141 | .conflicts_with(OPT_QUIET) 142 | .help("Increase logging verbosity")) 143 | .arg(Arg::with_name(OPT_QUIET) 144 | .long("quiet").short("q") 145 | .multiple(true) 146 | .conflicts_with(OPT_VERBOSE) 147 | .help("Decrease logging verbosity")) 148 | 149 | .help_short("H") 150 | .version_short("V") 151 | } 152 | -------------------------------------------------------------------------------- /src/cli/args/model.rs: -------------------------------------------------------------------------------- 1 | //! Data structures for command-line arguments. 2 | 3 | use std::error::Error; 4 | use std::fmt; 5 | use std::path::PathBuf; 6 | 7 | use clap; 8 | use rofl::ImageMacro; 9 | use serde_json; 10 | 11 | use super::image_macro::Error as ImageMacroError; 12 | 13 | 14 | /// Structure to hold options received from the command line. 15 | #[derive(Clone, Debug, PartialEq, Eq)] 16 | pub struct Options { 17 | /// Verbosity of the logging output. 18 | /// 19 | /// Corresponds to the number of times the -v flag has been passed. 20 | /// If -q has been used instead, this will be negative. 21 | pub verbosity: isize, 22 | 23 | /// The image macro to create. 24 | pub image_macro: ImageMacro, 25 | /// Path to write the finished image macro to. 26 | /// 27 | /// If absent, it shall be written to standard output. 28 | pub output_path: Option, 29 | } 30 | 31 | #[allow(dead_code)] 32 | impl Options { 33 | #[inline] 34 | pub fn verbose(&self) -> bool { self.verbosity > 0 } 35 | #[inline] 36 | pub fn quiet(&self) -> bool { self.verbosity < 0 } 37 | } 38 | 39 | 40 | macro_attr! { 41 | /// Error that can occur while parsing of command line arguments. 42 | #[derive(Debug, EnumFromInner!)] 43 | pub enum ArgsError { 44 | /// General when parsing the arguments. 45 | Parse(clap::Error), 46 | /// Image macro argument syntax error. 47 | ImageMacroArg(ImageMacroError), 48 | /// Image macro --json parsing error. 49 | ImageMacroJson(serde_json::Error), 50 | } 51 | } 52 | 53 | impl Error for ArgsError { 54 | fn description(&self) -> &str { "command line argument error" } 55 | fn cause(&self) -> Option<&Error> { 56 | match *self { 57 | ArgsError::Parse(ref e) => Some(e), 58 | ArgsError::ImageMacroArg(ref e) => Some(e), 59 | ArgsError::ImageMacroJson(ref e) => Some(e), 60 | } 61 | } 62 | } 63 | 64 | impl fmt::Display for ArgsError { 65 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 66 | match *self { 67 | ArgsError::Parse(ref e) => write!(fmt, "invalid arguments: {}", e), 68 | ArgsError::ImageMacroArg(ref e) => { 69 | write!(fmt, "image macro argument syntax error: {}", e) 70 | } 71 | ArgsError::ImageMacroJson(ref e) => { 72 | write!(fmt, "image macro JSON error: {}", e) 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/cli/args/tests.rs: -------------------------------------------------------------------------------- 1 | //! Tests for command line argument handling. 2 | 3 | use rofl::{HAlign, VAlign}; 4 | use spectral::prelude::*; 5 | 6 | use super::parse_from_argv; 7 | use ::NAME; 8 | 9 | 10 | #[test] 11 | fn no_args() { 12 | assert_that!(parse_from_argv(Vec::<&str>::new())).is_err(); 13 | assert_that!(parse_from_argv(vec![*NAME])).is_err(); 14 | } 15 | 16 | #[test] 17 | fn macro_just_template() { 18 | let opts = parse_from_argv(vec![*NAME, "zoidberg{Test}"]).unwrap(); 19 | assert_eq!("zoidberg", opts.image_macro.template); 20 | assert_eq!("Test", opts.image_macro.captions[0].text); 21 | } 22 | 23 | #[test] 24 | fn macro_one_text() { 25 | let opts = parse_from_argv(vec![*NAME, "zoidberg{Test}"]).unwrap(); 26 | assert_eq!("zoidberg", opts.image_macro.template); 27 | let caption = &opts.image_macro.captions[0]; 28 | assert_eq!("Test", caption.text); 29 | assert_eq!(VAlign::Bottom, caption.valign); 30 | assert_eq!(HAlign::Center, caption.halign); 31 | } 32 | 33 | #[test] 34 | fn macro_two_texts() { 35 | let opts = parse_from_argv(vec![*NAME, "zoidberg{Test1}{Test2}"]).unwrap(); 36 | assert_eq!("zoidberg", opts.image_macro.template); 37 | 38 | let caption1 = &opts.image_macro.captions[0]; 39 | let caption2 = &opts.image_macro.captions[1]; 40 | 41 | assert_eq!("Test1", caption1.text); 42 | assert_eq!("Test2", caption2.text); 43 | // TODO: the valign should be Top+Bottom here but this intelligent valign 44 | // picking is NYI 45 | assert_eq!(HAlign::Center, caption1.halign); 46 | assert_eq!(HAlign::Center, caption2.halign); 47 | } 48 | 49 | #[test] 50 | fn macro_text_just_valign() { 51 | let opts = parse_from_argv(vec![*NAME, "zoidberg{^Test}"]).unwrap(); 52 | assert_eq!("zoidberg", opts.image_macro.template); 53 | let caption = &opts.image_macro.captions[0]; 54 | assert_eq!("Test", caption.text); 55 | assert_eq!(VAlign::Top, caption.valign); 56 | } 57 | 58 | #[test] 59 | fn macro_text_just_halign() { 60 | let opts = parse_from_argv(vec![*NAME, "zoidberg{>Test}"]).unwrap(); 61 | assert_eq!("zoidberg", opts.image_macro.template); 62 | let caption = &opts.image_macro.captions[0]; 63 | assert_eq!("Test", caption.text); 64 | assert_eq!(HAlign::Right, caption.halign); 65 | } 66 | 67 | #[test] 68 | fn macro_text_valign_and_halign() { 69 | let opts = parse_from_argv(vec![*NAME, "zoidberg{-Why not Zoidberg?}"]).unwrap(); 91 | assert_eq!("zoidberg", opts.image_macro.template); 92 | 93 | let caption1 = &opts.image_macro.captions[0]; 94 | assert_eq!("Need a test?", caption1.text); 95 | assert_eq!(HAlign::Left, caption1.halign); 96 | assert_eq!(VAlign::Top, caption1.valign); 97 | 98 | let caption2 = &opts.image_macro.captions[1]; 99 | assert_eq!("Why not Zoidberg?", caption2.text); 100 | assert_eq!(VAlign::Bottom, caption2.valign); 101 | assert_eq!(HAlign::Right, caption2.halign); 102 | } 103 | 104 | #[test] 105 | fn macro_error_no_template() { 106 | assert_that!(parse_from_argv(vec![*NAME, "{}"])).is_err(); 107 | assert_that!(parse_from_argv(vec![*NAME, "{Test}"])).is_err(); 108 | } 109 | 110 | #[test] 111 | fn macro_error_unclosed_brace() { 112 | assert_that!(parse_from_argv(vec![*NAME, "zoidberg{Test"])).is_err(); 113 | } 114 | 115 | #[test] 116 | fn macro_error_nested_braces() { 117 | assert_that!(parse_from_argv(vec![*NAME, "zoidberg{Test{More tests}}"])) 118 | .is_err(); 119 | } 120 | 121 | #[test] 122 | fn macro_error_closing_brace_first() { 123 | assert_that!(parse_from_argv(vec![*NAME, "zoidberg}"])).is_err(); 124 | } 125 | 126 | // TODO: test the --json flag (which is actually difficult because it requires mocking 127 | // or DI'ing or otherwise seeding the stdin with JSON); 128 | // alternatively, we need an intermediate structure between clap::ArgMatches 129 | // and Options that contains the matches + the content of stdin 130 | -------------------------------------------------------------------------------- /src/cli/logging.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing logging for the application. 2 | //! 3 | //! This includes setting up log filtering given a verbosity value, 4 | //! as well as defining how the logs are being formatted to stderr. 5 | 6 | use std::borrow::Cow; 7 | use std::collections::HashMap; 8 | use std::env; 9 | use std::io; 10 | 11 | use ansi_term::{Colour, Style}; 12 | use isatty; 13 | use log::SetLoggerError; 14 | use slog::{self, DrainExt, FilterLevel, Level}; 15 | use slog_envlogger::LogBuilder; 16 | use slog_stdlog; 17 | use slog_stream; 18 | use time; 19 | 20 | 21 | // Default logging level defined using the two enums used by slog. 22 | // Both values must correspond to the same level. (This is checked by a test). 23 | const DEFAULT_LEVEL: Level = Level::Info; 24 | const DEFAULT_FILTER_LEVEL: FilterLevel = FilterLevel::Info; 25 | 26 | // Arrays of log levels, indexed by verbosity. 27 | const POSITIVE_VERBOSITY_LEVELS: &'static [FilterLevel] = &[ 28 | DEFAULT_FILTER_LEVEL, 29 | FilterLevel::Debug, 30 | FilterLevel::Trace, 31 | ]; 32 | const NEGATIVE_VERBOSITY_LEVELS: &'static [FilterLevel] = &[ 33 | DEFAULT_FILTER_LEVEL, 34 | FilterLevel::Warning, 35 | FilterLevel::Error, 36 | FilterLevel::Critical, 37 | FilterLevel::Off, 38 | ]; 39 | 40 | 41 | /// Initialize logging with given verbosity. 42 | /// The verbosity value has the same meaning as in args::Options::verbosity. 43 | pub fn init(verbosity: isize) -> Result<(), SetLoggerError> { 44 | let istty = cfg!(unix) && isatty::stderr_isatty(); 45 | let stderr = slog_stream::stream(io::stderr(), LogFormat{tty: istty}); 46 | 47 | // Determine the log filtering level based on verbosity. 48 | // If the argument is excessive, log that but clamp to the highest/lowest log level. 49 | let mut verbosity = verbosity; 50 | let mut excessive = false; 51 | let level = if verbosity >= 0 { 52 | if verbosity >= POSITIVE_VERBOSITY_LEVELS.len() as isize { 53 | excessive = true; 54 | verbosity = POSITIVE_VERBOSITY_LEVELS.len() as isize - 1; 55 | } 56 | POSITIVE_VERBOSITY_LEVELS[verbosity as usize] 57 | } else { 58 | verbosity = -verbosity; 59 | if verbosity >= NEGATIVE_VERBOSITY_LEVELS.len() as isize { 60 | excessive = true; 61 | verbosity = NEGATIVE_VERBOSITY_LEVELS.len() as isize - 1; 62 | } 63 | NEGATIVE_VERBOSITY_LEVELS[verbosity as usize] 64 | }; 65 | 66 | // Include universal logger options, like the level. 67 | let mut builder = LogBuilder::new(stderr); 68 | builder = builder.filter(None, level); 69 | 70 | // Make some of the libraries less chatty 71 | // by raising the minimum logging level for them 72 | // (e.g. Info means that Debug and Trace level logs are filtered). 73 | builder = builder 74 | .filter(Some("hyper"), FilterLevel::Info) 75 | .filter(Some("tokio"), FilterLevel::Info); 76 | 77 | // Include any additional config from environmental variables. 78 | // This will override the options above if necessary, 79 | // so e.g. it is still possible to get full debug output from hyper/tokio. 80 | if let Ok(ref conf) = env::var("RUST_LOG") { 81 | builder = builder.parse(conf); 82 | } 83 | 84 | // Initialize the logger, possibly logging the excessive verbosity option. 85 | let env_logger_drain = builder.build(); 86 | let logger = slog::Logger::root(env_logger_drain.fuse(), o!()); 87 | try!(slog_stdlog::set_logger(logger)); 88 | if excessive { 89 | warn!("-v/-q flag passed too many times, logging level {:?} assumed", level); 90 | } 91 | Ok(()) 92 | } 93 | 94 | 95 | // Log formatting 96 | 97 | /// Token type that's only uses to tell slog-stream how to format our log entries. 98 | struct LogFormat { 99 | pub tty: bool, 100 | } 101 | 102 | impl slog_stream::Format for LogFormat { 103 | /// Format a single log Record and write it to given output. 104 | fn format(&self, output: &mut io::Write, 105 | record: &slog::Record, 106 | _logger_kvp: &slog::OwnedKeyValueList) -> io::Result<()> { 107 | // Format the higher level (more fine-grained) messages with greater detail, 108 | // as they are only visible when user explicitly enables verbose logging. 109 | let msg = if record.level() > DEFAULT_LEVEL { 110 | let logtime = format_log_time(); 111 | let level: String = { 112 | let first_char = record.level().as_str().chars().next().unwrap(); 113 | first_char.to_uppercase().collect() 114 | }; 115 | let module = { 116 | let module = record.module(); 117 | match module.find("::") { 118 | Some(idx) => Cow::Borrowed(&module[idx + 2..]), 119 | None => "main".into(), 120 | } 121 | }; 122 | // Dim the prefix (everything that's not a message) if we're outputting to a TTY. 123 | let prefix_style = if self.tty { *TTY_FINE_PREFIX_STYLE } else { Style::default() }; 124 | let prefix = format!("{}{} {}#{}]", level, logtime, module, record.line()); 125 | format!("{} {}\n", prefix_style.paint(prefix), record.msg()) 126 | } else { 127 | // Colorize the level label if we're outputting to a TTY. 128 | let level: Cow = if self.tty { 129 | let style = TTY_LEVEL_STYLES.get(&record.level().as_usize()) 130 | .cloned() 131 | .unwrap_or_else(Style::default); 132 | format!("{}", style.paint(record.level().as_str())).into() 133 | } else { 134 | record.level().as_str().into() 135 | }; 136 | format!("{}: {}\n", level, record.msg()) 137 | }; 138 | 139 | try!(output.write_all(msg.as_bytes())); 140 | Ok(()) 141 | } 142 | } 143 | 144 | /// Format the timestamp part of a detailed log entry. 145 | fn format_log_time() -> String { 146 | let utc_now = time::now().to_utc(); 147 | let mut logtime = format!("{}", utc_now.rfc3339()); // E.g.: 2012-02-22T14:53:18Z 148 | 149 | // Insert millisecond count before the Z. 150 | let millis = utc_now.tm_nsec / NANOS_IN_MILLISEC; 151 | logtime.pop(); 152 | format!("{}.{:04}Z", logtime, millis) 153 | } 154 | 155 | const NANOS_IN_MILLISEC: i32 = 1000000; 156 | 157 | lazy_static! { 158 | /// Map of log levels to their ANSI terminal styles. 159 | // (Level doesn't implement Hash so it has to be usize). 160 | static ref TTY_LEVEL_STYLES: HashMap = hashmap!{ 161 | Level::Info.as_usize() => Colour::Green.normal(), 162 | Level::Warning.as_usize() => Colour::Yellow.normal(), 163 | Level::Error.as_usize() => Colour::Red.normal(), 164 | Level::Critical.as_usize() => Colour::Purple.normal(), 165 | }; 166 | 167 | /// ANSI terminal style for the prefix (timestamp etc.) of a fine log message. 168 | static ref TTY_FINE_PREFIX_STYLE: Style = Style::new().dimmed(); 169 | } 170 | 171 | 172 | #[cfg(test)] 173 | mod tests { 174 | use slog::FilterLevel; 175 | use super::{DEFAULT_LEVEL, DEFAULT_FILTER_LEVEL, 176 | NEGATIVE_VERBOSITY_LEVELS, POSITIVE_VERBOSITY_LEVELS}; 177 | 178 | /// Check that default logging level is defined consistently. 179 | #[test] 180 | fn default_level() { 181 | let level = DEFAULT_LEVEL.as_usize(); 182 | let filter_level = DEFAULT_FILTER_LEVEL.as_usize(); 183 | assert_eq!(level, filter_level, 184 | "Default logging level is defined inconsistently: Level::{:?} vs. FilterLevel::{:?}", 185 | DEFAULT_LEVEL, DEFAULT_FILTER_LEVEL); 186 | } 187 | 188 | #[test] 189 | fn verbosity_levels() { 190 | assert_eq!(NEGATIVE_VERBOSITY_LEVELS[0], POSITIVE_VERBOSITY_LEVELS[0]); 191 | assert!(NEGATIVE_VERBOSITY_LEVELS.contains(&FilterLevel::Off), 192 | "Verbosity levels don't allow to turn logging off completely"); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/cli/main.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! roflsh -- Lulz in the shell 3 | //! 4 | 5 | extern crate ansi_term; 6 | #[macro_use] extern crate clap; 7 | extern crate conv; 8 | #[macro_use] extern crate enum_derive; 9 | extern crate exitcode; 10 | extern crate isatty; 11 | #[macro_use] extern crate lazy_static; 12 | #[macro_use] extern crate macro_attr; 13 | #[macro_use] extern crate maplit; 14 | #[macro_use] extern crate nom; 15 | extern crate rofl; 16 | extern crate serde_json; 17 | #[macro_use] extern crate slog; 18 | extern crate slog_envlogger; 19 | extern crate slog_stdlog; 20 | extern crate slog_stream; 21 | extern crate time; 22 | 23 | // `log` must be at the end of these declarations because we want to simultaneously: 24 | // * use the standard `log` macros (which would be shadowed by `slog` or even `nom`) 25 | // * be able to initialize the slog logger using slog macros like o!() 26 | #[macro_use] extern crate log; 27 | 28 | 29 | #[cfg(test)] #[macro_use] extern crate spectral; 30 | 31 | 32 | mod args; 33 | mod logging; 34 | 35 | 36 | use std::env; 37 | use std::io::{self, Write}; 38 | use std::fs; 39 | use std::process::exit; 40 | 41 | use ansi_term::Colour; 42 | use exitcode::ExitCode; 43 | 44 | use args::{ArgsError, Options}; 45 | 46 | 47 | lazy_static! { 48 | /// Application / package name, as filled out by Cargo. 49 | static ref NAME: &'static str = option_env!("CARGO_PKG_NAME").unwrap_or("roflsh"); 50 | 51 | /// Application version, as filled out by Cargo. 52 | static ref VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); 53 | } 54 | 55 | 56 | fn main() { 57 | let opts = args::parse().unwrap_or_else(|e| { 58 | print_args_error(e).unwrap(); 59 | exit(exitcode::USAGE); 60 | }); 61 | 62 | logging::init(opts.verbosity).unwrap(); 63 | if cfg!(debug_assertions) { 64 | warn!("Debug mode! The program will likely be much slower."); 65 | } 66 | for (i, arg) in env::args().enumerate() { 67 | debug!("argv[{}] = {:?}", i, arg); 68 | } 69 | trace!("Options parsed from argv:\n{:#?}", opts); 70 | 71 | let exit_code = run(opts); 72 | exit(exit_code) 73 | } 74 | 75 | /// Print an error that may occur while parsing arguments. 76 | fn print_args_error(e: ArgsError) -> io::Result<()> { 77 | match e { 78 | ArgsError::Parse(ref e) => 79 | // In case of generic parse error, 80 | // message provided by the clap library will be the usage string. 81 | writeln!(&mut io::stderr(), "{}", e.message), 82 | e => { 83 | writeln!(&mut io::stderr(), "Failed to parse arguments: {}", e) 84 | }, 85 | } 86 | } 87 | 88 | /// Run the application with given options. 89 | fn run(opts: Options) -> ExitCode { 90 | let result = match opts.output_path.as_ref() { 91 | Some(path) => { 92 | trace!("Opening --output_path file {}...", path.display()); 93 | let file = fs::OpenOptions::new() 94 | .create(true).write(true).append(false) 95 | .open(path); 96 | match file { 97 | Ok(file) => { 98 | debug!("File {} opened successfully", path.display()); 99 | render(opts.image_macro, file) 100 | } 101 | Err(e) => { 102 | error!("Failed to open output file {} for writing: {}", 103 | path.display(), e); 104 | return exitcode::CANTCREAT; 105 | } 106 | } 107 | } 108 | None => { 109 | trace!("No --output_path given, using standard output"); 110 | if isatty::stdout_isatty() { 111 | warn!("Standard output is a terminal."); 112 | let should_continue = ask_before_stdout().unwrap(); 113 | if !should_continue { 114 | debug!("User didn't want to print to stdout after all."); 115 | return exitcode::OK; 116 | } 117 | } 118 | render(opts.image_macro, io::stdout()) 119 | } 120 | }; 121 | 122 | match result { 123 | Ok(_) => exitcode::OK, 124 | Err(e) => { 125 | error!("Error while rendering image macro: {}", e); 126 | exitcode::UNAVAILABLE 127 | } 128 | } 129 | } 130 | 131 | 132 | /// Ask the user before printing out binary stuff to stdout. 133 | fn ask_before_stdout() -> io::Result { 134 | write!(&mut io::stderr(), "{}", format_stdout_ack_prompt())?; 135 | let mut answer = String::with_capacity(YES.len()); 136 | io::stdin().read_line(&mut answer)?; 137 | Ok(answer.trim().to_lowercase() == YES) 138 | } 139 | 140 | /// Return the formatted prompt for stdout warning acknowledgment. 141 | fn format_stdout_ack_prompt() -> String { 142 | const ACK_PROMPT: &'static str = 143 | "Do you wish to print the binary image output on standard output?"; 144 | if cfg!(unix) { 145 | format!("{} [{}/{}]: ", ACK_PROMPT, YES, Colour::Green.paint("N")) 146 | } else { 147 | format!("{} [{}/{}]: ", ACK_PROMPT, YES, "N") 148 | } 149 | } 150 | 151 | const YES: &'static str = "y"; 152 | 153 | 154 | /// Render given `ImageMacro` and write it to the output. 155 | fn render(im: rofl::ImageMacro, mut output: W) -> io::Result<()> { 156 | trace!("Rendering macro {:#?}", im); 157 | 158 | // TODO: allow to adjust the resource directories from the command line 159 | let engine = rofl::EngineBuilder::new() 160 | .template_directory("data/templates") 161 | .font_directory("data/fonts") 162 | .build().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; 163 | let captioned = engine.caption(im) 164 | .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; 165 | 166 | trace!("Writing {} bytes to the output...", captioned.len()); 167 | output.write_all(captioned.bytes()) 168 | } 169 | -------------------------------------------------------------------------------- /src/lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rofl" 3 | version = "0.0.1" 4 | authors = ["Karol Kuczmarski "] 5 | description = "Lulz on demand" 6 | keywords = ["meme", "image-macro", "caption", "gif", "image-processing"] 7 | categories = ["multimedia::images", "visualization"] 8 | documentation = "https://docs.rs/rofl" 9 | homepage = "https://github.com/Xion/rofld" 10 | repository = "https://github.com/Xion/rofld" 11 | readme = "README.md" 12 | license = "BSD-3-Clause" 13 | 14 | [badges] 15 | travis-ci = { repository = "Xion/rofld" } 16 | 17 | [lib] 18 | name = "rofl" 19 | path = "lib.rs" 20 | crate-type = ["dylib", "rlib"] 21 | 22 | [dependencies] 23 | antidote = "1.0" 24 | color_quant = "1.0" 25 | conv = "0.3" 26 | css-color-parser = "0.1.2" 27 | derive_builder = { version = "0.4", features = ["private_fields"] } 28 | derive-error = "0.0.3" 29 | enum_derive = "0.1" 30 | either = "1.1" 31 | float-ord = "0.1" 32 | gif = "0.9" 33 | gif-dispose = "1.0.1" 34 | glob = "0.2" 35 | image = "0.12" 36 | itertools = "0.6" 37 | lazy_static = "0.2" 38 | log = "0.3" 39 | lru-cache = "0.1" 40 | macro-attr = "0.2" 41 | maplit = "0.1" 42 | mime = "0.3" 43 | newtype_derive = "0.1" 44 | num = "0.1" 45 | rand = "0.3" 46 | regex = "0.2" 47 | rusttype = "0.2" 48 | serde = "1.0" 49 | serde_derive = "1.0" 50 | time = "0.1" 51 | try_opt = "0.1" 52 | unicode-normalization = "0.1" 53 | unreachable = "0.1" 54 | 55 | [dev-dependencies] 56 | serde_json = "1.0" 57 | serde_qs = "0.3" 58 | serde_test = "1.0" 59 | spectral = "0.6.0" 60 | -------------------------------------------------------------------------------- /src/lib/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Karol Kuczmarski 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of lib nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /src/lib/README.md: -------------------------------------------------------------------------------- 1 | # rofl 2 | 3 | Lulz on demand 4 | 5 | [![Build Status](https://img.shields.io/travis/Xion/rofld.svg)](https://travis-ci.org/Xion/rofld) 6 | [![Crates.io](https://img.shields.io/crates/v/rofl.svg)](http://crates.io/crates/rofl) 7 | 8 | ## What? 9 | 10 | This here [_rofl_ crate](http://crates.io/crates/rofl) implements the tricky and complicated logic 11 | necessary for performing the important task of putting text over pictures. 12 | 13 | In other words, it makes memes (also known by purists as _image macros_). 14 | 15 | ## How? 16 | 17 | How about that: 18 | 19 | ```rust 20 | let engine = rofl::Engine::new("data/templates", "data/fonts"); 21 | let image_macro = ImageMacro { 22 | template: "zoidberg".into(), 23 | captions: vec![ 24 | Caption::text_at(VAlign::Top, "Need a meme?"), 25 | Caption::text_at(VAlign::Bottom, "Why not Zoidberg?"), 26 | ], 27 | ..ImageMacro::default() 28 | }; 29 | let output = engine.caption(image_macro)?; 30 | let mut file = fs::OpenOptions::new().write(true).open("zoidberg.png")?; 31 | file.write_all(&*output)?; 32 | ``` 33 | 34 | ![Need a meme? / Why not Zoidberg?](../../zoidberg.png) 35 | 36 | Neat, huh? 37 | 38 | For an actual application using the crate, check `src/server` in [this repo](https://github.com/Xion/rofld). 39 | -------------------------------------------------------------------------------- /src/lib/caption/engine/config.rs: -------------------------------------------------------------------------------- 1 | //! Module with captioning engine configuration. 2 | 3 | use std::error; 4 | use std::fmt; 5 | 6 | 7 | /// Structure holding configuration for the `Engine`. 8 | /// 9 | /// This is shared with `CaptionTask`s. 10 | #[derive(Clone, Copy, Debug)] 11 | pub struct Config { 12 | /// Quality of the generated JPEG images (in %). 13 | pub jpeg_quality: u8, 14 | /// Quality of the generated GIF animations (in %). 15 | pub gif_quality: u8, 16 | } 17 | 18 | impl Default for Config { 19 | /// Initialize Config with default values. 20 | fn default() -> Self { 21 | Config { 22 | jpeg_quality: 85, 23 | gif_quality: 60, 24 | } 25 | } 26 | } 27 | 28 | 29 | /// Error signifying an invalid value for one of the configuration options. 30 | #[derive(Clone, Debug)] 31 | pub enum Error { 32 | /// Invalid value for the GIF animation quality percentage. 33 | GifQuality(u8), 34 | /// Invalid value for the JPEG image quality percentage. 35 | JpegQuality(u8), 36 | } 37 | 38 | impl error::Error for Error { 39 | fn description(&self) -> &str { "invalid Engine configuration value" } 40 | fn cause(&self) -> Option<&error::Error> { None } 41 | } 42 | 43 | impl fmt::Display for Error { 44 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 45 | match *self { 46 | Error::GifQuality(q) => write!(fmt, "invalid GIF quality value: {}%", q), 47 | Error::JpegQuality(q) => write!(fmt, "invalid JPEG quality value: {}%", q), 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/caption/engine/mod.rs: -------------------------------------------------------------------------------- 1 | //! Module which defines the captioning engine. 2 | 3 | mod builder; 4 | mod config; 5 | 6 | pub use self::builder::Error as BuildError; 7 | pub use self::config::{Config, Error as ConfigError}; 8 | 9 | 10 | use std::path::Path; 11 | use std::sync::Arc; 12 | 13 | use antidote::{RwLock, RwLockReadGuard, RwLockWriteGuard}; 14 | 15 | use model::ImageMacro; 16 | use resources::{CachingLoader, Font, FontLoader, Loader, Template, TemplateLoader}; 17 | use util::cache::ThreadSafeCache; 18 | use super::error::CaptionError; 19 | use super::output::CaptionOutput; 20 | use super::task::CaptionTask; 21 | pub use self::builder::Builder; 22 | 23 | 24 | /// Image captioning engine. 25 | /// 26 | /// The engine is thread-safe (`Sync`) since normally you'd want the captioning 27 | /// to be performed in a background thread. 28 | /// 29 | /// *Note*: `Engine` implements `Clone` 30 | /// by merely cloning a shared reference to the underlying object. 31 | #[derive(Clone, Debug)] 32 | pub struct Engine 33 | where Tl: Loader, Fl: Loader 34 | { 35 | inner: Arc>, 36 | } 37 | 38 | /// Shared state of the engine that caption tasks have access to. 39 | #[derive(Debug)] 40 | pub(super) struct Inner 41 | where Tl: Loader, Fl: Loader 42 | { 43 | pub(super) config: RwLock, 44 | pub template_loader: CachingLoader, 45 | pub font_loader: CachingLoader, 46 | } 47 | 48 | impl Inner 49 | where Tl: Loader, Fl: Loader 50 | { 51 | #[inline] 52 | pub fn new(config: Config, 53 | template_loader: CachingLoader, 54 | font_loader: CachingLoader) -> Self { 55 | let config = RwLock::new(config); 56 | Inner{config, template_loader, font_loader} 57 | } 58 | } 59 | 60 | impl From> for Engine 61 | where Tl: Loader, Fl: Loader 62 | { 63 | fn from(inner: Inner) -> Self { 64 | Engine{inner: Arc::new(inner)} 65 | } 66 | } 67 | 68 | // Constructors. 69 | impl Engine { 70 | /// Create an Engine which loads templates & fonts from given directory paths. 71 | /// 72 | /// When loaded, both resources will be cached in memory (LRU cache). 73 | /// 74 | /// For other ways of creating `Engine`, see the `EngineBuilder`. 75 | #[inline] 76 | pub fn new(template_directory: Dt, font_directory: Df) -> Self 77 | where Dt: AsRef, Df: AsRef 78 | { 79 | Builder::new() 80 | .template_directory(template_directory) 81 | .font_directory(font_directory) 82 | .build().unwrap() 83 | } 84 | 85 | // TODO: consider deprecating all the other constructors now that we have a builder 86 | } 87 | impl Engine 88 | where Tl: Loader, Fl: Loader 89 | { 90 | /// Create an Engine that uses given loaders for templates & font. 91 | /// 92 | /// When loaded, both resources will be cached in memory (LRU cache). 93 | #[inline] 94 | pub fn with_loaders(template_loader: Tl, font_loader: Fl) -> Self { 95 | Builder::new() 96 | .template_loader(template_loader) 97 | .font_loader(font_loader) 98 | .build().unwrap() 99 | } 100 | 101 | /// Create an Engine that uses given template & font loaders directly. 102 | /// 103 | /// Any caching scheme, if necessary, should be implemented by loaders themselves. 104 | #[inline] 105 | pub fn with_raw_loaders(template_loader: Tl, font_loader: Fl) -> Self { 106 | Builder::new() 107 | .raw_template_loader(template_loader) 108 | .raw_font_loader(font_loader) 109 | .build().unwrap() } 110 | } 111 | 112 | 113 | // Image macro captioning. 114 | impl Engine 115 | where Tl: Loader, Fl: Loader 116 | { 117 | /// Render a given image macro by captioning the template with the specified text(s). 118 | /// 119 | /// Note that captioning is a CPU-intensive process and can be relatively lengthy, 120 | /// especially if the template is an animated GIF. 121 | /// It is recommended to execute it in a separate thread. 122 | #[inline] 123 | pub fn caption(&self, image_macro: ImageMacro) -> Result> { 124 | CaptionTask::new(image_macro, self.inner.clone()).perform() 125 | } 126 | } 127 | 128 | // Managing resources. 129 | impl Engine 130 | where Tl: Loader, Fl: Loader 131 | { 132 | /// Preemptively load a template into engine's cache. 133 | pub fn preload_template(&self, name: &str) -> Result<(), Tl::Err> { 134 | if !self.inner.template_loader.phony { 135 | self.inner.template_loader.load(name)?; 136 | } 137 | Ok(()) 138 | } 139 | 140 | /// Preemptively load a font into engine's cache. 141 | pub fn preload_font(&self, name: &str) -> Result<(), Fl::Err> { 142 | if !self.inner.font_loader.phony { 143 | self.inner.font_loader.load(name)?; 144 | } 145 | Ok(()) 146 | } 147 | 148 | /// Return a reference to the internal template cache, if any. 149 | /// This can be used to examine cache statistics (hits & misses). 150 | pub fn template_cache(&self) -> Option<&ThreadSafeCache> { 151 | if self.inner.template_loader.phony { 152 | None 153 | } else { 154 | Some(self.inner.template_loader.cache()) 155 | } 156 | } 157 | 158 | /// Return a reference to the internal font cache, if any. 159 | /// This can be used to examine cache statistics (hits & misses). 160 | pub fn font_cache(&self) -> Option<&ThreadSafeCache> { 161 | if self.inner.font_loader.phony { 162 | None 163 | } else { 164 | Some(self.inner.font_loader.cache()) 165 | } 166 | } 167 | } 168 | 169 | // Configuration. 170 | impl Engine 171 | where Tl: Loader, Fl: Loader 172 | { 173 | /// Read the `Engine`'s configuration. 174 | #[inline] 175 | pub fn config(&self) -> RwLockReadGuard { 176 | self.inner.config.read() 177 | } 178 | 179 | /// Modify the `Engine`'s configuration. 180 | /// 181 | /// Changes will affect both pending and future captioning tasks. 182 | #[inline] 183 | pub fn config_mut(&self) -> RwLockWriteGuard { 184 | self.inner.config.write() 185 | } 186 | } 187 | 188 | 189 | #[cfg(test)] 190 | mod tests { 191 | use super::Engine; 192 | 193 | #[test] 194 | fn thread_safe() { 195 | fn assert_sync() {} 196 | fn assert_send() {} 197 | 198 | assert_sync::(); 199 | assert_send::(); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/lib/caption/error.rs: -------------------------------------------------------------------------------- 1 | //! Captioning error. 2 | 3 | use std::error::Error; 4 | use std::fmt; 5 | use std::io; 6 | 7 | use resources::{Loader, Font, FontLoader, Template, TemplateLoader}; 8 | 9 | 10 | /// Error that may occur during the captioning. 11 | pub enum CaptionError 12 | where Tl: Loader, Fl: Loader 13 | { 14 | /// Error while loading the template. 15 | Template { 16 | /// Name of the template that failed to load. 17 | name: String, 18 | /// Error that occurred while loading the template. 19 | error: Tl::Err, 20 | }, 21 | /// Error while loading the font. 22 | Font { 23 | /// Name of the font that failed to load. 24 | name: String, 25 | /// Error that occurred while loading the font. 26 | error: Fl::Err, 27 | }, 28 | /// Error while encoding the final image macro. 29 | Encode(io::Error), 30 | } 31 | 32 | impl CaptionError 33 | where Tl: Loader, Fl: Loader 34 | { 35 | /// Create `CaptionError` for when a template failed to load. 36 | #[inline] 37 | pub fn template(name: N, error: Tl::Err) -> Self { 38 | CaptionError::Template{ name: name.to_string(), error: error } 39 | } 40 | 41 | /// Create `CaptionError` for when a font failed to load. 42 | #[inline] 43 | pub fn font(name: N, error: Fl::Err) -> Self { 44 | CaptionError::Font{ name: name.to_string(), error: error } 45 | } 46 | } 47 | 48 | impl Error for CaptionError 49 | where Tl: Loader, Fl: Loader 50 | { 51 | fn description(&self) -> &str { "captioning error" } 52 | fn cause(&self) -> Option<&Error> { 53 | match *self { 54 | CaptionError::Template{ ref error, .. } => Some(error), 55 | CaptionError::Font{ ref error, .. } => Some(error), 56 | CaptionError::Encode(ref e) => Some(e), 57 | } 58 | } 59 | } 60 | 61 | impl fmt::Debug for CaptionError 62 | where Tl: Loader, Fl: Loader 63 | { 64 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 65 | match *self { 66 | CaptionError::Template{ ref name, ref error } => 67 | fmt.debug_struct("CaptionError::Template") 68 | .field("name", name) 69 | .field("error", &error.description()) 70 | .finish(), 71 | CaptionError::Font{ ref name, ref error } => 72 | fmt.debug_struct("CaptionError::Font") 73 | .field("name", name) 74 | .field("error", &error.description()) 75 | .finish(), 76 | CaptionError::Encode(ref e) => write!(fmt, "CaptionError::Encode({:?})", e) 77 | } 78 | } 79 | } 80 | 81 | impl fmt::Display for CaptionError 82 | where Tl: Loader, Fl: Loader 83 | { 84 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 85 | match *self { 86 | CaptionError::Template{ ref name, ref error } => 87 | write!(fmt, "cannot load template `{}`: {}", name, error.description()), 88 | CaptionError::Font{ ref name, ref error } => 89 | write!(fmt, "cannot load font `{}`: {}", name, error.description()), 90 | CaptionError::Encode(ref e) => write!(fmt, "failed to encode the final image: {}", e), 91 | } 92 | } 93 | } 94 | 95 | 96 | #[cfg(test)] 97 | mod tests { 98 | use super::CaptionError; 99 | 100 | #[test] 101 | fn thread_safe() { 102 | fn assert_sync() {} 103 | fn assert_send() {} 104 | 105 | assert_sync::(); 106 | assert_send::(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/caption/mod.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing image captioning. 2 | 3 | mod engine; 4 | mod error; 5 | mod output; 6 | mod task; 7 | 8 | 9 | pub use self::engine::{Builder as EngineBuilder, 10 | BuildError as EngineBuildError, 11 | Config as EngineConfig, 12 | ConfigError as EngineConfigError, 13 | Engine}; 14 | pub use self::error::CaptionError; 15 | pub use self::output::CaptionOutput; 16 | -------------------------------------------------------------------------------- /src/lib/caption/output.rs: -------------------------------------------------------------------------------- 1 | //! Defines the output of a captioning operation. 2 | 3 | use std::ops::Deref; 4 | 5 | use image::ImageFormat; 6 | use mime::{self, Mime}; 7 | 8 | 9 | /// Output of the captioning process. 10 | #[derive(Clone, Debug)] 11 | #[must_use = "unused caption output which must be used"] 12 | pub struct CaptionOutput { 13 | format: ImageFormat, 14 | bytes: Vec, 15 | } 16 | 17 | impl CaptionOutput { 18 | #[inline] 19 | pub(super) fn new(format: ImageFormat, bytes: Vec) -> Self { 20 | CaptionOutput{format, bytes} 21 | } 22 | } 23 | 24 | impl CaptionOutput { 25 | /// Image format of the output. 26 | #[inline] 27 | pub fn format(&self) -> ImageFormat { 28 | self.format 29 | } 30 | 31 | /// Raw bytes of the output. 32 | /// 33 | /// See `CaptionOutput::format` for how to interpret it. 34 | #[inline] 35 | pub fn bytes(&self) -> &[u8] { 36 | &self.bytes[..] 37 | } 38 | 39 | /// Convert the output into a vector of bytes. 40 | #[inline] 41 | pub fn into_bytes(self) -> Vec { 42 | self.bytes 43 | } 44 | 45 | /// Convert the output into boxed slice of bytes. 46 | #[inline] 47 | pub fn into_boxed_bytes(self) -> Box<[u8]> { 48 | self.bytes.into_boxed_slice() 49 | } 50 | 51 | /// The MIME type that matches output's format. 52 | pub fn mime_type(&self) -> Option { 53 | match self.format { 54 | ImageFormat::GIF => Some(mime::IMAGE_GIF), 55 | ImageFormat::JPEG => Some(mime::IMAGE_JPEG), 56 | ImageFormat::PNG => Some(mime::IMAGE_PNG), 57 | _ => None, 58 | } 59 | } 60 | } 61 | 62 | impl Deref for CaptionOutput { 63 | type Target = [u8]; 64 | 65 | fn deref(&self) -> &Self::Target { 66 | self.bytes() 67 | } 68 | } 69 | 70 | impl Into> for CaptionOutput { 71 | fn into(self) -> Vec { 72 | self.into_bytes() 73 | } 74 | } 75 | impl Into> for CaptionOutput { 76 | fn into(self) -> Box<[u8]> { 77 | self.into_boxed_bytes() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/caption/task.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing the actual captioning task. 2 | //! Most if not all captioning logic lives here. 3 | 4 | use std::io; 5 | use std::ops::Deref; 6 | use std::sync::Arc; 7 | 8 | use image::{self, DynamicImage, FilterType, GenericImage, ImageFormat}; 9 | use rusttype::{point, Rect, vector}; 10 | 11 | use model::{Caption, ImageMacro, Size, DEFAULT_TEXT_SIZE}; 12 | use resources::{Loader, Font, FontLoader, Template, TemplateLoader}; 13 | use util::animated_gif; 14 | use util::text::{self, Style}; 15 | use super::error::CaptionError; 16 | use super::engine; 17 | use super::output::CaptionOutput; 18 | 19 | 20 | /// Represents a single captioning task and contains all the relevant logic. 21 | /// 22 | /// This is a separate struct so that the engine doesn't have to be 23 | /// carried explicitly between its methods. 24 | /// 25 | /// All the code here is executed in a background thread, 26 | /// and so it can be synchronous. 27 | pub(super) struct CaptionTask 28 | where Tl: Loader, Fl: Loader 29 | { 30 | image_macro: ImageMacro, 31 | engine: Arc>, 32 | } 33 | 34 | impl Deref for CaptionTask 35 | where Tl: Loader, Fl: Loader 36 | { 37 | type Target = ImageMacro; 38 | fn deref(&self) -> &Self::Target { 39 | &self.image_macro // makes the rendering code a little terser 40 | } 41 | } 42 | 43 | impl CaptionTask 44 | where Tl: Loader, Fl: Loader 45 | { 46 | #[inline] 47 | pub fn new(image_macro: ImageMacro, engine: Arc>) -> Self { 48 | CaptionTask{image_macro, engine} 49 | } 50 | } 51 | 52 | impl CaptionTask 53 | where Tl: Loader, Fl: Loader 54 | { 55 | /// Perform the captioning task. 56 | pub fn perform(self) -> Result> { 57 | debug!("Rendering {:?}", self.image_macro); 58 | 59 | let template = self.engine.template_loader.load(&self.template) 60 | .map_err(|e| CaptionError::template(self.template.clone(), e))?; 61 | if template.is_animated() { 62 | debug!("Image macro uses an animated template `{}` with {} frames", 63 | self.template, template.image_count()); 64 | } 65 | 66 | // Render the text on all images of the templates 67 | // (which usually means just one, unless it's an animated GIF). 68 | let mut images = Vec::with_capacity(template.image_count()); 69 | for mut img in template.iter_images().cloned() { 70 | img = self.resize_template(img); 71 | if self.has_text() { 72 | img = self.draw_texts(img)?; 73 | } 74 | images.push(img); 75 | } 76 | 77 | let bytes = self.encode_result(images, &*template)?; 78 | let output = CaptionOutput::new(template.preferred_format(), bytes); 79 | Ok(output) 80 | } 81 | 82 | /// Resize a template image to fit the desired dimensions. 83 | fn resize_template(&self, template: DynamicImage) -> DynamicImage { 84 | // Note that resizing preserves original aspect, so the final image 85 | // may be smaller than requested. 86 | let (orig_width, orig_height) = template.dimensions(); 87 | trace!("Original size of the template image `{}`: {}x{}", 88 | self.template, orig_width, orig_height); 89 | let target_width = self.width.unwrap_or(orig_width); 90 | let target_height = self.height.unwrap_or(orig_height); 91 | 92 | let img; 93 | if target_width != orig_width || target_height != orig_height { 94 | debug!("Resizing template image `{}` from {}x{} to {}x{}", 95 | self.template, orig_width, orig_height, target_width, target_height); 96 | img = template.resize(target_width, target_height, FilterType::Lanczos3); 97 | } else { 98 | debug!("Using original template image size of {}x{}", orig_width, orig_height); 99 | img = template; 100 | } 101 | 102 | let (width, height) = img.dimensions(); 103 | trace!("Final image size: {}x{}", width, height); 104 | img 105 | } 106 | 107 | /// Draw the text from ImageMacro on given image. 108 | /// Returns a new image. 109 | fn draw_texts(&self, img: DynamicImage) -> Result> { 110 | // Rendering text requires alpha blending. 111 | let mut img = img; 112 | if img.as_rgba8().is_none() { 113 | trace!("Converting image to RGBA..."); 114 | img = DynamicImage::ImageRgba8(img.to_rgba()); 115 | } 116 | 117 | for cap in &self.captions { 118 | img = self.draw_single_caption(img, cap)?; 119 | } 120 | 121 | Ok(img) 122 | } 123 | 124 | /// Draws a single caption text. 125 | /// Returns a new image. 126 | fn draw_single_caption(&self, img: DynamicImage, 127 | caption: &Caption) -> Result> { 128 | let mut img = img; 129 | 130 | if caption.text.is_empty() { 131 | debug!("Empty caption text, skipping."); 132 | return Ok(img); 133 | } 134 | debug!("Rendering {v}-{h} text: {text:?}", text = caption.text, 135 | v = format!("{:?}", caption.valign).to_lowercase(), 136 | h = format!("{:?}", caption.halign).to_lowercase()); 137 | 138 | trace!("Loading font `{}`...", caption.font); 139 | let font = self.engine.font_loader.load(&caption.font) 140 | .map_err(|e| CaptionError::font(caption.font.clone(), e))?; 141 | 142 | trace!("Checking if font `{}` has all glyphs for caption: {}", 143 | caption.font, caption.text); 144 | text::check(&*font, &caption.text); 145 | 146 | let (width, height) = img.dimensions(); 147 | let width = width as f32; 148 | let height = height as f32; 149 | 150 | // Make sure the vertical margin isn't too large by limiting it 151 | // to a small percentage of image height. 152 | let max_vmargin: f32 = 16.0; 153 | let vmargin = max_vmargin.min(height * 0.02); 154 | trace!("Vertical text margin computed as {}", vmargin); 155 | 156 | // Similarly for the horizontal margin. 157 | let max_hmargin: f32 = 16.0; 158 | let hmargin = max_hmargin.min(width * 0.02); 159 | trace!("Horizontal text margin computed as {}", hmargin); 160 | 161 | let margin_vector = vector(hmargin, vmargin); 162 | let rect: Rect = Rect{ 163 | min: point(0.0, 0.0) + margin_vector, 164 | max: point(width, height) - margin_vector, 165 | }; 166 | 167 | let alignment = (caption.halign, caption.valign); 168 | 169 | let text_size = match caption.size { 170 | Size::Fixed(s) => Some(s), 171 | Size::Shrink => text::fit_line(rect.width(), &caption.text, &*font), 172 | Size::Fit => text::fit_text(rect, &caption.text, &*font), 173 | }.unwrap_or(DEFAULT_TEXT_SIZE); 174 | 175 | // Draw four copies of the text, shifted in four diagonal directions, 176 | // to create the basis for an outline. 177 | if let Some(outline_color) = caption.outline { 178 | let outline_width = 2.0; 179 | debug!("Drawing text outline (width = {})", outline_width); 180 | for &v in [vector(-outline_width, -outline_width), 181 | vector(outline_width, -outline_width), 182 | vector(outline_width, outline_width), 183 | vector(-outline_width, outline_width)].iter() { 184 | let style = Style::new(&font, text_size, outline_color); 185 | let rect = Rect{min: rect.min + v, max: rect.max + v}; 186 | img = text::render_text(img, &caption.text, alignment, rect, style); 187 | } 188 | } 189 | 190 | // Now render the white text in the original position. 191 | debug!("Rendering actual caption text..."); 192 | let style = Style::new(&font, text_size, caption.color); 193 | img = text::render_text(img, &caption.text, alignment, rect, style); 194 | 195 | Ok(img) 196 | } 197 | 198 | /// Encode final result as bytes of the appropriate image format. 199 | fn encode_result(&self, images: Vec, 200 | template: &Template) -> Result, CaptionError> { 201 | let format = template.preferred_format(); 202 | debug!("Encoding final image as {:?}...", format); 203 | 204 | let mut result = vec![]; 205 | match format { 206 | ImageFormat::PNG => { 207 | trace!("Writing PNG image"); 208 | assert_eq!(1, images.len()); 209 | let img = &images[0]; 210 | 211 | let (width, height) = img.dimensions(); 212 | let pixels = &*img.raw_pixels(); 213 | image::png::PNGEncoder::new(&mut result) 214 | .encode(pixels, width, height, img.color()) 215 | .map_err(CaptionError::Encode)?; 216 | } 217 | ImageFormat::JPEG => { 218 | let quality = self.engine.config.read().jpeg_quality; 219 | trace!("Writing JPEG with quality {}%", quality); 220 | assert_eq!(1, images.len()); 221 | let img = &images[0]; 222 | 223 | let (width, height) = img.dimensions(); 224 | let pixels = &*img.raw_pixels(); 225 | image::jpeg::JPEGEncoder::new_with_quality(&mut result, quality) 226 | .encode(pixels, width, height, img.color()) 227 | .map_err(CaptionError::Encode)?; 228 | } 229 | ImageFormat::GIF => { 230 | let quality = self.engine.config.read().gif_quality; 231 | if let &Template::Animation(ref gif_anim) = template { 232 | trace!("Writing animated GIF of {} frame(s) with quality {}%", 233 | gif_anim.frames_count(), quality); 234 | animated_gif::encode_modified(gif_anim, images, quality, &mut result) 235 | .map_err(CaptionError::Encode)?; 236 | } else { 237 | trace!("Writing regular (still) GIF with quality {}%", quality); 238 | assert_eq!(1, images.len()); 239 | let img = &images[0]; 240 | 241 | // Encode the image as a single GIF frame. 242 | let (width, height) = img.dimensions(); 243 | let mut frame = image::gif::Frame::default(); 244 | let (buffer, palette, transparent) = animated_gif::quantize_image(img, quality); 245 | frame.width = width as u16; 246 | frame.height = height as u16; 247 | frame.buffer = buffer.into(); 248 | frame.palette = Some(palette); 249 | frame.transparent = transparent; 250 | 251 | image::gif::Encoder::new(&mut result).encode(frame).map_err(|e| { 252 | let io_error = match e { 253 | image::ImageError::IoError(e) => e, 254 | e => io::Error::new(io::ErrorKind::Other, e), 255 | }; 256 | CaptionError::Encode(io_error) 257 | })?; 258 | } 259 | } 260 | f => { 261 | panic!("Unexpected image format in CaptionTask::encode_result: {:?}", f); 262 | } 263 | } 264 | 265 | Ok(result) 266 | } 267 | } 268 | 269 | 270 | #[cfg(test)] 271 | mod tests { 272 | use super::CaptionTask; 273 | 274 | #[test] 275 | fn thread_safe() { 276 | fn assert_sync() {} 277 | fn assert_send() {} 278 | 279 | assert_sync::(); 280 | assert_send::(); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/lib/ext/mod.rs: -------------------------------------------------------------------------------- 1 | //! Extension module, gluing together & enhancing the third-party libraries, or even Rust itself. 2 | 3 | pub mod rust; 4 | -------------------------------------------------------------------------------- /src/lib/ext/rust.rs: -------------------------------------------------------------------------------- 1 | //! Extension module for Rust itself. 2 | 3 | #![allow(dead_code)] 4 | 5 | 6 | /// Additional mutation methods for `Option`. 7 | /// 8 | /// This is essentially the same thing that `#[feature(option_entry)]` solves. 9 | pub trait OptionMutExt { 10 | /// Replace an existing value with a new one. 11 | /// 12 | /// Returns the previous value if it was present, or `None` if no replacement was made. 13 | fn replace(&mut self, val: T) -> Option; 14 | 15 | /// Replace existing value with result of given closure. 16 | /// 17 | /// Returns the previous value if it was present, or `None` if no replacement was made. 18 | fn replace_with T>(&mut self, f: F) -> Option; 19 | 20 | /// Set the "default" value of `Option` (if it didn't have one before) 21 | /// and return a mutable reference to the final value (old or new one). 22 | /// 23 | /// This is identical to unstable `Option::get_or_insert`. 24 | fn set_default(&mut self, val: T) -> &mut T; 25 | 26 | /// Set the "default" value of `Option` (if it didn't have one before) 27 | /// by evaluating given closure, 28 | /// and return a mutable reference to the final value (old or new one). 29 | /// 30 | /// This is identical to unstable `Option::get_or_insert_with`. 31 | fn set_default_with T>(&mut self, f: F) -> &mut T; 32 | } 33 | 34 | impl OptionMutExt for Option { 35 | fn replace(&mut self, val: T) -> Option { 36 | self.replace_with(move || val) 37 | } 38 | 39 | fn replace_with T>(&mut self, f: F) -> Option { 40 | if self.is_some() { 41 | let result = self.take(); 42 | *self = Some(f()); 43 | result 44 | } else { 45 | None 46 | } 47 | } 48 | 49 | fn set_default(&mut self, val: T) -> &mut T { 50 | self.set_default_with(move || val) 51 | } 52 | 53 | fn set_default_with T>(&mut self, f: F) -> &mut T { 54 | if self.is_none() { 55 | *self = Some(f()); 56 | } 57 | self.as_mut().unwrap() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/lib.rs: -------------------------------------------------------------------------------- 1 | //! *Lulz on demand!* 2 | //! 3 | //! This here `rofl` crate, aptly named, is capable of the extraordinary feat 4 | //! of putting text on images. And not just still images: it does cover animated GIFs as well! 5 | //! 6 | //! In other words, the crate can be used to create _memes_, 7 | //! which purists generally refer to as _image macros_. 8 | //! 9 | //! # Much example 10 | //! 11 | //! ```rust 12 | //! extern crate rofl; 13 | //! 14 | //! # use std::error::Error; 15 | //! # use std::io::Write; 16 | //! # use std:: fs; 17 | //! # 18 | //! # fn zoidberg() -> Result<(), Box> { 19 | //! let engine = rofl::EngineBuilder::new() 20 | //! .template_directory("data/templates") 21 | //! .font_directory("data/fonts") 22 | //! .build().unwrap(); 23 | //! let image_macro = rofl::ImageMacro { 24 | //! template: "zoidberg".into(), 25 | //! captions: vec![ 26 | //! rofl::Caption::text_at(rofl::VAlign::Top, "Need an example?"), 27 | //! rofl::Caption::text_at(rofl::VAlign::Bottom, "Why not Zoidberg?"), 28 | //! ], 29 | //! ..rofl::ImageMacro::default() 30 | //! }; 31 | //! let output = engine.caption(image_macro)?; 32 | //! 33 | //! let mut file = fs::OpenOptions::new().write(true).open("zoidberg.png")?; 34 | //! file.write_all(&*output)?; 35 | //! # Ok(()) 36 | //! # } 37 | //! ``` 38 | //! 39 | //! # Very concepts 40 | //! 41 | //! To create memes, you need two types of media resources (in addition to impeccable wit): 42 | //! 43 | //! * _templates_ -- named images & animated GIFs that we can put text on 44 | //! * _fonts_ to render the text with (like `"Impact"` or `"Comic Sans"`) 45 | //! 46 | //! Those resources have to be provided to the captioning [`Engine`](struct.Engine.html). 47 | //! 48 | //! In the simple example above, they are just files contained within some directories. 49 | //! If you're doing something more complicated -- 50 | //! like a website where users can upload their own images -- 51 | //! you can implement your own [`Loader`s](trait.Loader.html) for templates or even fonts. 52 | //! 53 | //! A meme is defined by [the `ImageMacro` structure](struct.ImageMacro.html). 54 | //! These can be deserialized from JSON or query strings if desired. 55 | //! 56 | //! # Wow 57 | //! 58 | //! Go forth and meme! 59 | 60 | 61 | #![deny(missing_docs)] 62 | 63 | 64 | extern crate antidote; 65 | extern crate color_quant; 66 | extern crate conv; 67 | extern crate css_color_parser; 68 | #[macro_use] extern crate derive_builder; 69 | #[macro_use] extern crate derive_error; 70 | #[macro_use] extern crate enum_derive; 71 | extern crate either; 72 | extern crate float_ord; 73 | extern crate gif; 74 | extern crate gif_dispose; 75 | extern crate glob; 76 | extern crate image; 77 | extern crate itertools; 78 | #[macro_use] extern crate lazy_static; 79 | #[macro_use] extern crate log; 80 | extern crate lru_cache; 81 | #[macro_use] extern crate macro_attr; 82 | #[macro_use] extern crate maplit; 83 | extern crate mime; 84 | #[macro_use] extern crate newtype_derive; 85 | extern crate num; 86 | extern crate rand; 87 | extern crate regex; 88 | extern crate rusttype; 89 | extern crate serde; 90 | #[macro_use] extern crate serde_derive; 91 | extern crate time; 92 | #[macro_use] extern crate try_opt; 93 | extern crate unicode_normalization; 94 | extern crate unreachable; 95 | 96 | 97 | #[cfg(test)] #[macro_use] extern crate serde_json; 98 | #[cfg(test)] extern crate serde_qs; 99 | #[cfg(test)] extern crate serde_test; 100 | #[cfg(test)] #[macro_use] extern crate spectral; 101 | 102 | 103 | mod caption; 104 | mod ext; 105 | mod model; 106 | mod resources; 107 | mod util; 108 | 109 | 110 | pub use caption::*; 111 | pub use model::*; 112 | pub use resources::*; 113 | pub use util::{animated_gif, cache}; 114 | -------------------------------------------------------------------------------- /src/lib/model/constants.rs: -------------------------------------------------------------------------------- 1 | //! Module defining constants relevant to the data model. 2 | 3 | use super::types::{Color, HAlign}; 4 | 5 | 6 | /// Name of the default font. 7 | pub const DEFAULT_FONT: &'static str = "Impact"; 8 | 9 | /// Default color of the text. 10 | pub const DEFAULT_COLOR: Color = Color(0xff, 0xff, 0xff); 11 | /// Default color of the text outline. 12 | /// This is the inversion of `DEFAULT_COLOR`. 13 | pub const DEFAULT_OUTLINE_COLOR: Color = Color(0x0, 0x0, 0x0); 14 | 15 | /// Default horizontal alignment of text. 16 | pub const DEFAULT_HALIGN: HAlign = HAlign::Center; 17 | 18 | /// Default size of caption text. 19 | pub const DEFAULT_TEXT_SIZE: f32 = 64.0; 20 | 21 | 22 | /// Maximum number of captions an ImageMacro can have. 23 | pub const MAX_CAPTION_COUNT: usize = 16; 24 | 25 | /// Maximum width of the result image. 26 | pub const MAX_WIDTH: u32 = 1024; 27 | /// Maximum height of the result image. 28 | pub const MAX_HEIGHT: u32 = 1024; 29 | 30 | /// Maximum length (in Unicode codepoints) of a single caption text. 31 | pub const MAX_CAPTION_LENGTH: usize = 256; 32 | -------------------------------------------------------------------------------- /src/lib/model/de/caption.rs: -------------------------------------------------------------------------------- 1 | //! Deserializer for the Caption type. 2 | 3 | use std::fmt; 4 | 5 | use serde::de::{self, Deserialize, Visitor, Unexpected}; 6 | 7 | use super::super::{Caption, Size, 8 | DEFAULT_FONT, DEFAULT_HALIGN, DEFAULT_COLOR, 9 | DEFAULT_OUTLINE_COLOR, DEFAULT_TEXT_SIZE}; 10 | 11 | 12 | const FIELDS: &'static [&'static str] = &[ 13 | "text", "align", "valign", "font", "color", "outline", "size", 14 | ]; 15 | const REQUIRED_FIELDS_COUNT: usize = 2; // text & valign 16 | 17 | const EXPECTING_MSG: &'static str = "map or struct with image macro caption"; 18 | lazy_static! { 19 | static ref EXPECTING_FIELD_COUNT_MSG: String = format!( 20 | "at least {} and no more than {}", REQUIRED_FIELDS_COUNT, FIELDS.len()); 21 | } 22 | 23 | 24 | impl<'de> Deserialize<'de> for Caption { 25 | fn deserialize(deserializer: D) -> Result 26 | where D: de::Deserializer<'de> 27 | { 28 | deserializer.deserialize_map(CaptionVisitor) 29 | } 30 | } 31 | 32 | struct CaptionVisitor; 33 | impl<'de> Visitor<'de> for CaptionVisitor { 34 | type Value = Caption; 35 | 36 | fn expecting(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 37 | write!(fmt, "{}", EXPECTING_MSG) 38 | } 39 | 40 | fn visit_map(self, mut map: V) -> Result 41 | where V: de::MapAccess<'de> 42 | { 43 | // Preemptively check for length against minimum & maximum. 44 | if let Some(size) = map.size_hint() { 45 | if size < REQUIRED_FIELDS_COUNT || size > FIELDS.len() { 46 | return Err(de::Error::invalid_length( 47 | size, &(&*EXPECTING_FIELD_COUNT_MSG as &str))); 48 | } 49 | } 50 | 51 | let mut text = None; 52 | let mut halign = None; 53 | let mut valign = None; 54 | let mut font = None; 55 | let mut color = None; 56 | let mut outline: Option> = None; 57 | let mut size = None; 58 | 59 | while let Some(key) = map.next_key::()? { 60 | let key = key.trim().to_lowercase(); 61 | match key.as_str() { 62 | "text" => { 63 | if text.is_some() { 64 | return Err(de::Error::duplicate_field("text")); 65 | } 66 | let value: String = map.next_value()?; 67 | if value.is_empty() { 68 | return Err(de::Error::invalid_value( 69 | Unexpected::Str(&value), &"non-empty string")); 70 | } 71 | text = Some(value); 72 | } 73 | "align" | "halign" => { 74 | if halign.is_some() { 75 | return Err(de::Error::duplicate_field("align")); 76 | } 77 | halign = Some(map.next_value()?); 78 | } 79 | "valign" => { 80 | if valign.is_some() { 81 | return Err(de::Error::duplicate_field("valign")); 82 | } 83 | valign = Some(map.next_value()?); 84 | } 85 | "font" => { 86 | if font.is_some() { 87 | return Err(de::Error::duplicate_field("font")); 88 | } 89 | font = Some(map.next_value()?); 90 | } 91 | "color" => { 92 | if color.is_some() { 93 | return Err(de::Error::duplicate_field("color")); 94 | } 95 | color = Some(map.next_value()?); 96 | } 97 | "outline" => { 98 | // If "outline" is not provided, the default outline color is used. 99 | // It can also be provided but null, in which case there shall be 100 | // no text outline. 101 | if outline.is_some() { 102 | return Err(de::Error::duplicate_field("outline")); 103 | } 104 | outline = Some(map.next_value()?); 105 | } 106 | "size" => { 107 | if size.is_some() { 108 | return Err(de::Error::duplicate_field("size")); 109 | } 110 | size = Some(map.next_value()?); 111 | } 112 | key => return Err(de::Error::unknown_field(key, FIELDS)), 113 | } 114 | } 115 | 116 | let text = text.ok_or_else(|| de::Error::missing_field("text"))?; 117 | let halign = halign.unwrap_or(DEFAULT_HALIGN); 118 | let valign = valign.ok_or_else(|| de::Error::missing_field("valign"))?; 119 | let font = font.unwrap_or(DEFAULT_FONT).into(); 120 | let color = color.unwrap_or(DEFAULT_COLOR); 121 | let outline = outline.unwrap_or_else(|| Some(DEFAULT_OUTLINE_COLOR)); 122 | let size = size.unwrap_or_else(|| Size::Fixed(DEFAULT_TEXT_SIZE)); 123 | 124 | Ok(Caption{text, halign, valign, font, color, outline, size}) 125 | } 126 | } 127 | 128 | 129 | #[cfg(test)] 130 | mod tests { 131 | mod generic { 132 | use itertools::Itertools; 133 | use serde_test::{assert_de_tokens, assert_de_tokens_error, Token as T}; 134 | use ::model::{Color, HAlign, VAlign}; 135 | use super::super::{Caption, EXPECTING_FIELD_COUNT_MSG, EXPECTING_MSG, FIELDS}; 136 | 137 | lazy_static! { 138 | static ref EXPECTING_FIELD_MSG: String = format!("one of {}", 139 | FIELDS.iter().format_with(", ", |x, f| f(&format_args!("`{}`", x)))); 140 | } 141 | 142 | #[test] 143 | fn must_be_map() { 144 | assert_de_tokens_error::( 145 | &[T::Unit], 146 | &format!("invalid type: unit value, expected {}", EXPECTING_MSG)); 147 | assert_de_tokens_error::( 148 | &[T::Bool(true)], 149 | &format!("invalid type: boolean `true`, expected {}", EXPECTING_MSG)); 150 | assert_de_tokens_error::( 151 | &[T::I32(42)], 152 | &format!("invalid type: integer `42`, expected {}", EXPECTING_MSG)); 153 | assert_de_tokens_error::( 154 | &[T::Char(0x42 as char)], 155 | &format!(r#"invalid type: string "B", expected {}"#, EXPECTING_MSG)); 156 | assert_de_tokens_error::( 157 | &[T::Tuple { len: 1 }, T::Str("foo")], 158 | &format!("invalid type: sequence, expected {}", EXPECTING_MSG)); 159 | // String is possible only when deserializing as part of the ImageMacro; 160 | // otherwise we won't have any sensible default for valign. 161 | assert_de_tokens_error::( 162 | &[T::Str("test")], 163 | &format!(r#"invalid type: string "test", expected {}"#, EXPECTING_MSG)); 164 | assert_de_tokens_error::( 165 | &[T::String("test")], 166 | &format!(r#"invalid type: string "test", expected {}"#, EXPECTING_MSG)); 167 | } 168 | 169 | #[test] 170 | fn must_have_required_fields() { 171 | assert_de_tokens_error::( 172 | &[T::Map{len: Some(1)}], 173 | &format!("invalid length 1, expected {}", *EXPECTING_FIELD_COUNT_MSG)); 174 | assert_de_tokens_error::( 175 | &[T::Map { len: None }, T::MapEnd], 176 | "missing field `text`"); 177 | assert_de_tokens_error::(&[ 178 | T::Map { len: None }, 179 | T::Str("something"), T::Str("or other"), 180 | ], &format!("unknown field `something`, expected {}", *EXPECTING_FIELD_MSG)); 181 | assert_de_tokens_error::(&[ 182 | T::Map { len: None }, 183 | T::Str("text"), T::Str("very caption"), 184 | T::MapEnd, 185 | ], "missing field `valign`"); 186 | 187 | assert_de_tokens(&Caption::text_at(VAlign::Top, "Test"), &[ 188 | T::Map { len: None }, 189 | T::Str("text"), T::Str("Test"), 190 | T::Str("valign"), T::Enum{name: "VAlign"}, T::Str("top"), T::Unit, 191 | T::MapEnd, 192 | ]); 193 | assert_de_tokens_error::(&[ 194 | T::Map { len: None }, 195 | T::Str("text"), T::Str(""), 196 | ], r#"invalid value: string "", expected non-empty string"#); 197 | } 198 | 199 | #[test] 200 | fn can_have_optional_fields() { 201 | assert_de_tokens( 202 | &Caption{halign: HAlign::Center, ..Caption::text_at(VAlign::Top, "Test")}, 203 | &[ 204 | T::Map { len: None }, 205 | T::Str("text"), T::Str("Test"), 206 | T::Str("valign"), T::Enum{name: "VAlign"}, T::Str("top"), T::Unit, 207 | T::Str("halign"), T::Enum{name: "HAlign"}, T::Str("center"), T::Unit, 208 | T::MapEnd, 209 | ]); 210 | assert_de_tokens( 211 | &Caption{font: "Comic Sans".into(), ..Caption::text_at(VAlign::Top, "Test")}, 212 | &[ 213 | T::Map { len: None }, 214 | T::Str("text"), T::Str("Test"), 215 | T::Str("valign"), T::Enum{name: "VAlign"}, T::Str("top"), T::Unit, 216 | T::Str("font"), T::BorrowedStr("Comic Sans"), 217 | T::MapEnd, 218 | ]); 219 | assert_de_tokens( 220 | &Caption{color: Color(1, 2, 3), ..Caption::text_at(VAlign::Top, "Test")}, 221 | &[ 222 | T::Map { len: None }, 223 | T::Str("text"), T::Str("Test"), 224 | T::Str("valign"), T::Enum{name: "VAlign"}, T::Str("top"), T::Unit, 225 | T::Str("color"), T::Seq { len: Some(3) }, T::U8(1), T::U8(2), T::U8(3), T::SeqEnd, 226 | T::MapEnd, 227 | ]); 228 | // But not too many. 229 | assert_de_tokens_error::( 230 | &[T::Map{len: Some(9)}], 231 | &format!("invalid length 9, expected {}", *EXPECTING_FIELD_COUNT_MSG)); 232 | } 233 | 234 | #[test] 235 | fn can_have_null_outline() { 236 | assert_de_tokens( 237 | &Caption{outline: None, ..Caption::text_at(VAlign::Top, "Test")}, 238 | &[ 239 | T::Map { len: None }, 240 | T::Str("text"), T::Str("Test"), 241 | T::Str("valign"), T::Enum{name: "VAlign"}, T::Str("top"), T::Unit, 242 | T::Str("outline"), T::None, 243 | T::MapEnd, 244 | ]); 245 | } 246 | } 247 | 248 | mod json { 249 | use serde_json::from_value as from_json; 250 | use spectral::prelude::*; 251 | use ::model::{Color, Caption, DEFAULT_OUTLINE_COLOR}; 252 | 253 | #[test] 254 | fn required_fields() { 255 | assert_that!(from_json::(json!({"text": "Test"}))) 256 | .is_err().matches(|e| format!("{}", e).contains("at least")); 257 | assert_that!(from_json::(json!({"halign": "left", "valign": "top"}))) 258 | .is_err().matches(|e| format!("{}", e).contains("text")); 259 | // Text cannot be empty. 260 | assert_that!(from_json::(json!({"text": "", "valign": "center"}))) 261 | .is_err().matches(|e| format!("{}", e).contains("non-empty string")); 262 | } 263 | 264 | #[test] 265 | fn default_outline() { 266 | let caption = json!({"text": "Test", "valign": "top"}); 267 | assert_that!(from_json::(caption)).is_ok() 268 | .map(|c| &c.outline).is_some().is_equal_to(&DEFAULT_OUTLINE_COLOR); 269 | } 270 | 271 | /// Test that the default outline color is used even when custom "color" is provided. 272 | /// 273 | /// Historically, we would invert "color" in this case, 274 | /// but this is too cumbersome to keep consistent between different ways both colors 275 | /// can be provided in ImageMacro. 276 | #[test] 277 | fn default_outline_around_non_default_color() { 278 | let caption = json!({"text": "Test", "valign": "top", "color": [0, 0, 255]}); 279 | assert_that!(from_json::(caption)).is_ok() 280 | .map(|c| &c.outline).is_some().is_equal_to(&DEFAULT_OUTLINE_COLOR); 281 | } 282 | 283 | #[test] 284 | fn outline_custom_color() { 285 | let caption = json!({"text": "Test", "valign": "top", "outline": "red"}); 286 | assert_that!(from_json::(caption)).is_ok() 287 | .map(|c| &c.outline).is_some().is_equal_to(&Color(0xff, 0x0, 0x0)); 288 | } 289 | 290 | #[test] 291 | fn outline_disabled_if_null() { 292 | let caption = json!({"text": "Test", "valign": "top", "outline": null}); 293 | assert_that!(from_json::(caption)).is_ok() 294 | .map(|c| &c.outline).is_none(); 295 | } 296 | } 297 | 298 | // TODO: tests for "size" field 299 | } 300 | -------------------------------------------------------------------------------- /src/lib/model/de/color.rs: -------------------------------------------------------------------------------- 1 | //! Deserializer for the Color type. 2 | 3 | use std::fmt; 4 | use std::str::FromStr; 5 | 6 | use css_color_parser::{Color as CssColor, ColorParseError as CssColorParseError}; 7 | use serde::de::{self, Deserialize, Visitor}; 8 | 9 | use super::super::Color; 10 | 11 | 12 | const FIELDS: &'static [&'static str] = &["r", "g", "b"]; 13 | const EXPECTING_MSG: &'static str = "CSS color string or array/map of RGB values"; 14 | 15 | 16 | impl<'de> Deserialize<'de> for Color { 17 | fn deserialize(deserializer: D) -> Result 18 | where D: de::Deserializer<'de> 19 | { 20 | deserializer.deserialize_any(ColorVisitor) 21 | } 22 | } 23 | 24 | struct ColorVisitor; 25 | impl<'de> Visitor<'de> for ColorVisitor { 26 | type Value = Color; 27 | 28 | fn expecting(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 29 | write!(fmt, "{}", EXPECTING_MSG) 30 | } 31 | 32 | fn visit_str(self, v: &str) -> Result { 33 | let color = Color::from_str(v).map_err(|e| { 34 | warn!("Failed to parse color `{}`: {}", v, e); 35 | E::custom(e) 36 | })?; 37 | Ok(color) 38 | } 39 | 40 | fn visit_seq(self, mut seq: A) -> Result 41 | where A: de::SeqAccess<'de> 42 | { 43 | // Preemptively check for length. 44 | if let Some(size) = seq.size_hint() { 45 | if size != FIELDS.len() { 46 | return Err(de::Error::invalid_length( 47 | size, &(&format!("{}", FIELDS.len()) as &str))); 48 | } 49 | } 50 | 51 | let mut channels = Vec::with_capacity(FIELDS.len()); 52 | while let Some(elem) = seq.next_element::()? { 53 | channels.push(elem); 54 | 55 | // Immediately signal any length errors. 56 | if channels.len() > FIELDS.len() { 57 | return Err(de::Error::invalid_length( 58 | channels.len(), &(&format!("{}", FIELDS.len()) as &str))); 59 | } 60 | } 61 | let mut result = channels.into_iter(); 62 | Ok(Color(result.next().unwrap(), 63 | result.next().unwrap(), 64 | result.next().unwrap())) 65 | } 66 | 67 | fn visit_map(self, mut map: A) -> Result 68 | where A: de::MapAccess<'de> 69 | { 70 | // Preemptively check for length. 71 | if let Some(size) = map.size_hint() { 72 | if size != FIELDS.len() { 73 | return Err(de::Error::invalid_length( 74 | size, &(&format!("{}", FIELDS.len()) as &str))); 75 | } 76 | } 77 | 78 | let (mut r, mut g, mut b) = (None, None, None); 79 | while let Some(key) = map.next_key::()? { 80 | let key = key.trim().to_lowercase(); 81 | match key.as_str() { 82 | // TODO: consider accepting 'r'/'g'/'b' characters as keys, too 83 | "r" | "red" => { 84 | if r.is_some() { 85 | return Err(de::Error::duplicate_field("r")); 86 | } 87 | r = Some(map.next_value()?); 88 | } 89 | "g" | "green" => { 90 | if g.is_some() { 91 | return Err(de::Error::duplicate_field("g")); 92 | } 93 | g = Some(map.next_value()?); 94 | } 95 | "b" | "blue" => { 96 | if b.is_some() { 97 | return Err(de::Error::duplicate_field("b")); 98 | } 99 | b = Some(map.next_value()?); 100 | } 101 | key => return Err(de::Error::unknown_field(key, FIELDS)), 102 | } 103 | } 104 | 105 | let r = r.ok_or_else(|| de::Error::missing_field("r"))?; 106 | let g = g.ok_or_else(|| de::Error::missing_field("g"))?; 107 | let b = b.ok_or_else(|| de::Error::missing_field("b"))?; 108 | Ok(Color(r, g, b)) 109 | } 110 | } 111 | 112 | 113 | impl FromStr for Color { 114 | type Err = ColorParseError; 115 | 116 | fn from_str(v: &str) -> Result { 117 | // Prep the string, most notably replacing all other possible hex prefixes 118 | // with the standard CSS one. 119 | let mut s = v.trim().to_lowercase(); 120 | let mut had_hex_prefix = false; 121 | for &prefix in ["#", "0x", "$"].into_iter() { 122 | if s.starts_with(prefix) { 123 | s = s.trim_left_matches(prefix).to_owned(); 124 | 125 | // If a prefix other than the standard CSS one is used, 126 | // the color has to be a full 24-bit hex number. 127 | if prefix != "#" && s.len() != 6 { 128 | return Err(ColorParseError::Css(CssColorParseError)); 129 | } 130 | 131 | had_hex_prefix = true; 132 | break; 133 | } 134 | } 135 | if had_hex_prefix { 136 | s = format!("#{}", s); 137 | } 138 | 139 | let css_color: CssColor = s.parse()?; 140 | if css_color.a != 1.0 { 141 | return Err(ColorParseError::Alpha(css_color.a)); 142 | } 143 | 144 | Ok(Color(css_color.r, css_color.g, css_color.b)) 145 | } 146 | } 147 | 148 | 149 | /// Error that may occur while deserializing the Color. 150 | #[derive(Debug, Error)] 151 | pub enum ColorParseError { 152 | /// Error while trying to parse a string as CSS color. 153 | #[error(msg = "invalid CSS color syntax")] 154 | Css(CssColorParseError), 155 | /// Error for when the color erroneously includes an alpha channel value. 156 | #[error(no_from, non_std, msg = "color transparency not supported")] 157 | Alpha(f32), 158 | } 159 | 160 | // This is necessary because css_color_parser::ColorParseError doesn't impl PartialEq, 161 | // so we cannot #[derive] that ourselves :( 162 | impl PartialEq for ColorParseError { 163 | fn eq(&self, other: &Self) -> bool { 164 | match (self, other) { 165 | (&ColorParseError::Css(_), &ColorParseError::Css(_)) => true, 166 | (&ColorParseError::Alpha(a1), &ColorParseError::Alpha(a2)) => a1 == a2, 167 | _ => false, 168 | } 169 | } 170 | } 171 | 172 | 173 | #[cfg(test)] 174 | mod tests { 175 | mod generic { 176 | use itertools::Itertools; 177 | use serde_test::{assert_de_tokens, assert_de_tokens_error, Token as T}; 178 | use super::super::{Color, EXPECTING_MSG, FIELDS}; 179 | 180 | lazy_static! { 181 | static ref EXPECTING_FIELD_MSG: String = format!("one of {}", 182 | FIELDS.iter().format_with(", ", |x, f| f(&format_args!("`{}`", x)))); 183 | } 184 | 185 | #[test] 186 | fn must_be_valid_type() { 187 | assert_de_tokens_error::( 188 | &[T::Unit], 189 | &format!("invalid type: unit value, expected {}", EXPECTING_MSG)); 190 | assert_de_tokens_error::( 191 | &[T::Bool(false)], 192 | &format!("invalid type: boolean `false`, expected {}", EXPECTING_MSG)); 193 | } 194 | 195 | #[test] 196 | fn can_be_css_color_name() { 197 | assert_de_tokens(&Color(255, 0, 0), &[T::Str("red")]); 198 | assert_de_tokens(&Color(255, 99, 71), &[T::Str("tomato")]); 199 | // Valid CSS string though. 200 | assert_de_tokens_error::(&[T::Str("uwotm8")], "invalid CSS color syntax"); 201 | } 202 | 203 | #[test] 204 | fn can_be_rgb_sequence() { 205 | assert_de_tokens(&Color(1, 2, 3), &[ 206 | T::Seq{len: Some(3)}, T::U8(1), T::U8(2), T::U8(3), T::SeqEnd]); 207 | assert_de_tokens(&Color(1, 2, 3), &[ 208 | T::Seq{len: None}, T::U8(1), T::U8(2), T::U8(3), T::SeqEnd]); 209 | assert_de_tokens(&Color(1, 2, 3), &[ 210 | T::Tuple{len: 3}, T::U8(1), T::U8(2), T::U8(3), T::TupleEnd]); 211 | // Must be exactly 3 elements. 212 | assert_de_tokens_error::(&[T::Seq{len: Some(7)}], "invalid length 7, expected 3"); 213 | assert_de_tokens_error::(&[ 214 | T::Seq{len: None}, T::U8(1), T::U8(2), T::U8(3), T::U8(4), 215 | ], "invalid length 4, expected 3"); 216 | } 217 | 218 | #[test] 219 | #[should_panic(expected = "remaining tokens")] 220 | fn cannot_be_too_long_rgb_sequence() { 221 | // This will signal error at 4th token but then serde_test will panic. 222 | assert_de_tokens_error::(&[ 223 | T::Seq{len: None}, T::U8(1), T::U8(2), T::U8(3), T::U8(4), T::U8(5), T::U8(6), 224 | ], "invalid length 4, expected 3"); 225 | } 226 | 227 | #[test] 228 | fn can_be_valid_map() { 229 | assert_de_tokens(&Color(1, 2, 3), &[ 230 | T::Map{len: None}, 231 | T::Str("r"), T::U8(1), T::Str("g"), T::U8(2), T::Str("b"), T::U8(3), 232 | T::MapEnd, 233 | ]); 234 | assert_de_tokens(&Color(1, 2, 3), &[ 235 | T::Map{len: None}, 236 | T::Str("red"), T::U8(1), T::Str("green"), T::U8(2), T::Str("blue"), T::U8(3), 237 | T::MapEnd, 238 | ]); 239 | // Mixed long/short field names are actually allowed... 240 | assert_de_tokens(&Color(1, 2, 3), &[ 241 | T::Map{len: None}, 242 | T::Str("r"), T::U8(1), T::Str("green"), T::U8(2), T::Str("b"), T::U8(3), 243 | T::MapEnd, 244 | ]); 245 | } 246 | 247 | #[test] 248 | fn cannot_be_invalid_map() { 249 | assert_de_tokens_error::( 250 | &[T::Map{len: Some(0)}], "invalid length 0, expected 3"); 251 | assert_de_tokens_error::( 252 | &[T::Map{len: None}, T::MapEnd], "missing field `r`"); 253 | assert_de_tokens_error::( 254 | &[T::Map{len: None}, T::Str("weird"), T::Str("wat")], 255 | &format!("unknown field `weird`, expected {}", *EXPECTING_FIELD_MSG)); 256 | assert_de_tokens_error::(&[ 257 | T::Map{len: None}, 258 | T::Str("r"), T::U8(255), 259 | T::Str("b"), T::U8(0), 260 | T::MapEnd, 261 | ], "missing field `g`"); 262 | assert_de_tokens_error::( 263 | &[T::Map{len: Some(5)}], "invalid length 5, expected 3"); 264 | } 265 | } 266 | 267 | mod from_str { 268 | use std::str::FromStr; 269 | use spectral::prelude::*; 270 | use super::super::{Color, ColorParseError}; 271 | 272 | #[test] 273 | fn pure_named_colors() { 274 | assert_that!(Color::from_str("black")).is_ok().is_equal_to(Color(0, 0, 0)); 275 | assert_that!(Color::from_str("white")).is_ok().is_equal_to(Color(0xff, 0xff, 0xff)); 276 | assert_that!(Color::from_str("red")).is_ok().is_equal_to(Color(0xff, 0, 0)); 277 | assert_that!(Color::from_str("lime")).is_ok().is_equal_to(Color(0, 0xff, 0)); // "green" is just half green 278 | assert_that!(Color::from_str("blue")).is_ok().is_equal_to(Color(0, 0, 0xff)); 279 | } 280 | 281 | #[test] 282 | fn common_named_colors() { 283 | assert_that!(Color::from_str("gray")).is_ok().is_equal_to(Color(0x80, 0x80, 0x80)); 284 | assert_that!(Color::from_str("silver")).is_ok().is_equal_to(Color(192, 192, 192)); 285 | assert_that!(Color::from_str("teal")).is_ok().is_equal_to(Color(0, 0x80, 0x80)); 286 | assert_that!(Color::from_str("brown")).is_ok().is_equal_to(Color(165, 42, 42)); 287 | assert_that!(Color::from_str("maroon")).is_ok().is_equal_to(Color(0x80, 0, 0)); 288 | assert_that!(Color::from_str("navy")).is_ok().is_equal_to(Color(0, 0, 0x80)); 289 | assert_that!(Color::from_str("green")).is_ok().is_equal_to(Color(0, 0x80, 0)); 290 | assert_that!(Color::from_str("magenta")).is_ok().is_equal_to(Color(0xff, 0, 0xff)); 291 | assert_that!(Color::from_str("cyan")).is_ok().is_equal_to(Color(0, 0xff, 0xff)); 292 | assert_that!(Color::from_str("yellow")).is_ok().is_equal_to(Color(0xff, 0xff, 0)); 293 | } 294 | 295 | #[test] 296 | fn exotic_named_colors() { 297 | assert_that!(Color::from_str("aquamarine")).is_ok().is_equal_to(Color(127, 255, 212)); 298 | assert_that!(Color::from_str("bisque")).is_ok().is_equal_to(Color(255, 228, 196)); 299 | assert_that!(Color::from_str("chocolate")).is_ok().is_equal_to(Color(210, 105, 30)); 300 | assert_that!(Color::from_str("crimson")).is_ok().is_equal_to(Color(220, 20, 60)); 301 | assert_that!(Color::from_str("darksalmon")).is_ok().is_equal_to(Color(233, 150, 122)); 302 | assert_that!(Color::from_str("firebrick")).is_ok().is_equal_to(Color(178, 34, 34)); 303 | assert_that!(Color::from_str("ivory")).is_ok().is_equal_to(Color(255, 255, 240)); 304 | assert_that!(Color::from_str("lavender")).is_ok().is_equal_to(Color(230, 230, 250)); 305 | assert_that!(Color::from_str("lightsteelblue")).is_ok().is_equal_to(Color(176, 196, 222)); 306 | assert_that!(Color::from_str("mediumseagreen")).is_ok().is_equal_to(Color(60, 179, 113)); 307 | assert_that!(Color::from_str("paleturquoise")).is_ok().is_equal_to(Color(175, 238, 238)); 308 | assert_that!(Color::from_str("sienna")).is_ok().is_equal_to(Color(160, 82, 45)); 309 | assert_that!(Color::from_str("tomato")).is_ok().is_equal_to(Color(255, 99, 71)); 310 | assert_that!(Color::from_str("wheat")).is_ok().is_equal_to(Color(245, 222, 179)); 311 | assert_that!(Color::from_str("yellowgreen")).is_ok().is_equal_to(Color(154, 205, 50)); 312 | // ...and that's not even all of them! 313 | } 314 | 315 | #[test] 316 | fn html_rgb() { 317 | assert_that!(Color::from_str("#0f0")).is_ok().is_equal_to(Color(0, 0xff, 0)); 318 | assert_that!(Color::from_str("#00ff00")).is_ok().is_equal_to(Color(0, 0xff, 0)); 319 | assert_that!(Color::from_str("0xff0000")).is_ok().is_equal_to(Color(0xff, 0, 0)); 320 | assert_that!(Color::from_str("$0000ff")).is_ok().is_equal_to(Color(0, 0, 0xff)); 321 | // These are forbidden because it's unclear what they would mean. 322 | assert_that!(Color::from_str("0xf0f")).is_err(); 323 | assert_that!(Color::from_str("$ff0")).is_err(); 324 | // Multiple prefixes are NOT cleared. 325 | assert_that!(Color::from_str("$0x00ffff")).is_err(); 326 | // We do need a prefix though (otherwise it's ambiguous if it's hex or name). 327 | assert_that!(Color::from_str("f0f0f0")).is_err(); 328 | } 329 | 330 | #[test] 331 | fn transparency_not_supported() { 332 | assert_that!(Color::from_str("transparent")) 333 | .is_err().is_equal_to(ColorParseError::Alpha(0.0)); 334 | assert_that!(Color::from_str("rgba(0, 0, 0, 0.5)")) 335 | .is_err().is_equal_to(ColorParseError::Alpha(0.5)); 336 | } 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /src/lib/model/de/mod.rs: -------------------------------------------------------------------------------- 1 | //! Deserializers for data model types. 2 | 3 | mod caption; 4 | mod color; 5 | mod image_macro; 6 | mod size; 7 | 8 | #[cfg(test)] 9 | mod tests; 10 | -------------------------------------------------------------------------------- /src/lib/model/de/size.rs: -------------------------------------------------------------------------------- 1 | //! Deserializer for the Size type. 2 | 3 | use std::fmt; 4 | use std::str::FromStr; 5 | 6 | use conv::errors::Unrepresentable; 7 | use serde::de::{self, Deserialize, Unexpected, Visitor}; 8 | 9 | use super::super::Size; 10 | 11 | 12 | const EXPECTING_MSG: &'static str = "numeric size, \"shrink\" or \"fit\""; 13 | 14 | 15 | impl<'de> Deserialize<'de> for Size { 16 | fn deserialize(deserializer: D) -> Result 17 | where D: de::Deserializer<'de> 18 | { 19 | deserializer.deserialize_any(SizeVisitor) 20 | } 21 | } 22 | 23 | struct SizeVisitor; 24 | impl<'de> Visitor<'de> for SizeVisitor { 25 | type Value = Size; 26 | 27 | fn expecting(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 28 | write!(fmt, "{}", EXPECTING_MSG) 29 | } 30 | 31 | fn visit_str(self, v: &str) -> Result { 32 | let size = Size::from_str(v).map_err(|_| { 33 | warn!("Failed to parse size `{}`", v); 34 | E::invalid_value(Unexpected::Str(v), &self) 35 | })?; 36 | Ok(size) 37 | } 38 | 39 | fn visit_f32(self, v: f32) -> Result { 40 | Ok(Size::from(v)) 41 | } 42 | 43 | fn visit_f64(self, v: f64) -> Result { 44 | let v32 = v as f32; 45 | if (v32 as f64) < v { 46 | warn!("Clamping the size float value from {} (64-bit) to {} (32-bit)", 47 | v, v32); 48 | } 49 | Ok(Size::from(v32)) 50 | } 51 | 52 | // Other numeric visitor methods that delegate to the ones above. 53 | fn visit_i8(self, v: i8) -> Result { 54 | self.visit_f32(v as f32) 55 | } 56 | fn visit_i16(self, v: i16) -> Result { 57 | self.visit_f32(v as f32) 58 | } 59 | fn visit_i32(self, v: i32) -> Result { 60 | self.visit_f32(v as f32) 61 | } 62 | fn visit_i64(self, v: i64) -> Result { 63 | self.visit_f64(v as f64) 64 | } 65 | fn visit_u8(self, v: u8) -> Result { 66 | self.visit_f32(v as f32) 67 | } 68 | fn visit_u16(self, v: u16) -> Result { 69 | self.visit_f32(v as f32) 70 | } 71 | fn visit_u32(self, v: u32) -> Result { 72 | self.visit_f32(v as f32) 73 | } 74 | fn visit_u64(self, v: u64) -> Result { 75 | self.visit_f64(v as f64) 76 | } 77 | } 78 | 79 | 80 | impl FromStr for Size { 81 | type Err = Unrepresentable; 82 | 83 | fn from_str(v: &str) -> Result { 84 | match v.trim().to_lowercase().as_str() { 85 | "shrink" => Ok(Size::Shrink), 86 | "fit" | "flex" => Ok(Size::Fit), 87 | // We can allow stringified numbers too, 88 | // just don't have it mentioned anywhere :) 89 | s => s.parse::().map(Into::into) 90 | .map_err(|_| Unrepresentable(v.to_owned())), 91 | } 92 | } 93 | } 94 | 95 | 96 | // TODO: tests, like in super::color 97 | -------------------------------------------------------------------------------- /src/lib/model/de/tests/json.rs: -------------------------------------------------------------------------------- 1 | //! Tests for deserializing complete ImageMacro specs from JSON. 2 | 3 | use serde_json::{self, from_value as from_json, Value}; 4 | use spectral::prelude::*; 5 | 6 | use model::{Caption, Color, HAlign, ImageMacro, VAlign}; 7 | 8 | 9 | #[test] 10 | fn just_template() { 11 | let input = json!({"template": "zoidberg"}); 12 | let expected = ImageMacro{ 13 | template: "zoidberg".into(), 14 | ..Default::default() 15 | }; 16 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 17 | } 18 | 19 | #[test] 20 | fn scaled_template() { 21 | let input = json!({ 22 | "template": "zoidberg", 23 | "width": 640, 24 | "height": 480, 25 | }); 26 | let expected = ImageMacro{ 27 | template: "zoidberg".into(), 28 | width: Some(640), 29 | height: Some(480), 30 | ..Default::default() 31 | }; 32 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 33 | } 34 | 35 | #[test] 36 | fn one_simple_caption() { 37 | let input = json!({ 38 | "template": "grumpycat", 39 | "bottom_text": "No.", 40 | }); 41 | let expected = ImageMacro{ 42 | template: "grumpycat".into(), 43 | captions: vec![ 44 | Caption{ 45 | text: "No.".into(), 46 | ..Caption::at(VAlign::Bottom) 47 | }, 48 | ], 49 | ..Default::default() 50 | }; 51 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 52 | } 53 | 54 | #[test] 55 | fn several_simple_captions() { 56 | let input = json!({ 57 | "template": "zoidberg", 58 | "top_text": "Need more text?", 59 | "bottom_text": "Why not Zoidberg?", 60 | }); 61 | let expected = ImageMacro{ 62 | template: "zoidberg".into(), 63 | captions: vec![ 64 | Caption{ 65 | text: "Need more text?".into(), 66 | ..Caption::at(VAlign::Top) 67 | }, 68 | Caption{ 69 | text: "Why not Zoidberg?".into(), 70 | ..Caption::at(VAlign::Bottom) 71 | }, 72 | ], 73 | ..Default::default() 74 | }; 75 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 76 | } 77 | 78 | #[test] 79 | fn simple_caption_with_invalid_alignment() { 80 | let input = json!({ 81 | "template": "firstworldproblems", 82 | "top_text": "My meme text", 83 | "bottom_text": "is not aligned correctly", 84 | "bottom_align": "justify", 85 | }); 86 | assert_that!(parse(input)).is_err().map(|e| { 87 | let msg = format!("{}", e); 88 | assert_that!(msg).contains("unknown variant"); 89 | assert_that!(msg).contains("justify"); 90 | e 91 | }); 92 | } 93 | 94 | #[test] 95 | fn simple_captions_with_alignment() { 96 | let input = json!({ 97 | "template": "doge", 98 | "top_text": "much aligned", 99 | "top_align": "left", 100 | "middle_text": "very text", 101 | "middle_align": "right", 102 | "bottom_text": "wow", 103 | "bottom_align": "center", 104 | }); 105 | let expected = ImageMacro{ 106 | template: "doge".into(), 107 | captions: vec![ 108 | Caption{ 109 | text: "much aligned".into(), 110 | halign: HAlign::Left, 111 | ..Caption::at(VAlign::Top) 112 | }, 113 | Caption{ 114 | text: "very text".into(), 115 | halign: HAlign::Right, 116 | ..Caption::at(VAlign::Middle) 117 | }, 118 | Caption{ 119 | text: "wow".into(), 120 | halign: HAlign::Center, 121 | ..Caption::at(VAlign::Bottom) 122 | }, 123 | ], 124 | ..Default::default() 125 | }; 126 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 127 | } 128 | 129 | #[test] 130 | fn simple_captions_with_color() { 131 | let input = json!({ 132 | "template": "doge", 133 | "top_text": "very color", 134 | "top_color": "red", 135 | "middle_text": "much rgb", 136 | "middle_color": "rgb(0,255,255)", 137 | "bottom_text": "wow", 138 | "bottom_color": "lime", 139 | }); 140 | let expected = ImageMacro{ 141 | template: "doge".into(), 142 | captions: vec![ 143 | Caption{ 144 | text: "very color".into(), 145 | color: Color(0xff, 0, 0), 146 | ..Caption::at(VAlign::Top) 147 | }, 148 | Caption{ 149 | text: "much rgb".into(), 150 | color: Color(0, 0xff, 0xff), 151 | ..Caption::at(VAlign::Middle) 152 | }, 153 | Caption{ 154 | text: "wow".into(), 155 | color: Color(0, 0xff, 0), 156 | ..Caption::at(VAlign::Bottom) 157 | }, 158 | ], 159 | ..Default::default() 160 | }; 161 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 162 | } 163 | 164 | #[test] 165 | fn simple_captions_without_outline() { 166 | let input = json!({ 167 | "template": "grumpycat", 168 | "top_text": "Outline?", 169 | "top_outline": null, 170 | "bottom_text": "No.", 171 | "bottom_outline": null, 172 | }); 173 | let expected = ImageMacro{ 174 | template: "grumpycat".into(), 175 | captions: vec![ 176 | Caption{ 177 | text: "Outline?".into(), 178 | outline: None, 179 | ..Caption::at(VAlign::Top) 180 | }, 181 | Caption{ 182 | text: "No.".into(), 183 | outline: None, 184 | ..Caption::at(VAlign::Bottom) 185 | }, 186 | ], 187 | ..Default::default() 188 | }; 189 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 190 | } 191 | 192 | #[test] 193 | fn custom_font_for_simple_captions() { 194 | let input = json!({ 195 | "template": "grumpycat", 196 | "font": "Comic Sans", 197 | "top_text": "No.", 198 | "bottom_text": "Just no.", 199 | }); 200 | let expected = ImageMacro{ 201 | template: "grumpycat".into(), 202 | captions: vec![ 203 | Caption{ 204 | text: "No.".into(), 205 | font: "Comic Sans".into(), 206 | ..Caption::at(VAlign::Top) 207 | }, 208 | Caption{ 209 | text: "Just no.".into(), 210 | font: "Comic Sans".into(), 211 | ..Caption::at(VAlign::Bottom) 212 | }, 213 | ], 214 | ..Default::default() 215 | }; 216 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 217 | } 218 | 219 | #[test] 220 | fn custom_color_for_simple_captions() { 221 | let input = json!({ 222 | "template": "boromir", 223 | "color": "black", 224 | "top_text": "One does not simply", 225 | "bottom_text": "make a meme", 226 | }); 227 | let expected = ImageMacro{ 228 | template: "boromir".into(), 229 | captions: vec![ 230 | Caption{ 231 | text: "One does not simply".into(), 232 | color: Color(0, 0, 0), 233 | ..Caption::at(VAlign::Top) 234 | }, 235 | Caption{ 236 | text: "make a meme".into(), 237 | color: Color(0, 0, 0), 238 | ..Caption::at(VAlign::Bottom) 239 | }, 240 | ], 241 | ..Default::default() 242 | }; 243 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 244 | } 245 | 246 | #[test] 247 | fn no_outline_for_simple_captions() { 248 | let input = json!({ 249 | "template": "y_u_no", 250 | "outline": null, 251 | "top_text": "Y U no", 252 | "bottom_text": "draw a text border", 253 | }); 254 | let expected = ImageMacro{ 255 | template: "y_u_no".into(), 256 | captions: vec![ 257 | Caption{ 258 | text: "Y U no".into(), 259 | outline: None, 260 | ..Caption::at(VAlign::Top) 261 | }, 262 | Caption{ 263 | text: "draw a text border".into(), 264 | outline: None, 265 | ..Caption::at(VAlign::Bottom) 266 | }, 267 | ], 268 | ..Default::default() 269 | }; 270 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 271 | } 272 | 273 | #[test] 274 | fn custom_outline_for_simple_captions() { 275 | let input = json!({ 276 | "template": "yodawg", 277 | "outline": "blue", 278 | "top_text": "Yo dawg, I heard you like colors", 279 | "bottom_text": "so I put a colored text in a colored outline", 280 | }); 281 | let expected = ImageMacro{ 282 | template: "yodawg".into(), 283 | captions: vec![ 284 | Caption{ 285 | text: "Yo dawg, I heard you like colors".into(), 286 | outline: Some(Color(0, 0, 0xff)), 287 | ..Caption::at(VAlign::Top) 288 | }, 289 | Caption{ 290 | text: "so I put a colored text in a colored outline".into(), 291 | outline: Some(Color(0, 0, 0xff)), 292 | ..Caption::at(VAlign::Bottom) 293 | }, 294 | ], 295 | ..Default::default() 296 | }; 297 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 298 | } 299 | 300 | #[test] 301 | fn empty_full_captions() { 302 | let input = json!({ 303 | "template": "anditsgone", 304 | "captions": [], 305 | }); 306 | let expected = ImageMacro{ 307 | template: "anditsgone".into(), 308 | captions: vec![], 309 | ..Default::default() 310 | }; 311 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 312 | } 313 | 314 | #[test] 315 | fn full_captions_with_just_text() { 316 | let input = json!({ 317 | "template": "slowpoke", 318 | "captions": ["Hey guys", "Have you heard about this meme thing?"], 319 | }); 320 | let expected = ImageMacro{ 321 | template: "slowpoke".into(), 322 | captions: vec![ 323 | Caption{ 324 | text: "Hey guys".into(), 325 | ..Caption::at(VAlign::Top) 326 | }, 327 | Caption{ 328 | text: "Have you heard about this meme thing?".into(), 329 | ..Caption::at(VAlign::Bottom) 330 | } 331 | ], 332 | ..Default::default() 333 | }; 334 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 335 | } 336 | 337 | #[test] 338 | fn too_many_full_captions_with_just_text() { 339 | let input = json!({ 340 | "template": "firstworldproblems", 341 | "captions": [ 342 | "My meme generator", 343 | "seems to be pretty limited", 344 | "and it cannot automatically infer", 345 | "where to put", 346 | "all those captions", 347 | "without further hints.", 348 | ], 349 | }); 350 | assert_that!(parse(input)).is_err().map(|e| { 351 | let msg = format!("{}", e); 352 | assert_that!(msg).contains("invalid length"); 353 | for allowed in ["0", "1", "2", "3"].iter() { 354 | assert_that!(msg).contains(allowed); 355 | } 356 | e 357 | }); 358 | } 359 | 360 | #[test] 361 | fn full_captions_with_parameters() { 362 | let input = json!({ 363 | "template": "philosoraptor", 364 | "captions": [ 365 | { 366 | "valign": "top", 367 | "text": "If you communicate with memes", 368 | "halign": "center", 369 | }, 370 | { 371 | "valign": "bottom", 372 | "text": "is it called comemecation?", 373 | "halign": "center", 374 | }, 375 | ], 376 | }); 377 | let expected = ImageMacro{ 378 | template: "philosoraptor".into(), 379 | captions: vec![ 380 | Caption{ 381 | text: "If you communicate with memes".into(), 382 | halign: HAlign::Center, 383 | ..Caption::at(VAlign::Top) 384 | }, 385 | Caption{ 386 | text: "is it called comemecation?".into(), 387 | halign: HAlign::Center, 388 | ..Caption::at(VAlign::Bottom) 389 | }, 390 | ], 391 | ..Default::default() 392 | }; 393 | assert_that!(parse(input)).is_ok().is_equal_to(expected); 394 | } 395 | 396 | #[test] 397 | fn mixed_full_captions() { 398 | let input = json!({ 399 | "template": "asianfather", 400 | "captions": [ 401 | "Meme with text?", 402 | { 403 | "text": "Why not meme with aligned text?", 404 | "valign": "bottom", 405 | "halign": "center", 406 | } 407 | ], 408 | }); 409 | assert_that!(parse(input)).is_err().map(|e| { 410 | let msg = format!("{}", e); 411 | assert_that!(msg).contains("captions"); 412 | assert_that!(msg).contains("must be either"); 413 | assert_that!(msg).contains("or"); 414 | assert_that!(msg).contains("all"); 415 | e 416 | }); 417 | } 418 | 419 | 420 | // Utility functions 421 | 422 | fn parse(json: Value) -> Result { 423 | // This function may seem pointless, but it saves us on using turbofish everywhere 424 | // to tell the compiler it's ImageMacro we're deserializing. 425 | from_json(json) 426 | } 427 | -------------------------------------------------------------------------------- /src/lib/model/de/tests/mod.rs: -------------------------------------------------------------------------------- 1 | //! "End-to"end" tests for deserializing ImageMacros from all supported input formats. 2 | 3 | mod json; 4 | mod qs; 5 | 6 | // TODO: add generic tests for Deserialize impl of ImageMacro using serde_test 7 | -------------------------------------------------------------------------------- /src/lib/model/de/tests/qs.rs: -------------------------------------------------------------------------------- 1 | //! Tests for deserializing complete ImageMacros from query strings. 2 | 3 | use serde_qs::{self, from_str as from_qs}; 4 | use spectral::prelude::*; 5 | 6 | use model::{Caption, Color, ImageMacro, VAlign}; 7 | 8 | 9 | #[test] 10 | fn simple_captions() { 11 | let input = "template=zoidberg&top_text=Need%20a%20meme?&bottom_text=Why%20not%20Zoidberg?"; 12 | assert_that!(parse(input)).is_ok().is_equal_to(&*ZOIDBERG); 13 | } 14 | 15 | #[test] 16 | fn simple_captions_with_color() { 17 | let input = "template=fullofstars&\ 18 | top_text=Oh%20my%20god&top_color=0xffff00&\ 19 | bottom_text=It%27s%20full%20of%20colors&bottom_color=0x00ffff"; 20 | assert_that!(parse(input)).is_ok().is_equal_to(&*FULL_OF_COLORS); 21 | } 22 | 23 | #[test] 24 | fn full_captions_with_just_text() { 25 | let input = "template=zoidberg&captions[0]=Need%20a%20meme?&captions[1]=Why%20not%20Zoidberg?"; 26 | assert_that!(parse(input)).is_ok().is_equal_to(&*ZOIDBERG); 27 | } 28 | 29 | #[test] 30 | fn full_captions_with_valign() { 31 | let input = "template=zoidberg&\ 32 | captions[0][valign]=top&captions[0][text]=Need%20a%20meme?&\ 33 | captions[1][valign]=bottom&captions[1][text]=Why%20not%20Zoidberg?"; 34 | assert_that!(parse(input)).is_ok().is_equal_to(&*ZOIDBERG); 35 | } 36 | 37 | #[test] 38 | fn full_captions_with_valign_and_color() { 39 | let input = "template=fullofstars&\ 40 | captions[0][text]=Oh%20my%20god&\ 41 | captions[0][color]=0xffff00&captions[0][valign]=top&\ 42 | captions[1][text]=It%27s%20full%20of%20colors&\ 43 | captions[1][color]=0x00ffff&captions[1][valign]=bottom"; 44 | assert_that!(parse(input)).is_ok().is_equal_to(&*FULL_OF_COLORS); 45 | } 46 | 47 | #[test] 48 | fn caption_text_with_ampersand() { 49 | // The ampersand is of course URL-encoded (as %26). 50 | let input = "template=zoidberg?top_text=Need%20a%20meme%20%26%20text?"; 51 | assert_that!(parse(input)).is_ok(); 52 | } 53 | 54 | 55 | // Common test data 56 | 57 | lazy_static! { 58 | static ref ZOIDBERG: ImageMacro = ImageMacro{ 59 | template: "zoidberg".into(), 60 | captions: vec![ 61 | Caption{ 62 | text: "Need a meme?".into(), 63 | ..Caption::at(VAlign::Top) 64 | }, 65 | Caption{ 66 | text: "Why not Zoidberg?".into(), 67 | ..Caption::at(VAlign::Bottom) 68 | }, 69 | ], 70 | ..Default::default() 71 | }; 72 | 73 | static ref FULL_OF_COLORS: ImageMacro = ImageMacro{ 74 | template: "fullofstars".into(), 75 | captions: vec![ 76 | Caption{ 77 | text: "Oh my god".into(), 78 | color: Color(255, 255, 0), 79 | ..Caption::at(VAlign::Top) 80 | }, 81 | Caption{ 82 | text: "It's full of colors".into(), 83 | color: Color(0, 255, 255), 84 | ..Caption::at(VAlign::Bottom) 85 | }, 86 | ], 87 | ..Default::default() 88 | }; 89 | } 90 | 91 | 92 | // Utility functions 93 | 94 | fn parse(qs: &str) -> Result { 95 | // This function may seem pointless, but it saves us on using turbofish everywhere 96 | // to tell the compiler it's ImageMacro we're deserializing. 97 | from_qs(qs) 98 | } 99 | -------------------------------------------------------------------------------- /src/lib/model/mod.rs: -------------------------------------------------------------------------------- 1 | //! Module defining the data model. 2 | 3 | mod constants; 4 | mod de; 5 | mod types; 6 | 7 | pub use self::constants::*; 8 | pub use self::types::*; 9 | -------------------------------------------------------------------------------- /src/lib/model/types/align.rs: -------------------------------------------------------------------------------- 1 | //! Module defining the alignment enums. 2 | 3 | #![allow(missing_docs)] // Because IterVariants! produces undocumented methods. 4 | 5 | 6 | macro_attr! { 7 | /// Horizontal alignment of text within a rectangle. 8 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, 9 | Deserialize, IterVariants!(HAligns))] 10 | #[serde(rename_all = "lowercase")] 11 | pub enum HAlign { 12 | /// Left alignment. 13 | Left, 14 | /// Horizontal centering. 15 | Center, 16 | /// Right alignment. 17 | Right, 18 | } 19 | } 20 | 21 | macro_attr! { 22 | /// Vertical alignment of text within a rectangle. 23 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, 24 | Deserialize, IterVariants!(VAligns))] 25 | #[serde(rename_all = "lowercase")] 26 | pub enum VAlign { 27 | /// Top alignment. 28 | Top, 29 | /// Vertical centering. 30 | Middle, 31 | /// Bottom alignment. 32 | Bottom, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/model/types/caption.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing the `Caption` type and its builder. 2 | 3 | use std::error; 4 | use std::fmt; 5 | 6 | use model::constants::{DEFAULT_COLOR, DEFAULT_HALIGN, DEFAULT_FONT, 7 | DEFAULT_OUTLINE_COLOR, DEFAULT_TEXT_SIZE, 8 | MAX_CAPTION_LENGTH}; 9 | use super::align::{HAlign, VAlign}; 10 | use super::color::Color; 11 | use super::size::Size; 12 | 13 | 14 | /// Describes a single piece of text rendered on the image macro. 15 | /// 16 | /// Use the provided `Caption::text_at` method to create it 17 | /// with most of the fields set to default values. 18 | #[derive(Builder, Clone, Eq, PartialEq)] 19 | #[builder(derive(Debug, Eq, PartialEq), 20 | pattern = "owned", build_fn(skip))] 21 | pub struct Caption { 22 | /// Text to render. 23 | /// 24 | /// Newline characters (`"\n"`) cause the text to wrap. 25 | pub text: String, 26 | /// Horizontal alignment of the caption within the template rectangle. 27 | /// Default is `HAlign::Center`. 28 | pub halign: HAlign, 29 | /// Vertical alignment of the caption within the template rectangle. 30 | pub valign: VAlign, 31 | /// Name of the font to render the caption with. Defaults to `"Impact"`. 32 | pub font: String, // TODO: this could be a Cow, but needs lifetime param 33 | /// Text color, defaults to white. 34 | pub color: Color, 35 | /// Text of the color outline, if any. Defaults to black. 36 | /// 37 | /// Pass `None` to draw the text without an outline. 38 | pub outline: Option, 39 | /// Caption text size. 40 | pub size: Size, 41 | } 42 | 43 | impl Caption { 44 | /// Create an empty Caption at the particular vertical alignment. 45 | #[inline] 46 | pub fn at(valign: VAlign) -> Self { 47 | CaptionBuilder::default() 48 | .valign(valign) 49 | .build() 50 | .expect("Caption::at") 51 | } 52 | 53 | /// Create a Caption with a text at the particular vertical alignment. 54 | #[inline] 55 | pub fn text_at>(valign: VAlign, s: S) -> Self { 56 | CaptionBuilder::default() 57 | .valign(valign).text(s.into()) 58 | .build() 59 | .expect("Caption::text_at") 60 | } 61 | } 62 | 63 | impl fmt::Debug for Caption { 64 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 65 | write!(fmt, "{valign:?}{halign:?}{{{font:?} {outline}[{color}]}}({text:?})@{size}", 66 | text = self.text, 67 | halign = self.halign, 68 | valign = self.valign, 69 | font = self.font, 70 | color = self.color, 71 | outline = self.outline.map(|o| format!("{}", o)).unwrap_or_else(String::new), 72 | size = self.size.as_number().map(|s| format!("{}", s.floor())).unwrap_or_else(|| "s".into())) 73 | } 74 | } 75 | 76 | 77 | impl CaptionBuilder { 78 | /// Create a new `Builder` for a `Caption`. 79 | #[inline] 80 | pub fn new() -> Self { 81 | Self::default() 82 | } 83 | } 84 | 85 | impl CaptionBuilder { 86 | /// Build the resulting `Caption`. 87 | pub fn build(self) -> Result { 88 | self.validate()?; 89 | Ok(Caption{ 90 | // Note that we can't use #[builder(default)] if we override the build() 91 | // method with #[builder(build_fn)], which is why we have to put the defaults here. 92 | text: self.text.unwrap_or_else(String::new), 93 | halign: self.halign.unwrap_or(DEFAULT_HALIGN), 94 | valign: self.valign.unwrap(), // mandatory 95 | font: self.font.unwrap_or_else(|| DEFAULT_FONT.into()), 96 | color: self.color.unwrap_or(DEFAULT_COLOR), 97 | outline: self.outline.unwrap_or(Some(DEFAULT_OUTLINE_COLOR)), 98 | size: self.size.unwrap_or_else(|| DEFAULT_TEXT_SIZE.into()), 99 | }) 100 | } 101 | 102 | #[doc(hidden)] 103 | fn validate(&self) -> Result<(), Error> { 104 | if let Some(ref text) = self.text { 105 | if text.len() > MAX_CAPTION_LENGTH { 106 | return Err(Error::TooLong(text.len())); 107 | } 108 | } 109 | if self.valign.is_none() { 110 | return Err(Error::NoVerticalAlign); 111 | } 112 | if let Some(Size::Fixed(s)) = self.size { 113 | if s < 0.0 { 114 | return Err(Error::NegativeSize(s)); 115 | } 116 | } 117 | Ok(()) 118 | } 119 | } 120 | 121 | 122 | /// Error while building a `Caption`. 123 | #[derive(Clone, Debug)] 124 | pub enum Error { 125 | /// No vertical alignment given. 126 | NoVerticalAlign, 127 | /// Caption text too long. 128 | TooLong(usize), 129 | /// Negative text size. 130 | NegativeSize(f32), 131 | } 132 | 133 | impl error::Error for Error { 134 | fn description(&self) -> &str { "Caption creation error" } 135 | fn cause(&self) -> Option<&error::Error> { None } 136 | } 137 | 138 | impl fmt::Display for Error { 139 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 140 | match *self { 141 | Error::NoVerticalAlign => write!(fmt, "no vertical alignment chosen"), 142 | Error::TooLong(l) => write!(fmt, "caption text too long: {} > {}", 143 | l, MAX_CAPTION_LENGTH), 144 | Error::NegativeSize(s) => write!(fmt, "text size cannot be negative (got {})", s), 145 | } 146 | } 147 | } 148 | 149 | 150 | #[cfg(test)] 151 | mod tests { 152 | use model::{VAlign}; 153 | use super::Caption; 154 | 155 | #[test] 156 | fn text_at() { 157 | let cap = Caption::text_at(VAlign::Top, "Test"); 158 | assert_eq!(VAlign::Top, cap.valign); 159 | assert_eq!("Test", cap.text); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/lib/model/types/color.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing the `Color` type. 2 | 3 | use std::fmt; 4 | 5 | use image::{Rgb, Rgba}; 6 | 7 | 8 | /// RGB color of the text. 9 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] 10 | pub struct Color(pub u8, pub u8, pub u8); 11 | 12 | impl Color { 13 | /// Create a white color. 14 | #[inline] 15 | pub fn white() -> Self { 16 | Self::gray(0xff) 17 | } 18 | 19 | /// Create a black color. 20 | #[inline] 21 | pub fn black() -> Self { 22 | Self::gray(0xff) 23 | } 24 | 25 | /// Create a gray color of given intensity. 26 | #[inline] 27 | pub fn gray(value: u8) -> Self { 28 | Color(value, value, value) 29 | } 30 | } 31 | 32 | impl Color { 33 | /// Convert the color to its chromatic inverse. 34 | #[inline] 35 | pub fn invert(self) -> Self { 36 | let Color(r, g, b) = self; 37 | Color(0xff - r, 0xff - g, 0xff - b) 38 | } 39 | 40 | #[inline] 41 | pub(crate) fn to_rgb(&self) -> Rgb { 42 | let &Color(r, g, b) = self; 43 | Rgb{data: [r, g, b]} 44 | } 45 | 46 | #[inline] 47 | pub(crate) fn to_rgba(&self, alpha: u8) -> Rgba { 48 | let &Color(r, g, b) = self; 49 | Rgba{data: [r, g, b, alpha]} 50 | } 51 | } 52 | 53 | impl From for Rgb { 54 | #[inline] 55 | fn from(color: Color) -> Rgb { 56 | color.to_rgb() 57 | } 58 | } 59 | 60 | impl fmt::Display for Color { 61 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 62 | let &Color(r, g, b) = self; 63 | write!(fmt, "#{:0>2x}{:0>2x}{:0>2x}", r, g, b) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/model/types/image_macro.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing the `ImageMacro` type and its builder. 2 | 3 | use std::error; 4 | use std::fmt; 5 | 6 | use model::constants::{MAX_CAPTION_COUNT, MAX_CAPTION_LENGTH, MAX_HEIGHT, MAX_WIDTH}; 7 | use super::align::{HAlign, VAlign}; 8 | use super::caption::Caption; 9 | 10 | 11 | /// Describes an image macro. Used as an input structure. 12 | /// 13 | /// *Note*: If `width` or `height` is provided, the result will be resized 14 | /// whilst preserving the original aspect ratio of the template. 15 | /// This means the final size of the image may be smaller than requested. 16 | #[derive(Clone, Default, Eq)] 17 | pub struct ImageMacro { 18 | /// Name of the template used by this image macro. 19 | pub template: String, 20 | /// Width of the rendered macro (if it is to be different from the template). 21 | pub width: Option, 22 | /// Height of the rendered macro (if it is to be different from the template). 23 | pub height: Option, 24 | /// Text captions to render over the template. 25 | pub captions: Vec, 26 | } 27 | 28 | impl ImageMacro { 29 | /// Whether the image macro includes any text. 30 | #[inline] 31 | pub fn has_text(&self) -> bool { 32 | self.captions.len() > 0 && self.captions.iter().any(|c| !c.text.is_empty()) 33 | } 34 | } 35 | 36 | impl PartialEq for ImageMacro { 37 | /// Check equality with another ImageMacro. 38 | /// This is implemented not to take the order of Captions into account. 39 | fn eq(&self, other: &Self) -> bool { 40 | self.template == other.template && 41 | self.width == other.width && 42 | self.height == other.height && 43 | // O(n^2), I know. 44 | self.captions.iter().all(|c1| other.captions.iter().any(|c2| c1 == c2)) 45 | // TODO: consider implementing captions as HashSet for this reason 46 | } 47 | } 48 | 49 | impl fmt::Debug for ImageMacro { 50 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 51 | let mut ds = fmt.debug_struct("ImageMacro"); 52 | ds.field("template", &self.template); 53 | 54 | macro_rules! fmt_opt_field { 55 | ($name:ident) => ( 56 | if let Some(ref $name) = self.$name { 57 | ds.field(stringify!($name), $name); 58 | } 59 | ); 60 | } 61 | fmt_opt_field!(width); 62 | fmt_opt_field!(height); 63 | 64 | if self.captions.len() > 0 { 65 | ds.field("captions", &self.captions); 66 | } 67 | 68 | ds.finish() 69 | } 70 | } 71 | 72 | 73 | /// Builder for `ImageMacro`. 74 | #[derive(Debug, Default, PartialEq)] 75 | #[must_use = "unused builder which must be used"] 76 | pub struct Builder { 77 | template: Option, 78 | width: Option, 79 | height: Option, 80 | captions: Vec, 81 | } 82 | 83 | impl Builder { 84 | /// Create a new `Builder` for an `ImageMacro`. 85 | #[inline] 86 | pub fn new() -> Self { 87 | Self::default() 88 | } 89 | } 90 | 91 | impl Builder { 92 | /// Set the template used by resulting `ImageMacro`. 93 | #[inline] 94 | pub fn template>(mut self, template: S) -> Self { 95 | self.template = Some(template.into()); self 96 | } 97 | 98 | /// Change the width of the resulting image macro. 99 | /// 100 | /// Note that any resizing done during rendering of the result `ImageMacro` 101 | /// (whether due to custom `width` or `height`) will preserve 102 | /// the original aspect of the template. 103 | /// 104 | /// By default, the width of the template will be used. 105 | #[inline] 106 | pub fn width(mut self, width: u32) -> Self { 107 | self.width = Some(width); self 108 | } 109 | 110 | /// Reset the `ImageMacro` width back to the width of the template. 111 | #[inline] 112 | pub fn clear_width(mut self) -> Self { 113 | self.width = None; self 114 | } 115 | 116 | /// Change the height of the resulting image macro. 117 | /// 118 | /// Note that any resizing done during rendering of the result `ImageMacro` 119 | /// (whether due to custom `width` or `height`) will preserve 120 | /// the original aspect of the template. 121 | /// 122 | /// By default, the height of the template will be used. 123 | #[inline] 124 | pub fn height(mut self, height: u32) -> Self { 125 | self.height = Some(height); self 126 | } 127 | 128 | /// Reset the `ImageMacro` height back to the height of the template. 129 | #[inline] 130 | pub fn clear_height(mut self) -> Self { 131 | self.height = None; self 132 | } 133 | } 134 | 135 | // Captioning interface. 136 | impl Builder { 137 | /// Add a `Caption` to the resulting `ImageMacro`. 138 | #[inline] 139 | pub fn caption(mut self, caption: Caption) -> Self { 140 | self.captions.push(caption); self 141 | } 142 | 143 | /// Add a caption with given text and alignment to the resulting `ImageMacro`. 144 | #[inline] 145 | pub fn text_at>(self, valign: VAlign, halign: HAlign, text: S) -> Self { 146 | self.caption(Caption { 147 | halign: halign, 148 | ..Caption::text_at(valign, text) 149 | }) 150 | } 151 | 152 | /// Add a centered text caption of given vertical alignment to the `ImageMacro`. 153 | #[inline] 154 | pub fn centered_text_at>(self, valign: VAlign, text: S) -> Self { 155 | self.caption(Caption::text_at(valign, text)) 156 | } 157 | 158 | // TODO: top_text, middle_text, bottom_text (with halign center) 159 | // TODO: top_left_text, top_center_text, etc. 160 | } 161 | 162 | impl Builder { 163 | /// Build the resulting `ImageMacro`. 164 | #[inline] 165 | pub fn build(self) -> Result { 166 | self.validate()?; 167 | Ok(ImageMacro{ 168 | template: self.template.unwrap(), 169 | width: self.width, 170 | height: self.height, 171 | captions: self.captions, 172 | }) 173 | } 174 | 175 | #[doc(hidden)] 176 | fn validate(&self) -> Result<(), Error> { 177 | if self.template.is_none() { 178 | return Err(Error::NoTemplate); 179 | } 180 | 181 | let width = self.width.unwrap_or(0); 182 | let height = self.height.unwrap_or(0); 183 | if !(width <= MAX_WIDTH && height <= MAX_HEIGHT) { 184 | return Err(Error::TooLarge(self.width, self.height)); 185 | } 186 | 187 | if self.captions.len() > MAX_CAPTION_COUNT { 188 | return Err(Error::TooManyCaptions(self.captions.len())); 189 | } 190 | for cap in &self.captions { 191 | if cap.text.len() > MAX_CAPTION_LENGTH { 192 | return Err(Error::CaptionTooLong(cap.text.len())); 193 | } 194 | } 195 | 196 | Ok(()) 197 | } 198 | } 199 | 200 | 201 | /// Error while building an `ImageMacro`. 202 | #[derive(Clone, Debug)] 203 | pub enum Error { 204 | /// No template given. 205 | NoTemplate, 206 | /// Requested image size is too large. 207 | TooLarge(Option, Option), 208 | /// Too many captions. 209 | TooManyCaptions(usize), 210 | /// Caption text too long. 211 | CaptionTooLong(usize), 212 | } 213 | 214 | impl error::Error for Error { 215 | fn description(&self) -> &str { "ImageMacro creation error" } 216 | fn cause(&self) -> Option<&error::Error> { None } 217 | } 218 | 219 | impl fmt::Display for Error { 220 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 221 | match *self { 222 | Error::NoTemplate => write!(fmt, "no template chosen"), 223 | Error::TooLarge(w, h) => write!(fmt, "target image too large: {}x{} > {}x{}", 224 | w.map(|w| format!("{}", w)).as_ref().map(|s| s.as_str()).unwrap_or("(default)"), 225 | h.map(|h| format!("{}", h)).as_ref().map(|s| s.as_str()).unwrap_or("(default)"), 226 | MAX_WIDTH, MAX_HEIGHT), 227 | Error::TooManyCaptions(c) => 228 | write!(fmt, "too many captions: {} > {}", c, MAX_CAPTION_COUNT), 229 | Error::CaptionTooLong(l) => 230 | write!(fmt, "caption too long: {} > {}", l, MAX_CAPTION_LENGTH), 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/lib/model/types/mod.rs: -------------------------------------------------------------------------------- 1 | //! Module defining the model types. 2 | 3 | mod align; 4 | mod caption; 5 | mod color; 6 | mod image_macro; 7 | mod size; 8 | 9 | pub use self::align::{HAlign, VAlign}; 10 | pub use self::caption::{Caption, 11 | CaptionBuilder, 12 | Error as CaptionBuildError}; 13 | pub use self::color::Color; 14 | pub use self::image_macro::{ImageMacro, 15 | Builder as ImageMacroBuilder, 16 | Error as ImageMacroBuildError}; 17 | pub use self::size::Size; 18 | -------------------------------------------------------------------------------- /src/lib/model/types/size.rs: -------------------------------------------------------------------------------- 1 | //! Module defining the text size enum. 2 | 3 | use float_ord::FloatOrd; 4 | 5 | use super::super::constants::DEFAULT_TEXT_SIZE; 6 | 7 | 8 | /// Size of the caption text. 9 | #[derive(Clone, Copy, Debug)] 10 | pub enum Size { 11 | /// Use fixed text size. 12 | /// 13 | /// The text will be broken up into multiple lines if necessary, 14 | /// but its size will remain constant. 15 | Fixed(f32), 16 | /// Shrink a single line caption to fit the image. 17 | /// 18 | /// Caption text will not be broken into multiple lines 19 | /// and any preexisting line breaks will be ignored 20 | Shrink, 21 | /// Fit the text within the image, 22 | /// breaking it up and reducing its size if necessary. 23 | Fit, 24 | } 25 | 26 | impl Size { 27 | /// Whether this text size as been defined as fixed. 28 | #[inline] 29 | pub fn is_fixed(&self) -> bool { 30 | self.as_number().is_some() 31 | } 32 | 33 | /// Return the numeric text size, if specified. 34 | #[inline] 35 | pub fn as_number(&self) -> Option { 36 | match *self { Size::Fixed(s) => Some(s), _ => None } 37 | } 38 | } 39 | 40 | impl Default for Size { 41 | fn default() -> Self { 42 | DEFAULT_TEXT_SIZE.into() 43 | } 44 | } 45 | 46 | impl From for Size { 47 | fn from(input: f32) -> Self { 48 | Size::Fixed(input) 49 | } 50 | } 51 | impl From for Size { 52 | fn from(input: f64) -> Self { 53 | Size::from(input as f32) 54 | } 55 | } 56 | 57 | impl PartialEq for Size { 58 | fn eq(&self, other: &Self) -> bool { 59 | match (*self, *other) { 60 | (Size::Fixed(a), Size::Fixed(b)) => FloatOrd(a).eq(&FloatOrd(b)), 61 | (Size::Shrink, Size::Shrink) => true, 62 | (Size::Fit, Size::Fit) => true, 63 | _ => false, 64 | } 65 | } 66 | } 67 | impl Eq for Size {} 68 | -------------------------------------------------------------------------------- /src/lib/resources/filesystem.rs: -------------------------------------------------------------------------------- 1 | //! Module defining and implementing resource loaders. 2 | 3 | use std::fmt; 4 | use std::fs::{self, File}; 5 | use std::iter; 6 | use std::io::{self, BufReader, Read}; 7 | use std::path::{Path, PathBuf}; 8 | use std::sync::Arc; 9 | 10 | use glob; 11 | 12 | use super::Loader; 13 | 14 | 15 | /// Loader for file paths from given directory. 16 | /// 17 | /// The resources here are just file *paths* (`std::path::PathBuf`), 18 | /// and no substantial "loading" is performing (only path resolution). 19 | /// 20 | /// This isn't particularly useful on its own, but can be wrapped around 21 | /// to make more interesting loaders. 22 | #[derive(Clone)] 23 | pub struct PathLoader<'pl> { 24 | directory: PathBuf, 25 | predicate: Arc bool + Send + Sync + 'pl>, 26 | } 27 | 28 | impl<'pl> PathLoader<'pl> { 29 | /// Create a loader which gives out paths to any file within a directory 30 | /// whose base name matches the requested resource name. 31 | #[inline] 32 | pub fn new>(directory: D) -> Self { 33 | Self::with_predicate(directory, |_| true) 34 | } 35 | 36 | /// Create a loader which only gives out paths to files 37 | /// in given directory that have the specified extension. 38 | #[inline] 39 | pub fn for_extension, S>(directory: D, extension: S) -> Self 40 | where S: ToString 41 | { 42 | Self::for_extensions(directory, iter::once(extension)) 43 | } 44 | 45 | /// Create a loader which only gives out paths to files 46 | /// in given directory that have one of the extensions specified. 47 | pub fn for_extensions, I, S>(directory: D, extensions: I) -> Self 48 | where I: IntoIterator, S: ToString 49 | { 50 | Self::with_predicate(directory, { 51 | let extensions: Vec<_> = extensions.into_iter() 52 | .map(|e| e.to_string()).map(|e| e.trim().to_lowercase()) 53 | .collect(); 54 | move |path| { 55 | let ext = path.extension().and_then(|e| e.to_str()) 56 | .map(|s| s.trim().to_lowercase()); 57 | extensions.iter().any(|e| Some(e) == ext.as_ref()) 58 | } 59 | }) 60 | } 61 | 62 | /// Create a loader which gives out paths to files within a directory 63 | /// that additionally match a specified boolean predicate. 64 | pub fn with_predicate(directory: D, predicate: P) -> Self 65 | where D: AsRef, P: Fn(&Path) -> bool + Send + Sync + 'pl 66 | { 67 | PathLoader{ 68 | directory: directory.as_ref().to_owned(), 69 | predicate: Arc::new(predicate), 70 | } 71 | } 72 | } 73 | 74 | impl<'pl> Loader for PathLoader<'pl> { 75 | type Item = PathBuf; 76 | type Err = io::Error; 77 | 78 | /// "Load" a path "resource" from the loader's directory. 79 | fn load<'n>(&self, name: &'n str) -> Result { 80 | let file_part = format!("{}.*", name); 81 | let pattern = format!("{}", self.directory.join(file_part).display()); 82 | trace!("Globbing with {}", pattern); 83 | 84 | let glob_iter = match glob::glob(&pattern) { 85 | Ok(it) => it, 86 | Err(e) => { 87 | error!("Failed to glob over files with {}: {}", pattern, e); 88 | return Err(io::Error::new(io::ErrorKind::Other, e)); 89 | }, 90 | }; 91 | let matches: Vec<_> = glob_iter 92 | .filter_map(Result::ok) // TODO: report those errors 93 | .filter(|f| (self.predicate)(f)) 94 | .collect(); 95 | 96 | match matches.len() { 97 | 0 => Err(io::Error::new(io::ErrorKind::NotFound, 98 | format!("resource `{}` not found in {}", name, self.directory.display()))), 99 | 1 => Ok(matches.into_iter().next().unwrap()), 100 | c => Err(io::Error::new(io::ErrorKind::InvalidInput, 101 | format!("ambiguous resource name `{}` matching {} files in {}", 102 | name, c, self.directory.display()))), 103 | } 104 | } 105 | } 106 | 107 | impl<'pl> fmt::Debug for PathLoader<'pl> { 108 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 109 | fmt.debug_struct("PathLoader") 110 | .field("directory", &self.directory) 111 | .finish() 112 | } 113 | } 114 | 115 | 116 | /// Loader for files in given directory. 117 | /// 118 | /// The resources it doles out are just file handles (`std::fs::File`). 119 | /// Wrappers around this loaded can then implement their own decoding. 120 | #[derive(Clone, Debug)] 121 | pub struct FileLoader<'pl> { 122 | inner: PathLoader<'pl>, 123 | } 124 | 125 | // Constructors that for convenience are delegating to the PathLoader ones. 126 | impl<'pl> FileLoader<'pl> { 127 | /// Create loader for files within given directory. 128 | #[inline] 129 | pub fn new>(directory: D) -> Self { 130 | FileLoader{inner: PathLoader::new(directory)} 131 | } 132 | 133 | /// Create loader for files within given directory 134 | /// that have the specified file extension. 135 | #[inline] 136 | pub fn for_extension, S>(directory: D, extension: S) -> Self 137 | where S: ToString 138 | { 139 | FileLoader{inner: PathLoader::for_extension(directory, extension)} 140 | } 141 | 142 | /// Create a loader which only loads files from the specified directory 143 | /// that have one of the extensions given. 144 | #[inline] 145 | pub fn for_extensions, I, S>(directory: D, extensions: I) -> Self 146 | where I: IntoIterator, S: ToString 147 | { 148 | FileLoader{inner: PathLoader::for_extensions(directory, extensions)} 149 | } 150 | 151 | /// Create a loader which only loads files from the specified directory 152 | /// that additionally match a specified boolean predicate for their paths. 153 | #[inline] 154 | pub fn with_path_predicate(directory: D, predicate: P) -> Self 155 | where D: AsRef, P: Fn(&Path) -> bool + Send + Sync + 'pl 156 | { 157 | FileLoader{inner: PathLoader::with_predicate(directory, predicate)} 158 | } 159 | 160 | // TODO: add filtering based on file metadata too 161 | } 162 | 163 | impl<'pl> Loader for FileLoader<'pl> { 164 | type Item = File; 165 | type Err = io::Error; 166 | 167 | /// Load a `File` resource from loader's directory. 168 | fn load<'n>(&self, name: &'n str) -> Result { 169 | let path = self.inner.load(name)?; 170 | fs::OpenOptions::new().read(true).open(path) 171 | } 172 | } 173 | 174 | 175 | /// Wrapper around `FileLoader` that loads the entire content of the files. 176 | /// 177 | /// The content is given out as simple vector of bytes, i.e. `Vec`. 178 | #[derive(Clone, Debug)] 179 | pub struct BytesLoader<'fl> { 180 | inner: FileLoader<'fl>, 181 | } 182 | 183 | impl<'fl> BytesLoader<'fl> { 184 | /// Wrap a FileLoader within the BytesLoader. 185 | #[inline] 186 | pub fn new(inner: FileLoader<'fl>) -> Self { 187 | BytesLoader{inner} 188 | } 189 | } 190 | impl<'fl> From> for BytesLoader<'fl> { 191 | fn from(input: FileLoader<'fl>) -> Self { 192 | Self::new(input) 193 | } 194 | } 195 | 196 | impl<'fl> Loader for BytesLoader<'fl> { 197 | type Item = Vec; 198 | type Err = io::Error; 199 | 200 | /// Load a file resource as its byte content. 201 | fn load<'n>(&self, name: &'n str) -> Result { 202 | let file = self.inner.load(name)?; 203 | 204 | let mut bytes = match file.metadata() { 205 | Ok(stat) => Vec::with_capacity(stat.len() as usize), 206 | Err(e) => { 207 | warn!("Failed to stat file of resource `{}` to obtain its size: {}", 208 | name, e); 209 | Vec::new() 210 | }, 211 | }; 212 | 213 | let mut reader = BufReader::new(file); 214 | reader.read_to_end(&mut bytes)?; 215 | Ok(bytes) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/lib/resources/fonts.rs: -------------------------------------------------------------------------------- 1 | //! Module for loading fonts used in image macros. 2 | 3 | use std::error::Error; 4 | use std::fmt; 5 | use std::io; 6 | use std::path::Path; 7 | 8 | use rusttype::{self, FontCollection}; 9 | 10 | use super::Loader; 11 | use super::filesystem::{BytesLoader, FileLoader}; 12 | 13 | 14 | /// File extension of font files. 15 | pub const FILE_EXTENSION: &'static str = "ttf"; 16 | 17 | 18 | macro_attr! { 19 | /// Font that can be used to caption image macros. 20 | #[derive(NewtypeDeref!, NewtypeFrom!)] 21 | pub struct Font(rusttype::Font<'static>); 22 | // TODO: add font name for better Debug 23 | } 24 | impl fmt::Debug for Font { 25 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 26 | write!(fmt, "Font(...)") 27 | } 28 | } 29 | 30 | 31 | /// Error that may occur during font load. 32 | #[derive(Debug)] 33 | pub enum FontError { 34 | /// Error while loading the font file. 35 | File(io::Error), 36 | /// Error for when the font file contains no fonts. 37 | NoFonts, 38 | /// Error for when the font file contains too many fonts. 39 | TooManyFonts(usize), 40 | } 41 | 42 | impl Error for FontError { 43 | fn description(&self) -> &str { "font loading error" } 44 | fn cause(&self) -> Option<&Error> { 45 | match *self { 46 | FontError::File(ref e) => Some(e), 47 | _ => None, 48 | } 49 | } 50 | } 51 | 52 | impl fmt::Display for FontError { 53 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 54 | match *self { 55 | FontError::File(ref e) => write!(fmt, "I/O error while loading font: {}", e), 56 | FontError::NoFonts => write!(fmt, "no fonts found in the file"), 57 | FontError::TooManyFonts(c) => 58 | write!(fmt, "expected a single font in the file, found {}", c), 59 | } 60 | } 61 | } 62 | 63 | 64 | /// Loader for fonts stored in a directory. 65 | /// 66 | /// Font names are translated directly into file names, loaded, and cached. 67 | #[derive(Clone, Debug)] 68 | pub struct FontLoader { 69 | inner: BytesLoader<'static>, 70 | } 71 | 72 | impl FontLoader { 73 | /// Create a new font loader. 74 | #[inline] 75 | pub fn new>(directory: D) -> Self { 76 | FontLoader{ 77 | inner: BytesLoader::new( 78 | FileLoader::for_extension(directory, FILE_EXTENSION)) 79 | } 80 | } 81 | } 82 | 83 | impl Loader for FontLoader { 84 | type Item = Font; 85 | type Err = FontError; 86 | 87 | /// Load a font with given name. 88 | fn load<'n>(&self, name: &'n str) -> Result { 89 | let bytes = self.inner.load(name).map_err(FontError::File)?; 90 | 91 | let fonts: Vec<_> = FontCollection::from_bytes(bytes).into_fonts().collect(); 92 | match fonts.len() { 93 | 0 => { 94 | error!("No fonts in a file for `{}` font resource", name); 95 | Err(FontError::NoFonts) 96 | } 97 | 1 => { 98 | debug!("Font `{}` loaded successfully", name); 99 | Ok(fonts.into_iter().next().unwrap().into()) 100 | } 101 | count => { 102 | error!("Font file for `{}` resource contains {} fonts, expected one", 103 | name, count); 104 | Err(FontError::TooManyFonts(count)) 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/resources/mod.rs: -------------------------------------------------------------------------------- 1 | //! Module handling the resources used for captioning. 2 | 3 | mod filesystem; 4 | mod fonts; 5 | mod templates; 6 | 7 | 8 | pub use self::filesystem::{BytesLoader, FileLoader}; 9 | pub use self::fonts::{Font, FontLoader, FontError, 10 | FILE_EXTENSION as FONT_FILE_EXTENSION}; 11 | pub use self::templates::{DEFAULT_IMAGE_FORMAT, IMAGE_FORMAT_EXTENSIONS, 12 | Template, TemplateLoader, TemplateError}; 13 | 14 | 15 | use std::error::Error; 16 | use std::fmt; 17 | use std::sync::Arc; 18 | 19 | use util::cache::ThreadSafeCache; 20 | 21 | 22 | /// Loader of resources from some external source. 23 | pub trait Loader { 24 | /// Type of resources that this loader can load. 25 | type Item; 26 | /// Error that may occur while loading the resource. 27 | type Err: Error; 28 | 29 | /// Load a resource of given name. 30 | fn load<'n>(&self, name: &'n str) -> Result; 31 | } 32 | 33 | /// Type of a loader that doles out shared references to the resources. 34 | pub type SharingLoader = Loader, Err=E>; 35 | 36 | 37 | /// A loader that keeps a cache of resources previously loaded. 38 | pub struct CachingLoader { 39 | inner: L, 40 | cache: ThreadSafeCache, 41 | pub(crate) phony: bool, 42 | } 43 | 44 | impl CachingLoader { 45 | /// Wrap a `Loader` with a `CachingLoader` of given capacity. 46 | #[inline] 47 | pub fn new(inner: L, capacity: usize) -> Self { 48 | CachingLoader{ 49 | inner: inner, 50 | cache: ThreadSafeCache::new(capacity), 51 | phony: false, 52 | } 53 | } 54 | 55 | /// Create a phony version of CachingLoader that doesn't actually cache anything. 56 | /// 57 | /// This is used to transparently wrap a Loader into Loader>, 58 | /// which is necessary because Rust cannot really abstract between the two. 59 | #[inline] 60 | pub(crate) fn phony(inner: L) -> Self { 61 | CachingLoader{ 62 | inner: inner, 63 | cache: ThreadSafeCache::new(1), 64 | phony: true, 65 | } 66 | } 67 | } 68 | 69 | impl CachingLoader { 70 | /// Returns a reference to the loader's cache. 71 | #[inline] 72 | pub fn cache(&self) -> &ThreadSafeCache { 73 | &self.cache 74 | } 75 | } 76 | 77 | impl Loader for CachingLoader { 78 | type Item = Arc; 79 | type Err = L::Err; 80 | 81 | /// Load the object from cache or fall back on the original Loader. 82 | /// Cache the objects loaded this way. 83 | fn load<'n>(&self, name: &'n str) -> Result { 84 | if self.phony { 85 | let obj = self.inner.load(name)?; 86 | Ok(Arc::new(obj)) 87 | } else { 88 | if let Some(obj) = self.cache.get(name) { 89 | return Ok(obj); 90 | } 91 | let obj = self.inner.load(name)?; 92 | let cached_obj = self.cache.put(name.to_owned(), obj); 93 | Ok(cached_obj) 94 | } 95 | } 96 | } 97 | 98 | // TODO: add impl when specialization is stable 99 | impl fmt::Debug for CachingLoader { 100 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 101 | fmt.debug_struct("CachingLoader") 102 | .field("inner", &"...") 103 | .field("cache", &self.cache) 104 | .field("phony", &self.phony) 105 | .finish() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/lib/resources/templates.rs: -------------------------------------------------------------------------------- 1 | //! Module handling image macro templates. 2 | 3 | use std::collections::HashMap; 4 | use std::fmt; 5 | use std::io; 6 | use std::iter; 7 | use std::path::Path; 8 | 9 | use image::{self, DynamicImage, GenericImage, ImageFormat}; 10 | 11 | use util::animated_gif::{self, GifAnimation, is_gif, is_gif_animated}; 12 | use super::Loader; 13 | use super::filesystem::PathLoader; 14 | 15 | 16 | /// Default image format to use when encoding image macros. 17 | pub const DEFAULT_IMAGE_FORMAT: ImageFormat = ImageFormat::PNG; 18 | 19 | lazy_static! { 20 | /// Map of template file extensions to supported image formats. 21 | #[doc(hidden)] 22 | pub static ref IMAGE_FORMAT_EXTENSIONS: HashMap<&'static str, ImageFormat> = hashmap!{ 23 | "gif" => ImageFormat::GIF, 24 | "jpeg" => ImageFormat::JPEG, 25 | "jpg" => ImageFormat::JPEG, 26 | "png" => ImageFormat::PNG, 27 | }; 28 | } 29 | 30 | 31 | /// Represents an image macro template. 32 | /// 33 | /// Currently, templates can either be regular (still) images, 34 | /// or animations loaded from a GIF file. 35 | #[derive(Clone)] 36 | pub enum Template { 37 | /// Single still image, loaded from some image format. 38 | Image(DynamicImage, ImageFormat), 39 | /// An animation, loaded from a GIF. 40 | Animation(GifAnimation), 41 | } 42 | 43 | impl Template { 44 | /// Create the template for an image loaded from a file. 45 | /// Image format is figured out from the file extension. 46 | pub fn for_image>(img: DynamicImage, path: P) -> Self { 47 | let extension = path.as_ref().extension().and_then(|e| e.to_str()) 48 | .map(|s| s.trim().to_lowercase()); 49 | let img_format = extension 50 | .and_then(|ext| IMAGE_FORMAT_EXTENSIONS.get(ext.as_str()).map(|f| *f)) 51 | .unwrap_or(DEFAULT_IMAGE_FORMAT); 52 | Template::Image(img, img_format) 53 | } 54 | 55 | /// Create the template for an animation loaded from a GIF file. 56 | #[inline] 57 | pub fn for_gif_animation(gif_anim: GifAnimation) -> Self { 58 | Template::Animation(gif_anim) 59 | } 60 | } 61 | 62 | impl Template { 63 | /// Whether this is an animated template. 64 | #[inline] 65 | pub fn is_animated(&self) -> bool { 66 | match *self { Template::Animation(..) => true, _ => false, } 67 | } 68 | 69 | /// Number of images that comprise the template 70 | #[inline] 71 | pub fn image_count(&self) -> usize { 72 | match *self { 73 | Template::Image(..) => 1, 74 | Template::Animation(ref gif_anim) => gif_anim.frames_count(), 75 | } 76 | } 77 | 78 | /// Iterate over all DynamicImages in this template. 79 | pub fn iter_images<'t>(&'t self) -> Box + 't> { 80 | match *self { 81 | Template::Image(ref img, ..) => Box::new(iter::once(img)), 82 | Template::Animation(ref gif_anim) => Box::new( 83 | gif_anim.iter_frames().map(|f| &f.image)), 84 | } 85 | } 86 | 87 | /// The preferred format for image macros generated using this template. 88 | /// This is usually the same that the template was loaded from. 89 | pub fn preferred_format(&self) -> ImageFormat { 90 | match *self { 91 | Template::Image(_, fmt) => match fmt { 92 | // These are the formats that image crate encodes natively. 93 | ImageFormat::PNG | ImageFormat::JPEG => return fmt, 94 | _ => {} 95 | }, 96 | Template::Animation(..) => return ImageFormat::GIF, 97 | } 98 | DEFAULT_IMAGE_FORMAT 99 | } 100 | } 101 | 102 | impl fmt::Debug for Template { 103 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 104 | match *self { 105 | Template::Image(ref img, f) => { 106 | let (width, height) = img.dimensions(); 107 | write!(fmt, "Template::Image({}x{}, {:?})", width, height, f) 108 | } 109 | Template::Animation(ref gif_anim) => { 110 | write!(fmt, "Template::Animation({} frame(s))", gif_anim.frames_count()) 111 | } 112 | } 113 | } 114 | } 115 | 116 | 117 | /// Error that may occur during template load. 118 | #[derive(Debug, Error)] 119 | pub enum TemplateError { 120 | /// Error while loading the template file. 121 | #[error(msg = "I/O error while loading template")] 122 | File(io::Error), 123 | /// Error when opening a template image didn't succeed. 124 | #[error(msg = "error while opening template image")] 125 | OpenImage(image::ImageError), 126 | /// Error when opening a template's animated GIF didn't succeed. 127 | #[error(msg = "error while opening animated GIF template")] 128 | DecodeAnimatedGif(animated_gif::DecodeError), 129 | } 130 | 131 | 132 | /// Loader for templates stored in a directory. 133 | /// 134 | /// Template names are translated directly into file names, loaded, and cached. 135 | #[derive(Clone, Debug)] 136 | pub struct TemplateLoader { 137 | inner: PathLoader<'static>, 138 | } 139 | 140 | impl TemplateLoader { 141 | /// Create a new template loader. 142 | #[inline] 143 | pub fn new>(directory: D) -> Self { 144 | TemplateLoader{ 145 | inner: PathLoader::for_extensions(directory, IMAGE_FORMAT_EXTENSIONS.keys()), 146 | } 147 | } 148 | } 149 | 150 | impl Loader for TemplateLoader { 151 | type Item = Template; 152 | type Err = TemplateError; 153 | 154 | /// Load a template given its name. 155 | fn load<'n>(&self, name: &'n str) -> Result { 156 | let path = self.inner.load(name)?; 157 | 158 | // Use the `gif` crate to load animated GIFs. 159 | // Use the regular `image` crate to load any other (still) image. 160 | if is_gif(&path) && is_gif_animated(&path).unwrap_or(false) { 161 | trace!("Image {} is an animated GIF", path.display()); 162 | let gif_anim = animated_gif::decode_from_file(&path).map_err(|e| { 163 | error!("Failed to open animated GIF template {}: {}", 164 | path.display(), e); e 165 | })?; 166 | Ok(Template::for_gif_animation(gif_anim)) 167 | } else { 168 | trace!("Opening image {}", path.display()); 169 | let img = image::open(&path)?; 170 | Ok(Template::for_image(img, &path)) 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/lib/util/cache.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing a thread-safe LRU cache. 2 | 3 | use std::borrow::Borrow; 4 | use std::collections::hash_map::RandomState; 5 | use std::hash::{BuildHasher, Hash}; 6 | use std::fmt; 7 | use std::sync::Arc; 8 | use std::sync::atomic::{AtomicUsize, Ordering}; 9 | 10 | use antidote::Mutex; 11 | use lru_cache::LruCache; 12 | 13 | 14 | /// A thread-safe cache of keys & cached values. 15 | /// Actual values stored in the cache are `Arc'`s. 16 | /// 17 | /// This is a wrapper around `LruCache` that also counts various cache statistics, 18 | /// like cache hits or cache misses. 19 | pub struct ThreadSafeCache 20 | where K: Eq + Hash, S: BuildHasher 21 | { 22 | inner: Mutex, S>>, 23 | // Cache statistics. 24 | hits: AtomicUsize, 25 | misses: AtomicUsize, 26 | } 27 | 28 | impl ThreadSafeCache { 29 | /// Create the cache with given capacity. 30 | #[inline] 31 | pub fn new(capacity: usize) -> Self { 32 | Self::with_hasher(capacity, RandomState::new()) 33 | } 34 | } 35 | 36 | impl ThreadSafeCache 37 | where K: Eq + Hash, S: BuildHasher 38 | { 39 | /// Create the cache with custom hasher and given capacity. 40 | pub fn with_hasher(capacity: usize, hasher: S) -> Self { 41 | ThreadSafeCache{ 42 | inner: Mutex::new(LruCache::with_hasher(capacity, hasher)), 43 | hits: AtomicUsize::new(0), 44 | misses: AtomicUsize::new(0), 45 | } 46 | } 47 | } 48 | 49 | // LruCache interface wrappers. 50 | #[allow(dead_code)] 51 | impl ThreadSafeCache { 52 | /// Check if the cache contains given key. 53 | pub fn contains_key(&self, key: &Q) -> bool 54 | where K: Borrow, Q: ?Sized + Eq + Hash 55 | { 56 | let y = self.inner.lock().contains_key(key); 57 | if !y { self.miss(); } 58 | y 59 | } 60 | 61 | /// Get the element corresponding to given key if it's present in the cache. 62 | pub fn get(&self, key: &Q) -> Option> 63 | where K: Borrow, Q: ?Sized + Eq + Hash 64 | { 65 | match self.inner.lock().get_mut(key) { 66 | Some(v) => { self.hit(); Some(v.clone()) } 67 | None => { self.miss(); None } 68 | } 69 | } 70 | 71 | /// Put an item into cache under given key. 72 | /// 73 | /// This is like insert(), except it always returns the (`Arc`'d) value 74 | /// that's under the cached key. 75 | /// If it wasn't there before, it will be the new value just inserted (i.e. `v`). 76 | pub fn put(&self, k: K, v: V) -> Arc { 77 | let value = Arc::new(v); 78 | self.inner.lock().insert(k, value.clone()).unwrap_or_else(|| value) 79 | } 80 | 81 | /// Insert an item into the cache under given key. 82 | /// 83 | /// If the key is already present in the cache, returns its corresponding value. 84 | pub fn insert(&self, k: K, v: V) -> Option> { 85 | self.inner.lock().insert(k, Arc::new(v)) 86 | } 87 | 88 | /// Removes a key from the cache, if present, and returns its value. 89 | pub fn remove(&self, key: &Q) -> Option> 90 | where K: Borrow, Q: ?Sized + Eq + Hash 91 | { 92 | match self.inner.lock().remove(key) { 93 | r @ Some(_) => { self.hit(); r } 94 | r @ None => { self.miss(); r } 95 | } 96 | } 97 | 98 | /// Cache capacity. 99 | pub fn capacity(&self) -> usize { 100 | self.inner.lock().capacity() 101 | } 102 | 103 | /// Set the capacity of the cache. 104 | /// 105 | /// If the new capacity is smaller than current size of the cache, 106 | /// elements will be removed from it in the LRU manner. 107 | pub fn set_capacity(&self, capacity: usize) { 108 | self.inner.lock().set_capacity(capacity); 109 | } 110 | 111 | /// Remove the least recently used element from the cache. 112 | pub fn remove_lru(&self) -> Option<(K, Arc)> { 113 | self.inner.lock().remove_lru() 114 | } 115 | 116 | /// Current size of the cache. 117 | pub fn len(&self) -> usize { 118 | self.inner.lock().len() 119 | } 120 | 121 | /// Whether the cache is empty. 122 | pub fn is_empty(&self) -> bool { 123 | self.inner.lock().is_empty() 124 | } 125 | 126 | /// Remove all elements from the cache. 127 | pub fn clear(&self) { 128 | self.inner.lock().clear() 129 | } 130 | } 131 | 132 | // Incrementing the statistics' counters. 133 | impl ThreadSafeCache { 134 | /// Increment the number of cache hits. Returns the new total. 135 | fn hit(&self) -> usize { 136 | let inc = 1; 137 | self.hits.fetch_add(inc, Ordering::Relaxed) + inc 138 | } 139 | 140 | /// Increment the number of cache misses. Returns the new total. 141 | fn miss(&self) -> usize { 142 | let inc = 1; 143 | self.misses.fetch_add(inc, Ordering::Relaxed) + inc 144 | } 145 | } 146 | 147 | // Getting counter values. 148 | impl ThreadSafeCache { 149 | /// Returns the number of cache hits. 150 | pub fn hits(&self) -> usize { 151 | self.hits.load(Ordering::Relaxed) 152 | } 153 | 154 | /// Returns the number of cache misses. 155 | pub fn misses(&self) -> usize { 156 | self.misses.load(Ordering::Relaxed) 157 | } 158 | } 159 | 160 | impl fmt::Debug for ThreadSafeCache { 161 | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { 162 | let mut ds = fmt.debug_struct("ThreadSafeCache"); 163 | if let Ok(inner) = self.inner.try_lock() { 164 | ds.field("capacity", &inner.capacity()); 165 | ds.field("len", &inner.len()); 166 | } 167 | ds.field("hits", &self.hits()); 168 | ds.field("misses", &self.misses()); 169 | ds.finish() 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/lib/util/mod.rs: -------------------------------------------------------------------------------- 1 | //! Utility code. 2 | 3 | pub mod animated_gif; 4 | pub mod cache; 5 | pub mod text; 6 | -------------------------------------------------------------------------------- /src/server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rofld" 3 | version = "0.0.1" 4 | authors = ["Karol Kuczmarski "] 5 | description = "Lulz server" 6 | documentation = "https://github.com/Xion/rofld" 7 | homepage = "https://github.com/Xion/rofld" 8 | repository = "https://github.com/Xion/rofld" 9 | readme = "README.md" 10 | license = "GPLv2" 11 | publish = false 12 | 13 | [badges] 14 | travis-ci = { repository = "Xion/rofld" } 15 | 16 | [[bin]] 17 | name = "rofld" 18 | path = "main.rs" 19 | 20 | [dependencies] 21 | antidote = "1.0" 22 | atomic = "0.3" 23 | ansi_term = "0.9" 24 | clap = { version = "2.19", features = ["suggestions"] } 25 | conv = "0.3" 26 | derive-error = "0.0.3" 27 | enum_derive = "*" 28 | enum-set = "*" 29 | exitcode = "1.0" 30 | futures = "0.1" 31 | futures-cpupool = "0.1" 32 | glob = "0.2" 33 | hyper = "0.11" 34 | isatty = "0.1.1" 35 | itertools = "0.6" 36 | lazy_static = "*" 37 | log = "*" 38 | macro-attr = "0.2" 39 | maplit = "0.1" 40 | mime = "0.3" 41 | nix = "0.8" 42 | num = "0.1" 43 | rand = "0.3" 44 | regex = "0.2" 45 | rofl = { path = "../lib" } 46 | serde = "1.0" 47 | serde_json = "1.0" 48 | serde_qs = "0.3" 49 | slog = "1.5.2" 50 | slog-envlogger = "0.5" 51 | slog-stdlog = "1.1" 52 | slog-stream = "1.2" 53 | thread-id = "3.1" 54 | tokio-core = "0.1" 55 | tokio-signal = "0.1" 56 | tokio-timer = "0.1" 57 | time = "0.1" 58 | unreachable = "0.1" 59 | 60 | [build-dependencies] 61 | rustc_version = "0.2" 62 | 63 | [dev-dependencies] 64 | spectral = "0.6.0" 65 | -------------------------------------------------------------------------------- /src/server/README.md: -------------------------------------------------------------------------------- 1 | # rofld 2 | 3 | Lulz server 4 | 5 | [![Build Status](https://img.shields.io/travis/Xion/rofld.svg)](https://travis-ci.org/Xion/rofld) 6 | 7 | ## What? 8 | 9 | _rofld_ (rofl-DEE) is a mission-critical HTTP service that should be a crucial part 10 | of any large scale, distributed infrastructure. 11 | 12 | It makes memes. 13 | 14 | ## How? 15 | 16 | Run it with `./cargo server run` and hit its `/caption` endpoint: 17 | 18 | $ ./cargo server run 19 | INFO: rofld v0.1.0 (rev. b707bc5) 20 | INFO: Starting the server to listen on 0.0.0.0:1337... 21 | 22 | # elsewhere 23 | $ curl http://127.0.0.1:1337/caption?template=zoidberg&top_text=Need%20a%20meme?&bottom_text=Why%20not%20Zoidberg? 24 | 25 | ![Need a meme? / Why not Zoidberg?](../../zoidberg.png) 26 | 27 | Want more templates? Put them in the `data/templates` directory, duh. 28 | 29 | ## Why? 30 | 31 | Wait, you say we'd need a _reason_ for this? 32 | 33 | Alright, if you insist, it's for checking what's up with async Rust. 34 | See [this post](http://xion.io/post/programming/rust-async-closer-look.html) for more details. 35 | -------------------------------------------------------------------------------- /src/server/build.rs: -------------------------------------------------------------------------------- 1 | //! Le build script. 2 | 3 | extern crate rustc_version; 4 | 5 | 6 | use std::env; 7 | use std::error::Error; 8 | use std::fs::File; 9 | use std::io::{self, Write}; 10 | use std::path::Path; 11 | use std::process::Command; 12 | use std::str; 13 | 14 | 15 | fn main() { 16 | match git_head_sha() { 17 | Ok(rev) => pass_metadata("REVISION", &rev).unwrap(), 18 | Err(e) => println!("cargo:warning=Failed to obtain current Git SHA: {}", e), 19 | }; 20 | match compiler_signature() { 21 | Ok(sig) => pass_metadata("COMPILER", &sig).unwrap(), 22 | Err(e) => println!("cargo:warning=Failed to obtain compiler information: {}", e), 23 | }; 24 | } 25 | 26 | fn pass_metadata>(kind: P, data: &str) -> io::Result<()> { 27 | // We cannot data as an env!() variable to the crate code, 28 | // so the workaround is to write it to a file for include_str!(). 29 | // Details: https://github.com/rust-lang/cargo/issues/2875 30 | let out_dir = env::var("OUT_DIR").unwrap(); 31 | let path = Path::new(&out_dir).join(kind); 32 | let mut file = File::create(&path)?; 33 | file.write_all(data.as_bytes()) 34 | } 35 | 36 | 37 | fn git_head_sha() -> Result> { 38 | let mut cmd = Command::new("git"); 39 | cmd.args(&["rev-parse", "--short", "HEAD"]); 40 | 41 | let output = try!(cmd.output()); 42 | let sha = try!(str::from_utf8(&output.stdout[..])).trim().to_owned(); 43 | Ok(sha) 44 | } 45 | 46 | fn compiler_signature() -> Result> { 47 | let rustc = rustc_version::version_meta()?; 48 | let signature = format!("{channel} {version} on {host}", 49 | version = rustc.short_version_string, 50 | channel = format!("{:?}", rustc.channel).to_lowercase(), 51 | host = rustc.host); 52 | Ok(signature) 53 | } 54 | -------------------------------------------------------------------------------- /src/server/ext.rs: -------------------------------------------------------------------------------- 1 | //! Extension module, gluing together & enhancing the third-party libraries. 2 | 3 | pub mod hyper { 4 | use futures::{BoxFuture, future, Future, Stream}; 5 | use hyper::{Body, Error}; 6 | 7 | 8 | /// Trait with additional methods for the Hyper Body object. 9 | pub trait BodyExt { 10 | fn into_bytes(self) -> BoxFuture, Error>; 11 | } 12 | 13 | impl BodyExt for Body { 14 | fn into_bytes(self) -> BoxFuture, Error> { 15 | self.fold(vec![], |mut buf, chunk| { 16 | buf.extend_from_slice(&*chunk); 17 | future::ok::<_, Error>(buf) 18 | }).boxed() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/server/handlers/captioner.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing the thread pool that does the image captioning. 2 | //! This is used by the /caption request handler. 3 | 4 | use std::time::Duration; 5 | use std::sync::atomic::Ordering; 6 | use std::sync::Arc; 7 | 8 | use antidote::Mutex; 9 | use atomic::Atomic; 10 | use futures::{BoxFuture, future, Future}; 11 | use futures_cpupool::{self, CpuPool}; 12 | use log::LogLevel::*; 13 | use rand::{self, thread_rng}; 14 | use rofl::{self, CaptionOutput, CaptionError, Font, ImageMacro, Template}; 15 | use rofl::cache::ThreadSafeCache; 16 | use thread_id; 17 | use tokio_timer::{TimeoutError, Timer, TimerError}; 18 | 19 | use args::Resource; 20 | use super::{FONT_DIR, TEMPLATE_DIR}; 21 | use super::list::{list_templates, list_fonts}; 22 | 23 | 24 | lazy_static! { 25 | /// The singleton instance of Captioner. 26 | pub static ref CAPTIONER: Arc = Arc::new(Captioner::new()); 27 | } 28 | 29 | /// Renders image macros into captioned images. 30 | pub struct Captioner { 31 | pool: Mutex, 32 | engine: rofl::Engine, 33 | timer: Timer, 34 | // Configuration params. 35 | task_timeout: Atomic, 36 | } 37 | 38 | impl Captioner { 39 | #[inline] 40 | fn new() -> Self { 41 | let pool = Mutex::new(Self::pool_builder().create()); 42 | let engine = Self::engine_builder().build() 43 | .expect("failed to create rofl::Engine in Captioner::new"); 44 | let timer = Timer::default(); 45 | 46 | let task_timeout = Atomic::new(Duration::from_secs(0)); 47 | 48 | Captioner{pool, engine, timer, task_timeout} 49 | } 50 | 51 | #[doc(hidden)] 52 | fn pool_builder() -> futures_cpupool::Builder { 53 | let mut builder = futures_cpupool::Builder::new(); 54 | builder.name_prefix("caption-"); 55 | if log_enabled!(Trace) { 56 | builder.after_start(|| trace!( 57 | "Worker thread (ID={:#x}) created in Captioner::pool", 58 | thread_id::get())); 59 | builder.before_stop(|| trace!( 60 | "Stopping worker thread (ID={:#x}) in Captioner::pool", 61 | thread_id::get())); 62 | } 63 | builder 64 | } 65 | 66 | #[doc(hidden)] 67 | fn engine_builder() -> rofl::EngineBuilder { 68 | rofl::EngineBuilder::new() 69 | .template_directory(&*TEMPLATE_DIR) 70 | .font_directory(&*FONT_DIR) 71 | } 72 | } 73 | 74 | impl Captioner { 75 | #[inline] 76 | pub fn template_cache(&self) -> &ThreadSafeCache { 77 | self.engine.template_cache().unwrap() 78 | } 79 | 80 | #[inline] 81 | pub fn font_cache(&self) -> &ThreadSafeCache { 82 | self.engine.font_cache().unwrap() 83 | } 84 | } 85 | 86 | // Configuration tweaks. 87 | impl Captioner { 88 | #[inline] 89 | pub fn set_thread_count(&self, count: usize) -> &Self { 90 | trace!("Setting thread count for image captioning to {}", count); 91 | 92 | let mut builder = Self::pool_builder(); 93 | if count > 0 { 94 | builder.pool_size(count); 95 | } 96 | 97 | let pool = builder.create(); 98 | *self.pool.lock() = pool; 99 | self 100 | } 101 | 102 | #[inline] 103 | pub fn set_task_timeout(&self, timeout: Duration) -> &Self { 104 | let secs = timeout.as_secs(); 105 | if secs > 0 { 106 | trace!("Setting caption request timeout to {} secs", secs); 107 | } else { 108 | trace!("Disabling caption request timeout"); 109 | } 110 | self.task_timeout.store(timeout, Ordering::Relaxed); 111 | self 112 | } 113 | 114 | /// Fill the cache for given type of resource. 115 | pub fn preload(&self, what: Resource) { 116 | let mut rng = thread_rng(); 117 | match what { 118 | Resource::Template => { 119 | let capacity = self.template_cache().capacity(); 120 | debug!("Preloading up to {} templates", capacity); 121 | // TODO: the sampling here is O(N_t*C), so it can be quadratic; 122 | // pick a better method (probably the random_choice crate) 123 | for template in rand::sample(&mut rng, list_templates(), capacity) { 124 | if let Err(e) = self.engine.preload_template(&template) { 125 | warn!("Error preloading template `{}`: {}", template, e); 126 | } 127 | } 128 | } 129 | Resource::Font => { 130 | let capacity = self.font_cache().capacity(); 131 | debug!("Preloading up to {} fonts", capacity); 132 | for font in rand::sample(&mut rng, list_fonts(), capacity) { 133 | if let Err(e) = self.engine.preload_font(&font) { 134 | warn!("Error preloading font `{}`: {}", font, e); 135 | } 136 | } 137 | } 138 | } 139 | } 140 | 141 | #[inline] 142 | pub fn set_jpeg_quality(&self, quality: u8) -> bool { 143 | trace!("Setting generated JPEG quality to {}%", quality); 144 | if !(0 < quality && quality <= 100) { 145 | warn!("JPEG quality out of range: {}%", quality); 146 | return false; 147 | } 148 | self.engine.config_mut().jpeg_quality = quality; 149 | true 150 | } 151 | 152 | #[inline] 153 | pub fn set_gif_quality(&self, quality: u8) -> bool { 154 | trace!("Setting quality of generated GIF animations to {}%", quality); 155 | if !(0 < quality && quality <= 100) { 156 | warn!("GIF animation quality out of range: {}%", quality); 157 | return false; 158 | } 159 | self.engine.config_mut().gif_quality = quality; 160 | true 161 | } 162 | } 163 | 164 | // Rendering code. 165 | impl Captioner { 166 | /// Render an image macro as PNG. 167 | /// The rendering is done in a separate thread. 168 | pub fn render(&self, im: ImageMacro) -> BoxFuture { 169 | let pool = match self.pool.try_lock() { 170 | Ok(p) => p, 171 | Err(_) => { 172 | // Indicates we'd have to wait for the pool lock. 173 | // This should be only possible when set_thread_count() happens 174 | // to have been called at the exact same moment. 175 | warn!("Could not immediately lock CpuPool to render {:?}", im); 176 | // TODO: retry a few times, probably with exponential backoff 177 | return future::err(RenderError::Unavailable).boxed(); 178 | }, 179 | }; 180 | 181 | // Spawn a new task in the thread pool for the rendering process. 182 | let task_future = pool.spawn_fn({ 183 | let im_repr = format!("{:?}", im); 184 | let engine = self.engine.clone(); 185 | move || { 186 | match engine.caption(im) { 187 | Ok(out) => { 188 | debug!("Successfully rendered {} as {:?}, final result size: {} bytes", 189 | im_repr, out.format(), out.len()); 190 | future::ok(out) 191 | }, 192 | Err(e) => { 193 | error!("Failed to render image macro {}: {}", im_repr, e); 194 | future::err(e) 195 | }, 196 | } 197 | } 198 | }).map_err(RenderError::Caption); 199 | 200 | // Impose a timeout on the task. 201 | let max_duration = self.task_timeout.load(Ordering::Relaxed); 202 | if max_duration.as_secs() > 0 { 203 | // TODO: this doesn't seem to actually kill the underlying thread, 204 | // figure out how to do that 205 | self.timer.timeout(task_future, max_duration).boxed() 206 | } else { 207 | task_future.boxed() 208 | } 209 | } 210 | } 211 | 212 | 213 | /// Error that can occur during the image macro rendering process. 214 | #[derive(Debug, Error)] 215 | pub enum RenderError { 216 | /// Error during the captioning process. 217 | Caption(CaptionError), 218 | /// Timeout while performing the caption request. 219 | Timeout, 220 | /// Captioning service temporarily unavailable. 221 | Unavailable, 222 | } 223 | 224 | // Necessary for imposing a timeout on the CaptionTask. 225 | impl From> for RenderError { 226 | fn from(e: TimeoutError) -> Self { 227 | match e { 228 | TimeoutError::Timer(_, TimerError::NoCapacity) => RenderError::Unavailable, 229 | _ => RenderError::Timeout, 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/server/handlers/list.rs: -------------------------------------------------------------------------------- 1 | //! Module with the handlers for listing available resources. 2 | 3 | use std::collections::HashSet; 4 | use std::iter; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use glob; 8 | use rofl::{FONT_FILE_EXTENSION, IMAGE_FORMAT_EXTENSIONS}; 9 | 10 | use super::{TEMPLATE_DIR, FONT_DIR}; 11 | 12 | 13 | /// List all available font names. 14 | pub fn list_fonts() -> Vec { 15 | debug!("Listing all available fonts..."); 16 | 17 | let pattern = format!("{}", 18 | FONT_DIR.join(&format!("*.{}", FONT_FILE_EXTENSION)).display()); 19 | trace!("Globbing with {}", pattern); 20 | let fonts = glob::glob(&pattern).unwrap() 21 | .filter_map(Result::ok) // TODO: report errors about this 22 | .fold(HashSet::new(), |mut ts, t| { 23 | let name = t.file_stem().unwrap().to_str().unwrap().to_owned(); 24 | ts.insert(name); ts 25 | }); 26 | 27 | debug!("{} font(s) found", fonts.len()); 28 | let mut result: Vec<_> = fonts.into_iter().collect(); 29 | result.sort(); 30 | result 31 | } 32 | 33 | 34 | /// List all available template names. 35 | pub fn list_templates() -> Vec { 36 | debug!("Listing all available templates..."); 37 | let templates = glob_templates("*") 38 | .fold(HashSet::new(), |mut ts, t| { 39 | let name = t.file_stem().unwrap().to_str().unwrap().to_owned(); 40 | ts.insert(name); ts 41 | }); 42 | 43 | debug!("{} template(s) found", templates.len()); 44 | let mut result: Vec<_> = templates.into_iter().collect(); 45 | result.sort(); 46 | result 47 | } 48 | 49 | 50 | // Utility functions 51 | 52 | /// Yield paths to template files that have the given file stem. 53 | fn glob_templates(stem: &str) -> Box> { 54 | let file_part = format!("{}.*", stem); 55 | let pattern = format!("{}", TEMPLATE_DIR.join(file_part).display()); 56 | trace!("Globbing with {}", pattern); 57 | 58 | let glob_iter = match glob::glob(&pattern) { 59 | Ok(it) => it, 60 | Err(e) => { 61 | error!("Failed to glob over template files: {}", e); 62 | return Box::new(iter::empty()); 63 | }, 64 | }; 65 | 66 | // We manually filter out unsupported file extensions because the `glob` crate 67 | // doesn't support patterns like foo.{gif|png} (i.e. with braces). 68 | Box::new(glob_iter 69 | .filter_map(Result::ok) // TODO: report errors about this 70 | .filter(|f| { 71 | let ext = extension(f); 72 | IMAGE_FORMAT_EXTENSIONS.keys() 73 | .any(|&e| Some(e) == ext.as_ref().map(|e| e.as_str())) 74 | })) 75 | } 76 | 77 | /// Get the (useful part of) file extension from the path. 78 | fn extension>(path: P) -> Option { 79 | path.as_ref().extension().and_then(|e| e.to_str()) 80 | .map(|s| s.trim().to_lowercase()) 81 | } 82 | -------------------------------------------------------------------------------- /src/server/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | //! Module with the server's request handlers. 2 | 3 | mod captioner; 4 | pub mod list; 5 | pub mod util; 6 | 7 | 8 | use std::env; 9 | use std::error::Error; 10 | use std::path::PathBuf; 11 | 12 | use futures::{BoxFuture, future, Future}; 13 | use hyper::{self, Method, StatusCode, Uri}; 14 | use hyper::header::{ContentLength, ContentType}; 15 | use hyper::server::Response; 16 | use rofl::{CaptionError, ImageMacro}; 17 | use serde_json; 18 | use serde_qs; 19 | 20 | pub use self::captioner::{CAPTIONER, RenderError}; 21 | use self::util::error_response; 22 | 23 | 24 | lazy_static! { 25 | static ref TEMPLATE_DIR: PathBuf = 26 | env::current_dir().unwrap().join("data").join("templates"); 27 | 28 | static ref FONT_DIR: PathBuf = 29 | env::current_dir().unwrap().join("data").join("fonts"); 30 | } 31 | 32 | 33 | /// Handle the image captioning HTTP request. 34 | pub fn caption_macro(method: Method, url: Uri, body: Vec) -> BoxFuture { 35 | let parsed_im: Result<_, Box> = match method { 36 | Method::Get => { 37 | let query = match url.query() { 38 | Some(q) => { trace!("Caption request query string: {}", q); q } 39 | None => { trace!("No query string found in caption request"); "" } 40 | }; 41 | debug!("Decoding image macro spec from {} bytes of query string", 42 | query.len()); 43 | serde_qs::from_str(query).map_err(Into::into) 44 | }, 45 | Method::Post => { 46 | trace!("Caption request body: {}", String::from_utf8_lossy(&body)); 47 | debug!("Decoding image macro spec from {} bytes of JSON", body.len()); 48 | serde_json::from_reader(&*body).map_err(Into::into) 49 | }, 50 | m => { 51 | warn!("Unsupported HTTP method for caption request: {}", m); 52 | let response = Response::new().with_status(StatusCode::MethodNotAllowed) 53 | .with_header(ContentType::plaintext()) 54 | .with_header(ContentLength(0)); 55 | return future::ok(response).boxed(); 56 | }, 57 | }; 58 | 59 | let im: ImageMacro = match parsed_im { 60 | Ok(im) => im, 61 | Err(e) => { 62 | error!("Failed to decode image macro: {}", e); 63 | return future::ok(error_response( 64 | StatusCode::BadRequest, 65 | format!("cannot decode request: {}", e))).boxed(); 66 | }, 67 | }; 68 | debug!("Decoded {:?}", im); 69 | 70 | CAPTIONER.render(im) 71 | .map(|out| { 72 | let mime_type = match out.mime_type() { 73 | Some(mt) => mt, 74 | None => return error_response( 75 | StatusCode::InternalServerError, 76 | format!("invalid format: {:?}", out.format())), 77 | }; 78 | Response::new() 79 | .with_header(ContentType(mime_type)) 80 | .with_header(ContentLength(out.len() as u64)) 81 | .with_body(out.into_bytes()) 82 | }) 83 | .or_else(|e| future::ok(error_response(status_code_for(&e), e))) 84 | .boxed() 85 | } 86 | 87 | 88 | /// Determine the HTTP response code that best corresponds to a caption rendering error. 89 | fn status_code_for(e: &RenderError) -> StatusCode { 90 | match *e { 91 | RenderError::Caption(ref e) => match *e { 92 | CaptionError::Template{..} => StatusCode::NotFound, 93 | CaptionError::Font{..} => StatusCode::NotFound, 94 | CaptionError::Encode(..) => StatusCode::InternalServerError, 95 | }, 96 | RenderError::Timeout => StatusCode::InternalServerError, 97 | RenderError::Unavailable => StatusCode::ServiceUnavailable, 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/server/handlers/util.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for request handlers. 2 | 3 | use hyper::StatusCode; 4 | use hyper::header::{ContentLength, ContentType}; 5 | use hyper::server::Response; 6 | use mime; 7 | use serde_json::Value as Json; 8 | 9 | 10 | /// Create a JSON response. 11 | pub fn json_response(json: Json) -> Response { 12 | let body = json.to_string(); 13 | Response::new() 14 | .with_header(ContentType(mime::APPLICATION_JSON)) 15 | .with_header(ContentLength(body.len() as u64)) 16 | .with_body(body) 17 | } 18 | 19 | /// Create an erroneous JSON response. 20 | pub fn error_response(status_code: StatusCode, message: T) -> Response { 21 | json_response(json!({"error": message.to_string()})) 22 | .with_status(status_code) 23 | } 24 | -------------------------------------------------------------------------------- /src/server/logging.rs: -------------------------------------------------------------------------------- 1 | //! Module implementing logging for the application. 2 | //! 3 | //! This includes setting up log filtering given a verbosity value, 4 | //! as well as defining how the logs are being formatted to stderr. 5 | 6 | use std::borrow::Cow; 7 | use std::collections::HashMap; 8 | use std::env; 9 | use std::io; 10 | 11 | use ansi_term::{Colour, Style}; 12 | use isatty; 13 | use log::SetLoggerError; 14 | use slog::{self, DrainExt, FilterLevel, Level}; 15 | use slog_envlogger::LogBuilder; 16 | use slog_stdlog; 17 | use slog_stream; 18 | use time; 19 | 20 | 21 | // Default logging level defined using the two enums used by slog. 22 | // Both values must correspond to the same level. (This is checked by a test). 23 | const DEFAULT_LEVEL: Level = Level::Info; 24 | const DEFAULT_FILTER_LEVEL: FilterLevel = FilterLevel::Info; 25 | 26 | // Arrays of log levels, indexed by verbosity. 27 | const POSITIVE_VERBOSITY_LEVELS: &'static [FilterLevel] = &[ 28 | DEFAULT_FILTER_LEVEL, 29 | FilterLevel::Debug, 30 | FilterLevel::Trace, 31 | ]; 32 | const NEGATIVE_VERBOSITY_LEVELS: &'static [FilterLevel] = &[ 33 | DEFAULT_FILTER_LEVEL, 34 | FilterLevel::Warning, 35 | FilterLevel::Error, 36 | FilterLevel::Critical, 37 | FilterLevel::Off, 38 | ]; 39 | 40 | 41 | /// Initialize logging with given verbosity. 42 | /// The verbosity value has the same meaning as in args::Options::verbosity. 43 | pub fn init(verbosity: isize) -> Result<(), SetLoggerError> { 44 | let istty = cfg!(unix) && isatty::stderr_isatty(); 45 | let stderr = slog_stream::stream(io::stderr(), LogFormat{tty: istty}); 46 | 47 | // Determine the log filtering level based on verbosity. 48 | // If the argument is excessive, log that but clamp to the highest/lowest log level. 49 | let mut verbosity = verbosity; 50 | let mut excessive = false; 51 | let level = if verbosity >= 0 { 52 | if verbosity >= POSITIVE_VERBOSITY_LEVELS.len() as isize { 53 | excessive = true; 54 | verbosity = POSITIVE_VERBOSITY_LEVELS.len() as isize - 1; 55 | } 56 | POSITIVE_VERBOSITY_LEVELS[verbosity as usize] 57 | } else { 58 | verbosity = -verbosity; 59 | if verbosity >= NEGATIVE_VERBOSITY_LEVELS.len() as isize { 60 | excessive = true; 61 | verbosity = NEGATIVE_VERBOSITY_LEVELS.len() as isize - 1; 62 | } 63 | NEGATIVE_VERBOSITY_LEVELS[verbosity as usize] 64 | }; 65 | 66 | // Include universal logger options, like the level. 67 | let mut builder = LogBuilder::new(stderr); 68 | builder = builder.filter(None, level); 69 | 70 | // Make some of the libraries less chatty 71 | // by raising the minimum logging level for them 72 | // (e.g. Info means that Debug and Trace level logs are filtered). 73 | builder = builder 74 | .filter(Some("hyper"), FilterLevel::Info) 75 | .filter(Some("tokio"), FilterLevel::Info); 76 | 77 | // Include any additional config from environmental variables. 78 | // This will override the options above if necessary, 79 | // so e.g. it is still possible to get full debug output from hyper/tokio. 80 | if let Ok(ref conf) = env::var("RUST_LOG") { 81 | builder = builder.parse(conf); 82 | } 83 | 84 | // Initialize the logger, possibly logging the excessive verbosity option. 85 | let env_logger_drain = builder.build(); 86 | let logger = slog::Logger::root(env_logger_drain.fuse(), o!()); 87 | try!(slog_stdlog::set_logger(logger)); 88 | if excessive { 89 | warn!("-v/-q flag passed too many times, logging level {:?} assumed", level); 90 | } 91 | Ok(()) 92 | } 93 | 94 | 95 | // Log formatting 96 | 97 | /// Token type that's only uses to tell slog-stream how to format our log entries. 98 | struct LogFormat { 99 | pub tty: bool, 100 | } 101 | 102 | impl slog_stream::Format for LogFormat { 103 | /// Format a single log Record and write it to given output. 104 | fn format(&self, output: &mut io::Write, 105 | record: &slog::Record, 106 | _logger_kvp: &slog::OwnedKeyValueList) -> io::Result<()> { 107 | // Format the higher level (more fine-grained) messages with greater detail, 108 | // as they are only visible when user explicitly enables verbose logging. 109 | let msg = if record.level() > DEFAULT_LEVEL { 110 | let logtime = format_log_time(); 111 | let level: String = { 112 | let first_char = record.level().as_str().chars().next().unwrap(); 113 | first_char.to_uppercase().collect() 114 | }; 115 | let module = { 116 | let module = record.module(); 117 | match module.find("::") { 118 | Some(idx) => Cow::Borrowed(&module[idx + 2..]), 119 | None => "main".into(), 120 | } 121 | }; 122 | // Dim the prefix (everything that's not a message) if we're outputting to a TTY. 123 | let prefix_style = if self.tty { *TTY_FINE_PREFIX_STYLE } else { Style::default() }; 124 | let prefix = format!("{}{} {}#{}]", level, logtime, module, record.line()); 125 | format!("{} {}\n", prefix_style.paint(prefix), record.msg()) 126 | } else { 127 | // Colorize the level label if we're outputting to a TTY. 128 | let level: Cow = if self.tty { 129 | let style = TTY_LEVEL_STYLES.get(&record.level().as_usize()) 130 | .cloned() 131 | .unwrap_or_else(Style::default); 132 | format!("{}", style.paint(record.level().as_str())).into() 133 | } else { 134 | record.level().as_str().into() 135 | }; 136 | format!("{}: {}\n", level, record.msg()) 137 | }; 138 | 139 | try!(output.write_all(msg.as_bytes())); 140 | Ok(()) 141 | } 142 | } 143 | 144 | /// Format the timestamp part of a detailed log entry. 145 | fn format_log_time() -> String { 146 | let utc_now = time::now().to_utc(); 147 | let mut logtime = format!("{}", utc_now.rfc3339()); // E.g.: 2012-02-22T14:53:18Z 148 | 149 | // Insert millisecond count before the Z. 150 | let millis = utc_now.tm_nsec / NANOS_IN_MILLISEC; 151 | logtime.pop(); 152 | format!("{}.{:04}Z", logtime, millis) 153 | } 154 | 155 | const NANOS_IN_MILLISEC: i32 = 1000000; 156 | 157 | lazy_static! { 158 | /// Map of log levels to their ANSI terminal styles. 159 | // (Level doesn't implement Hash so it has to be usize). 160 | static ref TTY_LEVEL_STYLES: HashMap = hashmap!{ 161 | Level::Info.as_usize() => Colour::Green.normal(), 162 | Level::Warning.as_usize() => Colour::Yellow.normal(), 163 | Level::Error.as_usize() => Colour::Red.normal(), 164 | Level::Critical.as_usize() => Colour::Purple.normal(), 165 | }; 166 | 167 | /// ANSI terminal style for the prefix (timestamp etc.) of a fine log message. 168 | static ref TTY_FINE_PREFIX_STYLE: Style = Style::new().dimmed(); 169 | } 170 | 171 | 172 | #[cfg(test)] 173 | mod tests { 174 | use slog::FilterLevel; 175 | use super::{DEFAULT_LEVEL, DEFAULT_FILTER_LEVEL, 176 | NEGATIVE_VERBOSITY_LEVELS, POSITIVE_VERBOSITY_LEVELS}; 177 | 178 | /// Check that default logging level is defined consistently. 179 | #[test] 180 | fn default_level() { 181 | let level = DEFAULT_LEVEL.as_usize(); 182 | let filter_level = DEFAULT_FILTER_LEVEL.as_usize(); 183 | assert_eq!(level, filter_level, 184 | "Default logging level is defined inconsistently: Level::{:?} vs. FilterLevel::{:?}", 185 | DEFAULT_LEVEL, DEFAULT_FILTER_LEVEL); 186 | } 187 | 188 | #[test] 189 | fn verbosity_levels() { 190 | assert_eq!(NEGATIVE_VERBOSITY_LEVELS[0], POSITIVE_VERBOSITY_LEVELS[0]); 191 | assert!(NEGATIVE_VERBOSITY_LEVELS.contains(&FilterLevel::Off), 192 | "Verbosity levels don't allow to turn logging off completely"); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/server/main.rs: -------------------------------------------------------------------------------- 1 | //! 2 | //! rofld -- Lulz server 3 | //! 4 | 5 | extern crate antidote; 6 | extern crate atomic; 7 | extern crate ansi_term; 8 | #[macro_use] extern crate clap; 9 | extern crate conv; 10 | #[macro_use] extern crate derive_error; 11 | #[macro_use] extern crate enum_derive; 12 | extern crate enum_set; 13 | extern crate exitcode; 14 | extern crate futures; 15 | extern crate futures_cpupool; 16 | extern crate glob; 17 | extern crate hyper; 18 | extern crate isatty; 19 | extern crate itertools; 20 | #[macro_use] extern crate lazy_static; 21 | #[macro_use] extern crate macro_attr; 22 | #[macro_use] extern crate maplit; 23 | extern crate mime; 24 | extern crate nix; 25 | extern crate num; 26 | extern crate rand; 27 | extern crate regex; 28 | extern crate rofl; 29 | extern crate serde; 30 | #[macro_use] extern crate serde_json; 31 | extern crate serde_qs; 32 | extern crate slog_envlogger; 33 | extern crate slog_stdlog; 34 | extern crate slog_stream; 35 | extern crate thread_id; 36 | extern crate tokio_core; 37 | extern crate tokio_signal; 38 | extern crate tokio_timer; 39 | extern crate time; 40 | extern crate unreachable; 41 | 42 | // `slog` must precede `log` in declarations here, because we want to simultaneously: 43 | // * use the standard `log` macros (at least for a while) 44 | // * be able to initialize the slog logger using slog macros like o!() 45 | #[macro_use] extern crate slog; 46 | #[macro_use] extern crate log; 47 | 48 | 49 | #[cfg(test)] #[macro_use] extern crate spectral; 50 | 51 | 52 | mod args; 53 | mod ext; 54 | mod handlers; 55 | mod logging; 56 | mod service; 57 | 58 | 59 | use std::borrow::Cow; 60 | use std::error::Error; 61 | use std::env; 62 | use std::io::{self, Write}; 63 | use std::process::exit; 64 | 65 | use futures::{BoxFuture, Future, Stream, stream}; 66 | use hyper::server::{Http, NewService, Request, Response, Server}; 67 | use log::LogLevel::*; 68 | use tokio_core::reactor::Handle; 69 | 70 | use args::{ArgsError, Options, Resource}; 71 | use handlers::CAPTIONER; 72 | 73 | 74 | lazy_static! { 75 | /// Application / package name, as filled out by Cargo. 76 | static ref NAME: &'static str = option_env!("CARGO_PKG_NAME").unwrap_or("rofld"); 77 | 78 | /// Application version, as filled out by Cargo. 79 | static ref VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); 80 | 81 | /// Application revision, such as Git SHA. 82 | /// This is generated by a build script and written to an output file. 83 | static ref REVISION: Option<&'static str> = { 84 | let revision = include_str!(concat!(env!("OUT_DIR"), "/", "REVISION")); 85 | if revision.trim().is_empty() { None } else { Some(revision) } 86 | }; 87 | 88 | /// Metadata about the Rust compiler used to build the binary. 89 | /// Like REVISION, this is generated by a build script. 90 | static ref COMPILER: Option<&'static str> = { 91 | let signature = include_str!(concat!(env!("OUT_DIR"), "/", "COMPILER")); 92 | if signature.trim().is_empty() { None } else { Some(signature) } 93 | }; 94 | } 95 | 96 | 97 | fn main() { 98 | let opts = args::parse().unwrap_or_else(|e| { 99 | print_args_error(e).unwrap(); 100 | exit(exitcode::USAGE); 101 | }); 102 | 103 | logging::init(opts.verbosity).unwrap(); 104 | log_signature(); 105 | if log_enabled!(Debug) { 106 | if let Some(pid) = get_process_id() { 107 | debug!("PID = {}", pid); 108 | } 109 | if log_enabled!(Trace) { 110 | trace!("Main thread ID = {:#x}", thread_id::get()); 111 | } 112 | } 113 | if cfg!(debug_assertions) { 114 | warn!("Debug mode! The program will likely be much slower."); 115 | } 116 | 117 | for (i, arg) in env::args().enumerate() { 118 | debug!("argv[{}] = {:?}", i, arg); 119 | } 120 | trace!("Server config parsed from argv:\n{:#?}", opts); 121 | 122 | start_server(opts); 123 | debug!("Finishing main()."); 124 | } 125 | 126 | /// Print an error that may occur while parsing arguments. 127 | fn print_args_error(e: ArgsError) -> io::Result<()> { 128 | match e { 129 | ArgsError::Parse(ref e) => 130 | // In case of generic parse error, 131 | // message provided by the clap library will be the usage string. 132 | writeln!(&mut io::stderr(), "{}", e.message), 133 | e => { 134 | let mut msg: Cow = "Failed to parse arguments".into(); 135 | if let Some(cause) = e.cause() { 136 | msg = Cow::Owned(msg.into_owned() + &format!(": {}", cause)); 137 | } 138 | writeln!(&mut io::stderr(), "{}", msg) 139 | }, 140 | } 141 | } 142 | 143 | /// Log the program name, version, and other metadata. 144 | #[inline] 145 | fn log_signature() { 146 | if log_enabled!(Info) { 147 | let version = VERSION.map(|v| format!("v{}", v)) 148 | .unwrap_or_else(|| "".into()); 149 | let revision = REVISION.map(|r| format!(" (rev. {})", r)) 150 | .unwrap_or_else(|| "".into()); 151 | info!("{} {}{}", *NAME, version, revision); 152 | } 153 | if log_enabled!(Debug) { 154 | if let Some(compiler) = *COMPILER { 155 | debug!("Built with {}", compiler); 156 | } 157 | } 158 | } 159 | 160 | #[cfg(unix)] 161 | fn get_process_id() -> Option { 162 | Some(nix::unistd::getpid() as i64) 163 | } 164 | 165 | #[cfg(not(unix))] 166 | fn get_process_id() -> Option { 167 | warn!("Cannot retrieve process ID on non-Unix platforms"); 168 | None 169 | } 170 | 171 | 172 | /// Start the server with given options. 173 | /// This function only terminates when the server finishes. 174 | fn start_server(opts: Options) { 175 | info!("Starting the server to listen on {}...", opts.address); 176 | let mut server = Http::new().bind(&opts.address, || Ok(service::Rofl)).unwrap(); 177 | 178 | set_config(opts, &mut server); 179 | let ctrl_c = create_ctrl_c_handler(&server.handle()); 180 | 181 | debug!("Entering event loop..."); 182 | server.run_until(ctrl_c).unwrap_or_else(|e| { 183 | error!("Failed to start the server's event loop: {}", e); 184 | exit(exitcode::IOERR); 185 | }); 186 | 187 | info!("Server stopped."); 188 | } 189 | 190 | /// Set configuration options from the command line flags. 191 | fn set_config(opts: Options, server: &mut Server) 192 | where S: NewService, 194 | Error=hyper::Error> + Send + Sync + 'static, 195 | B: Stream + 'static, B::Item: AsRef<[u8]> 196 | { 197 | trace!("Setting configuration options..."); 198 | 199 | if let Some(rt_count) = opts.render_threads { 200 | CAPTIONER.set_thread_count(rt_count); 201 | debug!("Number of threads for image captioning set to {}", rt_count); 202 | } 203 | if let Some(quality) = opts.gif_quality { 204 | if CAPTIONER.set_gif_quality(quality) { 205 | debug!("GIF animation quality set to {}%", quality); 206 | } 207 | } 208 | if let Some(quality) = opts.jpeg_quality { 209 | if CAPTIONER.set_jpeg_quality(quality) { 210 | debug!("JPEG image quality set to {}%", quality); 211 | } 212 | } 213 | 214 | if let Some(tcs) = opts.template_cache_size { 215 | CAPTIONER.template_cache().set_capacity(tcs); 216 | debug!("Size of the template cache set to {}", tcs); 217 | } 218 | if let Some(fcs) = opts.font_cache_size { 219 | CAPTIONER.font_cache().set_capacity(fcs); 220 | debug!("Size of the font cache set to {}", fcs); 221 | } 222 | if !opts.preload.is_empty() { 223 | preload_resources(opts.preload.iter()); 224 | } 225 | 226 | server.shutdown_timeout(opts.shutdown_timeout); 227 | if opts.shutdown_timeout.as_secs() > 0 { 228 | debug!("Shutdown timeout set to {} secs", opts.shutdown_timeout.as_secs()); 229 | } else { 230 | debug!("Shutdown timeout disabled."); 231 | } 232 | 233 | CAPTIONER.set_task_timeout(opts.request_timeout); 234 | if opts.request_timeout.as_secs() > 0 { 235 | debug!("Request timeout set to {} secs", opts.request_timeout.as_secs()); 236 | } else { 237 | debug!("Request timeout disabled."); 238 | } 239 | } 240 | 241 | /// Preload resources of specified types, logging how long it took. 242 | fn preload_resources>(preload: I) { 243 | let preload: Vec<_> = preload.collect(); 244 | if log_enabled!(Trace) { 245 | trace!("Preloading: {}...", format_resources(&*preload)); 246 | } 247 | 248 | let start = time::precise_time_s(); 249 | for &resource in &preload { 250 | CAPTIONER.preload(resource); 251 | } 252 | let finish = time::precise_time_s(); 253 | 254 | if log_enabled!(Debug) { 255 | debug!("Done preloading resources ({}), total time elapsed: {:.3} secs", 256 | format_resources(&*preload), finish - start); 257 | } 258 | 259 | fn format_resources(resources: &[Resource]) -> String { 260 | resources.iter() 261 | .map(|r| format!("{:?}s", r).to_lowercase().to_owned()) 262 | .collect::>().join(", ").to_owned() 263 | } 264 | } 265 | 266 | /// Handle ^C and return a future a future that resolves when it's pressed. 267 | fn create_ctrl_c_handler(handle: &Handle) -> BoxFuture<(), ()> { 268 | let max_ctrl_c_count = 3; 269 | trace!("Setting up ^C handler: once to shutdown gracefully, {} times to abort...", 270 | max_ctrl_c_count); 271 | 272 | tokio_signal::ctrl_c(handle) 273 | .flatten_stream() // Future -> Stream (with delayed first element) 274 | .map_err(|e| { error!("Error while handling ^C: {:?}", e); e }) 275 | .zip(stream::iter((1..).into_iter().map(Ok))) 276 | .map(move |(x, i)| { 277 | match i { 278 | 1 => info!("Received shutdown signal..."), 279 | i if i == max_ctrl_c_count => { info!("Aborted."); exit(exitcode::OK); }, 280 | i => debug!("Got repeated ^C, {} more to abort", max_ctrl_c_count - i), 281 | }; 282 | x 283 | }) 284 | .into_future() // Stream => Future<(first, rest)> 285 | .then(|_| Ok(())) 286 | .boxed() 287 | } 288 | -------------------------------------------------------------------------------- /src/server/service.rs: -------------------------------------------------------------------------------- 1 | //! Module with the service that implements ALL the functionality. 2 | 3 | use std::hash::Hash; 4 | use std::time::{Duration, SystemTime}; 5 | 6 | use futures::{BoxFuture, future, Future}; 7 | use hyper::{self, Get, StatusCode}; 8 | use hyper::header::{Expires, ContentLength, ContentType}; 9 | use hyper::server::{Service, Request, Response}; 10 | use rofl::cache::ThreadSafeCache; 11 | use serde_json::Value as Json; 12 | use time::precise_time_s; 13 | 14 | use ext::hyper::BodyExt; 15 | use handlers::{CAPTIONER, caption_macro}; 16 | use handlers::list::{list_fonts, list_templates}; 17 | use handlers::util::json_response; 18 | 19 | 20 | pub struct Rofl; 21 | 22 | impl Service for Rofl { 23 | type Request = Request; 24 | type Response = Response; 25 | type Error = hyper::Error; 26 | type Future = BoxFuture; 27 | 28 | fn call(&self, req: Request) -> Self::Future { 29 | // TODO: log the request after the response is served, in Common Log Format; 30 | // need to retain the request info first 31 | self.log(&req); 32 | 33 | let start = precise_time_s(); 34 | self.handle(req).map(move |mut resp| { 35 | Self::fix_headers(&mut resp); 36 | 37 | let finish = precise_time_s(); 38 | debug!("HTTP {status}, produced {len} bytes of {ctype} in {time:.3} secs", 39 | status = resp.status(), 40 | len = if resp.headers().has::() { 41 | format!("{}", **resp.headers().get::().unwrap()) 42 | } else { 43 | "unknown number of".into() 44 | }, 45 | ctype = resp.headers().get::().unwrap(), 46 | time = finish - start); 47 | resp 48 | }).boxed() 49 | } 50 | } 51 | impl Rofl { 52 | fn handle(&self, req: Request) -> ::Future { 53 | match (req.method(), req.path()) { 54 | (_, "/caption") => self.handle_caption(req), 55 | (&Get, "/templates") => self.handle_list_templates(req), 56 | (&Get, "/fonts") => self.handle_list_fonts(req), 57 | (&Get, "/stats") => self.handle_stats(req), 58 | _ => self.handle_404(req), 59 | } 60 | } 61 | 62 | fn handle_404(&self, req: Request) -> ::Future { 63 | debug!("Path {} doesn't match any endpoint", req.path()); 64 | let response = Response::new().with_status(StatusCode::NotFound) 65 | .with_header(ContentType::plaintext()) 66 | .with_header(ContentLength(0)); 67 | future::ok(response).boxed() 68 | } 69 | } 70 | 71 | // Request handlers. 72 | impl Rofl { 73 | /// Handle the image captioning request. 74 | fn handle_caption(&self, request: Request) -> ::Future { 75 | let (method, url, _, _, body) = request.deconstruct(); 76 | body.into_bytes() 77 | .and_then(move |body| caption_macro(method, url, body)) 78 | .boxed() 79 | } 80 | 81 | /// Handle the template listing request. 82 | fn handle_list_templates(&self, _: Request) -> ::Future { 83 | let template_names = list_templates(); 84 | let response = json_response(json!(template_names)); 85 | future::ok(response).boxed() 86 | } 87 | 88 | /// Handle the font listing request. 89 | fn handle_list_fonts(&self, _: Request) -> ::Future { 90 | let font_names = list_fonts(); 91 | let response = json_response(json!(font_names)); 92 | future::ok(response).boxed() 93 | } 94 | 95 | /// Handle the server statistics request. 96 | fn handle_stats(&self, _: Request) -> ::Future { 97 | let stats = json!({ 98 | "cache": { 99 | "templates": cache_stats(CAPTIONER.template_cache()), 100 | "fonts": cache_stats(CAPTIONER.font_cache()), 101 | } 102 | }); 103 | return future::ok(json_response(stats)).boxed(); 104 | 105 | fn cache_stats(cache: &ThreadSafeCache) -> Json { 106 | let capacity = cache.capacity(); 107 | json!({ 108 | "capacity": capacity, 109 | "fill_rate": cache.len() as f32 / capacity as f32, 110 | "misses": cache.misses(), 111 | "hits": cache.hits(), 112 | }) 113 | } 114 | } 115 | } 116 | 117 | impl Rofl { 118 | #[inline] 119 | fn log(&self, req: &Request) { 120 | info!("{} {} {}{} {}", 121 | req.remote_addr().map(|a| format!("{}", a.ip())).unwrap_or_else(|| "-".to_owned()), 122 | format!("{}", req.method()).to_uppercase(), 123 | req.path(), 124 | req.query().map(|q| format!("?{}", q)).unwrap_or_else(String::new), 125 | req.version()); 126 | } 127 | 128 | /// Fix headers in the response, providing default values where necessary. 129 | fn fix_headers(resp: &mut Response) { 130 | if !resp.headers().has::() { 131 | resp.headers_mut().set(ContentType::octet_stream()); 132 | } 133 | if !resp.headers().has::() { 134 | let century = Duration::from_secs(100 * 365 * 24 * 60 * 60); 135 | let far_future = SystemTime::now() + century; 136 | resp.headers_mut().set(Expires(far_future.into())); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /zoidberg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xion/rofld/8a8b24822c43838920b6052c8099549e2d1f5e2c/zoidberg.png --------------------------------------------------------------------------------