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, 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
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(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                    // Capitalize the first alphabetic character we encounter
168                    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
181/// Render a single title part through Djot with case transform + smart quotes.
182/// Returns (`rendered_value`, `has_explicit_link`).
183fn 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
213/// Render a structured title with per-part case transforms.
214///
215/// For `SentenceApa`, each subtitle gets sentence-case (first word capitalized).
216/// For `SentenceNlm`, subtitles are lowercased (no first-word capitalization).
217fn 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
261/// Resolve the effective text-case for this title component.
262fn resolve_effective_text_case(
263    template: &TemplateTitle,
264    reference: &Reference,
265    options: &RenderOptions<'_>,
266) -> Option<TextCase> {
267    // 1. Template-level override takes precedence
268    if let Some(tc) = template.rendering.text_case {
269        return Some(apply_language_fallback(tc, reference));
270    }
271
272    // 2. Global title-category config
273    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
290/// Resolve the effective text-case for a title rendered outside its normal
291/// `title:` template component, e.g. when the CSL `substitute` chain falls
292/// through to `title` because the reference has no author/editor/translator.
293///
294/// There is no `TemplateTitle` to consult for a per-component override in
295/// that path, so this only resolves the style's category-level `titles:`
296/// configuration (mirroring the second half of [`resolve_effective_text_case`]).
297pub(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
361/// Apply language-aware fallback: non-English → as-is for English-specific transforms.
362fn 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
446/// Resolve which title field to render for the given `TitleType` and reference.
447fn 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
470/// Render a `Title` value into `(rendered_string, has_explicit_link, pre_formatted)`.
471///
472/// Handles structured, multilingual, and plain title variants with case transforms.
473fn 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
529/// Resolve multilingual title config (mode, transliteration, script) from render options.
530fn 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}