Skip to main content

docspec_markdown_reader/
lib.rs

1//! Markdown to `DocSpec` event stream reader.
2//!
3//! This crate provides a [`MarkdownReader`] that implements [`EventSource`] to convert
4//! Markdown documents into the `DocSpec` event stream format. It uses `pulldown-cmark`
5//! to parse CommonMark-compliant Markdown and emits typed events representing document
6//! structure.
7//!
8//! # Quick Start
9//!
10//! ```
11//! use docspec_markdown_reader::{MarkdownReader, EventSource};
12//!
13//! let markdown = "# Hello\n\nWorld";
14//! let mut reader = MarkdownReader::from_str(markdown);
15//!
16//! while let Some(event) = reader.next_event()? {
17//!     println!("{event:?}");
18//! }
19//! # Ok::<(), docspec_core::Error>(())
20//! ```
21//!
22//! # Supported Elements
23//!
24//! - Headings (h1–h6) → `StartHeading` / `EndHeading`
25//! - Paragraphs → `StartParagraph` / `EndParagraph`
26//! - Block quotes → `StartBlockQuote` / `EndBlockQuote`
27//! - Code blocks → `StartPreformatted` / `EndPreformatted`
28//! - Bold text → `StartTextStyle { kind: Bold }` / `EndTextStyle`
29//! - Italic text → `StartTextStyle { kind: Italic }` / `EndTextStyle`
30//! - Inline code → `StartTextStyle { kind: Code }` / `EndTextStyle`
31//! - Strikethrough → `StartTextStyle { kind: Strikethrough }` / `EndTextStyle`
32//! - Images → `Image { source: Uri, alt, title, decorative }`
33//! - Hard line breaks → `LineBreak`
34//! - Soft line breaks → `SoftBreak`
35//! - Thematic breaks → `ThematicBreak`
36//! - Tables → `StartTable` / `EndTable`, `StartTableRow` / `EndTableRow`,
37//!   `StartTableHeader` / `EndTableHeader`, `StartTableCell` / `EndTableCell`
38//!   (GFM column alignment syntax is parsed, but alignment data is discarded)
39//! - Bullet lists → `StartUnorderedListItem` / `EndUnorderedListItem`
40//! - Numbered lists → `StartOrderedListItem` / `EndOrderedListItem`
41//!   (`start: Option<u64>` is `Some(n)` on the first item of each list, `None` on subsequent items;
42//!   child items may nest inside their parent's `Start*`/`End*` pair with `level` indicating
43//!   indent depth; task list markers (`- [ ]`/`- [x]`) are parsed as literal text)
44//! - Links → `StartLink { href, title }` / `EndLink` (inline, reference, collapsed,
45//!   shortcut, autolink, and email autolink variants — all resolved to inline form
46//!   by pulldown-cmark; image-inside-link closes the link before emitting the image
47//!   as a sibling block: content preceding the image stays inside the link, content
48//!   following the image is outside the link, and the link is empty only when the
49//!   image is the sole link label, e.g. `[![alt](img)](url)`)
50//!
51//! # Supported Raw HTML Tags
52//!
53//! The following raw HTML tags embedded in markdown source are translated into
54//! `DocSpec` events. All attributes on these tags are silently ignored. All other
55//! HTML tags continue to be silently dropped.
56//!
57//! ## Inline formatting (translated to `StartTextStyle` / `EndTextStyle`)
58//! - `<b>`, `<strong>` → `TextStyleKind::Bold`
59//! - `<i>`, `<em>` → `TextStyleKind::Italic`
60//! - `<u>` → `TextStyleKind::Underline`
61//! - `<s>`, `<strike>`, `<del>` → `TextStyleKind::Strikethrough`
62//! - `<code>` → `TextStyleKind::Code`
63//! - `<sub>` → `TextStyleKind::Subscript`
64//! - `<sup>` → `TextStyleKind::Superscript`
65//! - `<mark>` → `TextStyleKind::Mark` with constant yellow `#FFFF00`
66//!
67//! ## Self-closing / void
68//! - `<br>`, `<br/>`, `<br />` → `Event::LineBreak`
69//! - `<hr>` → `Event::ThematicBreak` (block context only; ignored in paragraph context)
70//!
71//! ## Block (only inside an `HtmlBlock`)
72//! - `<h1>`...`<h6>` → `Event::StartHeading { level: N }` + content + `Event::EndHeading`
73//!
74//! ## Known limitations
75//! - Raw HTML `<pre><code>...</code></pre>` is NOT treated as a code block; the `<pre>` is dropped
76//!   (out of scope) and the `<code>` becomes an inline style. Use markdown fenced code blocks instead.
77//! - HTML attributes (id, class, style, href, src, etc.) are NOT extracted.
78//! - Unclosed tags are auto-closed at the end of the containing block.
79//!
80//! # Unsupported Elements
81//!
82//! The following elements are not emitted as structured events. Text content is
83//! recursively extracted where applicable; structure is silently dropped:
84//! - Definition lists and footnotes
85//! - Math blocks and inline math
86//! - Subscript and superscript formatting (use `<sub>` / `<sup>` raw HTML instead)
87//!
88//! # Memory Model
89//!
90//! `MarkdownReader` owns its source text for the parser's lifetime. While events
91//! are emitted one at a time via [`EventSource::next_event`] (the stream-event
92//! guarantee is preserved), the source `String` is held in memory until the reader
93//! is dropped. This is a constraint of `pulldown-cmark`, which is permanently
94//! borrow-based by design (see [pulldown-cmark issue #463]).
95//!
96//! For contrast, `HtmlReader` (from `docspec-html-reader`) streams its source via a
97//! 16 KB sliding-window buffer and does not hold the full document in memory.
98//!
99//! [pulldown-cmark issue #463]: https://github.com/raphlinus/pulldown-cmark/issues/463
100
101extern crate alloc;
102
103#[cfg_attr(all(), allow(clippy::mem_forget))]
104mod parser_cell {
105    use self_cell::self_cell;
106
107    use super::MarkdownParser;
108
109    self_cell!(
110        pub(super) struct ParserCell {
111            owner: String,
112            #[covariant]
113            dependent: MarkdownParser,
114        }
115    );
116}
117
118mod html;
119
120use alloc::collections::VecDeque;
121use std::io::{Read, Seek};
122
123pub use docspec_core::EventSource;
124use docspec_core::{Event, ImageSource, ListStyleType, Result, TableHeaderScope, TextStyleKind};
125use parser_cell::ParserCell;
126use pulldown_cmark::{CodeBlockKind, CowStr, HeadingLevel, Options, Parser, Tag, TagEnd};
127
128struct MarkdownParser<'a>(Parser<'a>);
129
130/// Whether content is inside a block-level element.
131#[derive(Clone, Copy, PartialEq, Eq)]
132enum BlockState {
133    /// Inside an auto-opened paragraph (text arrived outside any explicit block).
134    AutoParagraph,
135    /// Inside an explicit block (from a `StartParagraph` or `StartHeading` tag).
136    Explicit,
137    /// Not inside any block context.
138    None,
139    /// Explicit block whose `StartParagraph` is deferred until the first real event.
140    PendingExplicit,
141}
142
143/// Document processing phase.
144#[derive(Clone, Copy, PartialEq, Eq)]
145enum Phase {
146    /// `EndDocument` has been emitted.
147    Finished,
148    /// `StartDocument` not yet emitted.
149    NotStarted,
150    /// Processing events between `StartDocument` and `EndDocument`.
151    Running,
152}
153
154/// Context for a single list level tracked by [`MarkdownReader`].
155struct ListContext {
156    /// Whether the item at this list level is currently open (start emitted, end not yet emitted).
157    item_open: bool,
158    /// Whether this list is ordered (numbered) rather than unordered (bulleted).
159    ordered: bool,
160    /// Start number to attach to the next item emitted; `Some(n)` only before the first
161    /// item is emitted, then `None` for all subsequent items in the same list.
162    pending_start: Option<u64>,
163}
164
165/// Buffered image state during image alt text collection.
166struct ImageBuffer {
167    /// Accumulated alt text.
168    alt_buf: String,
169    /// Image title if provided.
170    title: Option<String>,
171    /// Image URL.
172    url: String,
173}
174
175enum MarkdownPulldownEvent {
176    BlockHtml(String),
177    Code(String),
178    End(TagEnd),
179    HardBreak,
180    InlineHtml(String),
181    Ignored,
182    Rule,
183    SoftBreak,
184    Start(MarkdownStartTag),
185    Text(String),
186}
187
188enum MarkdownStartTag {
189    BlockQuote,
190    CodeBlock {
191        syntax: Option<String>,
192    },
193    Emphasis,
194    Heading {
195        level: HeadingLevel,
196    },
197    Image {
198        dest_url: String,
199        title: Option<String>,
200    },
201    Item,
202    Link {
203        dest_url: String,
204        title: Option<String>,
205    },
206    HtmlBlock,
207    List(Option<u64>),
208    Paragraph,
209    Strikethrough,
210    Strong,
211    Table,
212    TableCell,
213    TableHead,
214    TableRow,
215}
216
217/// Buffered link state during link inline content collection.
218struct LinkBuffer {
219    /// Link target URL.
220    href: String,
221    /// Whether `StartLink` has been emitted yet (deferred until first inline event arrives).
222    started: bool,
223    /// Optional link title (from `CommonMark` `[text](url "title")` syntax).
224    title: Option<String>,
225}
226
227/// A streaming Markdown reader that implements [`EventSource`].
228///
229/// `MarkdownReader` parses Markdown using `pulldown-cmark` and emits `DocSpec` events
230/// one at a time. It handles the mapping from `pulldown-cmark`'s event model to `DocSpec`'s
231/// event model, including tracking inline formatting state.
232///
233/// # Example
234///
235/// ```
236/// use docspec_markdown_reader::{MarkdownReader, EventSource};
237///
238/// let mut reader = MarkdownReader::from_str("**bold** and *italic*");
239/// while let Some(event) = reader.next_event()? {
240///     // Process events...
241/// }
242/// # Ok::<(), docspec_core::Error>(())
243/// ```
244pub struct MarkdownReader {
245    /// Current block-level context.
246    block_state: BlockState,
247    /// Owned source text and parser borrowing from it.
248    cell: ParserCell,
249    /// Buffered code block text (accumulated until `EndCodeBlock` to strip trailing newline).
250    code_block_buffer: Option<String>,
251    /// Buffered image being processed (alt text accumulation).
252    image: Option<ImageBuffer>,
253    /// Heading accumulator for block HTML fragments.
254    html_block_heading_acc: crate::html::translator::BlockHeadingAccumulator,
255    /// Inline style stack scoped to block HTML headings.
256    html_block_inline_stack: docspec_core::StyleStack,
257    /// Whether the parser is currently inside a pulldown HTML block wrapper.
258    in_html_block: bool,
259    /// Whether the parser is currently inside a preformatted code block.
260    in_preformatted: bool,
261    /// Whether the parser is currently inside a table header row.
262    in_table_head: bool,
263    /// Buffered link being processed (deferred Start emission for image-in-link extraction).
264    link: Option<LinkBuffer>,
265    /// LIFO stack of list contexts. `len()` gives the current nesting depth;
266    /// `level = list_stack.len().saturating_sub(1)` at item-emit time.
267    list_stack: alloc::vec::Vec<ListContext>,
268    /// Unified inline style stack shared by markdown emphasis and inline HTML.
269    inline_style_stack: docspec_core::StyleStack,
270    /// Document processing phase.
271    phase: Phase,
272    /// Queue of `DocSpec` events to emit.
273    queue: VecDeque<Event>,
274}
275
276impl MarkdownReader {
277    fn close_current_item_if_open(&mut self) {
278        let Some(ctx) = self.list_stack.last() else {
279            return;
280        };
281        if !ctx.item_open {
282            return;
283        }
284
285        let ordered = ctx.ordered;
286        self.flush_html_styles();
287        if ordered {
288            self.queue.push_back(Event::EndOrderedListItem);
289        } else {
290            self.queue.push_back(Event::EndUnorderedListItem);
291        }
292        if let Some(current_ctx) = self.list_stack.last_mut() {
293            current_ctx.item_open = false;
294        }
295        self.block_state = BlockState::None;
296    }
297
298    fn close_style(&mut self, kind: &TextStyleKind) {
299        if self.in_preformatted {
300            return;
301        }
302
303        for event in self.inline_style_stack.close(kind) {
304            self.queue.push_back(event);
305        }
306    }
307
308    fn open_style(&mut self, kind: &TextStyleKind) {
309        if !self.in_preformatted {
310            for event in self.inline_style_stack.open(kind.clone()) {
311                self.queue.push_back(event);
312            }
313        }
314    }
315
316    fn enqueue_text(&mut self, content: String) {
317        for event in self.inline_style_stack.note_text() {
318            self.queue.push_back(event);
319        }
320        let text_event = Event::Text { content };
321        self.queue.push_back(text_event);
322    }
323
324    fn flush_html_styles(&mut self) {
325        for event in self.inline_style_stack.close_all() {
326            self.queue.push_back(event);
327        }
328    }
329
330    /// Emits `StartLink` for the buffered link if it hasn't been emitted yet.
331    /// Called before any inline event that would belong inside a link.
332    fn emit_pending_link_start(&mut self) {
333        self.flush_pending_paragraph_start();
334        if let Some(link) = self.link.as_mut() {
335            if !link.started {
336                self.queue.push_back(Event::StartLink {
337                    href: link.href.clone(),
338                    id: None,
339                    title: link.title.clone(),
340                });
341                link.started = true;
342            }
343        }
344    }
345
346    /// Emits `StartParagraph` for the deferred paragraph if it hasn't been emitted yet.
347    /// Called before any committing event that would belong inside a paragraph.
348    fn flush_pending_paragraph_start(&mut self) {
349        if self.block_state == BlockState::PendingExplicit {
350            self.queue.push_back(Event::StartParagraph {
351                alignment: None,
352                id: None,
353            });
354            self.block_state = BlockState::Explicit;
355        }
356    }
357
358    fn from_owned_string(source: String) -> Self {
359        let options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
360        let cell = ParserCell::new(source, |s| MarkdownParser(Parser::new_ext(s, options)));
361        Self {
362            block_state: BlockState::None,
363            cell,
364            code_block_buffer: None,
365            image: None,
366            html_block_heading_acc: crate::html::translator::BlockHeadingAccumulator::default(),
367            html_block_inline_stack: docspec_core::StyleStack::default(),
368            in_html_block: false,
369            in_preformatted: false,
370            in_table_head: false,
371            link: None,
372            list_stack: Vec::new(),
373            inline_style_stack: docspec_core::StyleStack::default(),
374            phase: Phase::NotStarted,
375            queue: VecDeque::new(),
376        }
377    }
378
379    /// Creates a `MarkdownReader` from any `Read + Seek` source.
380    ///
381    /// Reads the entire source into memory (required by `pulldown_cmark`'s
382    /// borrow-based parser).
383    ///
384    /// # Errors
385    ///
386    /// Returns [`Error::Io`](docspec_core::Error::Io) if reading fails.
387    #[inline]
388    pub fn from_reader<R>(mut reader: R) -> Result<Self>
389    where
390        R: Read + Seek + Send + 'static,
391    {
392        let mut source = String::new();
393        reader.read_to_string(&mut source)?;
394        Ok(Self::from_owned_string(source))
395    }
396
397    /// Creates a `MarkdownReader` from a string slice.
398    ///
399    /// The input is copied into an owned `String` for the parser's lifetime.
400    ///
401    /// # Example
402    ///
403    /// ```
404    /// use docspec_markdown_reader::MarkdownReader;
405    ///
406    /// let reader = MarkdownReader::from_str("# Hello World");
407    /// ```
408    #[inline]
409    #[must_use]
410    #[expect(
411        clippy::should_implement_trait,
412        reason = "constructor name is required for reader API consistency"
413    )]
414    pub fn from_str(input: &str) -> Self {
415        Self::from_owned_string(input.to_owned())
416    }
417
418    fn handle_code(&mut self, content: String) {
419        if let Some(img) = &mut self.image {
420            img.alt_buf.push_str(&content);
421        } else {
422            self.emit_pending_link_start();
423            if self.block_state == BlockState::None {
424                self.queue.push_back(Event::StartParagraph {
425                    alignment: None,
426                    id: None,
427                });
428                self.block_state = BlockState::AutoParagraph;
429            }
430            self.open_style(&TextStyleKind::Code);
431            self.enqueue_text(content);
432            self.close_style(&TextStyleKind::Code);
433        }
434    }
435
436    /// Emits the buffered code block content (stripping the parser-added trailing newline)
437    /// followed by `EndPreformatted`. Skips the text event if the buffer is empty.
438    fn handle_end_code_block(&mut self) {
439        if let Some(buf) = self.code_block_buffer.take() {
440            let content = buf.strip_suffix('\n').unwrap_or(&buf).to_owned();
441            if !content.is_empty() {
442                self.enqueue_text(content);
443            }
444        }
445        self.in_preformatted = false;
446        self.push_event_end(Event::EndPreformatted);
447    }
448
449    /// Emits an `Image` event from the accumulated image buffer, deriving
450    /// `decorative = true` when the trimmed alt text is empty. Consumes the
451    /// in-progress image state; does nothing if no image is in progress.
452    fn handle_end_image(&mut self) {
453        let Some(img) = self.image.take() else { return };
454        self.flush_pending_paragraph_start();
455        let trimmed = img.alt_buf.trim();
456        let alt = if trimmed.is_empty() {
457            None
458        } else {
459            Some(trimmed.to_owned())
460        };
461        let decorative = alt.is_none();
462        self.queue.push_back(Event::Image {
463            source: ImageSource::Uri { uri: img.url },
464            alt,
465            title: img.title,
466            decorative,
467            id: None,
468        });
469    }
470
471    /// Closes an auto-opened paragraph if one is open, then closes the current
472    /// list item and resets block state.
473    fn handle_end_item(&mut self) {
474        if self.block_state == BlockState::AutoParagraph {
475            self.flush_html_styles();
476            self.queue.push_back(Event::EndParagraph);
477        }
478        self.close_current_item_if_open();
479        self.block_state = BlockState::None;
480    }
481
482    /// Emits `EndLink` (and `StartLink` if not yet emitted) for the buffered link.
483    fn handle_end_link(&mut self) {
484        let Some(link) = self.link.take() else { return };
485        if link.started {
486            self.queue.push_back(Event::EndLink);
487        } else {
488            self.flush_pending_paragraph_start();
489            self.queue.push_back(Event::StartLink {
490                href: link.href,
491                id: None,
492                title: link.title,
493            });
494            self.queue.push_back(Event::EndLink);
495        }
496    }
497
498    /// Closes the current list item if open, pops the list context, and resets block state.
499    fn handle_end_list(&mut self) {
500        self.close_current_item_if_open();
501        self.list_stack.pop();
502        self.block_state = BlockState::None;
503    }
504
505    /// Emits `EndTableCell` or `EndTableHeader` depending on whether the parser
506    /// is currently inside a table header row.
507    fn handle_end_table_cell(&mut self) {
508        if self.in_table_head {
509            self.push_event_end(Event::EndTableHeader);
510        } else {
511            self.push_event_end(Event::EndTableCell);
512        }
513    }
514
515    /// Emits `EndTableRow` and clears the table-head flag for a table head closing tag.
516    fn handle_end_table_head(&mut self) {
517        self.push_event_end(Event::EndTableRow);
518        self.in_table_head = false;
519    }
520
521    /// Dispatches a `pulldown-cmark` end tag to the appropriate per-tag handler.
522    ///
523    /// Tags in the explicit ignore list below are known-unsupported elements whose
524    /// structure is intentionally dropped (text content may still be extracted by
525    /// other event handlers).
526    fn handle_end_tag(&mut self, tag_end: TagEnd) {
527        match tag_end {
528            TagEnd::BlockQuote(_) => self.push_event_end(Event::EndBlockQuote),
529            TagEnd::CodeBlock => self.handle_end_code_block(),
530            TagEnd::Emphasis => self.close_style(&TextStyleKind::Italic),
531            TagEnd::Heading(_) => self.push_event_end(Event::EndHeading),
532            TagEnd::HtmlBlock => {
533                self.in_html_block = false;
534                for event in self.html_block_inline_stack.close_all() {
535                    self.queue.push_back(event);
536                }
537                if let Some(event) = self.html_block_heading_acc.finish_block() {
538                    self.queue.push_back(event);
539                }
540            }
541            TagEnd::Image => self.handle_end_image(),
542            TagEnd::Item => self.handle_end_item(),
543            TagEnd::Link => self.handle_end_link(),
544            TagEnd::List(_) => self.handle_end_list(),
545            TagEnd::Paragraph => {
546                if self.block_state == BlockState::PendingExplicit {
547                    self.flush_html_styles();
548                    self.block_state = BlockState::None;
549                } else {
550                    self.push_event_end(Event::EndParagraph);
551                }
552            }
553            TagEnd::Strikethrough => self.close_style(&TextStyleKind::Strikethrough),
554            TagEnd::Strong => self.close_style(&TextStyleKind::Bold),
555            TagEnd::Table => self.push_event_end(Event::EndTable),
556            TagEnd::TableCell => self.handle_end_table_cell(),
557            TagEnd::TableHead => self.handle_end_table_head(),
558            TagEnd::TableRow => self.push_event_end(Event::EndTableRow),
559            // Tags intentionally ignored (structure dropped, text extracted elsewhere):
560            TagEnd::DefinitionList
561            | TagEnd::DefinitionListDefinition
562            | TagEnd::DefinitionListTitle
563            | TagEnd::FootnoteDefinition
564            | TagEnd::MetadataBlock(_)
565            | TagEnd::Subscript
566            | TagEnd::Superscript => {}
567        }
568    }
569
570    fn handle_item_start(&mut self) {
571        let depth = self.list_stack.len().saturating_sub(1);
572        let level = u32::try_from(depth).map_or(u32::MAX, |v| v);
573        if let Some(ctx) = self.list_stack.last_mut() {
574            if ctx.ordered {
575                self.queue.push_back(Event::StartOrderedListItem {
576                    start: ctx.pending_start.take(),
577                    style_type: ListStyleType::Decimal,
578                    level,
579                    id: None,
580                });
581            } else {
582                self.queue.push_back(Event::StartUnorderedListItem {
583                    style_type: ListStyleType::Disc,
584                    level,
585                    id: None,
586                });
587            }
588            ctx.item_open = true;
589            self.block_state = BlockState::Explicit;
590        }
591    }
592
593    fn handle_list_start(&mut self, start_opt: Option<u64>) {
594        self.list_stack.push(ListContext {
595            item_open: false,
596            ordered: start_opt.is_some(),
597            pending_start: start_opt,
598        });
599    }
600
601    /// Emits `StartPreformatted` for a code block opening tag, initialising
602    /// the internal code-block buffer for content accumulation.
603    fn handle_start_code_block(&mut self, syntax: Option<String>) {
604        self.code_block_buffer = Some(String::new());
605        self.in_preformatted = true;
606        self.push_event_start(Event::StartPreformatted { id: None, syntax });
607    }
608
609    /// Emits `StartHeading` after mapping a `pulldown-cmark` `HeadingLevel` to a `u8` level.
610    fn handle_start_heading(&mut self, level: HeadingLevel) {
611        let level_u8 = match level {
612            HeadingLevel::H1 => 1,
613            HeadingLevel::H2 => 2,
614            HeadingLevel::H3 => 3,
615            HeadingLevel::H4 => 4,
616            HeadingLevel::H5 => 5,
617            HeadingLevel::H6 => 6,
618        };
619        self.push_event_start(Event::StartHeading {
620            level: level_u8,
621            id: None,
622        });
623    }
624
625    /// Initialises image state for alt-text accumulation when an image opening tag is
626    /// encountered. The title is stored as `None` when the pulldown-cmark title string
627    /// is empty.
628    fn handle_start_image(&mut self, dest_url: String, title: Option<String>) {
629        // Image-in-link extraction: close the link before processing the image so the
630        // image can be emitted as a sibling block (BlockNote and similar schemas do not
631        // allow block-level images inside inline links). When `link.started` is true, the
632        // link already contains preceding inline content — emit only `EndLink`. When it
633        // is false (image is the sole link label, e.g. `[![alt](img)](url)`), emit an
634        // empty `StartLink`/`EndLink` pair so the URL is preserved. `TagEnd::Image` fires
635        // `Event::Image` before `TagEnd::Paragraph`, so downstream writers close the
636        // surrounding paragraph before serialising the image as a sibling block.
637        self.flush_pending_paragraph_start();
638        if let Some(link) = self.link.take() {
639            if link.started {
640                self.queue.push_back(Event::EndLink);
641            } else {
642                self.queue.push_back(Event::StartLink {
643                    href: link.href,
644                    id: None,
645                    title: link.title,
646                });
647                self.queue.push_back(Event::EndLink);
648            }
649        }
650
651        self.image = Some(ImageBuffer {
652            alt_buf: String::new(),
653            title,
654            url: dest_url,
655        });
656    }
657
658    /// Stores link state for deferred `StartLink` emission.
659    ///
660    /// Emission is deferred until the first inline event arrives (lazy emission).
661    /// This allows image-in-link to be detected before any `StartLink` is emitted.
662    fn handle_start_link(&mut self, dest_url: String, title: Option<String>) {
663        self.link = Some(LinkBuffer {
664            href: dest_url,
665            started: false,
666            title,
667        });
668    }
669
670    /// Emits `StartTableHeader` or `StartTableCell` depending on whether the parser
671    /// is currently inside a table header row.
672    fn handle_start_table_cell(&mut self) {
673        if self.in_table_head {
674            self.push_event_start(Event::StartTableHeader {
675                scope: Some(TableHeaderScope::Column),
676                abbr: None,
677                colspan: None,
678                rowspan: None,
679                id: None,
680            });
681        } else {
682            self.push_event_start(Event::StartTableCell {
683                colspan: None,
684                rowspan: None,
685                id: None,
686            });
687        }
688    }
689
690    /// Sets the table-head flag and emits `StartTableRow` for a table head opening tag.
691    fn handle_start_table_head(&mut self) {
692        self.in_table_head = true;
693        self.push_event_start(Event::StartTableRow { id: None });
694    }
695
696    /// Dispatches a `pulldown-cmark` start tag to the appropriate per-tag handler.
697    ///
698    /// Tags in the explicit ignore list below are known-unsupported elements whose
699    /// structure is intentionally dropped (text content may still be extracted by
700    /// other event handlers).
701    fn handle_start_tag(&mut self, tag: MarkdownStartTag) {
702        match tag {
703            MarkdownStartTag::BlockQuote => {
704                self.push_event_start(Event::StartBlockQuote { id: None });
705            }
706            MarkdownStartTag::CodeBlock { syntax } => self.handle_start_code_block(syntax),
707            MarkdownStartTag::Emphasis => self.open_style(&TextStyleKind::Italic),
708            MarkdownStartTag::Heading { level } => self.handle_start_heading(level),
709            MarkdownStartTag::HtmlBlock => self.in_html_block = true,
710            MarkdownStartTag::Image { dest_url, title } => self.handle_start_image(dest_url, title),
711            MarkdownStartTag::Item => self.handle_item_start(),
712            MarkdownStartTag::Link { dest_url, title } => self.handle_start_link(dest_url, title),
713            MarkdownStartTag::List(start_opt) => self.handle_list_start(start_opt),
714            MarkdownStartTag::Paragraph => self.block_state = BlockState::PendingExplicit,
715            MarkdownStartTag::Strikethrough => self.open_style(&TextStyleKind::Strikethrough),
716            MarkdownStartTag::Strong => self.open_style(&TextStyleKind::Bold),
717            MarkdownStartTag::Table => self.push_event_start(Event::StartTable { id: None }),
718            MarkdownStartTag::TableCell => self.handle_start_table_cell(),
719            MarkdownStartTag::TableHead => self.handle_start_table_head(),
720            MarkdownStartTag::TableRow => self.push_event_start(Event::StartTableRow { id: None }),
721        }
722    }
723
724    fn handle_text(&mut self, content: String) {
725        if let Some(img) = &mut self.image {
726            img.alt_buf.push_str(&content);
727        } else if let Some(buf) = &mut self.code_block_buffer {
728            buf.push_str(&content);
729        } else {
730            self.emit_pending_link_start();
731            if self.block_state == BlockState::None {
732                self.queue.push_back(Event::StartParagraph {
733                    alignment: None,
734                    id: None,
735                });
736                self.block_state = BlockState::AutoParagraph;
737            }
738            self.enqueue_text(content);
739        }
740    }
741
742    fn next_pulldown_event(&mut self) -> Option<MarkdownPulldownEvent> {
743        self.cell.with_dependent_mut(|_, dep| {
744            dep.0.next().map(|event| match event {
745                pulldown_cmark::Event::Start(tag) => markdown_start_tag(tag)
746                    .map_or(MarkdownPulldownEvent::Ignored, MarkdownPulldownEvent::Start),
747                pulldown_cmark::Event::End(tag_end) => MarkdownPulldownEvent::End(tag_end),
748                pulldown_cmark::Event::Text(text) => {
749                    MarkdownPulldownEvent::Text(text.into_string())
750                }
751                pulldown_cmark::Event::Code(code) => {
752                    MarkdownPulldownEvent::Code(code.into_string())
753                }
754                pulldown_cmark::Event::HardBreak => MarkdownPulldownEvent::HardBreak,
755                pulldown_cmark::Event::SoftBreak => MarkdownPulldownEvent::SoftBreak,
756                pulldown_cmark::Event::Rule => MarkdownPulldownEvent::Rule,
757                pulldown_cmark::Event::InlineHtml(tag_str) => {
758                    MarkdownPulldownEvent::InlineHtml(tag_str.into_string())
759                }
760                pulldown_cmark::Event::Html(fragment) => {
761                    MarkdownPulldownEvent::BlockHtml(fragment.into_string())
762                }
763                pulldown_cmark::Event::DisplayMath(_)
764                | pulldown_cmark::Event::FootnoteReference(_)
765                | pulldown_cmark::Event::InlineMath(_)
766                | pulldown_cmark::Event::TaskListMarker(_) => MarkdownPulldownEvent::Ignored,
767            })
768        })
769    }
770
771    fn process_next_pulldown_event(&mut self) {
772        let Some(pm_event) = self.next_pulldown_event() else {
773            if self.phase != Phase::Finished {
774                self.phase = Phase::Finished;
775                self.flush_html_styles();
776                self.queue.push_back(Event::EndDocument);
777            }
778            return;
779        };
780
781        match pm_event {
782            MarkdownPulldownEvent::BlockHtml(fragment) => {
783                let events = crate::html::translator::translate_block(
784                    &fragment,
785                    &mut self.html_block_heading_acc,
786                    &mut self.html_block_inline_stack,
787                    self.in_preformatted,
788                );
789                for event in events {
790                    match event {
791                        Event::Text { content } => self.enqueue_text(content),
792                        other => self.queue.push_back(other),
793                    }
794                }
795            }
796            MarkdownPulldownEvent::Start(tag) => self.handle_start_tag(tag),
797            MarkdownPulldownEvent::End(tag_end) => self.handle_end_tag(tag_end),
798            MarkdownPulldownEvent::Text(text) => self.handle_text(text),
799            MarkdownPulldownEvent::Code(code) => self.handle_code(code),
800            MarkdownPulldownEvent::InlineHtml(fragment) => {
801                let events = crate::html::translator::translate_inline(
802                    &fragment,
803                    &mut self.inline_style_stack,
804                    self.in_preformatted,
805                );
806                for event in events {
807                    match event {
808                        Event::Text { content } => self.enqueue_text(content),
809                        other => self.queue.push_back(other),
810                    }
811                }
812            }
813            MarkdownPulldownEvent::HardBreak => {
814                if let Some(img) = &mut self.image {
815                    img.alt_buf.push(' ');
816                } else if self.block_state == BlockState::PendingExplicit {
817                    // emitting a break before StartParagraph would be malformed — discard
818                } else {
819                    self.emit_pending_link_start();
820                    self.queue.push_back(Event::LineBreak);
821                }
822            }
823            MarkdownPulldownEvent::SoftBreak => {
824                if let Some(img) = &mut self.image {
825                    img.alt_buf.push(' ');
826                } else if self.block_state == BlockState::PendingExplicit {
827                    // emitting a break before StartParagraph would be malformed — discard
828                } else {
829                    self.emit_pending_link_start();
830                    self.queue.push_back(Event::SoftBreak);
831                }
832            }
833            MarkdownPulldownEvent::Rule => {
834                self.queue.push_back(Event::ThematicBreak { id: None });
835            }
836            MarkdownPulldownEvent::Ignored => {}
837        }
838    }
839
840    fn push_event(&mut self, event: Event, state: BlockState) {
841        self.queue.push_back(event);
842        self.block_state = state;
843    }
844
845    fn push_event_end(&mut self, event: Event) {
846        self.flush_html_styles();
847        self.push_event(event, BlockState::None);
848    }
849
850    fn push_event_start(&mut self, event: Event) {
851        self.push_event(event, BlockState::Explicit);
852    }
853}
854
855impl EventSource for MarkdownReader {
856    #[inline]
857    fn next_event(&mut self) -> Result<Option<Event>> {
858        if self.phase == Phase::NotStarted {
859            self.phase = Phase::Running;
860            return Ok(Some(Event::StartDocument {
861                id: None,
862                language: None,
863                metadata: None,
864            }));
865        }
866
867        if self.phase == Phase::Finished && self.queue.is_empty() {
868            return Ok(None);
869        }
870
871        while self.queue.is_empty() && self.phase != Phase::Finished {
872            self.process_next_pulldown_event();
873        }
874
875        Ok(self.queue.pop_front())
876    }
877}
878
879fn markdown_start_tag(tag: Tag<'_>) -> Option<MarkdownStartTag> {
880    match tag {
881        Tag::BlockQuote(_) => Some(MarkdownStartTag::BlockQuote),
882        Tag::CodeBlock(kind) => Some(MarkdownStartTag::CodeBlock {
883            syntax: code_block_syntax(kind),
884        }),
885        Tag::Emphasis => Some(MarkdownStartTag::Emphasis),
886        Tag::Heading { level, .. } => Some(MarkdownStartTag::Heading { level }),
887        Tag::Image {
888            dest_url, title, ..
889        } => Some(MarkdownStartTag::Image {
890            dest_url: dest_url.into_string(),
891            title: cow_to_optional_string(title),
892        }),
893        Tag::HtmlBlock => Some(MarkdownStartTag::HtmlBlock),
894        Tag::Item => Some(MarkdownStartTag::Item),
895        Tag::Link {
896            dest_url, title, ..
897        } => Some(MarkdownStartTag::Link {
898            dest_url: dest_url.into_string(),
899            title: cow_to_optional_string(title),
900        }),
901        Tag::List(start_opt) => Some(MarkdownStartTag::List(start_opt)),
902        Tag::Paragraph => Some(MarkdownStartTag::Paragraph),
903        Tag::Strikethrough => Some(MarkdownStartTag::Strikethrough),
904        Tag::Strong => Some(MarkdownStartTag::Strong),
905        Tag::Table(_) => Some(MarkdownStartTag::Table),
906        Tag::TableCell => Some(MarkdownStartTag::TableCell),
907        Tag::TableHead => Some(MarkdownStartTag::TableHead),
908        Tag::TableRow => Some(MarkdownStartTag::TableRow),
909        Tag::DefinitionList
910        | Tag::DefinitionListDefinition
911        | Tag::DefinitionListTitle
912        | Tag::FootnoteDefinition(_)
913        | Tag::MetadataBlock(_)
914        | Tag::Subscript
915        | Tag::Superscript => None,
916    }
917}
918
919fn code_block_syntax(kind: CodeBlockKind<'_>) -> Option<String> {
920    match kind {
921        CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.into_string()),
922        CodeBlockKind::Fenced(_) | CodeBlockKind::Indented => None,
923    }
924}
925
926fn cow_to_optional_string(value: CowStr<'_>) -> Option<String> {
927    if value.is_empty() {
928        None
929    } else {
930        Some(value.into_string())
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937
938    #[test]
939    fn handle_code_without_open_block_auto_opens_paragraph() {
940        let mut reader = MarkdownReader::from_str("");
941        reader.handle_code("code".to_string());
942
943        assert_eq!(reader.queue.len(), 4);
944        assert_eq!(
945            reader.queue.front(),
946            Some(&Event::StartParagraph {
947                alignment: None,
948                id: None,
949            })
950        );
951        assert_eq!(
952            reader.queue.get(1),
953            Some(&Event::StartTextStyle {
954                kind: TextStyleKind::Code,
955                id: None,
956            })
957        );
958        assert_eq!(
959            reader.queue.get(2),
960            Some(&Event::Text {
961                content: "code".to_string(),
962            })
963        );
964        assert_eq!(reader.queue.get(3), Some(&Event::EndTextStyle));
965    }
966
967    #[test]
968    fn handle_text_without_open_block_auto_opens_paragraph() {
969        let mut reader = MarkdownReader::from_str("");
970        reader.handle_text("hello".to_string());
971
972        assert_eq!(reader.queue.len(), 2);
973        assert_eq!(
974            reader.queue.front(),
975            Some(&Event::StartParagraph {
976                alignment: None,
977                id: None,
978            })
979        );
980        assert_eq!(
981            reader.queue.get(1),
982            Some(&Event::Text {
983                content: "hello".to_string(),
984            })
985        );
986    }
987}
988
989#[cfg(test)]
990mod send_static_assertions {
991    fn assert_send_static<T>()
992    where
993        T: Send + 'static,
994    {
995    }
996
997    #[test]
998    fn markdown_reader_is_send_static() {
999        assert_send_static::<crate::MarkdownReader>();
1000    }
1001}