use std::{
fs,
io::{IsTerminal, Read, Write},
path::{Path, PathBuf},
sync::LazyLock,
};
use clap::Parser;
use math_core::{LatexError, LatexToMathML, MathDisplay};
mod config_file;
mod html_entities;
mod replace;
use replace::{ConversionError, Replacer, SnippetWarning};
static DEFAULT_CONFIG_FILE: &str = "mathcore.toml";
#[derive(Parser, Debug)]
#[command(version, about = "Converts LaTeX formulas to MathML", long_about = None)]
struct Args {
#[arg(conflicts_with = "formula", value_name = "FILE")]
file: Option<PathBuf>,
#[arg(
long,
default_value = "$",
conflicts_with = "formula",
value_name = "STR"
)]
inline_del: String,
#[arg(
long,
default_value = "$$",
conflicts_with = "formula",
value_name = "STR"
)]
block_del: String,
#[arg(
long,
conflicts_with = "inline_del",
requires = "inline_close",
value_name = "STR"
)]
inline_open: Option<String>,
#[arg(
long,
conflicts_with = "inline_del",
requires = "inline_open",
value_name = "STR"
)]
inline_close: Option<String>,
#[arg(
long,
conflicts_with = "block_del",
requires = "block_close",
value_name = "STR"
)]
block_open: Option<String>,
#[arg(
long,
conflicts_with = "block_del",
requires = "block_open",
value_name = "STR"
)]
block_close: Option<String>,
#[arg(short, long, conflicts_with = "formula")]
recursive: bool,
#[arg(short, long, conflicts_with_all = ["formula", "recursive"])]
write: bool,
#[arg(long, conflicts_with = "formula")]
dry_run: bool,
#[arg(long, conflicts_with = "formula")]
ignore_escaped_delim: bool,
#[arg(long, conflicts_with = "formula")]
continue_on_error: bool,
#[arg(short, long, conflicts_with = "file")]
formula: Option<String>,
#[arg(short, long, conflicts_with = "file", group = "mode")]
inline: bool,
#[arg(short, long, conflicts_with = "file", group = "mode")]
block: bool,
#[arg(short, long, value_name = "FILE")]
config_file: Option<PathBuf>,
}
fn main() {
let args = Args::parse();
let config_path = args
.config_file
.as_deref()
.unwrap_or_else(|| Path::new(DEFAULT_CONFIG_FILE));
let config = match config_file::load_config_file(config_path) {
Ok(config) => config,
Err(config_file::ConfigError::Io(ref io_err))
if io_err.kind() == std::io::ErrorKind::NotFound =>
{
if args.config_file.is_none() {
config_file::Config::default()
} else {
eprintln!("Config file '{}' not found", config_path.display());
std::process::exit(3);
}
}
Err(err) => {
eprintln!(
"Failed to load config file '{}': {}",
config_path.display(),
err
);
std::process::exit(4);
}
};
let converter = LatexToMathML::new(config.math_core).unwrap_or_else(|err| {
render_ariadne_report(&err.0, &format!("macro {}", err.1), &err.2);
std::process::exit(2);
});
if let Some(fpath) = &args.file {
let inline_delim: (&str, &str) = if let Some(open) = &args.inline_open {
(open, args.inline_close.as_ref().unwrap())
} else {
(&args.inline_del, &args.inline_del)
};
let block_delim: (&str, &str) = if let Some(open) = &args.block_open {
(open, args.block_close.as_ref().unwrap())
} else {
(&args.block_del, &args.block_del)
};
let replacer = Replacer::new(inline_delim, block_delim, args.ignore_escaped_delim);
if fpath == &PathBuf::from("-") {
let input = read_stdin();
match replace(&replacer, &input, &converter, args.continue_on_error) {
Ok(converted) => {
print_warnings(&converted.warnings, None);
println!("{}", converted.html);
}
Err(e) => exit_conversion_error(e, None),
}
} else if args.recursive {
convert_html_recursive(&args, fpath, &replacer, &converter);
} else {
convert_html(&args, fpath, args.write, &replacer, &converter);
}
} else if let Some(formula) = &args.formula {
convert_and_exit(&args, formula, &converter);
} else {
convert_and_exit(&args, &read_stdin(), &converter);
}
}
fn read_stdin() -> String {
let mut buffer = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buffer) {
exit_io_error(&e);
}
buffer
}
fn convert_and_exit(args: &Args, latex: &str, converter: &LatexToMathML) {
let display = if args.block {
MathDisplay::Block
} else {
MathDisplay::Inline
};
match converter.convert_with_local_state(latex, display) {
Ok(mathml) => println!("{}", mathml.mathml),
Err(e) => {
render_ariadne_report(&e, "<input>", latex);
std::process::exit(2);
}
}
}
fn replace<'source>(
replacer: &Replacer,
input: &'source str,
converter: &LatexToMathML,
continue_on_error: bool,
) -> Result<Converted<'source>, ConversionError<'source>> {
let scan = replacer.scan(input)?;
let converted = converter.convert_all(&scan.snippets);
let mut result = String::with_capacity(input.len());
let mut warnings = Vec::new();
for (((latex, display), site), converted) in
scan.snippets.into_iter().zip(&scan.sites).zip(converted)
{
result += site.preceding_text;
match converted {
Ok(converted) => {
result += &converted.mathml;
warnings.extend(SnippetWarning::for_site(input, site, converted.warnings));
}
Err(err) if continue_on_error => {
result += &err.to_html(&latex, display, None);
}
Err(err) => return Err(ConversionError::latex_error(input, site, latex, *err)),
}
}
result += scan.trailing_text;
Ok(Converted {
html: result,
warnings,
})
}
struct Converted<'source> {
html: String,
warnings: Vec<SnippetWarning<'source>>,
}
fn convert_html_recursive(
args: &Args,
path: &Path,
replacer: &Replacer,
converter: &LatexToMathML,
) {
if path.is_dir() {
let dir = fs::read_dir(path).unwrap_or_else(|e| exit_io_error(&e));
for entry in dir.filter_map(Result::ok) {
convert_html_recursive(args, entry.path().as_ref(), replacer, converter)
}
} else if path.is_file()
&& let Some(ext) = path.extension()
&& ext == "html"
{
convert_html(args, path, true, replacer, converter);
}
}
fn convert_html(
args: &Args,
fp: &Path,
write: bool,
replacer: &Replacer,
converter: &LatexToMathML,
) {
let original = fs::read_to_string(fp).unwrap_or_else(|e| exit_io_error(&e));
let converted = replace(replacer, &original, converter, args.continue_on_error)
.unwrap_or_else(|e| exit_conversion_error(e, Some(fp)));
print_warnings(&converted.warnings, Some(fp));
if args.dry_run {
return;
}
if write {
if original != converted.html {
let mut fp = fs::File::create(fp).unwrap_or_else(|e| exit_io_error(&e));
fp.write_all(converted.html.as_bytes())
.unwrap_or_else(|e| exit_io_error(&e));
}
} else {
print!("{}", converted.html);
}
}
pub(crate) fn use_color() -> bool {
fn is_set(var: &str) -> bool {
std::env::var_os(var).is_some_and(|value| !value.is_empty())
}
fn terminal_is_dumb() -> bool {
cfg!(unix)
&& match std::env::var_os("TERM") {
Some(term) => term == "dumb",
None => true,
}
}
static USE_COLOR: LazyLock<bool> = LazyLock::new(|| {
decide_color(
is_set("NO_COLOR"),
is_set("CLICOLOR_FORCE"),
terminal_is_dumb(),
std::io::stderr().is_terminal(),
)
});
*USE_COLOR
}
fn decide_color(
no_color: bool,
clicolor_force: bool,
terminal_is_dumb: bool,
is_terminal: bool,
) -> bool {
!no_color && (clicolor_force || (is_terminal && !terminal_is_dumb))
}
fn render_ariadne_report(error: &LatexError, source_name: &str, input: &str) {
let report = error.to_report(source_name, use_color());
report
.eprint((source_name, ariadne::Source::from(input)))
.expect("failed to write report");
}
fn print_warnings(warnings: &[SnippetWarning], fp: Option<&Path>) {
for warning in warnings {
eprint!("Warning");
if let Some(fp) = fp {
eprint!(" in '{}'", fp.display());
}
eprintln!(": {warning}");
}
}
fn exit_conversion_error<E: std::error::Error>(e: E, fp: Option<&Path>) -> ! {
eprint!("Conversion error");
if let Some(fp) = fp {
eprint!(" in '{}'", fp.display());
}
eprintln!(": {e}");
std::process::exit(2);
}
fn exit_io_error(e: &std::io::Error) -> ! {
eprintln!("IO Error: {e}");
std::process::exit(1);
}
#[cfg(test)]
mod tests {
#[test]
fn cli_definition_is_valid() {
use clap::CommandFactory;
crate::Args::command().debug_assert();
}
#[test]
fn color_decision() {
use crate::decide_color;
for &clicolor_force in &[false, true] {
for &dumb in &[false, true] {
for &is_terminal in &[false, true] {
assert!(!decide_color(true, clicolor_force, dumb, is_terminal));
}
}
}
assert!(decide_color(false, true, false, false));
assert!(decide_color(false, true, true, false));
assert!(decide_color(false, false, false, true));
assert!(!decide_color(false, false, true, true));
assert!(!decide_color(false, false, false, false));
}
#[test]
fn full_test() {
let text = r#"
Let us consider a rigid sphere (i.e., one having a spherical figure when tested in the stationary system) of radius $R$
which is at rest relative to the system ($K$), and whose centre coincides with the origin of $K$ then the equation of the
surface of this sphere, which is moving with a velocity $v$ relative to $K$, is
$$\xi^2 + \eta^2 + \zeta^2 = R^2$$
At time $t = 0$ the equation is expressed by means of $(x, y, z, t)$ as
$$\frac{ x^2 }{ \left( \sqrt{ 1 - \frac{ v^2 }{ c^2 } } \right)^2 } + y^2 + z^2 = R^2 .$$
A rigid body which has the figure of a sphere when measured in the moving system, has therefore in the moving
condition — when considered from the stationary system, the figure of a rotational ellipsoid with semi-axes
$$R {\sqrt{1-{\frac {v^{2}}{c^{2}}}}}, \ R, \ R .$$
"#;
let converter =
math_core::LatexToMathML::new(math_core::MathCoreConfig::default()).unwrap();
let replacer = crate::Replacer::new(("$", "$"), ("$$", "$$"), false);
let mathml = crate::replace(&replacer, text, &converter, false).unwrap();
println!("{}", mathml.html);
}
#[test]
fn forward_reference() {
let text = r"<p>See $\eqref{eq:a}$.</p>
<p>$$\begin{align} x = 1 \label{eq:a}\end{align}$$</p>";
let converter =
math_core::LatexToMathML::new(math_core::MathCoreConfig::default()).unwrap();
let replacer = crate::Replacer::new(("$", "$"), ("$$", "$$"), false);
let converted = crate::replace(&replacer, text, &converter, false).unwrap();
let reference = converted.html.split_once("</p>").unwrap().0;
assert!(
reference.contains("(1)"),
"unresolved reference: {reference}"
);
}
#[test]
fn continue_on_error() {
let text = r"good $x$, bad $\frac$, good $y$";
let converter =
math_core::LatexToMathML::new(math_core::MathCoreConfig::default()).unwrap();
let replacer = crate::Replacer::new(("$", "$"), ("$$", "$$"), false);
assert!(crate::replace(&replacer, text, &converter, false).is_err());
let mathml = crate::replace(&replacer, text, &converter, true)
.unwrap()
.html;
assert!(mathml.starts_with("good <math"));
assert!(mathml.ends_with("</math>"));
}
}