fleuron/style/properties/
counter.rs1use serde::Serialize;
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)]
9pub struct CounterReset {
10 #[serde(skip_serializing_if = "Option::is_none")]
12 pub page: Option<u32>,
13 #[serde(skip_serializing_if = "Option::is_none")]
17 pub note: Option<u32>,
18}
19
20impl CounterReset {
21 pub fn is_none(self) -> bool {
23 self.page.is_none() && self.note.is_none()
24 }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Content {
31 None,
33 Counter(CounterStyle),
35 String(String),
38 Text(String),
40 Pieces(Vec<ContentPiece>),
44}
45
46impl Content {
47 pub fn counts_pages(&self) -> bool {
50 match self {
51 Content::Pieces(pieces) => pieces
52 .iter()
53 .any(|piece| matches!(piece, ContentPiece::TargetCounter { .. })),
54 _ => false,
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum ContentPiece {
63 Text(String),
65 TargetCounter {
68 target: Target,
70 style: CounterStyle,
72 },
73 TargetText {
75 target: Target,
77 },
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum Target {
84 Href,
87 Url(String),
89}
90
91impl Target {
92 pub fn url<'a>(&'a self, href: Option<&'a str>) -> Option<&'a str> {
95 match self {
96 Target::Href => href,
97 Target::Url(url) => Some(url),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
104#[serde(rename_all = "kebab-case")]
105pub enum CounterStyle {
106 Decimal,
108 LowerRoman,
110 UpperRoman,
112 LowerAlpha,
114 UpperAlpha,
116}
117
118impl CounterStyle {
119 pub const ALL: [CounterStyle; 5] = [
121 CounterStyle::Decimal,
122 CounterStyle::LowerRoman,
123 CounterStyle::UpperRoman,
124 CounterStyle::LowerAlpha,
125 CounterStyle::UpperAlpha,
126 ];
127
128 pub fn keyword(self) -> &'static str {
130 match self {
131 CounterStyle::Decimal => "decimal",
132 CounterStyle::LowerRoman => "lower-roman",
133 CounterStyle::UpperRoman => "upper-roman",
134 CounterStyle::LowerAlpha => "lower-alpha",
135 CounterStyle::UpperAlpha => "upper-alpha",
136 }
137 }
138
139 pub fn parse(keyword: &str) -> Option<CounterStyle> {
142 CounterStyle::ALL
143 .into_iter()
144 .find(|style| style.keyword().eq_ignore_ascii_case(keyword))
145 }
146
147 pub fn format(self, value: u32) -> String {
151 match self {
152 CounterStyle::Decimal => value.to_string(),
153 CounterStyle::LowerRoman => roman(value).unwrap_or_else(|| value.to_string()),
154 CounterStyle::UpperRoman => roman(value)
155 .map(|numeral| numeral.to_uppercase())
156 .unwrap_or_else(|| value.to_string()),
157 CounterStyle::LowerAlpha => alpha(value).unwrap_or_else(|| value.to_string()),
158 CounterStyle::UpperAlpha => alpha(value)
159 .map(|letters| letters.to_uppercase())
160 .unwrap_or_else(|| value.to_string()),
161 }
162 }
163}
164
165fn roman(value: u32) -> Option<String> {
167 const NUMERALS: [(u32, &str); 13] = [
168 (1000, "m"),
169 (900, "cm"),
170 (500, "d"),
171 (400, "cd"),
172 (100, "c"),
173 (90, "xc"),
174 (50, "l"),
175 (40, "xl"),
176 (10, "x"),
177 (9, "ix"),
178 (5, "v"),
179 (4, "iv"),
180 (1, "i"),
181 ];
182 if !(1..4000).contains(&value) {
183 return None;
184 }
185 let mut left = value;
186 let mut numeral = String::new();
187 for (amount, digits) in NUMERALS {
188 while left >= amount {
189 numeral.push_str(digits);
190 left -= amount;
191 }
192 }
193 Some(numeral)
194}
195
196fn alpha(value: u32) -> Option<String> {
198 if value == 0 {
199 return None;
200 }
201 let mut left = value;
202 let mut letters = Vec::new();
203 while left > 0 {
204 let digit = (left - 1) % 26;
205 letters.push(b'a' + digit as u8);
206 left = (left - 1) / 26;
207 }
208 letters.reverse();
209 Some(String::from_utf8(letters).expect("ascii letters"))
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
214#[serde(rename_all = "snake_case")]
215pub enum ListStyleType {
216 None,
218 Disc,
220 Circle,
222 Square,
224 Counter(CounterStyle),
226}
227
228impl ListStyleType {
229 pub fn marker(self, number: u32) -> Option<String> {
232 match self {
233 ListStyleType::None => None,
234 ListStyleType::Disc => Some("\u{2022} ".into()),
235 ListStyleType::Circle => Some("\u{25E6} ".into()),
236 ListStyleType::Square => Some("\u{25A0} ".into()),
237 ListStyleType::Counter(style) => Some(format!("{}. ", style.format(number))),
238 }
239 }
240
241 pub fn numeral(self, number: u32) -> Option<String> {
244 match self {
245 ListStyleType::None => None,
246 ListStyleType::Disc => Some("\u{2022}".into()),
247 ListStyleType::Circle => Some("\u{25E6}".into()),
248 ListStyleType::Square => Some("\u{25A0}".into()),
249 ListStyleType::Counter(style) => Some(style.format(number)),
250 }
251 }
252
253 pub fn keyword(self) -> &'static str {
255 match self {
256 ListStyleType::None => "none",
257 ListStyleType::Disc => "disc",
258 ListStyleType::Circle => "circle",
259 ListStyleType::Square => "square",
260 ListStyleType::Counter(style) => style.keyword(),
261 }
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
268pub struct StringSet {
269 pub name: String,
271 pub value: Vec<StringPiece>,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
277#[serde(rename_all = "snake_case")]
278pub enum StringPiece {
279 Content,
281 Text(String),
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
292 fn a_marker_is_its_glyph_or_its_number_and_a_space() {
293 assert_eq!(ListStyleType::Disc.marker(3).as_deref(), Some("\u{2022} "));
294 assert_eq!(
295 ListStyleType::Counter(CounterStyle::Decimal)
296 .marker(7)
297 .as_deref(),
298 Some("7. ")
299 );
300 assert_eq!(
301 ListStyleType::Counter(CounterStyle::UpperRoman)
302 .marker(9)
303 .as_deref(),
304 Some("IX. ")
305 );
306 assert_eq!(ListStyleType::None.marker(1), None);
307 }
308}