diff --git a/TODO.md b/TODO.md index 06108cc..738b8d8 100644 --- a/TODO.md +++ b/TODO.md @@ -5,4 +5,4 @@ * [X] ~~Сделать отображение типа кода и кнопку скопировать код~~ * [X] ~~Вынести зависимости, типа скриптов mermaid и highlight.js в include_bytes~~ -* [ ] Починить заголовки таблицы +* [X] ~~Починить заголовки таблицы~~ diff --git a/src/markdown.rs b/src/markdown.rs index 1f07f5b..313e462 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1,10 +1,300 @@ use pulldown_cmark::{ - BlockQuoteKind, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd, - TextMergeStream, + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag, + TagEnd, TextMergeStream, }; +use std::collections::HashMap; use std::fmt::Write; +// ========== Класс состояния таблицы ========== +#[derive(Debug, Clone, PartialEq)] +pub enum AlignmentState { + Left, + Center, + Right, + None, +} + +impl From for AlignmentState { + fn from(alignment: Alignment) -> Self { + match alignment { + Alignment::Left => AlignmentState::Left, + Alignment::Center => AlignmentState::Center, + Alignment::Right => AlignmentState::Right, + Alignment::None => AlignmentState::None, + } + } +} + +impl AlignmentState { + pub fn to_css_class(&self) -> &'static str { + match self { + AlignmentState::Left => "align-left", + AlignmentState::Center => "align-center", + AlignmentState::Right => "align-right", + AlignmentState::None => "", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct TableState { + pub alignments: Vec, + pub column_count: usize, + pub is_inside_table: bool, + pub is_inside_head: bool, + pub is_inside_body: bool, + pub current_row: usize, + pub current_column: usize, + pub cell_data: HashMap<(usize, usize), String>, + pub has_header: bool, +} + +impl TableState { + pub fn new() -> Self { + Self::default() + } + + pub fn start_table(&mut self, alignments: Vec) { + self.alignments = alignments.into_iter().map(AlignmentState::from).collect(); + self.column_count = self.alignments.len(); + self.is_inside_table = true; + self.is_inside_head = false; + self.is_inside_body = false; + self.current_row = 0; + self.current_column = 0; + self.cell_data.clear(); + self.has_header = false; + } + + pub fn end_table(&mut self) { + self.is_inside_table = false; + self.is_inside_head = false; + self.is_inside_body = false; + } + + pub fn start_head(&mut self) { + if self.is_inside_table { + self.is_inside_head = true; + self.is_inside_body = false; + self.current_row = 0; + self.current_column = 0; + self.has_header = true; + } + } + + pub fn end_head(&mut self) { + self.is_inside_head = false; + } + + pub fn start_row(&mut self) { + if self.is_inside_table { + if self.is_inside_body { + self.current_row += 1; + } + self.current_column = 0; + } + } + + pub fn end_row(&mut self) { + if self.current_column != self.column_count && self.is_inside_table && self.column_count > 0 + { + // Исправляем неполные строки, добавляя недостающие ячейки + while self.current_column < self.column_count { + let row = if self.is_inside_head { + 0 + } else { + self.current_row + }; + let col = self.current_column; + self.cell_data.insert((row, col), String::new()); + self.current_column += 1; + } + } + } + + pub fn start_cell(&mut self) { + if self.is_inside_table { + self.current_column += 1; + } + } + + pub fn end_cell(&mut self, data: Option) { + if let Some(text) = data { + let row = if self.is_inside_head { + 0 + } else { + self.current_row + }; + let col = self.current_column - 1; + self.cell_data.insert((row, col), text); + } + } + + pub fn get_current_alignment(&self) -> &AlignmentState { + if self.current_column == 0 || self.current_column > self.alignments.len() { + return &AlignmentState::None; + } + let col_index = self.current_column - 1; + &self.alignments[col_index] + } +} + +// ========== Основной рендерер ========== +pub struct MarkdownRenderer { + table_state: TableState, + in_code_block: bool, + code_content: String, +} + +impl MarkdownRenderer { + pub fn new() -> Self { + Self { + table_state: TableState::new(), + in_code_block: false, + code_content: String::new(), + } + } + + pub fn render_start_tag(&mut self, tag: Tag<'_>) -> String { + let mut output = String::new(); + match tag { + Tag::Heading { level, .. } => { + output.push_str(format!("<{level}>").as_str()); + } + Tag::Strikethrough => { + output.push_str(""); + } + Tag::List(Some(_)) => output.push_str("
    "), + Tag::List(None) => output.push_str("
      "), + Tag::Item => output.push_str("
    • "), + Tag::Paragraph => output.push_str("

      "), + Tag::CodeBlock(kind) => { + output.push_str(render_codeblock(kind).as_str()); + } + Tag::Emphasis => output.push_str(""), + Tag::Strong => output.push_str(""), + Tag::BlockQuote(kind) => output.push_str(render_blockquote(kind).as_str()), + Tag::Link { + link_type, + dest_url, + title, + .. + } => output.push_str(render_link(link_type, &dest_url, &title).as_str()), + Tag::Image { + link_type, + dest_url, + title, + .. + } => output.push_str(render_image(link_type, &dest_url, &title).as_str()), + + Tag::Table(alignments) => { + self.table_state.start_table(alignments.clone()); + output.push_str(""); + } + + Tag::TableHead => { + self.table_state.start_head(); + output.push_str(""); + } + + Tag::TableCell => { + self.table_state.start_cell(); + let alignment = self.table_state.get_current_alignment(); + let css_class = alignment.to_css_class(); + + if css_class.is_empty() { + output.push_str(""); + } + + Tag::FootnoteDefinition(fref) => { + let _ = write!(output, ""); + } + + Tag::HtmlBlock + | Tag::DefinitionList + | Tag::DefinitionListTitle + | Tag::DefinitionListDefinition + | Tag::Superscript + | Tag::Subscript + | Tag::MetadataBlock(_) => (), + } + + output + } + + pub fn render_end_tag(&mut self, tag: TagEnd) -> String { + let mut output = String::new(); + match tag { + TagEnd::Heading(level) => { + output.push_str(format!("").as_str()); + } + TagEnd::Strikethrough => { + output.push_str(""); + } + TagEnd::List(true) => output.push_str(""), + TagEnd::List(false) => output.push_str(""), + TagEnd::Item => output.push_str(""), + TagEnd::Paragraph => output.push_str("

      "), + TagEnd::CodeBlock => { + output.push_str(""); + } + TagEnd::Emphasis => output.push_str(""), + TagEnd::Strong => output.push_str(""), + TagEnd::BlockQuote(_) => output.push_str(""), + TagEnd::Link => output.push_str("
      "), + TagEnd::Image => output.push_str(""), + + TagEnd::Table => { + self.table_state.end_table(); + output.push_str("
      "); + } else { + let _ = write!(output, ""); + } + } + + Tag::TableRow => { + self.table_state.start_row(); + output.push_str("
      "); + } + + TagEnd::TableHead => { + self.table_state.end_head(); + output.push_str(""); + if self.table_state.is_inside_table { + output.push_str(""); + self.table_state.is_inside_body = true; + } + } + + TagEnd::TableCell => { + self.table_state.end_cell(None); + output.push_str(""); + } + + TagEnd::TableRow => { + self.table_state.end_row(); + output.push_str(""); + } + + TagEnd::FootnoteDefinition => { + output.push_str(""); + } + + TagEnd::HtmlBlock + | TagEnd::DefinitionList + | TagEnd::DefinitionListTitle + | TagEnd::DefinitionListDefinition + | TagEnd::Superscript + | TagEnd::Subscript + | TagEnd::MetadataBlock(_) => (), + } + + output + } +} + +// ========== Основная функция ========== pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String { let mut options = Options::empty(); options.insert(Options::ENABLE_TABLES); @@ -14,9 +304,7 @@ pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String { options.insert(Options::ENABLE_HEADING_ATTRIBUTES); options.insert(Options::ENABLE_GFM); - let mut in_code_block = false; - let mut code_content = String::new(); - + let mut renderer = MarkdownRenderer::new(); let mut html_output = String::new(); let iterator = TextMergeStream::new(Parser::new_ext(markdown, options)); @@ -25,21 +313,21 @@ pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String { match event { Event::Start(tag) => { if let Tag::CodeBlock(_) = tag { - in_code_block = true; - code_content.clear(); + renderer.in_code_block = true; + renderer.code_content.clear(); } - html_output.push_str(render_start_tag(tag).as_str()); + html_output.push_str(renderer.render_start_tag(tag).as_str()); } Event::End(tag) => { if let TagEnd::CodeBlock = tag { - in_code_block = false; - html_output.push_str(escape_html(&code_content).as_str()); + renderer.in_code_block = false; + html_output.push_str(escape_html(&renderer.code_content).as_str()); } - html_output.push_str(render_end_tag(tag).as_str()); + html_output.push_str(renderer.render_end_tag(tag).as_str()); } Event::Text(text) => { - if in_code_block { - code_content.push_str(&text); + if renderer.in_code_block { + renderer.code_content.push_str(&text); } else { html_output.push_str(&escape_html(&text)); } @@ -74,96 +362,7 @@ pub fn markdown_to_html(markdown: &str, _file_path: &str) -> String { html_output } -fn render_start_tag(tag: Tag<'_>) -> String { - let mut output = String::new(); - match tag { - Tag::Heading { level, .. } => { - output.push_str(format!("<{level}>").as_str()); - } - Tag::Strikethrough => { - output.push_str(""); - } - Tag::List(Some(_)) => output.push_str("

        "), - Tag::List(None) => output.push_str("
          "), - Tag::Item => output.push_str("
        • "), - Tag::Paragraph => output.push_str("

          "), - Tag::CodeBlock(kind) => output.push_str(render_codeblock(kind).as_str()), - Tag::Emphasis => output.push_str(""), - Tag::Strong => output.push_str(""), - Tag::BlockQuote(kind) => output.push_str(render_blockquote(kind).as_str()), - Tag::Link { - link_type, - dest_url, - title, - .. - } => output.push_str(render_link(link_type, &dest_url, &title).as_str()), - Tag::Image { - link_type, - dest_url, - title, - .. - } => output.push_str(render_image(link_type, &dest_url, &title).as_str()), - - Tag::Table(_) => output.push_str(""), - Tag::TableHead => output.push_str(""), - Tag::TableCell => output.push_str(""), - - Tag::FootnoteDefinition(fref) => { - let _ = write!(output, ""); - } - - Tag::HtmlBlock - | Tag::DefinitionList - | Tag::DefinitionListTitle - | Tag::DefinitionListDefinition - | Tag::Superscript - | Tag::Subscript - | Tag::MetadataBlock(_) => (), - } - - output -} - -fn render_end_tag(tag: TagEnd) -> String { - let mut output = String::new(); - match tag { - TagEnd::Heading(level) => { - output.push_str(format!("").as_str()); - } - TagEnd::Strikethrough => { - output.push_str(""); - } - TagEnd::List(true) => output.push_str(""), - TagEnd::List(false) => output.push_str(""), - TagEnd::Item => output.push_str(""), - TagEnd::Paragraph => output.push_str("

          "), - TagEnd::CodeBlock => output.push_str(""), - TagEnd::Emphasis => output.push_str(""), - TagEnd::Strong => output.push_str(""), - TagEnd::BlockQuote(_) => output.push_str(""), - TagEnd::Link => output.push_str("
          "), - TagEnd::Image => output.push_str(""), - - TagEnd::Table => output.push_str("
          "), - Tag::TableRow => output.push_str("
          "), - TagEnd::TableHead => output.push_str(""), - TagEnd::TableCell => output.push_str(""), - TagEnd::TableRow => output.push_str(""), - - TagEnd::FootnoteDefinition => output.push_str(""), - - TagEnd::HtmlBlock - | TagEnd::DefinitionList - | TagEnd::DefinitionListTitle - | TagEnd::DefinitionListDefinition - | TagEnd::Superscript - | TagEnd::Subscript - | TagEnd::MetadataBlock(_) => (), - } - - output -} - +// ========== Вспомогательные функции ========== fn render_codeblock(block_kind: CodeBlockKind<'_>) -> String { let header = String::from( r#" @@ -235,3 +434,79 @@ fn escape_html(text: &str) -> String { .replace('"', """) .replace('\'', "'") } + +// ========== Тесты ========== +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_table_with_alignments() { + let markdown = r#" +| Header 1 | Header 2 | Header 3 | +|----------|:--------:|---------:| +| Left | Center | Right | +| Data 1 | Data 2 | Data 3 | +"#; + + let html = markdown_to_html(markdown, ""); + + // Проверяем, что HTML содержит правильные классы и атрибуты + assert!(html.contains("")); + assert!(html.contains("")); + assert!(html.contains("")); + assert!(html.contains("text-left")); + assert!(html.contains("text-center")); + assert!(html.contains("text-right")); + } + + #[test] + fn test_table_state_tracking() { + let mut renderer = MarkdownRenderer::new(); + let alignments = vec![Alignment::Left, Alignment::Center, Alignment::Right]; + + renderer.render_start_tag(Tag::Table(alignments.clone())); + assert!(renderer.table_state.is_inside_table); + assert_eq!(renderer.table_state.column_count, 3); + + renderer.render_start_tag(Tag::TableHead); + assert!(renderer.table_state.is_inside_head); + + renderer.render_start_tag(Tag::TableRow); + renderer.render_start_tag(Tag::TableCell); + assert_eq!( + renderer.table_state.get_current_alignment(), + &AlignmentState::Left + ); + + renderer.render_end_tag(TagEnd::TableCell); + renderer.render_start_tag(Tag::TableCell); + assert_eq!( + renderer.table_state.get_current_alignment(), + &AlignmentState::Center + ); + + renderer.render_end_tag(TagEnd::TableCell); + renderer.render_start_tag(Tag::TableCell); + assert_eq!( + renderer.table_state.get_current_alignment(), + &AlignmentState::Right + ); + renderer.render_end_tag(TagEnd::TableCell); + renderer.render_end_tag(TagEnd::TableRow); + renderer.render_end_tag(TagEnd::TableHead); + + // Проверяем, что тело было автоматически открыто + renderer.render_start_tag(Tag::TableRow); + renderer.render_start_tag(Tag::TableCell); + assert_eq!( + renderer.table_state.get_current_alignment(), + &AlignmentState::Left + ); + renderer.render_end_tag(TagEnd::TableCell); + renderer.render_end_tag(TagEnd::TableRow); + renderer.render_end_tag(TagEnd::Table); + + assert!(!renderer.table_state.is_inside_table); + } +} diff --git a/templates/base.html b/templates/base.html index 69dffee..868e50a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,6 +16,7 @@ justify-content: center; line-height: 1.6; } + .content { max-width: 800px; width: 100%; @@ -24,12 +25,54 @@ border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); } - a { color: #bb86fc; text-decoration: none !important; } - h1, h2, h3, h4 { color: #ffffff; margin-top: 1.5em; margin-bottom: 0.5em; } - h1 { border-bottom: 1px solid #333; padding-bottom: 10px; } - table { border-collapse: collapse; width: 100%; margin: 1em 0; } - th, td { border: 1px solid #444; padding: 8px; text-align: left; } - th { background-color: #2c2c2c; } + + a { + color: #bb86fc; + text-decoration: none !important; + } + + h1, h2, h3, h4 { + color: #ffffff; + margin-top: 1.5em; + margin-bottom: 0.5em; + } + + h1 { + border-bottom: 1px solid #333; + padding-bottom: 10px; + } + + table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; + } + + th, td { + border: 1px solid #444; + padding: 8px; + } + + th, thead td { + font-weight: bold; + } + + .align-left { + text-align: left; + } + + .align-center { + text-align: center; + } + + .align-right { + text-align: right; + } + + .align-none { + text-align: default; + } + blockquote { border-left: 4px solid #bb86fc; margin: 1em 0; @@ -38,14 +81,24 @@ background: #252525; padding: 10px; } - code { font-family: 'Consolas', 'Monaco', monospace; } + + code { + font-family: 'Consolas', 'Monaco', monospace; + } + p > code, li > code { background-color: #2c2c2c; padding: 2px 6px; border-radius: 4px; color: #ff79c6; } - .header a { padding: 1em; color: #757575; font-size: 0.95em; } + + .header a { + padding: 1em; + color: #757575; + font-size: 0.95em; + } + .code-block-wrapper { margin: 1em 0; border: 1px solid #444; @@ -64,7 +117,9 @@ font-size: 0.85em; color: #a0a0a0; } + .code-lang { font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; } + .copy-btn { background: transparent; border: 1px solid #555; @@ -75,7 +130,9 @@ font-size: 0.8em; transition: all 0.2s; } + .copy-btn:hover { background-color: #444; color: #fff; border-color: #777; } + .copy-btn:active { transform: scale(0.95); } .copy-path-btn { @@ -86,16 +143,22 @@ font-size: 0.8em; transition: all 0.2s; } + .copy-path-btn:hover { background-color: #444; color: #fff; border-color: #777; } + .copy-path-btn:active { transform: scale(0.95); } + pre { padding: 15px; overflow-x: auto; margin: 0; } + pre code { background: transparent; padding: 0; color: inherit; } + .mermaid-wrapper .mermaid { background-color: #f9f9f9; border-radius: 0 0 6px 6px; display: flex; justify-content: center; } + .footer { margin-top: 30px; color: #666; @@ -103,7 +166,9 @@ border-top: 1px solid #333; padding-top: 15px; } + .footer a { padding: 1em; color: #757575; } + .back-link { display: inline-flex; align-items: center; @@ -112,41 +177,32 @@ font-size: 0.95em; transition: color 0.2s; } + .back-link span { margin-right: 8px; font-size: 1.2em; } + img { - max-width: 100%; - height: auto; - display: block; - margin: 1.5em auto; - border-radius: 6px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); + max-width: 100%; + height: auto; + display: block; + margin: 1.5em auto; + border-radius: 6px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); } - ul { - list-style-type: disc; - padding: 0; - margin: 0; - padding-left: 1.5em; - } - ol { - padding-left: 1.5em; - list-style-type: decimal; - } - li { - padding: .5em 0; - } - ul > li::marker, ol > li::marker { display: inline-block; } + .file-link { color: #64b5f6; font-size: 1.1em; display: flex; align-items: center; } + .file-link:hover { - color: #90caf9; + color: #90caf9; } + .icon { - margin-right: 10px; - min-width: 24px; + margin-right: 10px; + min-width: 24px; }