1use std::ops::Range;
24
25use super::format::{OutputFormat, QuoteMarks, realize_wrap};
26use super::visible_scan::{RunBuilder, find_matching, skip_balanced};
27use crate::values::ScriptClass;
28use citum_schema::template::WrapPunctuation;
29
30fn escape_commonmark_text(s: &str) -> String {
38 let mut out = String::with_capacity(s.len() + 4);
39 for ch in s.chars() {
40 match ch {
41 '\\' | '*' | '_' | '[' | ']' | '`' | '<' | '>' | '&' => {
42 out.push('\\');
43 out.push(ch);
44 }
45 _ => out.push(ch),
46 }
47 }
48 out
49}
50
51#[derive(Default, Clone)]
53pub struct Markdown;
54
55impl OutputFormat for Markdown {
56 type Output = String;
57
58 fn text(&self, s: &str) -> Self::Output {
59 escape_commonmark_text(s)
60 }
61
62 fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
63 items.join(delimiter)
64 }
65
66 fn finish(&self, output: Self::Output) -> String {
67 output
68 }
69
70 fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
72 let marks = "#".repeat(level.max(1) as usize);
73 format!("{marks} {content}\n\n")
74 }
75
76 fn emph(&self, content: Self::Output) -> Self::Output {
78 if content.is_empty() {
79 return content;
80 }
81 format!("*{content}*")
82 }
83
84 fn strong(&self, content: Self::Output) -> Self::Output {
86 if content.is_empty() {
87 return content;
88 }
89 format!("**{content}**")
90 }
91
92 fn small_caps(&self, content: Self::Output) -> Self::Output {
97 if content.is_empty() {
98 return content;
99 }
100 format!("<span style=\"font-variant:small-caps\">{content}</span>")
101 }
102
103 fn superscript(&self, content: Self::Output) -> Self::Output {
108 if content.is_empty() {
109 return content;
110 }
111 format!("<sup>{content}</sup>")
112 }
113
114 fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
115 if content.is_empty() {
116 return content;
117 }
118 let (open, close) = marks.for_depth(0);
119 format!("{open}{content}{close}")
120 }
121
122 fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
123 format!("{prefix}{content}{suffix}")
124 }
125
126 fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
127 format!("{prefix}{content}{suffix}")
128 }
129
130 fn wrap_punctuation(
131 &self,
132 wrap: &WrapPunctuation,
133 content: Self::Output,
134 marks: &QuoteMarks,
135 script: ScriptClass,
136 realization: Option<&citum_schema::options::PunctuationRealization>,
137 ) -> Self::Output {
138 match realize_wrap(wrap, script, realization) {
139 Some((open, close)) => {
140 format!("{}{}{}", self.text(&open), content, self.text(&close))
141 }
142 None => self.quote(content, marks),
143 }
144 }
145
146 fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
152 content
153 }
154
155 fn annotation(&self, content: Self::Output) -> Self::Output {
156 if content.is_empty() {
157 return content;
158 }
159 format!("\n\n{content}")
160 }
161
162 fn link(&self, url: &str, content: Self::Output) -> Self::Output {
163 if content.is_empty() {
164 return content;
165 }
166 format!("[{content}]({url})")
167 }
168
169 fn entry(
170 &self,
171 _id: &str,
172 content: Self::Output,
173 url: Option<&str>,
174 _metadata: &super::format::ProcEntryMetadata,
175 ) -> Self::Output {
176 if let Some(u) = url {
177 self.link(u, content)
178 } else {
179 content
180 }
181 }
182
183 fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
190 let mut runs = RunBuilder::default();
191 let chars: Vec<(usize, char)> = fragment.char_indices().collect();
192 let mut i = 0;
193 let mut in_tag = false;
194 let mut pending_link_close: Option<usize> = None;
195 while let Some(&(pos, ch)) = chars.get(i) {
196 if in_tag {
197 if ch == '>' {
198 in_tag = false;
199 }
200 i += 1;
201 continue;
202 }
203 match ch {
204 '\\' => {
205 if let Some(&(epos, echar)) = chars.get(i + 1) {
206 runs.push_visible(epos, epos + echar.len_utf8());
207 }
208 i += 2;
209 }
210 '<' => {
211 in_tag = true;
212 i += 1;
213 }
214 '*' => {
215 i += 1;
216 if chars.get(i).map(|&(_, c)| c) == Some('*') {
217 i += 1;
218 }
219 }
220 '[' => {
221 let close_i = find_matching(&chars, i, '[', ']', true);
222 let is_link = close_i
223 .is_some_and(|c| chars.get(c + 1).map(|&(_, next)| next) == Some('('));
224 if is_link {
225 pending_link_close = close_i;
226 i += 1;
227 } else {
228 runs.push_visible(pos, pos + 1);
229 i += 1;
230 }
231 }
232 ']' => {
233 if pending_link_close == Some(i) {
234 pending_link_close = None;
235 let mut j = i + 1;
236 if chars.get(j).map(|&(_, c)| c) == Some('(') {
237 j = skip_balanced(&chars, j, '(', ')', true);
238 }
239 i = j;
240 } else {
241 runs.push_visible(pos, pos + 1);
242 i += 1;
243 }
244 }
245 _ => {
246 runs.push_visible(pos, pos + ch.len_utf8());
247 i += 1;
248 }
249 }
250 }
251 runs.finish()
252 }
253}
254
255#[cfg(test)]
256#[allow(
257 clippy::unwrap_used,
258 clippy::expect_used,
259 clippy::panic,
260 clippy::indexing_slicing,
261 reason = "tests"
262)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_markdown_emph() {
268 let fmt = Markdown;
269 for (input, expected) in [("", ""), ("text", "*text*")] {
270 assert_eq!(fmt.emph(input.to_string()), expected);
271 }
272 }
273
274 #[test]
275 fn test_markdown_strong() {
276 let fmt = Markdown;
277 for (input, expected) in [("", ""), ("text", "**text**")] {
278 assert_eq!(fmt.strong(input.to_string()), expected);
279 }
280 }
281
282 #[test]
283 fn test_markdown_small_caps() {
284 let fmt = Markdown;
285 assert_eq!(fmt.small_caps(String::new()), "");
286 assert_eq!(
287 fmt.small_caps("Smith".to_string()),
288 "<span style=\"font-variant:small-caps\">Smith</span>"
289 );
290 }
291
292 #[test]
293 fn test_markdown_superscript() {
294 let fmt = Markdown;
295 assert_eq!(fmt.superscript(String::new()), "");
296 assert_eq!(fmt.superscript("2".to_string()), "<sup>2</sup>");
297 }
298
299 #[test]
300 fn test_markdown_quote() {
301 let fmt = Markdown;
302 let marks = QuoteMarks::default();
303 for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
304 assert_eq!(fmt.quote(input.to_string(), &marks), expected);
305 }
306 }
307
308 #[test]
309 fn test_markdown_quote_uses_locale_marks() {
310 let fmt = Markdown;
311 let marks = QuoteMarks {
312 open: "\u{ab}".to_string(),
313 close: "\u{bb}".to_string(),
314 open_inner: "\u{2039}".to_string(),
315 close_inner: "\u{203a}".to_string(),
316 };
317
318 assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
319 }
320
321 #[test]
322 fn test_markdown_semantic_passthrough() {
323 let fmt = Markdown;
324 assert_eq!(fmt.semantic("author", "Jane Doe".to_string()), "Jane Doe");
325 assert_eq!(fmt.semantic("title", String::new()), "");
326 }
327
328 #[test]
329 fn test_markdown_link() {
330 let fmt = Markdown;
331 assert_eq!(fmt.link("https://example.com", String::new()), "");
332 assert_eq!(
333 fmt.link("https://example.com", "Example".to_string()),
334 "[Example](https://example.com)"
335 );
336 }
337
338 #[test]
339 fn test_markdown_wrap_punctuation() {
340 let fmt = Markdown;
341 let marks = QuoteMarks::default();
342 for (wrap, script, input, expected) in [
343 (
344 WrapPunctuation::Parentheses,
345 ScriptClass::Latin,
346 "text",
347 "(text)",
348 ),
349 (
350 WrapPunctuation::Brackets,
351 ScriptClass::Latin,
352 "text",
353 "\\[text\\]",
354 ),
355 (
356 WrapPunctuation::Quotes,
357 ScriptClass::Latin,
358 "text",
359 "\u{201C}text\u{201D}",
360 ),
361 (
362 WrapPunctuation::Parentheses,
363 ScriptClass::Cjk,
364 "text",
365 "\u{ff08}text\u{ff09}",
366 ),
367 (
368 WrapPunctuation::Brackets,
369 ScriptClass::Cjk,
370 "text",
371 "\u{3010}text\u{3011}",
372 ),
373 ] {
374 assert_eq!(
375 fmt.wrap_punctuation(&wrap, input.to_string(), &marks, script, None),
376 expected
377 );
378 }
379 }
380
381 #[test]
382 fn test_markdown_text_escapes_active_chars() {
383 let fmt = Markdown;
384 assert_eq!(fmt.text("plain"), "plain");
385 assert_eq!(fmt.text("A * B"), "A \\* B");
386 assert_eq!(fmt.text("use [x]"), "use \\[x\\]");
387 assert_eq!(fmt.text("code `foo`"), "code \\`foo\\`");
388 assert_eq!(fmt.text("back\\slash"), "back\\\\slash");
389 assert_eq!(fmt.text("under_score"), "under\\_score");
390 assert_eq!(fmt.text("<doi:10.1/x>"), "\\<doi:10.1/x\\>");
393 assert_eq!(fmt.text("Smith & Jones"), "Smith \\& Jones");
394 assert_eq!(fmt.text("<em>bold</em>"), "\\<em\\>bold\\</em\\>");
395 }
396
397 #[test]
398 fn visible_text_strips_emph_and_strong_delimiters() {
399 let fmt = Markdown;
400 assert_eq!(fmt.visible_text("*Title.*"), "Title.");
401 assert_eq!(fmt.visible_text("**Title.**"), "Title.");
402 }
403
404 #[test]
405 fn visible_text_hides_link_url_keeps_text() {
406 let fmt = Markdown;
407 assert_eq!(
408 fmt.visible_text("[Example](https://example.com/a.b)"),
409 "Example"
410 );
411 }
412
413 #[test]
414 fn visible_text_keeps_literal_wrap_brackets_visible() {
415 let fmt = Markdown;
416 assert_eq!(fmt.visible_text("[Dataset]"), "[Dataset]");
418 }
419
420 #[test]
421 fn visible_text_strips_raw_html_spans() {
422 let fmt = Markdown;
423 assert_eq!(
424 fmt.visible_text("<span style=\"font-variant:small-caps\">Smith</span>"),
425 "Smith"
426 );
427 assert_eq!(fmt.visible_text("<sup>2</sup>"), "2");
428 }
429
430 #[test]
431 fn visible_text_keeps_escaped_punctuation() {
432 let fmt = Markdown;
433 assert_eq!(fmt.visible_text(r"A \* B"), "A * B");
434 }
435}