1use crate::reference::Reference;
10use crate::render::format::unicode_quote_marks;
11use crate::render::rich_text::{
12 InlineRenderContext, render_djot_inline_with_transform_and_context,
13};
14use crate::values::text_case::{self, apply_text_case_with_language};
15use crate::values::{
16 ComponentValues, ProcHints, ProcValues, RenderOptions, effective_component_language,
17};
18use citum_schema::locale::Locale;
19use citum_schema::options::titles::{TextCase, TitleRendering};
20use citum_schema::reference::ClassExtension;
21use citum_schema::reference::types::{StructuredTitle, Subtitle, Title};
22use citum_schema::template::{TemplateComponent, TemplateTitle, TitleForm, TitleType};
23
24fn smarten_title_quotes_at_depth(input: &str, quote_depth: usize) -> String {
30 let mut out = String::with_capacity(input.len());
31 let mut it = input.char_indices().peekable();
32 let mut prev: Option<char> = None;
33 let mut open_single_quotes = 0usize;
34 let mut open_double_quotes = 0usize;
35
36 while let Some((_, ch)) = it.next() {
37 let next = it.peek().map(|(_, c)| *c);
38 let prev_is_alpha = prev.is_some_and(char::is_alphabetic);
39 let prev_is_digit = prev.is_some_and(|c| c.is_ascii_digit());
40 let prev_can_close_double_quote = prev.is_some_and(|c| {
41 c.is_alphanumeric() || matches!(c, '\'' | '"' | '\u{2019}' | '\u{201D}')
42 });
43 let next_is_alpha = next.is_some_and(char::is_alphabetic);
44 let next_is_digit = next.is_some_and(|c| c.is_ascii_digit());
45 let next_is_alnum = next.is_some_and(char::is_alphanumeric);
46 let prev_opens_quote =
47 prev.is_none_or(|c| c.is_whitespace() || "([{\u{2018}\u{201C}'\"".contains(c));
48 let next_closes_quote =
49 next.is_none_or(|c| c.is_whitespace() || ".,;:!?)]}\u{2019}\u{201D}'\"".contains(c));
50
51 match ch {
52 '\'' => {
53 let (open_quote, close_quote) = unicode_quote_marks(quote_depth + 1);
54 if (prev_is_alpha && next_is_alpha) || (prev_opens_quote && next_is_digit) {
55 out.push('\u{2019}');
56 } else if prev_opens_quote && next_is_alnum {
57 out.push_str(open_quote);
58 open_single_quotes += 1;
59 } else if (open_single_quotes > 0 || prev_is_alpha || prev_is_digit)
60 && next_closes_quote
61 {
62 out.push_str(close_quote);
63 open_single_quotes = open_single_quotes.saturating_sub(1);
64 } else {
65 out.push('\'');
66 }
67 }
68 '"' => {
69 let (open_quote, close_quote) =
70 unicode_quote_marks(quote_depth + open_double_quotes);
71 if prev_opens_quote && next_is_alnum {
72 out.push_str(open_quote);
73 open_double_quotes += 1;
74 } else if open_double_quotes > 0 && prev_can_close_double_quote && next_closes_quote
75 {
76 let close_depth = quote_depth + open_double_quotes.saturating_sub(1);
77 let (_, close_quote) = unicode_quote_marks(close_depth);
78 out.push_str(close_quote);
79 open_double_quotes -= 1;
80 } else if prev_is_alpha && next_closes_quote {
81 out.push_str(close_quote);
82 } else {
83 out.push('"');
84 }
85 }
86 _ => out.push(ch),
87 }
88
89 prev = Some(ch);
90 }
91 out
92}
93
94fn title_text(title: &Title, form: Option<&TitleForm>) -> String {
95 match title {
96 Title::Shorthand(short, long) => {
97 if matches!(form, Some(TitleForm::Short)) {
98 short.clone()
99 } else {
100 long.clone()
101 }
102 }
103 Title::Single(s) => s.clone(),
104 _ => title.to_string(),
105 }
106}
107
108fn parent_short_title(reference: &Reference, title_type: &TitleType) -> Option<String> {
109 match title_type {
110 TitleType::ContainerTitle => reference.container_title().and_then(|t| match t {
111 Title::Shorthand(short, _) => Some(short),
112 Title::Single(s) => Some(s),
113 _ => None,
114 }),
115 TitleType::ParentMonograph => {
116 if reference.ref_type() == "chapter" || reference.ref_type() == "paper-conference" {
117 reference.container_title().and_then(|t| match t {
118 Title::Shorthand(short, _) => Some(short),
119 Title::Single(s) => Some(s),
120 _ => None,
121 })
122 } else {
123 None
124 }
125 }
126 TitleType::ParentSerial => {
127 if crate::values::type_class::is_serial_parent_type(&reference.ref_type()) {
128 reference.container_title().and_then(|t| match t {
129 Title::Shorthand(short, _) => Some(short),
130 Title::Single(s) => Some(s),
131 _ => None,
132 })
133 } else {
134 None
135 }
136 }
137 TitleType::CollectionTitle => reference.collection_title().and_then(|t| match t {
138 Title::Shorthand(short, _) => Some(short),
139 Title::Single(s) => Some(s),
140 _ => None,
141 }),
142 _ => None,
143 }
144}
145
146fn looks_like_djot_markup(value: &str) -> bool {
147 value.contains('_')
148 || value.contains('*')
149 || value.contains("](")
150 || value.contains("{.")
151 || value.contains('`')
152}
153
154fn make_case_transform(
159 case: TextCase,
160 quote_depth: usize,
161 language: Option<&str>,
162) -> impl FnMut(&str) -> String {
163 let mut seen_alpha = false;
164 let language = text_case::language_identifier_for_tag(language);
165 move |text: &str| {
166 let cased = match case {
167 TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
168 let lowered = text_case::apply_text_case_with_language_id(
169 text,
170 TextCase::Lowercase,
171 &language,
172 );
173 if seen_alpha {
174 lowered
175 } else {
176 let result = text_case::apply_text_case_with_language_id(
178 &lowered,
179 TextCase::CapitalizeFirst,
180 &language,
181 );
182 if result.chars().any(|c: char| c.is_alphabetic()) {
183 seen_alpha = true;
184 }
185 result
186 }
187 }
188 _ => text_case::apply_text_case_with_language_id(text, case, &language),
189 };
190 smarten_title_quotes_at_depth(&cased, quote_depth)
191 }
192}
193
194fn render_part_with_case<F: crate::render::format::OutputFormat<Output = String>>(
197 value: &str,
198 fmt: &F,
199 case: Option<TextCase>,
200 quote_depth: usize,
201 language: Option<&str>,
202) -> (String, bool) {
203 let context = InlineRenderContext { quote_depth };
204 if looks_like_djot_markup(value) {
205 match case {
206 Some(tc) => render_djot_inline_with_transform_and_context(
207 value,
208 fmt,
209 context,
210 make_case_transform(tc, quote_depth, language),
211 ),
212 None => {
213 render_djot_inline_with_transform_and_context(value, fmt, context, move |text| {
214 smarten_title_quotes_at_depth(text, quote_depth)
215 })
216 }
217 }
218 } else {
219 let result = match case {
220 Some(tc) => smarten_title_quotes_at_depth(
221 &apply_text_case_with_language(value, tc, language),
222 quote_depth,
223 ),
224 None => smarten_title_quotes_at_depth(value, quote_depth),
225 };
226 (result, false)
227 }
228}
229
230fn render_structured_title<F: crate::render::format::OutputFormat<Output = String>>(
235 st: &StructuredTitle,
236 fmt: &F,
237 case: Option<TextCase>,
238 short: bool,
239 quote_depth: usize,
240 language: Option<&str>,
241 delimiters: (&str, &str),
242) -> (String, bool) {
243 let (main_rendered, has_link) =
244 render_part_with_case(&st.main, fmt, case, quote_depth, language);
245 if short {
246 return (main_rendered, has_link);
247 }
248
249 let subtitle_case = case.map(|c| match c {
250 TextCase::SentenceNlm => TextCase::Lowercase,
251 other => other,
252 });
253
254 let mut has_link = has_link;
255
256 let subs: Vec<&str> = match &st.sub {
257 Subtitle::String(s) => vec![s.as_str()],
258 Subtitle::Vector(v) => v.iter().map(std::string::String::as_str).collect(),
259 };
260 if subs.is_empty() {
261 return (main_rendered, has_link);
262 }
263
264 let mut rendered_subtitles = Vec::with_capacity(subs.len());
265 for sub in subs {
266 let (sub_rendered, sub_link) =
267 render_part_with_case(sub, fmt, subtitle_case, quote_depth, language);
268 has_link |= sub_link;
269 rendered_subtitles.push(sub_rendered);
270 }
271 let (primary_delimiter, subtitle_delimiter) = delimiters;
272 let subtitle_group = rendered_subtitles.join(subtitle_delimiter);
273
274 let mut rendered = main_rendered;
275 rendered.push_str(primary_delimiter);
276 rendered.push_str(&subtitle_group);
277
278 (rendered, has_link)
279}
280
281fn resolve_effective_text_case(
283 template: &TemplateTitle,
284 reference: &Reference,
285 options: &RenderOptions<'_>,
286 language: Option<&str>,
287) -> Option<TextCase> {
288 if let Some(tc) = template.rendering.text_case {
290 return Some(apply_language_fallback(tc, language));
291 }
292
293 let ref_type = reference.ref_type();
295
296 if let Some(rendering) = crate::render::component::get_title_category_rendering(
297 &template.title,
298 Some(&ref_type),
299 language,
300 &options.config,
301 ) && let Some(tc) = rendering.text_case
302 {
303 return Some(apply_language_fallback(tc, language));
304 }
305
306 None
307}
308
309pub(crate) fn resolve_substitute_text_case(
317 title_type: &TitleType,
318 reference: &Reference,
319 options: &RenderOptions<'_>,
320) -> Option<TextCase> {
321 let ref_type = reference.ref_type();
322 let language = effective_title_language(title_type, reference);
323 let rendering = crate::render::component::get_title_category_rendering(
324 title_type,
325 Some(&ref_type),
326 language.as_deref(),
327 &options.config,
328 )?;
329 let tc = rendering.text_case?;
330 Some(apply_language_fallback(tc, language.as_deref()))
331}
332
333fn resolve_effective_title_rendering(
334 template: &TemplateTitle,
335 reference: &Reference,
336 options: &RenderOptions<'_>,
337) -> Option<TitleRendering> {
338 let component = TemplateComponent::Title(template.clone());
339 let item_language = effective_component_language(reference, &component);
340 let ref_type = reference.ref_type();
341 crate::render::component::get_title_category_title_rendering(
342 &template.title,
343 Some(&ref_type),
344 item_language.as_deref(),
345 &options.config,
346 )
347}
348
349fn structured_title_delimiters<'a>(
350 rendering: Option<&'a TitleRendering>,
351 locale: &'a Locale,
352) -> (&'a str, &'a str) {
353 let primary_delimiter = rendering
354 .and_then(|rendering| rendering.primary_delimiter.as_deref())
355 .unwrap_or(locale.grammar_options.title_subtitle_delimiter.as_str());
356 let subtitle_delimiter = rendering
357 .and_then(|rendering| rendering.subtitle_delimiter.as_deref())
358 .unwrap_or(locale.grammar_options.subtitle_delimiter.as_str());
359 (primary_delimiter, subtitle_delimiter)
360}
361
362fn effective_title_quote_depth(
363 template: &TemplateTitle,
364 reference: &Reference,
365 options: &RenderOptions<'_>,
366) -> usize {
367 let component = TemplateComponent::Title(template.clone());
368 let item_language = effective_component_language(reference, &component);
369 let mut rendering = crate::render::component::get_title_category_rendering(
370 &template.title,
371 options.ref_type.as_deref(),
372 item_language.as_deref(),
373 &options.config,
374 )
375 .unwrap_or_default();
376 rendering.merge(&template.rendering);
377 usize::from(rendering.quote == Some(true))
378}
379
380fn apply_language_fallback(case: TextCase, language: Option<&str>) -> TextCase {
382 text_case::resolve_text_case(case, language)
383}
384
385pub(crate) fn effective_title_language(
386 title_type: &TitleType,
387 reference: &Reference,
388) -> Option<String> {
389 let component = TemplateComponent::Title(TemplateTitle {
390 title: title_type.clone(),
391 ..Default::default()
392 });
393 effective_component_language(reference, &component)
394}
395
396impl ComponentValues for TemplateTitle {
397 fn values<F: crate::render::format::OutputFormat<Output = String>>(
398 &self,
399 reference: &Reference,
400 hints: &ProcHints,
401 options: &RenderOptions<'_>,
402 ) -> Option<ProcValues<F::Output>> {
403 if self.disambiguate_only == Some(true)
404 && (hints.group_length <= 1 || hints.suppress_disambiguation_title)
405 {
406 return None;
407 }
408
409 let quote_depth = effective_title_quote_depth(self, reference, options);
410
411 if matches!(self.form, Some(TitleForm::Short))
412 && let Some(short_title) = parent_short_title(reference, &self.title)
413 && !short_title.is_empty()
414 {
415 let (value, pre_formatted) = if looks_like_djot_markup(&short_title) {
416 let (v, _) = render_djot_inline_with_transform_and_context(
417 &short_title,
418 &F::default(),
419 InlineRenderContext { quote_depth },
420 move |text| smarten_title_quotes_at_depth(text, quote_depth),
421 );
422 (v, true)
423 } else {
424 (
425 smarten_title_quotes_at_depth(&short_title, quote_depth),
426 false,
427 )
428 };
429 let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
430 let value = if self.strip_periods_all == Some(true) {
431 crate::values::strip_all_periods(&value)
432 } else {
433 value
434 };
435 return Some(ProcValues {
436 value,
437 prefix: None,
438 suffix: None,
439 url: None,
440 substituted_key: None,
441 pre_formatted,
442 });
443 }
444
445 let title = resolve_primary_title(reference, &self.title)?;
446 let effective_language = effective_title_language(&self.title, reference);
447 let effective_case =
448 resolve_effective_text_case(self, reference, options, effective_language.as_deref());
449 let effective_title_rendering = resolve_effective_title_rendering(self, reference, options);
450 let (value, has_explicit_link, pre_formatted) = render_title_variant::<F>(
451 &title,
452 self.form.as_ref(),
453 effective_case,
454 effective_title_rendering.as_ref(),
455 options,
456 quote_depth,
457 effective_language.as_deref(),
458 );
459
460 if value.is_empty() {
461 return None;
462 }
463
464 use citum_schema::options::LinkAnchor;
465 let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
466 let value = if self.strip_periods_all == Some(true) {
467 crate::values::strip_all_periods(&value)
468 } else {
469 value
470 };
471 let url = crate::values::resolve_effective_url(
472 self.links.as_ref(),
473 options.config.links.as_ref(),
474 reference,
475 LinkAnchor::Title,
476 );
477 Some(ProcValues {
478 value,
479 prefix: None,
480 suffix: None,
481 url: if has_explicit_link { None } else { url },
482 substituted_key: None,
483 pre_formatted,
484 })
485 }
486}
487
488fn resolve_primary_title(reference: &Reference, title_type: &TitleType) -> Option<Title> {
490 match title_type {
491 TitleType::Primary => reference.title(),
492 TitleType::ContainerTitle => reference.container_title(),
493 TitleType::ParentMonograph => match reference.extension() {
494 ClassExtension::Monograph(_)
495 | ClassExtension::CollectionComponent(_)
496 | ClassExtension::Event(_)
497 | ClassExtension::AudioVisual(_) => reference.container_title(),
498 _ => None,
499 },
500 TitleType::ParentSerial => match reference.extension() {
501 ClassExtension::SerialComponent(_)
502 | ClassExtension::LegalCase(_)
503 | ClassExtension::Treaty(_) => reference.container_title(),
504 _ => None,
505 },
506 TitleType::CollectionTitle => reference.collection_title(),
507 TitleType::Original => reference.original_title(),
508 _ => None,
509 }
510}
511
512fn render_title_variant<F: crate::render::format::OutputFormat<Output = String>>(
516 title: &Title,
517 form: Option<&TitleForm>,
518 effective_case: Option<TextCase>,
519 effective_title_rendering: Option<&TitleRendering>,
520 options: &RenderOptions<'_>,
521 quote_depth: usize,
522 language: Option<&str>,
523) -> (String, bool, bool) {
524 let fmt = F::default();
525 match title {
526 Title::Structured(st) => {
527 let short = matches!(form, Some(TitleForm::Short));
528 let delimiters = structured_title_delimiters(effective_title_rendering, options.locale);
529 let (value, has_link) = render_structured_title(
530 st,
531 &fmt,
532 effective_case,
533 short,
534 quote_depth,
535 language,
536 delimiters,
537 );
538 let pre_formatted = if short {
539 looks_like_djot_markup(&st.main)
540 } else {
541 looks_like_djot_markup(&title_text(title, form))
542 };
543 (value, has_link, pre_formatted)
544 }
545 Title::Multilingual(m) => {
546 let (mode, preferred_transliteration, preferred_script) =
547 resolve_multilingual_title_config(options);
548 let complex = citum_schema::reference::types::MultilingualString::Complex(m.clone());
549 let value = crate::values::resolve_multilingual_string(
550 &complex,
551 mode,
552 preferred_transliteration,
553 preferred_script,
554 options.locale.locale.as_str(),
555 );
556 let (rendered, has_link) =
557 render_part_with_case(&value, &fmt, effective_case, quote_depth, language);
558 let pre_formatted = looks_like_djot_markup(&value);
559 (rendered, has_link, pre_formatted)
560 }
561 _ => {
562 let value = title_text(title, form);
563 let (rendered, has_link) =
564 render_part_with_case(&value, &fmt, effective_case, quote_depth, language);
565 let pre_formatted = looks_like_djot_markup(&value);
566 (rendered, has_link, pre_formatted)
567 }
568 }
569}
570
571fn resolve_multilingual_title_config<'a>(
573 options: &'a RenderOptions<'a>,
574) -> (
575 Option<&'a citum_schema::options::MultilingualMode>,
576 Option<&'a [String]>,
577 Option<&'a String>,
578) {
579 let mode = options
580 .config
581 .multilingual
582 .as_ref()
583 .and_then(|ml| ml.title_mode.as_ref());
584 let preferred_transliteration = options
585 .config
586 .multilingual
587 .as_ref()
588 .and_then(|ml| ml.preferred_transliteration.as_deref());
589 let preferred_script = options
590 .config
591 .multilingual
592 .as_ref()
593 .and_then(|ml| ml.preferred_script.as_ref());
594 (mode, preferred_transliteration, preferred_script)
595}