1use crate::reference::Reference;
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_schema::locale::{DigitSystem, GeneralTerm, GrammaticalGender, MessageArgs, TermForm};
14use citum_schema::reference::ClassExtension;
15use citum_schema::template::{LabelForm, NumberForm, NumberVariable, TemplateNumber};
16
17fn resolve_number_value(
19 number: &NumberVariable,
20 reference: &Reference,
21 hints: &ProcHints,
22 options: &RenderOptions<'_>,
23 show_with_locator: bool,
24) -> Option<String> {
25 match number {
26 NumberVariable::Volume => reference.volume().map(|v| v.to_string()),
27 NumberVariable::Issue => reference.issue().map(|v| v.to_string()),
28 NumberVariable::Pages => {
29 let suppress = !show_with_locator
30 && options.context == crate::values::RenderContext::Citation
31 && options.locator_raw.is_some()
32 && matches!(
33 options.config.processing,
34 Some(citum_schema::options::Processing::Note)
35 );
36 if suppress {
37 None
38 } else {
39 reference.pages().map(|p| {
40 let delimiter =
41 options.config.page_range_delimiter.as_deref().unwrap_or(
42 options.locale.grammar_options.page_range_delimiter.as_str(),
43 );
44 format_page_range(
45 &p.to_string(),
46 options.config.page_range_format.as_ref(),
47 delimiter,
48 )
49 })
50 }
51 }
52 NumberVariable::ChapterNumber => match reference.extension() {
53 ClassExtension::Statute(r) => r.chapter_number.clone(),
54 _ => reference.numbering_value(&citum_schema::reference::NumberingType::Chapter),
55 },
56 NumberVariable::Edition => reference.edition(),
57 NumberVariable::CollectionNumber => reference.collection_number(),
58 NumberVariable::Number => reference.number(),
59 NumberVariable::Custom(kind) => reference.numbering_value(
60 &citum_schema::reference::NumberingType::Custom(kind.clone()),
61 ),
62 NumberVariable::DocketNumber => match reference.extension() {
63 ClassExtension::Brief(r) => r.docket_number.clone(),
64 _ => None,
65 },
66 NumberVariable::PatentNumber => match reference.extension() {
67 ClassExtension::Patent(r) => Some(
72 r.application_number
73 .clone()
74 .unwrap_or_else(|| r.patent_number.clone()),
75 ),
76 _ => None,
77 },
78 NumberVariable::StandardNumber => match reference.extension() {
79 ClassExtension::Standard(r) => Some(r.standard_number.clone()),
80 _ => None,
81 },
82 NumberVariable::ReportNumber => reference.report_number(),
83 NumberVariable::PartNumber => {
84 reference.numbering_value(&citum_schema::reference::NumberingType::Part)
85 }
86 NumberVariable::SupplementNumber => {
87 reference.numbering_value(&citum_schema::reference::NumberingType::Supplement)
88 }
89 NumberVariable::PrintingNumber => {
90 reference.numbering_value(&citum_schema::reference::NumberingType::Printing)
91 }
92 NumberVariable::FirstReferenceNoteNumber => {
93 hints.first_reference_note_number.map(|n| n.to_string())
94 }
95 NumberVariable::CitationNumber => hints.citation_number.map(|n| {
96 if options.context == crate::values::RenderContext::Citation
97 && let Some(sub_label) = &hints.citation_sub_label
98 {
99 return format!("{n}{sub_label}");
100 }
101 n.to_string()
102 }),
103 NumberVariable::CitationLabel => {
104 let Some(citum_schema::options::Processing::Label(config)) =
105 options.config.processing.as_ref()
106 else {
107 return None;
108 };
109 let params = config.effective_params();
110 let base = crate::processor::labels::generate_base_label(reference, ¶ms);
111 if base.is_empty() {
112 return None;
113 }
114 let suffix = if hints.disamb_condition && hints.group_index > 0 {
115 crate::values::int_to_letter(hints.group_index as u32).unwrap_or_default()
116 } else {
117 String::new()
118 };
119 Some(format!("{base}{suffix}"))
120 }
121 _ => None,
122 }
123}
124
125fn label_form_to_term_form(label_form: &LabelForm) -> TermForm {
127 match label_form {
128 LabelForm::Long => TermForm::Long,
129 LabelForm::Short => TermForm::Short,
130 LabelForm::Symbol => TermForm::Symbol,
131 }
132}
133
134fn resolve_number_label<F: crate::render::format::OutputFormat<Output = String>>(
136 number: &NumberVariable,
137 label_form: &LabelForm,
138 value: &str,
139 requested_gender: Option<GrammaticalGender>,
140 effective_rendering: &citum_schema::template::Rendering,
141 options: &RenderOptions<'_>,
142 fmt: &F,
143) -> Option<String> {
144 if let Some(locator_type) = number_var_to_locator_type(number) {
145 let plural = check_plural(value, &locator_type);
147 let term_form = label_form_to_term_form(label_form);
148
149 options
150 .locale
151 .resolved_locator_term(&locator_type, plural, &term_form, requested_gender)
152 .map(|t| {
153 let term_str = if crate::values::should_strip_periods(effective_rendering, options)
154 {
155 crate::values::strip_trailing_periods(&t)
156 } else {
157 t
158 };
159 fmt.text(&format!("{term_str} "))
160 })
161 } else {
162 None
163 }
164}
165
166#[must_use]
172fn number_var_to_general_term(var: &NumberVariable) -> Option<GeneralTerm> {
173 match var {
174 NumberVariable::Edition => Some(GeneralTerm::Edition),
175 NumberVariable::Volume => Some(GeneralTerm::Volume),
176 _ => None,
177 }
178}
179
180fn split_numeric_term(term: &str) -> (Option<String>, Option<String>) {
189 if let Some((before, after)) = term.split_once("%s") {
190 let prefix = (!before.is_empty()).then(|| before.to_string());
191 let suffix = (!after.is_empty()).then(|| after.to_string());
192 (prefix, suffix)
193 } else {
194 (None, Some(format!(" {term}")))
195 }
196}
197
198fn render_ordinal(value: String, options: &RenderOptions<'_>) -> String {
203 let Ok(count) = value.parse::<u64>() else {
204 return value;
205 };
206 let args = MessageArgs {
207 count: Some(count),
208 value: Some(&value),
209 ..MessageArgs::default()
210 };
211 options
212 .locale
213 .resolve_message("number.ordinal", &args)
214 .unwrap_or(value)
215}
216
217fn localize_digits(value: String, digit_system: &DigitSystem) -> String {
222 let digits = match digit_system {
223 DigitSystem::Western => return value,
224 DigitSystem::ArabicIndic => ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'],
225 DigitSystem::ExtendedArabicIndic => ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'],
226 DigitSystem::Devanagari => ['०', '१', '२', '३', '४', '५', '६', '७', '८', '९'],
227 _ => return value,
228 };
229
230 value
231 .chars()
232 .map(|ch| {
233 ch.to_digit(10)
234 .filter(|_| ch.is_ascii_digit())
235 .and_then(|digit| digits.get(digit as usize).copied())
236 .unwrap_or(ch)
237 })
238 .collect()
239}
240
241impl ComponentValues for TemplateNumber {
242 fn values<F: crate::render::format::OutputFormat<Output = String>>(
243 &self,
244 reference: &Reference,
245 hints: &ProcHints,
246 options: &RenderOptions<'_>,
247 ) -> Option<ProcValues<F::Output>> {
248 let fmt = F::default();
249
250 let value = resolve_number_value(
251 &self.number,
252 reference,
253 hints,
254 options,
255 self.show_with_locator.unwrap_or(false),
256 );
257
258 value.filter(|s| !s.is_empty()).map(|value| {
259 let effective_rendering = &self.rendering;
261
262 let value = if let Some(tc) = effective_rendering.text_case {
265 let language = reference.language();
266 crate::values::text_case::apply_text_case_with_language(
267 &value,
268 tc,
269 language.as_deref(),
270 )
271 } else {
272 value
273 };
274 let value_is_numeric = is_numeric(&value);
275
276 let value = if self.form == Some(NumberForm::Ordinal) {
277 render_ordinal(value, options)
278 } else {
279 value
280 };
281
282 let label_prefix = if let Some(label_form) = &self.label_form {
284 resolve_number_label(
285 &self.number,
286 label_form,
287 &value,
288 self.gender.clone(),
289 effective_rendering,
290 options,
291 &fmt,
292 )
293 } else {
294 None
295 };
296
297 let (numeric_prefix, numeric_suffix) = self
302 .when_numeric
303 .as_ref()
304 .filter(|_| value_is_numeric)
305 .and_then(|form| {
306 let general_term = number_var_to_general_term(&self.number)?;
307 options.locale.resolved_general_term(
308 &general_term,
309 &label_form_to_term_form(form),
310 self.gender.clone(),
311 )
312 })
313 .map(|term| split_numeric_term(&term))
314 .unwrap_or((None, None));
315
316 let prefix = match (label_prefix, numeric_prefix) {
317 (Some(label), Some(numeric)) => Some(format!("{label}{numeric}")),
318 (Some(label), None) => Some(label),
319 (None, Some(numeric)) => Some(numeric),
320 (None, None) => None,
321 };
322 let suffix = numeric_suffix;
323 let value = localize_digits(value, &options.locale.number_formats.digit_system);
324
325 ProcValues {
326 value,
327 prefix,
328 suffix,
329 url: crate::values::resolve_effective_url(
330 self.links.as_ref(),
331 options.config.links.as_ref(),
332 reference,
333 citum_schema::options::LinkAnchor::Component,
334 ),
335 substituted_key: None,
336 pre_formatted: false,
337 }
338 })
339 }
340}
341
342fn is_numeric(value: &str) -> bool {
352 let value = value.trim();
353 let mut saw_digit = false;
354 for ch in value.chars() {
355 if ch.is_ascii_digit() {
356 saw_digit = true;
357 } else if !matches!(ch, '-' | ',' | '&' | ' ') {
358 return false;
359 }
360 }
361 saw_digit
362}
363
364#[cfg(test)]
365mod is_numeric_tests {
366 use super::is_numeric;
367
368 #[test]
369 fn given_bare_digits_when_checked_then_numeric() {
370 assert!(is_numeric("2"));
371 assert!(is_numeric("510"));
372 }
373
374 #[test]
375 fn given_digit_range_or_list_when_checked_then_numeric() {
376 assert!(is_numeric("1-3"));
377 assert!(is_numeric("12, 14"));
378 }
379
380 #[test]
381 fn given_digit_embedded_in_free_text_when_checked_then_not_numeric() {
382 assert!(!is_numeric("新1版"));
383 }
384
385 #[test]
386 fn given_free_text_without_digits_when_checked_then_not_numeric() {
387 assert!(!is_numeric("修订版"));
388 assert!(!is_numeric("美国卷"));
389 }
390
391 #[test]
392 fn given_cjk_numeral_when_checked_then_not_numeric() {
393 assert!(!is_numeric("第二卷"));
396 }
397
398 #[test]
399 fn given_empty_value_when_checked_then_not_numeric() {
400 assert!(!is_numeric(""));
401 assert!(!is_numeric(" "));
402 }
403}
404
405#[cfg(test)]
406mod digit_system_tests {
407 use super::localize_digits;
408 use citum_schema::locale::DigitSystem;
409
410 #[test]
411 fn localizes_ascii_digits_for_supported_digit_systems() {
412 for (digit_system, expected) in [
413 (DigitSystem::Western, "AB-12, 34"),
414 (DigitSystem::ArabicIndic, "AB-١٢, ٣٤"),
415 (DigitSystem::ExtendedArabicIndic, "AB-۱۲, ۳۴"),
416 (DigitSystem::Devanagari, "AB-१२, ३४"),
417 ] {
418 assert_eq!(
419 localize_digits("AB-12, 34".to_string(), &digit_system),
420 expected
421 );
422 }
423 }
424
425 #[test]
426 fn preserves_western_digits_for_unknown_digit_system() {
427 assert_eq!(
428 localize_digits(
429 "12".to_string(),
430 &DigitSystem::Unknown("future".to_string())
431 ),
432 "12"
433 );
434 }
435}
436
437#[cfg(test)]
438mod split_numeric_term_tests {
439 use super::split_numeric_term;
440
441 #[test]
442 fn given_circumfix_term_when_split_then_wraps_around_marker() {
443 assert_eq!(
444 split_numeric_term("第%s卷"),
445 (Some("第".to_string()), Some("卷".to_string()))
446 );
447 }
448
449 #[test]
450 fn given_suffix_only_term_when_split_then_prefix_only_marker() {
451 assert_eq!(split_numeric_term("%s卷"), (None, Some("卷".to_string())));
453 }
454
455 #[test]
456 fn given_plain_term_when_split_then_space_separated_suffix() {
457 assert_eq!(split_numeric_term("版"), (None, Some(" 版".to_string())));
458 }
459}
460
461#[must_use]
467pub fn number_var_to_locator_type(
468 var: &NumberVariable,
469) -> Option<citum_schema::citation::LocatorType> {
470 use citum_schema::citation::LocatorType;
471 match var {
472 NumberVariable::Volume => Some(LocatorType::Volume),
473 NumberVariable::Pages => Some(LocatorType::Page),
474 NumberVariable::ChapterNumber => Some(LocatorType::Chapter),
475 NumberVariable::NumberOfPages => Some(LocatorType::Page),
476 NumberVariable::NumberOfVolumes => Some(LocatorType::Volume),
477 NumberVariable::Number
478 | NumberVariable::DocketNumber
479 | NumberVariable::PatentNumber
480 | NumberVariable::StandardNumber
481 | NumberVariable::ReportNumber
482 | NumberVariable::PrintingNumber => Some(LocatorType::Number),
483 NumberVariable::PartNumber => Some(LocatorType::Part),
484 NumberVariable::SupplementNumber => Some(LocatorType::Supplement),
485 NumberVariable::Issue => Some(LocatorType::Issue),
486 NumberVariable::Custom(kind) => Some(LocatorType::Custom(kind.clone())),
487 _ => None,
488 }
489}
490
491#[must_use]
497pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
498 value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
501}
502
503#[must_use]
509pub fn format_page_range(
510 pages: &str,
511 format: Option<&citum_schema::options::PageRangeFormat>,
512 delimiter: &str,
513) -> String {
514 use citum_schema::options::PageRangeFormat;
515
516 let normalized = pages.replace('\u{2013}', "-");
519 let with_delimiter = || normalized.replace('-', delimiter);
520
521 let Some(format) = format else {
523 return with_delimiter();
524 };
525
526 let parts: Vec<&str> = normalized.split('-').collect();
527 let [start, end] = parts.as_slice() else {
528 return with_delimiter(); };
530 let start = start.trim();
531 let end = end.trim();
532
533 let start_num: Option<u32> = start.parse().ok();
535 let end_num: Option<u32> = end.parse().ok();
536
537 match (start_num, end_num) {
538 (Some(s), Some(e)) if e > s => {
539 let formatted_end = match format {
540 PageRangeFormat::Expanded => end.to_string(),
541 PageRangeFormat::Minimal => format_minimal(start, end, 1),
542 PageRangeFormat::MinimalTwo => format_minimal(start, end, 2),
543 PageRangeFormat::Chicago | PageRangeFormat::Chicago16 => {
544 format_chicago_range_end(s, e)
545 }
546 _ => end.to_string(), };
548 format!("{start}{delimiter}{formatted_end}")
549 }
550 _ => with_delimiter(), }
552}
553
554#[must_use]
556pub fn format_minimal(start: &str, end: &str, min_digits: usize) -> String {
557 let start_chars: Vec<char> = start.chars().collect();
558 let end_chars: Vec<char> = end.chars().collect();
559
560 if start_chars.len() != end_chars.len() {
561 return end.to_string();
562 }
563
564 let mut first_diff = 0;
566 for (i, (s, e)) in start_chars.iter().zip(end_chars.iter()).enumerate() {
567 if s != e {
568 first_diff = i;
569 break;
570 }
571 }
572
573 let keep_from = first_diff.min(end_chars.len().saturating_sub(min_digits));
575 end_chars
576 .get(keep_from..)
577 .unwrap_or_default()
578 .iter()
579 .collect()
580}
581
582#[must_use]
584pub(crate) fn format_chicago_range_end(start: u32, end: u32) -> String {
585 if start < 100 || start.is_multiple_of(100) {
594 return end.to_string();
595 }
596
597 let start_str = start.to_string();
598 let end_str = end.to_string();
599 let changed_end_part = if start % 100 <= 9 {
600 format_minimal(&start_str, &end_str, 1)
601 } else {
602 format_minimal(&start_str, &end_str, 2)
603 };
604
605 if changed_end_part.len() > 1 && changed_end_part.starts_with('0') {
606 let trimmed = changed_end_part.trim_start_matches('0');
607 if trimmed.is_empty() {
608 "0".to_string()
609 } else {
610 trimmed.to_string()
611 }
612 } else {
613 changed_end_part
614 }
615}
616
617#[must_use]
619pub fn format_chicago(start: u32, end: u32) -> String {
620 format_chicago_range_end(start, end)
621}
622
623#[cfg(test)]
624#[allow(
625 clippy::unwrap_used,
626 clippy::expect_used,
627 clippy::panic,
628 clippy::indexing_slicing,
629 clippy::todo,
630 clippy::unimplemented,
631 clippy::unreachable,
632 clippy::get_unwrap,
633 reason = "Panicking is acceptable and often desired in tests."
634)]
635mod tests {
636 use super::*;
637 use citum_schema::options::PageRangeFormat;
638
639 #[test]
640 fn test_format_chicago_page_range_end() {
641 for (start, end, expected) in [
642 (3, 10, "10"),
643 (71, 72, "72"),
644 (92, 113, "113"),
645 (100, 104, "104"),
646 (600, 613, "613"),
647 (107, 108, "8"),
648 (505, 517, "17"),
649 (1002, 1006, "6"),
650 (321, 325, "25"),
651 (415, 532, "532"),
652 (1087, 1089, "89"),
653 (1496, 1500, "500"),
654 (13792, 13803, "803"),
655 (12991, 13001, "3001"),
656 ] {
657 assert_eq!(format_chicago_range_end(start, end), expected);
658 }
659 }
660
661 #[test]
662 fn test_format_minimal() {
663 for (start, end, min_digits, expected) in [
664 ("100", "105", 1, "5"),
665 ("100", "105", 2, "05"),
666 ("1536", "1538", 1, "8"),
667 ("1536", "1538", 2, "38"),
668 ("1536", "1538", 4, "1538"),
669 ("12", "15", 1, "5"),
670 ("12", "15", 2, "15"),
671 ("10", "150", 1, "150"),
672 ] {
673 assert_eq!(format_minimal(start, end, min_digits), expected);
674 }
675 }
676
677 #[test]
678 fn test_format_page_range() {
679 let en = "\u{2013}";
681 for (input, format, expected) in [
682 ("10-15", None, "10–15"),
683 ("10–15", None, "10–15"),
684 ("321-328", None, "321–328"),
685 ("10-15", Some(PageRangeFormat::Expanded), "10–15"),
686 ("42-45", Some(PageRangeFormat::Expanded), "42–45"),
687 ("3-10", Some(PageRangeFormat::Chicago), "3–10"),
688 ("71-72", Some(PageRangeFormat::Chicago), "71–72"),
689 ("92-113", Some(PageRangeFormat::Chicago), "92–113"),
690 ("100-104", Some(PageRangeFormat::Chicago), "100–104"),
691 ("600-613", Some(PageRangeFormat::Chicago), "600–613"),
692 ("107-108", Some(PageRangeFormat::Chicago), "107–8"),
693 ("505-517", Some(PageRangeFormat::Chicago), "505–17"),
694 ("1002-1006", Some(PageRangeFormat::Chicago), "1002–6"),
695 ("321-325", Some(PageRangeFormat::Chicago), "321–25"),
696 ("415-532", Some(PageRangeFormat::Chicago), "415–532"),
697 ("1087-1089", Some(PageRangeFormat::Chicago), "1087–89"),
698 ("1496-1500", Some(PageRangeFormat::Chicago), "1496–500"),
699 ("13792-13803", Some(PageRangeFormat::Chicago), "13792–803"),
700 ("12991-13001", Some(PageRangeFormat::Chicago), "12991–3001"),
701 ("3-10", Some(PageRangeFormat::Chicago16), "3–10"),
702 ("71-72", Some(PageRangeFormat::Chicago16), "71–72"),
703 ("92-113", Some(PageRangeFormat::Chicago16), "92–113"),
704 ("100-104", Some(PageRangeFormat::Chicago16), "100–104"),
705 ("600-613", Some(PageRangeFormat::Chicago16), "600–613"),
706 ("107-108", Some(PageRangeFormat::Chicago16), "107–8"),
707 ("505-517", Some(PageRangeFormat::Chicago16), "505–17"),
708 ("1002-1006", Some(PageRangeFormat::Chicago16), "1002–6"),
709 ("321-325", Some(PageRangeFormat::Chicago16), "321–25"),
710 ("415-532", Some(PageRangeFormat::Chicago16), "415–532"),
711 ("1087-1089", Some(PageRangeFormat::Chicago16), "1087–89"),
712 ("1496-1500", Some(PageRangeFormat::Chicago16), "1496–500"),
713 ("13792-13803", Some(PageRangeFormat::Chicago16), "13792–803"),
714 (
715 "12991-13001",
716 Some(PageRangeFormat::Chicago16),
717 "12991–3001",
718 ),
719 ("100-105", Some(PageRangeFormat::Minimal), "100–5"),
720 ("321-328", Some(PageRangeFormat::Minimal), "321–8"),
721 ("42-45", Some(PageRangeFormat::Minimal), "42–5"),
722 ("12-17", Some(PageRangeFormat::Minimal), "12–7"),
723 ("100-105", Some(PageRangeFormat::MinimalTwo), "100–05"),
724 ("42-45", Some(PageRangeFormat::MinimalTwo), "42–45"),
725 ("10", Some(PageRangeFormat::Chicago), "10"),
726 ("10-5", Some(PageRangeFormat::Chicago), "10–5"),
727 ("X-Y", Some(PageRangeFormat::Chicago), "X–Y"),
728 ("10-15-20", Some(PageRangeFormat::Chicago), "10–15–20"),
729 ] {
730 assert_eq!(format_page_range(input, format.as_ref(), en), expected);
731 }
732 }
733
734 #[test]
735 fn test_format_page_range_hyphen_delimiter() {
736 for (input, format, expected) in [
739 ("436-444", None, "436-444"),
740 ("436–444", None, "436-444"),
741 ("321-328", Some(PageRangeFormat::Expanded), "321-328"),
742 ("321-328", Some(PageRangeFormat::Chicago), "321-28"),
743 ] {
744 assert_eq!(format_page_range(input, format.as_ref(), "-"), expected);
745 }
746 }
747
748 #[test]
749 fn test_check_plural() {
750 for (value, expected) in [
751 ("1-10", true),
752 ("1–10", true),
753 ("1, 3", true),
754 ("1 & 3", true),
755 ("1", false),
756 ("IV", false),
757 ] {
758 assert_eq!(
759 check_plural(value, &citum_schema::citation::LocatorType::Page),
760 expected
761 );
762 }
763 }
764
765 #[test]
766 fn number_var_to_locator_type_maps_printing_number() {
767 assert_eq!(
768 number_var_to_locator_type(&NumberVariable::PrintingNumber),
769 Some(citum_schema::citation::LocatorType::Number)
770 );
771 }
772
773 #[test]
774 fn number_var_to_locator_type_maps_part_number() {
775 assert_eq!(
776 number_var_to_locator_type(&NumberVariable::PartNumber),
777 Some(citum_schema::citation::LocatorType::Part)
778 );
779 }
780
781 #[test]
782 fn number_var_to_locator_type_maps_supplement_number() {
783 assert_eq!(
784 number_var_to_locator_type(&NumberVariable::SupplementNumber),
785 Some(citum_schema::citation::LocatorType::Supplement)
786 );
787 }
788}