Skip to main content

kobo_core/html_text/
style.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Nayeem Bin Ahsan
3//! Minimal CSS scan: class name -> block left indent, in `em`.
4//!
5//! Calibre-converted technical books do not use `<pre>` for code. They emit a
6//! plain `<p class="calibreN">` per source line and encode the nesting depth as
7//! `margin-left` on that class (1em per level). Dropping it turns Python into a
8//! flat wall of text, so the indent has to survive extraction.
9//!
10//! This is not a CSS engine: it reads top-level rule blocks, keeps the
11//! `margin-left` (or the 4-value `margin` shorthand's left component), and
12//! ignores everything else. Cascade, specificity, and media queries do not
13//! matter here -- Calibre writes one flat class per indent level.
14
15use std::collections::{HashMap, HashSet};
16
17/// class name (without the leading `.`) -> left indent in `em`.
18pub type IndentMap = HashMap<String, f32>;
19
20/// Class names whose rule sets `list-style` / `list-style-type` to `none`.
21pub type NoMarkerSet = HashSet<String>;
22
23/// Everything the extractor reads out of the book's stylesheets.
24///
25/// One struct rather than a widening argument list: the extractor already
26/// threads the indent map through four call levels, and every future style the
27/// scan learns to read would otherwise add another parameter to each of them.
28#[derive(Debug, Clone, Default)]
29pub struct BookStyle {
30    pub indents: IndentMap,
31    /// Lists carrying one of these classes render without bullets or numbers.
32    pub no_marker: NoMarkerSet,
33}
34
35impl BookStyle {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Build from an indent map alone (the pre-`BookStyle` call shape).
41    pub fn from_indents(indents: IndentMap) -> Self {
42        Self {
43            indents,
44            no_marker: NoMarkerSet::new(),
45        }
46    }
47
48    /// Merge another sheet's rules in. Later sheets win on collision, matching
49    /// the order the cascade would apply them.
50    pub fn extend(&mut self, other: BookStyle) {
51        self.indents.extend(other.indents);
52        self.no_marker.extend(other.no_marker);
53    }
54}
55
56/// Blocks indented further than this are almost certainly a stylesheet quirk,
57/// not real nesting; clamping keeps them from eating the whole text column.
58pub const MAX_INDENT_EM: f32 = 12.0;
59
60/// Assumed root font size, for stylesheets that specify indents in px.
61const PX_PER_EM: f32 = 16.0;
62
63/// Parse every `.class { ... }` rule in `css` into a class -> indent-em map.
64/// Rules with no left indent, or a zero/negative one, are omitted.
65pub fn parse_indents(css: &str) -> IndentMap {
66    parse_book_style(css).indents
67}
68
69/// Parse `css` into every style the extractor understands, in one pass over the
70/// rule blocks.
71pub fn parse_book_style(css: &str) -> BookStyle {
72    let mut out = BookStyle::new();
73    let mut rest = css;
74    while let Some(open) = rest.find('{') {
75        let selectors = &rest[..open];
76        let after = &rest[open + 1..];
77        let close = match after.find('}') {
78            Some(c) => c,
79            None => break,
80        };
81        let body = &after[..close];
82        rest = &after[close + 1..];
83        // `@media` / `@font-face` wrappers: skip the block, not the file.
84        if selectors.contains('@') {
85            continue;
86        }
87        let em = match left_indent_em(body) {
88            Some(v) if v > 0.0 => Some(v.min(MAX_INDENT_EM)),
89            _ => None,
90        };
91        let no_marker = list_style_is_none(body);
92        if em.is_none() && !no_marker {
93            continue;
94        }
95        for name in class_names(selectors) {
96            if let Some(v) = em {
97                out.indents.insert(name.clone(), v);
98            }
99            if no_marker {
100                out.no_marker.insert(name);
101            }
102        }
103    }
104    out
105}
106
107/// Bare `.class` selectors in a selector list. Anything compound is beyond what
108/// this scan can resolve without a real cascade, and is skipped.
109fn class_names(selectors: &str) -> Vec<String> {
110    let mut out = Vec::new();
111    for sel in selectors.split(',') {
112        let sel = sel.trim();
113        if let Some(name) = sel.strip_prefix('.') {
114            if !name.is_empty()
115                && name
116                    .chars()
117                    .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
118            {
119                out.push(name.to_string());
120            }
121        }
122    }
123    out
124}
125
126/// Does a rule body suppress the list marker?
127///
128/// Both the `list-style-type` longhand and the `list-style` shorthand are read.
129/// The shorthand also carries position and image, so `none` is matched as a
130/// whole word rather than a substring -- `list-style: none inside` suppresses
131/// the marker, `list-style: square` does not.
132fn list_style_is_none(body: &str) -> bool {
133    for decl in body.split(';') {
134        let Some((prop, value)) = decl.split_once(':') else {
135            continue;
136        };
137        let prop = prop.trim().to_ascii_lowercase();
138        let is_list_style = prop == "list-style-type" || prop == "list-style";
139        if is_list_style
140            && value.split_whitespace().any(|w| {
141                w.trim_end_matches(&[',', '!'][..])
142                    .eq_ignore_ascii_case("none")
143            })
144        {
145            return true;
146        }
147    }
148    false
149}
150
151/// `list-style` declared inline on an element, e.g.
152/// `style="list-style-type: none"`.
153pub fn inline_list_style_none(style_attr: &str) -> bool {
154    list_style_is_none(style_attr)
155}
156
157/// Left indent declared by a rule body, preferring the longhand.
158fn left_indent_em(body: &str) -> Option<f32> {
159    for decl in body.split(';') {
160        let Some((prop, value)) = decl.split_once(':') else {
161            continue;
162        };
163        if prop.trim().eq_ignore_ascii_case("margin-left")
164            || prop.trim().eq_ignore_ascii_case("padding-left")
165        {
166            return parse_len_em(value.trim());
167        }
168    }
169    // 4-value `margin: top right bottom left` shorthand.
170    for decl in body.split(';') {
171        let Some((prop, value)) = decl.split_once(':') else {
172            continue;
173        };
174        if prop.trim().eq_ignore_ascii_case("margin") {
175            let parts: Vec<&str> = value.split_whitespace().collect();
176            return match parts.len() {
177                4 => parse_len_em(parts[3]),
178                // `margin: v h` and `margin: t h b` both put the horizontal
179                // value second.
180                2 | 3 => parse_len_em(parts[1]),
181                _ => None,
182            };
183        }
184    }
185    None
186}
187
188/// A CSS length in `em`, `rem`, `px`, or `pt`. Anything else (`%`, `auto`,
189/// keywords) is not convertible without layout context, so it is dropped.
190fn parse_len_em(s: &str) -> Option<f32> {
191    let s = s.trim();
192    let (num, unit) = s.split_at(
193        s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+'))
194            .unwrap_or(s.len()),
195    );
196    let v: f32 = num.parse().ok()?;
197    match unit.trim().to_ascii_lowercase().as_str() {
198        "em" | "rem" => Some(v),
199        "px" => Some(v / PX_PER_EM),
200        "pt" => Some(v * 4.0 / 3.0 / PX_PER_EM),
201        _ => None,
202    }
203}
204
205/// Indent declared inline on an element, e.g. `style="margin-left: 2em"`.
206pub fn inline_indent_em(style_attr: &str) -> Option<f32> {
207    left_indent_em(style_attr).map(|v| v.clamp(0.0, MAX_INDENT_EM))
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn reads_margin_left_longhand() {
216        let m = parse_indents(".calibre7 { display: block; margin-left: 2em; }");
217        assert_eq!(m.get("calibre7"), Some(&2.0));
218    }
219
220    #[test]
221    fn reads_four_value_margin_shorthand() {
222        let m = parse_indents(".c { margin: 0 0 0 3em; }");
223        assert_eq!(m.get("c"), Some(&3.0));
224    }
225
226    #[test]
227    fn shares_one_rule_across_a_selector_list() {
228        let m = parse_indents(".a, .b { margin-left: 1em }");
229        assert_eq!(m.get("a"), Some(&1.0));
230        assert_eq!(m.get("b"), Some(&1.0));
231    }
232
233    #[test]
234    fn skips_zero_and_unitless_and_percent() {
235        let m =
236            parse_indents(".z { margin-left: 0 } .p { margin-left: 5% } .n { margin-left: -2em }");
237        assert!(m.is_empty());
238    }
239
240    #[test]
241    fn converts_px_to_em() {
242        let m = parse_indents(".c { margin-left: 32px; }");
243        assert_eq!(m.get("c"), Some(&2.0));
244    }
245
246    #[test]
247    fn clamps_absurd_indents() {
248        let m = parse_indents(".c { margin-left: 99em; }");
249        assert_eq!(m.get("c"), Some(&MAX_INDENT_EM));
250    }
251
252    #[test]
253    fn ignores_at_rules_but_keeps_parsing_after_them() {
254        let m = parse_indents("@font-face { src: url(x) } .c { margin-left: 4em }");
255        assert_eq!(m.get("c"), Some(&4.0));
256    }
257
258    #[test]
259    fn ignores_compound_selectors() {
260        let m = parse_indents("div.c p { margin-left: 4em }");
261        assert!(m.is_empty());
262    }
263
264    #[test]
265    fn reads_list_style_type_none() {
266        let s = parse_book_style(".toc { list-style-type: none; }");
267        assert!(s.no_marker.contains("toc"));
268    }
269
270    #[test]
271    fn reads_list_style_shorthand_none() {
272        let s = parse_book_style(".nav { list-style: none inside; }");
273        assert!(s.no_marker.contains("nav"));
274    }
275
276    #[test]
277    fn keeps_marker_for_other_list_styles() {
278        let s = parse_book_style(".a { list-style-type: square } .b { list-style: decimal }");
279        assert!(s.no_marker.is_empty());
280    }
281
282    #[test]
283    fn one_rule_can_carry_both_indent_and_no_marker() {
284        let s = parse_book_style(".toc { margin-left: 2em; list-style: none }");
285        assert_eq!(s.indents.get("toc"), Some(&2.0));
286        assert!(s.no_marker.contains("toc"));
287    }
288
289    #[test]
290    fn inline_list_style_none_detected() {
291        assert!(inline_list_style_none("list-style: none"));
292        assert!(!inline_list_style_none("list-style: disc"));
293    }
294}