#[cfg(feature = "cli")]
use clap::{Parser, Subcommand, ValueEnum};
#[cfg(feature = "cli")]
use std::fs;
#[cfg(feature = "cli")]
use std::io::{self, Read, Write};
#[cfg(feature = "cli")]
use tylax::{
batch::{convert_batch, BatchDirection, BatchFileStatus, BatchOptions},
convert_auto, convert_auto_document, detect_format,
diagnostics::{check_latex, format_diagnostics},
latex_document_to_typst, latex_to_typst, latex_to_typst_with_diagnostics_options,
tikz::{convert_cetz_to_tikz, convert_tikz_to_cetz, is_cetz_code},
typst_document_to_latex, typst_to_latex, typst_to_latex_with_diagnostics, CliDiagnostic,
DocumentWrapperMode, L2TOptions, PreambleMode, T2LOptions,
};
#[cfg(feature = "cli")]
#[derive(Parser)]
#[command(name = "t2l")]
#[command(author = "SciPenAI")]
#[command(version)]
#[command(about = "Tylax - High-performance bidirectional LaTeX <-> Typst converter", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
input_file: Option<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(short, long, value_enum, default_value_t = Direction::Auto)]
direction: Direction,
#[arg(short = 'f', long)]
full_document: bool,
#[arg(short, long)]
pretty: bool,
#[arg(long)]
detect: bool,
#[arg(long)]
check: bool,
#[arg(long, default_value_t = true)]
color: bool,
#[arg(long)]
no_eval: bool,
#[arg(long)]
strict: bool,
#[arg(short, long)]
quiet: bool,
#[arg(long)]
embed_warnings: bool,
#[arg(long, conflicts_with_all = ["preamble", "wrapper"])]
no_preamble: bool,
#[arg(long, value_name = "FILE")]
preamble: Option<String>,
#[arg(long, value_name = "FILE")]
wrapper: Option<String>,
}
#[cfg(feature = "cli")]
#[derive(Subcommand)]
enum Commands {
Check {
input: Option<String>,
#[arg(long)]
no_color: bool,
},
Convert {
input: Option<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(short, long, value_enum, default_value_t = Direction::Auto)]
direction: Direction,
#[arg(short = 'f', long)]
full_document: bool,
},
Tikz {
input: Option<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(short, long, value_enum, default_value_t = TikzDirection::Auto)]
direction: TikzDirection,
},
Batch {
input: String,
#[arg(short, long)]
output_dir: String,
#[arg(short, long, value_enum, default_value_t = Direction::L2t)]
direction: Direction,
#[arg(short = 'f', long)]
full_document: bool,
#[arg(short, long)]
extension: Option<String>,
#[arg(long)]
recursive: bool,
#[arg(long, value_name = "GLOB")]
exclude: Vec<String>,
},
Info,
}
#[cfg(feature = "cli")]
#[derive(Clone, ValueEnum)]
enum TikzDirection {
Auto,
TikzToCetz,
CetzToTikz,
}
#[cfg(feature = "cli")]
#[derive(Clone, ValueEnum)]
enum Direction {
Auto,
L2t,
T2l,
}
#[cfg(feature = "cli")]
fn main() -> io::Result<()> {
let cli = Cli::parse();
if let Some(cmd) = cli.command {
return handle_subcommand(cmd);
}
let (input, filename) = match cli.input_file {
Some(ref path) => (fs::read_to_string(path)?, Some(path.clone())),
None => {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
(buffer, None)
}
};
if cli.detect {
let format = detect_format(&input);
println!("{}", format);
return Ok(());
}
if cli.check {
let result = check_latex(&input);
let output = format_diagnostics(&result, cli.color);
println!("{}", output);
if result.has_errors() {
std::process::exit(1);
}
return Ok(());
}
let direction = match cli.direction {
Direction::Auto => {
if let Some(ref name) = filename {
if name.ends_with(".typ") {
Direction::T2l
} else if name.ends_with(".tex") {
Direction::L2t
} else {
let format = detect_format(&input);
if format == "latex" {
Direction::L2t
} else {
Direction::T2l
}
}
} else {
let format = detect_format(&input);
if format == "latex" {
Direction::L2t
} else {
Direction::T2l
}
}
}
d => d,
};
let is_full_document = cli.full_document || is_latex_document(&input);
let preamble_mode = resolve_preamble_mode(cli.no_preamble, cli.preamble.as_deref())?;
let wrapper_mode = resolve_wrapper_mode(cli.no_preamble, cli.wrapper.as_deref())?;
let (result, diagnostics): (String, Vec<CliDiagnostic>) = match direction {
Direction::L2t => {
let l2t_options = L2TOptions {
preamble: preamble_mode,
..Default::default()
};
let conv_result = latex_to_typst_with_diagnostics_options(&input, l2t_options);
let diags = conv_result
.warnings
.into_iter()
.map(CliDiagnostic::from)
.collect();
(conv_result.output, diags)
}
Direction::T2l => {
let options = T2LOptions {
full_document: is_full_document,
wrapper: wrapper_mode,
..Default::default()
};
if !cli.no_eval {
let conv_result = typst_to_latex_with_diagnostics(&input, &options);
let diags = conv_result
.warnings
.into_iter()
.map(CliDiagnostic::from)
.collect();
(conv_result.output, diags)
} else {
let output = if is_full_document {
typst_document_to_latex(&input)
} else {
typst_to_latex(&input)
};
(output, Vec::new())
}
}
Direction::Auto => {
let (output, _) = if is_full_document {
convert_auto_document(&input)
} else {
convert_auto(&input)
};
(output, Vec::new())
}
};
if !cli.quiet && !diagnostics.is_empty() {
print_diagnostics_to_stderr(&diagnostics, cli.color);
}
if cli.strict && !diagnostics.is_empty() {
eprintln!(
"Error: {} conversion warning(s) in strict mode",
diagnostics.len()
);
std::process::exit(1);
}
let result = if cli.embed_warnings && !diagnostics.is_empty() {
embed_diagnostics_as_comments(&result, &diagnostics)
} else {
result
};
let result = if cli.pretty {
pretty_print(&result)
} else {
result
};
match cli.output {
Some(path) => {
let mut file = fs::File::create(&path)?;
writeln!(file, "{}", result)?;
if diagnostics.is_empty() {
eprintln!("Output written to: {}", path);
} else {
eprintln!(
"Output written to: {} ({} warning(s))",
path,
diagnostics.len()
);
}
}
None => {
println!("{}", result);
}
}
Ok(())
}
#[cfg(feature = "cli")]
fn handle_subcommand(cmd: Commands) -> io::Result<()> {
match cmd {
Commands::Check { input, no_color } => {
let content = match input {
Some(path) => fs::read_to_string(&path)?,
None => {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
buffer
}
};
let result = check_latex(&content);
let output = format_diagnostics(&result, !no_color);
println!("{}", output);
if result.has_errors() {
std::process::exit(1);
}
}
Commands::Convert {
input,
output,
direction,
full_document,
} => {
let (content, filename) = match input {
Some(ref path) => (fs::read_to_string(path)?, Some(path.clone())),
None => {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
(buffer, None)
}
};
let direction = match direction {
Direction::Auto => {
if let Some(ref name) = filename {
if name.ends_with(".typ") {
Direction::T2l
} else if name.ends_with(".tex") {
Direction::L2t
} else {
let format = detect_format(&content);
if format == "latex" {
Direction::L2t
} else {
Direction::T2l
}
}
} else {
let format = detect_format(&content);
if format == "latex" {
Direction::L2t
} else {
Direction::T2l
}
}
}
d => d,
};
let result = if full_document {
match direction {
Direction::L2t => latex_document_to_typst(&content),
Direction::T2l => typst_document_to_latex(&content),
Direction::Auto => convert_auto_document(&content).0,
}
} else {
match direction {
Direction::L2t => latex_to_typst(&content),
Direction::T2l => typst_to_latex(&content),
Direction::Auto => convert_auto(&content).0,
}
};
match output {
Some(path) => {
let mut file = fs::File::create(&path)?;
writeln!(file, "{}", result)?;
eprintln!("Output written to: {}", path);
}
None => {
println!("{}", result);
}
}
}
Commands::Tikz {
input,
output,
direction,
} => {
let content = match input {
Some(path) => fs::read_to_string(&path)?,
None => {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
buffer
}
};
let direction = match direction {
TikzDirection::Auto => {
if is_cetz_code(&content) {
TikzDirection::CetzToTikz
} else {
TikzDirection::TikzToCetz
}
}
d => d,
};
let result = match direction {
TikzDirection::TikzToCetz => convert_tikz_to_cetz(&content),
TikzDirection::CetzToTikz => convert_cetz_to_tikz(&content),
TikzDirection::Auto => unreachable!(),
};
match output {
Some(path) => {
let mut file = fs::File::create(&path)?;
writeln!(file, "{}", result)?;
eprintln!("TikZ/CeTZ conversion written to: {}", path);
}
None => {
println!("{}", result);
}
}
}
Commands::Batch {
input,
output_dir,
direction,
full_document,
extension,
recursive,
exclude,
} => {
let options = BatchOptions {
input: input.into(),
output_dir: output_dir.into(),
direction: batch_direction(direction),
recursive,
full_document,
output_extension: extension,
excludes: exclude,
l2t_options: L2TOptions::default(),
t2l_options: T2LOptions::default(),
};
let report = convert_batch(&options)
.map_err(|err| io::Error::other(format!("batch conversion failed: {}", err)))?;
for result in &report.results {
match &result.status {
BatchFileStatus::Converted => {
eprintln!("Converted: {}", result.output_path.display());
}
BatchFileStatus::Failed(message) => {
eprintln!("Failed: {} - {}", result.output_path.display(), message);
}
}
}
eprintln!(
"\nBatch conversion complete: {} succeeded, {} failed",
report.success_count, report.error_count
);
if report.error_count > 0 {
std::process::exit(1);
}
}
Commands::Info => {
println!("Tylax - High-performance bidirectional LaTeX <-> Typst converter");
println!("Version: {}", env!("CARGO_PKG_VERSION"));
println!();
println!("Features:");
println!(" - LaTeX <-> Typst conversion (math + documents)");
println!(" - Typst <-> LaTeX conversion (math + documents)");
println!(" - TikZ <-> CeTZ graphics conversion");
println!(" - Batch file processing");
println!(" - LaTeX diagnostics and checking");
println!(" - Auto-detection of input format");
println!();
println!("Supported packages:");
println!(" - amsmath, amssymb, mathtools");
println!(" - graphicx, hyperref, biblatex");
println!(" - tikz, pgf (basic features)");
println!(" - siunitx, mhchem");
println!();
println!("Repository: https://github.com/scipenai/tylax");
println!();
}
}
Ok(())
}
#[cfg(feature = "cli")]
fn is_latex_document(input: &str) -> bool {
input.contains("\\documentclass")
|| input.contains("\\begin{document}")
|| input.contains("\\section")
|| input.contains("\\chapter")
|| input.contains("\\title")
|| input.contains("\\maketitle")
|| input.contains("\\usepackage")
}
#[cfg(feature = "cli")]
fn pretty_print(input: &str) -> String {
let mut result = String::new();
let mut indent_level: usize = 0;
for line in input.lines() {
let trimmed = line.trim();
if trimmed.starts_with('}') || trimmed.starts_with(']') || trimmed.starts_with(')') {
indent_level = indent_level.saturating_sub(1);
}
if trimmed.starts_with("\\end{") {
indent_level = indent_level.saturating_sub(1);
}
for _ in 0..indent_level {
result.push_str(" ");
}
result.push_str(trimmed);
result.push('\n');
if trimmed.ends_with('{') || trimmed.ends_with('[') {
indent_level += 1;
}
if trimmed.starts_with("\\begin{") {
indent_level += 1;
}
}
result.trim().to_string()
}
#[cfg(feature = "cli")]
fn batch_direction(direction: Direction) -> BatchDirection {
match direction {
Direction::Auto => BatchDirection::Auto,
Direction::L2t => BatchDirection::LatexToTypst,
Direction::T2l => BatchDirection::TypstToLatex,
}
}
#[cfg(feature = "cli")]
fn print_diagnostics_to_stderr(diagnostics: &[CliDiagnostic], use_color: bool) {
eprintln!();
eprintln!(
"{}Conversion Warnings ({}):{}",
if use_color { "\x1b[33m" } else { "" },
diagnostics.len(),
if use_color { "\x1b[0m" } else { "" }
);
eprintln!();
for diag in diagnostics {
let color = if use_color { diag.color_code() } else { "" };
let reset = if use_color { "\x1b[0m" } else { "" };
if let Some(ref loc) = diag.location {
eprintln!(
" {}[{}]{} {}: {}",
color, diag.kind, reset, loc, diag.message
);
} else {
eprintln!(" {}[{}]{} {}", color, diag.kind, reset, diag.message);
}
}
eprintln!();
}
#[cfg(feature = "cli")]
fn embed_diagnostics_as_comments(output: &str, diagnostics: &[CliDiagnostic]) -> String {
let mut result = output.to_string();
result.push_str("\n\n// ═══════════════════════════════════════════════════════════════\n");
result.push_str("// Conversion Warnings\n");
result.push_str("// ═══════════════════════════════════════════════════════════════\n");
for diag in diagnostics {
if let Some(ref loc) = diag.location {
result.push_str(&format!("// [{}] {}: {}\n", diag.kind, loc, diag.message));
} else {
result.push_str(&format!("// [{}] {}\n", diag.kind, diag.message));
}
}
result
}
#[cfg(feature = "cli")]
fn resolve_preamble_mode(
no_preamble: bool,
preamble_file: Option<&str>,
) -> io::Result<PreambleMode> {
if no_preamble {
return Ok(PreambleMode::None);
}
if let Some(path) = preamble_file {
let contents = fs::read_to_string(path).map_err(|e| {
io::Error::new(
e.kind(),
format!("failed to read --preamble file '{}': {}", path, e),
)
})?;
return Ok(PreambleMode::Custom(contents));
}
Ok(PreambleMode::Default)
}
#[cfg(feature = "cli")]
fn resolve_wrapper_mode(
no_preamble: bool,
wrapper_file: Option<&str>,
) -> io::Result<DocumentWrapperMode> {
if no_preamble {
return Ok(DocumentWrapperMode::BodyOnly);
}
if let Some(path) = wrapper_file {
let template = fs::read_to_string(path).map_err(|e| {
io::Error::new(
e.kind(),
format!("failed to read --wrapper file '{}': {}", path, e),
)
})?;
return DocumentWrapperMode::from_template(&template).map_err(|msg| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("--wrapper file '{}': {}", path, msg),
)
});
}
Ok(DocumentWrapperMode::Default)
}
#[cfg(not(feature = "cli"))]
fn main() {
eprintln!("CLI feature not enabled. Build with --features cli");
eprintln!();
eprintln!("Usage:");
eprintln!(" cargo install tylax --features cli");
eprintln!(" t2l [OPTIONS] [INPUT_FILE]");
}