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 return Some(ProcValues {
402 value,
403 prefix: None,
404 suffix: None,
405 url: None,
406 substituted_key: None,
407 pre_formatted,
408 });
409 }
410
411 let title = resolve_primary_title(reference, &self.title)?;
412 let effective_case = resolve_effective_text_case(self, reference, options);
413 let effective_title_rendering = resolve_effective_title_rendering(self, reference, options);
414 let (value, has_explicit_link, pre_formatted) = render_title_variant::<F>(
415 &title,
416 self.form.as_ref(),
417 effective_case,
418 effective_title_rendering.as_ref(),
419 options,
420 quote_depth,
421 );
422
423 if value.is_empty() {
424 return None;
425 }
426
427 use citum_schema::options::LinkAnchor;
428 let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
429 let url = crate::values::resolve_effective_url(
430 self.links.as_ref(),
431 options.config.links.as_ref(),
432 reference,
433 LinkAnchor::Title,
434 );
435 Some(ProcValues {
436 value,
437 prefix: None,
438 suffix: None,
439 url: if has_explicit_link { None } else { url },
440 substituted_key: None,
441 pre_formatted,
442 })
443 }
444}
445
446fn resolve_primary_title(reference: &Reference, title_type: &TitleType) -> Option<Title> {
448 match title_type {
449 TitleType::Primary => reference.title(),
450 TitleType::ContainerTitle => reference.container_title(),
451 TitleType::ParentMonograph => match reference.extension() {
452 ClassExtension::Monograph(_)
453 | ClassExtension::CollectionComponent(_)
454 | ClassExtension::Event(_)
455 | ClassExtension::AudioVisual(_) => reference.container_title(),
456 _ => None,
457 },
458 TitleType::ParentSerial => match reference.extension() {
459 ClassExtension::SerialComponent(_)
460 | ClassExtension::LegalCase(_)
461 | ClassExtension::Treaty(_) => reference.container_title(),
462 _ => None,
463 },
464 TitleType::CollectionTitle => reference.collection_title(),
465 TitleType::Original => reference.original_title(),
466 _ => None,
467 }
468}
469
470fn render_title_variant<F: crate::render::format::OutputFormat<Output = String>>(
474 title: &Title,
475 form: Option<&TitleForm>,
476 effective_case: Option<TextCase>,
477 effective_title_rendering: Option<&TitleRendering>,
478 options: &RenderOptions<'_>,
479 quote_depth: usize,
480) -> (String, bool, bool) {
481 let fmt = F::default();
482 match title {
483 Title::Structured(st) => {
484 let short = matches!(form, Some(TitleForm::Short));
485 let (primary_delimiter, subtitle_delimiter) =
486 structured_title_delimiters(effective_title_rendering, options.locale);
487 let (value, has_link) = render_structured_title(
488 st,
489 &fmt,
490 effective_case,
491 short,
492 quote_depth,
493 primary_delimiter,
494 subtitle_delimiter,
495 );
496 let pre_formatted = if short {
497 looks_like_djot_markup(&st.main)
498 } else {
499 looks_like_djot_markup(&title_text(title, form))
500 };
501 (value, has_link, pre_formatted)
502 }
503 Title::Multilingual(m) => {
504 let (mode, preferred_transliteration, preferred_script) =
505 resolve_multilingual_title_config(options);
506 let complex = citum_schema::reference::types::MultilingualString::Complex(m.clone());
507 let value = crate::values::resolve_multilingual_string(
508 &complex,
509 mode,
510 preferred_transliteration,
511 preferred_script,
512 options.locale.locale.as_str(),
513 );
514 let (rendered, has_link) =
515 render_part_with_case(&value, &fmt, effective_case, quote_depth);
516 let pre_formatted = looks_like_djot_markup(&value);
517 (rendered, has_link, pre_formatted)
518 }
519 _ => {
520 let value = title_text(title, form);
521 let (rendered, has_link) =
522 render_part_with_case(&value, &fmt, effective_case, quote_depth);
523 let pre_formatted = looks_like_djot_markup(&value);
524 (rendered, has_link, pre_formatted)
525 }
526 }
527}
528
529fn resolve_multilingual_title_config<'a>(
531 options: &'a RenderOptions<'a>,
532) -> (
533 Option<&'a citum_schema::options::MultilingualMode>,
534 Option<&'a [String]>,
535 Option<&'a String>,
536) {
537 let mode = options
538 .config
539 .multilingual
540 .as_ref()
541 .and_then(|ml| ml.title_mode.as_ref());
542 let preferred_transliteration = options
543 .config
544 .multilingual
545 .as_ref()
546 .and_then(|ml| ml.preferred_transliteration.as_deref());
547 let preferred_script = options
548 .config
549 .multilingual
550 .as_ref()
551 .and_then(|ml| ml.preferred_script.as_ref());
552 (mode, preferred_transliteration, preferred_script)
553}