#![forbid(unsafe_code)]
#![allow(
clippy::print_stdout,
reason = "stdout is this binary's output channel"
)]
use std::{
io::Write as _,
path::{Path, PathBuf},
process::ExitCode,
};
use clap::{Parser, Subcommand};
use meo_canvas_core::{
Error, ImageFormat, Renderer, chained, encode::EncodeOptions,
};
use meo_canvas_scene::Scene;
const EXIT_IO: u8 = 3;
const EXIT_MALFORMED_SCENE: u8 = 4;
const EXIT_FONT: u8 = 5;
const EXIT_UNRESOLVED_SOURCE: u8 = 6;
const EXIT_RENDER: u8 = 7;
#[derive(Debug, Parser)]
#[command(name = "meo-canvas", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Render(RenderArgs),
}
#[derive(Debug, Parser)]
struct RenderArgs {
scene: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
format: Option<String>,
#[arg(long = "font", value_name = "FAMILY=PATH")]
fonts: Vec<String>,
#[arg(long)]
quality: Option<f32>,
#[arg(long)]
lossless: bool,
#[arg(long)]
page: Option<usize>,
#[arg(long)]
fps: Option<f32>,
#[arg(long)]
loops: Option<u32>,
}
#[derive(Debug)]
struct Failure {
message: String,
code: u8,
}
impl Failure {
fn new(message: impl Into<String>, code: u8) -> Self {
Self {
message: message.into(),
code,
}
}
}
const fn exit_code_for(error: &Error) -> u8 {
match error {
Error::UnresolvedSource(_) => EXIT_UNRESOLVED_SOURCE,
Error::UnknownFont(_) | Error::FontRegister { .. } => EXIT_FONT,
Error::ImageRead { .. } => EXIT_IO,
_ => EXIT_RENDER,
}
}
fn explain(error: &Error) -> String {
match error {
Error::UnresolvedSource(_) if cfg!(not(feature = "net")) => {
format!("{error}; build with `--features net` to fetch it")
}
other => chained(other),
}
}
fn parse_font(pair: &str) -> Result<(&str, &Path), Failure> {
let (family, path) = pair.split_once('=').ok_or_else(|| {
Failure::new(
format!("--font expects `family=path`, not {pair:?}"),
EXIT_FONT,
)
})?;
if family.is_empty() {
return Err(Failure::new(
format!("--font {pair:?} names no family"),
EXIT_FONT,
));
}
Ok((family, Path::new(path)))
}
fn resolve_format(args: &RenderArgs) -> Result<ImageFormat, Failure> {
if let Some(name) = &args.format {
return ImageFormat::from_extension(name).ok_or_else(|| {
Failure::new(format!("{name:?} names no format"), EXIT_IO)
});
}
let extension = args
.output
.as_ref()
.and_then(|path| path.extension())
.and_then(std::ffi::OsStr::to_str)
.ok_or_else(|| {
Failure::new(
"no --format, and the output names no extension to infer one from",
EXIT_IO,
)
})?;
ImageFormat::from_extension(extension).ok_or_else(|| {
Failure::new(
format!("the output extension {extension:?} names no format"),
EXIT_IO,
)
})
}
fn read_scene(path: &Path) -> Result<Scene, Failure> {
let bytes = std::fs::read(path).map_err(|source| {
Failure::new(
format!("cannot read {}: {source}", path.display()),
EXIT_IO,
)
})?;
meo_canvas_scene::codec::decode(&bytes).map_err(|source| {
Failure::new(
format!(
"{} is not a scene this build reads: {source}",
path.display()
),
EXIT_MALFORMED_SCENE,
)
})
}
fn build_renderer(pairs: &[String]) -> Result<Renderer, Failure> {
let mut renderer = Renderer::new();
for pair in pairs {
let (family, path) = parse_font(pair)?;
renderer
.register_font(family, path)
.map_err(|source| Failure::new(explain(&source), EXIT_FONT))?;
}
Ok(renderer)
}
fn encode_options(args: &RenderArgs) -> EncodeOptions {
EncodeOptions {
quality: args.quality,
lossless: args.lossless.then_some(true),
matte: None,
page: args.page,
fps: args.fps,
frame_delays: Vec::new(),
loops: args.loops,
}
}
fn write_output(bytes: &[u8], output: Option<&Path>) -> Result<(), Failure> {
output.map_or_else(
|| {
std::io::stdout().write_all(bytes).map_err(|source| {
Failure::new(
format!("cannot write to stdout: {source}"),
EXIT_IO,
)
})
},
|path| {
std::fs::write(path, bytes).map_err(|source| {
Failure::new(
format!("cannot write {}: {source}", path.display()),
EXIT_IO,
)
})
},
)
}
fn render(args: &RenderArgs) -> Result<(), Failure> {
let format = resolve_format(args)?;
let scene = read_scene(&args.scene)?;
let renderer = build_renderer(&args.fonts)?;
let options = encode_options(args);
let image = renderer
.render_to_buffer(&scene, format, &options)
.map_err(|error| {
Failure::new(explain(&error), exit_code_for(&error))
})?;
write_output(&image, args.output.as_deref())
}
fn main() -> ExitCode {
let cli = Cli::parse();
let Command::Render(args) = &cli.command;
match render(args) {
Ok(()) => ExitCode::SUCCESS,
Err(failure) => {
eprintln!("meo-canvas: {}", failure.message);
ExitCode::from(failure.code)
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use meo_canvas_core::Error;
use super::{
EXIT_FONT, EXIT_IO, EXIT_UNRESOLVED_SOURCE, ImageFormat, RenderArgs,
encode_options, exit_code_for, parse_font, resolve_format,
};
fn bare(scene: &str, output: Option<&str>) -> RenderArgs {
RenderArgs {
scene: PathBuf::from(scene),
output: output.map(PathBuf::from),
format: None,
fonts: Vec::new(),
quality: None,
lossless: false,
page: None,
fps: None,
loops: None,
}
}
#[test]
fn a_font_pair_splits_on_the_first_equals() {
let (family, path) = parse_font("Inter=/fonts/a=b.ttf")
.unwrap_or_else(|failure| unreachable!("{}", failure.message));
assert_eq!(family, "Inter");
assert_eq!(path.to_string_lossy(), "/fonts/a=b.ttf");
}
#[test]
fn a_font_without_a_family_is_refused() {
for pair in ["/fonts/Inter.ttf", "=/fonts/Inter.ttf"] {
let failure = parse_font(pair)
.err()
.unwrap_or_else(|| unreachable!("{pair} names no family"));
assert_eq!(failure.code, EXIT_FONT);
}
}
#[test]
fn the_output_extension_names_the_format() {
let args = bare("scene.mcs", Some("out.webp"));
let format = resolve_format(&args)
.unwrap_or_else(|failure| unreachable!("{}", failure.message));
assert_eq!(format, ImageFormat::Webp);
}
#[test]
fn a_named_format_wins_over_the_extension() {
let mut args = bare("scene.mcs", Some("out.webp"));
args.format = Some("png".to_owned());
let format = resolve_format(&args)
.unwrap_or_else(|failure| unreachable!("{}", failure.message));
assert_eq!(format, ImageFormat::Png);
}
#[test]
fn a_name_and_an_extension_that_name_no_format_are_both_refused() {
let mut named = bare("scene.mcs", Some("out.png"));
named.format = Some("nonsense".to_owned());
let failure = resolve_format(&named)
.err()
.unwrap_or_else(|| unreachable!("nonsense names no format"));
assert_eq!(failure.code, EXIT_IO);
assert!(failure.message.contains("nonsense"), "{}", failure.message);
let inferred = bare("scene.mcs", Some("out.xyz"));
let failure = resolve_format(&inferred)
.err()
.unwrap_or_else(|| unreachable!("xyz names no format"));
assert_eq!(failure.code, EXIT_IO);
assert!(failure.message.contains("xyz"), "{}", failure.message);
}
#[test]
fn an_unreadable_image_is_an_io_class_rather_than_a_render_one() {
let error = Error::image_read(
"/no-such-directory/a.png".to_owned(),
std::io::Error::from(std::io::ErrorKind::NotFound),
);
assert_eq!(exit_code_for(&error), EXIT_IO);
}
#[test]
fn nothing_to_infer_from_is_refused_rather_than_defaulted() {
for output in [None, Some("out")] {
let failure = resolve_format(&bare("scene.mcs", output))
.err()
.unwrap_or_else(|| unreachable!("nothing names a format"));
assert_eq!(failure.code, EXIT_IO);
}
}
#[test]
fn an_unset_flag_leaves_the_renderers_default() {
let options = encode_options(&bare("scene.mcs", Some("out.webp")));
assert_eq!(options.lossless, None);
assert_eq!(options.fps, None);
assert_eq!(options.quality, None);
}
fn scratch(name: &str) -> PathBuf {
std::env::temp_dir()
.join(format!("meo-canvas-cli-{}-{name}", std::process::id()))
}
#[test]
fn a_named_output_receives_the_bytes_and_an_unwritable_path_is_an_io_failure()
{
let path = scratch("write-output.bin");
super::write_output(b"pixels", Some(&path))
.unwrap_or_else(|failure| unreachable!("{}", failure.message));
let written = std::fs::read(&path)
.unwrap_or_else(|source| unreachable!("{source}"));
assert_eq!(written, b"pixels");
drop(std::fs::remove_file(&path));
let missing = path.join("no-such-directory").join("out.png");
let failure = super::write_output(b"pixels", Some(&missing))
.err()
.unwrap_or_else(|| {
unreachable!("a missing directory cannot be written to")
});
assert_eq!(failure.code, EXIT_IO);
}
#[test]
fn bytes_that_are_not_a_scene_are_a_distinct_failure_from_a_missing_file() {
let path = scratch("not-a-scene.mcs");
std::fs::write(&path, b"not a scene at all")
.unwrap_or_else(|source| unreachable!("{source}"));
let malformed = super::read_scene(&path)
.err()
.unwrap_or_else(|| unreachable!("those bytes are not a scene"));
drop(std::fs::remove_file(&path));
let missing = super::read_scene(std::path::Path::new(
"/no-such-directory/no-such-scene.mcs",
))
.err()
.unwrap_or_else(|| unreachable!("that file does not exist"));
assert_eq!(malformed.code, super::EXIT_MALFORMED_SCENE);
assert_eq!(missing.code, EXIT_IO);
assert_ne!(malformed.code, missing.code);
}
#[test]
fn a_font_file_that_is_not_there_fails_as_a_font_rather_than_as_io() {
let failure = super::build_renderer(&[
"Inter=/no-such-directory/Inter.ttf".to_owned(),
])
.err()
.unwrap_or_else(|| unreachable!("that font is not there"));
assert_eq!(failure.code, EXIT_FONT);
}
#[test]
fn registering_nothing_succeeds_and_leaves_the_platforms_faces() {
assert!(super::build_renderer(&[]).is_ok());
}
#[test]
fn a_url_source_names_the_feature_that_would_fetch_it() {
let message = super::explain(&Error::UnresolvedSource(
meo_canvas_scene::NodeId::ROOT,
));
if cfg!(feature = "net") {
assert!(!message.contains("--features net"));
} else {
assert!(
message.contains("--features net"),
"the message should say how to obtain it: {message}"
);
}
}
#[test]
fn each_failure_class_exits_differently() {
let unresolved = exit_code_for(&Error::UnresolvedSource(
meo_canvas_scene::NodeId::ROOT,
));
let font = exit_code_for(&Error::UnknownFont("Inter".to_owned()));
let layout = exit_code_for(&Error::Layout("no".to_owned()));
assert_eq!(unresolved, EXIT_UNRESOLVED_SOURCE);
assert_eq!(font, EXIT_FONT);
assert_ne!(layout, unresolved);
assert_ne!(layout, font);
}
}