use lazy_static::lazy_static;
use regex::Regex;
use std::fmt::Write;
lazy_static! {
static ref COORD_ABS: Regex = Regex::new(r"\((-?[\d.]+)\s*,\s*(-?[\d.]+)\)").unwrap();
static ref COORD_NAMED: Regex = Regex::new(r"\(([a-zA-Z][\w.]*)\)").unwrap();
static ref COORD_RELATIVE: Regex = Regex::new(r"\+\+\((-?[\d.]+)\s*,\s*(-?[\d.]+)\)").unwrap();
static ref COORD_POLAR: Regex = Regex::new(r"\((-?[\d.]+):(-?[\d.]+)([a-zA-Z]*)\)").unwrap();
}
#[derive(Debug, Clone)]
pub enum Coordinate {
Absolute(f64, f64),
Relative(f64, f64),
Polar(f64, f64),
Named(String),
Variable { x_expr: String, y_expr: String },
Calc(CalcExpr),
}
#[derive(Debug, Clone)]
pub enum CalcExpr {
Add {
base: Box<Coordinate>,
offset: Box<Coordinate>,
},
Sub {
base: Box<Coordinate>,
offset: Box<Coordinate>,
},
Lerp {
from: Box<Coordinate>,
to: Box<Coordinate>,
factor: f64,
},
Projection {
line_start: Box<Coordinate>,
point: Box<Coordinate>,
line_end: Box<Coordinate>,
},
Scale { coord: Box<Coordinate>, factor: f64 },
}
impl Coordinate {
pub fn parse(input: &str) -> Option<Self> {
let input = input.trim();
if input.starts_with("($") && input.ends_with("$)") {
if let Some(calc) = Self::parse_calc_expr(input) {
return Some(Coordinate::Calc(calc));
}
}
if input.starts_with("++") {
if let Some(caps) = COORD_RELATIVE.captures(input) {
let x: f64 = caps.get(1)?.as_str().parse().ok()?;
let y: f64 = caps.get(2)?.as_str().parse().ok()?;
return Some(Coordinate::Relative(x, y));
}
}
if let Some(caps) = COORD_POLAR.captures(input) {
if input.contains(':') {
let angle: f64 = caps.get(1)?.as_str().parse().ok()?;
let radius_num: f64 = caps.get(2)?.as_str().parse().ok()?;
let unit = caps.get(3).map(|m| m.as_str()).unwrap_or("");
let radius = convert_dimension_to_cm(radius_num, unit);
return Some(Coordinate::Polar(angle, radius));
}
}
if let Some(caps) = COORD_ABS.captures(input) {
let x: f64 = caps.get(1)?.as_str().parse().ok()?;
let y: f64 = caps.get(2)?.as_str().parse().ok()?;
return Some(Coordinate::Absolute(x, y));
}
if let Some(caps) = COORD_NAMED.captures(input) {
let name = caps.get(1)?.as_str().to_string();
return Some(Coordinate::Named(name));
}
if let Some(coord) = Self::parse_variable_coord(input) {
return Some(coord);
}
None
}
fn parse_calc_expr(input: &str) -> Option<CalcExpr> {
let inner = input.strip_prefix("($")?.strip_suffix("$)")?.trim();
if let Some(lerp) = Self::parse_calc_lerp(inner) {
return Some(lerp);
}
if let Some(pos) = inner.find('+') {
let left = inner[..pos].trim();
let right = inner[pos + 1..].trim();
if let (Some(base), Some(offset)) = (Self::parse(left), Self::parse(right)) {
return Some(CalcExpr::Add {
base: Box::new(base),
offset: Box::new(offset),
});
}
}
if let Some(pos) = inner.rfind('-') {
if pos > 0 {
let before_minus = inner[..pos].trim();
if before_minus.ends_with(')') {
let left = before_minus;
let right = inner[pos + 1..].trim();
if let (Some(base), Some(offset)) = (Self::parse(left), Self::parse(right)) {
return Some(CalcExpr::Sub {
base: Box::new(base),
offset: Box::new(offset),
});
}
}
}
}
if let Some(pos) = inner.find('*') {
let left = inner[..pos].trim();
let right = inner[pos + 1..].trim();
if let Ok(factor) = left.parse::<f64>() {
if let Some(coord) = Self::parse(right) {
return Some(CalcExpr::Scale {
coord: Box::new(coord),
factor,
});
}
}
}
None
}
fn parse_calc_lerp(inner: &str) -> Option<CalcExpr> {
let parts: Vec<&str> = inner.split('!').collect();
match parts.len() {
3 => {
let a_str = parts[0].trim();
let factor_str = parts[1].trim();
let b_str = parts[2].trim();
let a = Self::parse(a_str)?;
let b = Self::parse(b_str)?;
let factor = factor_str.parse::<f64>().ok()?;
Some(CalcExpr::Lerp {
from: Box::new(a),
to: Box::new(b),
factor,
})
}
4 if parts[1].trim().is_empty() => {
let a_str = parts[0].trim();
let b_str = parts[2].trim();
let c_str = parts[3].trim();
let a = Self::parse(a_str)?;
let b = Self::parse(b_str)?;
let c = Self::parse(c_str)?;
Some(CalcExpr::Projection {
line_start: Box::new(a),
point: Box::new(b),
line_end: Box::new(c),
})
}
_ => None,
}
}
fn parse_variable_coord(input: &str) -> Option<Self> {
let input = input.trim();
if !input.starts_with('(') || !input.ends_with(')') {
return None;
}
let inner = &input[1..input.len() - 1];
let parts: Vec<&str> = inner.splitn(2, ',').collect();
if parts.len() != 2 {
return None;
}
let x_expr = parts[0].trim();
let y_expr = parts[1].trim();
if x_expr.contains('\\') || y_expr.contains('\\') {
Some(Coordinate::Variable {
x_expr: Self::convert_tikz_expr_to_typst(x_expr),
y_expr: Self::convert_tikz_expr_to_typst(y_expr),
})
} else {
None
}
}
fn convert_tikz_expr_to_typst(expr: &str) -> String {
let mut result = String::new();
let mut chars = expr.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
let mut var_name = String::new();
while let Some(&next) = chars.peek() {
if next.is_alphanumeric() || next == '_' {
var_name.push(chars.next().unwrap());
} else {
break;
}
}
result.push_str(&var_name);
} else if c == '*' {
result.push_str(" * ");
} else if c == '+' {
result.push_str(" + ");
} else if c == '-' {
result.push_str(" - ");
} else if c == '/' {
result.push_str(" / ");
} else {
result.push(c);
}
}
result.trim().to_string()
}
pub fn to_cetz(&self) -> String {
match self {
Coordinate::Absolute(x, y) => format!("({}, {})", x, y),
Coordinate::Relative(dx, dy) => format!("(rel: ({}, {}))", dx, dy),
Coordinate::Polar(angle, radius) => {
let rad = angle.to_radians();
let x = radius * rad.cos();
let y = radius * rad.sin();
format!("({:.4}, {:.4})", x, y)
}
Coordinate::Named(name) => format!("\"{}\"", name),
Coordinate::Variable { x_expr, y_expr } => format!("({}, {})", x_expr, y_expr),
Coordinate::Calc(expr) => expr.to_cetz(),
}
}
}
impl CalcExpr {
pub fn to_cetz(&self) -> String {
match self {
CalcExpr::Add { base, offset } => {
format!("calc.add({}, {})", base.to_cetz(), offset.to_cetz())
}
CalcExpr::Sub { base, offset } => {
format!("calc.sub({}, {})", base.to_cetz(), offset.to_cetz())
}
CalcExpr::Lerp { from, to, factor } => {
format!(
"calc.lerp({}, {}, {})",
from.to_cetz(),
to.to_cetz(),
factor
)
}
CalcExpr::Scale { coord, factor } => {
format!("calc.scale({}, {})", coord.to_cetz(), factor)
}
CalcExpr::Projection {
line_start,
point,
line_end,
} => {
format!(
"/* projection of {} onto line from {} to {} */",
point.to_cetz(),
line_start.to_cetz(),
line_end.to_cetz()
)
}
}
}
}
#[derive(Debug, Clone)]
pub enum PathSegment {
MoveTo(Coordinate),
LineTo(Coordinate),
CurveTo {
control1: Option<Coordinate>,
control2: Option<Coordinate>,
end: Coordinate,
},
Arc {
start_angle: f64,
end_angle: f64,
radius: f64,
},
Circle { center: Coordinate, radius: f64 },
Rectangle {
corner1: Coordinate,
corner2: Coordinate,
},
Ellipse {
center: Coordinate,
x_radius: f64,
y_radius: f64,
},
Grid {
corner1: Coordinate,
corner2: Coordinate,
step: Option<f64>,
},
Node {
text: String,
anchor: Option<String>,
},
Bezier {
start: Coordinate,
controls: Vec<Coordinate>,
end: Coordinate,
},
ClosePath,
}
#[derive(Debug, Clone)]
enum PathToken {
Coord(Coordinate),
LineTo,
CurveTo,
Controls,
And,
HorizVert,
VertHoriz,
Node { options: String, text: String },
Circle { radius: f64 },
Rectangle,
Arc { start: f64, end: f64, radius: f64 },
Grid,
Cycle,
}
fn find_matching(s: &str, open: char, close: char) -> Option<usize> {
let mut depth = 0;
for (i, c) in s.char_indices() {
if c == open {
depth += 1;
} else if c == close {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
}
None
}
fn find_calc_end(s: &str) -> Option<usize> {
if !s.starts_with("($") {
return None;
}
let mut depth = 1; let chars: Vec<char> = s.chars().collect();
let mut i = 2;
while i < chars.len() {
match chars[i] {
'(' => depth += 1,
')' => {
if depth == 1 && i > 0 && chars[i - 1] == '$' {
return Some(i - 1);
}
depth -= 1;
if depth == 0 {
return None;
}
}
_ => {}
}
i += 1;
}
None
}
fn tokenize_path(input: &str) -> Vec<PathToken> {
let mut tokens = Vec::new();
let mut rest = input.trim();
while !rest.is_empty() {
rest = rest.trim_start();
if rest.is_empty() {
break;
}
if rest.starts_with('%') {
if let Some(nl) = rest.find('\n') {
rest = &rest[nl + 1..];
} else {
break;
}
continue;
}
if rest.starts_with("--") {
tokens.push(PathToken::LineTo);
rest = &rest[2..];
continue;
}
if rest.starts_with("..") {
tokens.push(PathToken::CurveTo);
rest = &rest[2..];
continue;
}
if rest.starts_with("-|") {
tokens.push(PathToken::HorizVert);
rest = &rest[2..];
continue;
}
if rest.starts_with("|-") {
tokens.push(PathToken::VertHoriz);
rest = &rest[2..];
continue;
}
if rest.starts_with("cycle") {
tokens.push(PathToken::Cycle);
rest = &rest[5..];
continue;
}
if rest.starts_with("controls") {
tokens.push(PathToken::Controls);
rest = &rest[8..];
continue;
}
if rest.starts_with("and") && !rest[3..].starts_with(|c: char| c.is_alphanumeric()) {
tokens.push(PathToken::And);
rest = &rest[3..];
continue;
}
if rest.starts_with("node") {
rest = rest[4..].trim_start();
let mut options = String::new();
let mut text = String::new();
if rest.starts_with('[') {
if let Some(end) = find_matching(rest, '[', ']') {
options = rest[1..end].to_string();
rest = rest[end + 1..].trim_start();
}
}
if rest.starts_with('(') {
if let Some(end) = find_matching(rest, '(', ')') {
rest = rest[end + 1..].trim_start();
}
}
if rest.starts_with('{') {
if let Some(end) = find_matching(rest, '{', '}') {
text = rest[1..end].to_string();
rest = &rest[end + 1..];
}
}
tokens.push(PathToken::Node { options, text });
continue;
}
if rest.starts_with("circle") {
rest = rest[6..].trim_start();
let mut radius = 1.0;
if rest.starts_with('(') {
if let Some(end) = find_matching(rest, '(', ')') {
let r_str = rest[1..end].trim();
radius = parse_dimension_to_cm(r_str).unwrap_or(1.0);
rest = &rest[end + 1..];
}
} else if rest.starts_with('[') {
if let Some(end) = find_matching(rest, '[', ']') {
let opts = &rest[1..end];
if let Some(r_pos) = opts.find("radius=") {
let after = &opts[r_pos + 7..];
let end_pos = after
.find(|c: char| c == ',' || c == ']' || c.is_whitespace())
.unwrap_or(after.len());
let r_str = after[..end_pos].trim();
radius = parse_dimension_to_cm(r_str).unwrap_or(1.0);
}
rest = &rest[end + 1..];
}
}
tokens.push(PathToken::Circle { radius });
continue;
}
if rest.starts_with("rectangle") {
tokens.push(PathToken::Rectangle);
rest = &rest[9..];
continue;
}
if rest.starts_with("arc") {
rest = rest[3..].trim_start();
let mut start = 0.0;
let mut end = 90.0;
let mut radius = 1.0;
if rest.starts_with('(') {
if let Some(end_idx) = find_matching(rest, '(', ')') {
let content = &rest[1..end_idx];
let parts: Vec<&str> = content.split(':').collect();
if parts.len() >= 2 {
start = parts[0].trim().parse().unwrap_or(0.0);
end = parts[1].trim().parse().unwrap_or(90.0);
if parts.len() >= 3 {
radius = parse_dimension_to_cm(parts[2].trim()).unwrap_or(1.0);
}
}
rest = &rest[end_idx + 1..];
}
}
tokens.push(PathToken::Arc { start, end, radius });
continue;
}
if rest.starts_with("grid") {
tokens.push(PathToken::Grid);
rest = &rest[4..];
continue;
}
if rest.starts_with("($") {
if let Some(end) = find_calc_end(rest) {
let coord_str = &rest[..end + 2]; if let Some(coord) = Coordinate::parse(coord_str) {
tokens.push(PathToken::Coord(coord));
}
rest = &rest[end + 2..];
continue;
}
}
if rest.starts_with("++") || rest.starts_with('+') || rest.starts_with('(') {
let offset = if rest.starts_with("++") {
2
} else if rest.starts_with('+') && rest.chars().nth(1) == Some('(') {
1
} else {
0
};
let coord_start = if offset > 0 && !rest[offset..].starts_with('(') {
rest = &rest[1..];
continue;
} else if offset > 0 {
offset
} else {
0
};
if rest[coord_start..].starts_with('(') {
if let Some(end) = find_matching(&rest[coord_start..], '(', ')') {
let coord_str = &rest[..coord_start + end + 1];
if let Some(coord) = Coordinate::parse(coord_str) {
tokens.push(PathToken::Coord(coord));
}
rest = &rest[coord_start + end + 1..];
continue;
}
}
}
let mut chars = rest.chars();
chars.next();
rest = chars.as_str();
}
tokens
}
#[derive(Debug, Clone)]
pub struct TikZNode {
pub name: Option<String>,
pub position: Option<Coordinate>,
pub text: String,
pub options: DrawOptions,
}
#[derive(Debug, Clone, Default)]
pub struct DrawOptions {
pub color: Option<String>,
pub fill_color: Option<String>,
pub line_width: Option<String>,
pub dashed: bool,
pub dotted: bool,
pub arrow_start: bool,
pub arrow_end: bool,
pub rounded_corners: bool,
pub opacity: Option<f64>,
pub anchor: Option<String>,
pub font_size: Option<String>,
pub raw_options: Option<String>,
pub is_draw: bool,
pub is_fill: bool,
pub is_clip: bool,
pub relative_pos: Option<RelativePosition>,
}
#[derive(Debug, Clone)]
pub struct RelativePosition {
pub direction: String,
pub distance: Option<String>,
pub of_node: String,
}
impl DrawOptions {
pub fn parse(input: &str) -> Self {
let mut opts = DrawOptions::default();
let cleaned_input = input.trim_start_matches('[').trim_end_matches(']');
opts.raw_options = Some(cleaned_input.to_string());
for part in cleaned_input.split(',') {
let part = part.trim();
match part {
"ultra thin" => opts.line_width = Some("0.1pt".to_string()),
"very thin" => opts.line_width = Some("0.2pt".to_string()),
"thin" => opts.line_width = Some("0.4pt".to_string()),
"thick" => opts.line_width = Some("0.8pt".to_string()),
"very thick" => opts.line_width = Some("1.2pt".to_string()),
"ultra thick" => opts.line_width = Some("1.6pt".to_string()),
_ => {}
}
if part == "dashed" {
opts.dashed = true;
} else if part == "dotted" {
opts.dotted = true;
}
if part.contains("->") || part.ends_with('>') {
opts.arrow_end = true;
}
if part.contains("<-") || part.starts_with('<') {
opts.arrow_start = true;
}
if part == "<->" {
opts.arrow_start = true;
opts.arrow_end = true;
}
if part == "rounded corners" {
opts.rounded_corners = true;
}
if part.starts_with("draw=") {
opts.color = Some(part.trim_start_matches("draw=").to_string());
} else if part.starts_with("fill=") {
opts.fill_color = Some(part.trim_start_matches("fill=").to_string());
} else if part.starts_with("color=") {
opts.color = Some(part.trim_start_matches("color=").to_string());
} else if is_color_name(part) {
opts.color = Some(part.to_string());
}
if part.starts_with("anchor=") {
opts.anchor = Some(part.trim_start_matches("anchor=").to_string());
}
if opts.anchor.is_none() {
let anchor = match part {
"above right" => Some("south-west"),
"above left" => Some("south-east"),
"below right" => Some("north-west"),
"below left" => Some("north-east"),
"above" => Some("south"),
"below" => Some("north"),
"right" => Some("west"),
"left" => Some("east"),
_ => None,
};
if let Some(a) = anchor {
opts.anchor = Some(a.to_string());
}
}
if part.starts_with("opacity=") {
if let Ok(v) = part.trim_start_matches("opacity=").parse() {
opts.opacity = Some(v);
}
}
if let Some(rel_pos) = parse_relative_position(part) {
opts.relative_pos = Some(rel_pos);
}
}
opts
}
pub fn to_cetz_style(&self) -> String {
let mut parts = Vec::new();
let mut stroke_parts = Vec::new();
if let Some(ref color) = self.color {
stroke_parts.push(format!("paint: {}", convert_color(color)));
}
if let Some(ref width) = self.line_width {
stroke_parts.push(format!("thickness: {}pt", width.trim_end_matches("pt")));
}
if self.dashed {
stroke_parts.push("dash: \"dashed\"".to_string());
} else if self.dotted {
stroke_parts.push("dash: \"dotted\"".to_string());
}
if !stroke_parts.is_empty() {
let is_simple = stroke_parts.len() == 1
&& self.line_width.is_none()
&& !self.dashed
&& !self.dotted;
if is_simple {
if let Some(ref color) = self.color {
parts.push(format!("stroke: {}", convert_color(color)));
} else {
parts.push(format!("stroke: ({})", stroke_parts.join(", ")));
}
} else {
parts.push(format!("stroke: ({})", stroke_parts.join(", ")));
}
}
if let Some(ref fill) = self.fill_color {
parts.push(format!("fill: {}", convert_color(fill)));
}
if self.arrow_start || self.arrow_end {
let mark_str = match (self.arrow_start, self.arrow_end) {
(true, true) => "mark: (start: \">\", end: \">\")".to_string(),
(true, false) => "mark: (start: \">\")".to_string(),
(false, true) => "mark: (end: \">\")".to_string(),
_ => String::new(),
};
if !mark_str.is_empty() {
parts.push(mark_str);
}
}
if parts.is_empty() {
String::new()
} else {
parts.join(", ")
}
}
}
fn parse_relative_position(input: &str) -> Option<RelativePosition> {
let directions = [
"above right",
"above left",
"below right",
"below left",
"above",
"below",
"left",
"right",
];
for dir in &directions {
if let Some(stripped) = input.strip_prefix(dir) {
let rest = stripped.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let rest = rest.trim();
if let Some(of_pos) = rest.find("of ") {
let before_of = rest[..of_pos].trim();
let node_name = rest[of_pos + 3..].trim().to_string();
let distance = if before_of.is_empty() {
None
} else {
Some(before_of.to_string())
};
return Some(RelativePosition {
direction: dir.to_string(),
distance,
of_node: node_name,
});
} else if let Some(of_rest) = rest.strip_prefix("of ") {
let node_name = of_rest.trim().to_string();
return Some(RelativePosition {
direction: dir.to_string(),
distance: None,
of_node: node_name,
});
}
}
}
}
None
}
fn is_color_name(s: &str) -> bool {
matches!(
s,
"red"
| "green"
| "blue"
| "yellow"
| "cyan"
| "magenta"
| "black"
| "white"
| "gray"
| "grey"
| "orange"
| "purple"
| "brown"
| "pink"
| "lime"
| "olive"
| "teal"
| "violet"
| "darkgray"
| "lightgray"
| "darkblue"
| "darkred"
| "darkgreen"
)
}
fn convert_color(color: &str) -> String {
if color.contains('!') {
let parts: Vec<&str> = color.split('!').collect();
if parts.len() == 3 {
let color1 = parts[0].trim();
let color2 = parts[2].trim();
if let Ok(pct1) = parts[1].trim().parse::<f64>() {
let pct2 = 100.0 - pct1;
return format!(
"color.mix(({}, {:.0}%), ({}, {:.0}%))",
normalize_color_name(color1),
pct1,
normalize_color_name(color2),
pct2
);
}
}
else if parts.len() == 2 {
let base_color = parts[0].trim();
if let Ok(percentage) = parts[1].trim().parse::<f64>() {
let lighten_pct = 100.0 - percentage;
return format!(
"{}.lighten({:.0}%)",
normalize_color_name(base_color),
lighten_pct
);
}
}
else if parts.len() > 3 {
let color1 = parts[0].trim();
if let Ok(pct1) = parts[1].trim().parse::<f64>() {
let remaining = parts[2..].join("!");
let mixed_rest = convert_color(&remaining);
let pct2 = 100.0 - pct1;
return format!(
"color.mix(({}, {:.0}%), ({}, {:.0}%))",
normalize_color_name(color1),
pct1,
mixed_rest,
pct2
);
}
}
}
normalize_color_name(color)
}
fn normalize_color_name(color: &str) -> String {
match color.trim() {
"gray" | "grey" => "gray".to_string(),
"darkgray" | "darkgrey" => "luma(64)".to_string(),
"lightgray" | "lightgrey" => "luma(192)".to_string(),
c if c.starts_with('#') => format!("rgb(\"{}\")", c),
c => c.to_string(),
}
}
#[derive(Debug, Clone)]
pub enum TikZCommand {
Path {
options: DrawOptions,
segments: Vec<PathSegment>,
},
Node(TikZNode),
Coordinate { name: String, position: Coordinate },
Foreach {
variable: String,
values: Vec<String>,
body: Vec<TikZCommand>,
},
}
fn parse_path(input: &str) -> Vec<PathSegment> {
let tokens = tokenize_path(input);
let mut segments = Vec::new();
let mut iter = tokens.into_iter().peekable();
let mut last_coord: Option<Coordinate> = None;
let mut expect_line_to = false;
while let Some(token) = iter.next() {
match token {
PathToken::Coord(coord) => {
if expect_line_to {
segments.push(PathSegment::LineTo(coord.clone()));
} else {
segments.push(PathSegment::MoveTo(coord.clone()));
}
last_coord = Some(coord);
expect_line_to = false;
}
PathToken::LineTo | PathToken::HorizVert | PathToken::VertHoriz => {
expect_line_to = true;
}
PathToken::CurveTo => {
if let Some(PathToken::Controls) = iter.peek() {
iter.next();
let mut control_points: Vec<Coordinate> = Vec::new();
if let Some(PathToken::Coord(c1)) = iter.next() {
control_points.push(c1);
}
if let Some(PathToken::And) = iter.peek() {
iter.next(); if let Some(PathToken::Coord(c2)) = iter.next() {
control_points.push(c2);
}
}
if let Some(PathToken::CurveTo) = iter.peek() {
iter.next();
}
if let Some(PathToken::Coord(end)) = iter.next() {
let start = last_coord.clone().unwrap_or(Coordinate::Absolute(0.0, 0.0));
segments.push(PathSegment::Bezier {
start,
controls: control_points,
end: end.clone(),
});
last_coord = Some(end);
}
} else {
expect_line_to = true;
}
}
PathToken::Controls | PathToken::And => {
}
PathToken::Node { options, text } => {
let anchor = if options.contains("right") {
Some("right".to_string())
} else if options.contains("left") {
Some("left".to_string())
} else if options.contains("above") {
Some("above".to_string())
} else if options.contains("below") {
Some("below".to_string())
} else {
None
};
segments.push(PathSegment::Node { text, anchor });
}
PathToken::Circle { radius } => {
let center = last_coord.clone().unwrap_or(Coordinate::Absolute(0.0, 0.0));
segments.push(PathSegment::Circle { center, radius });
}
PathToken::Rectangle => {
let corner1 = last_coord.clone().unwrap_or(Coordinate::Absolute(0.0, 0.0));
let next_coord = match iter.peek() {
Some(PathToken::Coord(c)) => Some(c.clone()),
_ => None,
};
if let Some(corner2) = next_coord {
iter.next(); segments.push(PathSegment::Rectangle {
corner1,
corner2: corner2.clone(),
});
last_coord = Some(corner2);
}
}
PathToken::Arc { start, end, radius } => {
segments.push(PathSegment::Arc {
start_angle: start,
end_angle: end,
radius,
});
}
PathToken::Grid => {
let corner1 = last_coord.clone().unwrap_or(Coordinate::Absolute(0.0, 0.0));
let next_coord = match iter.peek() {
Some(PathToken::Coord(c)) => Some(c.clone()),
_ => None,
};
if let Some(corner2) = next_coord {
iter.next(); segments.push(PathSegment::Grid {
corner1,
corner2: corner2.clone(),
step: None,
});
last_coord = Some(corner2);
}
}
PathToken::Cycle => {
segments.push(PathSegment::ClosePath);
}
}
}
segments
}
fn parse_node(input: &str) -> Option<TikZNode> {
let input = input.trim().trim_end_matches(';');
let mut options = DrawOptions::default();
let mut name = None;
let mut position = None;
let mut text = String::new();
if let Some(opt_start) = input.find('[') {
if let Some(opt_end) = input[opt_start..].find(']') {
let opt_str = &input[opt_start..opt_start + opt_end + 1];
options = DrawOptions::parse(opt_str);
}
}
let name_pattern = Regex::new(r"\(([a-zA-Z][\w]*)\)").ok()?;
if let Some(caps) = name_pattern.captures(input) {
name = Some(caps.get(1)?.as_str().to_string());
}
if let Some(at_pos) = input.find(" at ") {
let after_at = &input[at_pos + 4..].trim();
if after_at.starts_with("($") {
if let Some(end) = find_calc_end(after_at) {
let coord_str = &after_at[..end + 2];
if let Some(coord) = Coordinate::parse(coord_str) {
position = Some(coord);
}
}
} else if after_at.starts_with('(') {
if let Some(end) = find_matching(after_at, '(', ')') {
let coord_str = &after_at[..end + 1];
if let Some(coord) = Coordinate::parse(coord_str) {
position = Some(coord);
}
}
}
}
if let Some(text_start) = input.find('{') {
if let Some(text_end) = input[text_start..].rfind('}') {
text = input[text_start + 1..text_start + text_end].to_string();
}
}
Some(TikZNode {
name,
position,
text,
options,
})
}
pub fn parse_tikz_picture(input: &str) -> Vec<TikZCommand> {
let content = input
.trim()
.trim_start_matches(r"\begin{tikzpicture}")
.trim_end_matches(r"\end{tikzpicture}")
.trim();
let content = if content.starts_with('[') {
content
.find(']')
.map(|i| &content[i + 1..])
.unwrap_or(content)
} else {
content
};
let raw_commands = split_tikz_commands(content);
parse_tikz_commands(&raw_commands)
}
fn split_tikz_commands(input: &str) -> Vec<String> {
let mut commands = Vec::new();
let mut current = String::new();
let mut brace_depth: i32 = 0;
let mut in_comment = false;
let chars: Vec<char> = input.chars().collect();
let mut i = 0;
let mut just_closed_block = false;
while i < chars.len() {
let c = chars[i];
if c == '%' && !in_comment {
in_comment = true;
i += 1;
continue;
}
if in_comment {
if c == '\n' {
in_comment = false;
}
i += 1;
continue;
}
match c {
'{' => {
brace_depth += 1;
current.push(c);
just_closed_block = false;
}
'}' => {
brace_depth = brace_depth.saturating_sub(1);
current.push(c);
if brace_depth == 0 && is_block_command(current.trim()) {
just_closed_block = true;
}
}
';' if brace_depth == 0 => {
let cmd = current.trim().to_string();
if !cmd.is_empty() {
commands.push(cmd);
}
current.clear();
just_closed_block = false;
}
'\\' if brace_depth == 0 && just_closed_block => {
let cmd = current.trim().to_string();
if !cmd.is_empty() {
commands.push(cmd);
}
current.clear();
current.push(c);
just_closed_block = false;
}
_ if c.is_whitespace() => {
current.push(c);
}
_ => {
current.push(c);
just_closed_block = false;
}
}
i += 1;
}
let cmd = current.trim().to_string();
if !cmd.is_empty() {
commands.push(cmd);
}
commands
}
fn is_block_command(cmd: &str) -> bool {
cmd.starts_with(r"\foreach")
|| cmd.starts_with(r"\scope")
|| cmd.starts_with(r"\begin{scope}")
|| cmd.starts_with(r"\pgfonlayer")
}
fn parse_tikz_commands(raw_commands: &[String]) -> Vec<TikZCommand> {
let mut commands = Vec::new();
for line in raw_commands {
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with(r"\foreach") {
if let Some(foreach_cmd) = parse_foreach(line) {
commands.push(foreach_cmd);
continue;
}
}
if let Some(cmd) = parse_path_command(line) {
commands.push(cmd);
} else if line.starts_with(r"\node") {
if let Some(node) = parse_node(line) {
commands.push(TikZCommand::Node(node));
}
} else if line.starts_with(r"\coordinate") {
let after_cmd = line.strip_prefix(r"\coordinate").unwrap_or(line);
let (_, rest) = parse_command_with_options(after_cmd);
if let Some(caps) = COORD_NAMED.captures(rest) {
if let Some(name) = caps.get(1) {
let name = name.as_str().to_string();
if let Some(at_pos) = rest.find(" at ") {
if let Some(coord) = Coordinate::parse(&rest[at_pos + 4..]) {
commands.push(TikZCommand::Coordinate {
name,
position: coord,
});
}
}
}
}
}
}
commands
}
fn parse_foreach(input: &str) -> Option<TikZCommand> {
let content = input.strip_prefix(r"\foreach")?.trim();
let mut chars = content.chars().peekable();
while chars.peek().map(|c| c.is_whitespace()).unwrap_or(false) {
chars.next();
}
if chars.next() != Some('\\') {
return None;
}
let mut variable = String::new();
while let Some(&c) = chars.peek() {
if c.is_alphanumeric() || c == '_' {
variable.push(chars.next().unwrap());
} else {
break;
}
}
if variable.is_empty() {
return None;
}
let remaining: String = chars.collect();
let remaining = remaining.trim();
if !remaining.starts_with("in") {
return None;
}
let remaining = remaining[2..].trim();
let values_start = remaining.find('{')?;
let mut brace_depth = 0;
let mut values_end = values_start;
for (i, c) in remaining[values_start..].char_indices() {
match c {
'{' => brace_depth += 1,
'}' => {
brace_depth -= 1;
if brace_depth == 0 {
values_end = values_start + i;
break;
}
}
_ => {}
}
}
let values_str = &remaining[values_start + 1..values_end];
let values: Vec<String> = parse_foreach_values(values_str);
let body_start_search = &remaining[values_end + 1..].trim();
let body_start = body_start_search.find('{')?;
let body_content = &body_start_search[body_start..];
let mut brace_depth = 0;
let mut body_end = 0;
for (i, c) in body_content.char_indices() {
match c {
'{' => brace_depth += 1,
'}' => {
brace_depth -= 1;
if brace_depth == 0 {
body_end = i;
break;
}
}
_ => {}
}
}
let body_str = &body_content[1..body_end];
let body_raw = split_tikz_commands(body_str);
let body = parse_tikz_commands(&body_raw);
Some(TikZCommand::Foreach {
variable,
values,
body,
})
}
fn parse_foreach_values(input: &str) -> Vec<String> {
let input = input.trim();
if input.contains("...") {
let parts: Vec<&str> = input.split(',').map(|s| s.trim()).collect();
if parts.len() >= 3 && parts.contains(&"...") {
let ellipsis_pos = parts.iter().position(|p| *p == "...").unwrap();
if ellipsis_pos >= 1 && ellipsis_pos < parts.len() - 1 {
let start: f64 = parts[0].parse().unwrap_or(0.0);
let end: f64 = parts[ellipsis_pos + 1].parse().unwrap_or(start);
let step = if ellipsis_pos >= 2 {
let second: f64 = parts[1].parse().unwrap_or(start + 1.0);
second - start
} else {
1.0
};
let mut values = Vec::new();
let mut current = start;
let max_iterations = 1000; let mut iterations = 0;
while (step > 0.0 && current <= end + f64::EPSILON)
|| (step < 0.0 && current >= end - f64::EPSILON)
{
if current.fract().abs() < 1e-9 {
values.push(format!("{}", current as i64));
} else {
values.push(
format!("{:.2}", current)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string(),
);
}
current += step;
iterations += 1;
if iterations > max_iterations {
break;
}
}
return values;
}
}
}
input
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn parse_path_command(line: &str) -> Option<TikZCommand> {
let (after_cmd, default_draw, default_fill, default_clip) = if line.starts_with(r"\filldraw") {
(line.strip_prefix(r"\filldraw")?, true, true, false)
} else if line.starts_with(r"\fill") {
(line.strip_prefix(r"\fill")?, false, true, false)
} else if line.starts_with(r"\draw") {
(line.strip_prefix(r"\draw")?, true, false, false)
} else if line.starts_with(r"\clip") {
(line.strip_prefix(r"\clip")?, false, false, true)
} else if line.starts_with(r"\path") {
(line.strip_prefix(r"\path")?, false, false, false)
} else {
return None;
};
let (mut opts, path) = parse_command_with_options(after_cmd);
if opts
.raw_options
.as_ref()
.map(|s| s.contains("draw"))
.unwrap_or(false)
{
opts.is_draw = true;
} else {
opts.is_draw = default_draw;
}
if opts
.raw_options
.as_ref()
.map(|s| s.contains("fill"))
.unwrap_or(false)
{
opts.is_fill = true;
} else {
opts.is_fill = default_fill;
}
if opts
.raw_options
.as_ref()
.map(|s| s.contains("clip"))
.unwrap_or(false)
{
opts.is_clip = true;
} else {
opts.is_clip = default_clip;
}
if opts.is_fill && !opts.is_draw && opts.fill_color.is_none() && opts.color.is_some() {
opts.fill_color = opts.color.take();
}
let segments = parse_path(path);
Some(TikZCommand::Path {
options: opts,
segments,
})
}
fn parse_command_with_options(input: &str) -> (DrawOptions, &str) {
let input = input.trim();
if input.starts_with('[') {
if let Some(end) = find_matching_bracket(input) {
let opts = DrawOptions::parse(&input[..end + 1]);
let rest = input[end + 1..].trim();
return (opts, rest);
}
}
(DrawOptions::default(), input)
}
fn find_matching_bracket(input: &str) -> Option<usize> {
let mut depth = 0;
for (i, c) in input.char_indices() {
match c {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
pub fn convert_tikz_to_cetz(input: &str) -> String {
let commands = parse_tikz_picture(input);
let mut output = String::new();
output.push_str("#import \"@preview/cetz:0.3.4\": canvas, draw\n\n");
output.push_str("#canvas({\n");
output.push_str(" import draw: *\n\n");
for cmd in commands {
convert_command_to_cetz(&mut output, &cmd, 1);
}
output.push_str("})\n");
output
}
fn convert_command_to_cetz(output: &mut String, cmd: &TikZCommand, indent_level: usize) {
let indent = " ".repeat(indent_level);
match cmd {
TikZCommand::Path { options, segments } => {
if options.is_clip {
output.push_str(&format!("{}// Clip region (partial support)\n", indent));
}
convert_path_command(output, options, segments, indent_level);
}
TikZCommand::Node(node) => {
convert_node_command_with_indent(output, node, indent_level);
}
TikZCommand::Coordinate { name, position } => {
let _ = writeln!(
output,
"{}// Coordinate: {} at {}",
indent,
name,
position.to_cetz()
);
}
TikZCommand::Foreach {
variable,
values,
body,
} => {
let values_str = values.join(", ");
let _ = writeln!(output, "{}for {} in ({}) {{", indent, variable, values_str);
for body_cmd in body {
convert_command_to_cetz(output, body_cmd, indent_level + 1);
}
let _ = writeln!(output, "{}}}", indent);
}
}
}
fn convert_path_command(
output: &mut String,
options: &DrawOptions,
segments: &[PathSegment],
indent_level: usize,
) {
convert_draw_command_impl(output, options, segments, indent_level);
}
fn map_tikz_position_to_cetz_anchor(tikz_pos: &str) -> &'static str {
match tikz_pos {
"right" => "west",
"left" => "east",
"above" => "south",
"below" => "north",
"above right" => "south-west",
"above left" => "south-east",
"below right" => "north-west",
"below left" => "north-east",
"north" => "north",
"south" => "south",
"east" => "east",
"west" => "west",
"north-east" => "north-east",
"north-west" => "north-west",
"south-east" => "south-east",
"south-west" => "south-west",
"center" => "center",
_ => "center",
}
}
fn convert_node_command_with_indent(output: &mut String, node: &TikZNode, indent_level: usize) {
let indent = " ".repeat(indent_level);
let pos = if let Some(ref rel_pos) = node.options.relative_pos {
convert_relative_position_to_cetz(rel_pos)
} else {
node.position
.as_ref()
.map(|c| c.to_cetz())
.unwrap_or_else(|| "(0, 0)".to_string())
};
let text = node.text.replace("#", "\\#");
let mut opts = Vec::new();
if let Some(ref anchor) = node.options.anchor {
let cetz_anchor = map_tikz_position_to_cetz_anchor(anchor);
opts.push(format!("anchor: \"{}\"", cetz_anchor));
}
if let Some(ref name) = node.name {
opts.push(format!("name: \"{}\"", name));
}
if opts.is_empty() {
let _ = writeln!(output, "{}content({}, [{}])", indent, pos, text);
} else {
let _ = writeln!(
output,
"{}content({}, [{}], {})",
indent,
pos,
text,
opts.join(", ")
);
}
}
fn convert_relative_position_to_cetz(rel_pos: &RelativePosition) -> String {
let (dx, dy) = match rel_pos.direction.as_str() {
"above" => (0.0, 1.0),
"below" => (0.0, -1.0),
"left" => (-1.0, 0.0),
"right" => (1.0, 0.0),
"above right" => (1.0, 1.0),
"above left" => (-1.0, 1.0),
"below right" => (1.0, -1.0),
"below left" => (-1.0, -1.0),
_ => (0.0, 0.0),
};
let distance = rel_pos
.distance
.as_ref()
.map(|d| {
let num_str = d.trim_end_matches(|c: char| c.is_alphabetic());
let unit = d.trim_start_matches(|c: char| c.is_numeric() || c == '.' || c == '-');
let num: f64 = num_str.parse().unwrap_or(1.0);
match unit {
"cm" => num,
"mm" => num / 10.0,
"pt" => num / 28.35, "in" => num * 2.54,
_ => num,
}
})
.unwrap_or(1.0);
let offset_x = dx * distance;
let offset_y = dy * distance;
format!(
"calc.add(\"{}\", ({:.2}, {:.2}))",
rel_pos.of_node, offset_x, offset_y
)
}
fn convert_draw_command_impl(
output: &mut String,
options: &DrawOptions,
segments: &[PathSegment],
indent_level: usize,
) {
let indent = " ".repeat(indent_level);
let mut current_pos = Coordinate::Absolute(0.0, 0.0);
let mut polyline: Vec<Coordinate> = Vec::new();
let mut is_closed = false;
let flush_polyline = |output: &mut String,
polyline: &mut Vec<Coordinate>,
options: &DrawOptions,
closed: bool,
indent: &str| {
if polyline.len() >= 2 {
let style = options.to_cetz_style();
let style_str = if style.is_empty() {
String::new()
} else {
format!(", {}", style)
};
let coords_str: Vec<_> = polyline.iter().map(|c| c.to_cetz()).collect();
if closed {
let _ = writeln!(
output,
"{}line({}{}, close: true)",
indent,
coords_str.join(", "),
style_str
);
} else {
let _ = writeln!(
output,
"{}line({}{})",
indent,
coords_str.join(", "),
style_str
);
}
}
polyline.clear();
};
for segment in segments {
match segment {
PathSegment::MoveTo(coord) => {
flush_polyline(output, &mut polyline, options, false, &indent);
current_pos = coord.clone();
polyline.push(coord.clone());
}
PathSegment::LineTo(coord) => {
if polyline.is_empty() {
polyline.push(current_pos.clone());
}
polyline.push(coord.clone());
current_pos = coord.clone();
}
PathSegment::Node { text, anchor } => {
let pos = current_pos.to_cetz();
let escaped_text = text.replace('#', "\\#");
if let Some(ref anch) = anchor {
let cetz_anchor = map_tikz_position_to_cetz_anchor(anch);
let _ = writeln!(
output,
"{}content({}, [{}], anchor: \"{}\")",
indent, pos, escaped_text, cetz_anchor
);
} else {
let _ = writeln!(output, "{}content({}, [{}])", indent, pos, escaped_text);
}
}
PathSegment::Circle { center, radius } => {
flush_polyline(output, &mut polyline, options, false, &indent);
let style = options.to_cetz_style();
let mut style_parts = Vec::new();
if !style.is_empty() {
style_parts.push(style);
}
if options.is_fill && options.fill_color.is_none() {
style_parts.push("fill: black".to_string());
}
let style_str = if style_parts.is_empty() {
String::new()
} else {
format!(", {}", style_parts.join(", "))
};
let _ = writeln!(
output,
"{}circle({}, radius: {}{})",
indent,
center.to_cetz(),
radius,
style_str
);
current_pos = center.clone();
}
PathSegment::Rectangle { corner1, corner2 } => {
flush_polyline(output, &mut polyline, options, false, &indent);
let style = options.to_cetz_style();
let style_str = if style.is_empty() {
String::new()
} else {
format!(", {}", style)
};
let _ = writeln!(
output,
"{}rect({}, {}{})",
indent,
corner1.to_cetz(),
corner2.to_cetz(),
style_str
);
current_pos = corner2.clone();
}
PathSegment::Arc {
start_angle,
end_angle,
radius,
} => {
flush_polyline(output, &mut polyline, options, false, &indent);
let _ = writeln!(
output,
"{}arc({}, start: {}deg, stop: {}deg, radius: {})",
indent,
current_pos.to_cetz(),
start_angle,
end_angle,
radius
);
}
PathSegment::Ellipse {
center,
x_radius,
y_radius,
} => {
flush_polyline(output, &mut polyline, options, false, &indent);
let _ = writeln!(
output,
"{}ellipse({}, {}, {})",
indent,
center.to_cetz(),
x_radius,
y_radius
);
current_pos = center.clone();
}
PathSegment::Grid {
corner1,
corner2,
step,
} => {
flush_polyline(output, &mut polyline, options, false, &indent);
let step_str = step.map(|s| format!(", step: {}", s)).unwrap_or_default();
let _ = writeln!(
output,
"{}grid({}, {}{})",
indent,
corner1.to_cetz(),
corner2.to_cetz(),
step_str
);
}
PathSegment::Bezier {
start,
controls,
end,
} => {
flush_polyline(output, &mut polyline, options, false, &indent);
let style = options.to_cetz_style();
let style_str = if style.is_empty() {
String::new()
} else {
format!(", {}", style)
};
let coords: Vec<String> = std::iter::once(start.to_cetz())
.chain(controls.iter().map(|c| c.to_cetz()))
.chain(std::iter::once(end.to_cetz()))
.collect();
let _ = writeln!(
output,
"{}bezier({}{})",
indent,
coords.join(", "),
style_str
);
current_pos = end.clone();
}
PathSegment::CurveTo {
control1,
control2,
end,
} => {
flush_polyline(output, &mut polyline, options, false, &indent);
let style = options.to_cetz_style();
let style_str = if style.is_empty() {
String::new()
} else {
format!(", {}", style)
};
let start = current_pos.clone();
let mut coords = vec![start.to_cetz()];
if let Some(c1) = control1 {
coords.push(c1.to_cetz());
}
if let Some(c2) = control2 {
coords.push(c2.to_cetz());
}
coords.push(end.to_cetz());
let _ = writeln!(
output,
"{}bezier({}{})",
indent,
coords.join(", "),
style_str
);
current_pos = end.clone();
}
PathSegment::ClosePath => {
is_closed = true;
}
}
}
flush_polyline(output, &mut polyline, options, is_closed, &indent);
}
pub fn convert_tikz_environment(input: &str) -> String {
let has_env = input.contains(r"\begin{tikzpicture}");
if has_env {
convert_tikz_to_cetz(input)
} else {
let full = format!(r"\begin{{tikzpicture}}{}\end{{tikzpicture}}", input);
convert_tikz_to_cetz(&full)
}
}
#[derive(Debug, Clone, PartialEq)]
enum CetzToken {
Ident(String),
Number(f64),
String(String),
Content(String),
Coord(f64, f64),
Comma,
Colon,
LParen,
RParen,
Bool(bool),
}
fn tokenize_cetz(input: &str) -> Vec<CetzToken> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
match c {
' ' | '\t' | '\n' | '\r' => {
chars.next();
}
'/' if chars.clone().nth(1) == Some('/') => {
while let Some(&ch) = chars.peek() {
chars.next();
if ch == '\n' {
break;
}
}
}
'[' => {
chars.next();
let mut content = String::new();
let mut depth = 1;
while let Some(&ch) = chars.peek() {
chars.next();
if ch == '[' {
depth += 1;
content.push(ch);
} else if ch == ']' {
depth -= 1;
if depth == 0 {
break;
}
content.push(ch);
} else {
content.push(ch);
}
}
tokens.push(CetzToken::Content(content));
}
'"' => {
chars.next();
let mut s = String::new();
while let Some(&ch) = chars.peek() {
chars.next();
if ch == '"' {
break;
} else if ch == '\\' {
if let Some(&escaped) = chars.peek() {
chars.next();
s.push(escaped);
}
} else {
s.push(ch);
}
}
tokens.push(CetzToken::String(s));
}
'(' => {
chars.next();
let mut inner = String::new();
let mut depth = 1;
while let Some(&ch) = chars.peek() {
if ch == '(' {
depth += 1;
} else if ch == ')' {
depth -= 1;
if depth == 0 {
chars.next();
break;
}
}
inner.push(ch);
chars.next();
}
if let Some((x, y)) = parse_coord_tuple(&inner) {
tokens.push(CetzToken::Coord(x, y));
} else {
tokens.push(CetzToken::LParen);
let inner_tokens = tokenize_cetz(&inner);
tokens.extend(inner_tokens);
tokens.push(CetzToken::RParen);
}
}
',' => {
chars.next();
tokens.push(CetzToken::Comma);
}
':' => {
chars.next();
tokens.push(CetzToken::Colon);
}
')' => {
chars.next();
tokens.push(CetzToken::RParen);
}
'-' | '0'..='9' | '.' => {
let mut num_str = String::new();
if c == '-' {
num_str.push(c);
chars.next();
}
while let Some(&ch) = chars.peek() {
if ch.is_ascii_digit() || ch == '.' {
num_str.push(ch);
chars.next();
} else {
break;
}
}
if let Ok(n) = num_str.parse::<f64>() {
tokens.push(CetzToken::Number(n));
}
}
'a'..='z' | 'A'..='Z' | '_' => {
let mut ident = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.' {
ident.push(ch);
chars.next();
} else {
break;
}
}
if ident == "true" {
tokens.push(CetzToken::Bool(true));
} else if ident == "false" {
tokens.push(CetzToken::Bool(false));
} else {
tokens.push(CetzToken::Ident(ident));
}
}
_ => {
chars.next();
}
}
}
tokens
}
fn parse_coord_tuple(s: &str) -> Option<(f64, f64)> {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() == 2 {
let x: f64 = parts[0].trim().parse().ok()?;
let y: f64 = parts[1].trim().parse().ok()?;
Some((x, y))
} else {
None
}
}
#[derive(Debug, Clone)]
enum CetzCommand {
Line {
coords: Vec<(f64, f64)>,
style: CetzStyle,
close: bool,
},
Circle {
center: (f64, f64),
radius: f64,
style: CetzStyle,
},
Rect {
corner1: (f64, f64),
corner2: (f64, f64),
style: CetzStyle,
},
Arc {
center: (f64, f64),
start: f64,
stop: f64,
radius: f64,
style: CetzStyle,
},
Bezier {
points: Vec<(f64, f64)>,
style: CetzStyle,
},
Ellipse {
center: (f64, f64),
radius_x: f64,
radius_y: f64,
style: CetzStyle,
},
Content {
pos: (f64, f64),
text: String,
anchor: Option<String>,
name: Option<String>,
},
Grid {
corner1: (f64, f64),
corner2: (f64, f64),
step: Option<f64>,
},
}
#[derive(Debug, Clone, Default)]
struct CetzStyle {
stroke: Option<String>,
fill: Option<String>,
stroke_width: Option<f64>,
dash: Option<String>,
arrow_start: bool,
arrow_end: bool,
}
fn parse_cetz_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
if tokens.is_empty() {
return None;
}
let cmd_name = match &tokens[0] {
CetzToken::Ident(name) => name.as_str(),
_ => return None,
};
match cmd_name {
"line" => parse_line_command(tokens),
"circle" => parse_circle_command(tokens),
"rect" => parse_rect_command(tokens),
"arc" => parse_arc_command(tokens),
"bezier" => parse_bezier_command(tokens),
"ellipse" => parse_ellipse_command(tokens),
"content" => parse_content_command(tokens),
"grid" => parse_grid_command(tokens),
_ => None,
}
}
fn extract_style_from_tokens(tokens: &[CetzToken]) -> CetzStyle {
let mut style = CetzStyle::default();
let mut i = 0;
while i < tokens.len() {
if let CetzToken::Ident(key) = &tokens[i] {
if i + 2 < tokens.len() && tokens[i + 1] == CetzToken::Colon {
match key.as_str() {
"stroke" => {
if let Some(val) = get_token_value(&tokens[i + 2]) {
if is_dimension_value(&val) {
style.stroke_width = parse_dimension(&val);
} else {
style.stroke = Some(val);
}
}
}
"fill" => {
if let Some(val) = get_token_value(&tokens[i + 2]) {
style.fill = Some(convert_typst_color_to_tikz(&val));
}
}
"dash" => {
if let CetzToken::String(s) = &tokens[i + 2] {
style.dash = Some(s.clone());
}
}
"mark" => {
for token in tokens.iter().skip(i).take(10) {
if let CetzToken::String(s) = token {
if s.contains('>') {
style.arrow_end = true;
}
if s.contains('<') {
style.arrow_start = true;
}
}
}
}
_ => {}
}
}
}
i += 1;
}
style
}
fn is_dimension_value(val: &str) -> bool {
let trimmed = val.trim();
if let Some(first) = trimmed.chars().next() {
first.is_ascii_digit() || first == '.'
} else {
false
}
}
fn parse_dimension(val: &str) -> Option<f64> {
let trimmed = val.trim();
let num_str: String = trimmed
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let num: f64 = num_str.parse().ok()?;
let unit = trimmed.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.');
match unit.trim() {
"pt" | "" => Some(num),
"cm" => Some(num * 28.35),
"mm" => Some(num * 2.835),
"in" => Some(num * 72.0),
_ => Some(num),
}
}
fn convert_dimension_to_cm(value: f64, unit: &str) -> f64 {
match unit.trim().to_lowercase().as_str() {
"cm" | "" => value, "mm" => value / 10.0, "pt" => value / 28.35, "in" => value * 2.54, "em" => value * 0.35, "ex" => value * 0.15, _ => value, }
}
fn parse_dimension_to_cm(val: &str) -> Option<f64> {
let trimmed = val.trim();
let num_str: String = trimmed
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
.collect();
let num: f64 = num_str.parse().ok()?;
let unit = trimmed.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == '-');
Some(convert_dimension_to_cm(num, unit))
}
fn convert_typst_color_to_tikz(color: &str) -> String {
if color.contains(".lighten") {
if let Some(base) = color.split('.').next() {
if let Some(pct_str) = color.split("lighten(").nth(1) {
if let Ok(pct) = pct_str.trim_end_matches([')', '%']).parse::<f64>() {
let color_pct = 100.0 - pct;
return format!("{}!{:.0}", base, color_pct);
}
}
}
}
color.to_string()
}
fn get_token_value(token: &CetzToken) -> Option<String> {
match token {
CetzToken::Ident(s) => Some(s.clone()),
CetzToken::String(s) => Some(s.clone()),
CetzToken::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn collect_coords(tokens: &[CetzToken]) -> Vec<(f64, f64)> {
tokens
.iter()
.filter_map(|t| {
if let CetzToken::Coord(x, y) = t {
Some((*x, *y))
} else {
None
}
})
.collect()
}
fn has_close_flag(tokens: &[CetzToken]) -> bool {
for i in 0..tokens.len().saturating_sub(2) {
if let CetzToken::Ident(s) = &tokens[i] {
if s == "close" && tokens.get(i + 1) == Some(&CetzToken::Colon) {
if let Some(CetzToken::Bool(true)) = tokens.get(i + 2) {
return true;
}
}
}
}
false
}
fn parse_line_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
let coords = collect_coords(tokens);
if coords.is_empty() {
return None;
}
let style = extract_style_from_tokens(tokens);
let close = has_close_flag(tokens);
Some(CetzCommand::Line {
coords,
style,
close,
})
}
fn parse_circle_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
let coords = collect_coords(tokens);
let center = coords.first().cloned().unwrap_or((0.0, 0.0));
let mut radius = 1.0;
for i in 0..tokens.len().saturating_sub(2) {
if let CetzToken::Ident(s) = &tokens[i] {
if s == "radius" && tokens.get(i + 1) == Some(&CetzToken::Colon) {
if let Some(CetzToken::Number(r)) = tokens.get(i + 2) {
radius = *r;
}
}
}
}
let style = extract_style_from_tokens(tokens);
Some(CetzCommand::Circle {
center,
radius,
style,
})
}
fn parse_rect_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
let coords = collect_coords(tokens);
if coords.len() < 2 {
return None;
}
let style = extract_style_from_tokens(tokens);
Some(CetzCommand::Rect {
corner1: coords[0],
corner2: coords[1],
style,
})
}
fn parse_arc_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
let coords = collect_coords(tokens);
let center = coords.first().cloned().unwrap_or((0.0, 0.0));
let mut start = 0.0;
let mut stop = 90.0;
let mut radius = 1.0;
for i in 0..tokens.len().saturating_sub(2) {
if let CetzToken::Ident(s) = &tokens[i] {
if tokens.get(i + 1) == Some(&CetzToken::Colon) {
if let Some(CetzToken::Number(n)) = tokens.get(i + 2) {
match s.as_str() {
"start" => start = *n,
"stop" => stop = *n,
"radius" => radius = *n,
_ => {}
}
}
}
}
}
let style = extract_style_from_tokens(tokens);
Some(CetzCommand::Arc {
center,
start,
stop,
radius,
style,
})
}
fn parse_bezier_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
let points = collect_coords(tokens);
if points.len() < 3 {
return None;
}
let style = extract_style_from_tokens(tokens);
Some(CetzCommand::Bezier { points, style })
}
fn parse_ellipse_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
let coords = collect_coords(tokens);
let center = coords.first().cloned().unwrap_or((0.0, 0.0));
let mut radius_x = 1.0;
let mut radius_y = 0.5;
for i in 0..tokens.len().saturating_sub(2) {
if let CetzToken::Ident(s) = &tokens[i] {
if tokens.get(i + 1) == Some(&CetzToken::Colon) {
if let Some(CetzToken::Number(n)) = tokens.get(i + 2) {
match s.as_str() {
"radius" | "semi-major" => radius_x = *n,
"radius-y" | "semi-minor" => radius_y = *n,
_ => {}
}
}
}
}
}
let style = extract_style_from_tokens(tokens);
Some(CetzCommand::Ellipse {
center,
radius_x,
radius_y,
style,
})
}
fn parse_content_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
let coords = collect_coords(tokens);
let pos = coords.first().cloned().unwrap_or((0.0, 0.0));
let mut text = String::new();
for token in tokens {
if let CetzToken::Content(s) = token {
text = s.clone();
break;
}
}
let mut anchor = None;
for i in 0..tokens.len().saturating_sub(2) {
if let CetzToken::Ident(s) = &tokens[i] {
if s == "anchor" && tokens.get(i + 1) == Some(&CetzToken::Colon) {
if let Some(CetzToken::String(a)) = tokens.get(i + 2) {
anchor = Some(a.clone());
} else if let Some(CetzToken::Ident(a)) = tokens.get(i + 2) {
anchor = Some(a.clone());
}
}
}
}
let mut name = None;
for i in 0..tokens.len().saturating_sub(2) {
if let CetzToken::Ident(s) = &tokens[i] {
if s == "name" && tokens.get(i + 1) == Some(&CetzToken::Colon) {
if let Some(CetzToken::String(n)) = tokens.get(i + 2) {
name = Some(n.clone());
}
}
}
}
Some(CetzCommand::Content {
pos,
text,
anchor,
name,
})
}
fn parse_grid_command(tokens: &[CetzToken]) -> Option<CetzCommand> {
let coords = collect_coords(tokens);
if coords.len() < 2 {
return None;
}
let mut step = None;
for i in 0..tokens.len().saturating_sub(2) {
if let CetzToken::Ident(s) = &tokens[i] {
if s == "step" && tokens.get(i + 1) == Some(&CetzToken::Colon) {
if let Some(CetzToken::Number(n)) = tokens.get(i + 2) {
step = Some(*n);
}
}
}
}
Some(CetzCommand::Grid {
corner1: coords[0],
corner2: coords[1],
step,
})
}
pub fn convert_cetz_to_tikz(input: &str) -> String {
let mut output = String::with_capacity(input.len());
output.push_str("\\begin{tikzpicture}\n");
let commands = extract_cetz_commands(input);
for cmd_str in commands {
let tokens = tokenize_cetz(&cmd_str);
if let Some(cmd) = parse_cetz_command(&tokens) {
if let Some(tikz) = convert_cetz_command_to_tikz(&cmd) {
output.push_str(" ");
output.push_str(&tikz);
output.push_str(";\n");
}
}
}
output.push_str("\\end{tikzpicture}");
output
}
fn extract_cetz_commands(input: &str) -> Vec<String> {
let mut commands = Vec::new();
let mut current_cmd = String::new();
let mut depth = 0;
let mut in_string = false;
let mut in_content = false;
for line in input.lines() {
let trimmed = line.trim();
if trimmed.starts_with("import")
|| trimmed.starts_with("#import")
|| trimmed.starts_with("canvas(")
|| trimmed.starts_with("canvas({")
|| trimmed.starts_with("#canvas(")
|| trimmed.starts_with("#canvas({")
|| trimmed.starts_with("#cetz.canvas(")
|| trimmed.starts_with("cetz.canvas(")
|| trimmed == "{"
|| trimmed == "})"
|| trimmed == "}"
|| trimmed.is_empty()
|| trimmed.starts_with("//")
{
continue;
}
for c in trimmed.chars() {
match c {
'"' if !in_content => in_string = !in_string,
'[' if !in_string => in_content = true,
']' if !in_string => in_content = false,
'(' if !in_string && !in_content => depth += 1,
')' if !in_string && !in_content => depth -= 1,
_ => {}
}
current_cmd.push(c);
}
if depth == 0 && !current_cmd.trim().is_empty() {
commands.push(current_cmd.trim().to_string());
current_cmd.clear();
} else {
current_cmd.push(' ');
}
}
if !current_cmd.trim().is_empty() {
commands.push(current_cmd.trim().to_string());
}
commands
}
fn convert_cetz_command_to_tikz(cmd: &CetzCommand) -> Option<String> {
match cmd {
CetzCommand::Line {
coords,
style,
close,
} => {
if coords.is_empty() {
return None;
}
let mut result = build_tikz_draw_prefix(style);
for (i, (x, y)) in coords.iter().enumerate() {
if i > 0 {
result.push_str(" -- ");
}
let _ = write!(result, "({}, {})", x, y);
}
if *close {
result.push_str(" -- cycle");
}
Some(result)
}
CetzCommand::Circle {
center,
radius,
style,
} => {
let prefix = build_tikz_draw_prefix(style);
Some(format!(
"{} ({}, {}) circle ({})",
prefix, center.0, center.1, radius
))
}
CetzCommand::Rect {
corner1,
corner2,
style,
} => {
let prefix = build_tikz_draw_prefix(style);
Some(format!(
"{} ({}, {}) rectangle ({}, {})",
prefix, corner1.0, corner1.1, corner2.0, corner2.1
))
}
CetzCommand::Arc {
center,
start,
stop,
radius,
style,
} => {
let prefix = build_tikz_draw_prefix(style);
Some(format!(
"{} ({}, {}) arc ({}:{}:{})",
prefix, center.0, center.1, start, stop, radius
))
}
CetzCommand::Bezier { points, style } => {
let prefix = build_tikz_draw_prefix(style);
match points.len() {
3 => {
let (x0, y0) = points[0];
let (x1, y1) = points[1];
let (x2, y2) = points[2];
Some(format!(
"{} ({}, {}) .. controls ({}, {}) .. ({}, {})",
prefix, x0, y0, x1, y1, x2, y2
))
}
4 => {
let (x0, y0) = points[0];
let (x1, y1) = points[1];
let (x2, y2) = points[2];
let (x3, y3) = points[3];
Some(format!(
"{} ({}, {}) .. controls ({}, {}) and ({}, {}) .. ({}, {})",
prefix, x0, y0, x1, y1, x2, y2, x3, y3
))
}
_ => None,
}
}
CetzCommand::Ellipse {
center,
radius_x,
radius_y,
style,
} => {
let prefix = build_tikz_draw_prefix(style);
Some(format!(
"{} ({}, {}) ellipse ({} and {})",
prefix, center.0, center.1, radius_x, radius_y
))
}
CetzCommand::Content {
pos,
text,
anchor,
name,
} => {
let mut node_opts = Vec::new();
if let Some(a) = anchor {
let tikz_anchor = convert_cetz_anchor_to_tikz(a);
node_opts.push(format!("anchor={}", tikz_anchor));
}
if let Some(n) = name {
node_opts.push(format!("name={}", n));
}
let opts_str = if node_opts.is_empty() {
String::new()
} else {
format!("[{}]", node_opts.join(", "))
};
Some(format!(
"\\node{} at ({}, {}) {{{}}}",
opts_str, pos.0, pos.1, text
))
}
CetzCommand::Grid {
corner1,
corner2,
step,
} => {
let step_str = step.map(|s| format!("[step={}]", s)).unwrap_or_default();
Some(format!(
"\\draw{} ({}, {}) grid ({}, {})",
step_str, corner1.0, corner1.1, corner2.0, corner2.1
))
}
}
}
fn build_tikz_draw_prefix(style: &CetzStyle) -> String {
let mut opts = Vec::new();
if let Some(ref color) = style.stroke {
opts.push(format!("draw={}", color));
}
if let Some(ref color) = style.fill {
opts.push(format!("fill={}", color));
}
if let Some(width) = style.stroke_width {
opts.push(format!("line width={}pt", width));
}
if let Some(ref dash) = style.dash {
let tikz_dash = match dash.as_str() {
"dashed" => "dashed",
"dotted" => "dotted",
"dashdotted" => "dash dot",
_ => dash.as_str(),
};
opts.push(tikz_dash.to_string());
}
if style.arrow_start && style.arrow_end {
opts.push("<->".to_string());
} else if style.arrow_end {
opts.push("->".to_string());
} else if style.arrow_start {
opts.push("<-".to_string());
}
if opts.is_empty() {
"\\draw".to_string()
} else {
format!("\\draw[{}]", opts.join(", "))
}
}
fn convert_cetz_anchor_to_tikz(anchor: &str) -> &str {
match anchor {
"north" | "top" => "north",
"south" | "bottom" => "south",
"east" | "right" => "east",
"west" | "left" => "west",
"north-east" | "top-right" => "north east",
"north-west" | "top-left" => "north west",
"south-east" | "bottom-right" => "south east",
"south-west" | "bottom-left" => "south west",
"center" | "mid" => "center",
_ => anchor,
}
}
pub fn is_cetz_code(input: &str) -> bool {
input.contains("import \"@preview/cetz")
|| input.contains("canvas(")
|| (input.contains("line(") && input.contains("(") && input.contains(")"))
}
pub fn convert_cetz_environment(input: &str) -> String {
convert_cetz_to_tikz(input)
}
#[cfg(test)]
mod tests {
use super::*;
fn convert_single_cetz_cmd(input: &str) -> Option<String> {
let tokens = tokenize_cetz(input);
let cmd = parse_cetz_command(&tokens)?;
convert_cetz_command_to_tikz(&cmd)
}
#[test]
fn test_cetz_line_to_tikz() {
let cetz = "line((0, 0), (1, 1))";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("\\draw"));
assert!(tikz.contains("(0, 0)"));
assert!(tikz.contains("(1, 1)"));
}
#[test]
fn test_cetz_circle_to_tikz() {
let cetz = "circle((0, 0), radius: 1)";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
assert!(tikz.unwrap().contains("circle"));
}
#[test]
fn test_cetz_content_to_tikz() {
let cetz = "content((0, 0), [Hello])";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
assert!(tikz.unwrap().contains("\\node"));
}
#[test]
fn test_cetz_content_with_anchor() {
let cetz = r#"content((1, 2), anchor: "west", [Label])"#;
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("\\node"));
assert!(tikz.contains("anchor=west"));
assert!(tikz.contains("Label"));
}
#[test]
fn test_full_cetz_conversion() {
let cetz = r#"
import "@preview/cetz:0.2.0"
canvas({
line((0, 0), (1, 1))
circle((2, 2), radius: 0.5)
})
"#;
let tikz = convert_cetz_to_tikz(cetz);
assert!(tikz.contains("\\begin{tikzpicture}"));
assert!(tikz.contains("\\end{tikzpicture}"));
}
#[test]
fn test_cetz_bezier_to_tikz() {
let cetz = "bezier((0, 0), (1, 2), (3, 0))";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("controls"));
}
#[test]
fn test_cetz_bezier_cubic_to_tikz() {
let cetz = "bezier((0, 0), (0.5, 1), (1.5, 1), (2, 0))";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("controls"));
assert!(tikz.contains("and"));
}
#[test]
fn test_cetz_style_extraction() {
let input = "line((0, 0), (1, 1), stroke: red)";
let tokens = tokenize_cetz(input);
let style = extract_style_from_tokens(&tokens);
assert_eq!(style.stroke, Some("red".to_string()));
}
#[test]
fn test_cetz_rect_to_tikz() {
let cetz = "rect((0, 0), (2, 3))";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("rectangle"));
assert!(tikz.contains("(0, 0)"));
assert!(tikz.contains("(2, 3)"));
}
#[test]
fn test_tokenize_cetz() {
let input = "line((0, 0), (1, 1), stroke: blue)";
let tokens = tokenize_cetz(input);
assert!(tokens
.iter()
.any(|t| matches!(t, CetzToken::Ident(s) if s == "line")));
assert!(tokens
.iter()
.any(|t| matches!(t, CetzToken::Coord(0.0, 0.0))));
assert!(tokens
.iter()
.any(|t| matches!(t, CetzToken::Ident(s) if s == "stroke")));
assert!(tokens
.iter()
.any(|t| matches!(t, CetzToken::Ident(s) if s == "blue")));
}
#[test]
fn test_coordinate_parse_absolute() {
let coord = Coordinate::parse("(1.5, 2.0)").unwrap();
match coord {
Coordinate::Absolute(x, y) => {
assert!((x - 1.5).abs() < 0.001);
assert!((y - 2.0).abs() < 0.001);
}
_ => panic!("Expected absolute coordinate"),
}
}
#[test]
fn test_coordinate_parse_relative() {
let coord = Coordinate::parse("++(1, 1)").unwrap();
match coord {
Coordinate::Relative(dx, dy) => {
assert!((dx - 1.0).abs() < 0.001);
assert!((dy - 1.0).abs() < 0.001);
}
_ => panic!("Expected relative coordinate"),
}
}
#[test]
fn test_draw_options_parse() {
let opts = DrawOptions::parse("[thick, red, ->]");
assert!(opts.arrow_end);
assert_eq!(opts.line_width, Some("0.8pt".to_string()));
assert_eq!(opts.color, Some("red".to_string()));
}
#[test]
fn test_simple_line() {
let tikz = r"\begin{tikzpicture}\draw (0,0) -- (1,1);\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
assert!(cetz.contains("line"));
assert!(cetz.contains("canvas"));
}
#[test]
fn test_circle() {
let tikz = r"\begin{tikzpicture}\draw (0,0) circle (1);\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
println!("Circle output: {}", cetz);
assert!(
cetz.contains("circle") || cetz.contains("line"),
"Expected circle or line in: {}",
cetz
);
}
#[test]
fn test_node() {
let tikz = r"\begin{tikzpicture}\node at (0,0) {Hello};\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
assert!(cetz.contains("content"));
assert!(cetz.contains("Hello"));
}
#[test]
fn test_rectangle() {
let tikz = r"\begin{tikzpicture}\draw (0,0) rectangle (2,2);\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
println!("Rectangle output: {}", cetz);
assert!(
cetz.contains("rect") || cetz.contains("line"),
"Expected rect or line in: {}",
cetz
);
}
#[test]
fn test_styled_draw() {
let tikz = r"\begin{tikzpicture}\draw[thick, blue] (0,0) -- (1,1);\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
assert!(cetz.contains("blue"));
}
#[test]
fn test_tikz_polyline() {
let tikz = r"\begin{tikzpicture}\draw (0,0) -- (1,1) -- (2,0) -- (3,1);\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
assert!(cetz.contains("line"));
assert!(cetz.contains("(0, 0)") || cetz.contains("(0,0)"));
assert!(cetz.contains("(3, 1)") || cetz.contains("(3,1)"));
}
#[test]
fn test_tikz_closed_path() {
let tikz = r"\begin{tikzpicture}\draw (0,0) -- (1,0) -- (1,1) -- cycle;\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
assert!(
cetz.contains("close: true"),
"Expected close: true in: {}",
cetz
);
}
#[test]
fn test_tikz_arc() {
let tikz = r"\begin{tikzpicture}\draw (0,0) arc (0:90:1);\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
assert!(cetz.contains("arc"), "Expected arc in: {}", cetz);
}
#[test]
fn test_tikz_grid() {
let tikz = r"\begin{tikzpicture}\draw[step=0.5] (0,0) grid (3,3);\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
assert!(cetz.contains("grid"), "Expected grid in: {}", cetz);
}
#[test]
fn test_tikz_filled_shape() {
let tikz = r"\begin{tikzpicture}\draw[fill=yellow] (0,0) circle (1);\end{tikzpicture}";
let cetz = convert_tikz_to_cetz(tikz);
assert!(cetz.contains("yellow"), "Expected fill color in: {}", cetz);
}
#[test]
fn test_cetz_polyline_to_tikz() {
let cetz = "line((0, 0), (1, 1), (2, 0), (3, 1))";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("--"), "Expected -- in: {}", tikz);
assert!(tikz.contains("(0, 0)"));
assert!(tikz.contains("(3, 1)"));
}
#[test]
fn test_cetz_closed_line_to_tikz() {
let cetz = "line((0, 0), (1, 0), (1, 1), (0, 1), close: true)";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("cycle"), "Expected cycle in: {}", tikz);
}
#[test]
fn test_cetz_arc_to_tikz() {
let cetz = "arc((0, 0), start: 0, stop: 90, radius: 1)";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("arc"), "Expected arc in: {}", tikz);
}
#[test]
fn test_cetz_grid_to_tikz() {
let cetz = "grid((0, 0), (3, 3), step: 0.5)";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("grid"), "Expected grid in: {}", tikz);
assert!(tikz.contains("step=0.5"), "Expected step in: {}", tikz);
}
#[test]
fn test_cetz_filled_circle_to_tikz() {
let cetz = "circle((0, 0), radius: 1, fill: yellow)";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(tikz.contains("fill=yellow"), "Expected fill in: {}", tikz);
}
#[test]
fn test_cetz_styled_line_to_tikz() {
let cetz = "line((0, 0), (1, 1), stroke: blue)";
let tikz = convert_single_cetz_cmd(cetz);
assert!(tikz.is_some());
let tikz = tikz.unwrap();
assert!(
tikz.contains("draw=blue"),
"Expected draw=blue in: {}",
tikz
);
}
#[test]
fn test_cetz_ellipse_to_tikz() {
let cetz = "ellipse((0, 0), radius: 2, radius-y: 1)";
let tokens = tokenize_cetz(cetz);
let cmd = parse_cetz_command(&tokens);
assert!(cmd.is_some() || cmd.is_none()); }
#[test]
fn test_roundtrip_cetz_line() {
let original_cetz = r#"
canvas({
line((0, 0), (1, 1))
})
"#;
let tikz = convert_cetz_to_tikz(original_cetz);
assert!(tikz.contains("\\draw"), "CeTZ->TikZ failed: {}", tikz);
let back_cetz = convert_tikz_to_cetz(&tikz);
assert!(
back_cetz.contains("line"),
"TikZ->CeTZ failed: {}",
back_cetz
);
}
#[test]
fn test_roundtrip_cetz_circle() {
let original_cetz = r#"
canvas({
circle((2, 2), radius: 1)
})
"#;
let tikz = convert_cetz_to_tikz(original_cetz);
assert!(tikz.contains("circle"), "CeTZ->TikZ failed: {}", tikz);
let back_cetz = convert_tikz_to_cetz(&tikz);
assert!(
back_cetz.contains("circle"),
"TikZ->CeTZ roundtrip failed: {}",
back_cetz
);
}
#[test]
fn test_roundtrip_cetz_node() {
let original_cetz = r#"
canvas({
content((0, 0), [Test])
})
"#;
let tikz = convert_cetz_to_tikz(original_cetz);
assert!(tikz.contains("\\node"), "CeTZ->TikZ failed: {}", tikz);
assert!(tikz.contains("Test"), "Text lost: {}", tikz);
let back_cetz = convert_tikz_to_cetz(&tikz);
assert!(
back_cetz.contains("content"),
"TikZ->CeTZ roundtrip failed: {}",
back_cetz
);
assert!(
back_cetz.contains("Test"),
"Text lost in roundtrip: {}",
back_cetz
);
}
#[test]
fn test_roundtrip_cetz_rect() {
let original_cetz = r#"
canvas({
rect((0, 0), (2, 2))
})
"#;
let tikz = convert_cetz_to_tikz(original_cetz);
assert!(tikz.contains("rectangle"), "CeTZ->TikZ failed: {}", tikz);
let back_cetz = convert_tikz_to_cetz(&tikz);
assert!(
back_cetz.contains("rect"),
"TikZ->CeTZ roundtrip failed: {}",
back_cetz
);
}
}