Skip to main content

docspec_html_reader/
lib.rs

1//! HTML to `DocSpec` event stream reader.
2//!
3//! This crate provides an [`HtmlReader`] that implements [`EventSource`] to convert
4//! HTML documents into the `DocSpec` event stream format. It uses `html5gum`
5//! to parse HTML5-compliant markup and emits typed events representing document
6//! structure.
7//!
8//! # Quick Start
9//!
10//! ```
11//! use docspec_html_reader::{HtmlReader, EventSource};
12//!
13//! let html = "<p>Hello world</p>";
14//! let mut reader = HtmlReader::from_str(html);
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//! - Paragraphs → `StartParagraph` / `EndParagraph`
25//!
26//! # Unsupported Elements
27//!
28//! All other HTML elements are silently ignored. Text content inside inline
29//! elements (e.g., `<strong>`, `<em>`) is preserved as `Text` events, but
30//! the formatting structure is dropped.
31//!
32//! # Streaming
33//!
34//! `HtmlReader` streams its source via `html5gum::IoReader`'s 16 KB sliding-window
35//! buffer. Memory usage is constant regardless of document size — the document need
36//! not fit in memory. Both [`HtmlReader::from_str`] and [`HtmlReader::from_reader`]
37//! use this streaming path internally.
38
39extern crate alloc;
40
41use alloc::collections::VecDeque;
42use std::io::{Cursor, Read, Seek};
43
44pub use docspec_core::EventSource;
45use docspec_core::{Event, Result};
46use html5gum::{IoReader, Tokenizer};
47
48/// Document processing phase.
49#[derive(Clone, Copy, PartialEq, Eq)]
50enum Phase {
51    /// `EndDocument` has been emitted.
52    Finished,
53    /// `StartDocument` not yet emitted.
54    NotStarted,
55    /// Processing events between `StartDocument` and `EndDocument`.
56    Running,
57}
58
59/// A streaming HTML reader that implements [`EventSource`].
60///
61/// `HtmlReader` parses HTML using `html5gum` and emits `DocSpec` events
62/// one at a time. Only `<p>` paragraph elements are recognized; all other
63/// elements are silently ignored.
64///
65/// # Example
66///
67/// ```
68/// use docspec_html_reader::{HtmlReader, EventSource};
69///
70/// let mut reader = HtmlReader::from_str("<p>hello</p>");
71/// while let Some(event) = reader.next_event()? {
72///     // Process events...
73/// }
74/// # Ok::<(), docspec_core::Error>(())
75/// ```
76pub struct HtmlReader {
77    /// Whether the reader is currently inside a `<p>` element.
78    in_paragraph: bool,
79    /// Document processing phase.
80    phase: Phase,
81    /// Queue of `DocSpec` events to emit.
82    queue: VecDeque<Event>,
83    /// The html5gum tokenizer iterator.
84    tokens: Tokenizer<IoReader<Box<dyn Read + Send>>>,
85}
86
87impl HtmlReader {
88    /// Pops the front event from the queue, if any.
89    fn drain_queue(&mut self) -> Option<Event> {
90        self.queue.pop_front()
91    }
92
93    fn from_boxed_reader(reader: Box<dyn Read + Send>) -> Self {
94        Self {
95            in_paragraph: false,
96            phase: Phase::NotStarted,
97            queue: VecDeque::new(),
98            tokens: Tokenizer::new(IoReader::new(reader)),
99        }
100    }
101
102    /// Creates an `HtmlReader` from any `Read + Seek` source.
103    ///
104    /// The source is streamed via `html5gum::IoReader`'s 16 KB sliding-window
105    /// buffer. Memory usage is constant regardless of document size.
106    ///
107    /// The `Seek` bound is required for API symmetry with other `DocSpec` readers
108    /// (e.g., `DocxReader`), even though `html5gum::IoReader` only needs `Read`.
109    ///
110    /// # Errors
111    ///
112    /// This constructor does not read from the source eagerly and therefore
113    /// currently cannot fail.
114    #[inline]
115    pub fn from_reader<R>(reader: R) -> Result<Self>
116    where
117        R: Read + Seek + Send + 'static,
118    {
119        let boxed: Box<dyn Read + Send> = Box::new(reader);
120        Ok(Self::from_boxed_reader(boxed))
121    }
122
123    /// Creates an `HtmlReader` from a string slice.
124    ///
125    /// The input bytes are copied into an owned `Vec<u8>` and streamed via
126    /// `html5gum::IoReader`'s 16 KB sliding-window buffer.
127    #[expect(
128        clippy::should_implement_trait,
129        reason = "Public API requires an infallible constructor named from_str."
130    )]
131    #[inline]
132    #[must_use]
133    pub fn from_str(input: &str) -> Self {
134        let bytes: Vec<u8> = input.as_bytes().to_vec();
135        let reader: Box<dyn Read + Send> = Box::new(Cursor::new(bytes));
136        Self::from_boxed_reader(reader)
137    }
138
139    /// Translates an end tag token into queued events.
140    fn handle_end_tag(&mut self, tag: &html5gum::EndTag<()>) {
141        if &*tag.name == b"p" && self.in_paragraph {
142            self.queue.push_back(Event::EndParagraph);
143            self.in_paragraph = false;
144        }
145        // Orphan </p> or non-p end tags: silently ignore
146    }
147
148    /// Handles end-of-input: auto-closes any open paragraph and emits `EndDocument`.
149    fn handle_eof(&mut self) {
150        if self.in_paragraph {
151            self.queue.push_back(Event::EndParagraph);
152            self.in_paragraph = false;
153        }
154        self.queue.push_back(Event::EndDocument);
155        self.phase = Phase::Finished;
156    }
157
158    /// Translates a start tag token into queued events.
159    fn handle_start_tag(&mut self, tag: &html5gum::StartTag<()>) {
160        if &*tag.name != b"p" || self.in_paragraph {
161            // Nested <p> while already in paragraph: silently ignore (including self-closing nested)
162            // All other tags: silently ignore
163            return;
164        }
165
166        self.queue.push_back(Event::StartParagraph {
167            alignment: None,
168            id: None,
169        });
170        self.in_paragraph = true;
171        if tag.self_closing {
172            self.queue.push_back(Event::EndParagraph);
173            self.in_paragraph = false;
174        }
175    }
176
177    /// Translates a text token into a queued event.
178    ///
179    /// # Errors
180    ///
181    /// Returns `Err` if the text bytes are not valid UTF-8.
182    fn handle_text(&mut self, text_bytes: &[u8]) -> Result<()> {
183        if self.in_paragraph {
184            let text =
185                core::str::from_utf8(text_bytes).map_err(|e| docspec_core::Error::Parse {
186                    message: format!("invalid UTF-8 in HTML text: {e}"),
187                    position: None,
188                })?;
189            self.queue.push_back(Event::Text {
190                content: text.to_string(),
191            });
192        }
193        Ok(())
194    }
195}
196
197impl EventSource for HtmlReader {
198    #[inline]
199    fn next_event(&mut self) -> Result<Option<Event>> {
200        loop {
201            if let Some(event) = self.drain_queue() {
202                return Ok(Some(event));
203            }
204
205            match self.phase {
206                Phase::NotStarted => {
207                    self.phase = Phase::Running;
208                    self.queue.push_back(Event::StartDocument {
209                        id: None,
210                        language: None,
211                        metadata: None,
212                    });
213                }
214                Phase::Finished => {
215                    return Ok(None);
216                }
217                Phase::Running => {
218                    let Some(result) = self.tokens.next() else {
219                        self.handle_eof();
220                        continue;
221                    };
222                    match result {
223                        Ok(token) => match token {
224                            html5gum::Token::StartTag(tag) => {
225                                self.handle_start_tag(&tag);
226                            }
227                            html5gum::Token::EndTag(tag) => {
228                                self.handle_end_tag(&tag);
229                            }
230                            html5gum::Token::String(spanned) => {
231                                self.handle_text(&spanned.value.0)?;
232                            }
233                            html5gum::Token::Comment(_) | html5gum::Token::Doctype(_) => {
234                                // Silently ignore
235                            }
236                            html5gum::Token::Error(spanned) => {
237                                return Err(docspec_core::Error::Parse {
238                                    message: format!("html5gum: {:?}", spanned.value),
239                                    position: None,
240                                });
241                            }
242                        },
243                        Err(source) => return Err(docspec_core::Error::Io { source }),
244                    }
245                }
246            }
247        }
248    }
249}
250
251#[cfg(test)]
252mod send_static_assertions {
253    fn assert_send_static<T>()
254    where
255        T: Send + 'static,
256    {
257    }
258
259    #[test]
260    fn html_reader_is_send_static() {
261        assert_send_static::<crate::HtmlReader>();
262    }
263}