use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct T2LOptions {
pub full_document: bool,
pub document_class: String,
pub title: Option<String>,
pub author: Option<String>,
pub math_only: bool,
pub block_math_mode: bool,
}
impl Default for T2LOptions {
fn default() -> Self {
Self {
full_document: false,
document_class: "article".to_string(),
title: None,
author: None,
math_only: false,
block_math_mode: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum EnvironmentContext {
#[default]
None,
Document,
Figure,
Table,
Tabular,
Itemize,
Enumerate,
Description,
Equation,
Align,
Matrix(String), Cases,
Theorem(String), Center,
Quote,
Verbatim,
}
impl T2LOptions {
pub fn new() -> Self {
Self::default()
}
pub fn math_only() -> Self {
Self {
math_only: true,
..Default::default()
}
}
pub fn full_document() -> Self {
Self {
full_document: true,
..Default::default()
}
}
pub fn inline_math() -> Self {
Self {
math_only: true,
block_math_mode: false,
..Default::default()
}
}
pub fn block_math() -> Self {
Self {
math_only: true,
block_math_mode: true,
..Default::default()
}
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum TokenType {
None,
Operator,
Punctuation,
Letter,
Number,
OpenParen,
CloseParen,
Command,
Text,
Newline,
}
pub struct ConvertContext {
pub output: String,
pub in_environment: bool,
pub last_token: TokenType,
pub indent_level: usize,
pub in_math: bool,
pub options: T2LOptions,
pub env_stack: Vec<EnvironmentContext>,
pub list_depth: usize,
pub labels: Vec<String>,
pub warnings: Vec<String>,
pub variables: HashMap<String, String>,
pub pending_label: Option<String>,
}
const INITIAL_BUFFER_CAPACITY: usize = 1024;
impl ConvertContext {
pub fn new() -> Self {
Self {
output: String::with_capacity(INITIAL_BUFFER_CAPACITY),
in_environment: false,
last_token: TokenType::None,
indent_level: 0,
in_math: false,
options: T2LOptions::default(),
env_stack: Vec::new(),
list_depth: 0,
labels: Vec::new(),
warnings: Vec::new(),
variables: HashMap::new(),
pending_label: None,
}
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
output: String::with_capacity(capacity),
in_environment: false,
last_token: TokenType::None,
indent_level: 0,
in_math: false,
options: T2LOptions::default(),
env_stack: Vec::new(),
list_depth: 0,
labels: Vec::new(),
warnings: Vec::new(),
variables: HashMap::new(),
pending_label: None,
}
}
pub fn push_env(&mut self, env: EnvironmentContext) {
if matches!(
env,
EnvironmentContext::Itemize | EnvironmentContext::Enumerate
) {
self.list_depth += 1;
}
self.env_stack.push(env);
}
pub fn pop_env(&mut self) -> Option<EnvironmentContext> {
let env = self.env_stack.pop();
if let Some(ref e) = env {
if matches!(
e,
EnvironmentContext::Itemize | EnvironmentContext::Enumerate
) {
self.list_depth = self.list_depth.saturating_sub(1);
}
}
env
}
pub fn current_env(&self) -> &EnvironmentContext {
self.env_stack.last().unwrap_or(&EnvironmentContext::None)
}
pub fn in_list(&self) -> bool {
self.env_stack.iter().any(|e| {
matches!(
e,
EnvironmentContext::Itemize | EnvironmentContext::Enumerate
)
})
}
pub fn is_in_env(&self, env: &EnvironmentContext) -> bool {
self.env_stack.contains(env)
}
pub fn add_warning(&mut self, msg: impl Into<String>) {
self.warnings.push(msg.into());
}
pub fn list_indent(&self) -> String {
" ".repeat(self.list_depth)
}
pub fn push(&mut self, s: &str) {
self.output.push_str(s);
}
pub fn push_line(&mut self, s: &str) {
self.push_indent();
self.push(s);
self.push("\n");
self.last_token = TokenType::Newline;
}
pub fn push_indent(&mut self) {
for _ in 0..self.indent_level {
self.output.push_str(" ");
}
}
pub fn newline(&mut self) {
if !self.output.ends_with('\n') {
self.push("\n");
}
self.last_token = TokenType::Newline;
}
pub fn ensure_paragraph_break(&mut self) {
if !self.output.ends_with("\n\n") && !self.output.is_empty() {
if self.output.ends_with('\n') {
self.push("\n");
} else {
self.push("\n\n");
}
}
}
pub fn push_with_spacing(&mut self, s: &str, token_type: TokenType) {
let needs_space = matches!(
(self.last_token, token_type),
(TokenType::Letter, TokenType::Letter)
| (TokenType::Command, TokenType::Letter)
| (TokenType::Command, TokenType::Number)
| (TokenType::Number, TokenType::Letter)
);
if needs_space && !self.output.ends_with(' ') && !self.output.ends_with('{') {
self.push(" ");
}
self.push(s);
self.last_token = token_type;
}
pub fn trim_trailing_space(&mut self) {
if self.output.ends_with(' ') {
self.output.pop();
}
}
pub fn finalize(self) -> String {
let trimmed = self.output.trim();
let mut result = String::with_capacity(trimmed.len());
let mut prev_char = None;
for ch in trimmed.chars() {
if ch == ' ' && prev_char == Some(' ') {
continue;
}
if self.options.math_only {
if ch == ' ' && prev_char == Some('{') {
continue; }
if ch == '}' && prev_char == Some(' ') {
if result.ends_with(' ') {
result.pop();
}
}
}
result.push(ch);
prev_char = Some(ch);
}
result
}
pub fn output_len(&self) -> usize {
self.output.len()
}
}
impl Default for ConvertContext {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_options_default() {
let opts = T2LOptions::new();
assert!(!opts.full_document);
assert!(!opts.math_only);
assert_eq!(opts.document_class, "article");
}
#[test]
fn test_options_math_only() {
let opts = T2LOptions::math_only();
assert!(opts.math_only);
assert!(!opts.full_document);
}
#[test]
fn test_context_push() {
let mut ctx = ConvertContext::new();
ctx.push("hello");
ctx.push(" world");
assert_eq!(ctx.output, "hello world");
}
#[test]
fn test_context_indent() {
let mut ctx = ConvertContext::new();
ctx.indent_level = 2;
ctx.push_line("test");
assert_eq!(ctx.output, " test\n");
}
#[test]
fn test_context_finalize() {
let mut ctx = ConvertContext::new();
ctx.push(" hello world ");
let result = ctx.finalize();
assert_eq!(result, "hello world");
}
}