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, 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
45impl OutputFormat for Html {
46    type Output = String;
47
48    fn text(&self, s: &str) -> Self::Output {
49        // As requested, we avoid escaping and use raw Unicode.
50        s.to_string()
51    }
52
53    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
54        items.join(delimiter)
55    }
56
57    fn finish(&self, output: Self::Output) -> String {
58        output
59    }
60
61    fn emph(&self, content: Self::Output) -> Self::Output {
62        if content.is_empty() {
63            return content;
64        }
65        format!("<em>{content}</em>")
66    }
67
68    fn strong(&self, content: Self::Output) -> Self::Output {
69        if content.is_empty() {
70            return content;
71        }
72        format!("<b>{content}</b>")
73    }
74
75    fn small_caps(&self, content: Self::Output) -> Self::Output {
76        if content.is_empty() {
77            return content;
78        }
79        format!(r#"<span style="font-variant:small-caps">{content}</span>"#)
80    }
81
82    fn superscript(&self, content: Self::Output) -> Self::Output {
83        if content.is_empty() {
84            return content;
85        }
86        format!("<sup>{content}</sup>")
87    }
88
89    fn quote(&self, content: Self::Output) -> Self::Output {
90        if content.is_empty() {
91            return content;
92        }
93        format!("\u{201C}{content}\u{201D}")
94    }
95
96    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
97        format!("{prefix}{content}{suffix}")
98    }
99
100    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
101        format!("{prefix}{content}{suffix}")
102    }
103
104    fn wrap_punctuation(&self, wrap: &WrapPunctuation, content: Self::Output) -> Self::Output {
105        match wrap {
106            WrapPunctuation::Parentheses => format!("({content})"),
107            WrapPunctuation::Brackets => format!("[{content}]"),
108            WrapPunctuation::Quotes => format!("\u{201C}{content}\u{201D}"),
109        }
110    }
111
112    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
113        if content.is_empty() {
114            return content;
115        }
116        format!(r#"<span class="{class}">{content}</span>"#)
117    }
118
119    fn annotation(&self, content: Self::Output) -> Self::Output {
120        if content.is_empty() {
121            return content;
122        }
123        format!("<div class=\"citum-annotation\">{content}</div>")
124    }
125
126    fn semantic_with_attributes(
127        &self,
128        class: &str,
129        content: Self::Output,
130        attributes: &[SemanticAttribute],
131    ) -> Self::Output {
132        if content.is_empty() {
133            return content;
134        }
135
136        let mut extra_attrs = String::new();
137        for attribute in attributes {
138            let _ = write!(
139                &mut extra_attrs,
140                r#" {}="{}""#,
141                attribute.name,
142                Self::escape_attribute_value(&attribute.value)
143            );
144        }
145
146        format!(r#"<span class="{class}"{extra_attrs}>{content}</span>"#)
147    }
148
149    fn citation(&self, ids: Vec<String>, content: Self::Output) -> Self::Output {
150        if content.is_empty() {
151            return content;
152        }
153        let ids_str = ids.join(" ");
154        format!(r#"<span class="citum-citation" data-ref="{ids_str}">{content}</span>"#)
155    }
156
157    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
158        if content.is_empty() {
159            return content;
160        }
161        format!(r#"<a href="{}">{}</a>"#, Self::sanitize_href(url), content)
162    }
163
164    fn format_id(&self, id: &str) -> String {
165        format!("ref-{id}")
166    }
167
168    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
169        format!(
170            r#"<div class="citum-bibliography">
171{}
172</div>"#,
173            self.join(entries, "\n")
174        )
175    }
176
177    fn entry(
178        &self,
179        id: &str,
180        content: Self::Output,
181        url: Option<&str>,
182        metadata: &super::format::ProcEntryMetadata,
183    ) -> Self::Output {
184        let content = if let Some(u) = url {
185            self.link(u, content)
186        } else {
187            content
188        };
189
190        let mut attrs = format!(
191            r#"id="{}""#,
192            Self::escape_attribute_value(&self.format_id(id))
193        );
194        if let Some(author) = &metadata.author {
195            attrs.push_str(r#" data-author=""#);
196            attrs.push_str(&Self::escape_attribute_value(author));
197            attrs.push('"');
198        }
199        if let Some(year) = &metadata.year {
200            attrs.push_str(r#" data-year=""#);
201            attrs.push_str(&Self::escape_attribute_value(year));
202            attrs.push('"');
203        }
204        if let Some(title) = &metadata.title {
205            attrs.push_str(r#" data-title=""#);
206            attrs.push_str(&Self::escape_attribute_value(title));
207            attrs.push('"');
208        }
209
210        format!(r#"<div class="citum-entry" {attrs}>{content}</div>"#)
211    }
212}