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, capitalize_first_word};
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(case: TextCase, quote_depth: usize) -> impl FnMut(&str) -> String {
159 let mut seen_alpha = false;
160 move |text: &str| {
161 let cased = match case {
162 TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
163 let lowered = text.to_lowercase();
164 if seen_alpha {
165 lowered
166 } else {
167 let result = capitalize_first_word(&lowered);
169 if result.chars().any(|c: char| c.is_alphabetic()) {
170 seen_alpha = true;
171 }
172 result
173 }
174 }
175 _ => apply_text_case(text, case),
176 };
177 smarten_title_quotes_at_depth(&cased, quote_depth)
178 }
179}
180
181fn render_part_with_case<F: crate::render::format::OutputFormat<Output = String>>(
184 value: &str,
185 fmt: &F,
186 case: Option<TextCase>,
187 quote_depth: usize,
188) -> (String, bool) {
189 let context = InlineRenderContext { quote_depth };
190 if looks_like_djot_markup(value) {
191 match case {
192 Some(tc) => render_djot_inline_with_transform_and_context(
193 value,
194 fmt,
195 context,
196 make_case_transform(tc, quote_depth),
197 ),
198 None => {
199 render_djot_inline_with_transform_and_context(value, fmt, context, move |text| {
200 smarten_title_quotes_at_depth(text, quote_depth)
201 })
202 }
203 }
204 } else {
205 let result = match case {
206 Some(tc) => smarten_title_quotes_at_depth(&apply_text_case(value, tc), quote_depth),
207 None => smarten_title_quotes_at_depth(value, quote_depth),
208 };
209 (result, false)
210 }
211}
212
213fn render_structured_title<F: crate::render::format::OutputFormat<Output = String>>(
218 st: &StructuredTitle,
219 fmt: &F,
220 case: Option<TextCase>,
221 short: bool,
222 quote_depth: usize,
223 primary_delimiter: &str,
224 subtitle_delimiter: &str,
225) -> (String, bool) {
226 let (main_rendered, has_link) = render_part_with_case(&st.main, fmt, case, quote_depth);
227 if short {
228 return (main_rendered, has_link);
229 }
230
231 let subtitle_case = case.map(|c| match c {
232 TextCase::SentenceNlm => TextCase::Lowercase,
233 other => other,
234 });
235
236 let mut has_link = has_link;
237
238 let subs: Vec<&str> = match &st.sub {
239 Subtitle::String(s) => vec![s.as_str()],
240 Subtitle::Vector(v) => v.iter().map(std::string::String::as_str).collect(),
241 };
242 if subs.is_empty() {
243 return (main_rendered, has_link);
244 }
245
246 let mut rendered_subtitles = Vec::with_capacity(subs.len());
247 for sub in subs {
248 let (sub_rendered, sub_link) = render_part_with_case(sub, fmt, subtitle_case, quote_depth);
249 has_link |= sub_link;
250 rendered_subtitles.push(sub_rendered);
251 }
252 let subtitle_group = rendered_subtitles.join(subtitle_delimiter);
253
254 let mut rendered = main_rendered;
255 rendered.push_str(primary_delimiter);
256 rendered.push_str(&subtitle_group);
257
258 (rendered, has_link)
259}
260
261fn resolve_effective_text_case(
263 template: &TemplateTitle,
264 reference: &Reference,
265 options: &RenderOptions<'_>,
266) -> Option<TextCase> {
267 if let Some(tc) = template.rendering.text_case {
269 return Some(apply_language_fallback(tc, reference));
270 }
271
272 let ref_type = reference.ref_type();
274 let lang = reference.language();
275 let lang_str = lang.as_deref();
276
277 if let Some(rendering) = crate::render::component::get_title_category_rendering(
278 &template.title,
279 Some(&ref_type),
280 lang_str,
281 &options.config,
282 ) && let Some(tc) = rendering.text_case
283 {
284 return Some(apply_language_fallback(tc, reference));
285 }
286
287 None
288}
289
290pub(crate) fn resolve_substitute_text_case(
298 title_type: &TitleType,
299 reference: &Reference,
300 options: &RenderOptions<'_>,
301) -> Option<TextCase> {
302 let ref_type = reference.ref_type();
303 let lang = reference.language();
304 let rendering = crate::render::component::get_title_category_rendering(
305 title_type,
306 Some(&ref_type),
307 lang.as_deref(),
308 &options.config,
309 )?;
310 let tc = rendering.text_case?;
311 Some(apply_language_fallback(tc, reference))
312}
313
314fn resolve_effective_title_rendering(
315 template: &TemplateTitle,
316 reference: &Reference,
317 options: &RenderOptions<'_>,
318) -> Option<TitleRendering> {
319 let component = TemplateComponent::Title(template.clone());
320 let item_language = effective_component_language(reference, &component);
321 let ref_type = reference.ref_type();
322 crate::render::component::get_title_category_title_rendering(
323 &template.title,
324 Some(&ref_type),
325 item_language.as_deref(),
326 &options.config,
327 )
328}
329
330fn structured_title_delimiters<'a>(
331 rendering: Option<&'a TitleRendering>,
332 locale: &'a Locale,
333) -> (&'a str, &'a str) {
334 let primary_delimiter = rendering
335 .and_then(|rendering| rendering.primary_delimiter.as_deref())
336 .unwrap_or(locale.grammar_options.title_subtitle_delimiter.as_str());
337 let subtitle_delimiter = rendering
338 .and_then(|rendering| rendering.subtitle_delimiter.as_deref())
339 .unwrap_or(locale.grammar_options.subtitle_delimiter.as_str());
340 (primary_delimiter, subtitle_delimiter)
341}
342
343fn effective_title_quote_depth(
344 template: &TemplateTitle,
345 reference: &Reference,
346 options: &RenderOptions<'_>,
347) -> usize {
348 let component = TemplateComponent::Title(template.clone());
349 let item_language = effective_component_language(reference, &component);
350 let mut rendering = crate::render::component::get_title_category_rendering(
351 &template.title,
352 options.ref_type.as_deref(),
353 item_language.as_deref(),
354 &options.config,
355 )
356 .unwrap_or_default();
357 rendering.merge(&template.rendering);
358 usize::from(rendering.quote == Some(true))
359}
360
361fn apply_language_fallback(case: TextCase, reference: &Reference) -> TextCase {
363 let lang = reference.language();
364 text_case::resolve_text_case(case, lang.as_deref())
365}
366
367impl ComponentValues for TemplateTitle {
368 fn values<F: crate::render::format::OutputFormat<Output = String>>(
369 &self,
370 reference: &Reference,
371 hints: &ProcHints,
372 options: &RenderOptions<'_>,
373 ) -> Option<ProcValues<F::Output>> {
374 if self.disambiguate_only == Some(true)
375 && (hints.group_length <= 1 || hints.suppress_disambiguation_title)
376 {
377 return None;
378 }
379
380 let quote_depth = effective_title_quote_depth(self, reference, options);
381
382 if matches!(self.form, Some(TitleForm::Short))
383 && let Some(short_title) = parent_short_title(reference, &self.title)
384 && !short_title.is_empty()
385 {
386 let (value, pre_formatted) = if looks_like_djot_markup(&short_title) {
387 let (v, _) = render_djot_inline_with_transform_and_context(
388 &short_title,
389 &F::default(),
390 InlineRenderContext { quote_depth },
391 move |text| smarten_title_quotes_at_depth(text, quote_depth),
392 );
393 (v, true)
394 } else {
395 (
396 smarten_title_quotes_at_depth(&short_title, quote_depth),
397 false,
398 )
399 };
400 let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
401 let value = if self.strip_periods_all == Some(true) {
402 crate::values::strip_all_periods(&value)
403 } else {
404 value
405 };
406 return Some(ProcValues {
407 value,
408 prefix: None,
409 suffix: None,
410 url: None,
411 substituted_key: None,
412 pre_formatted,
413 });
414 }
415
416 let title = resolve_primary_title(reference, &self.title)?;
417 let effective_case = resolve_effective_text_case(self, reference, options);
418 let effective_title_rendering = resolve_effective_title_rendering(self, reference, options);
419 let (value, has_explicit_link, pre_formatted) = render_title_variant::<F>(
420 &title,
421 self.form.as_ref(),
422 effective_case,
423 effective_title_rendering.as_ref(),
424 options,
425 quote_depth,
426 );
427
428 if value.is_empty() {
429 return None;
430 }
431
432 use citum_schema::options::LinkAnchor;
433 let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
434 let value = if self.strip_periods_all == Some(true) {
435 crate::values::strip_all_periods(&value)
436 } else {
437 value
438 };
439 let url = crate::values::resolve_effective_url(
440 self.links.as_ref(),
441 options.config.links.as_ref(),
442 reference,
443 LinkAnchor::Title,
444 );
445 Some(ProcValues {
446 value,
447 prefix: None,
448 suffix: None,
449 url: if has_explicit_link { None } else { url },
450 substituted_key: None,
451 pre_formatted,
452 })
453 }
454}
455
456fn resolve_primary_title(reference: &Reference, title_type: &TitleType) -> Option<Title> {
458 match title_type {
459 TitleType::Primary => reference.title(),
460 TitleType::ContainerTitle => reference.container_title(),
461 TitleType::ParentMonograph => match reference.extension() {
462 ClassExtension::Monograph(_)
463 | ClassExtension::CollectionComponent(_)
464 | ClassExtension::Event(_)
465 | ClassExtension::AudioVisual(_) => reference.container_title(),
466 _ => None,
467 },
468 TitleType::ParentSerial => match reference.extension() {
469 ClassExtension::SerialComponent(_)
470 | ClassExtension::LegalCase(_)
471 | ClassExtension::Treaty(_) => reference.container_title(),
472 _ => None,
473 },
474 TitleType::CollectionTitle => reference.collection_title(),
475 TitleType::Original => reference.original_title(),
476 _ => None,
477 }
478}
479
480fn render_title_variant<F: crate::render::format::OutputFormat<Output = String>>(
484 title: &Title,
485 form: Option<&TitleForm>,
486 effective_case: Option<TextCase>,
487 effective_title_rendering: Option<&TitleRendering>,
488 options: &RenderOptions<'_>,
489 quote_depth: usize,
490) -> (String, bool, bool) {
491 let fmt = F::default();
492 match title {
493 Title::Structured(st) => {
494 let short = matches!(form, Some(TitleForm::Short));
495 let (primary_delimiter, subtitle_delimiter) =
496 structured_title_delimiters(effective_title_rendering, options.locale);
497 let (value, has_link) = render_structured_title(
498 st,
499 &fmt,
500 effective_case,
501 short,
502 quote_depth,
503 primary_delimiter,
504 subtitle_delimiter,
505 );
506 let pre_formatted = if short {
507 looks_like_djot_markup(&st.main)
508 } else {
509 looks_like_djot_markup(&title_text(title, form))
510 };
511 (value, has_link, pre_formatted)
512 }
513 Title::Multilingual(m) => {
514 let (mode, preferred_transliteration, preferred_script) =
515 resolve_multilingual_title_config(options);
516 let complex = citum_schema::reference::types::MultilingualString::Complex(m.clone());
517 let value = crate::values::resolve_multilingual_string(
518 &complex,
519 mode,
520 preferred_transliteration,
521 preferred_script,
522 options.locale.locale.as_str(),
523 );
524 let (rendered, has_link) =
525 render_part_with_case(&value, &fmt, effective_case, quote_depth);
526 let pre_formatted = looks_like_djot_markup(&value);
527 (rendered, has_link, pre_formatted)
528 }
529 _ => {
530 let value = title_text(title, form);
531 let (rendered, has_link) =
532 render_part_with_case(&value, &fmt, effective_case, quote_depth);
533 let pre_formatted = looks_like_djot_markup(&value);
534 (rendered, has_link, pre_formatted)
535 }
536 }
537}
538
539fn resolve_multilingual_title_config<'a>(
541 options: &'a RenderOptions<'a>,
542) -> (
543 Option<&'a citum_schema::options::MultilingualMode>,
544 Option<&'a [String]>,
545 Option<&'a String>,
546) {
547 let mode = options
548 .config
549 .multilingual
550 .as_ref()
551 .and_then(|ml| ml.title_mode.as_ref());
552 let preferred_transliteration = options
553 .config
554 .multilingual
555 .as_ref()
556 .and_then(|ml| ml.preferred_transliteration.as_deref());
557 let preferred_script = options
558 .config
559 .multilingual
560 .as_ref()
561 .and_then(|ml| ml.preferred_script.as_ref());
562 (mode, preferred_transliteration, preferred_script)
563}