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 component.quote_marks.punctuation_realization.as_ref(),
214 );
215 let (prefix, suffix) = realized_component_affixes(&rendering, script, realization.as_deref());
216 let inner_prefix = rendering
217 .wrap
218 .as_ref()
219 .and_then(|w| w.inner_prefix.as_deref())
220 .unwrap_or_default();
221 let inner_suffix = rendering
222 .wrap
223 .as_ref()
224 .and_then(|w| w.inner_suffix.as_deref())
225 .unwrap_or_default();
226
227 let mut output = if component.pre_formatted {
228 fmt.join(vec![component.value.clone()], "")
231 } else {
232 fmt.text(&component.value)
233 };
234
235 if rendering.emph == Some(true) {
237 output = fmt.emph(output);
238 }
239 if rendering.strong == Some(true) {
240 output = fmt.strong(output);
241 }
242 if rendering.small_caps == Some(true) {
243 output = fmt.small_caps(output);
244 }
245 if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
246 output = fmt.superscript(output);
247 }
248 let wrapped_in_quotes = rendering
252 .wrap
253 .as_ref()
254 .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
255 if rendering.quote == Some(true) && !wrapped_in_quotes {
256 output = fmt.quote(output, &component.quote_marks);
257 }
258
259 if let Some(url) = &component.url {
260 output = fmt.link(url, output);
261 }
262
263 let total_inner_prefix = format!(
264 "{}{}",
265 inner_prefix,
266 component.prefix.as_deref().unwrap_or_default()
267 );
268 let total_inner_suffix = format!(
269 "{}{}",
270 component.suffix.as_deref().unwrap_or_default(),
271 inner_suffix
272 );
273
274 if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
275 output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
276 }
277
278 if let Some(wrap_config) = rendering.wrap.as_ref() {
279 output = fmt.wrap_punctuation(
280 &wrap_config.punctuation,
281 output,
282 &component.quote_marks,
283 script,
284 realization.as_deref(),
285 );
286 }
287
288 if !prefix.is_empty() || !suffix.is_empty() {
289 output = super::format::apply_punctuation_affixes(
290 fmt,
291 rendering
292 .prefix
293 .as_ref()
294 .map(|punctuation| (punctuation, prefix.as_ref())),
295 output,
296 rendering
297 .suffix
298 .as_ref()
299 .map(|punctuation| (punctuation, suffix.as_ref())),
300 );
301 }
302
303 output = apply_component_semantics(component, fmt, show_semantics, output);
304
305 if wants_latin_punctuation(component) {
309 output = remap_to_latin_punctuation(output);
310 }
311
312 output
313}
314
315pub(crate) fn wants_latin_punctuation(component: &ProcTemplateComponent) -> bool {
321 let configured = component
322 .config
323 .as_ref()
324 .and_then(|cfg| cfg.multilingual.as_ref())
325 .and_then(|ml| ml.scripts.get("latin"))
326 .is_some_and(|script| {
327 script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
328 });
329
330 configured && crate::values::is_latin_script_language(component.item_language.as_deref())
331}
332
333pub(crate) fn remap_to_latin_punctuation(text: String) -> String {
339 if !text.contains([':', ',', '(', ')']) {
340 return text;
341 }
342
343 let mut mapped = String::with_capacity(text.len());
344 for ch in text.chars() {
345 match ch {
346 ':' => mapped.push_str(": "),
347 ',' => mapped.push_str(", "),
348 '(' => mapped.push('('),
349 ')' => mapped.push(')'),
350 _ => mapped.push(ch),
351 }
352 }
353
354 while mapped.contains(" ") {
355 mapped = mapped.replace(" ", " ");
356 }
357 mapped
358}
359
360#[must_use]
362pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
363 let mut effective = Rendering::default();
364
365 if let Some(config) = &component.config {
367 match &component.template_component {
368 TemplateComponent::Title(t) => {
369 if let Some(global_title) = get_title_category_rendering(
370 &t.title,
371 component.ref_type.as_deref(),
372 component.item_language.as_deref(),
373 config,
374 ) {
375 effective.merge(&global_title);
376 }
377 }
378 TemplateComponent::Contributor(c) => {
379 if let Some(contributors_config) = &config.contributors
380 && let Some(role_config) = &contributors_config.role
381 && let Some(primary_role) = c.contributor.as_slice().first()
382 && let Some(role_rendering) = role_config.role_rendering(primary_role)
383 {
384 effective.merge(&role_rendering.to_rendering());
385 }
386 }
387 _ => {}
389 }
390 }
391
392 effective.merge(component.template_component.rendering());
394
395 effective
396}
397
398#[must_use]
403pub fn get_title_category_rendering(
404 title_type: &TitleType,
405 ref_type: Option<&str>,
406 language: Option<&str>,
407 config: &Config,
408) -> Option<Rendering> {
409 get_title_category_title_rendering(title_type, ref_type, language, config)
410 .map(|rendering| rendering.to_rendering())
411}
412
413#[must_use]
418pub fn get_title_category_title_rendering(
419 title_type: &TitleType,
420 ref_type: Option<&str>,
421 language: Option<&str>,
422 config: &Config,
423) -> Option<TitleRendering> {
424 let titles_config = config.titles.as_ref()?;
425
426 let mapped_category = ref_type.and_then(|rt| titles_config.type_mapping.get(rt));
428
429 use crate::values::type_class::TitleCategory;
430
431 let rendering = match title_type {
432 TitleType::ContainerTitle => {
433 if let Some(cat) = mapped_category {
434 match cat.as_str() {
435 "periodical" => titles_config.periodical.as_ref(),
436 "serial" => titles_config.serial.as_ref(),
437 "monograph" | "collection" => titles_config
438 .container_monograph
439 .as_ref()
440 .or(titles_config.monograph.as_ref()),
441 _ => titles_config.default.as_ref(),
442 }
443 } else if let Some(rt) = ref_type {
444 match crate::values::type_class::container_title_category(rt) {
445 TitleCategory::Periodical => titles_config.periodical.as_ref(),
446 TitleCategory::ContainerMonograph => titles_config
447 .container_monograph
448 .as_ref()
449 .or(titles_config.monograph.as_ref()),
450 _ => titles_config.default.as_ref(),
451 }
452 } else {
453 titles_config.default.as_ref()
454 }
455 }
456 TitleType::ParentSerial => {
457 if let Some(cat) = mapped_category {
458 match cat.as_str() {
459 "periodical" => titles_config.periodical.as_ref(),
460 "serial" => titles_config.serial.as_ref(),
461 _ => titles_config.periodical.as_ref(),
462 }
463 } else if let Some(rt) = ref_type {
464 match crate::values::type_class::parent_serial_title_category(rt) {
465 TitleCategory::Periodical => titles_config.periodical.as_ref(),
466 _ => titles_config.serial.as_ref(),
467 }
468 } else {
469 titles_config.periodical.as_ref()
470 }
471 }
472 TitleType::ParentMonograph => titles_config
473 .container_monograph
474 .as_ref()
475 .or(titles_config.monograph.as_ref()),
476 TitleType::CollectionTitle => titles_config
477 .container_monograph
478 .as_ref()
479 .or(titles_config.monograph.as_ref())
480 .or(titles_config.default.as_ref()),
481 TitleType::Primary => {
482 if let Some(cat) = mapped_category {
483 match cat.as_str() {
484 "component" => titles_config.component.as_ref(),
485 "monograph" => titles_config.monograph.as_ref(),
486 _ => titles_config.default.as_ref(),
487 }
488 } else if let Some(rt) = ref_type {
489 match crate::values::type_class::title_category(rt) {
490 TitleCategory::Component => titles_config.component.as_ref(),
491 TitleCategory::Monograph => titles_config.monograph.as_ref(),
492 _ => titles_config.default.as_ref(),
493 }
494 } else {
495 titles_config.default.as_ref()
496 }
497 }
498 _ => None,
499 };
500
501 let selected = rendering.or(titles_config.default.as_ref())?;
502 let mut effective = selected.clone();
503 if let Some(override_rendering) = selected.locale_override(language) {
504 effective.merge(override_rendering);
505 }
506 Some(effective)
507}
508
509#[cfg(test)]
510#[allow(
511 clippy::unwrap_used,
512 clippy::expect_used,
513 clippy::panic,
514 clippy::indexing_slicing,
515 clippy::todo,
516 clippy::unimplemented,
517 clippy::unreachable,
518 clippy::get_unwrap,
519 reason = "Panicking is acceptable and often desired in tests."
520)]
521mod tests {
522 use super::*;
523 use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
524
525 #[test]
526 fn test_render_with_emphasis() {
527 let component = ProcTemplateComponent {
528 template_component: TemplateComponent::Title(TemplateTitle {
529 title: TitleType::Primary,
530 rendering: Rendering {
531 emph: Some(true),
532 ..Default::default()
533 },
534 ..Default::default()
535 }),
536 value: "The Structure of Scientific Revolutions".to_string(),
537 ..Default::default()
538 };
539
540 let result = render_component(&component);
541 assert_eq!(result, "_The Structure of Scientific Revolutions_");
542 }
543
544 #[test]
545 fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
546 use citum_schema::template::{WrapConfig, WrapPunctuation};
547
548 let component = ProcTemplateComponent {
551 template_component: TemplateComponent::Title(TemplateTitle {
552 title: TitleType::Primary,
553 rendering: Rendering {
554 quote: Some(true),
555 wrap: Some(WrapConfig {
556 punctuation: WrapPunctuation::Quotes,
557 inner_prefix: None,
558 inner_suffix: None,
559 }),
560 ..Default::default()
561 },
562 ..Default::default()
563 }),
564 value: "The Structure of Scientific Revolutions".to_string(),
565 ..Default::default()
566 };
567
568 let result = render_component(&component);
569 assert_eq!(
570 result,
571 "\u{201C}The Structure of Scientific Revolutions\u{201D}"
572 );
573 }
574
575 #[test]
576 fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
577 use citum_schema::template::{WrapConfig, WrapPunctuation};
578
579 let component = ProcTemplateComponent {
582 template_component: TemplateComponent::Title(TemplateTitle {
583 title: TitleType::Primary,
584 rendering: Rendering {
585 quote: Some(true),
586 wrap: Some(WrapConfig {
587 punctuation: WrapPunctuation::Parentheses,
588 inner_prefix: None,
589 inner_suffix: None,
590 }),
591 ..Default::default()
592 },
593 ..Default::default()
594 }),
595 value: "Title".to_string(),
596 ..Default::default()
597 };
598
599 let result = render_component(&component);
600 assert_eq!(result, "(\u{201C}Title\u{201D})");
601 }
602}