Skip to main content

base_markdown/
event_html_renderer.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4//! One HTML writer for parser events and document traversal.
5use crate::stream::{Event, EventKind, StreamError, Tag, TagKind, Text};
6use crate::util::{
7    constant::{SAFE_PROTOCOL_HREF, SAFE_PROTOCOL_SRC},
8    encode::{find_escape, write_escaped},
9    sanitize_uri::{sanitize, write_sanitized},
10};
11use crate::{CompileOptions, LineEnding};
12use alloc::{
13    string::{String, ToString},
14    vec::Vec,
15};
16use core::{
17    convert::TryFrom,
18    fmt::{self, Write},
19};
20
21// Every u64, including the supported targets' usize counters, fits in 20 decimal digits.
22fn decimal(mut value: u64, buffer: &mut [u8; 20]) -> &str {
23    let mut start = buffer.len();
24    loop {
25        start -= 1;
26        buffer[start] = b'0' + u8::try_from(value % 10).expect("a decimal digit fits in u8");
27        value /= 10;
28        if value == 0 {
29            break;
30        }
31    }
32    core::str::from_utf8(&buffer[start..]).expect("decimal digits are ASCII")
33}
34
35struct Frame {
36    kind: TagKind,
37    flag: bool,
38    level: u8,
39    end: usize,
40}
41struct HeadingCapture {
42    output: String,
43    text: String,
44}
45struct FootnoteCall {
46    key: String,
47    id: String,
48    count: usize,
49    definition: Option<usize>,
50}
51
52struct Capture {
53    key: String,
54    output: String,
55    previous: Option<u8>,
56}
57
58/// Render semantic events directly into a caller-owned writer.
59///
60/// Parser output and [`crate::Document::events`] use exactly this renderer. The renderer retains nesting state and footnote bodies, never the full event stream.
61pub struct EventHtmlRenderer<'a, W> {
62    output: &'a mut W,
63    source: &'a str,
64    options: &'a CompileOptions,
65    line_ending: LineEnding,
66    last: Option<u8>,
67    stack: Vec<Frame>,
68    images: usize,
69    image_titles: Vec<Text<'static>>,
70    captures: Vec<Capture>,
71    notes: Vec<(String, String)>,
72    calls: Vec<FootnoteCall>,
73    started: bool,
74    ended: bool,
75    heading: Option<HeadingCapture>,
76    anchors: crate::headings::AnchorGenerator,
77}
78
79impl<'a, W: Write> EventHtmlRenderer<'a, W> {
80    /// Construct a renderer writing directly into `output`.
81    pub fn new(source: &'a str, options: &'a CompileOptions, output: &'a mut W) -> Self {
82        Self {
83            output,
84            source,
85            options,
86            line_ending: options.default_line_ending.clone(),
87            last: None,
88            stack: Vec::new(),
89            images: 0,
90            image_titles: Vec::new(),
91            captures: Vec::new(),
92            notes: Vec::new(),
93            calls: Vec::new(),
94            started: false,
95            ended: false,
96            heading: None,
97            anchors: crate::headings::AnchorGenerator::default(),
98        }
99    }
100
101    // Paired mixed-document benchmarks justify inlining these small writes.
102    #[allow(clippy::inline_always)]
103    #[inline(always)]
104    fn write(&mut self, value: &str) -> Result<(), StreamError> {
105        if let Some(heading) = self.heading.as_mut() {
106            heading.output.push_str(value);
107        } else if let Some(capture) = self.captures.last_mut() {
108            capture.output.push_str(value);
109        } else {
110            self.output.write_str(value)?;
111        }
112        if let Some(&byte) = value.as_bytes().last() {
113            self.last = Some(byte);
114        }
115        Ok(())
116    }
117
118    fn escaped(&mut self, value: &str, encode: bool) -> Result<(), StreamError> {
119        let index = find_escape(value.as_bytes(), encode);
120        // Ordinary spans need only one write; select a destination for the replacement loop only when an escape is present.
121        if index == value.len() {
122            return self.write(value);
123        }
124        if let Some(heading) = self.heading.as_mut() {
125            write_escaped(&mut heading.output, &mut self.last, value, encode, index)
126        } else if let Some(capture) = self.captures.last_mut() {
127            write_escaped(&mut capture.output, &mut self.last, value, encode, index)
128        } else {
129            write_escaped(self.output, &mut self.last, value, encode, index)
130        }
131        .map_err(StreamError::Write)
132    }
133
134    fn newline(&mut self) -> Result<(), StreamError> {
135        let value = self.line_ending.clone();
136        self.write(value.as_str())
137    }
138    fn number(&mut self, value: u64) -> Result<(), StreamError> {
139        self.write(decimal(value, &mut [0; 20]))
140    }
141    fn terminate(&mut self) -> Result<(), StreamError> {
142        if !matches!(self.last, None | Some(b'\r' | b'\n')) {
143            self.newline()?;
144        }
145        Ok(())
146    }
147
148    fn destination(&mut self, value: &str, image: bool) -> Result<(), StreamError> {
149        let protocols =
150            if self.options.allow_dangerous_protocol || (image && self.options.allow_any_img_src) {
151                None
152            } else if image {
153                Some(SAFE_PROTOCOL_SRC.as_slice())
154            } else {
155                Some(SAFE_PROTOCOL_HREF.as_slice())
156            };
157        write_sanitized(value, protocols, |value| self.write(value))
158    }
159
160    fn start(&mut self, tag: &Tag<'_>, end: usize) -> Result<(), StreamError> {
161        let kind = tag.kind();
162        if kind == TagKind::Document {
163            if self.started || !self.stack.is_empty() {
164                return Err(StreamError::Unbalanced);
165            }
166            self.started = true;
167        } else if self.stack.is_empty() {
168            return Err(StreamError::Unbalanced);
169        }
170        if matches!(kind, TagKind::Heading | TagKind::FootnoteDefinition)
171            && self
172                .stack
173                .iter()
174                .any(|frame| !frame.kind.allows_descendant(kind))
175        {
176            return Err(StreamError::Unbalanced);
177        }
178        let mut frame = Frame {
179            kind,
180            flag: false,
181            level: 0,
182            end,
183        };
184        // Tags inside image alternative text contribute only their text content.
185        let tags = self.images == 0;
186        if !tags
187            && matches!(
188                kind,
189                TagKind::Table
190                    | TagKind::TableHead
191                    | TagKind::TableBody
192                    | TagKind::TableRow
193                    | TagKind::TableCell
194            )
195        {
196            self.stack.push(frame);
197            return Ok(());
198        }
199        match tag {
200            Tag::Document(line_ending) => {
201                if let Some(value) = line_ending {
202                    self.line_ending = value.clone();
203                }
204            }
205            Tag::Paragraph { tight } => {
206                frame.flag = *tight;
207                if tags && !tight {
208                    self.terminate()?;
209                    self.write("<p>")?;
210                }
211            }
212            Tag::Heading(level) => {
213                frame.level = *level;
214                self.terminate()?;
215                if self.options.heading_ids {
216                    self.heading = Some(HeadingCapture {
217                        output: String::new(),
218                        text: String::new(),
219                    });
220                } else {
221                    self.write(
222                        ["<h1>", "<h2>", "<h3>", "<h4>", "<h5>", "<h6>"][usize::from(*level - 1)],
223                    )?;
224                }
225            }
226            Tag::BlockQuote => {
227                if tags {
228                    self.terminate()?;
229                    self.write("<blockquote>")?;
230                }
231            }
232            Tag::List { start, .. } => {
233                frame.flag = start.is_some();
234                if tags {
235                    self.terminate()?;
236                    match start {
237                        None => self.write("<ul>")?,
238                        Some(1) => self.write("<ol>")?,
239                        Some(start) => {
240                            self.write("<ol start=\"")?;
241                            self.number(u64::from(*start))?;
242                            self.write("\">")?;
243                        }
244                    }
245                }
246            }
247            Tag::Item { block_end } => {
248                frame.flag = *block_end;
249                if tags {
250                    self.terminate()?;
251                    self.write("<li>")?;
252                }
253            }
254            Tag::Emphasis => {
255                if tags {
256                    self.write("<em>")?;
257                }
258            }
259            Tag::Strong => {
260                if tags {
261                    self.write("<strong>")?;
262                }
263            }
264            Tag::Delete => {
265                if tags {
266                    self.write("<del>")?;
267                }
268            }
269            Tag::Link { destination, title } => {
270                if tags {
271                    self.write("<a href=\"")?;
272                    self.destination(
273                        destination
274                            .resolve(self.source)
275                            .expect("event values are source-valid"),
276                        false,
277                    )?;
278                    if let Some(title) = title {
279                        self.write("\" title=\"")?;
280                        self.escaped(
281                            title
282                                .resolve(self.source)
283                                .expect("event values are source-valid"),
284                            true,
285                        )?;
286                    }
287                    self.write("\">")?;
288                }
289            }
290            Tag::Image { destination, title } => {
291                frame.flag = tags;
292                if tags {
293                    self.write("<img src=\"")?;
294                    self.destination(
295                        destination
296                            .resolve(self.source)
297                            .expect("event values are source-valid"),
298                        true,
299                    )?;
300                    self.write("\" alt=\"")?;
301                }
302                // The title belongs after alt. Retain its value until the image closes.
303                if tags {
304                    if let Some(title) = title {
305                        frame.level = 1;
306                        self.image_titles.push(title.borrowed().owned());
307                    }
308                }
309                self.images += 1;
310            }
311            Tag::Table => {
312                self.terminate()?;
313                self.write("<table>")?;
314            }
315            Tag::TableHead => {
316                self.terminate()?;
317                self.write("<thead>")?;
318            }
319            Tag::TableBody => self.write("<tbody>")?,
320            Tag::TableRow => {
321                self.terminate()?;
322                self.write("<tr>")?;
323            }
324            Tag::TableCell { alignment, header } => {
325                frame.flag = *header;
326                self.terminate()?;
327                self.write(match (header, alignment) {
328                    (true, crate::AlignKind::Left) => "<th align=\"left\">",
329                    (true, crate::AlignKind::Right) => "<th align=\"right\">",
330                    (true, crate::AlignKind::Center) => "<th align=\"center\">",
331                    (true, crate::AlignKind::None) => "<th>",
332                    (false, crate::AlignKind::Left) => "<td align=\"left\">",
333                    (false, crate::AlignKind::Right) => "<td align=\"right\">",
334                    (false, crate::AlignKind::Center) => "<td align=\"center\">",
335                    (false, crate::AlignKind::None) => "<td>",
336                })?;
337            }
338            Tag::FootnoteDefinition(label) => {
339                let key = label
340                    .resolve(self.source)
341                    .expect("event values are source-valid")
342                    .to_string();
343                self.captures.push(Capture {
344                    key,
345                    output: String::new(),
346                    previous: self.last,
347                });
348                self.last = None;
349            }
350        }
351        self.stack.push(frame);
352        Ok(())
353    }
354
355    fn end(&mut self, kind: TagKind, end: usize) -> Result<(), StreamError> {
356        let frame = self.stack.pop().ok_or(StreamError::Unbalanced)?;
357        if frame.kind != kind {
358            return Err(StreamError::Unbalanced);
359        }
360        if end < frame.end {
361            return Err(StreamError::InvalidRange);
362        }
363        if kind == TagKind::Image {
364            self.images -= 1;
365            if frame.flag {
366                if frame.level != 0 {
367                    let title = self.image_titles.pop().unwrap();
368                    self.write("\" title=\"")?;
369                    self.escaped(
370                        title
371                            .resolve(self.source)
372                            .expect("event values are source-valid"),
373                        true,
374                    )?;
375                }
376                self.write("\" />")?;
377            }
378            return Ok(());
379        }
380        if self.images != 0 {
381            return Ok(());
382        }
383        match kind {
384            TagKind::Document => {
385                self.footnotes()?;
386                self.ended = true;
387            }
388            TagKind::Paragraph => {
389                if !frame.flag {
390                    self.write("</p>")?;
391                }
392            }
393            TagKind::Heading => {
394                if let Some(heading) = self.heading.take() {
395                    let text = crate::util::line_ending::trim_terminal_ending(&heading.text);
396                    let anchor = self.anchors.anchor_for(text);
397                    self.write(
398                        [
399                            "<h1 id=\"",
400                            "<h2 id=\"",
401                            "<h3 id=\"",
402                            "<h4 id=\"",
403                            "<h5 id=\"",
404                            "<h6 id=\"",
405                        ][usize::from(frame.level - 1)],
406                    )?;
407                    self.escaped(&anchor, true)?;
408                    self.write("\">")?;
409                    self.write(&heading.output)?;
410                }
411                self.write(
412                    ["</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"]
413                        [usize::from(frame.level - 1)],
414                )?;
415            }
416            TagKind::BlockQuote => {
417                self.terminate()?;
418                self.write("</blockquote>")?;
419            }
420            TagKind::List => {
421                self.newline()?;
422                self.write(if frame.flag { "</ol>" } else { "</ul>" })?;
423            }
424            TagKind::Item => {
425                if frame.flag {
426                    self.terminate()?;
427                }
428                self.write("</li>")?;
429            }
430            TagKind::Emphasis => self.write("</em>")?,
431            TagKind::Strong => self.write("</strong>")?,
432            TagKind::Delete => self.write("</del>")?,
433            TagKind::Link => self.write("</a>")?,
434            TagKind::Table => {
435                self.terminate()?;
436                self.write("</table>")?;
437            }
438            TagKind::TableHead => {
439                self.terminate()?;
440                self.write("</thead>")?;
441            }
442            TagKind::TableBody => {
443                self.terminate()?;
444                self.write("</tbody>")?;
445            }
446            TagKind::TableRow => {
447                self.terminate()?;
448                self.write("</tr>")?;
449            }
450            TagKind::TableCell => self.write(if frame.flag { "</th>" } else { "</td>" })?,
451            TagKind::FootnoteDefinition => {
452                let capture = self.captures.pop().unwrap();
453                self.last = capture.previous;
454                self.notes.push((capture.key, capture.output));
455            }
456            TagKind::Image => {
457                unreachable!("images were handled before suppressing tags in alt text")
458            }
459        }
460        Ok(())
461    }
462
463    /// Pull an entire event source into the output writer.
464    ///
465    /// # Errors
466    /// Propagates parse, source-range, malformed-stream, and output errors.
467    pub fn render<'e>(
468        &mut self,
469        events: impl IntoIterator<Item = Result<Event<'e>, StreamError>>,
470    ) -> Result<(), StreamError> {
471        self.render_inner::<true>(events)
472    }
473
474    /// Internal parser rendering uses the producer's range and tag invariants.
475    /// Keep arbitrary public event sources on the checked path above.
476    pub(crate) fn render_markdown(
477        &mut self,
478        options: &crate::ParseOptions,
479    ) -> Result<(), StreamError> {
480        let events = crate::EventParser::new(self.source, options);
481        self.render_inner::<false>(events)
482    }
483
484    fn render_inner<'e, const CHECK: bool>(
485        &mut self,
486        events: impl IntoIterator<Item = Result<Event<'e>, StreamError>>,
487    ) -> Result<(), StreamError> {
488        for event in events {
489            self.event_ref::<CHECK>(&event?)?;
490        }
491        if !self.started || !self.ended || !self.stack.is_empty() {
492            return Err(StreamError::Unbalanced);
493        }
494        Ok(())
495    }
496
497    /// Consume one event without retaining it.
498    ///
499    /// # Errors
500    /// Propagates invalid source ranges, malformed streams, and writer errors.
501    // Consume the public event while borrowing it through internal helpers to avoid copies.
502    #[allow(clippy::needless_pass_by_value)]
503    pub fn event(&mut self, event: Event<'_>) -> Result<(), StreamError> {
504        self.event_ref::<true>(&event)
505    }
506
507    // Paired mixed-document benchmarks justify inlining this event boundary.
508    #[allow(clippy::inline_always)]
509    #[inline(always)]
510    fn event_ref<const CHECK: bool>(&mut self, event: &Event<'_>) -> Result<(), StreamError> {
511        if self.ended {
512            return Err(StreamError::Unbalanced);
513        }
514        if CHECK {
515            event.validate(Some(self.source))?;
516        }
517        if let Some(heading) = &mut self.heading {
518            crate::headings::append_text::<true>(&event.kind, self.source, &mut heading.text);
519        }
520        match &event.kind {
521            EventKind::Start(tag) => self.start(tag, event.span.end),
522            EventKind::End(kind) => self.end(*kind, event.span.end),
523            kind => {
524                if self.stack.is_empty() {
525                    return Err(StreamError::Unbalanced);
526                }
527                match kind {
528                    EventKind::Text(value) | EventKind::SoftBreak(value) => self.escaped(
529                        value
530                            .resolve(self.source)
531                            .expect("event values are source-valid"),
532                        true,
533                    ),
534                    EventKind::Separator { value, conditional } => {
535                        if *conditional {
536                            self.terminate()
537                        } else {
538                            self.escaped(
539                                value
540                                    .resolve(self.source)
541                                    .expect("event values are source-valid"),
542                                true,
543                            )
544                        }
545                    }
546                    EventKind::HardBreak => {
547                        if self.images == 0 {
548                            self.write("<br />")
549                        } else {
550                            Ok(())
551                        }
552                    }
553                    EventKind::Rule => {
554                        if self.images == 0 {
555                            self.terminate()?;
556                            self.write("<hr />")
557                        } else {
558                            Ok(())
559                        }
560                    }
561                    EventKind::Code { value, language } => {
562                        if self.images == 0 {
563                            if let Some(language) = language {
564                                self.write("<code class=\"language-")?;
565                                self.escaped(
566                                    language
567                                        .resolve(self.source)
568                                        .expect("event values are source-valid"),
569                                    true,
570                                )?;
571                                self.write("\">")?;
572                            } else {
573                                self.write("<code>")?;
574                            }
575                        }
576                        self.escaped(
577                            value
578                                .resolve(self.source)
579                                .expect("event values are source-valid"),
580                            true,
581                        )?;
582                        if self.images == 0 {
583                            self.write("</code>")?;
584                        }
585                        Ok(())
586                    }
587                    EventKind::Math(value) => {
588                        if self.images == 0 {
589                            self.write("<code class=\"language-math math-inline\">")?;
590                        }
591                        self.escaped(
592                            value
593                                .resolve(self.source)
594                                .expect("event values are source-valid"),
595                            true,
596                        )?;
597                        if self.images == 0 {
598                            self.write("</code>")?;
599                        }
600                        Ok(())
601                    }
602                    EventKind::CodeBlock {
603                        value,
604                        language,
605                        math,
606                        unclosed,
607                        ..
608                    } => {
609                        if self.images != 0 {
610                            return self.escaped(
611                                value
612                                    .resolve(self.source)
613                                    .expect("event values are source-valid"),
614                                true,
615                            );
616                        }
617                        self.terminate()?;
618                        if *math {
619                            self.write("<pre><code class=\"language-math math-display\">")?;
620                        } else if let Some(language) = language {
621                            self.write("<pre><code class=\"language-")?;
622                            self.escaped(
623                                language
624                                    .resolve(self.source)
625                                    .expect("event values are source-valid"),
626                                true,
627                            )?;
628                            self.write("\">")?;
629                        } else {
630                            self.write("<pre><code>")?;
631                        }
632                        let value = value
633                            .resolve(self.source)
634                            .expect("event values are source-valid");
635                        self.escaped(value, true)?;
636                        if !value.is_empty() {
637                            self.terminate()?;
638                        }
639                        self.write("</code></pre>")?;
640                        if *unclosed {
641                            self.terminate()?;
642                        }
643                        Ok(())
644                    }
645                    EventKind::Html { value, block } => {
646                        if *block {
647                            self.terminate()?;
648                        }
649                        let value = value
650                            .resolve(self.source)
651                            .expect("event values are source-valid");
652                        if self.options.allow_dangerous_html && self.options.tagfilter {
653                            crate::util::tagfilter::write_filtered(value, |part| {
654                                self.escaped(part, false)
655                            })
656                        } else {
657                            self.escaped(value, !self.options.allow_dangerous_html)
658                        }
659                    }
660                    EventKind::TaskMarker(checked) => {
661                        if self.images == 0 {
662                            self.write(match (self.options.task_list_item_checkable, checked) {
663                                (false, false) => "<input type=\"checkbox\" disabled=\"\" />",
664                                (false, true) => {
665                                    "<input type=\"checkbox\" disabled=\"\" checked=\"\" />"
666                                }
667                                (true, false) => "<input type=\"checkbox\" />",
668                                (true, true) => "<input type=\"checkbox\" checked=\"\" />",
669                            })?;
670                        }
671                        Ok(())
672                    }
673                    EventKind::Definition { .. } | EventKind::Opaque { .. } => Ok(()),
674                    EventKind::FootnoteReference(value) => self.footnote_call(
675                        value
676                            .resolve(self.source)
677                            .expect("event values are source-valid"),
678                    ),
679                    EventKind::Start(_) | EventKind::End(_) => {
680                        unreachable!("containers were handled above")
681                    }
682                }
683            }
684        }
685    }
686
687    fn prefix(&mut self) -> Result<(), StreamError> {
688        self.escaped(
689            self.options
690                .footnote_clobber_prefix
691                .as_deref()
692                .unwrap_or("user-content-"),
693            true,
694        )
695    }
696
697    fn footnote_call(&mut self, key: &str) -> Result<(), StreamError> {
698        let index = self
699            .calls
700            .iter()
701            .position(|call| call.key == key)
702            .unwrap_or_else(|| {
703                self.calls.push(FootnoteCall {
704                    key: key.into(),
705                    id: sanitize(&key.to_lowercase()),
706                    count: 0,
707                    definition: None,
708                });
709                self.calls.len() - 1
710            });
711        let call = &mut self.calls[index];
712        call.count += 1;
713        let count = call.count;
714        if self.images != 0 {
715            return Ok(());
716        }
717        // Temporarily move the cached ID out so writes can borrow the renderer. Restore it even when the caller's writer fails.
718        let safe = core::mem::take(&mut call.id);
719        let result = self.write_footnote_call(&safe, index + 1, count);
720        self.calls[index].id = safe;
721        result
722    }
723
724    fn write_footnote_call(
725        &mut self,
726        safe: &str,
727        number: usize,
728        count: usize,
729    ) -> Result<(), StreamError> {
730        self.write("<sup><a href=\"#")?;
731        self.prefix()?;
732        self.write("fn-")?;
733        self.write(safe)?;
734        self.write("\" id=\"")?;
735        self.prefix()?;
736        self.write("fnref-")?;
737        self.write(safe)?;
738        if count > 1 {
739            self.write("-")?;
740            self.number(count as u64)?;
741        }
742        self.write("\" data-footnote-ref=\"\" aria-describedby=\"footnote-label\">")?;
743        self.number(number as u64)?;
744        self.write("</a></sup>")
745    }
746
747    fn backreferences(&mut self, key: &str, count: usize) -> Result<(), StreamError> {
748        for reference in 1..=count {
749            if reference != 1 {
750                self.write(" ")?;
751            }
752            let mut buffer = [0; 20];
753            let number = (reference > 1).then(|| decimal(reference as u64, &mut buffer));
754            self.write("<a href=\"#")?;
755            self.prefix()?;
756            self.write("fnref-")?;
757            self.write(key)?;
758            if let Some(number) = number {
759                self.write("-")?;
760                self.write(number)?;
761            }
762            self.write("\" data-footnote-backref=\"\" aria-label=\"")?;
763            self.escaped(
764                self.options
765                    .footnote_back_label
766                    .as_deref()
767                    .unwrap_or("Back to content"),
768                true,
769            )?;
770            self.write("\" class=\"data-footnote-backref\">↩")?;
771            if let Some(number) = number {
772                self.write("<sup>")?;
773                self.write(number)?;
774                self.write("</sup>")?;
775            }
776            self.write("</a>")?;
777        }
778        Ok(())
779    }
780
781    fn footnotes(&mut self) -> Result<(), StreamError> {
782        if self.calls.is_empty() {
783            return Ok(());
784        }
785        self.terminate()?;
786        self.write("<section data-footnotes=\"\" class=\"footnotes\"><")?;
787        let tag = self
788            .options
789            .footnote_label_tag_name
790            .as_deref()
791            .unwrap_or("h2");
792        self.escaped(tag, true)?;
793        self.write(" id=\"footnote-label\" ")?;
794        self.write(
795            self.options
796                .footnote_label_attributes
797                .as_deref()
798                .unwrap_or("class=\"sr-only\""),
799        )?;
800        self.write(">")?;
801        self.escaped(
802            self.options
803                .footnote_label
804                .as_deref()
805                .unwrap_or("Footnotes"),
806            true,
807        )?;
808        self.write("</")?;
809        self.escaped(tag, true)?;
810        self.write(">")?;
811        self.newline()?;
812        self.write("<ol>")?;
813        // Index called labels, then match definitions in completion order. Unused labels need no index storage, and the first matching body wins.
814        let mut order = Vec::new();
815        crate::definitions::build_index(&mut order, &self.calls, |call| call.key.as_str());
816        let mut remaining = self.calls.len();
817        for (index, (key, _)) in self.notes.iter().enumerate() {
818            if let Some(call) =
819                crate::definitions::find_index(&order, &self.calls, key, |call| call.key.as_str())
820            {
821                if self.calls[call].definition.is_none() {
822                    self.calls[call].definition = Some(index);
823                    remaining -= 1;
824                    if remaining == 0 {
825                        break;
826                    }
827                }
828            }
829        }
830        for index in 0..self.calls.len() {
831            let call = &self.calls[index];
832            let note = call.definition.ok_or(StreamError::Unbalanced)?;
833            let count = call.count;
834            let safe = core::mem::take(&mut self.calls[index].id);
835            let value = core::mem::take(&mut self.notes[note].1);
836            self.newline()?;
837            self.write("<li id=\"")?;
838            self.prefix()?;
839            self.write("fn-")?;
840            self.write(&safe)?;
841            self.write("\">")?;
842            self.newline()?;
843            let trimmed = value.trim_end_matches(['\r', '\n']);
844            if let Some(before) = trimmed.strip_suffix("</p>") {
845                self.write(before)?;
846                self.write(" ")?;
847                self.backreferences(&safe, count)?;
848                self.write(&value[before.len()..])?;
849            } else {
850                self.write(&value)?;
851                self.terminate()?;
852                self.backreferences(&safe, count)?;
853            }
854            self.terminate()?;
855            self.write("</li>")?;
856        }
857        self.newline()?;
858        self.write("</ol>")?;
859        self.newline()?;
860        self.write("</section>")?;
861        self.newline()
862    }
863}
864
865/// Render events with the shared HTML writer.
866///
867/// # Errors
868/// Returns parser, malformed-stream, invalid-range, or output errors.
869pub fn render_events<'a>(
870    source: &str,
871    events: impl IntoIterator<Item = Result<Event<'a>, StreamError>>,
872    options: &CompileOptions,
873    output: &mut impl fmt::Write,
874) -> Result<(), StreamError> {
875    EventHtmlRenderer::new(source, options, output).render(events)
876}
877
878#[cfg(test)]
879mod tests {
880    use super::EventHtmlRenderer;
881    use crate::stream::{Event, EventKind, Tag, Text};
882    use crate::CompileOptions;
883    use alloc::string::String;
884    use core::fmt::{self, Write};
885
886    #[test]
887    fn decimal_formats_zero_boundaries_and_full_width_values() {
888        let mut buffer = [0; 20];
889        for value in [0, 1, 9, 10, 99, 100, u64::from(u32::MAX), u64::MAX] {
890            assert_eq!(
891                super::decimal(value, &mut buffer),
892                alloc::format!("{value}")
893            );
894        }
895        for exponent in 1..=19 {
896            let power = 10_u64.pow(exponent);
897            for value in [power - 1, power, power + 1] {
898                assert_eq!(
899                    super::decimal(value, &mut buffer),
900                    alloc::format!("{value}")
901                );
902            }
903        }
904    }
905
906    struct Writer {
907        remaining: usize,
908        output: String,
909    }
910    impl Write for Writer {
911        fn write_str(&mut self, value: &str) -> fmt::Result {
912            if self.remaining == 0 {
913                return Err(fmt::Error);
914            }
915            self.remaining -= 1;
916            self.output.push_str(value);
917            Ok(())
918        }
919    }
920
921    #[test]
922    fn filtered_html_preserves_written_prefix_when_a_writer_fails() {
923        let options = CompileOptions {
924            allow_dangerous_html: true,
925            tagfilter: true,
926            ..CompileOptions::default()
927        };
928        for (remaining, expected) in [
929            "",
930            "prefix",
931            "prefix&lt;",
932            "prefix&lt;script>",
933            "prefix&lt;script>�",
934        ]
935        .iter()
936        .copied()
937        .enumerate()
938        {
939            let mut writer = Writer {
940                remaining,
941                output: String::new(),
942            };
943            let mut renderer = EventHtmlRenderer::new("", &options, &mut writer);
944            renderer
945                .event(Event {
946                    kind: EventKind::Start(Tag::Document(None)),
947                    span: 0..0,
948                })
949                .unwrap();
950            renderer
951                .event(Event {
952                    kind: EventKind::Html {
953                        value: Text::Literal("prefix<script>\0tail"),
954                        block: false,
955                    },
956                    span: 0..0,
957                })
958                .unwrap_err();
959            assert_eq!(renderer.last, expected.as_bytes().last().copied());
960            assert_eq!(writer.output, expected);
961        }
962    }
963
964    #[test]
965    fn footnote_backreferences_escape_options_and_propagate_each_write_failure() {
966        let options = CompileOptions {
967            footnote_clobber_prefix: Some("<&é".into()),
968            footnote_back_label: Some("back \"<&".into()),
969            ..CompileOptions::default()
970        };
971        let expected = "<a href=\"#&lt;&amp;éfnref-note\" data-footnote-backref=\"\" aria-label=\"back &quot;&lt;&amp;\" class=\"data-footnote-backref\">↩</a> <a href=\"#&lt;&amp;éfnref-note-2\" data-footnote-backref=\"\" aria-label=\"back &quot;&lt;&amp;\" class=\"data-footnote-backref\">↩<sup>2</sup></a>";
972        let mut complete = false;
973        for remaining in 0..64 {
974            let mut writer = Writer {
975                remaining,
976                output: String::new(),
977            };
978            let mut renderer = EventHtmlRenderer::new("", &options, &mut writer);
979            let result = renderer.backreferences("note", 2);
980            let last = renderer.last;
981            assert_eq!(last, writer.output.as_bytes().last().copied());
982            assert!(expected.starts_with(&writer.output));
983            if result.is_ok() {
984                assert_eq!(writer.output, expected);
985                complete = true;
986            }
987        }
988        assert!(
989            complete,
990            "backreferences should finish within the write budget"
991        );
992    }
993}