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