Skip to content

Commit

Permalink
Merge pull request #81 from mfontanini/verify-exporter-version
Browse files Browse the repository at this point in the history
Verify exporter version
  • Loading branch information
mfontanini authored Dec 8, 2023
2 parents b4fc9c5 + b230d86 commit 8fc6e1a
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 66 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 29 additions & 21 deletions src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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(())
}

Expand All @@ -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<ExportMetadata, ExportError> {
let elements = self.parser.parse(content)?;
Expand All @@ -69,28 +83,19 @@ 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);
let metadata = ExportMetadata { commands, presentation_path: path, images };
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(())
}

Expand Down Expand Up @@ -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),
}
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
10 changes: 7 additions & 3 deletions src/render/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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()?;
Expand Down
99 changes: 99 additions & 0 deletions src/tools.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>>,
}

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<u8>) -> 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<Vec<u8>, ExecutionError> {
self.command.stdout(Stdio::piped());

let output = self.spawn()?;
Ok(output.stdout)
}

fn spawn(mut self) -> Result<Output, ExecutionError> {
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 },
}
62 changes: 20 additions & 42 deletions src/typst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -35,54 +35,32 @@ impl TypstRender {
}

pub(crate) fn render_latex(&self, input: &str, style: &TypstStyle) -> Result<Image, TypstRenderError> {
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<Image, TypstRenderError> {
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<String, TypstRenderError> {
let x_margin = style.horizontal_margin.unwrap_or(DEFAULT_HORIZONTAL_MARGIN);
let y_margin = style.vertical_margin.unwrap_or(DEFAULT_VERTICAL_MARGIN);
Expand Down Expand Up @@ -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),
}

0 comments on commit 8fc6e1a

Please sign in to comment.