diff --git a/crates/quarto-markdown-pandoc/src/main.rs b/crates/quarto-markdown-pandoc/src/main.rs index e86aba8..d46622c 100644 --- a/crates/quarto-markdown-pandoc/src/main.rs +++ b/crates/quarto-markdown-pandoc/src/main.rs @@ -160,6 +160,7 @@ fn main() { "json" => writers::json::write(&pandoc, &context, &mut buf), "native" => writers::native::write(&pandoc, &mut buf), "markdown" | "qmd" => writers::qmd::write(&pandoc, &mut buf), + "html" => writers::html::write(&pandoc, &mut buf), _ => { eprintln!("Unknown output format: {}", args.to); return; diff --git a/crates/quarto-markdown-pandoc/src/writers/html.rs b/crates/quarto-markdown-pandoc/src/writers/html.rs new file mode 100644 index 0000000..f157d82 --- /dev/null +++ b/crates/quarto-markdown-pandoc/src/writers/html.rs @@ -0,0 +1,539 @@ +/* + * html.rs + * Copyright (c) 2025 Posit, PBC + */ + +use crate::pandoc::{Attr, Block, CitationMode, Inline, Inlines, Pandoc}; + +/// Escape HTML special characters +fn escape_html(s: &str) -> String { + s.chars() + .map(|c| match c { + '&' => "&".to_string(), + '<' => "<".to_string(), + '>' => ">".to_string(), + '"' => """.to_string(), + '\'' => "'".to_string(), + _ => c.to_string(), + }) + .collect() +} + +/// Write HTML attributes (id, classes, key-value pairs) +fn write_attr(attr: &Attr, buf: &mut T) -> std::io::Result<()> { + let (id, classes, attrs) = attr; + + if !id.is_empty() { + write!(buf, " id=\"{}\"", escape_html(id))?; + } + + if !classes.is_empty() { + write!(buf, " class=\"{}\"", escape_html(&classes.join(" ")))?; + } + + // Pandoc prefixes custom attributes with "data-" + for (k, v) in attrs { + write!(buf, " data-{}=\"{}\"", escape_html(k), escape_html(v))?; + } + + Ok(()) +} + +/// Write inline elements +fn write_inline(inline: &Inline, buf: &mut T) -> std::io::Result<()> { + match inline { + Inline::Str(s) => { + write!(buf, "{}", escape_html(&s.text))?; + } + Inline::Space(_) => { + write!(buf, " ")?; + } + Inline::SoftBreak(_) => { + write!(buf, "\n")?; + } + Inline::LineBreak(_) => { + write!(buf, "
")?; + } + Inline::Emph(e) => { + write!(buf, "")?; + write_inlines(&e.content, buf)?; + write!(buf, "")?; + } + Inline::Strong(s) => { + write!(buf, "")?; + write_inlines(&s.content, buf)?; + write!(buf, "")?; + } + Inline::Underline(u) => { + write!(buf, "")?; + write_inlines(&u.content, buf)?; + write!(buf, "")?; + } + Inline::Strikeout(s) => { + write!(buf, "")?; + write_inlines(&s.content, buf)?; + write!(buf, "")?; + } + Inline::Superscript(s) => { + write!(buf, "")?; + write_inlines(&s.content, buf)?; + write!(buf, "")?; + } + Inline::Subscript(s) => { + write!(buf, "")?; + write_inlines(&s.content, buf)?; + write!(buf, "")?; + } + Inline::SmallCaps(s) => { + write!(buf, "")?; + write_inlines(&s.content, buf)?; + write!(buf, "")?; + } + Inline::Quoted(q) => { + let (open, close) = match q.quote_type { + crate::pandoc::QuoteType::SingleQuote => ("\u{2018}", "\u{2019}"), + crate::pandoc::QuoteType::DoubleQuote => ("\u{201C}", "\u{201D}"), + }; + write!(buf, "{}", open)?; + write_inlines(&q.content, buf)?; + write!(buf, "{}", close)?; + } + Inline::Code(c) => { + write!(buf, "{}", escape_html(&c.text))?; + } + Inline::Math(m) => { + let class = match m.math_type { + crate::pandoc::MathType::InlineMath => "math inline", + crate::pandoc::MathType::DisplayMath => "math display", + }; + // Use \(...\) for inline math and \[...\] for display math + let (open, close) = match m.math_type { + crate::pandoc::MathType::InlineMath => ("\\(", "\\)"), + crate::pandoc::MathType::DisplayMath => ("\\[", "\\]"), + }; + write!( + buf, + "{}{}{}", + class, + open, + escape_html(&m.text), + close + )?; + } + Inline::Link(link) => { + write!(buf, "")?; + write_inlines(&link.content, buf)?; + write!(buf, "")?; + } + Inline::Image(image) => { + write!(buf, "\"")?;")?; + } + Inline::RawInline(raw) => { + // Only output raw HTML if format is "html" + if raw.format == "html" { + write!(buf, "{}", raw.text)?; + } + } + Inline::Span(span) => { + write!(buf, "")?; + write_inlines(&span.content, buf)?; + write!(buf, "")?; + } + Inline::Note(note) => { + // Footnotes are rendered as superscript with a link + write!( + buf, + "", + note.content.len() + )?; + write!(buf, "[{}]", note.content.len())?; + write!(buf, "")?; + // Note: Proper footnote handling would require collecting all footnotes + // and rendering them at the end of the document + } + Inline::Cite(cite) => { + // Collect all citation IDs for data-cites attribute + let cite_ids: Vec = cite.citations.iter().map(|c| c.id.clone()).collect(); + let data_cites = cite_ids.join(" "); + + write!( + buf, + "", + escape_html(&data_cites) + )?; + + // Pandoc outputs citation content if present, otherwise builds citation text + if !cite.content.is_empty() { + write_inlines(&cite.content, buf)?; + } else { + for (i, citation) in cite.citations.iter().enumerate() { + if i > 0 { + write!(buf, "; ")?; + } + write_inlines(&citation.prefix, buf)?; + if !citation.prefix.is_empty() { + write!(buf, " ")?; + } + match citation.mode { + CitationMode::AuthorInText => write!(buf, "{}", escape_html(&citation.id))?, + CitationMode::SuppressAuthor => { + write!(buf, "-@{}", escape_html(&citation.id))? + } + CitationMode::NormalCitation => { + write!(buf, "@{}", escape_html(&citation.id))? + } + } + if !citation.suffix.is_empty() { + write!(buf, " ")?; + } + write_inlines(&citation.suffix, buf)?; + } + } + write!(buf, "")?; + } + // Quarto extensions - render as raw HTML or skip + Inline::Shortcode(_) | Inline::NoteReference(_) | Inline::Attr(_) => { + // These should not appear in final output + } + Inline::Insert(ins) => { + write!(buf, "")?; + write_inlines(&ins.content, buf)?; + write!(buf, "")?; + } + Inline::Delete(del) => { + write!(buf, "")?; + write_inlines(&del.content, buf)?; + write!(buf, "")?; + } + Inline::Highlight(h) => { + write!(buf, "")?; + write_inlines(&h.content, buf)?; + write!(buf, "")?; + } + Inline::EditComment(c) => { + write!(buf, "")?; + write_inlines(&c.content, buf)?; + write!(buf, "")?; + } + } + Ok(()) +} + +/// Write a sequence of inlines +fn write_inlines(inlines: &Inlines, buf: &mut T) -> std::io::Result<()> { + for inline in inlines { + write_inline(inline, buf)?; + } + Ok(()) +} + +/// Write inlines as plain text (for alt attributes, etc.) +fn write_inlines_as_text(inlines: &Inlines, buf: &mut T) -> std::io::Result<()> { + for inline in inlines { + match inline { + Inline::Str(s) => write!(buf, "{}", escape_html(&s.text))?, + Inline::Space(_) => write!(buf, " ")?, + Inline::SoftBreak(_) | Inline::LineBreak(_) => write!(buf, " ")?, + Inline::Emph(e) => write_inlines_as_text(&e.content, buf)?, + Inline::Strong(s) => write_inlines_as_text(&s.content, buf)?, + Inline::Underline(u) => write_inlines_as_text(&u.content, buf)?, + Inline::Strikeout(s) => write_inlines_as_text(&s.content, buf)?, + Inline::Superscript(s) => write_inlines_as_text(&s.content, buf)?, + Inline::Subscript(s) => write_inlines_as_text(&s.content, buf)?, + Inline::SmallCaps(s) => write_inlines_as_text(&s.content, buf)?, + Inline::Span(span) => write_inlines_as_text(&span.content, buf)?, + Inline::Quoted(q) => write_inlines_as_text(&q.content, buf)?, + Inline::Code(c) => write!(buf, "{}", escape_html(&c.text))?, + Inline::Link(link) => write_inlines_as_text(&link.content, buf)?, + Inline::Image(image) => write_inlines_as_text(&image.content, buf)?, + _ => {} + } + } + Ok(()) +} + +/// Write block elements +fn write_block(block: &Block, buf: &mut T) -> std::io::Result<()> { + match block { + Block::Plain(plain) => { + write_inlines(&plain.content, buf)?; + writeln!(buf)?; + } + Block::Paragraph(para) => { + write!(buf, "

")?; + write_inlines(¶.content, buf)?; + writeln!(buf, "

")?; + } + Block::LineBlock(lineblock) => { + writeln!(buf, "
")?; + for line in &lineblock.content { + write!(buf, " ")?; + write_inlines(line, buf)?; + writeln!(buf, "
")?; + } + writeln!(buf, "
")?; + } + Block::CodeBlock(codeblock) => { + write!(buf, "")?; + write!(buf, "{}", escape_html(&codeblock.text))?; + writeln!(buf, "")?; + } + Block::RawBlock(raw) => { + // Only output raw HTML if format is "html" + if raw.format == "html" { + writeln!(buf, "{}", raw.text)?; + } + } + Block::BlockQuote(quote) => { + writeln!(buf, "
")?; + write_blocks("e.content, buf)?; + writeln!(buf, "
")?; + } + Block::OrderedList(list) => { + let (start, style, _delim) = &list.attr; + write!(buf, " "1", + crate::pandoc::ListNumberStyle::LowerAlpha => "a", + crate::pandoc::ListNumberStyle::UpperAlpha => "A", + crate::pandoc::ListNumberStyle::LowerRoman => "i", + crate::pandoc::ListNumberStyle::UpperRoman => "I", + _ => "1", + }; + write!(buf, " type=\"{}\"", list_type)?; + writeln!(buf, ">")?; + for item in &list.content { + write!(buf, "
  • ")?; + write_blocks_inline(item, buf)?; + writeln!(buf, "
  • ")?; + } + writeln!(buf, "")?; + } + Block::BulletList(list) => { + writeln!(buf, "
      ")?; + for item in &list.content { + write!(buf, "
    • ")?; + write_blocks_inline(item, buf)?; + writeln!(buf, "
    • ")?; + } + writeln!(buf, "
    ")?; + } + Block::DefinitionList(deflist) => { + writeln!(buf, "
    ")?; + for (term, definitions) in &deflist.content { + write!(buf, "
    ")?; + write_inlines(term, buf)?; + writeln!(buf, "
    ")?; + for def_blocks in definitions { + writeln!(buf, "
    ")?; + write_blocks(def_blocks, buf)?; + writeln!(buf, "
    ")?; + } + } + writeln!(buf, "
    ")?; + } + Block::Header(header) => { + write!(buf, "")?; + write_inlines(&header.content, buf)?; + writeln!(buf, "", header.level)?; + } + Block::HorizontalRule(_) => { + writeln!(buf, "
    ")?; + } + Block::Table(table) => { + write!(buf, "")?; + + // Caption (if any) + if let Some(ref long_caption) = table.caption.long { + if !long_caption.is_empty() { + writeln!(buf, "")?; + write_blocks(long_caption, buf)?; + writeln!(buf, "")?; + } + } + + // Column group (for alignment) + if !table.colspec.is_empty() { + writeln!(buf, "")?; + for colspec in &table.colspec { + let align = match colspec.0 { + crate::pandoc::table::Alignment::Left => " align=\"left\"", + crate::pandoc::table::Alignment::Right => " align=\"right\"", + crate::pandoc::table::Alignment::Center => " align=\"center\"", + crate::pandoc::table::Alignment::Default => "", + }; + writeln!(buf, "", align)?; + } + writeln!(buf, "")?; + } + + // Head + if !table.head.rows.is_empty() { + writeln!(buf, "")?; + for row in &table.head.rows { + write_table_row(row, buf, true)?; + } + writeln!(buf, "")?; + } + + // Bodies + for body in &table.bodies { + writeln!(buf, "")?; + for row in &body.body { + write_table_row(row, buf, false)?; + } + writeln!(buf, "")?; + } + + // Foot + if !table.foot.rows.is_empty() { + writeln!(buf, "")?; + for row in &table.foot.rows { + write_table_row(row, buf, false)?; + } + writeln!(buf, "")?; + } + + writeln!(buf, "")?; + } + Block::Figure(figure) => { + write!(buf, "")?; + write_blocks(&figure.content, buf)?; + if let Some(ref long_caption) = figure.caption.long { + if !long_caption.is_empty() { + writeln!(buf, "
    ")?; + write_blocks(long_caption, buf)?; + writeln!(buf, "
    ")?; + } + } + writeln!(buf, "")?; + } + Block::Div(div) => { + write!(buf, "")?; + write_blocks(&div.content, buf)?; + writeln!(buf, "")?; + } + // Quarto extensions + Block::BlockMetadata(_) => { + // Metadata blocks don't render to HTML + } + Block::NoteDefinitionPara(note) => { + // Note definitions would typically be collected and rendered as footnotes + write!( + buf, + "
    [{}] ", + note.id, note.id + )?; + write_inlines(¬e.content, buf)?; + writeln!(buf, "
    ")?; + } + Block::NoteDefinitionFencedBlock(note) => { + writeln!( + buf, + "
    [{}]", + note.id, note.id + )?; + write_blocks(¬e.content, buf)?; + writeln!(buf, "
    ")?; + } + } + Ok(()) +} + +/// Write a table row +fn write_table_row( + row: &crate::pandoc::table::Row, + buf: &mut T, + is_header: bool, +) -> std::io::Result<()> { + writeln!(buf, "")?; + for cell in &row.cells { + let tag = if is_header { "th" } else { "td" }; + write!(buf, "<{}", tag)?; + write_attr(&cell.attr, buf)?; + + if cell.row_span > 1 { + write!(buf, " rowspan=\"{}\"", cell.row_span)?; + } + if cell.col_span > 1 { + write!(buf, " colspan=\"{}\"", cell.col_span)?; + } + + let align = match cell.alignment { + crate::pandoc::table::Alignment::Left => " align=\"left\"", + crate::pandoc::table::Alignment::Right => " align=\"right\"", + crate::pandoc::table::Alignment::Center => " align=\"center\"", + crate::pandoc::table::Alignment::Default => "", + }; + write!(buf, "{}>", align)?; + + write_blocks(&cell.content, buf)?; + writeln!(buf, "", tag)?; + } + writeln!(buf, "")?; + Ok(()) +} + +/// Write a sequence of blocks +fn write_blocks(blocks: &[Block], buf: &mut T) -> std::io::Result<()> { + for block in blocks { + write_block(block, buf)?; + } + Ok(()) +} + +/// Write blocks inline (for list items) - strips paragraph tags for simple cases +fn write_blocks_inline(blocks: &[Block], buf: &mut T) -> std::io::Result<()> { + // For simple list items with just a single paragraph, write the content inline + if blocks.len() == 1 { + if let Block::Paragraph(para) = &blocks[0] { + write_inlines(¶.content, buf)?; + return Ok(()); + } else if let Block::Plain(plain) = &blocks[0] { + write_inlines(&plain.content, buf)?; + return Ok(()); + } + } + + // For complex list items, write blocks normally + write_blocks(blocks, buf)?; + Ok(()) +} + +/// Main entry point for the HTML writer +pub fn write(pandoc: &Pandoc, buf: &mut T) -> std::io::Result<()> { + write_blocks(&pandoc.blocks, buf)?; + Ok(()) +} diff --git a/crates/quarto-markdown-pandoc/src/writers/mod.rs b/crates/quarto-markdown-pandoc/src/writers/mod.rs index 437d61d..586f78a 100644 --- a/crates/quarto-markdown-pandoc/src/writers/mod.rs +++ b/crates/quarto-markdown-pandoc/src/writers/mod.rs @@ -3,6 +3,7 @@ * Copyright (c) 2025 Posit, PBC */ +pub mod html; pub mod json; pub mod native; pub mod qmd; diff --git a/crates/quarto-markdown-pandoc/tests/test.rs b/crates/quarto-markdown-pandoc/tests/test.rs index 73999df..cc3f998 100644 --- a/crates/quarto-markdown-pandoc/tests/test.rs +++ b/crates/quarto-markdown-pandoc/tests/test.rs @@ -398,6 +398,89 @@ fn test_json_writer() { ); } +/// Normalize HTML for comparison by removing extra whitespace +fn normalize_html(html: &str) -> String { + // First, join all lines with spaces to handle attributes split across lines + let single_line = html + .lines() + .map(|line| line.trim()) + .collect::>() + .join(" "); + + // Then split by > to preserve tag boundaries + single_line + .split('>') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect::>() + .join(">\n") +} + +#[test] +fn test_html_writer() { + assert!( + has_good_pandoc_version(), + "Pandoc version is not suitable for testing" + ); + let mut file_count = 0; + for entry in glob("tests/writers/html/*.md").expect("Failed to read glob pattern") { + match entry { + Ok(path) => { + eprintln!("Opening file: {}", path.display()); + let markdown = std::fs::read_to_string(&path).expect("Failed to read file"); + + // Parse with our parser + let mut parser = MarkdownParser::default(); + let input_bytes = markdown.as_bytes(); + let tree = parser + .parse(input_bytes, None) + .expect("Failed to parse input"); + let pandoc = treesitter_to_pandoc( + &mut std::io::sink(), + &tree, + input_bytes, + &ASTContext::anonymous(), + ) + .unwrap(); + let mut buf = Vec::new(); + writers::html::write(&pandoc, &mut buf).unwrap(); + let our_html = String::from_utf8(buf).expect("Invalid UTF-8 in our HTML output"); + + // Get Pandoc's output + let output = Command::new("pandoc") + .arg("-t") + .arg("html") + .arg("-f") + .arg("markdown") + .arg(&path) + .output() + .expect("Failed to execute pandoc"); + + let pandoc_html = String::from_utf8(output.stdout).expect("Invalid UTF-8"); + + // Normalize both HTML outputs for comparison + let our_normalized = normalize_html(&our_html); + let pandoc_normalized = normalize_html(&pandoc_html); + + assert_eq!( + our_normalized, + pandoc_normalized, + "HTML outputs don't match for file {}.\n\nOurs:\n{}\n\nPandoc's:\n{}", + path.display(), + our_html, + pandoc_html + ); + file_count += 1; + } + Err(e) => panic!("Error reading glob entry: {}", e), + } + } + assert!( + file_count > 0, + "No files found in tests/writers/html directory" + ); +} + fn ensure_file_does_not_parse(path: &std::path::Path) { let markdown = std::fs::read_to_string(path).expect("Failed to read file"); let mut parser = MarkdownParser::default(); diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/bulletlist.md b/crates/quarto-markdown-pandoc/tests/writers/html/bulletlist.md new file mode 100644 index 0000000..409248a --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/bulletlist.md @@ -0,0 +1,3 @@ +- Item one +- Item two +- Item three diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/cite.md b/crates/quarto-markdown-pandoc/tests/writers/html/cite.md new file mode 100644 index 0000000..87958b4 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/cite.md @@ -0,0 +1 @@ +This is a pandoc @citation. \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/code.md b/crates/quarto-markdown-pandoc/tests/writers/html/code.md new file mode 100644 index 0000000..3b5c5a0 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/code.md @@ -0,0 +1 @@ +This is `inline code` text \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/codeblock.md b/crates/quarto-markdown-pandoc/tests/writers/html/codeblock.md new file mode 100644 index 0000000..4c4c26c --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/codeblock.md @@ -0,0 +1,5 @@ +``` +function hello() { + return "world" +} +``` diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/emphasis.md b/crates/quarto-markdown-pandoc/tests/writers/html/emphasis.md new file mode 100644 index 0000000..a0bc584 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/emphasis.md @@ -0,0 +1 @@ +This is *emphasized* text \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/header.md b/crates/quarto-markdown-pandoc/tests/writers/html/header.md new file mode 100644 index 0000000..99debb9 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/header.md @@ -0,0 +1 @@ +# Main Header \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/linebreak.md b/crates/quarto-markdown-pandoc/tests/writers/html/linebreak.md new file mode 100644 index 0000000..8b82405 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/linebreak.md @@ -0,0 +1,2 @@ +Line one +Line two diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/link.md b/crates/quarto-markdown-pandoc/tests/writers/html/link.md new file mode 100644 index 0000000..a4a17c2 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/link.md @@ -0,0 +1 @@ +[a link](./hello "out"){#fig .class key=value} diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/paragraph.md b/crates/quarto-markdown-pandoc/tests/writers/html/paragraph.md new file mode 100644 index 0000000..45baa24 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/paragraph.md @@ -0,0 +1 @@ +This is a simple paragraph \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/quoted.md b/crates/quarto-markdown-pandoc/tests/writers/html/quoted.md new file mode 100644 index 0000000..c9bbce0 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/quoted.md @@ -0,0 +1 @@ +'single quote' "double quote" diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/span.md b/crates/quarto-markdown-pandoc/tests/writers/html/span.md new file mode 100644 index 0000000..1cea50b --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/span.md @@ -0,0 +1 @@ +a [span]{#with-id .class key=value} \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/strikeout.md b/crates/quarto-markdown-pandoc/tests/writers/html/strikeout.md new file mode 100644 index 0000000..4f5a22a --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/strikeout.md @@ -0,0 +1 @@ +~~strikeout~~ diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/strong.md b/crates/quarto-markdown-pandoc/tests/writers/html/strong.md new file mode 100644 index 0000000..8b2cb8d --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/strong.md @@ -0,0 +1 @@ +This is **strong** text \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/superscript.md b/crates/quarto-markdown-pandoc/tests/writers/html/superscript.md new file mode 100644 index 0000000..07fe89c --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/superscript.md @@ -0,0 +1 @@ +^superscript^ diff --git a/crates/quarto-markdown-pandoc/tests/writers/html/underline.md b/crates/quarto-markdown-pandoc/tests/writers/html/underline.md new file mode 100644 index 0000000..a292fc1 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/html/underline.md @@ -0,0 +1 @@ +[underlined]{.underline} [underline]{.ul}