html-to-markdown-rs 3.6.6

High-performance HTML to Markdown converter using the astral-tl parser. Part of the Kreuzberg ecosystem.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
//! Type definitions for the visitor pattern.
//!
//! This module contains the core data types used in the visitor pattern:
//! - `NodeType`: Categorization of HTML elements
//! - `NodeContext`: Metadata about the current node being visited
//! - `VisitResult`: Control flow signals from visitor methods

use std::borrow::Cow;
use std::cell::OnceCell;
use std::collections::BTreeMap;

/// Shared empty attribute map used by visitor call sites that have no attributes.
///
/// Allows constructing a [`NodeContext`] with borrowed empty attributes
/// instead of allocating a fresh `BTreeMap` per dispatch.
pub static EMPTY_ATTRS: BTreeMap<String, String> = BTreeMap::new();

/// Node type enumeration covering all HTML element types.
///
/// This enum categorizes all HTML elements that the converter recognizes,
/// providing a coarse-grained classification for visitor dispatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum NodeType {
    /// Text node (most frequent - 100+ per document)
    Text,

    /// Generic element node
    Element,

    /// Heading elements (h1-h6)
    Heading,
    /// Paragraph element
    Paragraph,
    /// Generic div container
    Div,
    /// Blockquote element
    Blockquote,
    /// Preformatted text block
    Pre,
    /// Horizontal rule
    Hr,

    /// Ordered or unordered list (ul, ol)
    List,
    /// List item (li)
    ListItem,
    /// Definition list (dl)
    DefinitionList,
    /// Definition term (dt)
    DefinitionTerm,
    /// Definition description (dd)
    DefinitionDescription,

    /// Table element
    Table,
    /// Table row (tr)
    TableRow,
    /// Table cell (td, th)
    TableCell,
    /// Table header cell (th)
    TableHeader,
    /// Table body (tbody)
    TableBody,
    /// Table head (thead)
    TableHead,
    /// Table foot (tfoot)
    TableFoot,

    /// Anchor link (a)
    Link,
    /// Image (img)
    Image,
    /// Strong/bold (strong, b)
    Strong,
    /// Emphasis/italic (em, i)
    Em,
    /// Inline code (code)
    Code,
    /// Strikethrough (s, del, strike)
    Strikethrough,
    /// Underline (u, ins)
    Underline,
    /// Subscript (sub)
    Subscript,
    /// Superscript (sup)
    Superscript,
    /// Mark/highlight (mark)
    Mark,
    /// Small text (small)
    Small,
    /// Line break (br)
    Br,
    /// Span element
    Span,

    /// Article element
    Article,
    /// Section element
    Section,
    /// Navigation element
    Nav,
    /// Aside element
    Aside,
    /// Header element
    Header,
    /// Footer element
    Footer,
    /// Main element
    Main,
    /// Figure element
    Figure,
    /// Figure caption
    Figcaption,
    /// Time element
    Time,
    /// Details element
    Details,
    /// Summary element
    Summary,

    /// Form element
    Form,
    /// Input element
    Input,
    /// Select element
    Select,
    /// Option element
    Option,
    /// Button element
    Button,
    /// Textarea element
    Textarea,
    /// Label element
    Label,
    /// Fieldset element
    Fieldset,
    /// Legend element
    Legend,

    /// Audio element
    Audio,
    /// Video element
    Video,
    /// Picture element
    Picture,
    /// Source element
    Source,
    /// Iframe element
    Iframe,
    /// SVG element
    Svg,
    /// Canvas element
    Canvas,

    /// Ruby annotation
    Ruby,
    /// Ruby text
    Rt,
    /// Ruby parenthesis
    Rp,
    /// Abbreviation
    Abbr,
    /// Keyboard input
    Kbd,
    /// Sample output
    Samp,
    /// Variable
    Var,
    /// Citation
    Cite,
    /// Quote
    Q,
    /// Deleted text
    Del,
    /// Inserted text
    Ins,
    /// Data element
    Data,
    /// Meter element
    Meter,
    /// Progress element
    Progress,
    /// Output element
    Output,
    /// Template element
    Template,
    /// Slot element
    Slot,

    /// HTML root element
    Html,
    /// Head element
    Head,
    /// Body element
    Body,
    /// Title element
    Title,
    /// Meta element
    Meta,
    /// Link element (not anchor)
    LinkTag,
    /// Style element
    Style,
    /// Script element
    Script,
    /// Base element
    Base,

    /// Custom element (web components) or unknown tag
    Custom,
}

/// Lazy-evaluated attribute source for a [`NodeContext`].
///
/// Attributes are only materialized from the DOM when [`NodeContext::attributes`]
/// is first called. If the visitor never reads attributes, the `BTreeMap`
/// allocation is skipped entirely.
///
/// This type is opaque — callers interact with it only through
/// [`NodeContext::attributes`]. The only producer of the `Lazy` variant is
/// [`NodeContext::with_lazy_attributes`], which is `pub(crate)`.
pub struct AttributesSource<'a> {
    inner: AttributesInner<'a>,
}

enum AttributesInner<'a> {
    /// Borrowed reference to an existing map (no allocation on access).
    Borrowed(&'a BTreeMap<String, String>),
    /// Owned map (returned by reference on access).
    Owned(BTreeMap<String, String>),
    /// Lazy: materialize from the tl tag on first access.
    ///
    /// `tl` does not appear in the public signature of `AttributesSource` or
    /// `NodeContext`; this variant is only reachable via `with_lazy_attributes`
    /// which is `pub(crate)`.
    Lazy {
        tag: &'a tl::HTMLTag<'a>,
        cell: OnceCell<BTreeMap<String, String>>,
    },
}

impl<'a> AttributesSource<'a> {
    /// Construct from a borrowed map reference.
    pub(crate) const fn borrowed(map: &'a BTreeMap<String, String>) -> Self {
        Self {
            inner: AttributesInner::Borrowed(map),
        }
    }

    /// Construct from an owned map.
    pub(crate) const fn owned(map: BTreeMap<String, String>) -> Self {
        Self {
            inner: AttributesInner::Owned(map),
        }
    }

    /// Construct the lazy variant backed by a `tl::HTMLTag`.
    ///
    /// Only callable within the `html-to-markdown-rs` crate.
    pub(crate) const fn lazy(tag: &'a tl::HTMLTag<'a>) -> Self {
        Self {
            inner: AttributesInner::Lazy {
                tag,
                cell: OnceCell::new(),
            },
        }
    }

    /// Return a reference to the attribute map, materializing it if needed.
    pub fn get(&self) -> &BTreeMap<String, String> {
        match &self.inner {
            AttributesInner::Borrowed(map) => map,
            AttributesInner::Owned(map) => map,
            AttributesInner::Lazy { tag, cell } => cell.get_or_init(|| {
                tag.attributes()
                    .iter()
                    .filter_map(|(k, v)| v.as_ref().map(|val| (k.to_string(), val.to_string())))
                    .collect()
            }),
        }
    }
}

impl Clone for AttributesSource<'_> {
    fn clone(&self) -> Self {
        // Materialize before cloning so the clone is self-contained (owned).
        Self {
            inner: AttributesInner::Owned(self.get().clone()),
        }
    }
}

impl std::fmt::Debug for AttributesSource<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_map().entries(self.get()).finish()
    }
}

#[cfg(feature = "serde")]
impl serde::Serialize for AttributesSource<'_> {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        self.get().serialize(serializer)
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for AttributesSource<'static> {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let map = BTreeMap::<String, String>::deserialize(deserializer)?;
        Ok(Self::owned(map))
    }
}

/// Context information passed to all visitor methods.
///
/// Provides comprehensive metadata about the current node being visited,
/// including its type, tag name, position in the DOM tree, and parent context.
///
/// ## Attributes
///
/// Access attributes via [`NodeContext::attributes`], which returns
/// `&BTreeMap<String, String>`. When the context was built with
/// [`NodeContext::with_lazy_attributes`] (the hot path inside the converter),
/// the map is only materialized on the first call — if the visitor never reads
/// attributes, the allocation is skipped.
///
/// ## Lifetimes
///
/// String fields use [`Cow<'_, str>`] so the converter can pass slices directly
/// out of the parsed DOM without allocating. Visitor implementations that need
/// to outlive the callback should call [`NodeContext::into_owned`].
#[derive(Debug, Clone)]
pub struct NodeContext<'a> {
    /// Coarse-grained node type classification
    pub node_type: NodeType,

    /// Raw HTML tag name (e.g., "div", "h1", "custom-element")
    pub tag_name: Cow<'a, str>,

    /// Lazily-materialized HTML attributes. Access via [`NodeContext::attributes`].
    attributes: AttributesSource<'a>,

    /// Depth in the DOM tree (0 = root)
    pub depth: usize,

    /// Index among siblings (0-based)
    pub index_in_parent: usize,

    /// Parent element's tag name (None if root)
    pub parent_tag: Option<Cow<'a, str>>,

    /// Whether this element is treated as inline vs block
    pub is_inline: bool,
}

#[cfg(feature = "serde")]
impl serde::Serialize for NodeContext<'_> {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        use serde::ser::SerializeStruct;
        let mut s = serializer.serialize_struct("NodeContext", 7)?;
        s.serialize_field("node_type", &self.node_type)?;
        s.serialize_field("tag_name", &self.tag_name)?;
        s.serialize_field("attributes", &self.attributes)?;
        s.serialize_field("depth", &self.depth)?;
        s.serialize_field("index_in_parent", &self.index_in_parent)?;
        s.serialize_field("parent_tag", &self.parent_tag)?;
        s.serialize_field("is_inline", &self.is_inline)?;
        s.end()
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for NodeContext<'static> {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        use serde::de::{self, MapAccess, Visitor};

        #[derive(serde::Deserialize)]
        #[serde(field_identifier, rename_all = "snake_case")]
        enum Field {
            NodeType,
            TagName,
            Attributes,
            Depth,
            IndexInParent,
            ParentTag,
            IsInline,
        }

        struct NodeContextVisitor;

        impl<'de> Visitor<'de> for NodeContextVisitor {
            type Value = NodeContext<'static>;

            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
                formatter.write_str("struct NodeContext")
            }

            fn visit_map<V: MapAccess<'de>>(self, mut map: V) -> Result<NodeContext<'static>, V::Error> {
                let mut node_type = None;
                let mut tag_name: Option<String> = None;
                let mut attributes: Option<BTreeMap<String, String>> = None;
                let mut depth = None;
                let mut index_in_parent = None;
                let mut parent_tag: Option<Option<String>> = None;
                let mut is_inline = None;

                while let Some(key) = map.next_key()? {
                    match key {
                        Field::NodeType => node_type = Some(map.next_value()?),
                        Field::TagName => tag_name = Some(map.next_value()?),
                        Field::Attributes => attributes = Some(map.next_value()?),
                        Field::Depth => depth = Some(map.next_value()?),
                        Field::IndexInParent => index_in_parent = Some(map.next_value()?),
                        Field::ParentTag => parent_tag = Some(map.next_value()?),
                        Field::IsInline => is_inline = Some(map.next_value()?),
                    }
                }

                Ok(NodeContext {
                    node_type: node_type.ok_or_else(|| de::Error::missing_field("node_type"))?,
                    tag_name: Cow::Owned(tag_name.ok_or_else(|| de::Error::missing_field("tag_name"))?),
                    attributes: AttributesSource::owned(
                        attributes.ok_or_else(|| de::Error::missing_field("attributes"))?,
                    ),
                    depth: depth.ok_or_else(|| de::Error::missing_field("depth"))?,
                    index_in_parent: index_in_parent.ok_or_else(|| de::Error::missing_field("index_in_parent"))?,
                    parent_tag: parent_tag
                        .ok_or_else(|| de::Error::missing_field("parent_tag"))?
                        .map(Cow::Owned),
                    is_inline: is_inline.ok_or_else(|| de::Error::missing_field("is_inline"))?,
                })
            }
        }

        const FIELDS: &[&str] = &[
            "node_type",
            "tag_name",
            "attributes",
            "depth",
            "index_in_parent",
            "parent_tag",
            "is_inline",
        ];
        deserializer.deserialize_struct("NodeContext", FIELDS, NodeContextVisitor)
    }
}

impl<'a> NodeContext<'a> {
    /// Return a reference to the attribute map.
    ///
    /// If the context was built with [`NodeContext::with_lazy_attributes`], the
    /// map is materialized on the first call and cached for subsequent calls.
    /// If this method is never called, no allocation occurs for attributes.
    #[inline]
    pub fn attributes(&self) -> &BTreeMap<String, String> {
        self.attributes.get()
    }

    /// Construct a `NodeContext` with a borrowed attribute map.
    ///
    /// Use this when you already have a `&BTreeMap` that outlives `'a`,
    /// such as [`EMPTY_ATTRS`] for text nodes or a locally-cached map.
    #[must_use]
    pub const fn with_borrowed_attributes(
        node_type: NodeType,
        tag_name: Cow<'a, str>,
        attributes: &'a BTreeMap<String, String>,
        depth: usize,
        index_in_parent: usize,
        parent_tag: Option<Cow<'a, str>>,
        is_inline: bool,
    ) -> Self {
        Self {
            node_type,
            tag_name,
            attributes: AttributesSource::borrowed(attributes),
            depth,
            index_in_parent,
            parent_tag,
            is_inline,
        }
    }

    /// Construct a `NodeContext` with an owned attribute map.
    ///
    /// Prefer [`NodeContext::with_lazy_attributes`] (pub(crate)) inside the
    /// converter to avoid the eager `collect_tag_attributes` allocation.
    #[must_use]
    pub const fn with_owned_attributes(
        node_type: NodeType,
        tag_name: Cow<'a, str>,
        attributes: BTreeMap<String, String>,
        depth: usize,
        index_in_parent: usize,
        parent_tag: Option<Cow<'a, str>>,
        is_inline: bool,
    ) -> Self {
        Self {
            node_type,
            tag_name,
            attributes: AttributesSource::owned(attributes),
            depth,
            index_in_parent,
            parent_tag,
            is_inline,
        }
    }

    /// Construct a `NodeContext` with lazy attribute evaluation backed by a
    /// `tl::HTMLTag`.
    ///
    /// Attributes are only materialized (via `collect_tag_attributes`) if
    /// [`NodeContext::attributes`] is actually called. This eliminates the
    /// per-element `BTreeMap` allocation on the hot path when visitors do not
    /// read attributes.
    ///
    /// This constructor is `pub(crate)` to keep `tl` out of the public API.
    #[cfg(feature = "visitor")]
    pub(crate) const fn with_lazy_attributes(
        node_type: NodeType,
        tag_name: Cow<'a, str>,
        tag: &'a tl::HTMLTag<'a>,
        depth: usize,
        index_in_parent: usize,
        parent_tag: Option<Cow<'a, str>>,
        is_inline: bool,
    ) -> Self {
        Self {
            node_type,
            tag_name,
            attributes: AttributesSource::lazy(tag),
            depth,
            index_in_parent,
            parent_tag,
            is_inline,
        }
    }

    /// Promote any borrowed fields into owned storage so the context can outlive `'a`.
    #[must_use]
    pub fn into_owned(self) -> NodeContext<'static> {
        NodeContext {
            node_type: self.node_type,
            tag_name: Cow::Owned(self.tag_name.into_owned()),
            attributes: AttributesSource::owned(self.attributes.get().clone()),
            depth: self.depth,
            index_in_parent: self.index_in_parent,
            parent_tag: self.parent_tag.map(|p| Cow::Owned(p.into_owned())),
            is_inline: self.is_inline,
        }
    }
}

/// Result of a visitor callback.
///
/// Allows visitors to control the conversion flow by either proceeding
/// with default behavior, providing custom output, skipping elements,
/// preserving HTML, or signaling errors.
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum VisitResult {
    #[default]
    /// Continue with default conversion behavior
    Continue,

    /// Replace default output with custom markdown
    ///
    /// The visitor takes full responsibility for the markdown output
    /// of this node and its children.
    Custom(String),

    /// Skip this element entirely (don't output anything)
    ///
    /// The element and all its children are ignored in the output.
    Skip,

    /// Preserve original HTML (don't convert to markdown)
    ///
    /// The element's raw HTML is included verbatim in the output.
    PreserveHtml,

    /// Stop conversion with an error
    ///
    /// The conversion process halts and returns this error message.
    Error(String),
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_node_type_equality() {
        assert_eq!(NodeType::Text, NodeType::Text);
        assert_ne!(NodeType::Text, NodeType::Element);
        assert_eq!(NodeType::Heading, NodeType::Heading);
    }

    #[test]
    fn test_node_context_with_borrowed_attributes() {
        let attrs = BTreeMap::new();
        let ctx = NodeContext::with_borrowed_attributes(
            NodeType::Heading,
            Cow::Borrowed("h1"),
            &attrs,
            1,
            0,
            Some(Cow::Borrowed("body")),
            false,
        );

        assert_eq!(ctx.node_type, NodeType::Heading);
        assert_eq!(ctx.tag_name, "h1");
        assert_eq!(ctx.depth, 1);
        assert!(!ctx.is_inline);
        assert!(ctx.attributes().is_empty());
    }

    #[test]
    fn test_node_context_with_owned_attributes() {
        let mut attrs = BTreeMap::new();
        attrs.insert("class".to_string(), "hero".to_string());
        let ctx = NodeContext::with_owned_attributes(NodeType::Div, Cow::Borrowed("div"), attrs, 0, 0, None, false);

        assert_eq!(ctx.attributes().get("class").map(String::as_str), Some("hero"));
    }

    #[test]
    fn test_node_context_attributes_empty() {
        let ctx =
            NodeContext::with_borrowed_attributes(NodeType::Text, Cow::Borrowed(""), &EMPTY_ATTRS, 0, 0, None, true);
        assert!(ctx.attributes().is_empty());
    }

    #[test]
    fn test_node_context_into_owned() {
        let mut attrs = BTreeMap::new();
        attrs.insert("id".to_string(), "main".to_string());
        let ctx = NodeContext::with_owned_attributes(
            NodeType::Div,
            Cow::Borrowed("div"),
            attrs,
            1,
            0,
            Some(Cow::Borrowed("body")),
            false,
        );
        let owned = ctx.into_owned();
        assert_eq!(owned.tag_name, "div");
        assert_eq!(owned.attributes().get("id").map(String::as_str), Some("main"));
        assert_eq!(owned.parent_tag, Some(Cow::Borrowed("body")));
    }

    #[test]
    fn test_node_context_clone() {
        let mut attrs = BTreeMap::new();
        attrs.insert("href".to_string(), "https://example.com".to_string());
        let ctx = NodeContext::with_owned_attributes(
            NodeType::Link,
            Cow::Borrowed("a"),
            attrs,
            2,
            1,
            Some(Cow::Borrowed("p")),
            true,
        );
        let cloned = ctx.clone();
        assert_eq!(
            cloned.attributes().get("href").map(String::as_str),
            Some("https://example.com")
        );
    }

    #[test]
    fn test_visit_result_variants() {
        let continue_result = VisitResult::Continue;
        matches!(continue_result, VisitResult::Continue);

        let custom_result = VisitResult::Custom("# Custom Output".to_string());
        if let VisitResult::Custom(output) = custom_result {
            assert_eq!(output, "# Custom Output");
        }

        let skip_result = VisitResult::Skip;
        matches!(skip_result, VisitResult::Skip);

        let preserve_result = VisitResult::PreserveHtml;
        matches!(preserve_result, VisitResult::PreserveHtml);

        let error_result = VisitResult::Error("Test error".to_string());
        if let VisitResult::Error(msg) = error_result {
            assert_eq!(msg, "Test error");
        }
    }
}