diff --git a/src/lib/config.rs b/src/lib/config.rs index dec0d8f..a0e350b 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -220,6 +220,14 @@ pub fn parse_config_string(config_str: &str) -> StyleMatch { link: parse_style(config.get("link"), default_style.link), image: parse_style(config.get("image"), default_style.image), text: parse_style(config.get("text"), default_style.text), + table_header: parse_style( + config.get("table").and_then(|t| t.get("header")), + default_style.table_header, + ), + table_cell: parse_style( + config.get("table").and_then(|t| t.get("cell")), + default_style.table_cell, + ), horizontal_rule: parse_style(config.get("horizontal_rule"), default_style.horizontal_rule), } } diff --git a/src/lib/debug.rs b/src/lib/debug.rs index 9631a19..a60cf05 100644 --- a/src/lib/debug.rs +++ b/src/lib/debug.rs @@ -167,6 +167,95 @@ impl Token { ) } + Token::Table { + headers, + aligns, + rows, + } => { + let mut result = format!("{}{{\n", indent); + result.push_str(&format!("{}\"type\": \"Table\",\n", inner_indent)); + + // Headers + result.push_str(&format!("{}\"headers\": [\n", inner_indent)); + for (i, header_cell) in headers.iter().enumerate() { + result.push_str(&format!("{}[\n", " ".repeat(indent_level + 2))); + for (j, token) in header_cell.iter().enumerate() { + result.push_str(&token.to_readable_json(indent_level + 3)); + if j < header_cell.len() - 1 { + result.push(','); + } + result.push('\n'); + } + result.push_str(&format!("{}]", " ".repeat(indent_level + 2))); + if i < headers.len() - 1 { + result.push(','); + } + result.push('\n'); + } + result.push_str(&format!("{}],\n", inner_indent)); + + // Alignments + result.push_str(&format!("{}\"aligns\": [\n", inner_indent)); + for (i, align) in aligns.iter().enumerate() { + let align_str = match align { + genpdfi::Alignment::Left => "Left", + genpdfi::Alignment::Center => "Center", + genpdfi::Alignment::Right => "Right", + }; + result.push_str(&format!( + "{}\"{}\"", + " ".repeat(indent_level + 2), + align_str + )); + if i < aligns.len() - 1 { + result.push(','); + } + result.push('\n'); + } + result.push_str(&format!("{}],\n", inner_indent)); + + // Rows + result.push_str(&format!("{}\"rows\": [\n", inner_indent)); + for (i, row) in rows.iter().enumerate() { + result.push_str(&format!("{}[\n", " ".repeat(indent_level + 2))); + for (j, cell) in row.iter().enumerate() { + result.push_str(&format!("{}[\n", " ".repeat(indent_level + 3))); + for (k, token) in cell.iter().enumerate() { + result.push_str(&token.to_readable_json(indent_level + 4)); + if k < cell.len() - 1 { + result.push(','); + } + result.push('\n'); + } + result.push_str(&format!("{}]", " ".repeat(indent_level + 3))); + if j < row.len() - 1 { + result.push(','); + } + result.push('\n'); + } + result.push_str(&format!("{}]", " ".repeat(indent_level + 2))); + if i < rows.len() - 1 { + result.push(','); + } + result.push('\n'); + } + result.push_str(&format!("{}]\n", inner_indent)); + result.push_str(&format!("{}}}", indent)); + result + } + + Token::TableAlignment(align) => { + let align_str = match align { + genpdfi::Alignment::Left => "Left", + genpdfi::Alignment::Center => "Center", + genpdfi::Alignment::Right => "Right", + }; + format!( + "{}{{\n{}\"type\": \"TableAlignment\",\n{}\"alignment\": \"{}\"\n{}}}", + indent, inner_indent, inner_indent, align_str, indent + ) + } + Token::HtmlComment(content) => { format!( "{}{{\n{}\"type\": \"HtmlComment\",\n{}\"content\": \"{}\"\n{}}}", @@ -191,7 +280,6 @@ impl Token { indent, inner_indent, indent ) } - Token::Unknown(content) => { format!( "{}{{\n{}\"type\": \"Unknown\",\n{}\"content\": \"{}\"\n{}}}", diff --git a/src/lib/markdown.rs b/src/lib/markdown.rs index 17ada28..040bece 100644 --- a/src/lib/markdown.rs +++ b/src/lib/markdown.rs @@ -41,6 +41,7 @@ //! ├── text: String //! └── url: String +use genpdfi::Alignment; /// Parsing context — determines which tokens are valid in the current location. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ParseContext { @@ -78,6 +79,14 @@ pub enum Token { Image(String, String), /// Plain text content Text(String), + /// Table with header, alignment info, and rows + Table { + headers: Vec>, + aligns: Vec, + rows: Vec>>, + }, + /// Text alignment for table columns + TableAlignment(Alignment), /// HTML comment content HtmlComment(String), /// Line break @@ -148,6 +157,27 @@ impl Token { Token::Newline | Token::HorizontalRule => { // These don't contain text } + Token::Table { + headers, + aligns: _, + rows, + } => { + for header in headers { + for token in header { + token.collect_text_recursive(result); + } + } + for row in rows { + for cell in row { + for token in cell { + token.collect_text_recursive(result); + } + } + } + } + Token::TableAlignment(_) => { + // These don't contain text + } } } } @@ -321,6 +351,13 @@ impl Lexer { } '<' if self.is_html_comment_start() => self.parse_html_comment()?, '\n' => self.parse_newline()?, + '|' if is_line_start => { + if self.is_table_start() { + self.parse_table()? + } else { + self.parse_text(ctx)? + } + } _ => self.parse_text(ctx)?, }; @@ -747,6 +784,95 @@ impl Lexer { }) } + /// Checks if the current posisiton is the start of a table + fn is_table_start(&self) -> bool { + let rest: String = self.input[self.position..].iter().collect(); + // Next line with --- or :--- + if let Some(pos) = rest.find('\n') { + let next_line = rest[pos + 1..].lines().next().unwrap_or(""); + next_line.contains('-') + } else { + false + } + } + + /// Parses a table, handling column alignment + fn parse_table(&mut self) -> Result { + // Parse header row + let header_line = self.read_until_newline(); + let header_cells: Vec = header_line + .trim_matches('|') + .split('|') + .map(|s| s.trim().to_string()) + .collect(); + + if self.current_char() == '\n' { + self.advance(); + } + + // Parse alignment row + let align_line = self.read_until_newline(); + let aligns: Vec = align_line + .trim_matches('|') + .split('|') + .map(|s| { + let s = s.trim(); + match (s.starts_with(':'), s.ends_with(':')) { + (true, true) => Alignment::Center, + (true, false) => Alignment::Left, + (false, true) => Alignment::Right, + _ => Alignment::Left, + } + }) + .collect(); + + if self.current_char() == '\n' { + self.advance(); + } + + // Convert header strings to token vectors + let mut headers = Vec::new(); + for cell in header_cells { + let mut cell_lexer = Lexer::new(cell); + let parsed = cell_lexer.parse_with_context(ParseContext::TableCell)?; + headers.push(parsed); + } + + // Parse rows until blank or non-table start + let mut rows = Vec::new(); + while self.position < self.input.len() { + let line = self.read_until_newline(); + if line.trim().is_empty() { + break; + } + + let cell_texts: Vec = line + .trim_matches('|') + .split('|') + .map(|s| s.trim().to_string()) + .collect(); + + let mut row_tokens = Vec::new(); + for cell in cell_texts { + // FIX: large unbreakable words don't fit in cells + let mut cell_lexer = Lexer::new(cell); + let parsed = cell_lexer.parse_with_context(ParseContext::TableCell)?; + row_tokens.push(parsed); + } + rows.push(row_tokens); + + if self.current_char() == '\n' { + self.advance(); + } + } + + Ok(Token::Table { + headers, + aligns, + rows, + }) + } + /// Gets the current line's indentation level fn get_current_indent(&self) -> usize { let mut count = 0; @@ -1201,4 +1327,37 @@ A paragraph with `code` and [link](url). )] ); } + + #[test] + fn test_tables() { + let input = r#"| Name | Age | City | +|:-----|:---:|----:| +| Alice | 30 | Paris | +| Bob | 25 | Lyon |"#; + + let tokens = parse(input); + assert_eq!( + tokens, + vec![Token::Table { + headers: vec![ + vec![Token::Text("Name".to_string())], + vec![Token::Text("Age".to_string())], + vec![Token::Text("City".to_string())], + ], + aligns: vec![Alignment::Left, Alignment::Center, Alignment::Right], + rows: vec![ + vec![ + vec![Token::Text("Alice".to_string())], + vec![Token::Text("30".to_string())], + vec![Token::Text("Paris".to_string())], + ], + vec![ + vec![Token::Text("Bob".to_string())], + vec![Token::Text("25".to_string())], + vec![Token::Text("Lyon".to_string())], + ], + ], + }] + ); + } } diff --git a/src/lib/pdf.rs b/src/lib/pdf.rs index 02feb68..7aa0f39 100644 --- a/src/lib/pdf.rs +++ b/src/lib/pdf.rs @@ -19,7 +19,7 @@ use crate::{fonts::load_unicode_system_font, styling::StyleMatch, Token}; use genpdfi::{ fonts::{FontData, FontFamily}, - Document, + Alignment, Document, }; /// The main PDF document generator that orchestrates the conversion process from markdown to PDF. @@ -310,6 +310,15 @@ impl Pdf { self.flush_paragraph(doc, ¤t_tokens); current_tokens.clear(); } + Token::Table { + headers, + aligns, + rows, + } => { + self.flush_paragraph(doc, ¤t_tokens); + current_tokens.clear(); + self.render_table(doc, headers, aligns, rows) + } _ => { current_tokens.push(token.clone()); } @@ -521,6 +530,77 @@ impl Pdf { } } } + + /// Renders a table with headers, alignment information, and rows. + /// + /// Each row is a vector of cells. + /// + /// The table is rendered using genpdfi's TableLayout with proper column weights + /// and cell borders. Each cell content is processed as inline tokens to handle + /// formatting within table them. + fn render_table( + &self, + doc: &mut Document, + headers: &Vec>, + aligns: &Vec, + rows: &Vec>>, + ) { + doc.push(genpdfi::elements::Break::new( + self.style.text.before_spacing, + )); + + let column_count = headers.len(); + let column_weights = vec![1; column_count]; + + let mut table = genpdfi::elements::TableLayout::new(column_weights); + table.set_cell_decorator(genpdfi::elements::FrameCellDecorator::new( + true, true, false, + )); + + // Render header row + let mut header_row = table.row(); + for (i, header_cell) in headers.iter().enumerate() { + let mut para = genpdfi::elements::Paragraph::default(); + let style = genpdfi::style::Style::new().with_font_size(self.style.table_header.size); + + if let Some(align) = aligns.get(i) { + para.set_alignment(*align); + } + + self.render_inline_content_with_style(&mut para, header_cell, style); + header_row.push_element(para); + } + + if let Err(_) = header_row.push() { + eprintln!("Warning: Failed rendering a table"); + return; // Skip the entire table if header fails + } + + // Render data rows + for (row_idx, row) in rows.iter().enumerate() { + let mut table_row = table.row(); + + for (i, cell_tokens) in row.iter().enumerate() { + let mut para = genpdfi::elements::Paragraph::default(); + let style = genpdfi::style::Style::new().with_font_size(self.style.table_cell.size); + + if let Some(align) = aligns.get(i) { + para.set_alignment(*align); + } + + self.render_inline_content_with_style(&mut para, cell_tokens, style); + table_row.push_element(para); + } + + if let Err(_) = table_row.push() { + eprintln!("Warning: Failed to push row {} in a table", row_idx); + continue; // Continue with next row + } + } + + doc.push(table); + doc.push(genpdfi::elements::Break::new(self.style.text.after_spacing)); + } } #[cfg(test)] diff --git a/src/lib/styling.rs b/src/lib/styling.rs index feb1de7..ed4fd1b 100644 --- a/src/lib/styling.rs +++ b/src/lib/styling.rs @@ -147,6 +147,10 @@ pub struct StyleMatch { pub image: BasicTextStyle, /// Style for regular text pub text: BasicTextStyle, + /// Style for table headers + pub table_header: BasicTextStyle, + /// Style for table cells + pub table_cell: BasicTextStyle, // TODO: Not parsed into a actual horizontal rule currently, we need a proper styling for this /// Style for horizontal rules (---) @@ -272,6 +276,32 @@ impl Default for StyleMatch { false, None, ), + table_header: BasicTextStyle::new( + 8, + Some((0, 0, 0)), + None, + None, + None, + None, + false, + false, + false, + false, + None, + ), + table_cell: BasicTextStyle::new( + 8, + Some((0, 0, 0)), + None, + None, + None, + None, + false, + false, + false, + false, + None, + ), link: BasicTextStyle::new( 8, Some((128, 128, 128)),