use crate::{
ImageRegistry,
config::{default_mermaid_scale, default_snippet_render_threads, default_typst_ppi},
markdown::{
elements::{Line, Percent, Text},
text_style::{Color, TextStyle},
},
render::{
operation::{
AsRenderOperations, ImageRenderProperties, ImageSize, Pollable, PollableState, RenderAsync,
RenderAsyncStartPolicy, RenderOperation,
},
properties::WindowSize,
},
terminal::image::{
Image,
printer::{ImageSpec, RegisterImageError},
},
theme::{Alignment, D2Style, MermaidStyle, PresentationTheme, TypstStyle, raw::RawColor},
tools::{ExecutionError, ThirdPartyTools},
};
use std::{
collections::{HashMap, VecDeque},
fs, io, mem,
path::Path,
rc::Rc,
sync::{Arc, Condvar, Mutex},
thread,
};
pub struct ThirdPartyConfigs {
pub typst_ppi: String,
pub mermaid_scale: String,
pub mermaid_puppeteer_file: Option<String>,
pub mermaid_config_file: Option<String>,
pub d2_scale: String,
pub threads: usize,
}
pub struct ThirdPartyRender {
render_pool: RenderPool,
}
impl ThirdPartyRender {
pub fn new(config: ThirdPartyConfigs, image_registry: ImageRegistry, root_dir: &Path) -> Self {
let root_dir = match root_dir.to_string_lossy().to_string() {
path if path.is_empty() => ".".into(),
path => path,
};
let render_pool = RenderPool::new(config, root_dir, image_registry);
Self { render_pool }
}
pub(crate) fn render(
&self,
request: ThirdPartyRenderRequest,
theme: &PresentationTheme,
width: Option<Percent>,
) -> Result<RenderOperation, ThirdPartyRenderError> {
let result = self.render_pool.render(request);
let operation = Rc::new(RenderThirdParty::new(result, theme.default_style.style, width));
Ok(RenderOperation::RenderAsync(operation))
}
}
impl Default for ThirdPartyRender {
fn default() -> Self {
let config = ThirdPartyConfigs {
typst_ppi: default_typst_ppi().to_string(),
mermaid_scale: default_mermaid_scale().to_string(),
mermaid_puppeteer_file: None,
mermaid_config_file: None,
d2_scale: "-1".to_string(),
threads: default_snippet_render_threads(),
};
Self::new(config, Default::default(), Path::new("."))
}
}
#[derive(Debug)]
pub(crate) enum ThirdPartyRenderRequest {
Typst(String, TypstStyle),
Latex(String, TypstStyle),
Mermaid(String, MermaidStyle),
D2(String, D2Style),
}
#[derive(Debug, Default)]
enum RenderResult {
Success(Image),
Failure(String),
#[default]
Pending,
}
struct RenderPoolState {
requests: VecDeque<(ThirdPartyRenderRequest, Arc<Mutex<RenderResult>>)>,
image_registry: ImageRegistry,
cache: HashMap<ImageSnippet, Image>,
}
struct Shared {
config: ThirdPartyConfigs,
root_dir: String,
signal: Condvar,
}
struct RenderPool {
state: Arc<Mutex<RenderPoolState>>,
shared: Arc<Shared>,
}
impl RenderPool {
fn new(config: ThirdPartyConfigs, root_dir: String, image_registry: ImageRegistry) -> Self {
let threads = config.threads;
let shared = Shared { config, root_dir, signal: Default::default() };
let state = RenderPoolState { requests: Default::default(), image_registry, cache: Default::default() };
let this = Self { state: Arc::new(Mutex::new(state)), shared: Arc::new(shared) };
for _ in 0..threads {
let worker = Worker { state: this.state.clone(), shared: this.shared.clone() };
thread::spawn(move || worker.run());
}
this
}
fn render(&self, request: ThirdPartyRenderRequest) -> Arc<Mutex<RenderResult>> {
let result: Arc<Mutex<RenderResult>> = Default::default();
let mut state = self.state.lock().expect("lock poisoned");
state.requests.push_back((request, result.clone()));
self.shared.signal.notify_one();
result
}
}
struct Worker {
state: Arc<Mutex<RenderPoolState>>,
shared: Arc<Shared>,
}
impl Worker {
fn run(self) {
loop {
let mut state = self.state.lock().unwrap();
let (request, result) = loop {
let Some((request, result)) = state.requests.pop_front() else {
state = self.shared.signal.wait(state).unwrap();
continue;
};
break (request, result);
};
drop(state);
self.render(request, result);
}
}
fn render(&self, request: ThirdPartyRenderRequest, result: Arc<Mutex<RenderResult>>) {
let output = match request {
ThirdPartyRenderRequest::Typst(input, style) => self.render_typst(input, &style),
ThirdPartyRenderRequest::Latex(input, style) => self.render_latex(input, &style),
ThirdPartyRenderRequest::Mermaid(input, style) => self.render_mermaid(input, &style),
ThirdPartyRenderRequest::D2(input, style) => self.render_d2(input, &style),
};
let mut result = result.lock().unwrap();
match output {
Ok(image) => *result = RenderResult::Success(image),
Err(error) => *result = RenderResult::Failure(error.to_string()),
};
}
pub(crate) fn render_typst(&self, input: String, style: &TypstStyle) -> Result<Image, ThirdPartyRenderError> {
let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Typst };
if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() {
return Ok(image);
}
self.do_render_typst(snippet, &input, style)
}
pub(crate) fn render_latex(&self, input: String, style: &TypstStyle) -> Result<Image, ThirdPartyRenderError> {
let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Latex };
if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() {
return Ok(image);
}
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.do_render_typst(snippet, &input, style)
}
pub(crate) fn render_mermaid(&self, input: String, style: &MermaidStyle) -> Result<Image, ThirdPartyRenderError> {
let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::Mermaid };
if let Some(image) = self.state.lock().unwrap().cache.get(&snippet).cloned() {
return Ok(image);
}
let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir()?;
let output_path = workdir.path().join("output.png");
let input_path = workdir.path().join("input.mmd");
fs::write(&input_path, input)?;
let input_path = input_path.to_string_lossy();
let output_path_str = output_path.to_string_lossy();
let mut args = vec![
"-i",
&input_path,
"-o",
&output_path_str,
"-s",
&self.shared.config.mermaid_scale,
"-t",
&style.theme,
"-b",
&style.background,
];
if let Some(path) = &self.shared.config.mermaid_puppeteer_file {
args.extend(&["-p", path]);
}
if let Some(path) = &self.shared.config.mermaid_config_file {
args.extend(&["-c", path]);
}
ThirdPartyTools::mermaid(&args).run()?;
self.load_image(snippet, &output_path)
}
pub(crate) fn render_d2(&self, input: String, style: &D2Style) -> Result<Image, ThirdPartyRenderError> {
let snippet = ImageSnippet { snippet: input.clone(), source: SnippetSource::D2 };
let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir()?;
let output_path = workdir.path().join("output.png");
let input_path = workdir.path().join("input.d2");
fs::write(&input_path, input)?;
ThirdPartyTools::d2(&[
&input_path.to_string_lossy(),
&output_path.to_string_lossy(),
"--pad",
"0",
"--scale",
&self.shared.config.d2_scale,
"--theme",
&style.theme,
])
.run()?;
self.load_image(snippet, &output_path)
}
fn do_render_typst(
&self,
snippet: ImageSnippet,
input: &str,
style: &TypstStyle,
) -> Result<Image, ThirdPartyRenderError> {
let workdir = tempfile::Builder::default().prefix(".presenterm").tempdir_in(&self.shared.root_dir)?;
let mut typst_input = Self::generate_page_header(style)?;
typst_input.push_str(input);
let input_path = workdir.path().join("input.typst");
fs::write(&input_path, &typst_input)?;
let output_path = workdir.path().join("output.png");
ThirdPartyTools::typst(&[
"compile",
"--format",
"png",
"--root",
&self.shared.root_dir,
"--ppi",
&self.shared.config.typst_ppi,
&input_path.to_string_lossy(),
&output_path.to_string_lossy(),
])
.run()?;
self.load_image(snippet, &output_path)
}
fn generate_page_header(style: &TypstStyle) -> Result<String, ThirdPartyRenderError> {
let x_margin = style.horizontal_margin;
let y_margin = style.vertical_margin;
let background = style
.style
.colors
.background
.as_ref()
.map(Self::as_typst_color)
.unwrap_or_else(|| Ok(String::from("none")))?;
let mut header = format!(
"#set page(width: auto, height: auto, margin: (x: {x_margin}pt, y: {y_margin}pt), fill: {background})\n"
);
if let Some(color) = &style.style.colors.foreground {
let color = Self::as_typst_color(color)?;
header.push_str(&format!("#set text(fill: {color})\n"));
}
Ok(header)
}
fn as_typst_color(color: &Color) -> Result<String, ThirdPartyRenderError> {
match color.as_rgb() {
Some((r, g, b)) => Ok(format!("rgb(\"#{r:02x}{g:02x}{b:02x}\")")),
None => Err(ThirdPartyRenderError::UnsupportedColor(RawColor::from(*color).to_string())),
}
}
fn load_image(&self, snippet: ImageSnippet, path: &Path) -> Result<Image, ThirdPartyRenderError> {
let contents = fs::read(path)?;
let image = image::load_from_memory(&contents)?;
let image = self.state.lock().unwrap().image_registry.register(ImageSpec::Generated(image))?;
self.state.lock().unwrap().cache.insert(snippet, image.clone());
Ok(image)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ThirdPartyRenderError {
#[error(transparent)]
Execution(#[from] ExecutionError),
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("invalid image: {0}")]
InvalidImage(#[from] image::ImageError),
#[error("invalid image: {0}")]
RegisterImage(#[from] RegisterImageError),
#[error("unsupported color '{0}', only RGB is supported")]
UnsupportedColor(String),
}
#[derive(Hash, PartialEq, Eq)]
enum SnippetSource {
Typst,
Latex,
Mermaid,
D2,
}
#[derive(Hash, PartialEq, Eq)]
struct ImageSnippet {
snippet: String,
source: SnippetSource,
}
#[derive(Debug)]
pub(crate) struct RenderThirdParty {
contents: Arc<Mutex<Option<Output>>>,
pending_result: Arc<Mutex<RenderResult>>,
default_style: TextStyle,
width: Option<Percent>,
}
impl RenderThirdParty {
fn new(pending_result: Arc<Mutex<RenderResult>>, default_style: TextStyle, width: Option<Percent>) -> Self {
Self { contents: Default::default(), pending_result, default_style, width }
}
}
impl RenderAsync for RenderThirdParty {
fn pollable(&self) -> Box<dyn Pollable> {
Box::new(OperationPollable { contents: self.contents.clone(), pending_result: self.pending_result.clone() })
}
fn start_policy(&self) -> RenderAsyncStartPolicy {
RenderAsyncStartPolicy::Automatic
}
}
impl AsRenderOperations for RenderThirdParty {
fn as_render_operations(&self, _: &WindowSize) -> Vec<RenderOperation> {
match &*self.contents.lock().unwrap() {
Some(Output::Image(image)) => {
let size = match &self.width {
Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() },
None => Default::default(),
};
let properties = ImageRenderProperties {
size,
background_color: self.default_style.colors.background,
..Default::default()
};
vec![RenderOperation::RenderImage(image.clone(), properties)]
}
Some(Output::Error) => Vec::new(),
None => {
let text = Line::from(Text::new("Loading...", TextStyle::default().bold()));
vec![RenderOperation::RenderText {
line: text.into(),
alignment: Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 },
}]
}
}
}
}
#[derive(Debug)]
enum Output {
Image(Image),
Error,
}
#[derive(Clone)]
struct OperationPollable {
contents: Arc<Mutex<Option<Output>>>,
pending_result: Arc<Mutex<RenderResult>>,
}
impl Pollable for OperationPollable {
fn poll(&mut self) -> PollableState {
let mut contents = self.contents.lock().unwrap();
if contents.is_some() {
return PollableState::Done;
}
match mem::take(&mut *self.pending_result.lock().unwrap()) {
RenderResult::Success(image) => {
*contents = Some(Output::Image(image));
PollableState::Done
}
RenderResult::Failure(error) => {
*contents = Some(Output::Error);
PollableState::Failed { error }
}
RenderResult::Pending => PollableState::Unmodified,
}
}
}