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