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;
62
63fn resolve_semantic_class(component: &ProcTemplateComponent) -> Option<String> {
65 use citum_schema::template::{DateVariable, SimpleVariable};
66 match &component.template_component {
67 TemplateComponent::Title(t) => match t.title {
68 TitleType::Primary => Some("citum-title".to_string()),
69 TitleType::ContainerTitle
70 | TitleType::ParentMonograph
71 | TitleType::ParentSerial
72 | TitleType::CollectionTitle => Some("citum-container-title".to_string()),
73 _ => Some("citum-title".to_string()),
74 },
75 TemplateComponent::Contributor(c) => Some(format!(
76 "citum-{}",
77 c.contributor
78 .as_slice()
79 .iter()
80 .map(citum_schema::template::ContributorRole::as_str)
81 .collect::<Vec<_>>()
82 .join("-")
83 )),
84 TemplateComponent::Date(d) => Some(format!(
85 "citum-{}",
86 match d.date {
87 DateVariable::Issued => "issued",
88 DateVariable::Accessed => "accessed",
89 DateVariable::OriginalPublished => "original-published",
90 DateVariable::Submitted => "submitted",
91 DateVariable::EventDate => "event-date",
92 }
93 )),
94 TemplateComponent::Number(n) => Some(format!("citum-{}", n.number.as_key())),
95 TemplateComponent::Variable(v) => Some(format!(
96 "citum-{}",
97 match v.variable {
98 SimpleVariable::Doi => "doi",
99 SimpleVariable::Url => "url",
100 SimpleVariable::Isbn => "isbn",
101 SimpleVariable::Issn => "issn",
102 SimpleVariable::Pmid => "pmid",
103 SimpleVariable::Note => "note",
104 SimpleVariable::Publisher => "publisher",
105 SimpleVariable::PublisherPlace => "publisher-place",
106 SimpleVariable::ContainerTitleShort => "container-title-short",
107 SimpleVariable::Archive => "archive",
108 _ => "variable",
109 }
110 )),
111 TemplateComponent::Message(m) => Some(format!(
112 "citum-message-{}",
113 m.message
114 .chars()
115 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
116 .collect::<String>()
117 .trim_matches('-')
118 )),
119 _ => None,
120 }
121}
122
123#[must_use]
125pub fn render_component(component: &ProcTemplateComponent) -> String {
126 PlainText.finish(render_component_with_format::<PlainText>(component))
127}
128
129#[must_use]
131pub fn render_component_with_format<F: OutputFormat<Output = String>>(
132 component: &ProcTemplateComponent,
133) -> F::Output {
134 render_component_with_format_and_renderer::<F>(component, &F::default(), true)
135}
136
137pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
139 component: &ProcTemplateComponent,
140 fmt: &F,
141 show_semantics: bool,
142) -> F::Output {
143 let rendering = get_effective_rendering(component);
145
146 if rendering.suppress == Some(true) {
148 return fmt.text("");
149 }
150
151 let prefix = rendering.prefix.as_deref().unwrap_or_default();
152 let suffix = rendering.suffix.as_deref().unwrap_or_default();
153 let inner_prefix = rendering
154 .wrap
155 .as_ref()
156 .and_then(|w| w.inner_prefix.as_deref())
157 .unwrap_or_default();
158 let inner_suffix = rendering
159 .wrap
160 .as_ref()
161 .and_then(|w| w.inner_suffix.as_deref())
162 .unwrap_or_default();
163
164 let mut output = if component.pre_formatted {
165 fmt.join(vec![component.value.clone()], "")
168 } else {
169 fmt.text(&component.value)
170 };
171
172 if rendering.emph == Some(true) {
182 output = fmt.emph(output);
183 }
184 if rendering.strong == Some(true) {
185 output = fmt.strong(output);
186 }
187 if rendering.small_caps == Some(true) {
188 output = fmt.small_caps(output);
189 }
190 if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
191 output = fmt.superscript(output);
192 }
193 let wrapped_in_quotes = rendering
197 .wrap
198 .as_ref()
199 .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
200 if rendering.quote == Some(true) && !wrapped_in_quotes {
201 output = fmt.quote(output, &component.quote_marks);
202 }
203
204 if let Some(url) = &component.url {
206 output = fmt.link(url, output);
207 }
208
209 let total_inner_prefix = format!(
211 "{}{}",
212 inner_prefix,
213 component.prefix.as_deref().unwrap_or_default()
214 );
215 let total_inner_suffix = format!(
216 "{}{}",
217 component.suffix.as_deref().unwrap_or_default(),
218 inner_suffix
219 );
220
221 if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
222 output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
223 }
224
225 if let Some(wrap_config) = rendering.wrap.as_ref() {
227 output = fmt.wrap_punctuation(&wrap_config.punctuation, output, &component.quote_marks);
228 }
229
230 if !prefix.is_empty() || !suffix.is_empty() {
232 output = fmt.affix(prefix, output, suffix);
233 }
234
235 if show_semantics && let Some(class) = resolve_semantic_class(component) {
237 let semantic_attributes = component
238 .template_index
239 .map(|index| {
240 vec![SemanticAttribute {
241 name: "data-index",
242 value: index.to_string(),
243 }]
244 })
245 .unwrap_or_default();
246 output = fmt.semantic_with_attributes(&class, output, &semantic_attributes);
247 }
248
249 output
250}
251
252#[must_use]
254pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
255 let mut effective = Rendering::default();
256
257 if let Some(config) = &component.config {
259 match &component.template_component {
260 TemplateComponent::Title(t) => {
261 if let Some(global_title) = get_title_category_rendering(
262 &t.title,
263 component.ref_type.as_deref(),
264 component.item_language.as_deref(),
265 config,
266 ) {
267 effective.merge(&global_title);
268 }
269 }
270 TemplateComponent::Contributor(c) => {
271 if let Some(contributors_config) = &config.contributors
272 && let Some(role_config) = &contributors_config.role
273 && let Some(primary_role) = c.contributor.as_slice().first()
274 && let Some(role_rendering) = role_config.role_rendering(primary_role)
275 {
276 effective.merge(&role_rendering.to_rendering());
277 }
278 }
279 _ => {}
281 }
282 }
283
284 effective.merge(component.template_component.rendering());
286
287 effective
288}
289
290#[must_use]
295pub fn get_title_category_rendering(
296 title_type: &TitleType,
297 ref_type: Option<&str>,
298 language: Option<&str>,
299 config: &Config,
300) -> Option<Rendering> {
301 get_title_category_title_rendering(title_type, ref_type, language, config)
302 .map(|rendering| rendering.to_rendering())
303}
304
305#[must_use]
310pub fn get_title_category_title_rendering(
311 title_type: &TitleType,
312 ref_type: Option<&str>,
313 language: Option<&str>,
314 config: &Config,
315) -> Option<TitleRendering> {
316 let titles_config = config.titles.as_ref()?;
317
318 let mapped_category = ref_type.and_then(|rt| titles_config.type_mapping.get(rt));
320
321 use crate::values::type_class::TitleCategory;
322
323 let rendering = match title_type {
324 TitleType::ContainerTitle => {
325 if let Some(cat) = mapped_category {
326 match cat.as_str() {
327 "periodical" => titles_config.periodical.as_ref(),
328 "serial" => titles_config.serial.as_ref(),
329 "monograph" | "collection" => titles_config
330 .container_monograph
331 .as_ref()
332 .or(titles_config.monograph.as_ref()),
333 _ => titles_config.default.as_ref(),
334 }
335 } else if let Some(rt) = ref_type {
336 match crate::values::type_class::container_title_category(rt) {
337 TitleCategory::Periodical => titles_config.periodical.as_ref(),
338 TitleCategory::ContainerMonograph => titles_config
339 .container_monograph
340 .as_ref()
341 .or(titles_config.monograph.as_ref()),
342 _ => titles_config.default.as_ref(),
343 }
344 } else {
345 titles_config.default.as_ref()
346 }
347 }
348 TitleType::ParentSerial => {
349 if let Some(cat) = mapped_category {
350 match cat.as_str() {
351 "periodical" => titles_config.periodical.as_ref(),
352 "serial" => titles_config.serial.as_ref(),
353 _ => titles_config.periodical.as_ref(),
354 }
355 } else if let Some(rt) = ref_type {
356 match crate::values::type_class::parent_serial_title_category(rt) {
357 TitleCategory::Periodical => titles_config.periodical.as_ref(),
358 _ => titles_config.serial.as_ref(),
359 }
360 } else {
361 titles_config.periodical.as_ref()
362 }
363 }
364 TitleType::ParentMonograph => titles_config
365 .container_monograph
366 .as_ref()
367 .or(titles_config.monograph.as_ref()),
368 TitleType::CollectionTitle => titles_config
369 .container_monograph
370 .as_ref()
371 .or(titles_config.monograph.as_ref())
372 .or(titles_config.default.as_ref()),
373 TitleType::Primary => {
374 if let Some(cat) = mapped_category {
375 match cat.as_str() {
376 "component" => titles_config.component.as_ref(),
377 "monograph" => titles_config.monograph.as_ref(),
378 _ => titles_config.default.as_ref(),
379 }
380 } else if let Some(rt) = ref_type {
381 match crate::values::type_class::title_category(rt) {
382 TitleCategory::Component => titles_config.component.as_ref(),
383 TitleCategory::Monograph => titles_config.monograph.as_ref(),
384 _ => titles_config.default.as_ref(),
385 }
386 } else {
387 titles_config.default.as_ref()
388 }
389 }
390 _ => None,
391 };
392
393 let selected = rendering.or(titles_config.default.as_ref())?;
394 let mut effective = selected.clone();
395 if let Some(override_rendering) = selected.locale_override(language) {
396 effective.merge(override_rendering);
397 }
398 Some(effective)
399}
400
401#[cfg(test)]
402#[allow(
403 clippy::unwrap_used,
404 clippy::expect_used,
405 clippy::panic,
406 clippy::indexing_slicing,
407 clippy::todo,
408 clippy::unimplemented,
409 clippy::unreachable,
410 clippy::get_unwrap,
411 reason = "Panicking is acceptable and often desired in tests."
412)]
413mod tests {
414 use super::*;
415 use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
416
417 #[test]
418 fn test_render_with_emphasis() {
419 let component = ProcTemplateComponent {
420 template_component: TemplateComponent::Title(TemplateTitle {
421 title: TitleType::Primary,
422 rendering: Rendering {
423 emph: Some(true),
424 ..Default::default()
425 },
426 ..Default::default()
427 }),
428 value: "The Structure of Scientific Revolutions".to_string(),
429 ..Default::default()
430 };
431
432 let result = render_component(&component);
433 assert_eq!(result, "_The Structure of Scientific Revolutions_");
434 }
435
436 #[test]
437 fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
438 use citum_schema::template::{WrapConfig, WrapPunctuation};
439
440 let component = ProcTemplateComponent {
443 template_component: TemplateComponent::Title(TemplateTitle {
444 title: TitleType::Primary,
445 rendering: Rendering {
446 quote: Some(true),
447 wrap: Some(WrapConfig {
448 punctuation: WrapPunctuation::Quotes,
449 inner_prefix: None,
450 inner_suffix: None,
451 }),
452 ..Default::default()
453 },
454 ..Default::default()
455 }),
456 value: "The Structure of Scientific Revolutions".to_string(),
457 ..Default::default()
458 };
459
460 let result = render_component(&component);
461 assert_eq!(
462 result,
463 "\u{201C}The Structure of Scientific Revolutions\u{201D}"
464 );
465 }
466
467 #[test]
468 fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
469 use citum_schema::template::{WrapConfig, WrapPunctuation};
470
471 let component = ProcTemplateComponent {
474 template_component: TemplateComponent::Title(TemplateTitle {
475 title: TitleType::Primary,
476 rendering: Rendering {
477 quote: Some(true),
478 wrap: Some(WrapConfig {
479 punctuation: WrapPunctuation::Parentheses,
480 inner_prefix: None,
481 inner_suffix: None,
482 }),
483 ..Default::default()
484 },
485 ..Default::default()
486 }),
487 value: "Title".to_string(),
488 ..Default::default()
489 };
490
491 let result = render_component(&component);
492 assert_eq!(result, "(\u{201C}Title\u{201D})");
493 }
494}