From 5db08cea39d5e8d45e6dab50d4b260e8a8372131 Mon Sep 17 00:00:00 2001 From: OrangeX4 <34951714+OrangeX4@users.noreply.github.com> Date: Thu, 25 Apr 2024 17:48:43 +0800 Subject: [PATCH] feat(converter): support figure and tabular environments (#154) * dev: clean code * dev: more convert functions * dev: update itemcmd * dev: minor improvements * test(convert): refactor tests * dev: add converter.rs * fix: make ci happy * dev: add convert_env * dev: simplify \label for env * feat: add quote and abstract envs * dev: support caption for figure * fix: fix figure env bug * test: use assert_snapshot * test: update tests * fix: make insta test happy * fix: make ci happy * fix: fix bug of commands * feat: add support for \includegraphics * feat: add convert_env_figure * feat: support basic tabular * fix: make ci happy * feat: support \toprule, \midrule, and \bottomrule * dev: merge main branch * dev: update * fix: fix bug of glob env * fix: fix bug of Glob * fix: add pattern for Glob * docs: update README --- README.md | 1 + assets/artifacts | 2 +- crates/mitex-parser/src/arg_match.rs | 2 +- crates/mitex-parser/tests/ast.rs | 6 + crates/mitex-parser/tests/ast/figure.rs | 180 ++++++++ crates/mitex-parser/tests/ast/tabular.rs | 76 +++ crates/mitex-spec/src/lib.rs | 11 +- crates/mitex-spec/src/preludes.rs | 16 +- crates/mitex-spec/src/query.rs | 21 +- crates/mitex/src/converter.rs | 537 ++++++++++++++++++---- crates/mitex/tests/cvt.rs | 9 + crates/mitex/tests/cvt/basic_text_mode.rs | 20 + crates/mitex/tests/cvt/figure.rs | 52 +++ crates/mitex/tests/cvt/simple_env.rs | 13 + crates/mitex/tests/cvt/tabular.rs | 25 + crates/mitex/tests/cvt/trivia.rs | 12 +- packages/mitex/examples/example.typ | 18 +- packages/mitex/specs/latex/standard.typ | 27 +- packages/mitex/specs/prelude.typ | 9 + 19 files changed, 921 insertions(+), 116 deletions(-) create mode 100644 crates/mitex-parser/tests/ast/figure.rs create mode 100644 crates/mitex-parser/tests/ast/tabular.rs create mode 100644 crates/mitex/tests/cvt/figure.rs create mode 100644 crates/mitex/tests/cvt/simple_env.rs create mode 100644 crates/mitex/tests/cvt/tabular.rs diff --git a/README.md b/README.md index d89fb98..33bbb15 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ https://github.com/mitex-rs/mitex/assets/34951714/0ce77a2c-0a7d-445f-b26d-e139f3 - [x] Inline and block math equations. - [x] `\ref`, `\eqref` and `\label`. - [x] `itemize` and `enumerate` environments. + - [x] `figure`, `table` and `tabular` environments. ## Features to Implement diff --git a/assets/artifacts b/assets/artifacts index c6b397d..6dd2b1a 160000 --- a/assets/artifacts +++ b/assets/artifacts @@ -1 +1 @@ -Subproject commit c6b397df9ad621920d65fdf740d9e5ce88e8ec92 +Subproject commit 6dd2b1a3f60e511c87e121034136b298109a2277 diff --git a/crates/mitex-parser/src/arg_match.rs b/crates/mitex-parser/src/arg_match.rs index c5ca313..7496297 100644 --- a/crates/mitex-parser/src/arg_match.rs +++ b/crates/mitex-parser/src/arg_match.rs @@ -99,7 +99,7 @@ impl ArgMatcherBuilder { } } ArgPattern::Greedy => ArgMatcher::Greedy, - ArgPattern::Glob(re) => ArgMatcher::Glob { + ArgPattern::Glob { pattern: re } => ArgMatcher::Glob { re: re.clone(), prefix: String::new(), }, diff --git a/crates/mitex-parser/tests/ast.rs b/crates/mitex-parser/tests/ast.rs index d8f4e3a..122e4f4 100644 --- a/crates/mitex-parser/tests/ast.rs +++ b/crates/mitex-parser/tests/ast.rs @@ -38,6 +38,12 @@ mod ast { #[cfg(test)] mod trivia; + #[cfg(test)] + mod figure; + + #[cfg(test)] + mod tabular; + /// Convenient function to launch/debug a test case #[test] fn bug_playground() {} diff --git a/crates/mitex-parser/tests/ast/figure.rs b/crates/mitex-parser/tests/ast/figure.rs new file mode 100644 index 0000000..f6a5ee1 --- /dev/null +++ b/crates/mitex-parser/tests/ast/figure.rs @@ -0,0 +1,180 @@ +use super::prelude::*; + +#[test] +fn figure() { + assert_debug_snapshot!(parse(r###" + \begin{figure}[ht] + \centering + \includegraphics[width=0.5\textwidth]{example-image} + \caption{This is an example image.} + \label{fig:example} + \end{figure} + "###), @r###" + root + |br'("\n") + |space'(" ") + |env + ||begin + |||sym'("figure") + |||args + ||||bracket + |||||lbracket'("[") + |||||text(word'("ht")) + |||||rbracket'("]") + ||br'("\n") + ||space'(" ") + ||cmd(cmd-name("\\centering")) + ||br'("\n") + ||space'(" ") + ||cmd + |||cmd-name("\\includegraphics") + |||args + ||||bracket + |||||lbracket'("[") + |||||text(word'("width=0.5")) + |||||cmd(cmd-name("\\textwidth")) + |||||rbracket'("]") + |||args + ||||curly + |||||lbrace'("{") + |||||text(word'("example-image")) + |||||rbrace'("}") + ||br'("\n") + ||space'(" ") + ||cmd + |||cmd-name("\\caption") + |||args + ||||curly + |||||lbrace'("{") + |||||text(word'("This"),space'(" "),word'("is"),space'(" "),word'("an"),space'(" "),word'("example"),space'(" "),word'("image.")) + |||||rbrace'("}") + ||br'("\n") + ||space'(" ") + ||cmd + |||cmd-name("\\label") + |||args + ||||curly + |||||lbrace'("{") + |||||text(word'("fig:example")) + |||||rbrace'("}") + ||br'("\n") + ||space'(" ") + ||end(sym'("figure")) + |br'("\n") + |space'(" ") + "###); +} + +#[test] +fn table() { + assert_debug_snapshot!(parse(r###" + \begin{table}[ht] + \centering + \begin{tabular}{|c|c|} + \hline + \textbf{Name} & \textbf{Age} \\ + \hline + John & 25 \\ + Jane & 22 \\ + \hline + \end{tabular} + \caption{This is an example table.} + \label{tab:example} + \end{table} + "###), @r###" + root + |br'("\n") + |space'(" ") + |env + ||begin + |||sym'("table") + |||args + ||||bracket + |||||lbracket'("[") + |||||text(word'("ht")) + |||||rbracket'("]") + ||br'("\n") + ||space'(" ") + ||cmd(cmd-name("\\centering")) + ||br'("\n") + ||space'(" ") + ||env + |||begin + ||||sym'("tabular") + ||||args + |||||curly + ||||||lbrace'("{") + ||||||text(word'("|c|c|")) + ||||||rbrace'("}") + |||br'("\n") + |||space'(" ") + |||cmd(cmd-name("\\hline")) + |||br'("\n") + |||space'(" ") + |||cmd + ||||cmd-name("\\textbf") + ||||args + |||||curly + ||||||lbrace'("{") + ||||||text(word'("Name")) + ||||||rbrace'("}") + |||space'(" ") + |||ampersand'("&") + |||space'(" ") + |||cmd + ||||cmd-name("\\textbf") + ||||args + |||||curly + ||||||lbrace'("{") + ||||||text(word'("Age")) + ||||||rbrace'("}") + |||space'(" ") + |||newline("\\\\") + |||br'("\n") + |||space'(" ") + |||cmd(cmd-name("\\hline")) + |||br'("\n") + |||space'(" ") + |||text(word'("John"),space'(" ")) + |||ampersand'("&") + |||space'(" ") + |||text(word'("25"),space'(" ")) + |||newline("\\\\") + |||br'("\n") + |||space'(" ") + |||text(word'("Jane"),space'(" ")) + |||ampersand'("&") + |||space'(" ") + |||text(word'("22"),space'(" ")) + |||newline("\\\\") + |||br'("\n") + |||space'(" ") + |||cmd(cmd-name("\\hline")) + |||br'("\n") + |||space'(" ") + |||end(sym'("tabular")) + ||br'("\n") + ||space'(" ") + ||cmd + |||cmd-name("\\caption") + |||args + ||||curly + |||||lbrace'("{") + |||||text(word'("This"),space'(" "),word'("is"),space'(" "),word'("an"),space'(" "),word'("example"),space'(" "),word'("table.")) + |||||rbrace'("}") + ||br'("\n") + ||space'(" ") + ||cmd + |||cmd-name("\\label") + |||args + ||||curly + |||||lbrace'("{") + |||||text(word'("tab:example")) + |||||rbrace'("}") + ||br'("\n") + ||space'(" ") + ||end(sym'("table")) + |br'("\n") + |space'(" ") + "###); +} diff --git a/crates/mitex-parser/tests/ast/tabular.rs b/crates/mitex-parser/tests/ast/tabular.rs new file mode 100644 index 0000000..d883818 --- /dev/null +++ b/crates/mitex-parser/tests/ast/tabular.rs @@ -0,0 +1,76 @@ +use super::prelude::*; + +#[test] +fn tabular() { + assert_debug_snapshot!(parse(r###" + \begin{tabular}{|c|c|} + \hline + \textbf{Name} & \textbf{Age} \\ + \hline + John & 25 \\ + Jane & 22 \\ + \hline + \end{tabular} + "###), @r###" + root + |br'("\n") + |space'(" ") + |env + ||begin + |||sym'("tabular") + |||args + ||||curly + |||||lbrace'("{") + |||||text(word'("|c|c|")) + |||||rbrace'("}") + ||br'("\n") + ||space'(" ") + ||cmd(cmd-name("\\hline")) + ||br'("\n") + ||space'(" ") + ||cmd + |||cmd-name("\\textbf") + |||args + ||||curly + |||||lbrace'("{") + |||||text(word'("Name")) + |||||rbrace'("}") + ||space'(" ") + ||ampersand'("&") + ||space'(" ") + ||cmd + |||cmd-name("\\textbf") + |||args + ||||curly + |||||lbrace'("{") + |||||text(word'("Age")) + |||||rbrace'("}") + ||space'(" ") + ||newline("\\\\") + ||br'("\n") + ||space'(" ") + ||cmd(cmd-name("\\hline")) + ||br'("\n") + ||space'(" ") + ||text(word'("John"),space'(" ")) + ||ampersand'("&") + ||space'(" ") + ||text(word'("25"),space'(" ")) + ||newline("\\\\") + ||br'("\n") + ||space'(" ") + ||text(word'("Jane"),space'(" ")) + ||ampersand'("&") + ||space'(" ") + ||text(word'("22"),space'(" ")) + ||newline("\\\\") + ||br'("\n") + ||space'(" ") + ||cmd(cmd-name("\\hline")) + ||br'("\n") + ||space'(" ") + ||end(sym'("tabular")) + |br'("\n") + |space'(" ") + "###); +} diff --git a/crates/mitex-spec/src/lib.rs b/crates/mitex-spec/src/lib.rs index d461670..7c28872 100644 --- a/crates/mitex-spec/src/lib.rs +++ b/crates/mitex-spec/src/lib.rs @@ -278,7 +278,10 @@ pub enum ArgPattern { /// - {,b}: first, it matches a bracket option, e.g. `\sqrt[3]` /// - t: it then matches a single term, e.g. `\sqrt[3]{a}` or `\sqrt{a}` #[cfg_attr(feature = "serde", serde(rename = "glob"))] - Glob(GlobStr), + Glob { + /// The glob pattern to match the arguments + pattern: GlobStr, + }, } // struct ArgShape(ArgPattern, Direction); @@ -336,6 +339,12 @@ pub enum ContextFeature { /// Parse content like cases #[cfg_attr(feature = "serde", serde(rename = "is-cases"))] IsCases, + /// Parse content like figure + #[cfg_attr(feature = "serde", serde(rename = "is-figure"))] + IsFigure, + /// Parse content like table + #[cfg_attr(feature = "serde", serde(rename = "is-table"))] + IsTable, /// Parse content like itemize #[cfg_attr(feature = "serde", serde(rename = "is-itemize"))] IsItemize, diff --git a/crates/mitex-spec/src/preludes.rs b/crates/mitex-spec/src/preludes.rs index b149230..c1c923d 100644 --- a/crates/mitex-spec/src/preludes.rs +++ b/crates/mitex-spec/src/preludes.rs @@ -2,7 +2,7 @@ #![allow(missing_docs)] pub mod command { - use crate::{ArgShape, CommandSpecItem}; + use crate::{ArgShape, CommandSpecItem, ContextFeature}; pub fn define_command(len: u8) -> CommandSpecItem { CommandSpecItem::Cmd(crate::CmdShape { @@ -16,12 +16,24 @@ pub mod command { pub fn define_glob_command(reg: &str, alias: &str) -> CommandSpecItem { CommandSpecItem::Cmd(crate::CmdShape { args: crate::ArgShape::Right { - pattern: crate::ArgPattern::Glob(reg.into()), + pattern: crate::ArgPattern::Glob { + pattern: reg.into(), + }, }, alias: Some(alias.to_owned()), }) } + pub fn define_glob_env(reg: &str, alias: &str, ctx_feature: ContextFeature) -> CommandSpecItem { + CommandSpecItem::Env(crate::EnvShape { + args: crate::ArgPattern::Glob { + pattern: reg.into(), + }, + ctx_feature, + alias: Some(alias.to_owned()), + }) + } + pub fn define_symbol(alias: &str) -> CommandSpecItem { CommandSpecItem::Cmd(crate::CmdShape { args: crate::ArgShape::Right { diff --git a/crates/mitex-spec/src/query.rs b/crates/mitex-spec/src/query.rs index 131d88f..91cc9df 100644 --- a/crates/mitex-spec/src/query.rs +++ b/crates/mitex-spec/src/query.rs @@ -5,7 +5,7 @@ use std::{collections::HashMap, sync::Arc}; use serde::{Deserialize, Serialize}; -use crate::{CmdShape, EnvShape}; +use crate::{CmdShape, ContextFeature, EnvShape}; /// A package specification. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -57,6 +57,16 @@ pub enum CommandSpecItem { /// A command that takes no argument and is a normal environment. #[serde(rename = "normal-env")] EnvNormal, + /// A command that has a glob argument pattern and is an environment. + #[serde(rename = "glob-env")] + EnvGlob { + /// The glob pattern of the command. + pattern: String, + /// The aliasing typst handle of the command. + alias: String, + /// The context feature of the command. + ctx_feature: ContextFeature, + }, /// A command that is aliased to a Typst symbol. #[serde(rename = "alias-sym")] @@ -70,15 +80,15 @@ pub enum CommandSpecItem { /// The aliasing typst handle of the command. alias: String, }, - #[serde(rename = "infix-cmd")] /// A command that is an infix operator and is aliased to a Typst handler. + #[serde(rename = "infix-cmd")] CmdInfix { /// The aliasing typst handle of the command. alias: String, }, - #[serde(rename = "glob-cmd")] /// A command that has a glob argument pattern and is aliased to a Typst /// handler. + #[serde(rename = "glob-cmd")] CmdGlob { /// The glob pattern of the command. pattern: String, @@ -100,6 +110,11 @@ impl From for crate::CommandSpecItem { CommandSpecItem::CmdLeft1 => TEX_LEFT1_OPEARTOR, CommandSpecItem::EnvMatrix => TEX_MATRIX_ENV, CommandSpecItem::EnvNormal => TEX_NORMAL_ENV, + CommandSpecItem::EnvGlob { + pattern, + alias, + ctx_feature, + } => define_glob_env(&pattern, &alias, ctx_feature), CommandSpecItem::SymAlias { alias } => define_symbol(&alias), CommandSpecItem::CmdGreedy { alias } => define_greedy_command(&alias), CommandSpecItem::CmdInfix { alias } => crate::CommandSpecItem::Cmd(crate::CmdShape { diff --git a/crates/mitex/src/converter.rs b/crates/mitex/src/converter.rs index 216a35d..7418cb2 100644 --- a/crates/mitex/src/converter.rs +++ b/crates/mitex/src/converter.rs @@ -24,6 +24,8 @@ enum LaTeXEnv { #[default] // Text mode None, + Figure, + Table, Itemize, Enumerate, // Math mode @@ -269,105 +271,16 @@ impl Converter { "label" => { self.convert_command_label(f, &cmd)?; } + "includegraphics" => { + self.convert_command_includegraphics(f, &cmd)?; + } _ => { self.convert_normal_command(f, elem, spec)?; } } } ItemEnv => { - let env = EnvItem::cast(elem.as_node().unwrap().clone()).unwrap(); - let name = env - .name_tok() - .expect("environment name must be non-empty") - .text() - .to_string(); - let name = name.trim(); - let args = env.arguments(); - // todo: handle options - - let env_shape = spec - .get_env(name) - .ok_or_else(|| format!("unknown environment: \\{}", name))?; - let typst_name = env_shape.alias.as_deref().unwrap_or(name); - - let env_kind = match env_shape.ctx_feature { - ContextFeature::None => LaTeXEnv::None, - ContextFeature::IsMath => LaTeXEnv::Math, - ContextFeature::IsMatrix => LaTeXEnv::Matrix, - ContextFeature::IsCases => LaTeXEnv::Cases, - ContextFeature::IsItemize => LaTeXEnv::Itemize, - ContextFeature::IsEnumerate => LaTeXEnv::Enumerate, - }; - - // hack for itemize and enumerate - if matches!(env_kind, LaTeXEnv::Itemize | LaTeXEnv::Enumerate) { - let prev = self.enter_env(env_kind); - - for child in elem.as_node().unwrap().children_with_tokens() { - if matches!(child.kind(), ItemBegin | ItemEnd) { - continue; - } - - self.convert(f, child, spec)?; - } - - self.exit_env(prev); - - return Ok(()); - } - - // text mode to math mode with $ ... $ - let is_need_dollar = matches!(self.mode, LaTeXMode::Text) - && !matches!( - env_kind, - LaTeXEnv::None | LaTeXEnv::Itemize | LaTeXEnv::Enumerate - ); - let prev = self.enter_env(env_kind); - let mut prev_mode = LaTeXMode::Text; - if is_need_dollar { - f.write_str("$ ")?; - prev_mode = self.enter_mode(LaTeXMode::Math); - } - - // environment name - f.write_str(typst_name)?; - f.write_char('(')?; - // named args - for (index, arg) in args.enumerate() { - f.write_str(format!("arg{}: ", index).as_str())?; - self.convert(f, rowan::NodeOrToken::Node(arg), spec)?; - f.write_char(',')?; - } - - for child in elem.as_node().unwrap().children_with_tokens() { - if matches!(child.kind(), ItemBegin | ItemEnd) { - continue; - } - - self.convert(f, child, spec)?; - } - - f.write_char(')')?; - - self.exit_env(prev); - - if is_need_dollar { - f.write_str(" $")?; - self.exit_mode(prev_mode); - } - - // handle label - if matches!( - self.env, - LaTeXEnv::None | LaTeXEnv::Itemize | LaTeXEnv::Enumerate - ) { - if let Some(label) = self.label.take() { - f.write_char('<')?; - f.write_str(label.as_str())?; - f.write_char('>')?; - self.label = None; - } - } + self.convert_env(f, elem, spec)?; } ItemTypstCode => { write!(f, "{}", elem.as_node().unwrap().text())?; @@ -599,6 +512,80 @@ impl Converter { Ok(()) } + /// Convert command `\includegraphics[width=0.5\textwidth]{example-image}` + fn convert_command_includegraphics( + &mut self, + f: &mut fmt::Formatter<'_>, + cmd: &CmdItem, + ) -> Result<(), ConvertError> { + let opt_arg = cmd.arguments().find(|arg| { + matches!( + arg.first_child().unwrap().kind(), + LatexSyntaxKind::ItemBracket + ) + }); + let arg = cmd + .arguments() + .find(|arg| { + matches!( + arg.first_child().unwrap().kind(), + LatexSyntaxKind::ItemCurly + ) + }) + .expect("\\includegraphics command must have one argument"); + // remove { and } then trim + let body = arg.text().to_string(); + let body = &body[1..(body.len() - 1)]; + let body = body.trim(); + f.write_str("#image(")?; + // optional arguments + if let Some(opt_arg) = opt_arg { + let arg_text = opt_arg.text().to_string(); + let arg_text = &arg_text[1..(arg_text.len() - 1)]; + let arg_text = arg_text.trim(); + // example: \includegraphics[width=0.5\textwidth, height=3cm, + // angle=45]{example-image} split by comma and convert + // to key-value pairs + let args = arg_text.split(',').collect::>(); + let args = args + .iter() + .map(|arg| { + let arg = arg.trim(); + let arg = arg.split('=').collect::>(); + let key = arg[0].trim(); + let value = if arg.len() == 2 { arg[1].trim() } else { "" }; + (key, value) + }) + .collect::>(); + for (key, value) in args.iter() { + if matches!(key, &"width" | &"height") { + f.write_str(key)?; + f.write_char(':')?; + f.write_char(' ')?; + if value.ends_with("\\textwidth") { + let value = value.trim_end_matches("\\textwidth"); + f.write_str(value)?; + f.write_str(" * 100%")?; + } else if value.ends_with("\\textheight") { + let value = value.trim_end_matches("\\textheight"); + f.write_str(value)?; + f.write_str(" * 100%")?; + } else { + f.write_str(value)?; + } + f.write_char(',')?; + f.write_char(' ')?; + } + } + } + // image path + f.write_char('"')?; + f.write_str(body)?; + f.write_char('"')?; + f.write_char(')')?; + Ok(()) + } + /// Convert normal command fn convert_normal_command( &mut self, @@ -701,8 +688,8 @@ impl Converter { self.exit_mode(prev_mode); f.write_char(']')?; } - f.write_char(';')?; } + f.write_char(';')?; } // hack for \substack{abc \\ bcd} @@ -712,6 +699,368 @@ impl Converter { Ok(()) } + + /// Convert environments + fn convert_env( + &mut self, + f: &mut fmt::Formatter<'_>, + elem: LatexSyntaxElem, + spec: &CommandSpec, + ) -> Result<(), ConvertError> { + let env = EnvItem::cast(elem.as_node().unwrap().clone()).unwrap(); + let name = env + .name_tok() + .expect("environment name must be non-empty") + .text() + .to_string(); + let name = name.trim(); + let args = env.arguments(); + + let env_shape = spec + .get_env(name) + .ok_or_else(|| format!("unknown environment: \\{}", name))?; + let typst_name = env_shape.alias.as_deref().unwrap_or(name); + + let env_kind = match env_shape.ctx_feature { + ContextFeature::None => LaTeXEnv::None, + ContextFeature::IsMath => LaTeXEnv::Math, + ContextFeature::IsMatrix => LaTeXEnv::Matrix, + ContextFeature::IsCases => LaTeXEnv::Cases, + ContextFeature::IsFigure => LaTeXEnv::Figure, + ContextFeature::IsTable => LaTeXEnv::Table, + ContextFeature::IsItemize => LaTeXEnv::Itemize, + ContextFeature::IsEnumerate => LaTeXEnv::Enumerate, + }; + + // hack for itemize and enumerate + if matches!(env_kind, LaTeXEnv::Itemize | LaTeXEnv::Enumerate) { + let prev = self.enter_env(env_kind); + + for child in elem.as_node().unwrap().children_with_tokens() { + if matches!( + child.kind(), + LatexSyntaxKind::ItemBegin | LatexSyntaxKind::ItemEnd + ) { + continue; + } + + self.convert(f, child, spec)?; + } + + self.exit_env(prev); + + return Ok(()); + } + + // is environment for math + let is_math_env = matches!( + env_kind, + // math environments + LaTeXEnv::Math + | LaTeXEnv::Matrix + | LaTeXEnv::Cases + | LaTeXEnv::SubStack + | LaTeXEnv::MathCurlyGroup + ); + + // convert math environments into functions like `mat(a, b; c, d)` + if is_math_env { + // text mode to math mode with $ ... $ + let with_dollar = matches!(self.mode, LaTeXMode::Text) && is_math_env; + let prev = self.enter_env(env_kind); + let prev_mode = self.enter_mode(LaTeXMode::Math); + if with_dollar { + f.write_str("$ ")?; + } + + // environment name + f.write_str(typst_name)?; + + f.write_char('(')?; + // named args + for (index, arg) in args.enumerate() { + f.write_str(format!("arg{}: ", index).as_str())?; + self.convert(f, rowan::NodeOrToken::Node(arg), spec)?; + f.write_char(',')?; + } + + for child in elem.as_node().unwrap().children_with_tokens() { + // skip \begin and \end commands + if matches!( + child.kind(), + LatexSyntaxKind::ItemBegin | LatexSyntaxKind::ItemEnd + ) { + continue; + } + self.convert(f, child, spec)?; + } + + f.write_char(')')?; + + self.exit_env(prev); + + if with_dollar { + f.write_str(" $")?; + self.exit_mode(prev_mode); + } + } else { + // convert text environments + // 1. \begin{quote}xxx\end{quote} -> #quote(block: true)[xxx] + // \begin{abstract}xxx\end{abstract} -> #quote(block: true)[xxx] + // 2. \begin{figure}xxx\end{figure} -> #figure(image(), caption: []) + // \begin{table}xxx\end{table} -> #figure(table(), caption: []) + // 3. \begin{tabular}xxx\end{tabular} + // environment name + match env_kind { + LaTeXEnv::Figure => { + self.convert_env_figure(f, elem, spec, env_kind, typst_name)?; + } + LaTeXEnv::Table => { + self.convert_env_table(f, elem, spec, env_kind, typst_name)?; + } + _ => { + // normal environment + let prev = self.enter_env(env_kind); + f.write_char('#')?; + f.write_str(typst_name)?; + f.write_char('[')?; + for child in elem.as_node().unwrap().children_with_tokens() { + // skip \begin and \end commands + if matches!( + child.kind(), + LatexSyntaxKind::ItemBegin | LatexSyntaxKind::ItemEnd + ) { + continue; + } + self.convert(f, child, spec)?; + } + f.write_str("];")?; + self.exit_env(prev); + } + } + } + + // handle label, only add