Skip to main content

fleuron/style/properties/
counter.rs

1//! What `content` holds: a counter spelled out, a named string, text
2//! the sheet wrote, or a reference to another element.
3
4use serde::Serialize;
5
6/// What `counter-reset` restarts on one element, and what it
7/// restarts at.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)]
9pub struct CounterReset {
10    /// The folio the page this element opens takes.
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub page: Option<u32>,
13    /// The number the first note under this element takes. On the
14    /// footnote area it is the number the first note on every page
15    /// takes.
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub note: Option<u32>,
18}
19
20impl CounterReset {
21    /// Whether it restarts nothing, which is what `none` says.
22    pub fn is_none(self) -> bool {
23        self.page.is_none() && self.note.is_none()
24    }
25}
26
27/// What a page margin box paints, or what a pseudo-element generates.
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Content {
31    /// Nothing: the box is not generated.
32    None,
33    /// The page's own folio, spelled as the style names.
34    Counter(CounterStyle),
35    /// A running string, at the value it stood at when the page began.
36    /// A string nothing has set yet paints nothing.
37    String(String),
38    /// A literal string.
39    Text(String),
40    /// Literals and references, in the order they are set. Only a
41    /// value with a reference in it comes to this. Strings alone are
42    /// joined into one `Text`.
43    Pieces(Vec<ContentPiece>),
44}
45
46impl Content {
47    /// Whether this prints the page another element lands on, which
48    /// is only known once the book is paginated.
49    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/// One piece of a `content` value that names another element.
60#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum ContentPiece {
63    /// A literal.
64    Text(String),
65    /// `target-counter()`: the folio of the page the target lands on,
66    /// spelled as the style names.
67    TargetCounter {
68        /// The element whose page is printed.
69        target: Target,
70        /// How the folio is spelled.
71        style: CounterStyle,
72    },
73    /// `target-text()`: the text of the target itself.
74    TargetText {
75        /// The element whose text is printed.
76        target: Target,
77    },
78}
79
80/// The element a reference names.
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum Target {
84    /// `attr(href url)`: the url of the link the pseudo-element
85    /// belongs to.
86    Href,
87    /// A url the sheet wrote.
88    Url(String),
89}
90
91impl Target {
92    /// The url this target names, read from `href` where the target is
93    /// the link's own.
94    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/// How a counter's value is spelled.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
104#[serde(rename_all = "kebab-case")]
105pub enum CounterStyle {
106    /// `1`, `2`, `3`
107    Decimal,
108    /// `i`, `ii`, `iii`
109    LowerRoman,
110    /// `I`, `II`, `III`
111    UpperRoman,
112    /// `a`, `b`, `c`
113    LowerAlpha,
114    /// `A`, `B`, `C`
115    UpperAlpha,
116}
117
118impl CounterStyle {
119    /// Every style, in the order the subset lists them.
120    pub const ALL: [CounterStyle; 5] = [
121        CounterStyle::Decimal,
122        CounterStyle::LowerRoman,
123        CounterStyle::UpperRoman,
124        CounterStyle::LowerAlpha,
125        CounterStyle::UpperAlpha,
126    ];
127
128    /// The CSS keyword.
129    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    /// Parses one of the keywords, or `None` for a style outside the
140    /// subset.
141    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    /// One value as this style spells it. A value the style has no
148    /// spelling for — nothing before `i`, nothing past `mmmcmxcix` —
149    /// falls back to decimal, as CSS asks.
150    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
165/// Roman numerals, lowercase, over the range they have spellings for.
166fn 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
196/// Bijective base 26, lowercase: `a`…`z`, `aa`…
197fn 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/// The marker a list item is set with, from `list-style-type`.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
214#[serde(rename_all = "snake_case")]
215pub enum ListStyleType {
216    /// `none`: the item has no marker.
217    None,
218    /// `disc`: a filled circle.
219    Disc,
220    /// `circle`: an open circle.
221    Circle,
222    /// `square`: a filled square.
223    Square,
224    /// The item's number, spelled as the style names.
225    Counter(CounterStyle),
226}
227
228impl ListStyleType {
229    /// The marker of the item numbered `number`, and the space after
230    /// it, as CSS writes a marker. Nothing for `none`.
231    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    /// The mark itself, with no space and no point after it: what a
242    /// note's reference prints. Nothing for `none`.
243    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    /// The CSS keyword.
254    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/// One `string-set` entry: a named string, and what the element sets
266/// it to when the flow reaches it.
267#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
268pub struct StringSet {
269    /// The string's name, as `string()` asks for it.
270    pub name: String,
271    /// The pieces the value is built from, concatenated.
272    pub value: Vec<StringPiece>,
273}
274
275/// One piece of a `string-set` value.
276#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
277#[serde(rename_all = "snake_case")]
278pub enum StringPiece {
279    /// `content()`: the element's own text.
280    Content,
281    /// A literal.
282    Text(String),
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    /// A bullet is a glyph and a space. A number takes the counter
290    /// style's spelling, a period, and a space. `none` is no marker.
291    #[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}