From fc2d95137f3a55010a0cb9ae30e9b99547d75d58 Mon Sep 17 00:00:00 2001 From: kirbylife Date: Tue, 30 Jul 2024 01:28:51 -0600 Subject: [PATCH] Add table support --- Cargo.toml | 1 + src/lib.rs | 155 ++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9925abb..fbbc70a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,5 +12,6 @@ keywords = ["gemini", "markdown"] maintenance = { status = "experimental" } [dependencies] +comfy-table = { version = "7.1.1", default-features = false } gemtext = "0.2.1" pulldown-cmark = { version = "0.8", default-features = false } diff --git a/src/lib.rs b/src/lib.rs index f5b2aee..f2d0516 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +use comfy_table::Table; use pulldown_cmark as md; /// Converts a given string of Markdown to semi-equivalent gemtext. @@ -7,7 +8,9 @@ use pulldown_cmark as md; /// Will panic if gemtext::render somehow produces invalid UTF-8. /// Since gemtext::render only produces valid UTF-8, this should never happen. pub fn convert(markdown_text: &str) -> String { - let parser = md::Parser::new_ext(markdown_text, md::Options::empty()); + let mut opts = md::Options::empty(); + opts.toggle(md::Options::ENABLE_TABLES); + let parser = md::Parser::new_ext(markdown_text, opts); let mut state = State::new(); for event in parser { @@ -18,11 +21,13 @@ pub fn convert(markdown_text: &str) -> String { md::Event::Start(md::Tag::CodeBlock(_)) => state.start_code_block(), md::Event::Start(md::Tag::List(_)) => (), md::Event::Start(md::Tag::Item) => state.start_list_item(), - md::Event::Start(md::Tag::FootnoteDefinition(_)) => unimplemented!("footnotes disabled"), - md::Event::Start(md::Tag::Table(_)) => unimplemented!("tables disabled"), - md::Event::Start(md::Tag::TableHead) => unimplemented!("tables disabled"), - md::Event::Start(md::Tag::TableRow) => unimplemented!("tables disabled"), - md::Event::Start(md::Tag::TableCell) => unimplemented!("tables disabled"), + md::Event::Start(md::Tag::FootnoteDefinition(_)) => { + unimplemented!("footnotes disabled") + } + md::Event::Start(md::Tag::Table(_)) => state.start_table_building(), + md::Event::Start(md::Tag::TableHead) => state.add_table_row(), + md::Event::Start(md::Tag::TableRow) => state.add_table_row(), + md::Event::Start(md::Tag::TableCell) => (), md::Event::Start(md::Tag::Emphasis) => state.toggle_emphasis(), md::Event::Start(md::Tag::Strong) => state.toggle_strong(), md::Event::Start(md::Tag::Strikethrough) => unimplemented!("strikethrough disabled"), @@ -36,10 +41,10 @@ pub fn convert(markdown_text: &str) -> String { md::Event::End(md::Tag::List(_)) => state.finish_list(), md::Event::End(md::Tag::Item) => state.finish_node(), md::Event::End(md::Tag::FootnoteDefinition(_)) => unimplemented!("footnotes disabled"), - md::Event::End(md::Tag::Table(_)) => unimplemented!("tables disabled"), - md::Event::End(md::Tag::TableHead) => unimplemented!("tables disabled"), - md::Event::End(md::Tag::TableRow) => unimplemented!("tables disabled"), - md::Event::End(md::Tag::TableCell) => unimplemented!("tables disabled"), + md::Event::End(md::Tag::Table(_)) => state.finish_table_building(), + md::Event::End(md::Tag::TableHead) => (), + md::Event::End(md::Tag::TableRow) => (), + md::Event::End(md::Tag::TableCell) => (), md::Event::End(md::Tag::Emphasis) => state.toggle_emphasis(), md::Event::End(md::Tag::Strong) => state.toggle_strong(), md::Event::End(md::Tag::Strikethrough) => unimplemented!("strikethrough disabled"), @@ -57,7 +62,8 @@ pub fn convert(markdown_text: &str) -> String { } } - let nodes = state.nodes + let nodes = state + .nodes .into_iter() .filter(|cluster| !cluster.is_empty()) .map(condense) @@ -72,7 +78,9 @@ type NodeCluster = Vec; fn condense(original: NodeCluster) -> NodeCluster { match original.as_slice() { - [gemtext::Node::Text(text), gemtext::Node::Link { name: Some(name), .. }] if text == name => vec![original[1].clone()], + [gemtext::Node::Text(text), gemtext::Node::Link { + name: Some(name), .. + }] if text == name => vec![original[1].clone()], _ => original, } } @@ -108,6 +116,8 @@ struct State { pending_node_type: NodeType, pending_other: Vec, link_text_stack: Vec, + table: Vec>, + building_table: bool, } impl State { @@ -118,6 +128,8 @@ impl State { pending_node_type: NodeType::Text, pending_other: vec![], link_text_stack: vec![], + table: vec![], + building_table: false, } } @@ -130,6 +142,14 @@ impl State { self.pending_node_type = NodeType::Heading { level }; } + fn start_table_building(&mut self) { + self.building_table = true; + } + + fn add_table_row(&mut self) { + self.table.push(vec![]); + } + fn start_block_quote(&mut self) { self.pending_node_type = NodeType::Quote; } @@ -159,25 +179,57 @@ impl State { self.pending_node_content += "[image: "; } + fn finish_table_building(&mut self) { + let mut table = Table::new(); + + if let Some(header) = self.table.first() { + table.set_header(header); + } + + table.add_rows(self.table[1..].into_iter()); + + self.building_table = false; + self.table = vec![]; + + self.finish_node(); + self.pending_node_content += &table.to_string(); + self.finish_node(); + } + fn finish_list(&mut self) { self.nodes.push(vec![]); } fn end_link(&mut self, href: &str) { - let text = self.link_text_stack.pop().unwrap_or_else(|| href.to_string()); - self.pending_other.push(gemtext::Node::Link { to: href.to_string(), name: Some(text) }); + let text = self + .link_text_stack + .pop() + .unwrap_or_else(|| href.to_string()); + self.pending_other.push(gemtext::Node::Link { + to: href.to_string(), + name: Some(text), + }); } fn end_image(&mut self, src: &str) { - let text = self.link_text_stack.pop().unwrap_or_else(|| src.to_string()); + let text = self + .link_text_stack + .pop() + .unwrap_or_else(|| src.to_string()); let text = format!("[image: {}]", text); - self.pending_other.push(gemtext::Node::Link { to: src.to_string(), name: Some(text) }); + self.pending_other.push(gemtext::Node::Link { + to: src.to_string(), + name: Some(text), + }); self.pending_node_content += "]"; } // will create an empty paragraph if pending_text is empty fn finish_node(&mut self) { - match (&self.pending_node_type, self.nodes.last().and_then(|cluster| cluster.last())) { + match ( + &self.pending_node_type, + self.nodes.last().and_then(|cluster| cluster.last()), + ) { (NodeType::ListItem, Some(gemtext::Node::ListItem(_))) => (), _ => self.nodes.push(vec![]), } @@ -191,10 +243,16 @@ impl State { } fn add_text(&mut self, text: &str) { - for link_text in &mut self.link_text_stack { - *link_text += text; + if self.building_table { + if let Some(last_row) = self.table.last_mut() { + last_row.push(text.to_string()); + } + } else { + for link_text in &mut self.link_text_stack { + *link_text += text; + } + self.pending_node_content += text; } - self.pending_node_content += text; } fn add_inline_code(&mut self, code: &str) { @@ -284,3 +342,60 @@ fn test_readme() { let gemtext = include_str!("../README.gmi"); assert_eq!(convert(markdown), gemtext); } + +#[cfg(test)] +#[test] +fn test_single_table() { + let markdown = r#" +| Column 1 | Col 2 | +| -------- | ----- | +| ten | 10 | +| veinte | 20 | +"#; + let gemtext = r#" ++----------+-------+ +| Column 1 | Col 2 | ++==================+ +| ten | 10 | +|----------+-------| +| veinte | 20 | ++----------+-------+ +"#; + assert_eq!(convert(markdown).trim(), gemtext.trim()); +} + +#[cfg(test)] +#[test] +fn test_multi_tables() { + let markdown = r#" +| Column 1 | Col 2 | +| -------- | ----- | +| ten | 10 | +| veinte | 20 | + +| Column 1 | Col 2 | +| -------- | ----- | +| 30 | 40 | +| 50 | 60 | +"#; + let gemtext = r#" ++----------+-------+ +| Column 1 | Col 2 | ++==================+ +| ten | 10 | +|----------+-------| +| veinte | 20 | ++----------+-------+ + + + ++----------+-------+ +| Column 1 | Col 2 | ++==================+ +| 30 | 40 | +|----------+-------| +| 50 | 60 | ++----------+-------+ +"#; + assert_eq!(convert(markdown).trim(), gemtext.trim()); +}