diff options
author | Federico Mena Quintero <federico@gnome.org> | 2023-05-08 13:25:18 -0600 |
---|---|---|
committer | Federico Mena Quintero <federico@gnome.org> | 2023-05-08 14:21:51 -0600 |
commit | 859725ed5b75ba57c4344f68f488c407291f6f3a (patch) | |
tree | b8c03ff359e854386f1c8dbf567d275338c66c64 /rsvg_convert/src | |
parent | 3ac8b7ca56524aac1d53b383935c8dfe03b12967 (diff) | |
download | librsvg-859725ed5b75ba57c4344f68f488c407291f6f3a.tar.gz |
(#956): Rename rsvg-convert directory to rsvg_convert to avoid clashing with the rsvg-convert binary
When building with autotools and srcdir==builddir,
./target/.../rsvg-convert gets moved into ./rsvg-convert. But since
./rsvg-convert is already a directory, we get an
./rsvg-convert/rsvg-convert binary.
Later, "make install" finds that ./rsvg-convert is a directory, not a
file, and fails to install the binary.
This renames the rsvg-convert directory to rsvg_convert, but makes
Cargo keep the name of the binary as rsvg-convert. Another option
would be to let Cargo use rsvg_convert per the package/directory name,
and rename the resulting binary.
Thanks to Abderrahim Kitouni for figuring this out!
Fixes https://gitlab.gnome.org/GNOME/librsvg/-/issues/956
Part-of: <https://gitlab.gnome.org/GNOME/librsvg/-/merge_requests/829>
Diffstat (limited to 'rsvg_convert/src')
-rw-r--r-- | rsvg_convert/src/main.rs | 1552 |
1 files changed, 1552 insertions, 0 deletions
diff --git a/rsvg_convert/src/main.rs b/rsvg_convert/src/main.rs new file mode 100644 index 00000000..0a8c527d --- /dev/null +++ b/rsvg_convert/src/main.rs @@ -0,0 +1,1552 @@ +use clap::crate_version; +use clap_complete::{Generator, Shell}; + +use gio::prelude::*; +use gio::{Cancellable, FileCreateFlags, InputStream, OutputStream}; + +#[cfg(unix)] +use gio::{UnixInputStream, UnixOutputStream}; + +#[cfg(windows)] +mod windows_imports { + pub use gio::{Win32InputStream, WriteOutputStream}; + pub use glib::ffi::gboolean; + pub use glib::translate::*; + pub use libc::c_void; + pub use std::io; + pub use std::os::windows::io::AsRawHandle; +} +#[cfg(windows)] +use self::windows_imports::*; + +use cssparser::{_cssparser_internal_to_lowercase, match_ignore_ascii_case}; + +use librsvg_c::{handle::PathOrUrl, sizing::LegacySize}; +use rsvg::rsvg_convert_only::{ + AspectRatio, CssLength, Horizontal, Length, Normalize, NormalizeParams, Parse, Signed, ULength, + Unsigned, Validate, Vertical, ViewBox, +}; +use rsvg::{ + AcceptLanguage, CairoRenderer, Color, Dpi, Language, LengthUnit, Loader, Rect, RenderingError, +}; + +use std::ffi::OsString; +use std::io; +use std::ops::Deref; +use std::path::PathBuf; + +#[derive(Debug)] +pub struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<cairo::Error> for Error { + fn from(s: cairo::Error) -> Self { + match s { + cairo::Error::InvalidSize => Self(String::from( + "The resulting image would be larger than 32767 pixels on either dimension.\n\ + Librsvg currently cannot render to images bigger than that.\n\ + Please specify a smaller size.", + )), + e => Self(format!("{e}")), + } + } +} + +macro_rules! impl_error_from { + ($err:ty) => { + impl From<$err> for Error { + fn from(e: $err) -> Self { + Self(format!("{e}")) + } + } + }; +} + +impl_error_from!(RenderingError); +impl_error_from!(cairo::IoError); +impl_error_from!(cairo::StreamWithError); +impl_error_from!(clap::Error); + +macro_rules! error { + ($($arg:tt)*) => (Error(std::format!($($arg)*))); +} + +#[derive(Clone, Copy, Debug)] +struct Scale { + pub x: f64, + pub y: f64, +} + +impl Scale { + #[allow(clippy::float_cmp)] + pub fn is_identity(&self) -> bool { + self.x == 1.0 && self.y == 1.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Size { + pub w: f64, + pub h: f64, +} + +impl Size { + pub fn new(w: f64, h: f64) -> Self { + Self { w, h } + } +} + +#[derive(Clone, Copy, Debug)] +enum ResizeStrategy { + Scale(Scale), + Fit { + size: Size, + keep_aspect_ratio: bool, + }, + FitWidth(f64), + FitHeight(f64), + ScaleWithMaxSize { + scale: Scale, + max_width: Option<f64>, + max_height: Option<f64>, + keep_aspect_ratio: bool, + }, +} + +impl ResizeStrategy { + pub fn apply(self, input: &Size) -> Option<Size> { + if input.w == 0.0 || input.h == 0.0 { + return None; + } + + let output_size = match self { + ResizeStrategy::Scale(s) => Size::new(input.w * s.x, input.h * s.y), + + ResizeStrategy::Fit { + size, + keep_aspect_ratio, + } => { + if keep_aspect_ratio { + let aspect = AspectRatio::parse_str("xMinYMin meet").unwrap(); + let rect = aspect.compute( + &ViewBox::from(Rect::from_size(input.w, input.h)), + &Rect::from_size(size.w, size.h), + ); + Size::new(rect.width(), rect.height()) + } else { + size + } + } + + ResizeStrategy::FitWidth(w) => Size::new(w, input.h * w / input.w), + + ResizeStrategy::FitHeight(h) => Size::new(input.w * h / input.h, h), + + ResizeStrategy::ScaleWithMaxSize { + scale, + max_width, + max_height, + keep_aspect_ratio, + } => { + let scaled = Size::new(input.w * scale.x, input.h * scale.y); + + match (max_width, max_height, keep_aspect_ratio) { + (None, None, _) => scaled, + + (Some(max_width), Some(max_height), false) => { + if scaled.w <= max_width && scaled.h <= max_height { + scaled + } else { + Size::new(max_width, max_height) + } + } + + (Some(max_width), Some(max_height), true) => { + if scaled.w <= max_width && scaled.h <= max_height { + scaled + } else { + let aspect = AspectRatio::parse_str("xMinYMin meet").unwrap(); + let rect = aspect.compute( + &ViewBox::from(Rect::from_size(scaled.w, scaled.h)), + &Rect::from_size(max_width, max_height), + ); + Size::new(rect.width(), rect.height()) + } + } + + (Some(max_width), None, false) => { + if scaled.w <= max_width { + scaled + } else { + Size::new(max_width, scaled.h) + } + } + + (Some(max_width), None, true) => { + if scaled.w <= max_width { + scaled + } else { + let factor = max_width / scaled.w; + Size::new(max_width, scaled.h * factor) + } + } + + (None, Some(max_height), false) => { + if scaled.h <= max_height { + scaled + } else { + Size::new(scaled.w, max_height) + } + } + + (None, Some(max_height), true) => { + if scaled.h <= max_height { + scaled + } else { + let factor = max_height / scaled.h; + Size::new(scaled.w * factor, max_height) + } + } + } + } + }; + + Some(output_size) + } +} + +enum Surface { + Png(cairo::ImageSurface, OutputStream), + #[cfg(system_deps_have_cairo_pdf)] + Pdf(cairo::PdfSurface, Size), + #[cfg(system_deps_have_cairo_ps)] + Ps(cairo::PsSurface, Size), + #[cfg(system_deps_have_cairo_svg)] + Svg(cairo::SvgSurface, Size), +} + +impl Deref for Surface { + type Target = cairo::Surface; + + fn deref(&self) -> &cairo::Surface { + match self { + Self::Png(surface, _) => surface, + #[cfg(system_deps_have_cairo_pdf)] + Self::Pdf(surface, _) => surface, + #[cfg(system_deps_have_cairo_ps)] + Self::Ps(surface, _) => surface, + #[cfg(system_deps_have_cairo_svg)] + Self::Svg(surface, _) => surface, + } + } +} + +impl AsRef<cairo::Surface> for Surface { + fn as_ref(&self) -> &cairo::Surface { + self + } +} + +impl Surface { + pub fn new( + format: Format, + size: Size, + stream: OutputStream, + unit: LengthUnit, + ) -> Result<Self, Error> { + match format { + Format::Png => Self::new_for_png(size, stream), + Format::Pdf => Self::new_for_pdf(size, stream), + Format::Ps => Self::new_for_ps(size, stream, false), + Format::Eps => Self::new_for_ps(size, stream, true), + Format::Svg => Self::new_for_svg(size, stream, unit), + } + } + + fn new_for_png(size: Size, stream: OutputStream) -> Result<Self, Error> { + // We use ceil() to avoid chopping off the last pixel if it is partially covered. + let w = checked_i32(size.w.ceil())?; + let h = checked_i32(size.h.ceil())?; + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, w, h)?; + Ok(Self::Png(surface, stream)) + } + + #[cfg(system_deps_have_cairo_pdf)] + fn new_for_pdf(size: Size, stream: OutputStream) -> Result<Self, Error> { + let surface = cairo::PdfSurface::for_stream(size.w, size.h, stream.into_write())?; + if let Some(date) = metadata::creation_date()? { + surface.set_metadata(cairo::PdfMetadata::CreateDate, &date)?; + } + Ok(Self::Pdf(surface, size)) + } + + #[cfg(not(system_deps_have_cairo_pdf))] + fn new_for_pdf(_size: Size, _stream: OutputStream) -> Result<Self, Error> { + Err(Error("unsupported format".to_string())) + } + + #[cfg(system_deps_have_cairo_ps)] + fn new_for_ps(size: Size, stream: OutputStream, eps: bool) -> Result<Self, Error> { + let surface = cairo::PsSurface::for_stream(size.w, size.h, stream.into_write())?; + surface.set_eps(eps); + Ok(Self::Ps(surface, size)) + } + + #[cfg(not(system_deps_have_cairo_ps))] + fn new_for_ps(_size: Size, _stream: OutputStream, _eps: bool) -> Result<Self, Error> { + Err(Error("unsupported format".to_string())) + } + + #[cfg(system_deps_have_cairo_svg)] + fn new_for_svg(size: Size, stream: OutputStream, unit: LengthUnit) -> Result<Self, Error> { + let mut surface = cairo::SvgSurface::for_stream(size.w, size.h, stream.into_write())?; + + let svg_unit = match unit { + LengthUnit::Cm => cairo::SvgUnit::Cm, + LengthUnit::In => cairo::SvgUnit::In, + LengthUnit::Mm => cairo::SvgUnit::Mm, + LengthUnit::Pc => cairo::SvgUnit::Pc, + LengthUnit::Pt => cairo::SvgUnit::Pt, + _ => cairo::SvgUnit::User, + }; + + surface.set_document_unit(svg_unit); + Ok(Self::Svg(surface, size)) + } + + #[cfg(not(system_deps_have_cairo_svg))] + fn new_for_svg(_size: Size, _stream: OutputStream, u: LengthUnit) -> Result<Self, Error> { + Err(Error("unsupported format".to_string())) + } + + #[allow(clippy::too_many_arguments)] // yeah, yeah, we'll refactor it eventually + pub fn render( + &self, + renderer: &CairoRenderer, + left: f64, + top: f64, + final_size: Size, + geometry: cairo::Rectangle, + background_color: Option<Color>, + id: Option<&str>, + ) -> Result<(), Error> { + let cr = cairo::Context::new(self)?; + + if let Some(Color::RGBA(rgba)) = background_color { + cr.set_source_rgba( + rgba.red_f32().into(), + rgba.green_f32().into(), + rgba.blue_f32().into(), + rgba.alpha_f32().into(), + ); + + cr.paint()?; + } + + cr.translate(left, top); + + // Note that we don't scale the viewport; we change the cr's transform instead. This + // is because SVGs are rendered proportionally to fit within the viewport, regardless + // of the viewport's proportions. Rsvg-convert allows non-proportional scaling, so + // we do that with a separate transform. + + let scale = Scale { + x: final_size.w / geometry.width(), + y: final_size.h / geometry.height(), + }; + + cr.scale(scale.x, scale.y); + + let viewport = cairo::Rectangle::new(0.0, 0.0, geometry.width(), geometry.height()); + + match id { + None => renderer.render_document(&cr, &viewport)?, + Some(_) => renderer.render_element(&cr, id, &viewport)?, + } + + if !matches!(self, Self::Png(_, _)) { + cr.show_page()?; + } + + Ok(()) + } + + pub fn finish(self) -> Result<(), Error> { + match self { + Self::Png(surface, stream) => surface.write_to_png(&mut stream.into_write())?, + _ => self.finish_output_stream().map(|_| ())?, + } + + Ok(()) + } +} + +fn checked_i32(x: f64) -> Result<i32, cairo::Error> { + cast::i32(x).map_err(|_| cairo::Error::InvalidSize) +} + +mod metadata { + use super::Error; + use chrono::prelude::*; + use std::env; + use std::str::FromStr; + + pub fn creation_date() -> Result<Option<String>, Error> { + match env::var("SOURCE_DATE_EPOCH") { + Ok(epoch) => match i64::from_str(&epoch) { + Ok(seconds) => { + let datetime = Utc.timestamp_opt(seconds, 0).unwrap(); + Ok(Some(datetime.to_rfc3339())) + } + Err(e) => Err(error!("Environment variable $SOURCE_DATE_EPOCH: {}", e)), + }, + Err(env::VarError::NotPresent) => Ok(None), + Err(env::VarError::NotUnicode(_)) => Err(error!( + "Environment variable $SOURCE_DATE_EPOCH is not valid Unicode" + )), + } + } +} + +struct Stdin; + +impl Stdin { + #[cfg(unix)] + pub fn stream() -> InputStream { + let stream = unsafe { UnixInputStream::with_fd(0) }; + stream.upcast::<InputStream>() + } + + #[cfg(windows)] + pub fn stream() -> InputStream { + let stream = unsafe { Win32InputStream::with_handle(io::stdin()) }; + stream.upcast::<InputStream>() + } +} + +struct Stdout; + +impl Stdout { + #[cfg(unix)] + pub fn stream() -> OutputStream { + let stream = unsafe { UnixOutputStream::with_fd(1) }; + stream.upcast::<OutputStream>() + } + + #[cfg(windows)] + pub fn stream() -> OutputStream { + // Ideally, we could use a Win32OutputStream, but when it's used with a file redirect, + // it gets buggy. + // https://gitlab.gnome.org/GNOME/librsvg/-/issues/812 + let stream = WriteOutputStream::new(io::stdout()); + stream.upcast::<OutputStream>() + } +} + +#[derive(Clone, Debug)] +enum Input { + Stdin, + Named(PathOrUrl), +} + +impl std::fmt::Display for Input { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Input::Stdin => "stdin".fmt(f), + Input::Named(p) => p.fmt(f), + } + } +} + +#[derive(Clone, Debug)] +enum Output { + Stdout, + Path(PathBuf), +} + +impl std::fmt::Display for Output { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Output::Stdout => "stdout".fmt(f), + Output::Path(p) => p.display().fmt(f), + } + } +} + +// Keep this enum in sync with supported_formats in parse_args() +#[derive(Clone, Copy, Debug)] +enum Format { + Png, + Pdf, + Ps, + Eps, + Svg, +} + +struct Converter { + pub dpi_x: Resolution, + pub dpi_y: Resolution, + pub zoom: Scale, + pub width: Option<ULength<Horizontal>>, + pub height: Option<ULength<Vertical>>, + pub left: Option<Length<Horizontal>>, + pub top: Option<Length<Vertical>>, + pub page_size: Option<(ULength<Horizontal>, ULength<Vertical>)>, + pub format: Format, + pub export_id: Option<String>, + pub keep_aspect_ratio: bool, + pub background_color: Option<Color>, + pub stylesheet: Option<PathBuf>, + pub language: Language, + pub unlimited: bool, + pub keep_image_data: bool, + pub input: Vec<Input>, + pub output: Output, + pub testing: bool, +} + +impl Converter { + pub fn convert(self) -> Result<(), Error> { + let stylesheet = match self.stylesheet { + Some(ref p) => std::fs::read_to_string(p) + .map(Some) + .map_err(|e| error!("Error reading stylesheet: {}", e))?, + None => None, + }; + + let mut surface: Option<Surface> = None; + + // Use user units per default + let mut unit = LengthUnit::Px; + + fn set_unit<N: Normalize, V: Validate>( + l: CssLength<N, V>, + p: &NormalizeParams, + u: LengthUnit, + ) -> f64 { + match u { + LengthUnit::Pt => l.to_points(p), + LengthUnit::In => l.to_inches(p), + LengthUnit::Cm => l.to_cm(p), + LengthUnit::Mm => l.to_mm(p), + LengthUnit::Pc => l.to_picas(p), + _ => l.to_user(p), + } + } + + for (page_idx, input) in self.input.iter().enumerate() { + let (stream, basefile) = match input { + Input::Stdin => (Stdin::stream(), None), + Input::Named(p) => { + let file = p.get_gfile(); + let stream = file + .read(None::<&Cancellable>) + .map_err(|e| error!("Error reading file \"{}\": {}", input, e))?; + (stream.upcast::<InputStream>(), Some(file)) + } + }; + + let mut handle = Loader::new() + .with_unlimited_size(self.unlimited) + .keep_image_data(self.keep_image_data) + .read_stream(&stream, basefile.as_ref(), None::<&Cancellable>) + .map_err(|e| error!("Error reading SVG {}: {}", input, e))?; + + if let Some(ref css) = stylesheet { + handle + .set_stylesheet(css) + .map_err(|e| error!("Error applying stylesheet: {}", e))?; + } + + let renderer = CairoRenderer::new(&handle) + .with_dpi(self.dpi_x.0, self.dpi_y.0) + .with_language(&self.language) + .test_mode(self.testing); + + let geometry = natural_geometry(&renderer, input, self.export_id.as_deref())?; + + let natural_size = Size::new(geometry.width(), geometry.height()); + + let params = NormalizeParams::from_dpi(Dpi::new(self.dpi_x.0, self.dpi_y.0)); + + // Convert natural size and requested size to pixels or points, depending on the target format, + let (natural_size, requested_width, requested_height, page_size) = match self.format { + Format::Png => { + // PNG surface requires units in pixels + ( + natural_size, + self.width.map(|l| l.to_user(¶ms)), + self.height.map(|l| l.to_user(¶ms)), + self.page_size.map(|(w, h)| Size { + w: w.to_user(¶ms), + h: h.to_user(¶ms), + }), + ) + } + + Format::Pdf | Format::Ps | Format::Eps => { + // These surfaces require units in points + unit = LengthUnit::Pt; + + ( + Size { + w: ULength::<Horizontal>::new(natural_size.w, LengthUnit::Px) + .to_points(¶ms), + h: ULength::<Vertical>::new(natural_size.h, LengthUnit::Px) + .to_points(¶ms), + }, + self.width.map(|l| l.to_points(¶ms)), + self.height.map(|l| l.to_points(¶ms)), + self.page_size.map(|(w, h)| Size { + w: w.to_points(¶ms), + h: h.to_points(¶ms), + }), + ) + } + + Format::Svg => { + let (w_unit, h_unit) = + (self.width.map(|l| l.unit), self.height.map(|l| l.unit)); + + unit = match (w_unit, h_unit) { + (None, None) => LengthUnit::Px, + (None, u) | (u, None) => u.unwrap(), + (u1, u2) => { + if u1 == u2 { + u1.unwrap() + } else { + LengthUnit::Px + } + } + }; + + // Supported SVG units are px, in, cm, mm, pt, pc + ( + Size { + w: set_unit( + ULength::<Horizontal>::new(natural_size.w, LengthUnit::Px), + ¶ms, + unit, + ), + h: set_unit( + ULength::<Vertical>::new(natural_size.h, LengthUnit::Px), + ¶ms, + unit, + ), + }, + self.width.map(|l| set_unit(l, ¶ms, unit)), + self.height.map(|l| set_unit(l, ¶ms, unit)), + self.page_size.map(|(w, h)| Size { + w: set_unit(w, ¶ms, unit), + h: set_unit(h, ¶ms, unit), + }), + ) + } + }; + + let strategy = match (requested_width, requested_height) { + // when w and h are not specified, scale to the requested zoom (if any) + (None, None) => ResizeStrategy::Scale(self.zoom), + + // when w and h are specified, but zoom is not, scale to the requested size + (Some(width), Some(height)) if self.zoom.is_identity() => ResizeStrategy::Fit { + size: Size::new(width, height), + keep_aspect_ratio: self.keep_aspect_ratio, + }, + + // if only one between w and h is specified and there is no zoom, scale to the + // requested w or h and use the same scaling factor for the other + (Some(w), None) if self.zoom.is_identity() => ResizeStrategy::FitWidth(w), + (None, Some(h)) if self.zoom.is_identity() => ResizeStrategy::FitHeight(h), + + // otherwise scale the image, but cap the zoom to match the requested size + _ => ResizeStrategy::ScaleWithMaxSize { + scale: self.zoom, + max_width: requested_width, + max_height: requested_height, + keep_aspect_ratio: self.keep_aspect_ratio, + }, + }; + + let final_size = self.final_size(&strategy, &natural_size, input)?; + + // Create the surface once on the first input, + // except for PDF, PS, and EPS, which allow differently-sized pages. + let page_size = page_size.unwrap_or(final_size); + let s = match &mut surface { + Some(s) => { + match s { + #[cfg(system_deps_have_cairo_pdf)] + Surface::Pdf(pdf, size) => { + pdf.set_size(page_size.w, page_size.h).map_err(|e| { + error!( + "Error setting PDF page #{} size {}: {}", + page_idx + 1, + input, + e + ) + })?; + *size = page_size; + } + #[cfg(system_deps_have_cairo_ps)] + Surface::Ps(ps, size) => { + ps.set_size(page_size.w, page_size.h); + *size = page_size; + } + _ => {} + } + s + } + surface @ None => surface.insert(self.create_surface(page_size, unit)?), + }; + + let left = self.left.map(|l| set_unit(l, ¶ms, unit)).unwrap_or(0.0); + let top = self.top.map(|l| set_unit(l, ¶ms, unit)).unwrap_or(0.0); + + s.render( + &renderer, + left, + top, + final_size, + geometry, + self.background_color, + self.export_id.as_deref(), + ) + .map_err(|e| error!("Error rendering SVG {}: {}", input, e))? + } + + if let Some(s) = surface.take() { + s.finish() + .map_err(|e| error!("Error saving output {}: {}", self.output, e))? + }; + + Ok(()) + } + + fn final_size( + &self, + strategy: &ResizeStrategy, + natural_size: &Size, + input: &Input, + ) -> Result<Size, Error> { + strategy + .apply(natural_size) + .ok_or_else(|| error!("The SVG {} has no dimensions", input)) + } + + fn create_surface(&self, size: Size, unit: LengthUnit) -> Result<Surface, Error> { + let output_stream = match self.output { + Output::Stdout => Stdout::stream(), + Output::Path(ref p) => { + let file = gio::File::for_path(p); + let stream = file + .replace(None, false, FileCreateFlags::NONE, None::<&Cancellable>) + .map_err(|e| error!("Error opening output \"{}\": {}", self.output, e))?; + stream.upcast::<OutputStream>() + } + }; + + Surface::new(self.format, size, output_stream, unit) + } +} + +fn natural_geometry( + renderer: &CairoRenderer, + input: &Input, + export_id: Option<&str>, +) -> Result<cairo::Rectangle, Error> { + match export_id { + None => renderer.legacy_layer_geometry(None), + Some(id) => renderer.geometry_for_element(Some(id)), + } + .map(|(ink_r, _)| ink_r) + .map_err(|e| match e { + RenderingError::IdNotFound => error!( + "File {} does not have an object with id \"{}\")", + input, + export_id.unwrap() + ), + _ => error!("Error rendering SVG {}: {}", input, e), + }) +} + +fn build_cli() -> clap::Command { + let supported_formats = vec![ + "png", + #[cfg(system_deps_have_cairo_pdf)] + "pdf", + #[cfg(system_deps_have_cairo_ps)] + "ps", + #[cfg(system_deps_have_cairo_ps)] + "eps", + #[cfg(system_deps_have_cairo_svg)] + "svg", + ]; + + clap::Command::new("rsvg-convert") + .version(concat!("version ", crate_version!())) + .about("Convert SVG files to other image formats") + .disable_version_flag(true) + .disable_help_flag(true) + .arg( + clap::Arg::new("help") + .short('?') + .long("help") + .help("Display the help") + .action(clap::ArgAction::Help) + ) + .arg( + clap::Arg::new("version") + .short('v') + .long("version") + .help("Display the version information") + .action(clap::ArgAction::Version) + ) + .arg( + clap::Arg::new("res_x") + .short('d') + .long("dpi-x") + .num_args(1) + .value_name("number") + .default_value("96") + .value_parser(parse_resolution) + .help("Pixels per inch") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("res_y") + .short('p') + .long("dpi-y") + .num_args(1) + .value_name("number") + .default_value("96") + .value_parser(parse_resolution) + .help("Pixels per inch") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("zoom_x") + .short('x') + .long("x-zoom") + .num_args(1) + .value_name("number") + .conflicts_with("zoom") + .value_parser(parse_zoom_factor) + .help("Horizontal zoom factor") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("zoom_y") + .short('y') + .long("y-zoom") + .num_args(1) + .value_name("number") + .conflicts_with("zoom") + .value_parser(parse_zoom_factor) + .help("Vertical zoom factor") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("zoom") + .short('z') + .long("zoom") + .num_args(1) + .value_name("number") + .value_parser(parse_zoom_factor) + .help("Zoom factor") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("size_x") + .short('w') + .long("width") + .num_args(1) + .value_name("length") + .value_parser(parse_length::<Horizontal, Unsigned>) + .help("Width [defaults to the width of the SVG]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("size_y") + .short('h') + .long("height") + .num_args(1) + .value_name("length") + .value_parser(parse_length::<Vertical, Unsigned>) + .help("Height [defaults to the height of the SVG]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("top") + .long("top") + .num_args(1) + .value_name("length") + .value_parser(parse_length::<Vertical, Signed>) + .help("Distance between top edge of page and the image [defaults to 0]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("left") + .long("left") + .num_args(1) + .value_name("length") + .value_parser(parse_length::<Horizontal, Signed>) + .help("Distance between left edge of page and the image [defaults to 0]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("page_width") + .long("page-width") + .num_args(1) + .value_name("length") + .value_parser(parse_length::<Horizontal, Unsigned>) + .help("Width of output media [defaults to the width of the SVG]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("page_height") + .long("page-height") + .num_args(1) + .value_name("length") + .value_parser(parse_length::<Vertical, Unsigned>) + .help("Height of output media [defaults to the height of the SVG]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("format") + .short('f') + .long("format") + .num_args(1) + .value_parser(clap::builder::PossibleValuesParser::new(supported_formats.as_slice())) + .ignore_case(true) + .default_value("png") + .help("Output format") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("output") + .short('o') + .long("output") + .num_args(1) + .value_parser(clap::value_parser!(PathBuf)) + .help("Output filename [defaults to stdout]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("export_id") + .short('i') + .long("export-id") + .value_parser(clap::builder::NonEmptyStringValueParser::new()) + .value_name("object id") + .help("SVG id of object to export [default is to export all objects]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("accept-language") + .short('l') + .long("accept-language") + .value_parser(clap::builder::NonEmptyStringValueParser::new()) + .value_name("languages") + .help("Languages to accept, for example \"es-MX,de,en\" [default uses language from the environment]") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("keep_aspect") + .short('a') + .long("keep-aspect-ratio") + .help("Preserve the aspect ratio") + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("background") + .short('b') + .long("background-color") + .num_args(1) + .value_name("color") + .value_parser(clap::builder::NonEmptyStringValueParser::new()) + .default_value("none") + .help("Set the background color using a CSS color spec") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("stylesheet") + .short('s') + .long("stylesheet") + .num_args(1) + .value_parser(clap::value_parser!(PathBuf)) + .value_name("filename.css") + .help("Filename of CSS stylesheet to apply") + .action(clap::ArgAction::Set), + ) + .arg( + clap::Arg::new("unlimited") + .short('u') + .long("unlimited") + .help("Allow huge SVG files") + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("keep_image_data") + .long("keep-image-data") + .help("Keep image data") + .conflicts_with("no_keep_image_data") + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("no_keep_image_data") + .long("no-keep-image-data") + .help("Do not keep image data") + .conflicts_with("keep_image_data") + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("testing") + .long("testing") + .help("Render images for librsvg's test suite") + .hide(true) + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("completion") + .long("completion") + .help("Output shell completion for the given shell") + .num_args(1) + .action(clap::ArgAction::Set) + .value_parser(clap::value_parser!(Shell)), + ) + .arg( + clap::Arg::new("FILE") + .value_parser(clap::value_parser!(OsString)) + .help("The input file(s) to convert") + .num_args(1..) + .action(clap::ArgAction::Append), + ) +} + +fn print_completions<G: Generator>(gen: G, cmd: &mut clap::Command) { + clap_complete::generate(gen, cmd, cmd.get_name().to_string(), &mut io::stdout()); +} + +fn parse_args() -> Result<Converter, Error> { + let cli = build_cli(); + let matches = cli.get_matches(); + + if let Some(shell) = matches.get_one::<Shell>("completion").copied() { + let mut cmd = build_cli(); + eprintln!("Generating completion file for {shell}"); + print_completions(shell, &mut cmd); + std::process::exit(0); + } + + let format_str: &String = matches + .get_one("format") + .expect("already provided default_value"); + + let format = match_ignore_ascii_case! { + format_str, + "png" => Format::Png, + "pdf" => Format::Pdf, + "ps" => Format::Ps, + "eps" => Format::Eps, + "svg" => Format::Svg, + _ => unreachable!("clap should already have the list of possible values"), + }; + + let keep_image_data = match format { + Format::Ps | Format::Eps | Format::Pdf => !matches.get_flag("no_keep_image_data"), + _ => matches.get_flag("keep_image_data"), + }; + + let language = match matches.get_one::<String>("accept-language") { + None => Language::FromEnvironment, + Some(s) => AcceptLanguage::parse(s) + .map(Language::AcceptLanguage) + .map_err(|e| { + let desc = format!("{e}"); + clap::Error::raw(clap::error::ErrorKind::InvalidValue, desc) + })?, + }; + + let background_str: &String = matches + .get_one("background") + .expect("already provided default_value"); + let background_color: Option<Color> = parse_background_color(background_str) + .map_err(|e| clap::Error::raw(clap::error::ErrorKind::InvalidValue, e))?; + + // librsvg expects ids starting with '#', so it can lookup ids in externs like "subfile.svg#subid". + // For the user's convenience, we prepend '#' automatically; we only support specifying ids from + // the toplevel, and don't expect users to lookup things in externs. + let lookup_id = |id: &String| { + if id.starts_with('#') { + id.clone() + } else { + format!("#{id}") + } + }; + + let width: Option<ULength<Horizontal>> = matches.get_one("size_x").copied(); + let height: Option<ULength<Vertical>> = matches.get_one("size_y").copied(); + + let left: Option<Length<Horizontal>> = matches.get_one("left").copied(); + let top: Option<Length<Vertical>> = matches.get_one("top").copied(); + + let page_width: Option<ULength<Horizontal>> = matches.get_one("page_width").copied(); + let page_height: Option<ULength<Vertical>> = matches.get_one("page_height").copied(); + + let page_size = match (page_width, page_height) { + (None, None) => None, + (Some(_), None) | (None, Some(_)) => { + return Err(error!( + "Please specify both the --page-width and --page-height options together." + )); + } + (Some(w), Some(h)) => Some((w, h)), + }; + + let dpi_x = *matches + .get_one::<Resolution>("res_x") + .expect("already provided default_value"); + let dpi_y = *matches + .get_one::<Resolution>("res_y") + .expect("already provided default_value"); + + let zoom: Option<ZoomFactor> = matches.get_one("zoom").copied(); + let zoom_x: Option<ZoomFactor> = matches.get_one("zoom_x").copied(); + let zoom_y: Option<ZoomFactor> = matches.get_one("zoom_y").copied(); + + let input = match matches.get_many::<std::ffi::OsString>("FILE") { + Some(values) => values + .map(|f| PathOrUrl::from_os_str(f).map_err(Error)) + .map(|r| r.map(Input::Named)) + .collect::<Result<Vec<Input>, Error>>()?, + + None => vec![Input::Stdin], + }; + + if input.len() > 1 && !matches!(format, Format::Ps | Format::Eps | Format::Pdf) { + return Err(error!( + "Multiple SVG files are only allowed for PDF and (E)PS output." + )); + } + + let export_id: Option<String> = matches.get_one::<String>("export_id").map(lookup_id); + + let output = match matches.get_one::<PathBuf>("output") { + None => Output::Stdout, + Some(path) => Output::Path(path.clone()), + }; + + Ok(Converter { + dpi_x, + dpi_y, + zoom: Scale { + x: zoom.or(zoom_x).map(|factor| factor.0).unwrap_or(1.0), + y: zoom.or(zoom_y).map(|factor| factor.0).unwrap_or(1.0), + }, + width, + height, + left, + top, + page_size, + format, + export_id, + keep_aspect_ratio: matches.get_flag("keep_aspect"), + background_color, + stylesheet: matches.get_one("stylesheet").cloned(), + unlimited: matches.get_flag("unlimited"), + keep_image_data, + language, + input, + output, + testing: matches.get_flag("testing"), + }) +} + +#[derive(Copy, Clone)] +struct Resolution(f64); + +fn parse_resolution(v: &str) -> Result<Resolution, String> { + match v.parse::<f64>() { + Ok(res) if res > 0.0 => Ok(Resolution(res)), + Ok(_) => Err(String::from("Invalid resolution")), + Err(e) => Err(format!("{e}")), + } +} + +#[derive(Copy, Clone)] +struct ZoomFactor(f64); + +fn parse_zoom_factor(v: &str) -> Result<ZoomFactor, String> { + match v.parse::<f64>() { + Ok(res) if res > 0.0 => Ok(ZoomFactor(res)), + Ok(_) => Err(String::from("Invalid zoom factor")), + Err(e) => Err(format!("{e}")), + } +} + +trait NotFound { + type Ok; + type Error; + + fn or_none(self) -> Result<Option<Self::Ok>, Self::Error>; +} + +impl<T> NotFound for Result<T, clap::Error> { + type Ok = T; + type Error = clap::Error; + + /// Maps the Result to an Option, translating the ArgumentNotFound error to + /// Ok(None), while mapping other kinds of errors to Err(e). + /// + /// This allows to get proper error reporting for invalid values on optional + /// arguments. + fn or_none(self) -> Result<Option<T>, clap::Error> { + self.map_or_else( + |e| match e.kind() { + clap::error::ErrorKind::UnknownArgument => Ok(None), + _ => Err(e), + }, + |v| Ok(Some(v)), + ) + } +} + +fn parse_background_color(s: &str) -> Result<Option<Color>, String> { + match s { + "none" | "None" => Ok(None), + _ => <Color as Parse>::parse_str(s).map(Some).map_err(|_| { + format!("Invalid value: The argument '{s}' can not be parsed as a CSS color value") + }), + } +} + +fn is_absolute_unit(u: LengthUnit) -> bool { + use LengthUnit::*; + + match u { + Percent | Em | Ex => false, + Px | In | Cm | Mm | Pt | Pc => true, + } +} + +fn parse_length<N: Normalize, V: Validate>(s: &str) -> Result<CssLength<N, V>, String> { + <CssLength<N, V> as Parse>::parse_str(s) + .map_err(|_| format!("Invalid value: The argument '{s}' can not be parsed as a length")) + .and_then(|l| { + if is_absolute_unit(l.unit) { + Ok(l) + } else { + Err(format!( + "Invalid value '{s}': supported units are px, in, cm, mm, pt, pc" + )) + } + }) +} + +fn main() { + if let Err(e) = parse_args().and_then(|converter| converter.convert()) { + std::eprintln!("{e}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod color_tests { + use super::*; + + #[test] + fn valid_color_is_ok() { + assert!(parse_background_color("Red").is_ok()); + } + + #[test] + fn none_is_handled_as_transparent() { + assert_eq!(parse_background_color("None").unwrap(), None,); + } + + #[test] + fn invalid_is_handled_as_invalid_value() { + assert!(parse_background_color("foo").is_err()); + } +} + +#[cfg(test)] +mod sizing_tests { + use super::*; + + #[test] + fn detects_empty_size() { + let strategy = ResizeStrategy::Scale(Scale { x: 42.0, y: 42.0 }); + assert!(strategy.apply(&Size::new(0.0, 0.0)).is_none()); + } + + #[test] + fn scale() { + let strategy = ResizeStrategy::Scale(Scale { x: 2.0, y: 3.0 }); + assert_eq!( + strategy.apply(&Size::new(1.0, 2.0)).unwrap(), + Size::new(2.0, 6.0), + ); + } + + #[test] + fn fit_non_proportional() { + let strategy = ResizeStrategy::Fit { + size: Size::new(40.0, 10.0), + keep_aspect_ratio: false, + }; + + assert_eq!( + strategy.apply(&Size::new(2.0, 1.0)).unwrap(), + Size::new(40.0, 10.0), + ); + } + + #[test] + fn fit_proportional_wider_than_tall() { + let strategy = ResizeStrategy::Fit { + size: Size::new(40.0, 10.0), + keep_aspect_ratio: true, + }; + + assert_eq!( + strategy.apply(&Size::new(2.0, 1.0)).unwrap(), + Size::new(20.0, 10.0), + ); + } + + #[test] + fn fit_proportional_taller_than_wide() { + let strategy = ResizeStrategy::Fit { + size: Size::new(100.0, 50.0), + keep_aspect_ratio: true, + }; + + assert_eq!( + strategy.apply(&Size::new(1.0, 2.0)).unwrap(), + Size::new(25.0, 50.0), + ); + } + + #[test] + fn fit_width() { + let strategy = ResizeStrategy::FitWidth(100.0); + + assert_eq!( + strategy.apply(&Size::new(1.0, 2.0)).unwrap(), + Size::new(100.0, 200.0), + ); + } + + #[test] + fn fit_height() { + let strategy = ResizeStrategy::FitHeight(100.0); + + assert_eq!( + strategy.apply(&Size::new(1.0, 2.0)).unwrap(), + Size::new(50.0, 100.0), + ); + } + + #[test] + fn scale_no_max_size_non_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 2.0, y: 3.0 }, + max_width: None, + max_height: None, + keep_aspect_ratio: false, + }; + + assert_eq!( + strategy.apply(&Size::new(1.0, 2.0)).unwrap(), + Size::new(2.0, 6.0), + ); + } + + #[test] + fn scale_with_max_width_and_height_fits_non_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 2.0, y: 3.0 }, + max_width: Some(10.0), + max_height: Some(20.0), + keep_aspect_ratio: false, + }; + + assert_eq!( + strategy.apply(&Size::new(4.0, 2.0)).unwrap(), + Size::new(8.0, 6.0) + ); + } + + #[test] + fn scale_with_max_width_and_height_fits_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 2.0, y: 3.0 }, + max_width: Some(10.0), + max_height: Some(20.0), + keep_aspect_ratio: true, + }; + + assert_eq!( + strategy.apply(&Size::new(4.0, 2.0)).unwrap(), + Size::new(8.0, 6.0) + ); + } + + #[test] + fn scale_with_max_width_and_height_doesnt_fit_non_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 10.0, y: 20.0 }, + max_width: Some(10.0), + max_height: Some(20.0), + keep_aspect_ratio: false, + }; + + assert_eq!( + strategy.apply(&Size::new(4.0, 5.0)).unwrap(), + Size::new(10.0, 20.0) + ); + } + + #[test] + fn scale_with_max_width_and_height_doesnt_fit_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 10.0, y: 20.0 }, + max_width: Some(10.0), + max_height: Some(15.0), + keep_aspect_ratio: true, + }; + + assert_eq!( + // this will end up with a 40:120 aspect ratio + strategy.apply(&Size::new(4.0, 6.0)).unwrap(), + // which should be shrunk to 1:3 that fits in (10, 15) per the max_width/max_height above + Size::new(5.0, 15.0) + ); + } + + #[test] + fn scale_with_max_width_fits_non_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 5.0, y: 20.0 }, + max_width: Some(10.0), + max_height: None, + keep_aspect_ratio: false, + }; + + assert_eq!( + strategy.apply(&Size::new(1.0, 10.0)).unwrap(), + Size::new(5.0, 200.0), + ); + } + + #[test] + fn scale_with_max_width_fits_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 5.0, y: 20.0 }, + max_width: Some(10.0), + max_height: None, + keep_aspect_ratio: true, + }; + + assert_eq!( + strategy.apply(&Size::new(1.0, 10.0)).unwrap(), + Size::new(5.0, 200.0), + ); + } + + #[test] + fn scale_with_max_height_fits_non_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 20.0, y: 5.0 }, + max_width: None, + max_height: Some(10.0), + keep_aspect_ratio: false, + }; + + assert_eq!( + strategy.apply(&Size::new(10.0, 1.0)).unwrap(), + Size::new(200.0, 5.0), + ); + } + + #[test] + fn scale_with_max_height_fits_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 20.0, y: 5.0 }, + max_width: None, + max_height: Some(10.0), + keep_aspect_ratio: true, + }; + + assert_eq!( + strategy.apply(&Size::new(10.0, 1.0)).unwrap(), + Size::new(200.0, 5.0), + ); + } + + #[test] + fn scale_with_max_width_doesnt_fit_non_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 10.0, y: 20.0 }, + max_width: Some(10.0), + max_height: None, + keep_aspect_ratio: false, + }; + + assert_eq!( + strategy.apply(&Size::new(5.0, 10.0)).unwrap(), + Size::new(10.0, 200.0), + ); + } + + #[test] + fn scale_with_max_width_doesnt_fit_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 10.0, y: 20.0 }, + max_width: Some(10.0), + max_height: None, + keep_aspect_ratio: true, + }; + + assert_eq!( + strategy.apply(&Size::new(5.0, 10.0)).unwrap(), + Size::new(10.0, 40.0), + ); + } + + #[test] + fn scale_with_max_height_doesnt_fit_non_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 10.0, y: 20.0 }, + max_width: None, + max_height: Some(10.0), + keep_aspect_ratio: false, + }; + + assert_eq!( + strategy.apply(&Size::new(5.0, 10.0)).unwrap(), + Size::new(50.0, 10.0), + ); + } + + #[test] + fn scale_with_max_height_doesnt_fit_proportional() { + let strategy = ResizeStrategy::ScaleWithMaxSize { + scale: Scale { x: 8.0, y: 20.0 }, + max_width: None, + max_height: Some(10.0), + keep_aspect_ratio: true, + }; + + assert_eq!( + strategy.apply(&Size::new(5.0, 10.0)).unwrap(), + Size::new(2.0, 10.0), + ); + } +} |