Skip to main content

html2md_rs/
parser.rs

1//! Parses a pragmatic HTML subset into a custom [`Node`] tree; this is not an HTML5 parser.
2//!
3//! Tags are never implicitly closed. When input ends with open tags, remaining stack entries are
4//! returned as top-level nodes. Whitespace-only ordinary text nodes are omitted; raw-text element
5//! bodies are preserved.
6//! Supported element names map to their `NodeType` variants; all others become `NodeType::Unknown`.
7//! Closing-tag names are not matched: any closing tag closes the most recently opened node. A
8//! single top-level node is returned directly; zero or multiple top-level nodes are wrapped in a
9//! synthetic [`Node`] whose `tag_name` is `None`.
10//!
11//! [`safe_parse_html`] returns a structured error for malformed input.
12
13use crate::structs::{
14    AttributeValues, Attributes, Node,
15    NodeType::{self, *},
16};
17use std::fmt::Display;
18
19/// Errors reported for malformed HTML tags.
20///
21/// Each offset is an approximate UTF-8 byte offset from the start of the input.
22#[derive(Debug, PartialEq, Eq)]
23pub enum MalformedTagError {
24    /// The closing bracket of the tag is missing
25    MissingClosingBracket(u32),
26    /// The tag name is missing
27    MissingTagName(u32),
28}
29
30/// Errors reported for malformed HTML attributes.
31///
32/// Each offset is an approximate UTF-8 byte offset from the start of the input.
33#[derive(Debug, PartialEq, Eq)]
34pub enum MalformedAttributeError {
35    /// The quotation mark of the attribute is missing
36    MissingQuotationMark(u32),
37    /// The attribute name is missing
38    MissingAttributeName(u32),
39    /// The attribute value is missing
40    MissingAttributeValue(u32),
41}
42
43/// Errors that can occur while parsing HTML.
44///
45/// Each variant contains the malformed source fragment followed by its specific error.
46#[derive(Debug, PartialEq, Eq)]
47pub enum ParseHTMLError {
48    /// The tag is malformed
49    MalformedTag(String, MalformedTagError),
50    /// The attribute is malformed
51    MalformedAttribute(String, MalformedAttributeError),
52}
53
54impl Display for ParseHTMLError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            ParseHTMLError::MalformedTag(tag, error) => match error {
58                MalformedTagError::MissingClosingBracket(index) => {
59                    write!(
60                        f,
61                        "Malformed tag: {} - Missing closing bracket at around index {}",
62                        tag, index
63                    )
64                }
65                MalformedTagError::MissingTagName(index) => {
66                    write!(
67                        f,
68                        "Malformed tag: {} - Missing tag name at around index {}",
69                        tag, index
70                    )
71                }
72            },
73            ParseHTMLError::MalformedAttribute(attr, error) => match error {
74                MalformedAttributeError::MissingQuotationMark(index) => {
75                    write!(
76                        f,
77                        "Malformed attribute: {} - Missing quotation mark at around index {}",
78                        attr, index
79                    )
80                }
81                MalformedAttributeError::MissingAttributeName(index) => {
82                    write!(
83                        f,
84                        "Malformed attribute: {} - Missing attribute name at around index {}",
85                        attr, index
86                    )
87                }
88                MalformedAttributeError::MissingAttributeValue(index) => {
89                    write!(
90                        f,
91                        "Malformed attribute: {} - Missing attribute value at around index {}",
92                        attr, index
93                    )
94                }
95            },
96        }
97    }
98}
99
100/// Consumes an owned HTML string and returns a [`Node`] tree.
101///
102/// # Arguments
103///
104/// * `input` - HTML source to parse.
105///
106/// # Errors
107///
108/// Returns [`ParseHTMLError`] for malformed tags or attributes recognized by this parser. See the
109/// [module documentation](self) for accepted subset and root-shape rules.
110///
111/// # Examples
112///
113/// ```
114/// use html2md_rs::{
115///     parser::safe_parse_html,
116///     structs::{
117///         Node,
118///         NodeType::{Div, Text},
119///     },
120/// };
121///
122/// let input = "<div>hello</div>".to_string();
123/// let parsed = safe_parse_html(input);
124/// let expected = Node::new(
125///     Some(Div),
126///     None,
127///     None,
128///     None,
129///     vec![Node::new(
130///         Some(Text),
131///         Some("hello".to_string()),
132///         None,
133///         None,
134///         Vec::new(),
135///     )],
136/// );
137///
138/// assert_eq!(parsed, Ok(expected));
139/// ```
140pub fn safe_parse_html(input: String) -> Result<Node, ParseHTMLError> {
141    // current_index is the index of the current character being processed
142    let mut current_index = 0;
143    // nodes is a vector of nodes that will be returned as an attribute of the resulting node
144    let mut nodes = Vec::new();
145    // stack is a LIFO stack of nodes that are being processed
146    let mut stack: Vec<Node> = Vec::new();
147
148    while current_index < input.len() {
149        let rest = &input[current_index..];
150        if rest.starts_with("<!") {
151            // if the current character is an exclamation mark, it's a comment or DOCTYPE
152            if rest
153                .as_bytes()
154                .get(..9)
155                .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"<!DOCTYPE"))
156            {
157                // if the comment is a DOCTYPE, ignore it
158                if let Some(closing_index) = rest.find('>') {
159                    current_index += closing_index + 1;
160                    continue;
161                }
162                return Err(ParseHTMLError::MalformedTag(
163                    rest.to_string(),
164                    MalformedTagError::MissingClosingBracket(current_index as u32),
165                ));
166            }
167            if rest.starts_with("<![CDATA[") {
168                if let Some(closing_index) = rest.find("]]>") {
169                    current_index += closing_index + 3;
170                    continue;
171                }
172                return Err(ParseHTMLError::MalformedTag(
173                    rest.to_string(),
174                    MalformedTagError::MissingClosingBracket(current_index as u32),
175                ));
176            }
177            // find the closing comment tag
178            if let Some(closing_comment_index) = rest.find("-->") {
179                // if the closing comment tag is found, the comment is valid
180                // extract the comment from the rest
181                let comment = &rest[..closing_comment_index + 3];
182                // create a new node with the comment
183                let mut new_node = Node {
184                    tag_name: Some(Comment),
185                    value: Some(
186                        comment
187                            .trim_start_matches("<!")
188                            .trim_start_matches("--")
189                            .trim_end_matches("-->")
190                            .to_string(),
191                    ),
192                    attributes: None,
193                    explicitly_self_closing: false,
194                    within_special_tag: None,
195                    children: Vec::new(),
196                };
197                if let Some(parent) = stack.last_mut() {
198                    modify_node_with_parent(&mut new_node, parent);
199                    parent.children.push(new_node);
200                } else {
201                    nodes.push(new_node);
202                }
203                // increment the current_index by the closing_comment_index + 3
204                // and continue to the next iteration
205                current_index += closing_comment_index + 3;
206                continue;
207            }
208            // if the closing comment tag is not found, the comment is malformed
209            return Err(ParseHTMLError::MalformedTag(
210                rest.to_string(),
211                MalformedTagError::MissingClosingBracket(current_index as u32),
212            ));
213        }
214
215        if rest.starts_with('<') {
216            let closing_bracket = find_closing_bracket_index(rest);
217            if let Err(Some(value_start)) = closing_bracket {
218                let value_end = rest.len() - usize::from(rest.ends_with('>'));
219                return Err(ParseHTMLError::MalformedAttribute(
220                    rest[value_start..value_end].to_string(),
221                    MalformedAttributeError::MissingQuotationMark(current_index as u32),
222                ));
223            }
224            if let Ok(mut closing_index) = closing_bracket {
225                let tag_end = closing_index + 1;
226                // if the tag is a self-closing tag (i.e. <tag_name ... />)
227                let explicitly_self_closing =
228                    if rest.as_bytes().get(closing_index - 1) == Some(&b'/') {
229                        // if the last character right before the closing bracket is a forward slash, the tag is self-closing
230                        // closing_index is the index of the closing bracket, so decrement it to ignore the forward slash
231                        closing_index -= 1;
232                        true
233                    } else {
234                        // if the last character right before the closing bracket is not a forward slash, the tag is not self-closing
235                        false
236                    };
237
238                // the tag content is the string between the opening and closing brackets
239                let tag_content = &rest[1..closing_index];
240
241                // initialize the node name and attribute map
242                let node_name;
243                let mut attribute_map = None;
244                if let Some(space_index) = tag_content.find(|c: char| c.is_whitespace()) {
245                    // if the tag contains a space, split the tag into the node name and attributes
246                    // space_index is the index of the first spce
247                    // node_name is the tag name (i.e. <tag_name ...>)
248                    node_name = &tag_content[..space_index];
249                    // attributes is the string after the first space before the closing bracket
250                    let attributes = &tag_content[space_index..];
251                    // parse the attribute string into a map
252                    attribute_map = parse_tag_attributes(attributes, current_index)?;
253                } else {
254                    // if the tag doesn't contain a space, the tag is the node name
255                    node_name = tag_content;
256                }
257
258                if node_name.is_empty() {
259                    // if the tag name is empty, the tag is malformed
260                    return Err(ParseHTMLError::MalformedTag(
261                        tag_content.to_string(),
262                        MalformedTagError::MissingTagName(current_index as u32),
263                    ));
264                }
265
266                if rest.starts_with("</") {
267                    // if the tag is a closing tag, pop the last node from the stack and add it to the parent
268                    match stack.pop() {
269                        Some(last_node) => {
270                            if let Some(parent) = stack.last_mut() {
271                                parent.children.push(last_node);
272                            } else {
273                                // if the stack is empty, the last node is the root node
274                                nodes.push(last_node);
275                            }
276                            current_index += closing_index + 1;
277                            continue;
278                        }
279                        None => {
280                            // if there is nothing in the stack, the tag is malformed
281                            let closing_bracket_of_closing_tag = rest.find('>');
282                            return Err(ParseHTMLError::MalformedTag(
283                                if let Some(index) = closing_bracket_of_closing_tag {
284                                    // if there is a closing bracket, return the tag with the error
285                                    rest[..index + 1].to_string()
286                                } else {
287                                    rest.to_string()
288                                },
289                                MalformedTagError::MissingClosingBracket(current_index as u32),
290                            ));
291                        }
292                    }
293                }
294
295                // parse thae tag name into a NodeType from the node_name string
296                let node_type = NodeType::from_tag_str(node_name);
297                let raw_text = is_raw_text_tag(node_name);
298                // initialize a new node with the tag name and attribute map
299                let mut new_node = Node {
300                    tag_name: Some(node_type),
301                    value: None,
302                    attributes: attribute_map,
303                    explicitly_self_closing,
304                    within_special_tag: None,
305                    children: Vec::new(),
306                };
307
308                if new_node.closes_immediately() {
309                    // if the tag is self-closing, add the node to the parent
310                    // if a parent does not exist, add the node to the nodes vector
311                    if let Some(parent) = stack.last_mut() {
312                        modify_node_with_parent(&mut new_node, parent);
313                        parent.children.push(new_node);
314                    } else {
315                        nodes.push(new_node);
316                    }
317                    // because the tag is self-closing, increment past its closing bracket
318                    // and continute to the next iteration
319                    current_index += tag_end;
320                    continue;
321                }
322                if raw_text {
323                    let content_start = closing_index + 1;
324                    let raw_rest = &rest[content_start..];
325                    let Some((body_end, closing_end)) = find_raw_text_closing(raw_rest, node_name)
326                    else {
327                        return Err(ParseHTMLError::MalformedTag(
328                            rest.to_string(),
329                            MalformedTagError::MissingClosingBracket(current_index as u32),
330                        ));
331                    };
332                    if let Some(parent) = stack.last_mut() {
333                        modify_node_with_parent(&mut new_node, parent);
334                    }
335                    let mut text_node = Node {
336                        tag_name: Some(Text),
337                        value: Some(raw_rest[..body_end].to_string()),
338                        attributes: None,
339                        explicitly_self_closing: false,
340                        within_special_tag: None,
341                        children: Vec::new(),
342                    };
343                    modify_node_with_parent(&mut text_node, &new_node);
344                    new_node.children.push(text_node);
345                    if let Some(parent) = stack.last_mut() {
346                        parent.children.push(new_node);
347                    } else {
348                        nodes.push(new_node);
349                    }
350                    current_index += content_start + closing_end;
351                    continue;
352                }
353                // if the tag is not self-closing
354                // add the new_node to the stack
355                if let Some(parent) = stack.last_mut() {
356                    modify_node_with_parent(&mut new_node, parent);
357                }
358                stack.push(new_node);
359                // because the tag is not self-closing, increment the current_index by the closing_index + 1
360                current_index += tag_end;
361                continue;
362            } else {
363                return Err(ParseHTMLError::MalformedTag(
364                    rest.to_string(),
365                    MalformedTagError::MissingClosingBracket(current_index as u32),
366                ));
367            }
368        }
369
370        // if the current character is not a '<', it's just a text
371        // if an opening bracket is not found, the rest is the content of the text
372        // else, anything upto the opening bracket is the content of the text
373        let next_opening_tag = rest.find('<').unwrap_or(rest.len());
374        let text = &rest[..next_opening_tag];
375        let preserve_whitespace = stack
376            .last()
377            .and_then(|parent| parent.tag_name.as_ref())
378            .is_some_and(|tag| matches!(tag, Code | Pre));
379        if text.trim().is_empty() && !preserve_whitespace {
380            let previous_is_phrasing = stack
381                .last()
382                .and_then(|parent| parent.children.last())
383                .or_else(|| stack.is_empty().then(|| nodes.last()).flatten())
384                .is_some_and(is_phrasing_node);
385            if previous_is_phrasing && starts_with_phrasing_node(&rest[next_opening_tag..]) {
386                let mut whitespace = Node {
387                    tag_name: Some(Text),
388                    value: Some(" ".to_string()),
389                    attributes: None,
390                    explicitly_self_closing: false,
391                    within_special_tag: None,
392                    children: Vec::new(),
393                };
394                if let Some(parent) = stack.last_mut() {
395                    modify_node_with_parent(&mut whitespace, parent);
396                    parent.children.push(whitespace);
397                } else {
398                    nodes.push(whitespace);
399                }
400            }
401            // increment the current_index by next_opening_tag and continue to the next iteration
402            current_index += next_opening_tag;
403            continue;
404        }
405
406        // initialize new_node as text with the content of the text
407        let mut new_node = Node {
408            tag_name: Some(Text),
409            value: Some(text.to_string()),
410            attributes: None,
411            explicitly_self_closing: false,
412            within_special_tag: None,
413            children: Vec::new(),
414        };
415
416        if let Some(parent) = stack.last_mut() {
417            modify_node_with_parent(&mut new_node, parent);
418            parent.children.push(new_node);
419        } else {
420            nodes.push(new_node);
421        }
422
423        current_index += next_opening_tag
424    }
425
426    // if the stack is not empty, add the stack to the nodes vector
427    if !stack.is_empty() {
428        for stack_node in stack.drain(..) {
429            nodes.push(stack_node);
430        }
431    }
432
433    if nodes.len() == 1 {
434        return Ok(nodes.remove(0));
435    }
436
437    Ok(Node {
438        tag_name: None,
439        value: None,
440        attributes: None,
441        explicitly_self_closing: false,
442        within_special_tag: None,
443        children: nodes,
444    })
445}
446
447/// Modifies a node with the parent's within_special_tag and tag type
448///
449/// # Arguments
450///
451/// * `node` - A mutable reference to a Node to be modified
452/// * `parent` - A reference to the parent Node
453fn modify_node_with_parent(node: &mut Node, parent: &Node) {
454    if parent.within_special_tag.is_some() {
455        node.within_special_tag
456            .clone_from(&parent.within_special_tag)
457    }
458    if let Some(parent_tag_name) = &parent.tag_name {
459        if parent_tag_name.is_special_tag() {
460            if let Some(within_special_tag) = &mut node.within_special_tag {
461                within_special_tag.push(parent_tag_name.clone());
462            } else {
463                node.within_special_tag = Some(vec![parent_tag_name.clone()]);
464            }
465        }
466    }
467}
468
469fn parse_tag_attributes(
470    tag_attributes: &str,
471    current_index: usize,
472) -> Result<Option<Attributes>, ParseHTMLError> {
473    let tag_attributes = tag_attributes.trim();
474
475    // if the input is empty or only whitespace, return None
476    if tag_attributes.is_empty() {
477        return Ok(None);
478    }
479
480    let mut attribute_map = Attributes::new();
481
482    let mut current_key = String::new();
483    let mut current_value_in_quotes = String::new();
484    let mut quote = None;
485    let mut may_be_reading_non_quoted_value = false;
486    let mut whitespace_after_key = false;
487
488    for char in tag_attributes.trim().chars() {
489        // iterate through each character in the trimmed tag_attributes string
490
491        if let Some(quotation_mark) = quote {
492            // if we are in quotation marks, just add the character to the current_value_in_quotes
493            // except for if the character is a quotation mark, which indicates the end of the value
494            if char == quotation_mark {
495                // if the character is a quotation mark, add the current_value_in_quotes to the attribute_map
496                // and reset the current_key and current_value_in_quotes
497                add_to_attribute_map(&mut attribute_map, &current_key, &current_value_in_quotes);
498                current_key.clear();
499                current_value_in_quotes.clear();
500                quote = None;
501                continue;
502            }
503            current_value_in_quotes.push(char);
504            continue;
505        }
506
507        if whitespace_after_key && !char.is_whitespace() && char != '=' {
508            attribute_map.insert(current_key.clone(), AttributeValues::from(true));
509            current_key.clear();
510            whitespace_after_key = false;
511        }
512
513        if char.eq(&'"') || char.eq(&'\'') {
514            // if the character is a quotation mark, we are about to start the value
515            // we know in_quotes is false because that is checked above
516            if current_key.is_empty() {
517                // if the current_key is empty, the attribute is malformed
518                return Err(ParseHTMLError::MalformedAttribute(
519                    tag_attributes.to_string(),
520                    MalformedAttributeError::MissingAttributeName(current_index as u32),
521                ));
522            }
523            // set the in_quotes flag to true
524            quote = Some(char);
525            // if the character is a quotation mark, we are going to be in quotes
526            // so we don't need to keep track of non-quoted value flag
527            may_be_reading_non_quoted_value = false;
528            continue;
529        }
530
531        if char.is_whitespace() {
532            if may_be_reading_non_quoted_value {
533                if current_value_in_quotes.is_empty() {
534                    // if we are reading a non-quoted value and the value is empty, we can ignore the whitespace
535                    continue;
536                }
537                // if we are reading a non-quoted value, the whitespace indicates the end of the value
538                // add the value to the attribute_map
539                add_to_attribute_map(&mut attribute_map, &current_key, &current_value_in_quotes);
540                current_key.clear();
541                current_value_in_quotes.clear();
542                may_be_reading_non_quoted_value = false;
543                continue;
544            }
545            // if the character is whitespace, if could be indicating the end of a key
546            if !current_key.is_empty() {
547                // Defer insertion until we know whether an equal sign follows the whitespace.
548                whitespace_after_key = true;
549                continue;
550            }
551            // if the current_key is empty, the whitespace can be ignored
552            continue;
553        }
554
555        if !may_be_reading_non_quoted_value && char.eq(&'=') {
556            // if the character is an equal sign, the current_key is complete
557            // if we are in quotes or reading a non-quoted value, the equal sign is part of the value
558            // and we are about to start the value
559            if current_key.is_empty() {
560                // if the current_key is empty, the attribute is malformed
561                return Err(ParseHTMLError::MalformedAttribute(
562                    tag_attributes.to_string(),
563                    MalformedAttributeError::MissingAttributeName(current_index as u32),
564                ));
565            }
566            // equal sign indicates the start of the value up to the next whitespace
567            whitespace_after_key = false;
568            may_be_reading_non_quoted_value = true;
569            continue;
570        }
571
572        if may_be_reading_non_quoted_value {
573            // if we are reading a non-quoted value, add the character to the current_value_in_quotes
574            current_value_in_quotes.push(char);
575            continue;
576        }
577
578        // otherwise, add the character to the current_key
579        current_key.push(char);
580    }
581
582    if may_be_reading_non_quoted_value {
583        if current_value_in_quotes.is_empty() {
584            return Err(ParseHTMLError::MalformedAttribute(
585                tag_attributes.to_string(),
586                MalformedAttributeError::MissingAttributeValue(current_index as u32),
587            ));
588        }
589        add_to_attribute_map(&mut attribute_map, &current_key, &current_value_in_quotes);
590    }
591
592    if quote.is_some() {
593        return Err(ParseHTMLError::MalformedAttribute(
594            current_value_in_quotes,
595            MalformedAttributeError::MissingQuotationMark(current_index as u32),
596        ));
597    }
598
599    if !may_be_reading_non_quoted_value && !current_key.is_empty() {
600        attribute_map.insert(current_key, AttributeValues::from(true));
601    }
602
603    // if not, return the attribute map
604    match attribute_map.is_empty() {
605        true => Ok(None),
606        false => Ok(Some(attribute_map)),
607    }
608}
609
610fn add_to_attribute_map(
611    attribute_map: &mut Attributes,
612    current_key: &str,
613    current_value_in_quotes: &str,
614) {
615    if current_key.is_empty() {
616        return;
617    }
618    attribute_map.insert(
619        current_key.to_string(),
620        AttributeValues::from(current_value_in_quotes),
621    );
622}
623
624fn find_closing_bracket_index(rest: &str) -> Result<usize, Option<usize>> {
625    let mut quote = None;
626    for (idx, char) in rest.char_indices() {
627        match quote {
628            Some((quotation_mark, _)) if char == quotation_mark => quote = None,
629            Some(_) => {}
630            None if char.eq(&'"') || char.eq(&'\'') => quote = Some((char, idx + 1)),
631            None if char.eq(&'>') => return Ok(idx),
632            None => {}
633        }
634    }
635    Err(quote.map(|(_, value_start)| value_start))
636}
637
638fn is_raw_text_tag(tag_name: &str) -> bool {
639    ["script", "style", "textarea", "title"]
640        .iter()
641        .any(|raw_tag| tag_name.eq_ignore_ascii_case(raw_tag))
642}
643
644fn is_phrasing_node(node: &Node) -> bool {
645    node.tag_name.as_ref().is_some_and(NodeType::is_phrasing)
646}
647
648fn starts_with_phrasing_node(input: &str) -> bool {
649    if input.starts_with("<!--") {
650        return true;
651    }
652    let Some(tag) = input.strip_prefix('<') else {
653        return false;
654    };
655    if tag.starts_with('/') {
656        return false;
657    }
658    let name = tag
659        .split(|character: char| character.is_whitespace() || matches!(character, '/' | '>'))
660        .next()
661        .unwrap_or_default();
662    NodeType::from_tag_str(name).is_phrasing()
663}
664
665fn find_raw_text_closing(rest: &str, tag_name: &str) -> Option<(usize, usize)> {
666    let bytes = rest.as_bytes();
667    let tag_name = tag_name.as_bytes();
668    let mut search_from = 0;
669    while let Some(relative_start) = rest[search_from..].find("</") {
670        let start = search_from + relative_start;
671        let name_start = start + 2;
672        let name_end = name_start + tag_name.len();
673        if bytes
674            .get(name_start..name_end)
675            .is_some_and(|candidate| candidate.eq_ignore_ascii_case(tag_name))
676        {
677            let mut closing_bracket = name_end;
678            while bytes
679                .get(closing_bracket)
680                .is_some_and(|byte| byte.is_ascii_whitespace())
681            {
682                closing_bracket += 1;
683            }
684            if bytes.get(closing_bracket) == Some(&b'>') {
685                return Some((start, closing_bracket + 1));
686            }
687        }
688        search_from = name_start;
689    }
690    None
691}
692
693// https://github.com/izyuumi/html2md-rs/issues/25
694#[test]
695fn issue_25() {
696    let input = "property=\"og:type\" content= \"website\"".to_string();
697    let expected = Attributes::from(vec![
698        ("property".to_string(), AttributeValues::from("og:type")),
699        ("content".to_string(), AttributeValues::from("website")),
700    ]);
701    let parsed = parse_tag_attributes(&input, 0).unwrap().unwrap();
702    assert_eq!(parsed, expected);
703}
704
705// https://github.com/izyuumi/html2md-rs/issues/31
706#[test]
707fn issue_31() {
708    let input = r#"<img src="https://exmaple.com/img.png" alt="Rust<br/>Logo"/>"#.to_string();
709    let expected = Node {
710        tag_name: Some(Unknown("img".to_string())),
711        value: None,
712        attributes: Some(Attributes {
713            id: None,
714            class: None,
715            attributes: std::collections::HashMap::from([
716                (
717                    "src".to_string(),
718                    AttributeValues::from("https://exmaple.com/img.png"),
719                ),
720                ("alt".to_string(), AttributeValues::from("Rust<br/>Logo")),
721            ]),
722        }),
723        explicitly_self_closing: true,
724        children: Vec::new(),
725        within_special_tag: None,
726    };
727    let parsed = safe_parse_html(input).unwrap();
728    assert_eq!(parsed, expected)
729}
730
731// https://github.com/izyuumi/html2md-rs/issues/36
732#[test]
733fn issue_36() {
734    let input = "<img src=\"https://hoerspiele.dra.de/fileadmin/www.hoerspiele.dra.de/images/vollinfo/4970918_B01.jpg\" />".to_string();
735    let expected = Node {
736        tag_name: Some(Unknown("img".to_string())),
737        value: None,
738        attributes: Some(Attributes {
739            id: None,
740            class: None,
741            attributes: std::collections::HashMap::from([(
742                "src".to_string(),
743                AttributeValues::from("https://hoerspiele.dra.de/fileadmin/www.hoerspiele.dra.de/images/vollinfo/4970918_B01.jpg"),
744            )]),
745        }),
746        explicitly_self_closing: true,
747        children: Vec::new(),
748        within_special_tag: None,
749    };
750    let parsed = safe_parse_html(input).unwrap();
751    assert_eq!(parsed, expected);
752
753    let input = r#"<!DOCTYPE html><meta http-equiv="content-type" content="text/html; charset=utf-8"><div class="column"><div class="gallery-wrap single">
754    <div class="gallery-container">
755        <figure class="image">
756            <figure class="image">
757            <img title="Illustration »Der dunkle Kongress« © ARD / Jürgen Frey"
758                 alt="Illustration »Der dunkle Kongress« © ARD / Jürgen Frey" 
759                 src="https://hoerspiele.dra.de/fileadmin/www.hoerspiele.dra.de/images/vollinfo/4970918_B01.jpg">
760                <figcaption class="image-caption">Illustration »Der dunkle Kongress«
761© ARD / Jürgen Frey</figcaption>
762</figure></div></div></div>"#.to_string();
763    safe_parse_html(input).unwrap();
764}