pub mod context;
pub mod engine;
mod markup;
mod math;
mod math_emit;
mod math_ir;
mod preprocess;
mod table;
mod utils;
pub use context::{ConvertContext, EnvironmentContext, T2LOptions, TokenType};
use engine::ContentNode;
use typst_syntax::{parse, parse_math};
pub use engine::{expand_macros, EvalError, EvalResult, MiniEval, SourceSpan, Value};
pub use preprocess::{extract_let_definitions, preprocess_typst, TypstDefDb};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WarningKind {
UndefinedVariable,
TypeMismatch,
DivisionByZero,
InvalidOperation,
TooManyIterations,
ArgumentError,
IndexOutOfBounds,
KeyNotFound,
SyntaxError,
FileNotFound,
ImportError,
RegexError,
RecursionLimitExceeded,
EvalWarning,
Other,
}
impl std::fmt::Display for WarningKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WarningKind::UndefinedVariable => write!(f, "undefined variable"),
WarningKind::TypeMismatch => write!(f, "type mismatch"),
WarningKind::DivisionByZero => write!(f, "division by zero"),
WarningKind::InvalidOperation => write!(f, "invalid operation"),
WarningKind::TooManyIterations => write!(f, "too many iterations"),
WarningKind::ArgumentError => write!(f, "argument error"),
WarningKind::IndexOutOfBounds => write!(f, "index out of bounds"),
WarningKind::KeyNotFound => write!(f, "key not found"),
WarningKind::SyntaxError => write!(f, "syntax error"),
WarningKind::FileNotFound => write!(f, "file not found"),
WarningKind::ImportError => write!(f, "import error"),
WarningKind::RegexError => write!(f, "regex error"),
WarningKind::RecursionLimitExceeded => write!(f, "recursion limit exceeded"),
WarningKind::EvalWarning => write!(f, "eval warning"),
WarningKind::Other => write!(f, "other"),
}
}
}
impl From<&engine::EvalErrorKind> for WarningKind {
fn from(kind: &engine::EvalErrorKind) -> Self {
use engine::EvalErrorKind;
match kind {
EvalErrorKind::UndefinedVariable(_) => WarningKind::UndefinedVariable,
EvalErrorKind::TypeMismatch { .. } => WarningKind::TypeMismatch,
EvalErrorKind::DivisionByZero => WarningKind::DivisionByZero,
EvalErrorKind::InvalidOperation(_) => WarningKind::InvalidOperation,
EvalErrorKind::TooManyIterations => WarningKind::TooManyIterations,
EvalErrorKind::ArgumentError(_) => WarningKind::ArgumentError,
EvalErrorKind::IndexOutOfBounds { .. } => WarningKind::IndexOutOfBounds,
EvalErrorKind::KeyNotFound(_) => WarningKind::KeyNotFound,
EvalErrorKind::SyntaxError(_) => WarningKind::SyntaxError,
EvalErrorKind::FileNotFound(_) => WarningKind::FileNotFound,
EvalErrorKind::ImportError(_) => WarningKind::ImportError,
EvalErrorKind::RegexError(_) => WarningKind::RegexError,
EvalErrorKind::RecursionLimitExceeded { .. } => WarningKind::RecursionLimitExceeded,
EvalErrorKind::Other(_) => WarningKind::Other,
}
}
}
#[derive(Debug, Clone)]
pub struct ConversionWarning {
pub kind: WarningKind,
pub message: String,
pub span: Option<SourceSpan>,
}
impl ConversionWarning {
pub fn new(kind: WarningKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
span: None,
}
}
pub fn with_span(kind: WarningKind, message: impl Into<String>, span: SourceSpan) -> Self {
Self {
kind,
message: message.into(),
span: Some(span),
}
}
pub fn from_eval_error(error: &EvalError) -> Self {
let kind = WarningKind::from(&error.kind);
let message = format!("{}", error);
Self {
kind,
message,
span: error.span,
}
}
}
impl From<engine::EvalWarning> for ConversionWarning {
fn from(warning: engine::EvalWarning) -> Self {
Self {
kind: WarningKind::EvalWarning,
message: warning.message,
span: warning.span,
}
}
}
impl std::fmt::Display for ConversionWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(span) = &self.span {
write!(
f,
"[{}] {}..{}: {}",
self.kind, span.start, span.end, self.message
)
} else {
write!(f, "[{}] {}", self.kind, self.message)
}
}
}
impl From<ConversionWarning> for crate::utils::error::CliDiagnostic {
fn from(warning: ConversionWarning) -> Self {
use crate::utils::error::{CliDiagnostic, DiagnosticSeverity};
let severity = match warning.kind {
WarningKind::UndefinedVariable
| WarningKind::DivisionByZero
| WarningKind::RecursionLimitExceeded => DiagnosticSeverity::Error,
WarningKind::TypeMismatch
| WarningKind::InvalidOperation
| WarningKind::TooManyIterations
| WarningKind::ArgumentError
| WarningKind::SyntaxError => DiagnosticSeverity::Warning,
_ => DiagnosticSeverity::Info,
};
let location = warning.span.map(|s| format!("{}..{}", s.start, s.end));
let mut diag = CliDiagnostic::new(severity, warning.kind.to_string(), warning.message);
if let Some(loc) = location {
diag = diag.with_location(loc);
}
diag
}
}
#[derive(Debug, Clone)]
pub struct ConversionResult {
pub output: String,
pub warnings: Vec<ConversionWarning>,
}
impl ConversionResult {
pub fn ok(output: String) -> Self {
Self {
output,
warnings: Vec::new(),
}
}
pub fn with_warnings(output: String, warnings: Vec<ConversionWarning>) -> Self {
Self { output, warnings }
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
pub fn format_warnings(&self) -> Vec<String> {
self.warnings.iter().map(|w| w.to_string()).collect()
}
}
pub fn typst_to_latex(input: &str) -> String {
typst_to_latex_with_options(input, &T2LOptions::default())
}
pub fn typst_to_latex_with_options(input: &str, options: &T2LOptions) -> String {
let mut ctx = ConvertContext::new();
ctx.options = options.clone();
let processed_input = preprocess::preprocess_typst(input);
if options.math_only {
let root = parse_math(&processed_input);
math::convert_math_node(&root, &mut ctx);
} else {
let root = parse(&processed_input);
markup::convert_markup_node(&root, &mut ctx);
}
let mut result = ctx.finalize();
if options.full_document {
result = wrap_in_document(&result, options);
}
result
}
pub fn typst_document_to_latex(input: &str) -> String {
typst_to_latex_with_options(input, &T2LOptions::full_document())
}
pub fn typst_to_latex_with_diagnostics(input: &str, options: &T2LOptions) -> ConversionResult {
let mut warnings = Vec::new();
let (expanded_input, expanded_nodes): (String, Option<Vec<ContentNode>>) =
match engine::expand_macros_with_warnings(input) {
Ok(result) => {
warnings.extend(result.warnings.into_iter().map(ConversionWarning::from));
(result.output, Some(result.nodes))
}
Err(e) => {
warnings.push(ConversionWarning::from_eval_error(&e));
(preprocess::preprocess_typst(input), None)
}
};
let mut ctx = ConvertContext::new();
ctx.options = options.clone();
if options.math_only {
let root = parse_math(&expanded_input);
math::convert_math_node(&root, &mut ctx);
} else if let Some(nodes) = expanded_nodes.as_ref() {
markup::convert_content_nodes_to_latex(nodes, &mut ctx);
} else {
let root = parse(&expanded_input);
markup::convert_markup_node(&root, &mut ctx);
}
let mut output = ctx.finalize();
if options.full_document {
output = wrap_in_document(&output, options);
}
ConversionResult::with_warnings(output, warnings)
}
pub fn typst_to_latex_with_eval(input: &str, options: &T2LOptions) -> String {
let result = typst_to_latex_with_diagnostics(input, options);
for warning in &result.warnings {
eprintln!("[tylax] Warning: {}", warning);
}
result.output
}
fn wrap_in_document(content: &str, options: &T2LOptions) -> String {
let mut doc = String::new();
let doc_class = if options.document_class.is_empty() {
"article"
} else {
&options.document_class
};
doc.push_str(&format!("\\documentclass{{{}}}\n", doc_class));
doc.push_str("\\usepackage[utf8]{inputenc}\n");
doc.push_str("\\usepackage{amsmath}\n");
doc.push_str("\\usepackage{amssymb}\n");
doc.push_str("\\usepackage{graphicx}\n");
doc.push_str("\\usepackage{hyperref}\n");
doc.push_str("\\usepackage{xcolor}\n");
doc.push_str("\\usepackage{longtable}\n"); doc.push_str("\\usepackage{booktabs}\n"); doc.push_str("\\usepackage{geometry}\n");
doc.push_str("\\geometry{a4paper, margin=2cm}\n");
if let Some(ref title) = options.title {
doc.push_str(&format!("\\title{{{}}}\n", title));
}
if let Some(ref author) = options.author {
doc.push_str(&format!("\\author{{{}}}\n", author));
}
doc.push('\n');
doc.push_str("\\begin{document}\n\n");
if options.title.is_some() {
doc.push_str("\\maketitle\n\n");
}
doc.push_str(content);
doc.push_str("\n\n\\end{document}");
doc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_conversion() {
let typst = "Hello *world*!";
let latex = typst_to_latex(typst);
assert_eq!(latex.trim(), "Hello \\textbf{world}!");
}
#[test]
fn test_diagnostics_basic_success() {
let result = typst_to_latex_with_diagnostics("Hello *world*!", &T2LOptions::default());
assert!(
result.output.contains("\\textbf{world}"),
"Output should contain bold: {}",
result.output
);
assert!(
!result.has_warnings(),
"Should have no warnings for simple content"
);
}
#[test]
fn test_diagnostics_with_eval() {
let input = r#"
#let x = 5
#x
"#;
let result = typst_to_latex_with_diagnostics(input, &T2LOptions::default());
assert!(
result.output.contains("5"),
"Output should contain expanded value: {}",
result.output
);
}
#[test]
fn test_diagnostics_graceful_degradation() {
let input = r#"#undefined_variable"#;
let result = typst_to_latex_with_diagnostics(input, &T2LOptions::default());
assert!(
result.has_warnings(),
"Should have warnings for undefined variable"
);
assert!(
!result.output.is_empty(),
"Should still produce output on error"
);
}
#[test]
fn test_diagnostics_wrapper_equivalence() {
let input = r#"
#let double(x) = x * 2
#double(5)
"#;
let options = T2LOptions::default();
let diagnostics_result = typst_to_latex_with_diagnostics(input, &options);
let wrapper_result = typst_to_latex_with_eval(input, &options);
assert_eq!(
diagnostics_result.output, wrapper_result,
"Wrapper should produce same output as diagnostics API"
);
}
#[test]
fn test_conversion_warning_display() {
let warning_no_span = ConversionWarning::new(WarningKind::Other, "Test warning");
assert_eq!(warning_no_span.to_string(), "[other] Test warning");
let warning_with_span = ConversionWarning::with_span(
WarningKind::SyntaxError,
"Test warning",
SourceSpan::new(10, 20),
);
assert_eq!(
warning_with_span.to_string(),
"[syntax error] 10..20: Test warning"
);
}
#[test]
fn test_warning_kind_from_eval_error() {
use engine::EvalErrorKind;
assert_eq!(
WarningKind::from(&EvalErrorKind::UndefinedVariable("x".to_string())),
WarningKind::UndefinedVariable
);
assert_eq!(
WarningKind::from(&EvalErrorKind::DivisionByZero),
WarningKind::DivisionByZero
);
assert_eq!(
WarningKind::from(&EvalErrorKind::SyntaxError("test".to_string())),
WarningKind::SyntaxError
);
}
#[test]
fn test_graceful_degradation_warning_kind() {
let input = r#"#undefined_variable"#;
let result = typst_to_latex_with_diagnostics(input, &T2LOptions::default());
assert!(result.has_warnings(), "Should have warnings");
let warning = &result.warnings[0];
assert_eq!(
warning.kind,
WarningKind::UndefinedVariable,
"Warning should be UndefinedVariable, got: {:?}",
warning.kind
);
}
#[test]
fn test_conversion_result_helpers() {
let ok_result = ConversionResult::ok("output".to_string());
assert!(!ok_result.has_warnings());
assert!(ok_result.format_warnings().is_empty());
let warning = ConversionWarning::new(WarningKind::Other, "test");
let warned_result = ConversionResult::with_warnings("output".to_string(), vec![warning]);
assert!(warned_result.has_warnings());
assert_eq!(warned_result.format_warnings().len(), 1);
}
#[test]
fn test_diagnostics_for_loop() {
let input = r#"#for i in range(3) [
#i
]"#;
let result = typst_to_latex_with_diagnostics(input, &T2LOptions::default());
assert!(
result.output.contains("0"),
"Should have 0: {}",
result.output
);
assert!(
result.output.contains("1"),
"Should have 1: {}",
result.output
);
assert!(
result.output.contains("2"),
"Should have 2: {}",
result.output
);
}
#[test]
fn test_diagnostics_conditional() {
let input = r#"#if true [yes] else [no]"#;
let result = typst_to_latex_with_diagnostics(input, &T2LOptions::default());
assert!(
result.output.contains("yes"),
"Should have yes: {}",
result.output
);
assert!(
!result.output.contains("no"),
"Should NOT have no: {}",
result.output
);
}
#[test]
fn test_dot_operator_conversion() {
let opts = T2LOptions {
math_only: true,
..Default::default()
};
let result = typst_to_latex_with_options("x dot y", &opts);
eprintln!("dot conversion result: {}", result);
assert!(
result.contains("\\cdot") || result.contains("cdot"),
"Expected \\cdot for dot operator, got: {}",
result
);
assert!(
!result.contains("x . y") && !result.contains("x.y"),
"Should not have literal period, got: {}",
result
);
}
#[test]
fn test_for_loop_list_not_nested() {
let input = r#"#for x in ("A", "B", "C") [
- #x
]"#;
let result = typst_to_latex_with_diagnostics(input, &T2LOptions::default());
eprintln!("For loop list result:\n{}", result.output);
let itemize_count = result.output.matches("\\begin{itemize}").count();
assert_eq!(
itemize_count, 1,
"Expected 1 itemize, got {}: {}",
itemize_count, result.output
);
let item_count = result.output.matches("\\item").count();
assert_eq!(
item_count, 3,
"Expected 3 items, got {}: {}",
item_count, result.output
);
}
}