citum_engine/render/
html.rs1use super::format::{OutputFormat, QuoteMarks, SemanticAttribute};
9use citum_schema::template::WrapPunctuation;
10use std::fmt::Write;
11
12#[derive(Default, Clone)]
13pub 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('&', "&")
39 .replace('"', """)
40 .replace('<', "<")
41 .replace('>', ">")
42 }
43
44 fn escape_text(value: &str) -> String {
49 value
50 .replace('&', "&")
51 .replace('<', "<")
52 .replace('>', ">")
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}