Skip to main content

cdx_core/extensions/semantic/
citation.rs

1//! Citations and footnotes for academic documents.
2
3use serde::{Deserialize, Serialize};
4
5use crate::content::Block;
6
7/// An inline citation reference.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct Citation {
11    /// Reference to bibliography entry ID.
12    #[serde(rename = "ref")]
13    pub reference: String,
14
15    /// Page or location within the reference.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub locator: Option<String>,
18
19    /// Locator type (page, chapter, section, etc.).
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub locator_type: Option<LocatorType>,
22
23    /// Text before the citation (e.g., "see").
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub prefix: Option<String>,
26
27    /// Text after the citation (e.g., "for details").
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub suffix: Option<String>,
30
31    /// Suppress author name in citation.
32    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
33    pub suppress_author: bool,
34}
35
36impl Citation {
37    /// Create a new citation.
38    #[must_use]
39    pub fn new(reference: impl Into<String>) -> Self {
40        Self {
41            reference: reference.into(),
42            locator: None,
43            locator_type: None,
44            prefix: None,
45            suffix: None,
46            suppress_author: false,
47        }
48    }
49
50    /// Set page locator.
51    #[must_use]
52    pub fn with_page(mut self, page: impl Into<String>) -> Self {
53        self.locator = Some(page.into());
54        self.locator_type = Some(LocatorType::Page);
55        self
56    }
57
58    /// Set locator with type.
59    #[must_use]
60    pub fn with_locator(mut self, locator: impl Into<String>, locator_type: LocatorType) -> Self {
61        self.locator = Some(locator.into());
62        self.locator_type = Some(locator_type);
63        self
64    }
65
66    /// Set prefix text.
67    #[must_use]
68    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
69        self.prefix = Some(prefix.into());
70        self
71    }
72
73    /// Set suffix text.
74    #[must_use]
75    pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
76        self.suffix = Some(suffix.into());
77        self
78    }
79
80    /// Suppress author name.
81    #[must_use]
82    pub const fn suppress_author(mut self) -> Self {
83        self.suppress_author = true;
84        self
85    }
86}
87
88/// Type of locator within a reference.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum LocatorType {
92    /// Page number.
93    Page,
94    /// Chapter number.
95    Chapter,
96    /// Section number.
97    Section,
98    /// Paragraph number.
99    Paragraph,
100    /// Verse number.
101    Verse,
102    /// Line number.
103    Line,
104    /// Figure number.
105    Figure,
106    /// Table number.
107    Table,
108    /// Equation number.
109    Equation,
110    /// Timestamp (for media).
111    Timestamp,
112}
113
114/// A footnote with content blocks.
115///
116/// Per the spec, footnotes support either `content` (plain text) or `children`
117/// (rich content with blocks), but not both on the same footnote.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Footnote {
121    /// Sequential footnote number.
122    pub number: u32,
123
124    /// Optional unique identifier for cross-referencing with footnote marks.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub id: Option<String>,
127
128    /// Simple text content (for footnotes without complex formatting).
129    /// Mutually exclusive with `children`.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub content: Option<String>,
132
133    /// Rich content blocks (paragraph blocks with formatting).
134    /// Mutually exclusive with `content`.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub children: Vec<Block>,
137}
138
139impl Footnote {
140    /// Create a new footnote with the given number.
141    #[must_use]
142    pub fn new(number: u32) -> Self {
143        Self {
144            number,
145            id: None,
146            content: None,
147            children: Vec::new(),
148        }
149    }
150
151    /// Set the unique identifier.
152    #[must_use]
153    pub fn with_id(mut self, id: impl Into<String>) -> Self {
154        self.id = Some(id.into());
155        self
156    }
157
158    /// Set the text content (simple footnotes without formatting).
159    ///
160    /// Note: This is mutually exclusive with `with_children`. If both are
161    /// set, implementations should prefer `children`.
162    #[must_use]
163    pub fn with_content(mut self, content: impl Into<String>) -> Self {
164        self.content = Some(content.into());
165        self
166    }
167
168    /// Set the rich content blocks (footnotes with formatting).
169    ///
170    /// Note: This is mutually exclusive with `with_content`. If both are
171    /// set, implementations should prefer `children`.
172    #[must_use]
173    pub fn with_children(mut self, children: Vec<Block>) -> Self {
174        self.children = children;
175        self
176    }
177
178    /// Check if this footnote has rich content (children).
179    #[must_use]
180    pub fn has_children(&self) -> bool {
181        !self.children.is_empty()
182    }
183
184    /// Check if this footnote has simple content.
185    #[must_use]
186    pub fn has_content(&self) -> bool {
187        self.content.is_some()
188    }
189}