Skip to main content

citum_engine/values/
title.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Rendering logic for title fields with smartening, form selection,
7//! and text-case transforms.
8
9use 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
24/// Converts straight apostrophes and double quotes to curly quotes when the
25/// surrounding context is unambiguous.
26///
27/// Ambiguous characters are preserved as straight quotes so titles containing
28/// measurements or other non-quotation uses do not get rewritten arbitrarily.
29fn 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
154/// Build a text-transform closure that applies case transform then smart quotes.
155///
156/// The closure is used as the Djot text-leaf transform, so `.nocase` spans
157/// bypass it automatically via the rich-text renderer.
158fn 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                    // Capitalize the first alphabetic character we encounter
177                    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
194/// Render a single title part through Djot with case transform + smart quotes.
195/// Returns (`rendered_value`, `has_explicit_link`).
196fn 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
230/// Render a structured title with per-part case transforms.
231///
232/// For `SentenceApa`, each subtitle gets sentence-case (first word capitalized).
233/// For `SentenceNlm`, subtitles are lowercased (no first-word capitalization).
234fn 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
281/// Resolve the effective text-case for this title component.
282fn resolve_effective_text_case(
283    template: &TemplateTitle,
284    reference: &Reference,
285    options: &RenderOptions<'_>,
286    language: Option<&str>,
287) -> Option<TextCase> {
288    // 1. Template-level override takes precedence
289    if let Some(tc) = template.rendering.text_case {
290        return Some(apply_language_fallback(tc, language));
291    }
292
293    // 2. Global title-category config
294    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
309/// Resolve the effective text-case for a title rendered outside its normal
310/// `title:` template component, e.g. when the CSL `substitute` chain falls
311/// through to `title` because the reference has no author/editor/translator.
312///
313/// There is no `TemplateTitle` to consult for a per-component override in
314/// that path, so this only resolves the style's category-level `titles:`
315/// configuration (mirroring the second half of [`resolve_effective_text_case`]).
316pub(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
380/// Apply language-aware fallback: non-English → as-is for English-specific transforms.
381fn 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
488/// Resolve which title field to render for the given `TitleType` and reference.
489fn 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
512/// Render a `Title` value into `(rendered_string, has_explicit_link, pre_formatted)`.
513///
514/// Handles structured, multilingual, and plain title variants with case transforms.
515fn 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
571/// Resolve multilingual title config (mode, transliteration, script) from render options.
572fn 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}