1#![forbid(unsafe_code)]
2
3use docspec_core::{Event, EventSink, Result};
6use html5ever::serialize::{HtmlSerializer, SerializeOpts, Serializer as _};
7use html5ever::{local_name, namespace_url, ns, LocalName, QualName};
8use std::io::Write;
9
10pub 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 #[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 & b < c > 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}