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