citum_engine/render/
html.rs1use 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)]
14pub 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('&', "&")
40 .replace('"', """)
41 .replace('<', "<")
42 .replace('>', ">")
43 }
44
45 fn escape_text(value: &str) -> String {
50 value
51 .replace('&', "&")
52 .replace('<', "<")
53 .replace('>', ">")
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}