1use super::format::QuoteMarks;
7use citum_schema::options::{Config, bibliography::BibliographyConfig, titles::TitleRendering};
8use citum_schema::template::{Rendering, TemplateComponent, TitleType};
9use std::sync::Arc;
10
11#[derive(Debug, Clone, Default, PartialEq)]
13pub struct ProcTemplateComponent {
14 pub template_component: TemplateComponent,
16 pub template_index: Option<usize>,
18 pub value: String,
20 pub prefix: Option<String>,
22 pub suffix: Option<String>,
24 pub url: Option<String>,
26 pub ref_type: Option<String>,
28 pub config: Option<Arc<Config>>,
30 pub bibliography_config: Option<Arc<BibliographyConfig>>,
32 pub item_language: Option<String>,
34 pub quote_marks: QuoteMarks,
40 pub sentence_initial: bool,
42 pub pre_formatted: bool,
44 pub label_only: bool,
52}
53
54pub type ProcTemplate = Vec<ProcTemplateComponent>;
56
57#[derive(Debug, Clone, Default, PartialEq)]
59pub struct ProcEntry {
60 pub id: String,
62 pub template: ProcTemplate,
64 pub metadata: super::format::ProcEntryMetadata,
66}
67
68use super::format::{OutputFormat, SemanticAttribute};
69use super::plain::PlainText;
70use std::borrow::Cow;
71
72fn resolve_semantic_class(component: &ProcTemplateComponent) -> Option<String> {
74 use citum_schema::template::{DateVariable, SimpleVariable};
75 match &component.template_component {
76 TemplateComponent::Title(t) => match t.title {
77 TitleType::Primary => Some("citum-title".to_string()),
78 TitleType::ContainerTitle
79 | TitleType::ParentMonograph
80 | TitleType::ParentSerial
81 | TitleType::CollectionTitle => Some("citum-container-title".to_string()),
82 _ => Some("citum-title".to_string()),
83 },
84 TemplateComponent::Contributor(c) => Some(format!(
85 "citum-{}",
86 c.contributor
87 .as_slice()
88 .iter()
89 .map(citum_schema::template::ContributorRole::as_str)
90 .collect::<Vec<_>>()
91 .join("-")
92 )),
93 TemplateComponent::Date(d) => Some(format!(
94 "citum-{}",
95 match d.date {
96 DateVariable::Issued => "issued",
97 DateVariable::Accessed => "accessed",
98 DateVariable::OriginalPublished => "original-published",
99 DateVariable::Submitted => "submitted",
100 DateVariable::EventDate => "event-date",
101 DateVariable::Copyright => "copyright",
102 DateVariable::Printing => "printing",
103 }
104 )),
105 TemplateComponent::Number(n) => Some(format!("citum-{}", n.number.as_key())),
106 TemplateComponent::Identifier(identifier) => Some(format!(
107 "citum-identifier-{}",
108 identifier.identifier.as_str()
109 )),
110 TemplateComponent::Variable(v) => Some(format!(
111 "citum-{}",
112 match v.variable {
113 SimpleVariable::Doi => "doi",
114 SimpleVariable::Url => "url",
115 SimpleVariable::Isbn => "isbn",
116 SimpleVariable::Issn => "issn",
117 SimpleVariable::Pmid => "pmid",
118 SimpleVariable::Note => "note",
119 SimpleVariable::Publisher => "publisher",
120 SimpleVariable::PublisherPlace => "publisher-place",
121 SimpleVariable::ContainerTitleShort => "container-title-short",
122 SimpleVariable::Archive => "archive",
123 _ => "variable",
124 }
125 )),
126 TemplateComponent::Message(m) => Some(format!(
127 "citum-message-{}",
128 m.message
129 .chars()
130 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
131 .collect::<String>()
132 .trim_matches('-')
133 )),
134 _ => None,
135 }
136}
137
138#[must_use]
140pub fn render_component(component: &ProcTemplateComponent) -> String {
141 PlainText.finish(render_component_with_format::<PlainText>(component))
142}
143
144#[must_use]
146pub fn render_component_with_format<F: OutputFormat<Output = String>>(
147 component: &ProcTemplateComponent,
148) -> F::Output {
149 render_component_with_format_and_renderer::<F>(component, &F::default(), true)
150}
151
152fn realized_component_affixes<'a>(
153 rendering: &'a Rendering,
154 script: crate::values::ScriptClass,
155 realization: Option<&'a citum_schema::options::PunctuationRealization>,
156) -> (Cow<'a, str>, Cow<'a, str>) {
157 let realize = |punctuation: &'a citum_schema::template::DelimiterPunctuation, position| {
158 super::format::realize_punctuation(punctuation, script, realization, position)
159 };
160 let prefix = rendering
161 .prefix
162 .as_ref()
163 .map(|punctuation| realize(punctuation, super::format::PunctuationPosition::Prefix))
164 .unwrap_or(Cow::Borrowed(""));
165 let suffix = rendering
166 .suffix
167 .as_ref()
168 .map(|punctuation| realize(punctuation, super::format::PunctuationPosition::Suffix))
169 .unwrap_or(Cow::Borrowed(""));
170 (prefix, suffix)
171}
172
173fn apply_component_semantics<F>(
174 component: &ProcTemplateComponent,
175 fmt: &F,
176 show_semantics: bool,
177 output: F::Output,
178) -> F::Output
179where
180 F: OutputFormat<Output = String>,
181{
182 if !show_semantics {
183 return output;
184 }
185 let Some(class) = resolve_semantic_class(component) else {
186 return output;
187 };
188 let semantic_attributes = component
189 .template_index
190 .map(|index| {
191 vec![SemanticAttribute {
192 name: "data-index",
193 value: index.to_string(),
194 }]
195 })
196 .unwrap_or_default();
197 fmt.semantic_with_attributes(&class, output, &semantic_attributes)
198}
199
200pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
202 component: &ProcTemplateComponent,
203 fmt: &F,
204 show_semantics: bool,
205) -> F::Output {
206 let rendering = get_effective_rendering(component);
208
209 if rendering.suppress == Some(true) {
211 return fmt.text("");
212 }
213
214 let multilingual = component
215 .config
216 .as_ref()
217 .and_then(|config| config.multilingual.as_ref());
218 let (script, realization) = crate::values::punctuation_realization_context(
219 component.item_language.as_deref(),
220 multilingual,
221 component.quote_marks.punctuation_realization.as_ref(),
222 );
223 let (prefix, suffix) = realized_component_affixes(&rendering, script, realization.as_deref());
224 let inner_prefix = rendering
225 .wrap
226 .as_ref()
227 .and_then(|w| w.inner_prefix.as_deref())
228 .unwrap_or_default();
229 let inner_suffix = rendering
230 .wrap
231 .as_ref()
232 .and_then(|w| w.inner_suffix.as_deref())
233 .unwrap_or_default();
234
235 let mut output = if component.pre_formatted {
236 fmt.join(vec![component.value.clone()], "")
239 } else {
240 fmt.text(&component.value)
241 };
242
243 if rendering.emph == Some(true) {
245 output = fmt.emph(output);
246 }
247 if rendering.strong == Some(true) {
248 output = fmt.strong(output);
249 }
250 if rendering.small_caps == Some(true) {
251 output = fmt.small_caps(output);
252 }
253 if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
254 output = fmt.superscript(output);
255 }
256 let wrapped_in_quotes = rendering
260 .wrap
261 .as_ref()
262 .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
263 if rendering.quote == Some(true) && !wrapped_in_quotes {
264 output = fmt.quote(output, &component.quote_marks);
265 }
266
267 if let Some(url) = &component.url {
268 output = fmt.link(url, output);
269 }
270
271 let total_inner_prefix = format!(
272 "{}{}",
273 inner_prefix,
274 component.prefix.as_deref().unwrap_or_default()
275 );
276 let total_inner_suffix = format!(
277 "{}{}",
278 component.suffix.as_deref().unwrap_or_default(),
279 inner_suffix
280 );
281
282 if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
283 output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
284 }
285
286 if let Some(wrap_config) = rendering.wrap.as_ref() {
287 output = fmt.wrap_punctuation(
288 &wrap_config.punctuation,
289 output,
290 &component.quote_marks,
291 script,
292 realization.as_deref(),
293 );
294 }
295
296 if !prefix.is_empty() || !suffix.is_empty() {
297 output = super::format::apply_punctuation_affixes(
298 fmt,
299 rendering
300 .prefix
301 .as_ref()
302 .map(|punctuation| (punctuation, prefix.as_ref())),
303 output,
304 rendering
305 .suffix
306 .as_ref()
307 .map(|punctuation| (punctuation, suffix.as_ref())),
308 );
309 }
310
311 output = apply_component_semantics(component, fmt, show_semantics, output);
312
313 if wants_latin_punctuation(component) {
317 output = remap_to_latin_punctuation(output);
318 }
319
320 output
321}
322
323pub(crate) fn wants_latin_punctuation(component: &ProcTemplateComponent) -> bool {
329 let configured = component
330 .config
331 .as_ref()
332 .and_then(|cfg| cfg.multilingual.as_ref())
333 .and_then(|ml| ml.scripts.get("latin"))
334 .is_some_and(|script| {
335 script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
336 });
337
338 configured && crate::values::is_latin_script_language(component.item_language.as_deref())
339}
340
341pub(crate) fn remap_to_latin_punctuation(text: String) -> String {
347 if !text.contains([':', ',', '(', ')']) {
348 return text;
349 }
350
351 let mut mapped = String::with_capacity(text.len());
352 for ch in text.chars() {
353 match ch {
354 ':' => mapped.push_str(": "),
355 ',' => mapped.push_str(", "),
356 '(' => mapped.push('('),
357 ')' => mapped.push(')'),
358 _ => mapped.push(ch),
359 }
360 }
361
362 while mapped.contains(" ") {
363 mapped = mapped.replace(" ", " ");
364 }
365 mapped
366}
367
368#[must_use]
370pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
371 let mut effective = Rendering::default();
372
373 if let Some(config) = &component.config {
375 match &component.template_component {
376 TemplateComponent::Title(t) => {
377 if let Some(global_title) = get_title_category_rendering(
378 &t.title,
379 component.ref_type.as_deref(),
380 component.item_language.as_deref(),
381 config,
382 ) {
383 effective.merge(&global_title);
384 }
385 }
386 TemplateComponent::Contributor(c) => {
387 if let Some(contributors_config) = &config.contributors
388 && let Some(role_config) = &contributors_config.role
389 && let Some(primary_role) = c.contributor.as_slice().first()
390 && let Some(role_rendering) = role_config.role_rendering(primary_role)
391 {
392 effective.merge(&role_rendering.to_rendering());
393 }
394 }
395 _ => {}
397 }
398 }
399
400 effective.merge(component.template_component.rendering());
402
403 effective
404}
405
406#[must_use]
411pub fn get_title_category_rendering(
412 title_type: &TitleType,
413 ref_type: Option<&str>,
414 language: Option<&str>,
415 config: &Config,
416) -> Option<Rendering> {
417 get_title_category_title_rendering(title_type, ref_type, language, config)
418 .map(|rendering| rendering.to_rendering())
419}
420
421#[must_use]
426pub fn get_title_category_title_rendering(
427 title_type: &TitleType,
428 ref_type: Option<&str>,
429 language: Option<&str>,
430 config: &Config,
431) -> Option<TitleRendering> {
432 let titles_config = config.titles.as_ref()?;
433
434 let mapped_category = ref_type.and_then(|rt| {
436 titles_config
437 .type_mapping
438 .as_ref()
439 .and_then(|mapping| mapping.get(rt))
440 });
441
442 use crate::values::type_class::TitleCategory;
443
444 let rendering = match title_type {
445 TitleType::ContainerTitle => {
446 if let Some(cat) = mapped_category {
447 match cat.as_str() {
448 "periodical" => titles_config.periodical.as_ref(),
449 "serial" => titles_config.serial.as_ref(),
450 "monograph" | "collection" => titles_config
451 .container_monograph
452 .as_ref()
453 .or(titles_config.monograph.as_ref()),
454 _ => titles_config.default.as_ref(),
455 }
456 } else if let Some(rt) = ref_type {
457 match crate::values::type_class::container_title_category(rt) {
458 TitleCategory::Periodical => titles_config.periodical.as_ref(),
459 TitleCategory::ContainerMonograph => titles_config
460 .container_monograph
461 .as_ref()
462 .or(titles_config.monograph.as_ref()),
463 _ => titles_config.default.as_ref(),
464 }
465 } else {
466 titles_config.default.as_ref()
467 }
468 }
469 TitleType::ParentSerial => {
470 if let Some(cat) = mapped_category {
471 match cat.as_str() {
472 "periodical" => titles_config.periodical.as_ref(),
473 "serial" => titles_config.serial.as_ref(),
474 _ => titles_config.periodical.as_ref(),
475 }
476 } else if let Some(rt) = ref_type {
477 match crate::values::type_class::parent_serial_title_category(rt) {
478 TitleCategory::Periodical => titles_config.periodical.as_ref(),
479 _ => titles_config.serial.as_ref(),
480 }
481 } else {
482 titles_config.periodical.as_ref()
483 }
484 }
485 TitleType::ParentMonograph => titles_config
486 .container_monograph
487 .as_ref()
488 .or(titles_config.monograph.as_ref()),
489 TitleType::CollectionTitle => titles_config
490 .container_monograph
491 .as_ref()
492 .or(titles_config.monograph.as_ref())
493 .or(titles_config.default.as_ref()),
494 TitleType::Primary => {
495 if let Some(cat) = mapped_category {
496 match cat.as_str() {
497 "component" => titles_config.component.as_ref(),
498 "monograph" => titles_config.monograph.as_ref(),
499 _ => titles_config.default.as_ref(),
500 }
501 } else if let Some(rt) = ref_type {
502 match crate::values::type_class::title_category(rt) {
503 TitleCategory::Component => titles_config.component.as_ref(),
504 TitleCategory::Monograph => titles_config.monograph.as_ref(),
505 _ => titles_config.default.as_ref(),
506 }
507 } else {
508 titles_config.default.as_ref()
509 }
510 }
511 _ => None,
512 };
513
514 let selected = rendering.or(titles_config.default.as_ref())?;
515 let mut effective = selected.clone();
516 if let Some(override_rendering) = selected.locale_override(language) {
517 effective.merge(override_rendering);
518 }
519 Some(effective)
520}
521
522#[cfg(test)]
523#[allow(
524 clippy::unwrap_used,
525 clippy::expect_used,
526 clippy::panic,
527 clippy::indexing_slicing,
528 clippy::todo,
529 clippy::unimplemented,
530 clippy::unreachable,
531 clippy::get_unwrap,
532 reason = "Panicking is acceptable and often desired in tests."
533)]
534mod tests {
535 use super::*;
536 use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
537
538 #[test]
539 fn test_render_with_emphasis() {
540 let component = ProcTemplateComponent {
541 template_component: TemplateComponent::Title(TemplateTitle {
542 title: TitleType::Primary,
543 rendering: Rendering {
544 emph: Some(true),
545 ..Default::default()
546 },
547 ..Default::default()
548 }),
549 value: "The Structure of Scientific Revolutions".to_string(),
550 ..Default::default()
551 };
552
553 let result = render_component(&component);
554 assert_eq!(result, "_The Structure of Scientific Revolutions_");
555 }
556
557 #[test]
558 fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
559 use citum_schema::template::{WrapConfig, WrapPunctuation};
560
561 let component = ProcTemplateComponent {
564 template_component: TemplateComponent::Title(TemplateTitle {
565 title: TitleType::Primary,
566 rendering: Rendering {
567 quote: Some(true),
568 wrap: Some(WrapConfig {
569 punctuation: WrapPunctuation::Quotes,
570 inner_prefix: None,
571 inner_suffix: None,
572 }),
573 ..Default::default()
574 },
575 ..Default::default()
576 }),
577 value: "The Structure of Scientific Revolutions".to_string(),
578 ..Default::default()
579 };
580
581 let result = render_component(&component);
582 assert_eq!(
583 result,
584 "\u{201C}The Structure of Scientific Revolutions\u{201D}"
585 );
586 }
587
588 #[test]
589 fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
590 use citum_schema::template::{WrapConfig, WrapPunctuation};
591
592 let component = ProcTemplateComponent {
595 template_component: TemplateComponent::Title(TemplateTitle {
596 title: TitleType::Primary,
597 rendering: Rendering {
598 quote: Some(true),
599 wrap: Some(WrapConfig {
600 punctuation: WrapPunctuation::Parentheses,
601 inner_prefix: None,
602 inner_suffix: None,
603 }),
604 ..Default::default()
605 },
606 ..Default::default()
607 }),
608 value: "Title".to_string(),
609 ..Default::default()
610 };
611
612 let result = render_component(&component);
613 assert_eq!(result, "(\u{201C}Title\u{201D})");
614 }
615}