1use citum_schema::options::{Config, bibliography::BibliographyConfig};
7use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
8
9#[derive(Debug, Clone, Default, PartialEq)]
11pub struct ProcTemplateComponent {
12 pub template_component: TemplateComponent,
14 pub template_index: Option<usize>,
16 pub value: String,
18 pub prefix: Option<String>,
20 pub suffix: Option<String>,
22 pub url: Option<String>,
24 pub ref_type: Option<String>,
26 pub config: Option<Config>,
28 pub bibliography_config: Option<BibliographyConfig>,
30 pub item_language: Option<String>,
32 pub sentence_initial: bool,
34 pub pre_formatted: bool,
36}
37
38pub type ProcTemplate = Vec<ProcTemplateComponent>;
40
41#[derive(Debug, Clone, Default, PartialEq)]
43pub struct ProcEntry {
44 pub id: String,
46 pub template: ProcTemplate,
48 pub metadata: super::format::ProcEntryMetadata,
50}
51
52use super::format::{OutputFormat, SemanticAttribute};
53use super::plain::PlainText;
54
55fn resolve_semantic_class(component: &ProcTemplateComponent) -> Option<String> {
57 use citum_schema::template::{DateVariable, SimpleVariable};
58 match &component.template_component {
59 TemplateComponent::Title(t) => match t.title {
60 TitleType::Primary => Some("citum-title".to_string()),
61 TitleType::ContainerTitle
62 | TitleType::ParentMonograph
63 | TitleType::ParentSerial
64 | TitleType::CollectionTitle => Some("citum-container-title".to_string()),
65 _ => Some("citum-title".to_string()),
66 },
67 TemplateComponent::Contributor(c) => Some(format!("citum-{}", c.contributor.as_str())),
68 TemplateComponent::Date(d) => Some(format!(
69 "citum-{}",
70 match d.date {
71 DateVariable::Issued => "issued",
72 DateVariable::Accessed => "accessed",
73 DateVariable::OriginalPublished => "original-published",
74 DateVariable::Submitted => "submitted",
75 DateVariable::EventDate => "event-date",
76 }
77 )),
78 TemplateComponent::Number(n) => Some(format!("citum-{}", n.number.as_key())),
79 TemplateComponent::Variable(v) => Some(format!(
80 "citum-{}",
81 match v.variable {
82 SimpleVariable::Doi => "doi",
83 SimpleVariable::Url => "url",
84 SimpleVariable::Isbn => "isbn",
85 SimpleVariable::Issn => "issn",
86 SimpleVariable::Pmid => "pmid",
87 SimpleVariable::Note => "note",
88 SimpleVariable::Publisher => "publisher",
89 SimpleVariable::PublisherPlace => "publisher-place",
90 SimpleVariable::ContainerTitleShort => "container-title-short",
91 SimpleVariable::Archive => "archive",
92 _ => "variable",
93 }
94 )),
95 TemplateComponent::Message(m) => Some(format!(
96 "citum-message-{}",
97 m.message
98 .chars()
99 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
100 .collect::<String>()
101 .trim_matches('-')
102 )),
103 _ => None,
104 }
105}
106
107#[must_use]
109pub fn render_component(component: &ProcTemplateComponent) -> String {
110 PlainText.finish(render_component_with_format::<PlainText>(component))
111}
112
113#[must_use]
115pub fn render_component_with_format<F: OutputFormat<Output = String>>(
116 component: &ProcTemplateComponent,
117) -> F::Output {
118 render_component_with_format_and_renderer::<F>(component, &F::default(), true)
119}
120
121pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
123 component: &ProcTemplateComponent,
124 fmt: &F,
125 show_semantics: bool,
126) -> F::Output {
127 let rendering = get_effective_rendering(component);
129
130 if rendering.suppress == Some(true) {
132 return fmt.text("");
133 }
134
135 let prefix = rendering.prefix.as_deref().unwrap_or_default();
136 let suffix = rendering.suffix.as_deref().unwrap_or_default();
137 let inner_prefix = rendering
138 .wrap
139 .as_ref()
140 .and_then(|w| w.inner_prefix.as_deref())
141 .unwrap_or_default();
142 let inner_suffix = rendering
143 .wrap
144 .as_ref()
145 .and_then(|w| w.inner_suffix.as_deref())
146 .unwrap_or_default();
147
148 let mut output = if component.pre_formatted {
149 fmt.join(vec![component.value.clone()], "")
152 } else {
153 fmt.text(&component.value)
154 };
155
156 if rendering.emph == Some(true) {
166 output = fmt.emph(output);
167 }
168 if rendering.strong == Some(true) {
169 output = fmt.strong(output);
170 }
171 if rendering.small_caps == Some(true) {
172 output = fmt.small_caps(output);
173 }
174 if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
175 output = fmt.superscript(output);
176 }
177 let wrapped_in_quotes = rendering
181 .wrap
182 .as_ref()
183 .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
184 if rendering.quote == Some(true) && !wrapped_in_quotes {
185 output = fmt.quote(output);
186 }
187
188 if let Some(url) = &component.url {
190 output = fmt.link(url, output);
191 }
192
193 let total_inner_prefix = format!(
195 "{}{}",
196 inner_prefix,
197 component.prefix.as_deref().unwrap_or_default()
198 );
199 let total_inner_suffix = format!(
200 "{}{}",
201 component.suffix.as_deref().unwrap_or_default(),
202 inner_suffix
203 );
204
205 if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
206 output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
207 }
208
209 if let Some(wrap_config) = rendering.wrap.as_ref() {
211 output = fmt.wrap_punctuation(&wrap_config.punctuation, output);
212 }
213
214 if !prefix.is_empty() || !suffix.is_empty() {
216 output = fmt.affix(prefix, output, suffix);
217 }
218
219 if show_semantics && let Some(class) = resolve_semantic_class(component) {
221 let semantic_attributes = component
222 .template_index
223 .map(|index| {
224 vec![SemanticAttribute {
225 name: "data-index",
226 value: index.to_string(),
227 }]
228 })
229 .unwrap_or_default();
230 output = fmt.semantic_with_attributes(&class, output, &semantic_attributes);
231 }
232
233 output
234}
235
236#[must_use]
238pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
239 let mut effective = Rendering::default();
240
241 if let Some(config) = &component.config {
243 match &component.template_component {
244 TemplateComponent::Title(t) => {
245 if let Some(global_title) = get_title_category_rendering(
246 &t.title,
247 component.ref_type.as_deref(),
248 component.item_language.as_deref(),
249 config,
250 ) {
251 effective.merge(&global_title);
252 }
253 }
254 TemplateComponent::Contributor(c) => {
255 if let Some(contributors_config) = &config.contributors
256 && let Some(role_config) = &contributors_config.role
257 && let Some(role_rendering) = role_config.role_rendering(&c.contributor)
258 {
259 effective.merge(&role_rendering.to_rendering());
260 }
261 }
262 _ => {}
264 }
265 }
266
267 effective.merge(component.template_component.rendering());
269
270 if component.ref_type.as_deref() == Some("dataset")
271 && component.value.starts_with('[')
272 && matches!(
273 component.template_component,
274 TemplateComponent::Title(TemplateTitle {
275 title: TitleType::Primary,
276 ..
277 })
278 )
279 && effective.suffix.as_deref() == Some(" [Dataset].")
280 {
281 effective.suffix = Some(".".to_string());
282 }
283
284 effective
285}
286
287#[must_use]
292pub fn get_title_category_rendering(
293 title_type: &TitleType,
294 ref_type: Option<&str>,
295 language: Option<&str>,
296 config: &Config,
297) -> Option<Rendering> {
298 let titles_config = config.titles.as_ref()?;
299
300 let mapped_category = ref_type.and_then(|rt| titles_config.type_mapping.get(rt));
302
303 let rendering = match title_type {
304 TitleType::ContainerTitle => {
305 if let Some(cat) = mapped_category {
306 match cat.as_str() {
307 "periodical" => titles_config.periodical.as_ref(),
308 "serial" => titles_config.serial.as_ref(),
309 "monograph" | "collection" => titles_config
310 .container_monograph
311 .as_ref()
312 .or(titles_config.monograph.as_ref()),
313 _ => titles_config.default.as_ref(),
314 }
315 } else if let Some(rt) = ref_type {
316 if matches!(
317 rt,
318 "article-journal" | "article-magazine" | "article-newspaper" | "broadcast"
319 ) {
320 titles_config.periodical.as_ref()
321 } else if matches!(rt, "chapter" | "paper-conference") {
322 titles_config
323 .container_monograph
324 .as_ref()
325 .or(titles_config.monograph.as_ref())
326 } else {
327 titles_config.default.as_ref()
328 }
329 } else {
330 titles_config.default.as_ref()
331 }
332 }
333 TitleType::ParentSerial => {
334 if let Some(cat) = mapped_category {
335 match cat.as_str() {
336 "periodical" => titles_config.periodical.as_ref(),
337 "serial" => titles_config.serial.as_ref(),
338 _ => titles_config.periodical.as_ref(),
339 }
340 } else if let Some(rt) = ref_type {
341 if matches!(
342 rt,
343 "article-journal" | "article-magazine" | "article-newspaper"
344 ) {
345 titles_config.periodical.as_ref()
346 } else {
347 titles_config.serial.as_ref()
348 }
349 } else {
350 titles_config.periodical.as_ref()
351 }
352 }
353 TitleType::ParentMonograph => titles_config
354 .container_monograph
355 .as_ref()
356 .or(titles_config.monograph.as_ref()),
357 TitleType::CollectionTitle => titles_config
358 .container_monograph
359 .as_ref()
360 .or(titles_config.monograph.as_ref())
361 .or(titles_config.default.as_ref()),
362 TitleType::Primary => {
363 if let Some(cat) = mapped_category {
364 match cat.as_str() {
365 "component" => titles_config.component.as_ref(),
366 "monograph" => titles_config.monograph.as_ref(),
367 _ => titles_config.default.as_ref(),
368 }
369 } else if let Some(rt) = ref_type {
370 if matches!(
373 rt,
374 "article-journal"
375 | "article-magazine"
376 | "article-newspaper"
377 | "chapter"
378 | "entry"
379 | "entry-dictionary"
380 | "entry-encyclopedia"
381 | "paper-conference"
382 | "post"
383 | "post-weblog"
384 ) {
385 titles_config.component.as_ref()
386 } else if matches!(rt, "book" | "thesis" | "report") {
387 titles_config.monograph.as_ref()
388 } else {
389 titles_config.default.as_ref()
390 }
391 } else {
392 titles_config.default.as_ref()
393 }
394 }
395 _ => None,
396 };
397
398 let selected = rendering.or(titles_config.default.as_ref())?;
399 let mut effective = selected.to_rendering();
400 if let Some(override_rendering) = selected.locale_override(language) {
401 effective.merge(&override_rendering.to_rendering());
402 }
403 Some(effective)
404}
405
406#[cfg(test)]
407#[allow(
408 clippy::unwrap_used,
409 clippy::expect_used,
410 clippy::panic,
411 clippy::indexing_slicing,
412 clippy::todo,
413 clippy::unimplemented,
414 clippy::unreachable,
415 clippy::get_unwrap,
416 reason = "Panicking is acceptable and often desired in tests."
417)]
418mod tests {
419 use super::*;
420 use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
421
422 #[test]
423 fn test_render_with_emphasis() {
424 let component = ProcTemplateComponent {
425 template_component: TemplateComponent::Title(TemplateTitle {
426 title: TitleType::Primary,
427 rendering: Rendering {
428 emph: Some(true),
429 ..Default::default()
430 },
431 ..Default::default()
432 }),
433 value: "The Structure of Scientific Revolutions".to_string(),
434 ..Default::default()
435 };
436
437 let result = render_component(&component);
438 assert_eq!(result, "_The Structure of Scientific Revolutions_");
439 }
440
441 #[test]
442 fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
443 use citum_schema::template::{WrapConfig, WrapPunctuation};
444
445 let component = ProcTemplateComponent {
448 template_component: TemplateComponent::Title(TemplateTitle {
449 title: TitleType::Primary,
450 rendering: Rendering {
451 quote: Some(true),
452 wrap: Some(WrapConfig {
453 punctuation: WrapPunctuation::Quotes,
454 inner_prefix: None,
455 inner_suffix: None,
456 }),
457 ..Default::default()
458 },
459 ..Default::default()
460 }),
461 value: "The Structure of Scientific Revolutions".to_string(),
462 ..Default::default()
463 };
464
465 let result = render_component(&component);
466 assert_eq!(
467 result,
468 "\u{201C}The Structure of Scientific Revolutions\u{201D}"
469 );
470 }
471
472 #[test]
473 fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
474 use citum_schema::template::{WrapConfig, WrapPunctuation};
475
476 let component = ProcTemplateComponent {
479 template_component: TemplateComponent::Title(TemplateTitle {
480 title: TitleType::Primary,
481 rendering: Rendering {
482 quote: Some(true),
483 wrap: Some(WrapConfig {
484 punctuation: WrapPunctuation::Parentheses,
485 inner_prefix: None,
486 inner_suffix: None,
487 }),
488 ..Default::default()
489 },
490 ..Default::default()
491 }),
492 value: "Title".to_string(),
493 ..Default::default()
494 };
495
496 let result = render_component(&component);
497 assert_eq!(result, "(\u{201C}Title\u{201D})");
498 }
499}