diff --git a/Cargo.lock b/Cargo.lock index 7cf904d7..e2e3d9d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -934,6 +934,7 @@ dependencies = [ "merge-struct", "once_cell", "rstest", + "semver", "serde", "serde_json", "serde_with", diff --git a/Cargo.toml b/Cargo.toml index 30fe6168..1b0c7092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ image = "0.24" merge-struct = "0.1.0" itertools = "0.11" once_cell = "1.18" +semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" serde_json = "1.0" diff --git a/src/export.rs b/src/export.rs index 77ff7447..e175bcbf 100644 --- a/src/export.rs +++ b/src/export.rs @@ -3,20 +3,21 @@ use crate::{ markdown::parse::ParseError, presentation::{Presentation, RenderOperation}, render::media::{Image, ImageSource}, + tools::{ExecutionError, ThirdPartyTools}, typst::TypstRender, CodeHighlighter, MarkdownParser, PresentationTheme, Resources, }; use base64::{engine::general_purpose::STANDARD, Engine}; use image::{codecs::png::PngEncoder, DynamicImage, GenericImageView, ImageEncoder, ImageError}; +use semver::Version; use serde::Serialize; use std::{ env, fs, - io::{self, Write}, + io::{self}, path::{Path, PathBuf}, - process::{Command, Stdio}, }; -const COMMAND: &str = "presenterm-export"; +const MINIMUM_EXPORTER_VERSION: Version = Version::new(0, 2, 0); /// Allows exporting presentations into PDF. pub struct Exporter<'a> { @@ -43,8 +44,10 @@ impl<'a> Exporter<'a> { /// /// This uses a separate `presenterm-export` tool. pub fn export_pdf(&mut self, presentation_path: &Path) -> Result<(), ExportError> { + Self::validate_exporter_version()?; + let metadata = self.generate_metadata(presentation_path)?; - Self::execute_exporter(metadata).map_err(ExportError::InvokeExporter)?; + Self::execute_exporter(metadata)?; Ok(()) } @@ -55,6 +58,17 @@ impl<'a> Exporter<'a> { Ok(metadata) } + fn validate_exporter_version() -> Result<(), ExportError> { + let result = ThirdPartyTools::presenterm_export(&["--version"]).run_and_capture_stdout(); + let version = match result { + Ok(version) => String::from_utf8(version).expect("not utf8"), + Err(ExecutionError::Execution { .. }) => return Err(ExportError::MinimumVersion), + Err(e) => return Err(e.into()), + }; + let version = Version::parse(version.trim()).map_err(|_| ExportError::MinimumVersion)?; + if version >= MINIMUM_EXPORTER_VERSION { Ok(()) } else { Err(ExportError::MinimumVersion) } + } + /// Extract the metadata necessary to make an export. fn extract_metadata(&mut self, content: &str, path: &Path) -> Result { let elements = self.parser.parse(content)?; @@ -69,6 +83,7 @@ impl<'a> Exporter<'a> { options, ) .build(elements)?; + let images = Self::build_image_metadata(&mut presentation)?; Self::validate_theme_colors(&presentation)?; let commands = Self::build_capture_commands(presentation); @@ -76,21 +91,11 @@ impl<'a> Exporter<'a> { Ok(metadata) } - fn execute_exporter(metadata: ExportMetadata) -> io::Result<()> { - let presenterm_path = env::current_exe()?; - let mut command = - Command::new(COMMAND).arg("--presenterm-path").arg(presenterm_path).stdin(Stdio::piped()).spawn()?; - let mut stdin = command.stdin.take().expect("no stdin"); + fn execute_exporter(metadata: ExportMetadata) -> Result<(), ExportError> { + let presenterm_path = env::current_exe().map_err(ExportError::Io)?; + let presenterm_path = presenterm_path.display().to_string(); let metadata = serde_json::to_vec(&metadata).expect("serialization failed"); - stdin.write_all(&metadata)?; - stdin.flush()?; - drop(stdin); - - let status = command.wait()?; - if !status.success() { - println!("PDF generation failed"); - } - // huh? + ThirdPartyTools::presenterm_export(&["--presenterm-path", &presenterm_path]).stdin(metadata).run()?; Ok(()) } @@ -173,15 +178,18 @@ pub enum ExportError { #[error("failed to build presentation: {0}")] BuildPresentation(#[from] BuildError), - #[error("failed to invoke presenterm-export (is it installed?): {0}")] - InvokeExporter(io::Error), - #[error("unsupported {0} color in theme")] UnsupportedColor(&'static str), #[error("generating images: {0}")] GeneratingImages(#[from] ImageError), + #[error(transparent)] + Execution(#[from] ExecutionError), + + #[error("minimum presenterm-export version ({MINIMUM_EXPORTER_VERSION}) not met")] + MinimumVersion, + #[error("io: {0}")] Io(io::Error), } diff --git a/src/lib.rs b/src/lib.rs index 7c941a32..dbd24e80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub(crate) mod render; pub(crate) mod resource; pub(crate) mod style; pub(crate) mod theme; +pub(crate) mod tools; pub(crate) mod typst; pub use crate::{ diff --git a/src/render/draw.rs b/src/render/draw.rs index af26ea9f..301970f5 100644 --- a/src/render/draw.rs +++ b/src/render/draw.rs @@ -46,9 +46,9 @@ where WeightedText::from(StyledText::new("Error loading presentation", TextStyle::default().bold())), WeightedText::from(StyledText::from(": ")), ]; - let error = vec![WeightedText::from(StyledText::from(message))]; + let alignment = Alignment::Center { minimum_size: 0, minimum_margin: Margin::Percent(8) }; - let operations = [ + let mut operations = vec![ RenderOperation::ClearScreen, RenderOperation::SetColors(Colors { foreground: Some(Color::new(255, 0, 0)), @@ -58,8 +58,12 @@ where RenderOperation::RenderText { line: WeightedLine::from(heading), alignment: alignment.clone() }, RenderOperation::RenderLineBreak, RenderOperation::RenderLineBreak, - RenderOperation::RenderText { line: WeightedLine::from(error), alignment: alignment.clone() }, ]; + for line in message.lines() { + let error = vec![WeightedText::from(StyledText::from(line))]; + let op = RenderOperation::RenderText { line: WeightedLine::from(error), alignment: alignment.clone() }; + operations.extend([op, RenderOperation::RenderLineBreak]); + } let engine = RenderEngine::new(&mut self.terminal, dimensions); engine.render(operations.iter())?; self.terminal.flush()?; diff --git a/src/tools.rs b/src/tools.rs new file mode 100644 index 00000000..c57c34a8 --- /dev/null +++ b/src/tools.rs @@ -0,0 +1,99 @@ +use std::{ + io::{self, Write}, + process::{Command, Output, Stdio}, +}; + +use itertools::Itertools; + +const MAX_ERROR_LINES: usize = 10; + +pub(crate) struct ThirdPartyTools; + +impl ThirdPartyTools { + pub(crate) fn pandoc(args: &[&str]) -> Tool { + Tool::new("pandoc", args) + } + + pub(crate) fn typst(args: &[&str]) -> Tool { + Tool::new("typst", args) + } + + pub(crate) fn presenterm_export(args: &[&str]) -> Tool { + Tool::new("presenterm-export", args).inherit_stdout() + } +} + +pub(crate) struct Tool { + command_name: &'static str, + command: Command, + stdin: Option>, +} + +impl Tool { + fn new(command_name: &'static str, args: &[&str]) -> Self { + let mut command = Command::new(command_name); + command.args(args).stdout(Stdio::null()).stderr(Stdio::piped()); + Self { command_name, command, stdin: None } + } + + pub(crate) fn stdin(mut self, stdin: Vec) -> Self { + self.stdin = Some(stdin); + self + } + + pub(crate) fn inherit_stdout(mut self) -> Self { + self.command.stdout(Stdio::inherit()); + self + } + + pub(crate) fn run(self) -> Result<(), ExecutionError> { + self.spawn()?; + Ok(()) + } + + pub(crate) fn run_and_capture_stdout(mut self) -> Result, ExecutionError> { + self.command.stdout(Stdio::piped()); + + let output = self.spawn()?; + Ok(output.stdout) + } + + fn spawn(mut self) -> Result { + use ExecutionError::*; + if self.stdin.is_some() { + self.command.stdin(Stdio::piped()); + } + let mut child = self.command.spawn().map_err(|error| Spawn { command: self.command_name, error })?; + if let Some(data) = &self.stdin { + let mut stdin = child.stdin.take().expect("no stdin"); + stdin + .write_all(data) + .and_then(|_| stdin.flush()) + .map_err(|error| Communication { command: self.command_name, error })?; + } + let output = child.wait_with_output().map_err(|error| Communication { command: self.command_name, error })?; + self.validate_output(&output)?; + Ok(output) + } + + fn validate_output(self, output: &Output) -> Result<(), ExecutionError> { + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).lines().take(MAX_ERROR_LINES).join("\n"); + Err(ExecutionError::Execution { command: self.command_name, stderr }) + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ExecutionError { + #[error("spawning '{command}' failed: {error}")] + Spawn { command: &'static str, error: io::Error }, + + #[error("communicating with '{command}' failed: {error}")] + Communication { command: &'static str, error: io::Error }, + + #[error("'{command}' execution failed: \n{stderr}")] + Execution { command: &'static str, stderr: String }, +} diff --git a/src/typst.rs b/src/typst.rs index 52ea1ba3..3aa59093 100644 --- a/src/typst.rs +++ b/src/typst.rs @@ -2,12 +2,12 @@ use crate::{ render::media::{Image, ImageSource, InvalidImage}, style::Color, theme::TypstStyle, + tools::{ExecutionError, ThirdPartyTools}, }; use std::{ fs, - io::{self, Write}, + io::{self}, path::Path, - process::{Command, Output, Stdio}, }; use tempfile::tempdir; @@ -35,54 +35,32 @@ impl TypstRender { } pub(crate) fn render_latex(&self, input: &str, style: &TypstStyle) -> Result { - let mut child = Command::new("pandoc") - .args(["--from", "latex", "--to", "typst"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| TypstRenderError::CommandRun("pandoc", e.to_string()))?; - - child.stdin.take().expect("no stdin").write_all(input.as_bytes())?; - let output = child.wait_with_output().map_err(|e| TypstRenderError::CommandRun("pandoc", e.to_string()))?; - Self::validate_output(&output, "pandoc")?; - - let input = String::from_utf8_lossy(&output.stdout); + let output = ThirdPartyTools::pandoc(&["--from", "latex", "--to", "typst"]) + .stdin(input.as_bytes().into()) + .run_and_capture_stdout()?; + + let input = String::from_utf8_lossy(&output); self.render_typst(&input, style) } fn render_to_image(&self, base_path: &Path, path: &Path) -> Result { let output_path = base_path.join("output.png"); - let output = Command::new("typst") - .args([ - "compile", - "--format", - "png", - "--ppi", - &self.ppi, - &path.to_string_lossy(), - &output_path.to_string_lossy(), - ]) - .stderr(Stdio::piped()) - .output() - .map_err(|e| TypstRenderError::CommandRun("typst", e.to_string()))?; - Self::validate_output(&output, "typst")?; + ThirdPartyTools::typst(&[ + "compile", + "--format", + "png", + "--ppi", + &self.ppi, + &path.to_string_lossy(), + &output_path.to_string_lossy(), + ]) + .run()?; let png_contents = fs::read(&output_path)?; let image = Image::decode(&png_contents, ImageSource::Generated)?; Ok(image) } - fn validate_output(output: &Output, name: &'static str) -> Result<(), TypstRenderError> { - if output.status.success() { - Ok(()) - } else { - let error = String::from_utf8_lossy(&output.stderr); - let error = error.lines().take(10).collect(); - Err(TypstRenderError::CommandRun(name, error)) - } - } - fn generate_page_header(style: &TypstStyle) -> Result { let x_margin = style.horizontal_margin.unwrap_or(DEFAULT_HORIZONTAL_MARGIN); let y_margin = style.vertical_margin.unwrap_or(DEFAULT_VERTICAL_MARGIN); @@ -114,15 +92,15 @@ impl Default for TypstRender { #[derive(Debug, thiserror::Error)] pub enum TypstRenderError { + #[error(transparent)] + Execution(#[from] ExecutionError), + #[error("io: {0}")] Io(#[from] io::Error), #[error("invalid output image: {0}")] InvalidImage(#[from] InvalidImage), - #[error("running command '{0}': {1}")] - CommandRun(&'static str, String), - #[error("unsupported color '{0}', only RGB is supported")] UnsupportedColor(String), }