kobo_core/html_text/
style.rs1use std::collections::{HashMap, HashSet};
16
17pub type IndentMap = HashMap<String, f32>;
19
20pub type NoMarkerSet = HashSet<String>;
22
23#[derive(Debug, Clone, Default)]
29pub struct BookStyle {
30 pub indents: IndentMap,
31 pub no_marker: NoMarkerSet,
33}
34
35impl BookStyle {
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn from_indents(indents: IndentMap) -> Self {
42 Self {
43 indents,
44 no_marker: NoMarkerSet::new(),
45 }
46 }
47
48 pub fn extend(&mut self, other: BookStyle) {
51 self.indents.extend(other.indents);
52 self.no_marker.extend(other.no_marker);
53 }
54}
55
56pub const MAX_INDENT_EM: f32 = 12.0;
59
60const PX_PER_EM: f32 = 16.0;
62
63pub fn parse_indents(css: &str) -> IndentMap {
66 parse_book_style(css).indents
67}
68
69pub 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 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
107fn 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
126fn 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
151pub fn inline_list_style_none(style_attr: &str) -> bool {
154 list_style_is_none(style_attr)
155}
156
157fn 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 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 2 | 3 => parse_len_em(parts[1]),
181 _ => None,
182 };
183 }
184 }
185 None
186}
187
188fn 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
205pub 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}