Skip to main content

citum_engine/values/contributor/
mod.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 contributors (authors, editors, translators).
7//!
8//! This module handles contributor rendering with support for name ordering,
9//! role labels, et-al formatting, and multilingual name resolution.
10
11pub(crate) mod labels;
12pub(crate) mod merged;
13pub mod names;
14pub(crate) mod substitute;
15
16use crate::reference::Reference;
17use crate::values::{ComponentValues, ProcHints, ProcValues, RenderContext, RenderOptions};
18use citum_schema::options::SubsequentNameForm;
19use citum_schema::template::{ContributorForm, ContributorRole, TemplateContributor};
20
21#[cfg(test)]
22pub(crate) use names::{NameFormatContext, format_single_name};
23pub use names::{NamesOverrides, format_contributors_short, format_names};
24
25/// Resolve a contributor payload for a template contributor role.
26///
27/// This preserves the legacy `editor()` / `translator()` accessors for
28/// reference shapes that still store those roles outside the generic
29/// contributor-entry list.
30pub(super) fn contributor_for_role(
31    reference: &Reference,
32    role: &ContributorRole,
33) -> Option<citum_schema::reference::Contributor> {
34    match role {
35        ContributorRole::Author => reference.author(),
36        ContributorRole::Editor => reference.editor(),
37        ContributorRole::Translator => reference.translator(),
38        _ => contributor_role_to_reference_role(role).and_then(|role| reference.contributor(role)),
39    }
40}
41
42/// Map a template contributor role to the corresponding reference contributor role.
43pub(crate) fn contributor_role_to_reference_role(
44    role: &ContributorRole,
45) -> Option<citum_schema::reference::ContributorRole> {
46    match role {
47        ContributorRole::Author => Some(citum_schema::reference::ContributorRole::Author),
48        ContributorRole::Editor => Some(citum_schema::reference::ContributorRole::Editor),
49        ContributorRole::Translator => Some(citum_schema::reference::ContributorRole::Translator),
50        ContributorRole::Annotator => Some(citum_schema::reference::ContributorRole::Annotator),
51        ContributorRole::Commentator => Some(citum_schema::reference::ContributorRole::Commentator),
52        ContributorRole::ForewordAuthor => {
53            Some(citum_schema::reference::ContributorRole::ForewordAuthor)
54        }
55        ContributorRole::IntroductionAuthor => {
56            Some(citum_schema::reference::ContributorRole::IntroductionAuthor)
57        }
58        ContributorRole::AfterwordAuthor => {
59            Some(citum_schema::reference::ContributorRole::AfterwordAuthor)
60        }
61        ContributorRole::Recipient => Some(citum_schema::reference::ContributorRole::Recipient),
62        ContributorRole::Chair => Some(citum_schema::reference::ContributorRole::Unknown(
63            "chair".to_string(),
64        )),
65        ContributorRole::Interviewer => Some(citum_schema::reference::ContributorRole::Interviewer),
66        ContributorRole::Guest => Some(citum_schema::reference::ContributorRole::Guest),
67        ContributorRole::Performer => Some(citum_schema::reference::ContributorRole::Performer),
68        ContributorRole::Director => Some(citum_schema::reference::ContributorRole::Director),
69        ContributorRole::Composer => Some(citum_schema::reference::ContributorRole::Composer),
70        ContributorRole::Writer => Some(citum_schema::reference::ContributorRole::Writer),
71        ContributorRole::Producer => Some(citum_schema::reference::ContributorRole::Producer),
72        ContributorRole::Illustrator => Some(citum_schema::reference::ContributorRole::Illustrator),
73        ContributorRole::Inventor => Some(citum_schema::reference::ContributorRole::Unknown(
74            "inventor".to_string(),
75        )),
76        ContributorRole::Counsel => Some(citum_schema::reference::ContributorRole::Unknown(
77            "counsel".to_string(),
78        )),
79        ContributorRole::CollectionEditor => Some(
80            citum_schema::reference::ContributorRole::Unknown("collection-editor".to_string()),
81        ),
82        ContributorRole::ContainerAuthor => Some(
83            citum_schema::reference::ContributorRole::Unknown("container-author".to_string()),
84        ),
85        ContributorRole::EditorialDirector => Some(
86            citum_schema::reference::ContributorRole::Unknown("editorial-director".to_string()),
87        ),
88        ContributorRole::TextualEditor => Some(citum_schema::reference::ContributorRole::Unknown(
89            "textual-editor".to_string(),
90        )),
91        ContributorRole::OriginalAuthor => Some(citum_schema::reference::ContributorRole::Unknown(
92            "original-author".to_string(),
93        )),
94        ContributorRole::ReviewedAuthor => Some(citum_schema::reference::ContributorRole::Unknown(
95            "reviewed-author".to_string(),
96        )),
97        ContributorRole::Unknown(role) => Some(match role.as_str() {
98            "compiler" => citum_schema::reference::ContributorRole::Compiler,
99            "performer" => citum_schema::reference::ContributorRole::Performer,
100            "narrator" => citum_schema::reference::ContributorRole::Narrator,
101            "host" => citum_schema::reference::ContributorRole::Host,
102            "producer" | "executive-producer" => citum_schema::reference::ContributorRole::Producer,
103            "writer" => citum_schema::reference::ContributorRole::Writer,
104            _ => citum_schema::reference::ContributorRole::Unknown(role.clone()),
105        }),
106        ContributorRole::Interviewee | ContributorRole::Publisher => None,
107        _ => None,
108    }
109}
110
111/// Checks if a contributor role label should be omitted for a given reference.
112///
113/// Returns true if the role appears in the configuration's role.omit list.
114pub(super) fn is_role_label_omitted(options: &RenderOptions<'_>, role: &ContributorRole) -> bool {
115    options
116        .config
117        .contributors
118        .as_ref()
119        .and_then(|c| c.role.as_ref())
120        .is_some_and(|role_opts| {
121            role_opts
122                .omit
123                .iter()
124                .any(|entry| entry.eq_ignore_ascii_case(role.as_str()))
125        })
126}
127
128/// Format a role term with period stripping if configured.
129///
130/// Handles the repeated pattern of checking `should_strip_periods` and formatting
131/// the result with a given prefix and suffix pattern.
132pub(super) fn format_role_term<F: crate::render::format::OutputFormat<Output = String>>(
133    term: &str,
134    fmt: &F,
135    effective_rendering: &citum_schema::template::Rendering,
136    options: &RenderOptions<'_>,
137    prefix: &str,
138    suffix: &str,
139) -> String {
140    let term_str = normalized_role_term(term, effective_rendering, options);
141    fmt.text(&format!("{prefix}{term_str}{suffix}"))
142}
143
144fn normalized_role_term(
145    term: &str,
146    effective_rendering: &citum_schema::template::Rendering,
147    options: &RenderOptions<'_>,
148) -> String {
149    let term_str = if crate::values::should_strip_periods(effective_rendering, options) {
150        crate::values::strip_trailing_periods(term)
151    } else {
152        term.to_string()
153    };
154    // Locale role terms are stored lowercase (e.g. "translated by") since
155    // they usually sit mid-sentence. A `form: verb` component is marked
156    // `pre_formatted`, which skips the generic title/value text-case pass,
157    // so a style that positions the verb label as its own clause (e.g.
158    // after a `". "` prefix) must opt in here explicitly.
159    match effective_rendering.text_case {
160        Some(citum_schema::options::titles::TextCase::CapitalizeFirst) => {
161            crate::values::text_case::apply_text_case_with_language(
162                &term_str,
163                citum_schema::options::titles::TextCase::CapitalizeFirst,
164                Some(options.locale.locale.as_str()),
165            )
166        }
167        _ => term_str,
168    }
169}
170
171/// Format a role term with optional structural wrapping.
172///
173/// The unwrapped path delegates to [`format_role_term`] to preserve its exact
174/// escaping and affix behavior. Wrapped labels apply inner affixes, wrapping
175/// punctuation, and finally the outer label affixes.
176pub(super) fn format_wrapped_role_term<F: crate::render::format::OutputFormat<Output = String>>(
177    term: &str,
178    fmt: &F,
179    effective_rendering: &citum_schema::template::Rendering,
180    options: &RenderOptions<'_>,
181    affixes: (&str, &str),
182    wrap: Option<&citum_schema::template::WrapConfig>,
183    item_language: Option<&str>,
184) -> String {
185    let (prefix, suffix) = affixes;
186    let Some(wrap) = wrap else {
187        return format_role_term(term, fmt, effective_rendering, options, prefix, suffix);
188    };
189    let term = normalized_role_term(term, effective_rendering, options);
190    let content = fmt.text(&term);
191    let content = fmt.inner_affix(
192        wrap.inner_prefix.as_deref().unwrap_or_default(),
193        content,
194        wrap.inner_suffix.as_deref().unwrap_or_default(),
195    );
196    let marks = crate::render::format::QuoteMarks::from(&options.locale.grammar_options);
197    let (script, realization) = crate::values::punctuation_realization_context(
198        item_language,
199        options.config.multilingual.as_ref(),
200        options.locale.punctuation_realization.as_ref(),
201    );
202    let content = fmt.wrap_punctuation(
203        &wrap.punctuation,
204        content,
205        &marks,
206        script,
207        realization.as_deref(),
208    );
209    format!("{}{content}{}", fmt.text(prefix), fmt.text(suffix))
210}
211
212/// Apply the integral-citation subsequent-form rewrite to a contributor on a
213/// `Subsequent` mention. No-op unless the style configures `integral-name-memory`.
214fn apply_integral_subsequent_form(
215    component: &mut TemplateContributor,
216    hints: &ProcHints,
217    options: &RenderOptions<'_>,
218) {
219    if options.context != RenderContext::Citation {
220        return;
221    }
222    if !matches!(options.mode, citum_schema::citation::CitationMode::Integral) {
223        return;
224    }
225    if !component.contributor.contains(&ContributorRole::Author) {
226        return;
227    }
228    if !matches!(
229        hints.integral_name_state,
230        Some(citum_schema::citation::IntegralNameState::Subsequent)
231    ) {
232        return;
233    }
234    let Some(memory) = options.config.integral_name_memory.as_ref() else {
235        return;
236    };
237    component.form = match memory.resolve().subsequent_form {
238        SubsequentNameForm::Short => ContributorForm::Short,
239        SubsequentNameForm::FamilyOnly => ContributorForm::FamilyOnly,
240    };
241}
242
243/// Build name overrides and format all names for a contributor component.
244fn format_contributor_names(
245    component: &TemplateContributor,
246    role: &ContributorRole,
247    names_vec: &[crate::reference::FlatName],
248    reference: &Reference,
249    effective_rendering: &citum_schema::template::Rendering,
250    options: &RenderOptions<'_>,
251    hints: &ProcHints,
252) -> String {
253    let effective_name_order = component.name_order.as_ref().or_else(|| {
254        options
255            .config
256            .contributors
257            .as_ref()?
258            .effective_role_name_order(role)
259    });
260    let effective_shorten = component
261        .shorten
262        .as_ref()
263        .or_else(|| options.config.contributors.as_ref()?.shorten.as_ref());
264
265    // Priority chain for name_form:
266    // 1. component.name_form (TemplateContributor-level override - highest priority)
267    // 2. effective_rendering.name_form (from overrides, second priority)
268    // 3. config (options-level fallback)
269    let effective_name_form = component.name_form.or(effective_rendering.name_form);
270
271    let name_overrides = names::NamesOverrides {
272        name_order: effective_name_order,
273        sort_separator: component.sort_separator.as_ref(),
274        delimiter: component.delimiter.as_ref(),
275        shorten: effective_shorten,
276        and: component.and.as_ref(),
277        initialize_with: effective_rendering.initialize_with.as_ref(),
278        name_form: effective_name_form,
279        strip_periods: effective_rendering.strip_periods,
280        item_language: crate::values::effective_item_language(reference),
281    };
282    names::format_names(names_vec, &component.form, options, &name_overrides, hints)
283}
284
285/// Render `component.fallback` when the author slot has no contributor and
286/// the entire `substitute.template` chain (editor, title, translator, ...)
287/// is exhausted — e.g. a `message: term.anonymous` component for GB/T
288/// 7714's `佚名` placeholder. Tries each fallback component in order,
289/// returning the first that renders; `None` if `fallback` is unset or every
290/// entry is itself empty (mirrors `TemplateDate`'s fallback semantics).
291fn resolve_author_fallback<F: crate::render::format::OutputFormat<Output = String>>(
292    component: &TemplateContributor,
293    reference: &Reference,
294    hints: &ProcHints,
295    options: &RenderOptions<'_>,
296    fmt: &F,
297) -> Option<ProcValues<F::Output>> {
298    let fallbacks = component.fallback.as_ref()?;
299    for fallback in fallbacks {
300        if let Some(values) = fallback.values::<F>(reference, hints, options) {
301            let output = crate::values::date::apply_fallback_component_rendering(
302                fmt,
303                &values.value,
304                values.pre_formatted,
305                fallback.rendering(),
306                reference,
307                options,
308            );
309            return Some(ProcValues {
310                value: output,
311                prefix: None,
312                suffix: None,
313                url: values.url,
314                substituted_key: values.substituted_key,
315                pre_formatted: true,
316            });
317        }
318    }
319    None
320}
321
322impl ComponentValues for TemplateContributor {
323    #[allow(
324        clippy::too_many_lines,
325        reason = "large match statement for contributor role dispatch"
326    )]
327    fn values<F: crate::render::format::OutputFormat<Output = String>>(
328        &self,
329        reference: &Reference,
330        hints: &ProcHints,
331        options: &RenderOptions<'_>,
332    ) -> Option<ProcValues<F::Output>> {
333        let fmt = F::default();
334
335        let mut component = self.clone();
336        let effective_rendering = self.rendering.clone();
337
338        // Apply integral-citation subsequent-form (FullThenShort rule)
339        apply_integral_subsequent_form(&mut component, hints, options);
340
341        // Respect explicit suppression before either contributor rendering path.
342        if effective_rendering.suppress == Some(true) {
343            return None;
344        }
345
346        let Some(role) = component.contributor.as_single().cloned() else {
347            return merged::values::<F>(
348                &component,
349                reference,
350                hints,
351                options,
352                &effective_rendering,
353                &fmt,
354            );
355        };
356
357        if merged::is_role_suppressed(reference, &role, &options.config) {
358            return None;
359        }
360
361        // Resolve substitute config once for all substitute/suppression checks below.
362        let substitute = citum_schema::options::SubstituteConfig::resolve_or_default(
363            options.config.substitute.as_ref(),
364        );
365
366        // The author slot is resolved as one effective-primary value so
367        // rendering, sorting, and disambiguation share type overrides and
368        // semantic-author precedence.
369        if matches!(role, ContributorRole::Author) {
370            if options.suppress_author {
371                return None;
372            }
373            if let Some(values) = substitute::resolve_author_substitute::<F>(
374                &component,
375                hints,
376                options,
377                reference,
378                &effective_rendering,
379                &fmt,
380                substitute.as_ref(),
381            ) {
382                return Some(values);
383            }
384            return resolve_author_fallback::<F>(&component, reference, hints, options, &fmt);
385        }
386
387        let contributor = contributor_for_role(reference, &role);
388
389        // Check if this secondary role is suppressed by role-substitute
390        // configuration. Primary-slot overrides deliberately promote roles
391        // that may also appear in these secondary fallback chains.
392        if substitute::is_role_suppressed_by_substitute(&role, substitute.as_ref(), reference) {
393            return None;
394        }
395
396        // Resolve multilingual names if configured
397        let names_vec = if let Some(contrib) = contributor {
398            substitute::resolve_multilingual_for_contrib(&contrib, options)
399        } else {
400            Vec::new()
401        };
402
403        // Handle role-substitute if this role is empty.
404        if names_vec.is_empty() {
405            return substitute::resolve_role_substitute::<F>(
406                &role,
407                &component,
408                hints,
409                options,
410                reference,
411                &effective_rendering,
412                &fmt,
413                substitute.as_ref(),
414            );
415        }
416
417        let formatted = format_contributor_names(
418            &component,
419            &role,
420            &names_vec,
421            reference,
422            &effective_rendering,
423            options,
424            hints,
425        );
426
427        let role_omitted = is_role_label_omitted(options, &role);
428        let (role_prefix, role_suffix) =
429            labels::resolve_role_labels::<F>(labels::RoleLabelContext {
430                component: &component,
431                role: &role,
432                reference,
433                names_count: names_vec.len(),
434                effective_rendering: &effective_rendering,
435                options,
436                fmt: &fmt,
437                role_omitted,
438            });
439
440        let is_pre_formatted = role_prefix.is_some() || role_suffix.is_some();
441        let formatted = crate::values::apply_abbreviation(formatted, options.abbreviation_map);
442        let final_value = if is_pre_formatted {
443            fmt.text(&formatted)
444        } else {
445            formatted
446        };
447
448        Some(ProcValues {
449            value: final_value,
450            prefix: role_prefix,
451            suffix: role_suffix,
452            url: crate::values::resolve_effective_url(
453                component.links.as_ref(),
454                options.config.links.as_ref(),
455                reference,
456                citum_schema::options::LinkAnchor::Component,
457            ),
458            substituted_key: None,
459            pre_formatted: is_pre_formatted,
460        })
461    }
462}