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