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 _ => None,
96 }
97}
98
99#[must_use]
101pub fn render_component(component: &ProcTemplateComponent) -> String {
102 PlainText.finish(render_component_with_format::<PlainText>(component))
103}
104
105#[must_use]
107pub fn render_component_with_format<F: OutputFormat<Output = String>>(
108 component: &ProcTemplateComponent,
109) -> F::Output {
110 render_component_with_format_and_renderer::<F>(component, &F::default(), true)
111}
112
113pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
115 component: &ProcTemplateComponent,
116 fmt: &F,
117 show_semantics: bool,
118) -> F::Output {
119 let rendering = get_effective_rendering(component);
121
122 if rendering.suppress == Some(true) {
124 return fmt.text("");
125 }
126
127 let prefix = rendering.prefix.as_deref().unwrap_or_default();
128 let suffix = rendering.suffix.as_deref().unwrap_or_default();
129 let inner_prefix = rendering
130 .wrap
131 .as_ref()
132 .and_then(|w| w.inner_prefix.as_deref())
133 .unwrap_or_default();
134 let inner_suffix = rendering
135 .wrap
136 .as_ref()
137 .and_then(|w| w.inner_suffix.as_deref())
138 .unwrap_or_default();
139
140 let mut output = if component.pre_formatted {
141 fmt.join(vec![component.value.clone()], "")
144 } else {
145 fmt.text(&component.value)
146 };
147
148 if rendering.emph == Some(true) {
158 output = fmt.emph(output);
159 }
160 if rendering.strong == Some(true) {
161 output = fmt.strong(output);
162 }
163 if rendering.small_caps == Some(true) {
164 output = fmt.small_caps(output);
165 }
166 if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
167 output = fmt.superscript(output);
168 }
169 let wrapped_in_quotes = rendering
173 .wrap
174 .as_ref()
175 .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
176 if rendering.quote == Some(true) && !wrapped_in_quotes {
177 output = fmt.quote(output);
178 }
179
180 if let Some(url) = &component.url {
182 output = fmt.link(url, output);
183 }
184
185 let total_inner_prefix = format!(
187 "{}{}",
188 inner_prefix,
189 component.prefix.as_deref().unwrap_or_default()
190 );
191 let total_inner_suffix = format!(
192 "{}{}",
193 component.suffix.as_deref().unwrap_or_default(),
194 inner_suffix
195 );
196
197 if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
198 output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
199 }
200
201 if let Some(wrap_config) = rendering.wrap.as_ref() {
203 output = fmt.wrap_punctuation(&wrap_config.punctuation, output);
204 }
205
206 if !prefix.is_empty() || !suffix.is_empty() {
208 output = fmt.affix(prefix, output, suffix);
209 }
210
211 if show_semantics && let Some(class) = resolve_semantic_class(component) {
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 output = fmt.semantic_with_attributes(&class, output, &semantic_attributes);
223 }
224
225 output
226}
227
228#[must_use]
230pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
231 let mut effective = Rendering::default();
232
233 if let Some(config) = &component.config {
235 match &component.template_component {
236 TemplateComponent::Title(t) => {
237 if let Some(global_title) = get_title_category_rendering(
238 &t.title,
239 component.ref_type.as_deref(),
240 component.item_language.as_deref(),
241 config,
242 ) {
243 effective.merge(&global_title);
244 }
245 }
246 TemplateComponent::Contributor(c) => {
247 if let Some(contributors_config) = &config.contributors
248 && let Some(role_config) = &contributors_config.role
249 && let Some(role_rendering) = role_config.role_rendering(&c.contributor)
250 {
251 effective.merge(&role_rendering.to_rendering());
252 }
253 }
254 _ => {}
256 }
257 }
258
259 effective.merge(component.template_component.rendering());
261
262 if component.ref_type.as_deref() == Some("dataset")
263 && component.value.starts_with('[')
264 && matches!(
265 component.template_component,
266 TemplateComponent::Title(TemplateTitle {
267 title: TitleType::Primary,
268 ..
269 })
270 )
271 && effective.suffix.as_deref() == Some(" [Dataset].")
272 {
273 effective.suffix = Some(".".to_string());
274 }
275
276 effective
277}
278
279#[must_use]
284pub fn get_title_category_rendering(
285 title_type: &TitleType,
286 ref_type: Option<&str>,
287 language: Option<&str>,
288 config: &Config,
289) -> Option<Rendering> {
290 let titles_config = config.titles.as_ref()?;
291
292 let mapped_category = ref_type.and_then(|rt| titles_config.type_mapping.get(rt));
294
295 let rendering = match title_type {
296 TitleType::ContainerTitle => {
297 if let Some(cat) = mapped_category {
298 match cat.as_str() {
299 "periodical" => titles_config.periodical.as_ref(),
300 "serial" => titles_config.serial.as_ref(),
301 "monograph" | "collection" => titles_config
302 .container_monograph
303 .as_ref()
304 .or(titles_config.monograph.as_ref()),
305 _ => titles_config.default.as_ref(),
306 }
307 } else if let Some(rt) = ref_type {
308 if matches!(
309 rt,
310 "article-journal" | "article-magazine" | "article-newspaper" | "broadcast"
311 ) {
312 titles_config.periodical.as_ref()
313 } else if matches!(rt, "chapter" | "paper-conference") {
314 titles_config
315 .container_monograph
316 .as_ref()
317 .or(titles_config.monograph.as_ref())
318 } else {
319 titles_config.default.as_ref()
320 }
321 } else {
322 titles_config.default.as_ref()
323 }
324 }
325 TitleType::ParentSerial => {
326 if let Some(cat) = mapped_category {
327 match cat.as_str() {
328 "periodical" => titles_config.periodical.as_ref(),
329 "serial" => titles_config.serial.as_ref(),
330 _ => titles_config.periodical.as_ref(),
331 }
332 } else if let Some(rt) = ref_type {
333 if matches!(
334 rt,
335 "article-journal" | "article-magazine" | "article-newspaper"
336 ) {
337 titles_config.periodical.as_ref()
338 } else {
339 titles_config.serial.as_ref()
340 }
341 } else {
342 titles_config.periodical.as_ref()
343 }
344 }
345 TitleType::ParentMonograph => titles_config
346 .container_monograph
347 .as_ref()
348 .or(titles_config.monograph.as_ref()),
349 TitleType::CollectionTitle => titles_config
350 .container_monograph
351 .as_ref()
352 .or(titles_config.monograph.as_ref())
353 .or(titles_config.default.as_ref()),
354 TitleType::Primary => {
355 if let Some(cat) = mapped_category {
356 match cat.as_str() {
357 "component" => titles_config.component.as_ref(),
358 "monograph" => titles_config.monograph.as_ref(),
359 _ => titles_config.default.as_ref(),
360 }
361 } else if let Some(rt) = ref_type {
362 if matches!(
365 rt,
366 "article-journal"
367 | "article-magazine"
368 | "article-newspaper"
369 | "chapter"
370 | "entry"
371 | "entry-dictionary"
372 | "entry-encyclopedia"
373 | "paper-conference"
374 | "post"
375 | "post-weblog"
376 ) {
377 titles_config.component.as_ref()
378 } else if matches!(rt, "book" | "thesis" | "report") {
379 titles_config.monograph.as_ref()
380 } else {
381 titles_config.default.as_ref()
382 }
383 } else {
384 titles_config.default.as_ref()
385 }
386 }
387 _ => None,
388 };
389
390 let selected = rendering.or(titles_config.default.as_ref())?;
391 let mut effective = selected.to_rendering();
392 if let Some(override_rendering) = selected.locale_override(language) {
393 effective.merge(&override_rendering.to_rendering());
394 }
395 Some(effective)
396}
397
398#[cfg(test)]
399#[allow(
400 clippy::unwrap_used,
401 clippy::expect_used,
402 clippy::panic,
403 clippy::indexing_slicing,
404 clippy::todo,
405 clippy::unimplemented,
406 clippy::unreachable,
407 clippy::get_unwrap,
408 reason = "Panicking is acceptable and often desired in tests."
409)]
410mod tests {
411 use super::*;
412 use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
413
414 #[test]
415 fn test_render_with_emphasis() {
416 let component = ProcTemplateComponent {
417 template_component: TemplateComponent::Title(TemplateTitle {
418 title: TitleType::Primary,
419 rendering: Rendering {
420 emph: Some(true),
421 ..Default::default()
422 },
423 ..Default::default()
424 }),
425 value: "The Structure of Scientific Revolutions".to_string(),
426 ..Default::default()
427 };
428
429 let result = render_component(&component);
430 assert_eq!(result, "_The Structure of Scientific Revolutions_");
431 }
432
433 #[test]
434 fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
435 use citum_schema::template::{WrapConfig, WrapPunctuation};
436
437 let component = ProcTemplateComponent {
440 template_component: TemplateComponent::Title(TemplateTitle {
441 title: TitleType::Primary,
442 rendering: Rendering {
443 quote: Some(true),
444 wrap: Some(WrapConfig {
445 punctuation: WrapPunctuation::Quotes,
446 inner_prefix: None,
447 inner_suffix: None,
448 }),
449 ..Default::default()
450 },
451 ..Default::default()
452 }),
453 value: "The Structure of Scientific Revolutions".to_string(),
454 ..Default::default()
455 };
456
457 let result = render_component(&component);
458 assert_eq!(
459 result,
460 "\u{201C}The Structure of Scientific Revolutions\u{201D}"
461 );
462 }
463
464 #[test]
465 fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
466 use citum_schema::template::{WrapConfig, WrapPunctuation};
467
468 let component = ProcTemplateComponent {
471 template_component: TemplateComponent::Title(TemplateTitle {
472 title: TitleType::Primary,
473 rendering: Rendering {
474 quote: Some(true),
475 wrap: Some(WrapConfig {
476 punctuation: WrapPunctuation::Parentheses,
477 inner_prefix: None,
478 inner_suffix: None,
479 }),
480 ..Default::default()
481 },
482 ..Default::default()
483 }),
484 value: "Title".to_string(),
485 ..Default::default()
486 };
487
488 let result = render_component(&component);
489 assert_eq!(result, "(\u{201C}Title\u{201D})");
490 }
491}