1use std::fmt;
6
7use cssparser::{ParseError, ParseErrorKind, Parser, ParserInput, SourceLocation, Token};
8use pdfboss_write::Color;
9
10use crate::style::{Align, Declared, Decoration, Element, FontFamily, FontSize};
11
12#[derive(Clone, Debug, PartialEq)]
14pub struct StyleError {
15 pub line: u32,
17 pub column: u32,
19 pub message: String,
21}
22
23impl fmt::Display for StyleError {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 write!(f, "line {}:{}: {}", self.line, self.column, self.message)
26 }
27}
28
29impl std::error::Error for StyleError {}
30
31#[derive(Clone, Debug)]
33pub(crate) struct Rule {
34 pub elements: Vec<Element>,
35 pub declared: Declared,
36}
37
38type Failure<'i> = ParseError<'i, String>;
39
40pub(crate) fn parse_sheet(css: &str) -> Result<Vec<Rule>, StyleError> {
44 let mut input = ParserInput::new(css);
45 let mut parser = Parser::new(&mut input);
46 let mut rules = Vec::new();
47 loop {
48 match next_rule(&mut parser) {
49 Ok(Some(rule)) => rules.push(rule),
50 Ok(None) => return Ok(rules),
51 Err(e) => return Err(style_error(e)),
52 }
53 }
54}
55
56fn style_error(e: Failure<'_>) -> StyleError {
57 let message = match e.kind {
58 ParseErrorKind::Custom(message) => message,
59 ParseErrorKind::Basic(basic) => basic.to_string(),
60 };
61 StyleError {
62 line: e.location.line + 1,
63 column: e.location.column,
64 message,
65 }
66}
67
68fn next_rule<'i>(parser: &mut Parser<'i, '_>) -> Result<Option<Rule>, Failure<'i>> {
69 let mut elements = Vec::new();
70 loop {
71 let location = parser.current_source_location();
72 let token = match parser.next() {
73 Ok(token) => token.clone(),
74 Err(_) => {
75 if elements.is_empty() {
76 return Ok(None);
77 }
78 return Err(
79 location.new_custom_error("selector list without a { } block".to_string())
80 );
81 }
82 };
83 match token {
84 Token::Ident(name) => {
85 let element = Element::from_name(&name.to_ascii_lowercase()).ok_or_else(|| {
86 location.new_custom_error(format!(
87 "unsupported selector {name:?}: only element type selectors are supported"
88 ))
89 })?;
90 elements.push(element);
91 }
92 Token::Comma => {}
93 Token::CurlyBracketBlock => {
94 if elements.is_empty() {
95 return Err(location.new_custom_error("rule has no selector".to_string()));
96 }
97 let declared = parser.parse_nested_block(|block| declarations(block))?;
98 return Ok(Some(Rule { elements, declared }));
99 }
100 other => {
101 return Err(location.new_custom_error(format!(
102 "unsupported selector token {other:?}: only element type selectors are supported"
103 )));
104 }
105 }
106 }
107}
108
109fn declarations<'i>(parser: &mut Parser<'i, '_>) -> Result<Declared, Failure<'i>> {
110 let mut declared = Declared::default();
111 loop {
112 let location = parser.current_source_location();
113 let token = match parser.next() {
114 Ok(token) => token.clone(),
115 Err(_) => return Ok(declared),
116 };
117 let name = match token {
118 Token::Semicolon => continue,
119 Token::Ident(name) => name.to_ascii_lowercase(),
120 other => {
121 return Err(
122 location.new_custom_error(format!("expected a property name, found {other:?}"))
123 )
124 }
125 };
126 parser.expect_colon()?;
127 declaration(parser, &name, &mut declared, location)?;
128 }
129}
130
131fn declaration<'i>(
132 parser: &mut Parser<'i, '_>,
133 name: &str,
134 declared: &mut Declared,
135 location: SourceLocation,
136) -> Result<(), Failure<'i>> {
137 match name {
138 "font-family" => declared.family = Some(font_family(parser)?),
139 "font-size" => declared.size = Some(font_size(parser)?),
140 "font-weight" => declared.bold = Some(font_weight(parser)?),
141 "font-style" => declared.italic = Some(font_style(parser)?),
142 "color" => declared.color = Some(color(parser)?),
143 "background-color" => declared.background = Some(color(parser)?),
144 "margin" => declared.margin = edges(parser)?,
145 "padding" => declared.padding = edges(parser)?,
146 "margin-top" => declared.margin[0] = Some(length(parser)?),
147 "margin-right" => declared.margin[1] = Some(length(parser)?),
148 "margin-bottom" => declared.margin[2] = Some(length(parser)?),
149 "margin-left" => declared.margin[3] = Some(length(parser)?),
150 "padding-top" => declared.padding[0] = Some(length(parser)?),
151 "padding-right" => declared.padding[1] = Some(length(parser)?),
152 "padding-bottom" => declared.padding[2] = Some(length(parser)?),
153 "padding-left" => declared.padding[3] = Some(length(parser)?),
154 "line-height" => declared.line_height = Some(line_height(parser)?),
155 "text-align" => declared.align = Some(text_align(parser)?),
156 "text-decoration" => declared.decoration = Some(text_decoration(parser)?),
157 other => return Err(location.new_custom_error(format!("unsupported property {other:?}"))),
158 }
159 finish(parser)
160}
161
162fn finish<'i>(parser: &mut Parser<'i, '_>) -> Result<(), Failure<'i>> {
163 let location = parser.current_source_location();
164 match parser.next() {
165 Err(_) => Ok(()),
166 Ok(&Token::Semicolon) => Ok(()),
167 Ok(other) => Err(location.new_custom_error(format!(
168 "unexpected {} after the value",
169 render_token(other)
170 ))),
171 }
172}
173
174fn render_token(token: &Token) -> String {
177 match token {
178 Token::Ident(name) | Token::AtKeyword(name) => name.to_string(),
179 Token::Hash(name) | Token::IDHash(name) => format!("#{name}"),
180 Token::QuotedString(value) => format!("\"{value}\""),
181 Token::UnquotedUrl(value) => format!("url({value})"),
182 Token::Delim(delim) => delim.to_string(),
183 Token::Number { value, .. } => value.to_string(),
184 Token::Percentage { unit_value, .. } => format!("{}%", unit_value * 100.0),
185 Token::Dimension { value, unit, .. } => format!("{value}{unit}"),
186 Token::Function(name) => format!("{name}("),
187 Token::Colon => ":".to_string(),
188 Token::Semicolon => ";".to_string(),
189 Token::Comma => ",".to_string(),
190 other => format!("{other:?}"),
191 }
192}
193
194fn length<'i>(parser: &mut Parser<'i, '_>) -> Result<f32, Failure<'i>> {
195 let location = parser.current_source_location();
196 let token = parser.next()?.clone();
197 match &token {
198 Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("pt") => Ok(*value),
199 Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("mm") => {
200 Ok(72.0 / 25.4 * value)
201 }
202 Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("cm") => {
203 Ok(72.0 / 2.54 * value)
204 }
205 Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("in") => Ok(72.0 * value),
206 Token::Number { value, .. } if *value == 0.0 => Ok(0.0),
207 _ => Err(location.new_custom_error(format!(
208 "length takes pt, mm, cm or in, found {}",
209 render_token(&token)
210 ))),
211 }
212}
213
214fn font_size<'i>(parser: &mut Parser<'i, '_>) -> Result<FontSize, Failure<'i>> {
215 let location = parser.current_source_location();
216 let token = parser.next()?.clone();
217 match &token {
218 Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("pt") => {
219 Ok(FontSize::Pt(*value))
220 }
221 Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("em") => {
222 Ok(FontSize::Em(*value))
223 }
224 _ => Err(location.new_custom_error(format!(
225 "font-size takes pt or em, found {}",
226 render_token(&token)
227 ))),
228 }
229}
230
231fn edges<'i>(parser: &mut Parser<'i, '_>) -> Result<[Option<f32>; 4], Failure<'i>> {
232 let mut values: Vec<f32> = Vec::new();
233 while values.len() < 4 {
234 match parser.try_parse(length) {
235 Ok(value) => values.push(value),
236 Err(_) => break,
237 }
238 }
239 let edges = match values.as_slice() {
240 [a] => [*a, *a, *a, *a],
241 [v, h] => [*v, *h, *v, *h],
242 [t, h, b] => [*t, *h, *b, *h],
243 [t, r, b, l] => [*t, *r, *b, *l],
244 _ => return Err(shorthand_error(parser)),
245 };
246 Ok(edges.map(Some))
247}
248
249fn shorthand_error<'i>(parser: &mut Parser<'i, '_>) -> Failure<'i> {
250 let location = parser.current_source_location();
251 match parser.next() {
252 Ok(token) => location.new_custom_error(format!(
253 "margin/padding shorthand takes 1 to 4 lengths, found {}",
254 render_token(token)
255 )),
256 Err(_) => location.new_custom_error(
257 "margin/padding shorthand takes 1 to 4 lengths, found nothing".to_string(),
258 ),
259 }
260}
261
262fn color<'i>(parser: &mut Parser<'i, '_>) -> Result<Color, Failure<'i>> {
263 let location = parser.current_source_location();
264 let token = parser.next()?.clone();
265 match &token {
266 Token::Hash(hex) | Token::IDHash(hex) => hex_color(location, hex),
267 Token::Function(name) if name.eq_ignore_ascii_case("rgb") => {
268 parser.parse_nested_block(|block| rgb_components(block))
269 }
270 Token::Ident(name) => named_color(location, name),
271 _ => Err(location.new_custom_error(format!(
272 "color takes a hex value, rgb() or a named color, found {}",
273 render_token(&token)
274 ))),
275 }
276}
277
278fn hex_color<'i>(location: SourceLocation, hex: &str) -> Result<Color, Failure<'i>> {
279 let expanded = match hex.len() {
280 3 => hex.chars().flat_map(|digit| [digit, digit]).collect(),
281 6 => hex.to_string(),
282 _ => {
283 return Err(
284 location.new_custom_error(format!("hex colors take 3 or 6 digits, found #{hex}"))
285 )
286 }
287 };
288 let channel = |slice: &str| -> Result<f32, Failure<'i>> {
289 u8::from_str_radix(slice, 16)
290 .map(|byte| byte as f32 / 255.0)
291 .map_err(|_| location.new_custom_error(format!("invalid hex color, found #{hex}")))
292 };
293 Ok(Color::Rgb(
294 channel(&expanded[0..2])?,
295 channel(&expanded[2..4])?,
296 channel(&expanded[4..6])?,
297 ))
298}
299
300fn named_color<'i>(location: SourceLocation, name: &str) -> Result<Color, Failure<'i>> {
301 let rgb = match name.to_ascii_lowercase().as_str() {
302 "black" => (0, 0, 0),
303 "white" => (255, 255, 255),
304 "red" => (255, 0, 0),
305 "green" => (0, 128, 0),
306 "blue" => (0, 0, 255),
307 "navy" => (0, 0, 128),
308 "teal" => (0, 128, 128),
309 "purple" => (128, 0, 128),
310 "orange" => (255, 165, 0),
311 "yellow" => (255, 255, 0),
312 "gray" | "grey" => (128, 128, 128),
313 "silver" => (192, 192, 192),
314 "maroon" => (128, 0, 0),
315 "aqua" | "cyan" => (0, 255, 255),
316 "fuchsia" | "magenta" => (255, 0, 255),
317 "lime" => (0, 255, 0),
318 "olive" => (128, 128, 0),
319 _ => return Err(location.new_custom_error(format!("unknown color name {name}"))),
320 };
321 let (r, g, b) = rgb;
322 Ok(Color::Rgb(
323 r as f32 / 255.0,
324 g as f32 / 255.0,
325 b as f32 / 255.0,
326 ))
327}
328
329fn rgb_components<'i>(parser: &mut Parser<'i, '_>) -> Result<Color, Failure<'i>> {
330 let r = rgb_component(parser)?;
331 parser.try_parse(Parser::expect_comma).ok();
332 let g = rgb_component(parser)?;
333 parser.try_parse(Parser::expect_comma).ok();
334 let b = rgb_component(parser)?;
335 Ok(Color::Rgb(r, g, b))
336}
337
338fn rgb_component<'i>(parser: &mut Parser<'i, '_>) -> Result<f32, Failure<'i>> {
339 let location = parser.current_source_location();
340 let token = parser.next()?.clone();
341 match &token {
342 Token::Number { value, .. } if (0.0..=255.0).contains(value) => Ok(value / 255.0),
343 _ => Err(location.new_custom_error(format!(
344 "rgb() takes three numbers 0-255, found {}",
345 render_token(&token)
346 ))),
347 }
348}
349
350fn font_family<'i>(parser: &mut Parser<'i, '_>) -> Result<FontFamily, Failure<'i>> {
351 let location = parser.current_source_location();
352 let token = parser.next()?.clone();
353 let name = match &token {
354 Token::Ident(name) => name.to_ascii_lowercase(),
355 _ => {
356 return Err(location.new_custom_error(format!(
357 "font-family takes a family name, found {}",
358 render_token(&token)
359 )))
360 }
361 };
362 match name.as_str() {
363 "helvetica" | "sans-serif" => Ok(FontFamily::Helvetica),
364 "times" | "serif" => Ok(FontFamily::Times),
365 "courier" | "monospace" => Ok(FontFamily::Courier),
366 _ => Err(location.new_custom_error(format!(
367 "unknown font family {name:?}: helvetica, times and courier are available until font embedding lands"
368 ))),
369 }
370}
371
372fn font_weight<'i>(parser: &mut Parser<'i, '_>) -> Result<bool, Failure<'i>> {
373 let location = parser.current_source_location();
374 let token = parser.next()?.clone();
375 match &token {
376 Token::Ident(name) if name.eq_ignore_ascii_case("normal") => Ok(false),
377 Token::Ident(name) if name.eq_ignore_ascii_case("bold") => Ok(true),
378 Token::Number { value, .. } if *value == 400.0 => Ok(false),
379 Token::Number { value, .. } if *value == 700.0 => Ok(true),
380 _ => Err(location.new_custom_error(format!(
381 "font-weight takes normal, bold, 400 or 700, found {}",
382 render_token(&token)
383 ))),
384 }
385}
386
387fn font_style<'i>(parser: &mut Parser<'i, '_>) -> Result<bool, Failure<'i>> {
388 let location = parser.current_source_location();
389 let token = parser.next()?.clone();
390 match &token {
391 Token::Ident(name) if name.eq_ignore_ascii_case("normal") => Ok(false),
392 Token::Ident(name) if name.eq_ignore_ascii_case("italic") => Ok(true),
393 _ => Err(location.new_custom_error(format!(
394 "font-style takes normal or italic, found {}",
395 render_token(&token)
396 ))),
397 }
398}
399
400fn line_height<'i>(parser: &mut Parser<'i, '_>) -> Result<f32, Failure<'i>> {
401 let location = parser.current_source_location();
402 let token = parser.next()?.clone();
403 match &token {
404 Token::Number { value, .. } if *value > 0.0 => Ok(*value),
405 _ => Err(location.new_custom_error(format!(
406 "line-height takes a positive number, found {}",
407 render_token(&token)
408 ))),
409 }
410}
411
412fn text_align<'i>(parser: &mut Parser<'i, '_>) -> Result<Align, Failure<'i>> {
413 let location = parser.current_source_location();
414 let token = parser.next()?.clone();
415 match &token {
416 Token::Ident(name) if name.eq_ignore_ascii_case("left") => Ok(Align::Left),
417 Token::Ident(name) if name.eq_ignore_ascii_case("center") => Ok(Align::Center),
418 Token::Ident(name) if name.eq_ignore_ascii_case("right") => Ok(Align::Right),
419 Token::Ident(name) if name.eq_ignore_ascii_case("justify") => {
420 Err(location.new_custom_error("justify is not supported".to_string()))
421 }
422 _ => Err(location.new_custom_error(format!(
423 "text-align takes left, center or right, found {}",
424 render_token(&token)
425 ))),
426 }
427}
428
429fn text_decoration<'i>(parser: &mut Parser<'i, '_>) -> Result<Decoration, Failure<'i>> {
430 let location = parser.current_source_location();
431 let token = parser.next()?.clone();
432 match &token {
433 Token::Ident(name) if name.eq_ignore_ascii_case("none") => Ok(Decoration::None),
434 Token::Ident(name) if name.eq_ignore_ascii_case("underline") => Ok(Decoration::Underline),
435 Token::Ident(name) if name.eq_ignore_ascii_case("line-through") => {
436 Ok(Decoration::LineThrough)
437 }
438 _ => Err(location.new_custom_error(format!(
439 "text-decoration takes none, underline or line-through, found {}",
440 render_token(&token)
441 ))),
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 fn sheet(css: &str) -> Vec<Rule> {
450 parse_sheet(css).unwrap()
451 }
452
453 fn error(css: &str) -> StyleError {
454 parse_sheet(css).unwrap_err()
455 }
456
457 #[test]
458 fn parses_grouped_selectors_and_declarations() {
459 let rules = sheet("h1, h2 { font-size: 24pt; color: #c00; }");
460 assert_eq!(rules.len(), 1);
461 assert_eq!(rules[0].elements, vec![Element::H1, Element::H2]);
462 assert_eq!(rules[0].declared.size, Some(FontSize::Pt(24.0)));
463 assert_eq!(rules[0].declared.color, Some(Color::Rgb(0.8, 0.0, 0.0)));
464 }
465
466 #[test]
467 fn parses_units_and_shorthand() {
468 let rules = sheet("body { margin: 1in 2cm; font-size: 1.5em; line-height: 1.2; }");
469 let d = &rules[0].declared;
470 assert_eq!(
471 d.margin,
472 [
473 Some(72.0),
474 Some(72.0 / 2.54 * 2.0),
475 Some(72.0),
476 Some(72.0 / 2.54 * 2.0)
477 ]
478 );
479 assert_eq!(d.size, Some(FontSize::Em(1.5)));
480 assert_eq!(d.line_height, Some(1.2));
481 }
482
483 #[test]
484 fn parses_colors() {
485 let rules = sheet("p { color: rgb(255, 128, 0); background-color: navy; }");
486 let d = &rules[0].declared;
487 assert_eq!(d.color, Some(Color::Rgb(1.0, 128.0 / 255.0, 0.0)));
488 assert_eq!(d.background, Some(Color::Rgb(0.0, 0.0, 128.0 / 255.0)));
489 }
490
491 #[test]
492 fn parses_keyword_properties() {
493 let rules = sheet(
494 "a { font-family: monospace; font-weight: 700; font-style: italic; \
495 text-align: center; text-decoration: underline; }",
496 );
497 let d = &rules[0].declared;
498 assert_eq!(d.family, Some(FontFamily::Courier));
499 assert_eq!(d.bold, Some(true));
500 assert_eq!(d.italic, Some(true));
501 assert_eq!(d.align, Some(Align::Center));
502 assert_eq!(d.decoration, Some(Decoration::Underline));
503 }
504
505 #[test]
506 fn rejects_unknown_selector_property_value_and_unit() {
507 assert!(error(".card { color: #000; }").message.contains("selector"));
508 assert!(error("p { display: flex; }").message.contains("display"));
509 assert!(error("p { text-align: justify; }")
510 .message
511 .contains("justify"));
512 assert!(error("p { font-size: 12px; }").message.contains("px"));
513 }
514
515 #[test]
516 fn errors_carry_the_location() {
517 let e = error("p { color: #000; }\nh1 { volume: 11; }");
518 assert_eq!(e.line, 2);
519 assert!(e.message.contains("volume"));
520 }
521
522 #[test]
523 fn comments_and_whitespace_are_ignored() {
524 let rules = sheet("/* heading */\nh1 { /* big */ font-size: 20pt; }");
525 assert_eq!(rules[0].declared.size, Some(FontSize::Pt(20.0)));
526 }
527
528 #[test]
529 fn selectors_match_case_insensitively() {
530 let rules = sheet("H1, P { font-size: 20pt; }");
531 assert_eq!(rules[0].elements, vec![Element::H1, Element::P]);
532 }
533
534 #[test]
535 fn rejects_rgb_components_outside_0_255() {
536 let e = error("p { color: rgb(500, -10, 0); }");
537 assert!(e.message.contains("500"));
538 }
539}