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