1use citum_schema::NoteStartTextCase;
13use citum_schema::options::titles::TextCase;
14
15#[must_use]
23pub fn apply_text_case(text: &str, case: TextCase) -> String {
24 match case {
25 TextCase::AsIs => text.to_string(),
26 TextCase::Lowercase => text.to_lowercase(),
27 TextCase::Uppercase => text.to_uppercase(),
28 TextCase::CapitalizeFirst => capitalize_first_word(text),
29 TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
30 to_sentence_case(text)
31 }
32 TextCase::Title => to_title_case(text),
33 }
34}
35
36#[must_use]
43pub fn apply_to_structured_parts(
44 main: &str,
45 subtitles: &[&str],
46 case: TextCase,
47) -> (String, Vec<String>) {
48 match case {
49 TextCase::SentenceApa => {
50 let main_cased = to_sentence_case(main);
51 let subs_cased = subtitles.iter().map(|s| to_sentence_case(s)).collect();
52 (main_cased, subs_cased)
53 }
54 TextCase::SentenceNlm => {
55 let main_cased = to_sentence_case(main);
56 let subs_cased = subtitles.iter().map(|s| s.to_lowercase()).collect();
58 (main_cased, subs_cased)
59 }
60 _ => {
61 let main_cased = apply_text_case(main, case);
62 let subs_cased = subtitles.iter().map(|s| apply_text_case(s, case)).collect();
63 (main_cased, subs_cased)
64 }
65 }
66}
67
68#[must_use]
70pub fn is_english_language(lang: Option<&str>) -> bool {
71 match lang {
72 Some(tag) => {
73 let primary = tag.split('-').next().unwrap_or(tag);
74 primary.eq_ignore_ascii_case("en")
75 }
76 None => true,
78 }
79}
80
81#[must_use]
85pub fn resolve_text_case(case: TextCase, language: Option<&str>) -> TextCase {
86 if is_english_language(language) {
87 case
88 } else {
89 match case {
92 TextCase::AsIs | TextCase::Lowercase | TextCase::Uppercase => case,
93 _ => TextCase::AsIs,
94 }
95 }
96}
97
98#[must_use]
101pub(crate) fn apply_note_start_text_case(
102 value: &str,
103 text_case: NoteStartTextCase,
104 language: Option<&str>,
105) -> String {
106 let case = match text_case {
107 NoteStartTextCase::CapitalizeFirst => TextCase::CapitalizeFirst,
108 NoteStartTextCase::Lowercase => TextCase::Lowercase,
109 };
110 apply_text_case(value, resolve_text_case(case, language))
111}
112
113fn has_internal_uppercase(word: &str) -> bool {
122 let mut chars = word.chars();
123 chars.next();
124 chars.any(char::is_uppercase)
125}
126
127fn rebuild_with_original_whitespace(text: &str, parts: &[String]) -> String {
131 let mut result = String::with_capacity(text.len());
132 let mut word_iter = parts.iter();
133 let mut in_word = false;
134 let mut current_word = word_iter.next();
135
136 for ch in text.chars() {
137 if ch.is_whitespace() {
138 if in_word {
139 in_word = false;
140 current_word = word_iter.next();
141 }
142 result.push(ch);
143 } else if !in_word && let Some(word) = current_word {
144 result.push_str(word);
145 in_word = true;
146 }
147 }
148
149 result
150}
151
152fn to_sentence_case(text: &str) -> String {
157 if text.is_empty() {
158 return String::new();
159 }
160
161 let words: Vec<&str> = text.split_whitespace().collect();
162 if words.is_empty() {
163 return text.to_string();
164 }
165
166 let mut parts: Vec<String> = Vec::with_capacity(words.len());
167 for (i, word) in words.iter().enumerate() {
168 if has_internal_uppercase(word) {
169 parts.push((*word).to_string());
170 } else if i == 0 {
171 parts.push(capitalize_first_word(&word.to_lowercase()));
172 } else {
173 parts.push(word.to_lowercase());
174 }
175 }
176
177 rebuild_with_original_whitespace(text, &parts)
178}
179
180pub(crate) fn capitalize_first_word(text: &str) -> String {
189 let mut result = String::with_capacity(text.len());
190 let mut found_first = false;
191 let mut blocked_by_digit = false;
192 for ch in text.chars() {
193 if !found_first && !blocked_by_digit && ch.is_alphabetic() {
194 for upper in ch.to_uppercase() {
195 result.push(upper);
196 }
197 found_first = true;
198 } else {
199 if !found_first && ch.is_ascii_digit() {
200 blocked_by_digit = true;
201 }
202 result.push(ch);
203 }
204 }
205 result
206}
207
208pub(crate) fn capitalize_first_word_markup_aware(text: &str) -> String {
215 let bytes = text.as_bytes();
216 let len = bytes.len();
217 let mut i = 0;
218
219 while i < len {
220 let Some(&b) = bytes.get(i) else { break };
221
222 if b == b'<'
225 && let Some(end) = text.get(i..).and_then(|s| s.find('>'))
226 {
227 i += end + 1;
228 continue;
229 }
230
231 if b == b'\\' {
233 let cmd_start = i + 1;
234 let cmd_len = bytes
235 .get(cmd_start..)
236 .unwrap_or_default()
237 .iter()
238 .take_while(|&&c| c.is_ascii_alphabetic())
239 .count();
240 if cmd_len > 0 {
241 let after_cmd = cmd_start + cmd_len;
242 let after_opt = if bytes.get(after_cmd) == Some(&b'[') {
244 text.get(after_cmd..)
245 .and_then(|s| s.find(']'))
246 .map(|e| after_cmd + e + 1)
247 .unwrap_or(after_cmd)
248 } else {
249 after_cmd
250 };
251 if bytes.get(after_opt) == Some(&b'{') {
252 i = after_opt + 1;
253 continue;
254 }
255 }
256 }
257
258 if b == b'#' {
260 let cmd_start = i + 1;
261 let cmd_len = bytes
262 .get(cmd_start..)
263 .unwrap_or_default()
264 .iter()
265 .take_while(|&&c| c.is_ascii_alphabetic())
266 .count();
267 if cmd_len > 0 {
268 let after_cmd = cmd_start + cmd_len;
269 if bytes.get(after_cmd) == Some(&b'[') {
270 i = after_cmd + 1;
271 continue;
272 }
273 }
274 }
275
276 let ch = text.get(i..).and_then(|s| s.chars().next()).unwrap_or('\0');
279 if ch.is_alphabetic() {
280 let ch_len = ch.len_utf8();
281 let mut result = String::with_capacity(text.len());
282 result.push_str(text.get(..i).unwrap_or_default());
283 for upper in ch.to_uppercase() {
284 result.push(upper);
285 }
286 result.push_str(text.get(i + ch_len..).unwrap_or_default());
287 return result;
288 }
289
290 i += ch.len_utf8().max(1);
291 }
292
293 text.to_string()
294}
295
296pub(crate) fn apply_text_case_markup_aware(text: &str, case: TextCase) -> String {
302 match case {
303 TextCase::CapitalizeFirst => capitalize_first_word_markup_aware(text),
304 _ => apply_text_case(text, case),
305 }
306}
307
308const TITLE_CASE_STOP_WORDS: &[&str] = &[
310 "a", "an", "and", "as", "at", "but", "by", "for", "from", "in", "nor", "of", "on", "or", "so",
311 "the", "to", "up", "yet", "v", "vs",
312];
313
314const HYPHEN_LIKE_CHARS: [char; 2] = ['-', '\u{2013}'];
321
322fn contains_hyphen_like(text: &str) -> bool {
323 text.contains(HYPHEN_LIKE_CHARS)
324}
325
326fn capitalize_hyphenated(word: &str, force_all: bool) -> String {
333 let mut result = String::with_capacity(word.len());
334 let mut last_end = 0;
335 for (idx, sep) in word.match_indices(HYPHEN_LIKE_CHARS) {
336 let part = word.get(last_end..idx).unwrap_or_default();
337 result.push_str(&capitalize_hyphen_part(part, force_all));
338 result.push_str(sep);
339 last_end = idx + sep.len();
340 }
341 let tail = word.get(last_end..).unwrap_or_default();
342 result.push_str(&capitalize_hyphen_part(tail, force_all));
343 result
344}
345
346fn capitalize_hyphen_part(part: &str, force_all: bool) -> String {
347 if force_all {
348 capitalize_first_word(part)
349 } else {
350 let alpha_core = part.trim_matches(|c: char| !c.is_alphanumeric());
351 if TITLE_CASE_STOP_WORDS.contains(&alpha_core) {
352 part.to_string()
353 } else {
354 capitalize_first_word(part)
355 }
356 }
357}
358
359fn trim_trailing_closing_punctuation(word: &str) -> &str {
360 word.trim_end_matches(['"', '\'', ')', ']', '}', '»', '”', '’'])
361}
362
363fn to_title_case(text: &str) -> String {
370 if text.is_empty() {
371 return String::new();
372 }
373
374 let words: Vec<&str> = text.split_whitespace().collect();
375 if words.is_empty() {
376 return text.to_string();
377 }
378
379 let last_idx = words.len() - 1;
380 let mut parts: Vec<String> = Vec::with_capacity(words.len());
381 let mut capitalize_next = false;
382
383 for (i, word) in words.iter().enumerate() {
384 if has_internal_uppercase(word) {
385 parts.push((*word).to_string());
388 } else {
389 let lower = word.to_lowercase();
390 if i == 0 || i == last_idx || capitalize_next {
391 if contains_hyphen_like(&lower) {
392 parts.push(capitalize_hyphenated(&lower, true));
393 } else {
394 parts.push(capitalize_first_word(&lower));
395 }
396 } else {
397 let alpha_core = lower.trim_matches(|c: char| !c.is_alphanumeric());
400 if TITLE_CASE_STOP_WORDS.contains(&alpha_core) {
401 parts.push(lower);
402 } else if contains_hyphen_like(&lower) {
403 parts.push(capitalize_hyphenated(&lower, false));
404 } else {
405 parts.push(capitalize_first_word(&lower));
406 }
407 }
408 }
409 let punctuation_core = trim_trailing_closing_punctuation(word);
412 capitalize_next = punctuation_core.ends_with(':')
413 || punctuation_core.ends_with('?')
414 || punctuation_core.ends_with('!');
415 }
416
417 rebuild_with_original_whitespace(text, &parts)
418}
419
420#[cfg(test)]
421#[allow(
422 clippy::unwrap_used,
423 clippy::expect_used,
424 clippy::panic,
425 clippy::indexing_slicing,
426 clippy::todo,
427 clippy::unimplemented,
428 clippy::unreachable,
429 clippy::get_unwrap,
430 reason = "Panicking is acceptable and often desired in tests."
431)]
432mod tests {
433 use super::*;
434
435 #[test]
438 fn test_capitalize_first_word_basic() {
439 assert_eq!(capitalize_first_word("hello world"), "Hello world");
440 }
441
442 #[test]
443 fn test_capitalize_first_word_leading_space() {
444 assert_eq!(capitalize_first_word(" hello"), " Hello");
445 }
446
447 #[test]
448 fn test_capitalize_first_word_empty() {
449 assert_eq!(capitalize_first_word(""), "");
450 }
451
452 #[test]
453 fn test_capitalize_first_word_already_upper() {
454 assert_eq!(capitalize_first_word("Hello"), "Hello");
455 }
456
457 #[test]
458 fn given_leading_numeral_when_capitalize_first_word_then_left_as_is() {
459 assert_eq!(capitalize_first_word("35 mm film"), "35 mm film");
462 }
463
464 #[test]
465 fn given_leading_letter_when_capitalize_first_word_then_capitalized() {
466 assert_eq!(capitalize_first_word("in Korean"), "In Korean");
467 }
468
469 #[test]
472 fn test_capitalize_markup_aware_plain_text() {
473 assert_eq!(
474 capitalize_first_word_markup_aware("the collected essays"),
475 "The collected essays"
476 );
477 }
478
479 #[test]
480 fn test_capitalize_markup_aware_html_tag() {
481 assert_eq!(
482 capitalize_first_word_markup_aware("<em>the collected essays</em>"),
483 "<em>The collected essays</em>"
484 );
485 }
486
487 #[test]
488 fn test_capitalize_markup_aware_html_nested_tags() {
489 assert_eq!(
490 capitalize_first_word_markup_aware(r#"<span class="x"><em>the title</em></span>"#),
491 r#"<span class="x"><em>The title</em></span>"#
492 );
493 }
494
495 #[test]
496 fn test_capitalize_markup_aware_latex_command() {
497 assert_eq!(
498 capitalize_first_word_markup_aware(r"\emph{the collected essays}"),
499 r"\emph{The collected essays}"
500 );
501 }
502
503 #[test]
504 fn test_capitalize_markup_aware_latex_number_not_corrupted() {
505 assert_eq!(
507 capitalize_first_word_markup_aware(r"\emph{521}"),
508 r"\emph{521}"
509 );
510 }
511
512 #[test]
513 fn test_capitalize_markup_aware_typst_command() {
514 assert_eq!(
515 capitalize_first_word_markup_aware("#emph[the collected essays]"),
516 "#emph[The collected essays]"
517 );
518 }
519
520 #[test]
521 fn test_capitalize_markup_aware_plain_underscore_delimiters() {
522 assert_eq!(
524 capitalize_first_word_markup_aware("_the collected essays_"),
525 "_The collected essays_"
526 );
527 }
528
529 #[test]
530 fn test_capitalize_markup_aware_empty_string() {
531 assert_eq!(capitalize_first_word_markup_aware(""), "");
532 }
533
534 #[test]
535 fn test_capitalize_markup_aware_all_markup_no_text() {
536 assert_eq!(capitalize_first_word_markup_aware("<em></em>"), "<em></em>");
537 }
538
539 #[test]
542 fn test_sentence_case_basic() {
543 assert_eq!(
544 to_sentence_case("The Quick Brown Fox"),
545 "The quick brown fox"
546 );
547 }
548
549 #[test]
550 fn test_sentence_case_all_caps() {
551 assert_eq!(to_sentence_case("DNA REPLICATION"), "DNA REPLICATION");
554 }
555
556 #[test]
557 fn test_sentence_case_empty() {
558 assert_eq!(to_sentence_case(""), "");
559 }
560
561 #[test]
562 fn given_mixed_case_word_when_sentence_case_then_preserved() {
563 assert_eq!(
564 to_sentence_case("An Introduction to DNA"),
565 "An introduction to DNA"
566 );
567 }
568
569 #[test]
570 fn given_lowercase_iphone_when_sentence_case_then_lowercased() {
571 assert_eq!(to_sentence_case("the iphone problem"), "The iphone problem");
572 }
573
574 #[test]
575 fn given_mixed_case_iphone_when_sentence_case_then_preserved() {
576 assert_eq!(to_sentence_case("the iPhone problem"), "The iPhone problem");
577 }
578
579 #[test]
580 fn given_mixed_case_surname_when_sentence_case_then_preserved() {
581 assert_eq!(
582 to_sentence_case("a study of McDonald"),
583 "A study of McDonald"
584 );
585 }
586
587 #[test]
590 fn test_title_case_basic() {
591 assert_eq!(to_title_case("the quick brown fox"), "The Quick Brown Fox");
592 }
593
594 #[test]
595 fn test_title_case_stop_words() {
596 assert_eq!(
597 to_title_case("a tale of two cities"),
598 "A Tale of Two Cities"
599 );
600 }
601
602 #[test]
603 fn test_title_case_last_word_capitalized() {
604 assert_eq!(
605 to_title_case("the world we live in"),
606 "The World We Live In"
607 );
608 }
609
610 #[test]
611 fn test_title_case_after_colon() {
612 assert_eq!(
613 to_title_case("the title: a subtitle"),
614 "The Title: A Subtitle"
615 );
616 }
617
618 #[test]
619 fn test_title_case_after_colon_stop_word() {
620 assert_eq!(
622 to_title_case("history of the world: a new perspective"),
623 "History of the World: A New Perspective"
624 );
625 }
626
627 #[test]
628 fn test_title_case_after_question_mark() {
629 assert_eq!(
630 to_title_case("who's black and why? a hidden chapter"),
631 "Who's Black and Why? A Hidden Chapter"
632 );
633 }
634
635 #[test]
636 fn test_title_case_after_question_mark_with_closing_quote() {
637 assert_eq!(
638 to_title_case("who's black and why?\" a hidden chapter"),
639 "Who's Black and Why?\" A Hidden Chapter"
640 );
641 }
642
643 #[test]
644 fn test_title_case_from_is_stop_word() {
645 assert_eq!(
646 to_title_case("a hidden chapter from the eighteenth-century invention of race"),
647 "A Hidden Chapter from the Eighteenth-Century Invention of Race"
648 );
649 }
650
651 #[test]
652 fn test_title_case_hyphenated_compound() {
653 assert_eq!(
654 to_title_case("eighteenth-century studies"),
655 "Eighteenth-Century Studies"
656 );
657 }
658
659 #[test]
660 fn test_title_case_hyphenated_stop_word_part() {
661 assert_eq!(to_title_case("a well-to-do family"), "A Well-to-Do Family");
663 }
664
665 #[test]
666 fn given_en_dash_compound_when_title_case_then_both_sides_capitalized() {
667 assert_eq!(
671 to_title_case("the aging\u{2013}disability nexus"),
672 "The Aging\u{2013}Disability Nexus"
673 );
674 }
675
676 #[test]
679 fn test_sentence_apa_structured() {
680 let (main, subs) = apply_to_structured_parts(
681 "Understanding Citation Systems",
682 &["History and Practice", "A Comparative View"],
683 TextCase::SentenceApa,
684 );
685 assert_eq!(main, "Understanding citation systems");
686 assert_eq!(subs, vec!["History and practice", "A comparative view"]);
687 }
688
689 #[test]
690 fn test_sentence_nlm_structured() {
691 let (main, subs) = apply_to_structured_parts(
692 "Understanding Citation Systems",
693 &["History and Practice"],
694 TextCase::SentenceNlm,
695 );
696 assert_eq!(main, "Understanding citation systems");
697 assert_eq!(subs, vec!["history and practice"]);
699 }
700
701 #[test]
702 fn test_title_case_structured() {
703 let (main, subs) =
706 apply_to_structured_parts("the DNA of empire", &["a new perspective"], TextCase::Title);
707 assert_eq!(main, "The DNA of Empire");
708 assert_eq!(subs, vec!["A New Perspective"]);
709 }
710
711 #[test]
712 fn given_mixed_case_surname_when_title_case_then_preserved() {
713 assert_eq!(to_title_case("a study of McDonald"), "A Study of McDonald");
714 }
715
716 #[test]
719 fn test_english_language_detection() {
720 assert!(is_english_language(Some("en")));
721 assert!(is_english_language(Some("en-US")));
722 assert!(is_english_language(Some("en-GB")));
723 assert!(is_english_language(None));
724 assert!(!is_english_language(Some("de")));
725 assert!(!is_english_language(Some("fr-FR")));
726 }
727
728 #[test]
729 fn test_resolve_non_english_falls_back() {
730 assert_eq!(
731 resolve_text_case(TextCase::SentenceApa, Some("de")),
732 TextCase::AsIs
733 );
734 assert_eq!(
735 resolve_text_case(TextCase::Title, Some("fr")),
736 TextCase::AsIs
737 );
738 assert_eq!(
740 resolve_text_case(TextCase::Lowercase, Some("de")),
741 TextCase::Lowercase
742 );
743 }
744
745 #[test]
746 fn test_resolve_english_passes_through() {
747 assert_eq!(
748 resolve_text_case(TextCase::SentenceApa, Some("en")),
749 TextCase::SentenceApa
750 );
751 assert_eq!(
752 resolve_text_case(TextCase::Title, Some("en-US")),
753 TextCase::Title
754 );
755 }
756
757 #[test]
758 fn test_note_start_capitalize_first_uses_english_language_rules() {
759 assert_eq!(
760 apply_note_start_text_case(
761 "edited by",
762 NoteStartTextCase::CapitalizeFirst,
763 Some("en-US"),
764 ),
765 "Edited by"
766 );
767 }
768
769 #[test]
770 fn test_note_start_capitalize_first_falls_back_to_as_is_for_non_english() {
771 assert_eq!(
772 apply_note_start_text_case(
773 "hg. von",
774 NoteStartTextCase::CapitalizeFirst,
775 Some("de-DE"),
776 ),
777 "hg. von"
778 );
779 }
780
781 #[test]
782 fn test_note_start_capitalize_first_is_no_op_for_uncased_scripts() {
783 assert_eq!(
784 apply_note_start_text_case("ابن سينا", NoteStartTextCase::CapitalizeFirst, Some("ar"),),
785 "ابن سينا"
786 );
787 }
788}