Skip to main content

docspec_html_writer/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Streaming HTML5 writer for `DocSpec` events.
4
5use docspec_core::{Event, EventSink, Result};
6use html5ever::serialize::{HtmlSerializer, SerializeOpts, Serializer as _};
7use html5ever::{local_name, ns, LocalName, QualName};
8use std::io::Write;
9
10/// A streaming HTML5 writer for `DocSpec` events.
11///
12/// Writes HTML5 markup directly to the underlying `Write` as events arrive.
13/// Implements [`EventSink`] for integration with the `DocSpec` pipeline.
14///
15/// # Type Parameters
16///
17/// * `W` - Any type implementing [`Write`]
18pub struct HtmlWriter<W: Write> {
19    finished: bool,
20    in_paragraph: bool,
21    serializer: HtmlSerializer<W>,
22    started: bool,
23}
24
25impl<W: Write> HtmlWriter<W> {
26    fn close(&mut self, local: LocalName) -> Result<()> {
27        let name = QualName::new(None, ns!(html), local);
28        self.serializer.end_elem(name)?;
29        Ok(())
30    }
31
32    /// Creates a new `HtmlWriter` that writes to the given writer.
33    #[inline]
34    #[must_use]
35    pub fn new(writer: W) -> Self {
36        Self {
37            serializer: HtmlSerializer::new(writer, SerializeOpts::default()),
38            started: false,
39            finished: false,
40            in_paragraph: false,
41        }
42    }
43
44    fn open(&mut self, local: LocalName) -> Result<()> {
45        let name = QualName::new(None, ns!(html), local);
46        self.serializer
47            .start_elem(name, core::iter::empty::<(&QualName, &str)>())?;
48        Ok(())
49    }
50}
51
52impl<W: Write> EventSink for HtmlWriter<W> {
53    #[inline]
54    fn finish(mut self) -> Result<()> {
55        self.serializer.writer.flush()?;
56        Ok(())
57    }
58
59    #[inline]
60    fn handle_event(&mut self, event: Event) -> Result<()> {
61        match event {
62            Event::StartDocument { .. } => {
63                if !self.started && !self.finished {
64                    self.open(local_name!("html"))?;
65                    self.open(local_name!("body"))?;
66                    self.started = true;
67                }
68            }
69            Event::EndDocument => {
70                if self.started && !self.finished {
71                    if self.in_paragraph {
72                        self.close(local_name!("p"))?;
73                        self.in_paragraph = false;
74                    }
75                    self.close(local_name!("body"))?;
76                    self.close(local_name!("html"))?;
77                    self.finished = true;
78                }
79            }
80            Event::StartParagraph { .. } => {
81                if self.started && !self.finished && !self.in_paragraph {
82                    self.open(local_name!("p"))?;
83                    self.in_paragraph = true;
84                }
85            }
86            Event::EndParagraph => {
87                if self.in_paragraph {
88                    self.close(local_name!("p"))?;
89                    self.in_paragraph = false;
90                }
91            }
92            Event::Text { content, .. } if self.in_paragraph => {
93                self.serializer.write_text(&content)?;
94            }
95            _ => {}
96        }
97        Ok(())
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    #![allow(clippy::panic_in_result_fn, clippy::unwrap_used)]
104
105    use super::HtmlWriter;
106    use docspec_core::{Event, EventSink as _, Result, TextStyle};
107
108    fn assert_output(events: impl IntoIterator<Item = Event>, expected: &str) {
109        let mut buf: Vec<u8> = Vec::new();
110        let mut writer = HtmlWriter::new(&mut buf);
111        for e in events {
112            let _r = writer.handle_event(e);
113        }
114        let _r = writer.finish();
115        let output = String::from_utf8(buf).unwrap();
116        assert_eq!(output, expected);
117    }
118
119    #[test]
120    fn autoclose_paragraph_on_enddocument() {
121        assert_output(
122            [
123                Event::StartDocument {
124                    id: None,
125                    language: None,
126                    metadata: None,
127                },
128                Event::StartParagraph {
129                    alignment: None,
130                    id: None,
131                },
132                Event::Text {
133                    content: "oops".to_string(),
134                    style: TextStyle::default(),
135                },
136                Event::EndDocument,
137            ],
138            "<html><body><p>oops</p></body></html>",
139        );
140    }
141
142    #[test]
143    fn double_start_document_is_noop() {
144        assert_output(
145            [
146                Event::StartDocument {
147                    id: None,
148                    language: None,
149                    metadata: None,
150                },
151                Event::StartDocument {
152                    id: None,
153                    language: None,
154                    metadata: None,
155                },
156                Event::EndDocument,
157            ],
158            "<html><body></body></html>",
159        );
160    }
161
162    #[test]
163    fn empty_document_exact_output() {
164        assert_output(
165            [
166                Event::StartDocument {
167                    id: None,
168                    language: None,
169                    metadata: None,
170                },
171                Event::EndDocument,
172            ],
173            "<html><body></body></html>",
174        );
175    }
176
177    #[test]
178    fn end_paragraph_without_start() {
179        assert_output(
180            [
181                Event::StartDocument {
182                    id: None,
183                    language: None,
184                    metadata: None,
185                },
186                Event::EndParagraph,
187                Event::EndDocument,
188            ],
189            "<html><body></body></html>",
190        );
191    }
192
193    #[test]
194    fn escapes_special_chars() {
195        assert_output(
196            [
197                Event::StartDocument {
198                    id: None,
199                    language: None,
200                    metadata: None,
201                },
202                Event::StartParagraph {
203                    alignment: None,
204                    id: None,
205                },
206                Event::Text {
207                    content: "a & b < c > d".to_string(),
208                    style: TextStyle::default(),
209                },
210                Event::EndParagraph,
211                Event::EndDocument,
212            ],
213            "<html><body><p>a &amp; b &lt; c &gt; d</p></body></html>",
214        );
215    }
216
217    #[test]
218    fn finish_after_normal_document_succeeds() -> Result<()> {
219        let mut buf: Vec<u8> = Vec::new();
220        let mut writer = HtmlWriter::new(&mut buf);
221        writer.handle_event(Event::StartDocument {
222            id: None,
223            language: None,
224            metadata: None,
225        })?;
226        writer.handle_event(Event::StartParagraph {
227            alignment: None,
228            id: None,
229        })?;
230        writer.handle_event(Event::Text {
231            content: "hello".to_string(),
232            style: TextStyle::default(),
233        })?;
234        writer.handle_event(Event::EndParagraph)?;
235        writer.handle_event(Event::EndDocument)?;
236        writer.finish()
237    }
238
239    #[test]
240    fn ignored_events_no_effect() {
241        assert_output(
242            [
243                Event::StartDocument {
244                    id: None,
245                    language: None,
246                    metadata: None,
247                },
248                Event::StartHeading { level: 1, id: None },
249                Event::EndHeading,
250                Event::StartParagraph {
251                    alignment: None,
252                    id: None,
253                },
254                Event::Text {
255                    content: "x".to_string(),
256                    style: TextStyle::default(),
257                },
258                Event::EndParagraph,
259                Event::ThematicBreak { id: None },
260                Event::EndDocument,
261            ],
262            "<html><body><p>x</p></body></html>",
263        );
264    }
265
266    #[test]
267    fn paragraph_with_text() {
268        assert_output(
269            [
270                Event::StartDocument {
271                    id: None,
272                    language: None,
273                    metadata: None,
274                },
275                Event::StartParagraph {
276                    alignment: None,
277                    id: None,
278                },
279                Event::Text {
280                    content: "hello".to_string(),
281                    style: TextStyle::default(),
282                },
283                Event::EndParagraph,
284                Event::EndDocument,
285            ],
286            "<html><body><p>hello</p></body></html>",
287        );
288    }
289
290    #[test]
291    fn start_paragraph_while_in_paragraph() {
292        assert_output(
293            [
294                Event::StartDocument {
295                    id: None,
296                    language: None,
297                    metadata: None,
298                },
299                Event::StartParagraph {
300                    alignment: None,
301                    id: None,
302                },
303                Event::StartParagraph {
304                    alignment: None,
305                    id: None,
306                },
307                Event::Text {
308                    content: "x".to_string(),
309                    style: TextStyle::default(),
310                },
311                Event::EndParagraph,
312                Event::EndDocument,
313            ],
314            "<html><body><p>x</p></body></html>",
315        );
316    }
317
318    #[test]
319    fn text_outside_paragraph_ignored() {
320        assert_output(
321            [
322                Event::StartDocument {
323                    id: None,
324                    language: None,
325                    metadata: None,
326                },
327                Event::Text {
328                    content: "ignored".to_string(),
329                    style: TextStyle::default(),
330                },
331                Event::EndDocument,
332            ],
333            "<html><body></body></html>",
334        );
335    }
336}