use crate::reference::Reference;
use crate::render::format::unicode_quote_marks;
use crate::render::rich_text::{
InlineRenderContext, render_djot_inline_with_transform_and_context,
};
use crate::values::text_case::{self, apply_text_case, capitalize_first_word};
use crate::values::{
ComponentValues, ProcHints, ProcValues, RenderOptions, effective_component_language,
};
use citum_schema::options::titles::TextCase;
use citum_schema::reference::ClassExtension;
use citum_schema::reference::types::{StructuredTitle, Subtitle, Title};
use citum_schema::template::{TemplateComponent, TemplateTitle, TitleForm, TitleType};
fn smarten_title_quotes_at_depth(input: &str, quote_depth: usize) -> String {
let mut out = String::with_capacity(input.len());
let mut it = input.char_indices().peekable();
let mut prev: Option<char> = None;
let mut open_single_quotes = 0usize;
let mut open_double_quotes = 0usize;
while let Some((_, ch)) = it.next() {
let next = it.peek().map(|(_, c)| *c);
let prev_is_alpha = prev.is_some_and(char::is_alphabetic);
let prev_is_digit = prev.is_some_and(|c| c.is_ascii_digit());
let prev_can_close_double_quote = prev.is_some_and(|c| {
c.is_alphanumeric() || matches!(c, '\'' | '"' | '\u{2019}' | '\u{201D}')
});
let next_is_alpha = next.is_some_and(char::is_alphabetic);
let next_is_digit = next.is_some_and(|c| c.is_ascii_digit());
let next_is_alnum = next.is_some_and(char::is_alphanumeric);
let prev_opens_quote =
prev.is_none_or(|c| c.is_whitespace() || "([{\u{2018}\u{201C}'\"".contains(c));
let next_closes_quote =
next.is_none_or(|c| c.is_whitespace() || ".,;:!?)]}\u{2019}\u{201D}'\"".contains(c));
match ch {
'\'' => {
let (open_quote, close_quote) = unicode_quote_marks(quote_depth + 1);
if (prev_is_alpha && next_is_alpha) || (prev_opens_quote && next_is_digit) {
out.push('\u{2019}');
} else if prev_opens_quote && next_is_alnum {
out.push_str(open_quote);
open_single_quotes += 1;
} else if (open_single_quotes > 0 || prev_is_alpha || prev_is_digit)
&& next_closes_quote
{
out.push_str(close_quote);
open_single_quotes = open_single_quotes.saturating_sub(1);
} else {
out.push('\'');
}
}
'"' => {
let (open_quote, close_quote) =
unicode_quote_marks(quote_depth + open_double_quotes);
if prev_opens_quote && next_is_alnum {
out.push_str(open_quote);
open_double_quotes += 1;
} else if open_double_quotes > 0 && prev_can_close_double_quote && next_closes_quote
{
let close_depth = quote_depth + open_double_quotes.saturating_sub(1);
let (_, close_quote) = unicode_quote_marks(close_depth);
out.push_str(close_quote);
open_double_quotes -= 1;
} else if prev_is_alpha && next_closes_quote {
out.push_str(close_quote);
} else {
out.push('"');
}
}
_ => out.push(ch),
}
prev = Some(ch);
}
out
}
fn title_text(title: &Title, form: Option<&TitleForm>) -> String {
match title {
Title::Shorthand(short, long) => {
if matches!(form, Some(TitleForm::Short)) {
short.clone()
} else {
long.clone()
}
}
Title::Single(s) => s.clone(),
_ => title.to_string(),
}
}
fn parent_short_title(reference: &Reference, title_type: &TitleType) -> Option<String> {
match title_type {
TitleType::ParentMonograph => {
if reference.ref_type() == "chapter" || reference.ref_type() == "paper-conference" {
reference.container_title().and_then(|t| match t {
Title::Shorthand(short, _) => Some(short),
Title::Single(s) => Some(s),
_ => None,
})
} else {
None
}
}
TitleType::ParentSerial => {
if reference.ref_type().contains("article") || reference.ref_type() == "broadcast" {
reference.container_title().and_then(|t| match t {
Title::Shorthand(short, _) => Some(short),
Title::Single(s) => Some(s),
_ => None,
})
} else {
None
}
}
_ => None,
}
}
fn looks_like_djot_markup(value: &str) -> bool {
value.contains('_')
|| value.contains('*')
|| value.contains("](")
|| value.contains("{.")
|| value.contains('`')
}
fn make_case_transform(case: TextCase, quote_depth: usize) -> impl FnMut(&str) -> String {
let mut seen_alpha = false;
move |text: &str| {
let cased = match case {
TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
let lowered = text.to_lowercase();
if seen_alpha {
lowered
} else {
let result = capitalize_first_word(&lowered);
if result.chars().any(|c: char| c.is_alphabetic()) {
seen_alpha = true;
}
result
}
}
_ => apply_text_case(text, case),
};
smarten_title_quotes_at_depth(&cased, quote_depth)
}
}
fn render_part_with_case<F: crate::render::format::OutputFormat<Output = String>>(
value: &str,
fmt: &F,
case: Option<TextCase>,
quote_depth: usize,
) -> (String, bool) {
let context = InlineRenderContext { quote_depth };
if looks_like_djot_markup(value) {
match case {
Some(tc) => render_djot_inline_with_transform_and_context(
value,
fmt,
context,
make_case_transform(tc, quote_depth),
),
None => {
render_djot_inline_with_transform_and_context(value, fmt, context, move |text| {
smarten_title_quotes_at_depth(text, quote_depth)
})
}
}
} else {
let result = match case {
Some(tc) => smarten_title_quotes_at_depth(&apply_text_case(value, tc), quote_depth),
None => smarten_title_quotes_at_depth(value, quote_depth),
};
(result, false)
}
}
fn render_structured_title<F: crate::render::format::OutputFormat<Output = String>>(
st: &StructuredTitle,
fmt: &F,
case: Option<TextCase>,
short: bool,
quote_depth: usize,
) -> (String, bool) {
let (main_rendered, has_link) = render_part_with_case(&st.main, fmt, case, quote_depth);
if short {
return (main_rendered, has_link);
}
let subtitle_case = case.map(|c| match c {
TextCase::SentenceNlm => TextCase::Lowercase,
other => other,
});
let mut parts = vec![main_rendered];
let mut has_link = has_link;
let subs: Vec<&str> = match &st.sub {
Subtitle::String(s) => vec![s.as_str()],
Subtitle::Vector(v) => v.iter().map(std::string::String::as_str).collect(),
};
for sub in subs {
let (sub_rendered, sub_link) = render_part_with_case(sub, fmt, subtitle_case, quote_depth);
has_link |= sub_link;
parts.push(sub_rendered);
}
(parts.join(": "), has_link)
}
fn resolve_effective_text_case(
template: &TemplateTitle,
reference: &Reference,
options: &RenderOptions<'_>,
) -> Option<TextCase> {
if let Some(tc) = template.rendering.text_case {
return Some(apply_language_fallback(tc, reference));
}
let ref_type = reference.ref_type();
let lang = reference.language();
let lang_str = lang.as_deref();
if let Some(rendering) = crate::render::component::get_title_category_rendering(
&template.title,
Some(&ref_type),
lang_str,
options.config,
) && let Some(tc) = rendering.text_case
{
return Some(apply_language_fallback(tc, reference));
}
None
}
fn effective_title_quote_depth(
template: &TemplateTitle,
reference: &Reference,
options: &RenderOptions<'_>,
) -> usize {
let component = TemplateComponent::Title(template.clone());
let item_language = effective_component_language(reference, &component);
let mut rendering = crate::render::component::get_title_category_rendering(
&template.title,
options.ref_type.as_deref(),
item_language.as_deref(),
options.config,
)
.unwrap_or_default();
rendering.merge(&template.rendering);
usize::from(rendering.quote == Some(true))
}
fn apply_language_fallback(case: TextCase, reference: &Reference) -> TextCase {
let lang = reference.language();
text_case::resolve_text_case(case, lang.as_deref())
}
impl ComponentValues for TemplateTitle {
fn values<F: crate::render::format::OutputFormat<Output = String>>(
&self,
reference: &Reference,
hints: &ProcHints,
options: &RenderOptions<'_>,
) -> Option<ProcValues<F::Output>> {
if self.disambiguate_only == Some(true) && hints.group_length <= 1 {
return None;
}
let quote_depth = effective_title_quote_depth(self, reference, options);
if matches!(self.form, Some(TitleForm::Short))
&& let Some(short_title) = parent_short_title(reference, &self.title)
&& !short_title.is_empty()
{
let (value, pre_formatted) = if looks_like_djot_markup(&short_title) {
let (v, _) = render_djot_inline_with_transform_and_context(
&short_title,
&F::default(),
InlineRenderContext { quote_depth },
move |text| smarten_title_quotes_at_depth(text, quote_depth),
);
(v, true)
} else {
(
smarten_title_quotes_at_depth(&short_title, quote_depth),
false,
)
};
let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
return Some(ProcValues {
value,
prefix: None,
suffix: None,
url: None,
substituted_key: None,
pre_formatted,
});
}
let title = resolve_primary_title(reference, &self.title)?;
let effective_case = resolve_effective_text_case(self, reference, options);
let (value, has_explicit_link, pre_formatted) = render_title_variant::<F>(
&title,
self.form.as_ref(),
effective_case,
options,
quote_depth,
);
if value.is_empty() {
return None;
}
use citum_schema::options::LinkAnchor;
let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
let url = crate::values::resolve_effective_url(
self.links.as_ref(),
options.config.links.as_ref(),
reference,
LinkAnchor::Title,
);
Some(ProcValues {
value,
prefix: None,
suffix: None,
url: if has_explicit_link { None } else { url },
substituted_key: None,
pre_formatted,
})
}
}
fn resolve_primary_title(reference: &Reference, title_type: &TitleType) -> Option<Title> {
match title_type {
TitleType::Primary => reference.title(),
TitleType::ParentMonograph => match reference.extension() {
ClassExtension::Monograph(_)
| ClassExtension::CollectionComponent(_)
| ClassExtension::Event(_)
| ClassExtension::AudioVisual(_) => reference.container_title(),
_ => None,
},
TitleType::ParentSerial => match reference.extension() {
ClassExtension::SerialComponent(_)
| ClassExtension::LegalCase(_)
| ClassExtension::Treaty(_) => reference.container_title(),
_ => None,
},
_ => None,
}
}
fn render_title_variant<F: crate::render::format::OutputFormat<Output = String>>(
title: &Title,
form: Option<&TitleForm>,
effective_case: Option<TextCase>,
options: &RenderOptions<'_>,
quote_depth: usize,
) -> (String, bool, bool) {
let fmt = F::default();
match title {
Title::Structured(st) => {
let short = matches!(form, Some(TitleForm::Short));
let (value, has_link) =
render_structured_title(st, &fmt, effective_case, short, quote_depth);
let pre_formatted = if short {
looks_like_djot_markup(&st.main)
} else {
looks_like_djot_markup(&title_text(title, form))
};
(value, has_link, pre_formatted)
}
Title::Multilingual(m) => {
let (mode, preferred_transliteration, preferred_script) =
resolve_multilingual_title_config(options);
let complex = citum_schema::reference::types::MultilingualString::Complex(m.clone());
let value = crate::values::resolve_multilingual_string(
&complex,
mode,
preferred_transliteration,
preferred_script,
options.locale.locale.as_str(),
);
let (rendered, has_link) =
render_part_with_case(&value, &fmt, effective_case, quote_depth);
let pre_formatted = looks_like_djot_markup(&value);
(rendered, has_link, pre_formatted)
}
_ => {
let value = title_text(title, form);
let (rendered, has_link) =
render_part_with_case(&value, &fmt, effective_case, quote_depth);
let pre_formatted = looks_like_djot_markup(&value);
(rendered, has_link, pre_formatted)
}
}
}
fn resolve_multilingual_title_config<'a>(
options: &'a RenderOptions<'a>,
) -> (
Option<&'a citum_schema::options::MultilingualMode>,
Option<&'a [String]>,
Option<&'a String>,
) {
let mode = options
.config
.multilingual
.as_ref()
.and_then(|ml| ml.title_mode.as_ref());
let preferred_transliteration = options
.config
.multilingual
.as_ref()
.and_then(|ml| ml.preferred_transliteration.as_deref());
let preferred_script = options
.config
.multilingual
.as_ref()
.and_then(|ml| ml.preferred_script.as_ref());
(mode, preferred_transliteration, preferred_script)
}