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}
45
46pub type ProcTemplate = Vec<ProcTemplateComponent>;
48
49#[derive(Debug, Clone, Default, PartialEq)]
51pub struct ProcEntry {
52 pub id: String,
54 pub marker: Option<String>,
58 pub template: ProcTemplate,
60 pub metadata: super::format::ProcEntryMetadata,
62}
63
64use super::format::{OutputFormat, SemanticAttribute};
65use super::plain::PlainText;
66use std::borrow::Cow;
67
68fn resolve_semantic_class(component: &ProcTemplateComponent) -> Option<String> {
70 use citum_schema::template::{DateVariable, SimpleVariable};
71 match &component.template_component {
72 TemplateComponent::Title(t) => match t.title {
73 TitleType::Primary => Some("citum-title".to_string()),
74 TitleType::ContainerTitle
75 | TitleType::ParentMonograph
76 | TitleType::ParentSerial
77 | TitleType::CollectionTitle => Some("citum-container-title".to_string()),
78 _ => Some("citum-title".to_string()),
79 },
80 TemplateComponent::Contributor(c) => Some(format!(
81 "citum-{}",
82 c.contributor
83 .as_slice()
84 .iter()
85 .map(citum_schema::template::ContributorRole::as_str)
86 .collect::<Vec<_>>()
87 .join("-")
88 )),
89 TemplateComponent::Date(d) => Some(format!(
90 "citum-{}",
91 match d.date {
92 DateVariable::Issued => "issued",
93 DateVariable::Accessed => "accessed",
94 DateVariable::OriginalPublished => "original-published",
95 DateVariable::Submitted => "submitted",
96 DateVariable::EventDate => "event-date",
97 DateVariable::Copyright => "copyright",
98 DateVariable::Printing => "printing",
99 }
100 )),
101 TemplateComponent::Number(n) => Some(format!("citum-{}", n.number.as_key())),
102 TemplateComponent::Identifier(identifier) => Some(format!(
103 "citum-identifier-{}",
104 identifier.identifier.as_str()
105 )),
106 TemplateComponent::Variable(v) => Some(format!(
107 "citum-{}",
108 match v.variable {
109 SimpleVariable::Doi => "doi",
110 SimpleVariable::Url => "url",
111 SimpleVariable::Isbn => "isbn",
112 SimpleVariable::Issn => "issn",
113 SimpleVariable::Pmid => "pmid",
114 SimpleVariable::Note => "note",
115 SimpleVariable::Publisher => "publisher",
116 SimpleVariable::PublisherPlace => "publisher-place",
117 SimpleVariable::ContainerTitleShort => "container-title-short",
118 SimpleVariable::Archive => "archive",
119 _ => "variable",
120 }
121 )),
122 TemplateComponent::Message(m) => Some(format!(
123 "citum-message-{}",
124 m.message
125 .chars()
126 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
127 .collect::<String>()
128 .trim_matches('-')
129 )),
130 _ => None,
131 }
132}
133
134#[must_use]
136pub fn render_component(component: &ProcTemplateComponent) -> String {
137 PlainText.finish(render_component_with_format::<PlainText>(component))
138}
139
140#[must_use]
142pub fn render_component_with_format<F: OutputFormat<Output = String>>(
143 component: &ProcTemplateComponent,
144) -> F::Output {
145 render_component_with_format_and_renderer::<F>(component, &F::default(), true)
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Default)]
164pub(crate) struct RenderedComponent {
165 pub(crate) text: String,
167}
168
169pub(crate) fn render_component_detailed<F: OutputFormat<Output = String>>(
172 component: &ProcTemplateComponent,
173) -> RenderedComponent {
174 render_component_detailed_with_format_and_renderer::<F>(component, &F::default(), true)
175}
176
177fn realized_component_affixes<'a>(
178 rendering: &'a Rendering,
179 script: crate::values::ScriptClass,
180 realization: Option<&'a citum_schema::options::PunctuationRealization>,
181) -> (Cow<'a, str>, Cow<'a, str>) {
182 let realize = |punctuation: &'a citum_schema::template::DelimiterPunctuation, position| {
183 super::format::realize_punctuation(punctuation, script, realization, position)
184 };
185 let prefix = rendering
186 .prefix
187 .as_ref()
188 .map(|punctuation| realize(punctuation, super::format::PunctuationPosition::Prefix))
189 .unwrap_or(Cow::Borrowed(""));
190 let suffix = rendering
191 .suffix
192 .as_ref()
193 .map(|punctuation| realize(punctuation, super::format::PunctuationPosition::Suffix))
194 .unwrap_or(Cow::Borrowed(""));
195 (prefix, suffix)
196}
197
198fn apply_component_semantics<F>(
199 component: &ProcTemplateComponent,
200 fmt: &F,
201 show_semantics: bool,
202 output: F::Output,
203) -> F::Output
204where
205 F: OutputFormat<Output = String>,
206{
207 if !show_semantics {
208 return output;
209 }
210 let Some(class) = resolve_semantic_class(component) else {
211 return output;
212 };
213 let semantic_attributes = component
214 .template_index
215 .map(|index| {
216 vec![SemanticAttribute {
217 name: "data-index",
218 value: index.to_string(),
219 }]
220 })
221 .unwrap_or_default();
222 fmt.semantic_with_attributes(&class, output, &semantic_attributes)
223}
224
225pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
227 component: &ProcTemplateComponent,
228 fmt: &F,
229 show_semantics: bool,
230) -> F::Output {
231 render_component_detailed_with_format_and_renderer::<F>(component, fmt, show_semantics).text
232}
233
234pub(crate) fn render_component_detailed_with_format_and_renderer<
237 F: OutputFormat<Output = String>,
238>(
239 component: &ProcTemplateComponent,
240 fmt: &F,
241 show_semantics: bool,
242) -> RenderedComponent {
243 let rendering = get_effective_rendering(component);
245
246 if rendering.suppress == Some(true) {
248 return RenderedComponent::default();
249 }
250
251 let multilingual = component
252 .config
253 .as_ref()
254 .and_then(|config| config.multilingual.as_ref());
255 let (script, realization) = crate::values::punctuation_realization_context(
256 component.item_language.as_deref(),
257 multilingual,
258 component.quote_marks.punctuation_realization.as_ref(),
259 );
260 let (prefix, suffix) = realized_component_affixes(&rendering, script, realization.as_deref());
261 let inner_prefix = rendering
262 .wrap
263 .as_ref()
264 .and_then(|w| w.inner_prefix.as_deref())
265 .unwrap_or_default();
266 let inner_suffix = rendering
267 .wrap
268 .as_ref()
269 .and_then(|w| w.inner_suffix.as_deref())
270 .unwrap_or_default();
271
272 let mut output = if component.pre_formatted {
273 fmt.join(vec![component.value.clone()], "")
276 } else {
277 fmt.text(&component.value)
278 };
279
280 if rendering.emph == Some(true) {
282 output = fmt.emph(output);
283 }
284 if rendering.strong == Some(true) {
285 output = fmt.strong(output);
286 }
287 if rendering.small_caps == Some(true) {
288 output = fmt.small_caps(output);
289 }
290 if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
291 output = fmt.superscript(output);
292 }
293 let wrapped_in_quotes = rendering
297 .wrap
298 .as_ref()
299 .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
300 if rendering.quote == Some(true) && !wrapped_in_quotes {
301 output = fmt.quote(output, &component.quote_marks);
302 }
303
304 if let Some(url) = &component.url {
305 output = fmt.link(url, output);
306 }
307
308 let total_inner_prefix = format!(
309 "{}{}",
310 inner_prefix,
311 component.prefix.as_deref().unwrap_or_default()
312 );
313 let total_inner_suffix = format!(
314 "{}{}",
315 component.suffix.as_deref().unwrap_or_default(),
316 inner_suffix
317 );
318
319 if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
320 output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
321 }
322
323 if let Some(wrap_config) = rendering.wrap.as_ref() {
324 output = fmt.wrap_punctuation(
325 &wrap_config.punctuation,
326 output,
327 &component.quote_marks,
328 script,
329 realization.as_deref(),
330 );
331 }
332
333 if !prefix.is_empty() || !suffix.is_empty() {
334 output = super::format::apply_punctuation_affixes(
335 fmt,
336 rendering
337 .prefix
338 .as_ref()
339 .map(|punctuation| (punctuation, prefix.as_ref())),
340 output,
341 rendering
342 .suffix
343 .as_ref()
344 .map(|punctuation| (punctuation, suffix.as_ref())),
345 );
346 }
347
348 output = apply_component_semantics(component, fmt, show_semantics, output);
349
350 if wants_latin_punctuation(component) {
354 output = remap_to_latin_punctuation(output);
355 }
356
357 RenderedComponent { text: output }
358}
359
360pub(crate) fn wants_latin_punctuation(component: &ProcTemplateComponent) -> bool {
366 let configured = component
367 .config
368 .as_ref()
369 .and_then(|cfg| cfg.multilingual.as_ref())
370 .and_then(|ml| ml.scripts.get("latin"))
371 .is_some_and(|script| {
372 script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
373 });
374
375 configured && crate::values::is_latin_script_language(component.item_language.as_deref())
376}
377
378pub(crate) fn remap_to_latin_punctuation(text: String) -> String {
384 if !text.contains([':', ',', '(', ')']) {
385 return text;
386 }
387
388 let mut mapped = String::with_capacity(text.len());
389 for ch in text.chars() {
390 match ch {
391 ':' => mapped.push_str(": "),
392 ',' => mapped.push_str(", "),
393 '(' => mapped.push('('),
394 ')' => mapped.push(')'),
395 _ => mapped.push(ch),
396 }
397 }
398
399 while mapped.contains(" ") {
400 mapped = mapped.replace(" ", " ");
401 }
402 mapped
403}
404
405#[must_use]
407pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
408 let mut effective = Rendering::default();
409
410 if let Some(config) = &component.config {
412 match &component.template_component {
413 TemplateComponent::Title(t) => {
414 if let Some(global_title) = get_title_category_rendering(
415 &t.title,
416 component.ref_type.as_deref(),
417 component.item_language.as_deref(),
418 config,
419 ) {
420 effective.merge(&global_title);
421 }
422 }
423 TemplateComponent::Contributor(c) => {
424 if let Some(contributors_config) = &config.contributors
425 && let Some(role_config) = &contributors_config.role
426 && let Some(primary_role) = c.contributor.as_slice().first()
427 && let Some(role_rendering) = role_config.role_rendering(primary_role)
428 {
429 effective.merge(&role_rendering.to_rendering());
430 }
431 }
432 _ => {}
434 }
435 }
436
437 effective.merge(component.template_component.rendering());
439
440 effective
441}
442
443#[must_use]
448pub fn get_title_category_rendering(
449 title_type: &TitleType,
450 ref_type: Option<&str>,
451 language: Option<&str>,
452 config: &Config,
453) -> Option<Rendering> {
454 get_title_category_title_rendering(title_type, ref_type, language, config)
455 .map(|rendering| rendering.to_rendering())
456}
457
458#[must_use]
463pub fn get_title_category_title_rendering(
464 title_type: &TitleType,
465 ref_type: Option<&str>,
466 language: Option<&str>,
467 config: &Config,
468) -> Option<TitleRendering> {
469 let titles_config = config.titles.as_ref()?;
470
471 let mapped_category = ref_type.and_then(|rt| titles_config.mapped_category(rt));
472
473 use crate::values::type_class::TitleCategory;
474
475 let rendering = match title_type {
476 TitleType::ContainerTitle => {
477 if let Some(cat) = mapped_category {
478 match cat {
479 TitleCategory::Periodical => titles_config.periodical.as_ref(),
480 TitleCategory::Serial => titles_config.serial.as_ref(),
481 TitleCategory::Monograph | TitleCategory::ContainerMonograph => titles_config
482 .container_monograph
483 .as_ref()
484 .or(titles_config.monograph.as_ref()),
485 TitleCategory::Component | TitleCategory::Default => {
486 titles_config.default.as_ref()
487 }
488 }
489 } else if let Some(rt) = ref_type {
490 match crate::values::type_class::container_title_category(rt) {
491 TitleCategory::Periodical => titles_config.periodical.as_ref(),
492 TitleCategory::ContainerMonograph => titles_config
493 .container_monograph
494 .as_ref()
495 .or(titles_config.monograph.as_ref()),
496 _ => titles_config.default.as_ref(),
497 }
498 } else {
499 titles_config.default.as_ref()
500 }
501 }
502 TitleType::ParentSerial => {
503 if let Some(cat) = mapped_category {
504 match cat {
505 TitleCategory::Periodical => titles_config.periodical.as_ref(),
506 TitleCategory::Serial => titles_config.serial.as_ref(),
507 TitleCategory::Component
508 | TitleCategory::Monograph
509 | TitleCategory::ContainerMonograph
510 | TitleCategory::Default => titles_config.periodical.as_ref(),
511 }
512 } else if let Some(rt) = ref_type {
513 match crate::values::type_class::parent_serial_title_category(rt) {
514 TitleCategory::Periodical => titles_config.periodical.as_ref(),
515 _ => titles_config.serial.as_ref(),
516 }
517 } else {
518 titles_config.periodical.as_ref()
519 }
520 }
521 TitleType::ParentMonograph => titles_config
522 .container_monograph
523 .as_ref()
524 .or(titles_config.monograph.as_ref()),
525 TitleType::CollectionTitle => titles_config
526 .container_monograph
527 .as_ref()
528 .or(titles_config.monograph.as_ref())
529 .or(titles_config.default.as_ref()),
530 TitleType::Primary => {
531 if let Some(cat) = mapped_category {
532 match cat {
533 TitleCategory::Component => titles_config.component.as_ref(),
534 TitleCategory::Monograph => titles_config.monograph.as_ref(),
535 TitleCategory::Periodical
536 | TitleCategory::Serial
537 | TitleCategory::ContainerMonograph
538 | TitleCategory::Default => titles_config.default.as_ref(),
539 }
540 } else if let Some(rt) = ref_type {
541 match crate::values::type_class::title_category(rt) {
542 TitleCategory::Component => titles_config.component.as_ref(),
543 TitleCategory::Monograph => titles_config.monograph.as_ref(),
544 _ => titles_config.default.as_ref(),
545 }
546 } else {
547 titles_config.default.as_ref()
548 }
549 }
550 _ => None,
551 };
552
553 let selected = rendering.or(titles_config.default.as_ref())?;
554 let mut effective = selected.clone();
555 if let Some(override_rendering) = selected.locale_override(language) {
556 effective.merge(override_rendering);
557 }
558 Some(effective)
559}
560
561#[cfg(test)]
562#[allow(
563 clippy::unwrap_used,
564 clippy::expect_used,
565 clippy::panic,
566 clippy::indexing_slicing,
567 clippy::todo,
568 clippy::unimplemented,
569 clippy::unreachable,
570 clippy::get_unwrap,
571 reason = "Panicking is acceptable and often desired in tests."
572)]
573mod tests {
574 use super::*;
575 use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
576
577 #[test]
578 fn test_render_with_emphasis() {
579 let component = ProcTemplateComponent {
580 template_component: TemplateComponent::Title(TemplateTitle {
581 title: TitleType::Primary,
582 rendering: Rendering {
583 emph: Some(true),
584 ..Default::default()
585 },
586 ..Default::default()
587 }),
588 value: "The Structure of Scientific Revolutions".to_string(),
589 ..Default::default()
590 };
591
592 let result = render_component(&component);
593 assert_eq!(result, "_The Structure of Scientific Revolutions_");
594 }
595
596 #[test]
597 fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
598 use citum_schema::template::{WrapConfig, WrapPunctuation};
599
600 let component = ProcTemplateComponent {
603 template_component: TemplateComponent::Title(TemplateTitle {
604 title: TitleType::Primary,
605 rendering: Rendering {
606 quote: Some(true),
607 wrap: Some(WrapConfig {
608 punctuation: WrapPunctuation::Quotes,
609 inner_prefix: None,
610 inner_suffix: None,
611 }),
612 ..Default::default()
613 },
614 ..Default::default()
615 }),
616 value: "The Structure of Scientific Revolutions".to_string(),
617 ..Default::default()
618 };
619
620 let result = render_component(&component);
621 assert_eq!(
622 result,
623 "\u{201C}The Structure of Scientific Revolutions\u{201D}"
624 );
625 }
626
627 #[test]
628 fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
629 use citum_schema::template::{WrapConfig, WrapPunctuation};
630
631 let component = ProcTemplateComponent {
634 template_component: TemplateComponent::Title(TemplateTitle {
635 title: TitleType::Primary,
636 rendering: Rendering {
637 quote: Some(true),
638 wrap: Some(WrapConfig {
639 punctuation: WrapPunctuation::Parentheses,
640 inner_prefix: None,
641 inner_suffix: None,
642 }),
643 ..Default::default()
644 },
645 ..Default::default()
646 }),
647 value: "Title".to_string(),
648 ..Default::default()
649 };
650
651 let result = render_component(&component);
652 assert_eq!(result, "(\u{201C}Title\u{201D})");
653 }
654
655 #[test]
656 fn underscore_title_mapping_key_matches_canonical_reference_type() {
657 let config: Config = serde_yaml::from_str(
658 "titles:\n type-mapping:\n personal_communication: component\n component:\n quote: true\n",
659 )
660 .expect("title configuration should parse");
661
662 let rendering = get_title_category_title_rendering(
663 &TitleType::Primary,
664 Some("personal-communication"),
665 None,
666 &config,
667 )
668 .expect("the normalized mapping should select component rendering");
669
670 assert_eq!(rendering.quote, Some(true));
671 }
672}