#[macro_use]
extern crate derive_builder;
use core::convert::TryFrom;
use core::fmt;
use quick_js::{self, Context as JsContext, JsValue};
use std::collections::HashMap;
use std::panic::RefUnwindSafe;
use std::sync::Arc;
const KATEX_SRC: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/katex.min.js"));
thread_local! {
static KATEX: Result<JsContext> = init_katex();
}
#[non_exhaustive]
#[derive(thiserror::Error, Clone, Debug)]
pub enum Error {
#[error("failed to initialize js environment (detail: {0})")]
JsInitError(String),
#[error("failed to execute js (detail: {0})")]
JsExecError(String),
#[error("failed to convert js value (detail: {0})")]
JsValueError(String),
}
impl From<quick_js::ContextError> for Error {
fn from(e: quick_js::ContextError) -> Self {
Self::JsInitError(format!("{}", e))
}
}
impl From<quick_js::ExecutionError> for Error {
fn from(e: quick_js::ExecutionError) -> Self {
Self::JsExecError(format!("{}", e))
}
}
impl From<quick_js::ValueError> for Error {
fn from(e: quick_js::ValueError) -> Self {
Self::JsValueError(format!("{}", e))
}
}
pub type Result<T> = core::result::Result<T, Error>;
fn init_katex() -> Result<JsContext> {
let ctx = JsContext::new()?;
let _ = ctx.eval(KATEX_SRC)?;
let _ = ctx.eval(
r#"
function renderToString(input, opts) {
if (opts.trust === "USE_TRUST_CALLBACK") {
opts.trust = trustCallback;
}
return katex.renderToString(input, opts);
}
"#,
)?;
Ok(ctx)
}
#[derive(Debug)]
pub struct TrustContext<'a> {
pub command: &'a str,
pub url: &'a str,
pub protocol: &'a str,
}
impl<'a> TryFrom<&'a JsValue> for TrustContext<'a> {
type Error = quick_js::ValueError;
fn try_from(input: &'a JsValue) -> core::result::Result<Self, Self::Error> {
match input {
JsValue::Object(obj) => {
let command = obj
.get("command")
.ok_or_else(|| quick_js::ValueError::UnexpectedType)?
.as_str()
.ok_or_else(|| quick_js::ValueError::UnexpectedType)?;
let url = obj
.get("url")
.ok_or_else(|| quick_js::ValueError::UnexpectedType)?
.as_str()
.ok_or_else(|| quick_js::ValueError::UnexpectedType)?;
let protocol = obj
.get("protocol")
.ok_or_else(|| quick_js::ValueError::UnexpectedType)?
.as_str()
.ok_or_else(|| quick_js::ValueError::UnexpectedType)?;
Ok(Self {
command,
url,
protocol,
})
}
_ => Err(quick_js::ValueError::UnexpectedType),
}
}
}
#[derive(Clone)]
pub struct TrustCallback(Arc<dyn Fn(TrustContext) -> bool + RefUnwindSafe>);
impl fmt::Debug for TrustCallback {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Fn(TrustContext) -> bool")
}
}
impl<F: Fn(TrustContext) -> bool + RefUnwindSafe + 'static> From<F> for TrustCallback {
fn from(f: F) -> Self {
Self(Arc::from(f))
}
}
impl quick_js::Callback<TrustCallback> for TrustCallback {
fn argument_count(&self) -> usize {
1
}
fn call(
&self,
args: Vec<JsValue>,
) -> core::result::Result<core::result::Result<JsValue, String>, quick_js::ValueError> {
let arg = args
.get(0)
.ok_or_else(|| quick_js::ValueError::UnexpectedType)?;
let ctx = TrustContext::try_from(arg)?;
let result = self.0(ctx);
Ok(Ok(JsValue::from(result)))
}
}
#[non_exhaustive]
#[derive(Clone, Builder, Debug, Default)]
#[builder(default)]
#[builder(setter(into, strip_option))]
#[builder(build_fn(validate = "Self::validate"))]
pub struct Opts {
display_mode: Option<bool>,
output_type: Option<OutputType>,
leqno: Option<bool>,
fleqn: Option<bool>,
throw_on_error: Option<bool>,
error_color: Option<String>,
macros: HashMap<String, String>,
min_rule_thickness: Option<f64>,
#[allow(clippy::option_option)]
max_size: Option<Option<f64>>,
#[allow(clippy::option_option)]
max_expand: Option<Option<i32>>,
trust: Option<bool>,
trust_callback: Option<TrustCallback>,
}
impl Opts {
pub fn builder() -> OptsBuilder {
OptsBuilder::default()
}
}
impl Into<JsValue> for Opts {
fn into(self) -> JsValue {
let mut opt: HashMap<String, JsValue> = HashMap::new();
if let Some(display_mode) = self.display_mode {
opt.insert("displayMode".to_owned(), display_mode.into());
}
if let Some(output_type) = self.output_type {
opt.insert(
"output".to_owned(),
match output_type {
OutputType::Html => "html",
OutputType::Mathml => "mathml",
OutputType::HtmlAndMathml => "htmlAndMathml",
}
.into(),
);
}
if let Some(leqno) = self.leqno {
opt.insert("leqno".to_owned(), leqno.into());
}
if let Some(fleqn) = self.fleqn {
opt.insert("fleqn".to_owned(), fleqn.into());
}
if let Some(throw_on_error) = self.throw_on_error {
opt.insert("throwOnError".to_owned(), throw_on_error.into());
}
if let Some(error_color) = self.error_color {
opt.insert("errorColor".to_owned(), error_color.into());
}
opt.insert("macros".to_owned(), self.macros.into());
if let Some(min_rule_thickness) = self.min_rule_thickness {
opt.insert("minRuleThickness".to_owned(), min_rule_thickness.into());
}
if let Some(max_size) = self.max_size {
if let Some(max_size) = max_size {
opt.insert("maxSize".to_owned(), max_size.into());
}
}
if let Some(max_expand) = self.max_expand {
match max_expand {
Some(max_expand) => {
opt.insert("maxExpand".to_owned(), max_expand.into());
}
None => {
opt.insert("maxExpand".to_owned(), i32::max_value().into());
}
}
}
if let Some(trust) = self.trust {
opt.insert("trust".to_owned(), trust.into());
}
if self.trust_callback.is_some() {
opt.insert("trust".to_owned(), "USE_TRUST_CALLBACK".into());
}
JsValue::Object(opt)
}
}
impl OptsBuilder {
pub fn add_macro(mut self, entry_name: String, entry_data: String) -> Self {
match self.macros.as_mut() {
Some(macros) => {
macros.insert(entry_name, entry_data);
}
None => {
let mut macros = HashMap::new();
macros.insert(entry_name, entry_data);
self.macros = Some(macros);
}
}
self
}
fn validate(&self) -> core::result::Result<(), String> {
if self.trust.is_some() && self.trust_callback.is_some() {
return Err("cannot set `trust` and `trust_callback` at the same time".to_owned());
}
Ok(())
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum OutputType {
Html,
Mathml,
HtmlAndMathml,
}
pub fn render_with_opts(input: &str, opts: Opts) -> Result<String> {
KATEX.with(|ctx| {
let ctx = match ctx.as_ref() {
Ok(ctx) => ctx,
Err(e) => return Err(e.clone()),
};
if let Some(trust_callback) = opts.trust_callback.clone() {
ctx.add_callback("trustCallback", trust_callback)?;
}
let args: Vec<JsValue> = vec![input.into(), opts.into()];
let result = ctx
.call_function("renderToString", args)?
.into_string()
.ok_or_else(|| quick_js::ValueError::UnexpectedType)?;
Ok(result)
})
}
#[inline]
pub fn render(input: &str) -> Result<String> {
render_with_opts(input, Default::default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render() {
let html = render("a = b + c").unwrap();
assert!(!html.contains(r#"span class="katex-display""#));
assert!(html.contains(r#"span class="katex""#));
assert!(html.contains(r#"span class="katex-mathml""#));
assert!(html.contains(r#"span class="katex-html""#));
}
#[test]
fn test_display_mode() {
let opts = Opts::builder().display_mode(true).build().unwrap();
let html = render_with_opts("a = b + c", opts).unwrap();
assert!(html.contains(r#"span class="katex-display""#));
}
#[test]
fn test_output_html_only() {
let opts = Opts::builder()
.output_type(OutputType::Html)
.build()
.unwrap();
let html = render_with_opts("a = b + c", opts).unwrap();
assert!(!html.contains(r#"span class="katex-mathml""#));
assert!(html.contains(r#"span class="katex-html""#));
}
#[test]
fn test_output_mathml_only() {
let opts = Opts::builder()
.output_type(OutputType::Mathml)
.build()
.unwrap();
let html = render_with_opts("a = b + c", opts).unwrap();
assert!(html.contains(r#"MathML"#));
assert!(!html.contains(r#"span class="katex-html""#));
}
#[test]
fn test_leqno() {
let opts = Opts::builder()
.display_mode(true)
.leqno(true)
.build()
.unwrap();
let html = render_with_opts("a = b + c", opts).unwrap();
assert!(html.contains(r#"span class="katex-display leqno""#));
}
#[test]
fn test_fleqn() {
let opts = Opts::builder()
.display_mode(true)
.fleqn(true)
.build()
.unwrap();
let html = render_with_opts("a = b + c", opts).unwrap();
assert!(html.contains(r#"span class="katex-display fleqn""#));
}
#[test]
fn test_throw_on_error() {
let err_msg = match render(r#"\"#) {
Ok(_) => unreachable!(),
Err(e) => match e {
Error::JsExecError(msg) => msg,
_ => unreachable!(),
},
};
assert!(err_msg.contains("ParseError"));
}
#[test]
fn test_error_color() {
let opts = Opts::builder()
.throw_on_error(false)
.error_color("#ff0000")
.build()
.unwrap();
let html = render_with_opts(r#"\"#, opts).unwrap();
assert!(html.contains(r#"span class="katex-error""#));
assert!(html.contains("color:#ff0000"));
}
#[test]
fn test_macros() {
let opts = Opts::builder()
.add_macro(r#"\RR"#.to_owned(), r#"\mathbb{R}"#.to_owned())
.build()
.unwrap();
let html = render_with_opts(r#"\RR"#, opts).unwrap();
assert!(html.contains("mathbb"));
}
#[test]
fn test_trust() {
let opts = Opts::builder().error_color("#ff0000").build().unwrap();
let html = render_with_opts(r#"\url{https://www.google.com}"#, opts).unwrap();
assert!(html.contains(r#"color:#ff0000"#));
assert!(!html.contains(r#"a href="https://www.google.com""#));
let opts = Opts::builder()
.error_color("#ff0000")
.trust(true)
.build()
.unwrap();
let html = render_with_opts(r#"\url{https://www.google.com}"#, opts).unwrap();
assert!(!html.contains(r#"color:#ff0000"#));
assert!(html.contains(r#"a href="https://www.google.com""#));
}
#[test]
fn test_set_both_trust_and_trust_callback() {
let opts = Opts::builder()
.trust(true)
.trust_callback(|_ctx: TrustContext| -> bool { true })
.build();
assert!(opts.is_err());
assert_eq!(
opts.unwrap_err(),
"cannot set `trust` and `trust_callback` at the same time"
);
}
#[test]
fn test_trust_callback_using_closure() {
let opts = Opts::builder()
.error_color("#ff0000")
.trust_callback(|ctx: TrustContext| -> bool {
ctx.command == r#"\url"#
&& ctx.protocol == "https"
&& ctx.url == "https://www.google.com"
})
.build()
.unwrap();
let html = render_with_opts(r#"\url{https://www.google.com}"#, opts).unwrap();
assert!(!html.contains(r#"color:#ff0000"#));
assert!(html.contains(r#"a href="https://www.google.com""#));
}
#[test]
fn test_trust_callback_using_fn() {
fn callback(ctx: TrustContext) -> bool {
ctx.command == r#"\url"#
&& ctx.protocol == "https"
&& ctx.url == "https://www.google.com"
}
let opts = Opts::builder()
.error_color("#ff0000")
.trust_callback(callback)
.build()
.unwrap();
let html = render_with_opts(r#"\url{https://www.google.com}"#, opts).unwrap();
assert!(!html.contains(r#"color:#ff0000"#));
assert!(html.contains(r#"a href="https://www.google.com""#));
}
}