Skip to main content

citum_engine/render/
html.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! HTML output format.
7
8use super::format::{OutputFormat, QuoteMarks, SemanticAttribute};
9use citum_schema::template::WrapPunctuation;
10use std::fmt::Write;
11
12#[derive(Default, Clone)]
13/// Renders processed citations and bibliography entries as HTML fragments.
14pub struct Html;
15
16impl Html {
17    fn sanitize_href(value: &str) -> String {
18        let mut escaped = String::with_capacity(value.len());
19        for ch in value.chars() {
20            if ch.is_ascii_control()
21                || ch.is_whitespace()
22                || matches!(ch, '"' | '\'' | '<' | '>' | '&')
23            {
24                let mut buf = [0u8; 4];
25                for byte in ch.encode_utf8(&mut buf).as_bytes() {
26                    escaped.push('%');
27                    let _ = write!(escaped, "{byte:02X}");
28                }
29            } else {
30                escaped.push(ch);
31            }
32        }
33        escaped
34    }
35
36    fn escape_attribute_value(value: &str) -> String {
37        value
38            .replace('&', "&amp;")
39            .replace('"', "&quot;")
40            .replace('<', "&lt;")
41            .replace('>', "&gt;")
42    }
43
44    /// Escapes the three HTML-active characters (`&`, `<`, `>`) in text content.
45    ///
46    /// Order matters: `&` must be escaped first, otherwise the entities emitted
47    /// for `<` and `>` would themselves be escaped.
48    fn escape_text(value: &str) -> String {
49        value
50            .replace('&', "&amp;")
51            .replace('<', "&lt;")
52            .replace('>', "&gt;")
53    }
54}
55
56impl OutputFormat for Html {
57    type Output = String;
58
59    fn text(&self, s: &str) -> Self::Output {
60        Self::escape_text(s)
61    }
62
63    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
64        items.join(delimiter)
65    }
66
67    fn finish(&self, output: Self::Output) -> String {
68        output
69    }
70
71    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
72        let level = level.clamp(1, 6);
73        format!("<h{level}>{content}</h{level}>\n\n")
74    }
75
76    fn emph(&self, content: Self::Output) -> Self::Output {
77        if content.is_empty() {
78            return content;
79        }
80        format!("<em>{content}</em>")
81    }
82
83    fn strong(&self, content: Self::Output) -> Self::Output {
84        if content.is_empty() {
85            return content;
86        }
87        format!("<b>{content}</b>")
88    }
89
90    fn small_caps(&self, content: Self::Output) -> Self::Output {
91        if content.is_empty() {
92            return content;
93        }
94        format!(r#"<span style="font-variant:small-caps">{content}</span>"#)
95    }
96
97    fn superscript(&self, content: Self::Output) -> Self::Output {
98        if content.is_empty() {
99            return content;
100        }
101        format!("<sup>{content}</sup>")
102    }
103
104    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
105        if content.is_empty() {
106            return content;
107        }
108        let (open, close) = marks.for_depth(0);
109        format!("{open}{content}{close}")
110    }
111
112    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
113        format!("{prefix}{content}{suffix}")
114    }
115
116    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
117        format!("{prefix}{content}{suffix}")
118    }
119
120    fn wrap_punctuation(
121        &self,
122        wrap: &WrapPunctuation,
123        content: Self::Output,
124        marks: &QuoteMarks,
125    ) -> Self::Output {
126        match wrap {
127            WrapPunctuation::Parentheses => format!("({content})"),
128            WrapPunctuation::Brackets => format!("[{content}]"),
129            WrapPunctuation::Quotes => self.quote(content, marks),
130        }
131    }
132
133    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
134        if content.is_empty() {
135            return content;
136        }
137        format!(r#"<span class="{class}">{content}</span>"#)
138    }
139
140    fn annotation(&self, content: Self::Output) -> Self::Output {
141        if content.is_empty() {
142            return content;
143        }
144        format!("<div class=\"citum-annotation\">{content}</div>")
145    }
146
147    fn semantic_with_attributes(
148        &self,
149        class: &str,
150        content: Self::Output,
151        attributes: &[SemanticAttribute],
152    ) -> Self::Output {
153        if content.is_empty() {
154            return content;
155        }
156
157        let mut extra_attrs = String::new();
158        for attribute in attributes {
159            let _ = write!(
160                &mut extra_attrs,
161                r#" {}="{}""#,
162                attribute.name,
163                Self::escape_attribute_value(&attribute.value)
164            );
165        }
166
167        format!(r#"<span class="{class}"{extra_attrs}>{content}</span>"#)
168    }
169
170    fn citation(&self, ids: Vec<String>, content: Self::Output) -> Self::Output {
171        if content.is_empty() {
172            return content;
173        }
174        let ids_str = Self::escape_attribute_value(&ids.join(" "));
175        format!(r#"<span class="citum-citation" data-ref="{ids_str}">{content}</span>"#)
176    }
177
178    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
179        if content.is_empty() {
180            return content;
181        }
182        format!(r#"<a href="{}">{}</a>"#, Self::sanitize_href(url), content)
183    }
184
185    fn format_id(&self, id: &str) -> String {
186        format!("ref-{id}")
187    }
188
189    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
190        format!(
191            r#"<div class="citum-bibliography">
192{}
193</div>"#,
194            self.join(entries, "\n")
195        )
196    }
197
198    fn entry(
199        &self,
200        id: &str,
201        content: Self::Output,
202        url: Option<&str>,
203        metadata: &super::format::ProcEntryMetadata,
204    ) -> Self::Output {
205        let content = if let Some(u) = url {
206            self.link(u, content)
207        } else {
208            content
209        };
210
211        let mut attrs = format!(
212            r#"id="{}""#,
213            Self::escape_attribute_value(&self.format_id(id))
214        );
215        if let Some(author) = &metadata.author {
216            attrs.push_str(r#" data-author=""#);
217            attrs.push_str(&Self::escape_attribute_value(author));
218            attrs.push('"');
219        }
220        if let Some(year) = &metadata.year {
221            attrs.push_str(r#" data-year=""#);
222            attrs.push_str(&Self::escape_attribute_value(year));
223            attrs.push('"');
224        }
225        if let Some(title) = &metadata.title {
226            attrs.push_str(r#" data-title=""#);
227            attrs.push_str(&Self::escape_attribute_value(title));
228            attrs.push('"');
229        }
230
231        format!(r#"<div class="citum-entry" {attrs}>{content}</div>"#)
232    }
233
234    fn visible_runs(&self, fragment: &str) -> Vec<std::ops::Range<usize>> {
235        let mut runs = super::visible_scan::RunBuilder::default();
236        let mut in_tag = false;
237        for (i, ch) in fragment.char_indices() {
238            match ch {
239                '<' => in_tag = true,
240                '>' if in_tag => in_tag = false,
241                _ if !in_tag => runs.push_visible(i, i + ch.len_utf8()),
242                _ => {}
243            }
244        }
245        runs.finish()
246    }
247}