html-to-markdown-rs 3.6.21

High-performance HTML to Markdown converter using the astral-tl parser. Part of the Kreuzberg ecosystem.
Documentation
#![allow(missing_docs)]
fn convert(
    html: &str,
    opts: Option<html_to_markdown_rs::ConversionOptions>,
) -> html_to_markdown_rs::error::Result<String> {
    html_to_markdown_rs::convert(html, opts).map(|r| r.content.unwrap_or_default())
}

use html_to_markdown_rs::{ConversionOptions, OutputFormat};

fn plain_options() -> ConversionOptions {
    ConversionOptions {
        output_format: OutputFormat::Plain,
        ..Default::default()
    }
}

#[test]
fn test_plain_basic_paragraph() {
    let html = "<p>Hello world</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "Hello world\n");
}

#[test]
fn test_plain_no_strong_markers() {
    let html = "<p>This is <strong>bold</strong> text</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "This is bold text\n");
}

#[test]
fn test_plain_no_emphasis_markers() {
    let html = "<p>This is <em>italic</em> text</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "This is italic text\n");
}

#[test]
fn test_plain_link_text_only() {
    let html = r#"<p>Visit <a href="https://example.com">our site</a> today</p>"#;
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "Visit our site today\n");
}

#[test]
fn test_plain_image_alt_text() {
    let html = r#"<img alt="A cute cat">"#;
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "A cute cat\n");
}

#[test]
fn test_plain_image_skipped_when_option_set() {
    let html = r#"<img alt="A cute cat">"#;
    let mut opts = plain_options();
    opts.skip_images = true;
    let result = convert(html, Some(opts)).unwrap();
    assert_eq!(result, "");
}

#[test]
fn test_plain_code_block() {
    let html = "<pre><code>fn main() {}</code></pre>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "fn main() {}\n");
}

#[test]
fn test_plain_blockquote_no_prefix() {
    let html = "<blockquote><p>Quoted text</p></blockquote>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        !result.contains('>'),
        "Plain text should not contain blockquote prefix, got: {result}"
    );
    assert!(result.contains("Quoted text"));
}

#[test]
fn test_plain_list_items_on_separate_lines() {
    let html = "<ul><li>First</li><li>Second</li><li>Third</li></ul>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "- First\n- Second\n- Third\n");
}

#[test]
fn test_plain_table_cells_extracted() {
    let html = "<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(result.contains('A'));
    assert!(result.contains('B'));
    assert!(result.contains('C'));
    assert!(result.contains('D'));
}

#[test]
fn test_plain_no_escaping() {
    let html = "<p>* not a list</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        result.contains("* not a list"),
        "Plain text should not escape asterisks, got: {result}"
    );
    assert!(
        !result.contains("\\*"),
        "Plain text should not backslash-escape, got: {result}"
    );
}

#[test]
fn test_plain_script_excluded() {
    let html = "<p>Before</p><script>alert('xss')</script><p>After</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        !result.contains("alert"),
        "Script content should be excluded, got: {result}"
    );
    assert!(result.contains("Before"));
    assert!(result.contains("After"));
}

#[test]
fn test_plain_style_excluded() {
    let html = "<p>Hello</p><style>.foo { color: red; }</style>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        !result.contains("color"),
        "Style content should be excluded, got: {result}"
    );
    assert!(result.contains("Hello"));
}

#[test]
fn test_plain_br_becomes_newline() {
    let html = "<p>Line one<br>Line two</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        result.contains("Line one\nLine two"),
        "Expected newline from <br>, got: {result}"
    );
}

#[test]
fn test_plain_hr_becomes_blank_line() {
    let html = "<p>Above</p><hr><p>Below</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(result.contains("Above"));
    assert!(result.contains("Below"));
    // Should have blank line between
    assert!(result.contains("\n\n"), "Expected blank line from <hr>, got: {result}");
}

#[test]
fn test_plain_nested_inline_formatting_stripped() {
    let html = "<p>Start <strong>bold <em>and italic</em></strong> end</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "Start bold and italic end\n");
}

#[test]
fn test_plain_heading_no_markers() {
    let html = "<h1>Title</h1><p>Content</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        !result.contains('#'),
        "Plain text should not contain heading markers, got: {result}"
    );
    assert!(result.contains("Title"));
    assert!(result.contains("Content"));
}

/// Regression for <https://github.com/kreuzberg-dev/html-to-markdown/issues/362> β€” the byte-oriented
/// post-processor mangled multibyte UTF-8 sequences (e.g. en-dash U+2013) whenever they followed
/// an inline element. Lock the four-case matrix from the issue body so a future rewrite cannot
/// silently regress.
#[test]
fn test_plain_utf8_after_inline_markup_preserved() {
    let cases = [
        ("abc \u{2013} def", "abc \u{2013} def\n"),
        ("<i>abc</i> \u{2013} def", "abc \u{2013} def\n"),
        ("plain \u{2014} text", "plain \u{2014} text\n"),
        ("<strong>bold</strong> \u{20ac}1.00", "bold \u{20ac}1.00\n"),
    ];
    for (input, expected) in cases {
        let result = convert(input, Some(plain_options())).unwrap();
        assert_eq!(result, expected, "for input: {input:?}");
    }
}

#[test]
fn test_plain_parse_variants() {
    assert_eq!(OutputFormat::parse("plain"), OutputFormat::Plain);
    assert_eq!(OutputFormat::parse("plaintext"), OutputFormat::Plain);
    assert_eq!(OutputFormat::parse("text"), OutputFormat::Plain);
    assert_eq!(OutputFormat::parse("Plain"), OutputFormat::Plain);
    assert_eq!(OutputFormat::parse("PLAINTEXT"), OutputFormat::Plain);
}

#[test]
fn test_plain_empty_input() {
    let html = "";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "");
}

#[test]
fn test_plain_whitespace_only_html() {
    let html = "<p>   </p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "");
}

#[test]
fn test_plain_inline_code_no_backticks() {
    let html = "<p>Use <code>fmt.Println</code> to print</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        !result.contains('`'),
        "Plain text should not contain backticks, got: {result}"
    );
    assert!(result.contains("fmt.Println"));
}

#[test]
fn test_plain_pre_preserves_whitespace() {
    let html = "<pre>  indented\n    more</pre>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        result.contains("  indented\n    more"),
        "Pre blocks should preserve whitespace, got: {result}"
    );
}

#[test]
fn test_plain_unordered_list_markers() {
    let html = "<ul><li>Alpha</li><li>Beta</li><li>Gamma</li></ul>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "- Alpha\n- Beta\n- Gamma\n");
}

#[test]
fn test_plain_ordered_list_markers() {
    let html = "<ol><li>First</li><li>Second</li><li>Third</li></ol>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "1. First\n2. Second\n3. Third\n");
}

#[test]
fn test_plain_ordered_list_custom_start() {
    let html = r#"<ol start="42"><li>First item starting at 42</li><li>Second item</li></ol>"#;
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "42. First item starting at 42\n43. Second item\n");
}

#[test]
fn test_plain_nested_lists() {
    let html = "<ul><li>Outer 1<ul><li>Inner A</li><li>Inner B</li></ul></li><li>Outer 2</li></ul>";
    let result = convert(html, Some(plain_options())).unwrap();
    // The outer items should have `- ` prefix and inner items should also have `- ` prefix
    assert!(
        result.contains("- Outer 1"),
        "Expected '- Outer 1' in output, got: {result}"
    );
    assert!(
        result.contains("- Inner A"),
        "Expected '- Inner A' in output, got: {result}"
    );
    assert!(
        result.contains("- Inner B"),
        "Expected '- Inner B' in output, got: {result}"
    );
    assert!(
        result.contains("- Outer 2"),
        "Expected '- Outer 2' in output, got: {result}"
    );
}

#[test]
fn test_plain_ordered_list_inside_unordered() {
    let html = "<ul><li>Bullet<ol><li>Numbered</li></ol></li></ul>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert!(
        result.contains("- Bullet"),
        "Expected '- Bullet' in output, got: {result}"
    );
    assert!(
        result.contains("1. Numbered"),
        "Expected '1. Numbered' in output, got: {result}"
    );
}

// Regression tests for issue #362 β€” UTF-8 mangling in plain-text post-process.

#[test]
fn test_plain_preserves_em_dash_after_markup() {
    // Exact repro from issue #362: bytes-cast path split the 3-byte en-dash (U+2013)
    // into three Latin-1 chars, producing "Γ’<ctrl><ctrl>" corruption.
    let html = "<i>abc</i> \u{2013} def";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "abc \u{2013} def\n");
}

#[test]
fn test_plain_preserves_cjk_emoji_mixed() {
    // Exercises every multibyte class that the old bytes-cast path could corrupt:
    // en-dash (U+2013, 3 bytes), CJK (U+4E16 δΈ–, 3 bytes), emoji (U+1F980 πŸ¦€, 4 bytes).
    let html = "<p>Hello <i>world</i> \u{2013} <strong>δΈ–η•Œ</strong> loves πŸ¦€</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "Hello world \u{2013} δΈ–η•Œ loves πŸ¦€\n");
}

#[test]
fn test_plain_collapses_trim_induced_newlines() {
    // Whitespace-only lines between paragraphs must collapse to a single blank line after
    // trailing-whitespace trim, not produce 3+ newlines that survive post-processing.
    let html = "<p>x</p>   \n   \n   \n<p>y</p>";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "x\n\ny\n");
}

#[test]
fn test_plain_empty_whitespace_input() {
    // All-whitespace content should produce an empty string, not a bare newline.
    let html = "<p>   </p>\n\n\n";
    let result = convert(html, Some(plain_options())).unwrap();
    assert_eq!(result, "");
}