Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Verify exporter version #81

Merged
merged 2 commits into from
Dec 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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),
}
Loading