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 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                // Word-level sentence case, shared with the plain-text path so a
169                // title's casing does not depend on whether it happens to contain
170                // Djot markup: words carrying internal capitalization (acronyms,
171                // mixed-case names) are preserved verbatim rather than flattened,
172                // and the "capitalize the title's first word" state threads across
173                // text leaves split around markup (e.g. emphasis).
174                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
185/// Render a single title part through Djot with case transform + smart quotes.
186/// Returns (`rendered_value`, `has_explicit_link`).
187fn 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
221/// Render a structured title with per-part case transforms.
222///
223/// For `SentenceApa`, each subtitle gets sentence-case (first word capitalized).
224/// For `SentenceNlm`, subtitles are lowercased (no first-word capitalization).
225fn 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
272/// Resolve the effective text-case for this title component.
273fn resolve_effective_text_case(
274    template: &TemplateTitle,
275    reference: &Reference,
276    options: &RenderOptions<'_>,
277    language: Option<&str>,
278) -> Option<TextCase> {
279    // 1. Template-level override takes precedence
280    if let Some(tc) = template.rendering.text_case {
281        return Some(apply_language_fallback(tc, language));
282    }
283
284    // 2. Global title-category config
285    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
300/// Resolve the effective text-case for a title rendered outside its normal
301/// `title:` template component, e.g. when the CSL `substitute` chain falls
302/// through to `title` because the reference has no author/editor/translator.
303///
304/// There is no `TemplateTitle` to consult for a per-component override in
305/// that path, so this only resolves the style's category-level `titles:`
306/// configuration (mirroring the second half of [`resolve_effective_text_case`]).
307pub(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
371/// Apply language-aware fallback: non-English → as-is for English-specific transforms.
372fn 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
479/// Resolve which title field to render for the given `TitleType` and reference.
480fn 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
503/// Render a `Title` value into `(rendered_string, has_explicit_link, pre_formatted)`.
504///
505/// Handles structured, multilingual, and plain title variants with case transforms.
506fn 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
562/// Resolve multilingual title config (mode, transliteration, script) from render options.
563fn 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}