Repository: jugglerchris/rust-html2text
Pull request #189 (2024-11-30 16:58:34)

Add `:nth-child()` and (as a debug extension) `display: x-raw-dom`.
This allows a simple debug mode where a portion of the document is shown as the raw DOM rather than rendered normally.
Also a couple of bug fixes.


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index ab43638..888634f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,6 +28,7 @@ html_trace = ["dep:log"]
 html_trace_bt = ["html_trace", "dep:backtrace"]
 default = []
 css = []
+css_ext = ["css"]
 
 [[example]]
 name = "html2term"
diff --git a/examples/html2term.rs b/examples/html2term.rs
index 3e9b01c..a1c2937 100644
--- a/examples/html2term.rs
+++ b/examples/html2term.rs
@@ -4,6 +4,8 @@ extern crate argparse;
 extern crate unicode_width;
 #[cfg(unix)]
 mod top {
+    #[cfg(feature = "css")]
+    use argparse::StoreFalse;
     use argparse::{ArgumentParser, Store};
     use html2text::render::{RichAnnotation, TaggedLine, TaggedLineElement};
     use std::collections::HashMap;
@@ -16,6 +18,7 @@ mod top {
     use unicode_width::UnicodeWidthStr;
 
     fn to_style(tag: &[RichAnnotation]) -> String {
+        use termion::color::*;
         let mut style = String::new();
 
         for ann in tag {
@@ -25,42 +28,32 @@ mod top {
                     style.push_str(&format!("{}", termion::style::Underline));
                 }
                 RichAnnotation::Image(_) => {
-                    style.push_str(&format!(
-                        "{}",
-                        termion::color::Fg(termion::color::LightBlue)
-                    ));
+                    style.push_str(&format!("{}", Fg(LightBlue)));
                 }
                 RichAnnotation::Emphasis => {
-                    style.push_str(&format!(
-                        "{}",
-                        termion::color::Fg(termion::color::LightGreen)
-                    ));
+                    style.push_str(&format!("{}", Fg(LightGreen)));
                 }
                 RichAnnotation::Strong => {
-                    style.push_str(&format!(
-                        "{}",
-                        termion::color::Fg(termion::color::LightGreen)
-                    ));
+                    style.push_str(&format!("{}", Fg(LightGreen)));
                 }
                 RichAnnotation::Strikeout => (),
                 RichAnnotation::Code => {
-                    style.push_str(&format!(
-                        "{}",
-                        termion::color::Fg(termion::color::LightYellow)
-                    ));
+                    style.push_str(&format!("{}", Fg(LightYellow)));
                 }
                 RichAnnotation::Preformat(is_cont) => {
                     if is_cont {
-                        style.push_str(&format!(
-                            "{}",
-                            termion::color::Fg(termion::color::LightMagenta)
-                        ));
+                        style.push_str(&format!("{}", Fg(LightMagenta)));
                     } else {
-                        style.push_str(&format!("{}", termion::color::Fg(termion::color::Magenta)));
+                        style.push_str(&format!("{}", Fg(Magenta)));
                     }
                 }
-                // Ignore unhandled annotations
-                _ => {}
+                RichAnnotation::Colour(col) => {
+                    style.push_str(&format!("{}", Fg(Rgb(col.r, col.g, col.b))));
+                }
+                RichAnnotation::BgColour(col) => {
+                    style.push_str(&format!("{}", Bg(Rgb(col.r, col.g, col.b))));
+                }
+                _ => todo!(),
             }
         }
         style
@@ -132,12 +125,31 @@ mod top {
         FragMap { start_xy: map }
     }
 
+    struct Options {
+        #[cfg(feature = "css")]
+        use_css: bool,
+    }
+
+    impl Options {
+        fn new() -> Options {
+            Options {
+                #[cfg(feature = "css")]
+                use_css: true,
+            }
+        }
+    }
+
     pub fn main() {
         let mut filename = String::new();
+        #[allow(unused_mut)]
+        let mut options = Options::new();
         {
             let mut ap = ArgumentParser::new();
             ap.refer(&mut filename)
                 .add_argument("filename", Store, "Set HTML filename");
+            #[cfg(feature = "css")]
+            ap.refer(&mut options.use_css)
+                .add_option(&["--no-css"], StoreFalse, "Disable CSS");
             ap.parse_args_or_exit();
         }
 
@@ -145,16 +157,13 @@ mod top {
         let (width, height) = (width as usize, height as usize);
 
         let mut file = std::fs::File::open(filename).expect("Tried to open file");
-        let annotated =
-            html2text::from_read_rich(&mut file, width).expect("Failed to convert from HTML");
 
-        let link_map = find_links(&annotated);
-        let frag_map = find_frags(&annotated);
+        let dom = html2text::config::plain()
+            .parse_html(&mut file)
+            .expect("Failed to parse HTML");
 
         let mut keys = io::stdin().keys();
 
-        // max_y is the largest (0-based) index of a real document line.
-        let max_y = annotated.len() - 1;
         // top_y is the (0-based) index of the document line shown at
         // the top of the visible screen.
         let mut top_y = 0;
@@ -169,7 +178,17 @@ mod top {
             .into_alternate_screen()
             .unwrap();
 
+        let mut annotated = rerender(&dom, &[], width, &options);
+
+        let link_map = find_links(&annotated);
+        let frag_map = find_frags(&annotated);
+
+        let mut inspect_path = vec![];
+
         loop {
+            // max_y is the largest (0-based) index of a real document line.
+            let max_y = annotated.len() - 1;
+
             // Sanity-check the current screen position. max_y should
             // be small enough that no blank lines beyond the end of
             // the document are visible on screen (except when the
@@ -186,7 +205,10 @@ mod top {
             top_y = std::cmp::min(top_y, doc_y);
 
             let opt_url = link_map.link_at(doc_x, doc_y);
-            let vis_y_limit = std::cmp::min(top_y + height, max_y + 1);
+            let mut vis_y_limit = std::cmp::min(top_y + height, max_y + 1);
+            if !inspect_path.is_empty() {
+                vis_y_limit -= 1;
+            }
             write!(screen, "{}", termion::clear::All).unwrap();
             for (i, line) in annotated[top_y..vis_y_limit].iter().enumerate() {
                 write!(screen, "{}", Goto(1, i as u16 + 1)).unwrap();
@@ -202,6 +224,23 @@ mod top {
                     write!(screen, "{}{}{}", style, ts.s, termion::style::Reset).unwrap();
                 }
             }
+            if !inspect_path.is_empty() {
+                let mut pth = String::from("top ");
+                let mut node = dom.document.clone();
+
+                for &idx in &inspect_path {
+                    node = node.nth_child(idx).unwrap();
+                    pth.push_str(&format!("> {}", node.element_name().unwrap()));
+                }
+                write!(
+                    screen,
+                    "{}{}{:?}",
+                    Goto(1, vis_y_limit as u16),
+                    pth,
+                    &inspect_path
+                )
+                .unwrap();
+            }
 
             // 1-based screen coordinates
             let cursor_x = (doc_x + 1) as u16;
@@ -213,19 +252,52 @@ mod top {
                 match k {
                     Key::Char('q') => break,
                     Key::Char('j') | Key::Down => {
-                        if doc_y < max_y {
-                            doc_y += 1;
+                        if inspect_path.is_empty() {
+                            if doc_y < max_y {
+                                doc_y += 1;
+                            }
+                        } else {
+                            *inspect_path.last_mut().unwrap() += 1;
+                            if dom.get_node_by_path(&inspect_path).is_none() {
+                                // No next node - undo.
+                                *inspect_path.last_mut().unwrap() -= 1;
+                            } else {
+                                annotated = rerender(&dom, &inspect_path, width, &options);
+                            }
                         }
                     }
                     Key::Char('k') | Key::Up => {
-                        doc_y = doc_y.saturating_sub(1);
+                        if inspect_path.is_empty() {
+                            doc_y = doc_y.saturating_sub(1);
+                        } else {
+                            if *inspect_path.last().unwrap() > 1 {
+                                *inspect_path.last_mut().unwrap() -= 1;
+                                annotated = rerender(&dom, &inspect_path, width, &options);
+                            }
+                        }
                     }
                     Key::Char('h') | Key::Left => {
-                        doc_x = doc_x.saturating_sub(1);
+                        if inspect_path.is_empty() {
+                            doc_x = doc_x.saturating_sub(1);
+                        } else {
+                            if inspect_path.len() > 1 {
+                                inspect_path.pop();
+                                annotated = rerender(&dom, &inspect_path, width, &options);
+                            }
+                        }
                     }
                     Key::Char('l') | Key::Right => {
-                        if doc_x + 1 < width {
-                            doc_x += 1;
+                        if inspect_path.is_empty() {
+                            if doc_x + 1 < width {
+                                doc_x += 1;
+                            }
+                        } else {
+                            inspect_path.push(1);
+                            if dom.get_node_by_path(&inspect_path).is_none() {
+                                inspect_path.pop();
+                            } else {
+                                annotated = rerender(&dom, &inspect_path, width, &options);
+                            }
                         }
                     }
                     Key::Char(' ') | Key::PageDown => {
@@ -266,11 +338,83 @@ mod top {
                             }
                         }
                     }
+                    #[cfg(feature = "css_ext")]
+                    Key::Char('I') => {
+                        // Enter/leave inspect mode
+                        if inspect_path.is_empty() {
+                            inspect_path.push(1);
+                        } else {
+                            inspect_path.clear();
+                        }
+                        annotated = rerender(&dom, &inspect_path, width, &options);
+                    }
                     _ => {}
                 }
             }
         }
     }
+
+    fn rerender(
+        dom: &html2text::RcDom,
+        inspect_path: &[usize],
+        width: usize,
+        #[allow(unused)] options: &Options,
+    ) -> Vec<TaggedLine<Vec<RichAnnotation>>> {
+        let config = html2text::config::rich();
+        #[cfg(feature = "css")]
+        let config = if options.use_css {
+            config
+                .use_doc_css()
+                .add_agent_css(
+                    r#"
+                    img {
+                        color: #77f;
+                    }
+                "#,
+                )
+                .unwrap()
+        } else {
+            config
+        };
+        if inspect_path.is_empty() {
+            let render_tree = config
+                .dom_to_render_tree(&dom)
+                .expect("Failed to build render tree");
+            config
+                .render_to_lines(render_tree, width)
+                .expect("Failed to render")
+        } else {
+            #[cfg(feature = "css_ext")]
+            {
+                let mut path_selector = String::new();
+                for &idx in &inspect_path[1..] {
+                    path_selector.push_str(&format!(" > :nth-child({})", idx));
+                }
+                let config = config
+                    .add_agent_css(
+                        &(format!(
+                            r#"
+                    html {} {{
+                        color: white !important;
+                        background-color: black !important;
+                        display: x-raw-dom;
+                    }}
+                "#,
+                            path_selector
+                        )),
+                    )
+                    .expect("Invalid CSS");
+                let render_tree = config
+                    .dom_to_render_tree(&dom)
+                    .expect("Failed to build render tree");
+                config
+                    .render_to_lines(render_tree, width)
+                    .expect("Failed to render")
+            }
+            #[cfg(not(feature = "css_ext"))]
+            unreachable!()
+        }
+    }
 }
 
 #[cfg(not(unix))]
diff --git a/examples/html2text.rs b/examples/html2text.rs
index 7879dda..b69c288 100644
--- a/examples/html2text.rs
+++ b/examples/html2text.rs
@@ -126,6 +126,15 @@ where
                 .unwrap();
         }
     }
+    #[cfg(feature = "css")]
+    {
+        if flags.show_css {
+            let conf = config::plain();
+            let conf = update_config(conf, &flags);
+            let dom = conf.parse_html(input).unwrap();
+            return html2text::dom_to_parsed_style(&dom).expect("Parsing CSS");
+        }
+    }
     if flags.show_dom {
         let conf = config::plain();
         let conf = update_config(conf, &flags);
@@ -162,6 +171,8 @@ struct Flags {
     use_only_css: bool,
     show_dom: bool,
     show_render: bool,
+    #[cfg(feature = "css")]
+    show_css: bool,
 }
 
 fn main() {
@@ -182,6 +193,8 @@ fn main() {
         use_only_css: false,
         show_dom: false,
         show_render: false,
+        #[cfg(feature = "css")]
+        show_css: false,
     };
     let mut literal: bool = false;
 
@@ -240,6 +253,12 @@ fn main() {
             StoreTrue,
             "Show the computed render tree instead of the rendered output",
         );
+        #[cfg(feature = "css")]
+        ap.refer(&mut flags.show_css).add_option(
+            &["--show-css"],
+            StoreTrue,
+            "Show the parsed CSS instead of rendered output",
+        );
         ap.parse_args_or_exit();
     }
 
diff --git a/src/css.rs b/src/css.rs
index c853b9a..f3207e9 100644
--- a/src/css.rs
+++ b/src/css.rs
@@ -1,6 +1,6 @@
 //! Some basic CSS support.
-use std::io::Write;
 use std::ops::Deref;
+use std::{io::Write, rc::Rc};
 
 mod parser;
 
@@ -24,6 +24,26 @@ pub(crate) enum SelectorComponent {
     Star,
     CombChild,
     CombDescendant,
+    NthChild {
+        /* An + B [of sel] */
+        a: i32,
+        b: i32,
+        sel: Selector,
+    },
+}
+
+impl std::fmt::Display for SelectorComponent {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            SelectorComponent::Class(name) => write!(f, ".{}", name),
+            SelectorComponent::Element(name) => write!(f, "{}", name),
+            SelectorComponent::Hash(val) => write!(f, "#{}", val),
+            SelectorComponent::Star => write!(f, " * "),
+            SelectorComponent::CombChild => write!(f, " > "),
+            SelectorComponent::CombDescendant => write!(f, " "),
+            SelectorComponent::NthChild { a, b, .. } => write!(f, ":nth-child({}n+{})", a, b),
+        }
+    }
 }
 
 #[derive(Debug, Clone, PartialEq)]
@@ -32,6 +52,15 @@ pub(crate) struct Selector {
     components: Vec<SelectorComponent>,
 }
 
+impl std::fmt::Display for Selector {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for comp in self.components.iter().rev() {
+            comp.fmt(f)?;
+        }
+        Ok(())
+    }
+}
+
 impl Selector {
     fn do_matches(comps: &[SelectorComponent], node: &Handle) -> bool {
         match comps.first() {
@@ -76,31 +105,58 @@ impl Selector {
                 },
                 SelectorComponent::Star => Self::do_matches(&comps[1..], node),
                 SelectorComponent::CombChild => {
-                    if let Some(parent) = node.parent.take() {
-                        let parent_handle = parent.upgrade();
-                        node.parent.set(Some(parent));
-                        if let Some(ph) = parent_handle {
-                            Self::do_matches(&comps[1..], &ph)
-                        } else {
-                            false
-                        }
+                    if let Some(parent) = node.get_parent() {
+                        Self::do_matches(&comps[1..], &parent)
                     } else {
                         false
                     }
                 }
                 SelectorComponent::CombDescendant => {
-                    if let Some(parent) = node.parent.take() {
-                        let parent_handle = parent.upgrade();
-                        node.parent.set(Some(parent));
-                        if let Some(ph) = parent_handle {
-                            Self::do_matches(&comps[1..], &ph) || Self::do_matches(comps, &ph)
-                        } else {
-                            false
-                        }
+                    if let Some(parent) = node.get_parent() {
+                        Self::do_matches(&comps[1..], &parent) || Self::do_matches(comps, &parent)
                     } else {
                         false
                     }
                 }
+                SelectorComponent::NthChild { a, b, sel } => {
+                    let parent = if let Some(parent) = node.get_parent() {
+                        parent
+                    } else {
+                        return false;
+                    };
+                    let mut idx = 0i32;
+                    for child in parent.children.borrow().iter() {
+                        if let Element { .. } = child.data {
+                            if sel.matches(child) {
+                                idx += 1;
+                                if Rc::ptr_eq(child, node) {
+                                    break;
+                                }
+                            } else {
+                                if Rc::ptr_eq(child, node) {
+                                    return false;
+                                }
+                            }
+                        }
+                    }
+                    if idx == 0 {
+                        // The child wasn't found(?)
+                        return false;
+                    }
+                    /* The selector matches if idx == a*n + b, where
+                     * n >= 0
+                     */
+                    let idx_offset = idx - b;
+                    if *a == 0 {
+                        return idx_offset == 0 && Self::do_matches(&comps[1..], &node);
+                    }
+                    if (idx_offset % a) != 0 {
+                        // Not a multiple
+                        return false;
+                    }
+                    let n = idx_offset / a;
+                    n >= 0 && Self::do_matches(&comps[1..], &node)
+                }
             },
         }
     }
@@ -124,6 +180,10 @@ impl Selector {
                 SelectorComponent::Star => {}
                 SelectorComponent::CombChild => {}
                 SelectorComponent::CombDescendant => {}
+                SelectorComponent::NthChild { sel, .. } => {
+                    result.class += 1;
+                    result += &sel.specificity();
+                }
             }
         }
 
@@ -131,11 +191,20 @@ impl Selector {
     }
 }
 
+#[derive(Debug, Clone, Copy)]
+pub(crate) enum Display {
+    /// display: none
+    None,
+    #[cfg(feature = "css_ext")]
+    /// Show node as HTML DOM
+    ExtRawDom,
+}
+
 #[derive(Debug, Clone)]
 pub(crate) enum Style {
     Colour(Colour),
     BgColour(Colour),
-    DisplayNone,
+    Display(Display),
     WhiteSpace(WhiteSpace),
 }
 
@@ -145,12 +214,41 @@ pub(crate) struct StyleDecl {
     importance: Importance,
 }
 
+impl std::fmt::Display for StyleDecl {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self.style {
+            Style::Colour(col) => write!(f, "color: {}", col)?,
+            Style::BgColour(col) => write!(f, "background-color: {}", col)?,
+            Style::Display(Display::None) => write!(f, "display: none")?,
+            #[cfg(feature = "css_ext")]
+            Style::Display(Display::ExtRawDom) => write!(f, "display: x-raw-dom")?,
+            Style::WhiteSpace(_) => todo!(),
+        }
+        match self.importance {
+            Importance::Default => (),
+            Importance::Important => write!(f, " !important")?,
+        }
+        Ok(())
+    }
+}
+
 #[derive(Debug, Clone)]
 struct Ruleset {
     selector: Selector,
     styles: Vec<StyleDecl>,
 }
 
+impl std::fmt::Display for Ruleset {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        writeln!(f, "  {} {{", self.selector)?;
+        for decl in &self.styles {
+            writeln!(f, "    {}", decl)?;
+        }
+        writeln!(f, "  }}")?;
+        Ok(())
+    }
+}
+
 /// Stylesheet data which can be used while building the render tree.
 #[derive(Clone, Default, Debug)]
 pub(crate) struct StyleData {
@@ -226,14 +324,22 @@ fn styles_from_properties(decls: &[parser::Declaration]) -> Vec<StyleDecl> {
                 overflow_hidden = true;
             }
             parser::Decl::Overflow { .. } | parser::Decl::OverflowY { .. } => {}
-            parser::Decl::Display { value } => {
-                if let parser::Display::None = value {
+            parser::Decl::Display { value } => match value {
+                parser::Display::None => {
                     styles.push(StyleDecl {
-                        style: Style::DisplayNone,
+                        style: Style::Display(Display::None),
                         importance: decl.important,
                     });
                 }
-            }
+                #[cfg(feature = "css_ext")]
+                parser::Display::RawDom => {
+                    styles.push(StyleDecl {
+                        style: Style::Display(Display::ExtRawDom),
+                        importance: decl.important,
+                    });
+                }
+                _ => (),
+            },
             parser::Decl::WhiteSpace { value } => {
                 styles.push(StyleDecl {
                     style: Style::WhiteSpace(*value),
@@ -249,7 +355,7 @@ fn styles_from_properties(decls: &[parser::Declaration]) -> Vec<StyleDecl> {
     // If the height is set to zero and overflow hidden, treat as display: none
     if height_zero && overflow_hidden {
         styles.push(StyleDecl {
-            style: Style::DisplayNone,
+            style: Style::Display(Display::None),
             importance: Importance::Default,
         });
     }
@@ -406,11 +512,11 @@ impl StyleData {
                     .bg_colour
                     .maybe_update(important, origin, specificity, col);
             }
-            Style::DisplayNone => {
+            Style::Display(disp) => {
                 // We don't have a "not DisplayNone" - we might need to fix this.
                 result
-                    .display_none
-                    .maybe_update(important, origin, specificity, true);
+                    .display
+                    .maybe_update(important, origin, specificity, disp);
             }
             Style::WhiteSpace(ws) => {
                 result
@@ -421,6 +527,30 @@ impl StyleData {
     }
 }
 
+impl std::fmt::Display for StyleData {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if !self.agent_rules.is_empty() {
+            writeln!(f, "Agent rules:")?;
+            for ruleset in &self.agent_rules {
+                ruleset.fmt(f)?;
+            }
+        }
+        if !self.user_rules.is_empty() {
+            writeln!(f, "User rules:")?;
+            for ruleset in &self.user_rules {
+                ruleset.fmt(f)?;
+            }
+        }
+        if !self.author_rules.is_empty() {
+            writeln!(f, "Author rules:")?;
+            for ruleset in &self.author_rules {
+                ruleset.fmt(f)?;
+            }
+        }
+        Ok(())
+    }
+}
+
 fn pending<F>(handle: Handle, f: F) -> TreeMapResult<'static, (), Handle, Vec<String>>
 where
     F: Fn(&mut (), Vec<Vec<String>>) -> Result<Option<Vec<String>>> + 'static,
diff --git a/src/css/parser.rs b/src/css/parser.rs
index d0040a3..0d64f0c 100644
--- a/src/css/parser.rs
+++ b/src/css/parser.rs
@@ -61,6 +61,8 @@ pub(crate) enum Overflow {
 pub(crate) enum Display {
     None,
     Other,
+    #[cfg(feature = "css_ext")]
+    RawDom,
 }
 
 #[derive(Debug, PartialEq)]
@@ -715,6 +717,8 @@ fn parse_display(value: &RawValue) -> Result<Display, nom::Err<nom::error::Error
             #[allow(clippy::single_match)]
             match word.deref() {
                 "none" => return Ok(Display::None),
+                #[cfg(feature = "css_ext")]
+                "x-raw-dom" => return Ok(Display::RawDom),
                 _ => (),
             }
         }
@@ -752,6 +756,96 @@ fn parse_class(text: &str) -> IResult<&str, SelectorComponent> {
     Ok((rest, SelectorComponent::Class(classname)))
 }
 
+#[derive(Eq, PartialEq, Copy, Clone)]
+enum Sign {
+    Plus,
+    Neg,
+}
+
+impl Sign {
+    fn val(&self) -> i32 {
+        match self {
+            Sign::Plus => 1,
+            Sign::Neg => -1,
+        }
+    }
+}
+
+fn opt_sign(text: &str) -> IResult<&str, Sign> {
+    match text.chars().next() {
+        Some('-') => Ok((&text[1..], Sign::Neg)),
+        Some('+') => Ok((&text[1..], Sign::Plus)),
+        _ => Ok((text, Sign::Plus)),
+    }
+}
+fn sign(text: &str) -> IResult<&str, Sign> {
+    match text.chars().next() {
+        Some('-') => Ok((&text[1..], Sign::Neg)),
+        Some('+') => Ok((&text[1..], Sign::Plus)),
+        _ => fail(text),
+    }
+}
+
+fn parse_nth_child_args(text: &str) -> IResult<&str, SelectorComponent> {
+    let (rest, _) = tag("(")(text)?;
+    let (rest, _) = skip_optional_whitespace(rest)?;
+
+    let (rest, (a, b)) = alt((
+        map(tag("even"), |_| (2, 0)),
+        map(tag("odd"), |_| (2, 1)),
+        // The case where both a and b are specified
+        map(
+            tuple((
+                opt_sign,
+                opt(digit1),
+                tag("n"),
+                skip_optional_whitespace,
+                sign,
+                digit1,
+            )),
+            |(a_sign, a_opt_val, _, _, b_sign, b_val)| {
+                let a =
+                    <i32 as FromStr>::from_str(a_opt_val.unwrap_or("1")).unwrap() * a_sign.val();
+                let b = <i32 as FromStr>::from_str(b_val).unwrap() * b_sign.val();
+                (a, b)
+            },
+        ),
+        // Just a
+        map(
+            tuple((opt_sign, opt(digit1), tag("n"))),
+            |(a_sign, a_opt_val, _)| {
+                let a =
+                    <i32 as FromStr>::from_str(a_opt_val.unwrap_or("1")).unwrap() * a_sign.val();
+                (a, 0)
+            },
+        ),
+        // Just b
+        map(tuple((opt_sign, digit1)), |(b_sign, b_val)| {
+            let b = <i32 as FromStr>::from_str(b_val).unwrap() * b_sign.val();
+            (0, b)
+        }),
+    ))(rest)?;
+
+    let (rest, _) = tuple((skip_optional_whitespace, tag(")")))(rest)?;
+
+    let sel = Selector {
+        components: vec![SelectorComponent::Star],
+    };
+    Ok((rest, SelectorComponent::NthChild { a, b, sel }))
+}
+
+fn parse_pseudo_class(text: &str) -> IResult<&str, SelectorComponent> {
+    let (rest, _) = tag(":")(text)?;
+    let (rest, pseudo_classname) = parse_ident(rest)?;
+    match pseudo_classname.as_str() {
+        "nth-child" => {
+            let (rest, component) = parse_nth_child_args(rest)?;
+            Ok((rest, component))
+        }
+        _ => fail(text),
+    }
+}
+
 fn parse_hash(text: &str) -> IResult<&str, SelectorComponent> {
     let (rest, _) = tag("#")(text)?;
     let (rest, word) = parse_identstring(rest)?;
@@ -777,6 +871,7 @@ fn parse_simple_selector_component(text: &str) -> IResult<&str, SelectorComponen
         parse_class,
         parse_hash,
         map(parse_ident, SelectorComponent::Element),
+        parse_pseudo_class,
     ))(text)
 }
 
@@ -834,8 +929,89 @@ fn parse_ruleset(text: &str) -> IResult<&str, RuleSet> {
     ))
 }
 
+fn skip_to_end_of_statement(text: &str) -> IResult<&str, ()> {
+    let mut rest = text;
+
+    let mut bra_stack = vec![];
+    loop {
+        let (remain, tok) = match parse_token(rest) {
+            Ok(res) => res,
+            Err(_) => return Ok((rest, ())),
+        };
+        match &tok {
+            Token::Ident(..)
+            | Token::AtKeyword(_)
+            | Token::Hash(_)
+            | Token::String(_)
+            | Token::BadString(_)
+            | Token::Url(_)
+            | Token::BadUrl(_)
+            | Token::Delim(_)
+            | Token::Number(_)
+            | Token::Dimension(_, _)
+            | Token::Percentage(_)
+            | Token::Colon
+            | Token::Comma => (),
+
+            Token::Function(_) | Token::OpenRound => {
+                bra_stack.push(Token::CloseRound);
+            }
+            Token::CDO => {
+                bra_stack.push(Token::CDC);
+            }
+            Token::OpenSquare => {
+                bra_stack.push(Token::CloseSquare);
+            }
+            Token::OpenBrace => {
+                bra_stack.push(Token::CloseBrace);
+            }
+            Token::Semicolon => {
+                if bra_stack.is_empty() {
+                    return Ok((remain, ()));
+                }
+            }
+            Token::CloseBrace if bra_stack.is_empty() => {
+                // The stack is empty, so don't include the closing brace.
+                return Ok((rest, ()));
+            }
+            // Standard closing brackets
+            Token::CDC | Token::CloseSquare | Token::CloseRound | Token::CloseBrace => {
+                if bra_stack.last() == Some(&tok) {
+                    bra_stack.pop();
+
+                    if tok == Token::CloseBrace && bra_stack.is_empty() {
+                        // The rule lasted until the end of the next block;
+                        // eat this closing brace.
+                        return Ok((remain, ()));
+                    }
+                } else {
+                    // Unbalanced brackets
+                    return fail(rest);
+                }
+            }
+        }
+        rest = remain;
+    }
+}
+
+fn parse_at_rule(text: &str) -> IResult<&str, ()> {
+    let (rest, _) = tuple((
+        skip_optional_whitespace,
+        tag("@"),
+        skip_optional_whitespace,
+        parse_ident,
+    ))(text)?;
+
+    skip_to_end_of_statement(rest)
+}
+
+fn parse_statement(text: &str) -> IResult<&str, Option<RuleSet>> {
+    alt((map(parse_ruleset, Some), map(parse_at_rule, |_| None)))(text)
+}
+
 pub(crate) fn parse_stylesheet(text: &str) -> IResult<&str, Vec<RuleSet>> {
-    many0(parse_ruleset)(text)
+    let (rest, items) = many0(parse_statement)(text)?;
+    Ok((rest, items.into_iter().flatten().collect()))
 }
 
 #[cfg(test)]
@@ -1004,6 +1180,56 @@ mod test {
         );
     }
 
+    #[test]
+    fn test_parse_at_rules() {
+        assert_eq!(
+            super::parse_stylesheet(
+                "
+            @media paper {
+            }
+
+            @blah asldfkjasfda;
+
+            @nested { lkasjfd alkdsjfa sldkfjas ( alksjdasfd ) [ alskdjfalskdf] }
+
+            @keyframes foo {
+                0% { transform: translateY(0); }
+               50% { opacity:0.8; }
+              100% { }
+            }
+
+
+            .foo {
+                color: #112233;
+                background-color: #332211 !important;
+            }
+            "
+            ),
+            Ok((
+                "",
+                vec![RuleSet {
+                    selectors: vec![Selector {
+                        components: vec![SelectorComponent::Class("foo".into()),],
+                    },],
+                    declarations: vec![
+                        Declaration {
+                            data: Decl::Color {
+                                value: Colour::Rgb(0x11, 0x22, 0x33)
+                            },
+                            important: Importance::Default,
+                        },
+                        Declaration {
+                            data: Decl::BackgroundColor {
+                                value: Colour::Rgb(0x33, 0x22, 0x11)
+                            },
+                            important: Importance::Important,
+                        },
+                    ],
+                }]
+            ))
+        );
+    }
+
     #[test]
     fn test_parse_named_colour() {
         assert_eq!(
@@ -1075,4 +1301,140 @@ mod test {
             ))
         );
     }
+
+    #[test]
+    fn test_nth_child() {
+        use SelectorComponent::NthChild;
+        let (_, sel_all) = super::parse_selector("*").unwrap();
+        assert_eq!(
+            super::parse_selector(":nth-child(even)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: 2,
+                        b: 0,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(odd)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: 2,
+                        b: 1,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(17)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: 0,
+                        b: 17,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(17n)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: 17,
+                        b: 0,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(10n-1)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: 10,
+                        b: -1,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(10n+9)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: 10,
+                        b: 9,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(-n+3)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: -1,
+                        b: 3,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(n)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: 1,
+                        b: 0,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(+n)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: 1,
+                        b: 0,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+        assert_eq!(
+            super::parse_selector(":nth-child(-n)").unwrap(),
+            (
+                "",
+                Selector {
+                    components: vec![NthChild {
+                        a: -1,
+                        b: 0,
+                        sel: sel_all.clone()
+                    }]
+                }
+            )
+        );
+    }
 }
diff --git a/src/lib.rs b/src/lib.rs
index e6f2c7f..41bea8a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -127,6 +127,12 @@ pub struct Colour {
     pub b: u8,
 }
 
+impl std::fmt::Display for Colour {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
+    }
+}
+
 #[derive(Debug, Copy, Clone, PartialEq, Eq, Default, PartialOrd)]
 pub(crate) enum StyleOrigin {
     #[default]
@@ -158,6 +164,28 @@ impl Specificity {
     }
 }
 
+impl std::ops::Add<&Specificity> for &Specificity {
+    type Output = Specificity;
+
+    fn add(self, rhs: &Specificity) -> Self::Output {
+        Specificity {
+            inline: self.inline || rhs.inline,
+            id: self.id + rhs.id,
+            class: self.class + rhs.class,
+            typ: self.typ + rhs.typ,
+        }
+    }
+}
+
+impl std::ops::AddAssign<&Specificity> for Specificity {
+    fn add_assign(&mut self, rhs: &Specificity) {
+        self.inline = self.inline || rhs.inline;
+        self.id += rhs.id;
+        self.class += rhs.class;
+        self.typ += rhs.typ;
+    }
+}
+
 impl PartialOrd for Specificity {
     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
         match self.inline.partial_cmp(&other.inline) {
@@ -248,7 +276,7 @@ pub(crate) struct ComputedStyle {
     pub(crate) bg_colour: WithSpec<Colour>,
     #[cfg(feature = "css")]
     /// If set, indicates whether `display: none` or something equivalent applies
-    pub(crate) display_none: WithSpec<bool>,
+    pub(crate) display: WithSpec<css::Display>,
     /// The CSS white-space property
     pub(crate) white_space: WithSpec<WhiteSpace>,
 
@@ -808,8 +836,8 @@ impl RenderNode {
             if let Some(col) = style.bg_colour.val() {
                 write!(f, " bg_colour={:?}", col)?;
             }
-            if let Some(val) = style.display_none.val() {
-                write!(f, " disp_none={:?}", val)?;
+            if let Some(val) = style.display.val() {
+                write!(f, " disp={:?}", val)?;
             }
         }
         if let Some(ws) = style.white_space.val() {
@@ -1343,6 +1371,15 @@ fn dom_to_render_tree_with_context<T: Write>(
     result
 }
 
+#[cfg(feature = "css")]
+/// Return a string representation of the CSS rules parsed from
+/// the DOM document.
+pub fn dom_to_parsed_style(dom: &RcDom) -> Result<String> {
+    let handle = dom.document.clone();
+    let doc_style_data = css::dom_to_stylesheet(handle, &mut std::io::sink())?;
+    Ok(doc_style_data.to_string())
+}
+
 fn pending<F>(
     input: RenderInput,
     f: F,
@@ -1479,8 +1516,25 @@ fn process_dom_node<T: Write>(
                     context
                         .style_data
                         .computed_style(**parent_style, handle, context.use_doc_css);
-                if let Some(true) = computed.display_none.val() {
-                    return Ok(Nothing);
+                match computed.display.val() {
+                    Some(css::Display::None) => return Ok(Nothing),
+                    #[cfg(feature = "css_ext")]
+                    Some(css::Display::ExtRawDom) => {
+                        let result_text = RcDom::node_as_dom_string(handle);
+                        let mut computed = computed;
+                        computed.white_space.maybe_update(
+                            false,
+                            StyleOrigin::Agent,
+                            Default::default(),
+                            WhiteSpace::Pre,
+                        );
+                        let text = RenderNode::new(RenderNodeInfo::Text(result_text));
+                        return Ok(Finished(RenderNode::new_styled(
+                            RenderNodeInfo::Block(vec![text]),
+                            computed,
+                        )));
+                    }
+                    _ => (),
                 }
                 computed
             };
diff --git a/src/markup5ever_rcdom.rs b/src/markup5ever_rcdom.rs
index 92b53dd..dfdccd9 100644
--- a/src/markup5ever_rcdom.rs
+++ b/src/markup5ever_rcdom.rs
@@ -47,6 +47,7 @@ use std::io;
 use std::mem;
 use std::rc::{Rc, Weak};
 
+use html5ever::interface::ElemName;
 use tendril::StrTendril;
 
 use markup5ever::interface::tree_builder;
@@ -122,6 +123,39 @@ impl Node {
             children: RefCell::new(Vec::new()),
         })
     }
+
+    pub fn get_parent(&self) -> Option<Rc<Self>> {
+        if let Some(parent) = self.parent.take() {
+            let parent_handle = parent.upgrade();
+            self.parent.set(Some(parent));
+            parent_handle
+        } else {
+            None
+        }
+    }
+
+    /// Return the nth child element of this node, or None.
+    pub fn nth_child(&self, idx: usize) -> Option<Rc<Self>> {
+        let mut element_idx = 0;
+        for child in self.children.borrow().iter() {
+            if let NodeData::Element { .. } = child.data {
+                element_idx += 1;
+                if element_idx == idx {
+                    return Some(child.clone());
+                }
+            }
+        }
+        None
+    }
+
+    /// Return the element type (if an element)
+    pub fn element_name(&self) -> Option<String> {
+        if let NodeData::Element { ref name, .. } = self.data {
+            Some(format!("{}", &*name.local_name()))
+        } else {
+            None
+        }
+    }
 }
 
 impl Drop for Node {
@@ -250,6 +284,26 @@ impl RcDom {
         Self::add_node_to_string(&mut s, &self.document, 0);
         s
     }
+
+    /// A low-quality debug DOM rendering of an individual node
+    pub fn node_as_dom_string(node: &Handle) -> String {
+        let mut s = String::new();
+        Self::add_node_to_string(&mut s, node, 0);
+        s
+    }
+
+    /// Find the node at a child path starting from the root element.  At each level, 1 is the
+    /// first child element, and only elements are counted.
+    pub fn get_node_by_path(&self, path: &[usize]) -> Option<Handle> {
+        let mut node = self.document.clone();
+        for idx in path {
+            node = match node.nth_child(*idx) {
+                Some(new_node) => new_node,
+                None => return None,
+            };
+        }
+        Some(node)
+    }
 }
 
 impl TreeSink for RcDom {
diff --git a/src/render/text_renderer.rs b/src/render/text_renderer.rs
index c584ebc..063ba1b 100644
--- a/src/render/text_renderer.rs
+++ b/src/render/text_renderer.rs
@@ -318,7 +318,6 @@ impl<T: Debug + Eq + PartialEq + Clone + Default> TaggedLine<T> {
 struct WrappedBlock<T> {
     width: usize,
     text: Vec<TaggedLine<T>>,
-    textlen: usize,
     line: TaggedLine<T>,
     spacetag: Option<T>, // Tag for the whitespace before the current word
     word: TaggedLine<T>, // The current word (with no whitespace).
@@ -334,7 +333,6 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
         WrappedBlock {
             width,
             text: Vec::new(),
-            textlen: 0,
             line: TaggedLine::new(),
             spacetag: None,
             word: TaggedLine::new(),
@@ -646,7 +644,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
     }
 
     fn text_len(&self) -> usize {
-        self.textlen + self.line.len + self.wordlen
+        self.text.len() + self.line.len + self.wordlen
     }
 
     fn is_empty(&self) -> bool {


Test patch:
diff --git a/src/tests.rs b/src/tests.rs
index d78298f..c06c12c 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -2643,4 +2643,128 @@ at  line  breaks
             },
         );
     }
+
+    #[test]
+    fn test_nth_child() {
+        test_html_coloured(
+            br#"
+          <style>
+              li:nth-child(even) {
+                  color: #f00;
+              }
+          </style>
+          <body><ul>
+              <li>One</li>
+              <li>Two</li>
+              <li>Three</li>
+              <li>Four</li>
+              <li>Five</li>
+          </ul>"#,
+            r#"* One
+* <R>Two</R>
+* Three
+* <R>Four</R>
+* Five
+"#,
+            20,
+        );
+        test_html_coloured(
+            br#"
+          <style>
+              li:nth-child(odd) {
+                  color: #f00;
+              }
+          </style>
+          <body><ul>
+              <li>One</li>
+              <li>Two</li>
+              <li>Three</li>
+              <li>Four</li>
+              <li>Five</li>
+          </ul>"#,
+            r#"* <R>One</R>
+* Two
+* <R>Three</R>
+* Four
+* <R>Five</R>
+"#,
+            20,
+        );
+        test_html_coloured(
+            br#"
+          <style>
+              li:nth-child(-n+3) {
+                  color: #f00;
+              }
+          </style>
+          <body><ul>
+              <li>One</li>
+              <li>Two</li>
+              <li>Three</li>
+              <li>Four</li>
+              <li>Five</li>
+          </ul>"#,
+            r#"* <R>One</R>
+* <R>Two</R>
+* <R>Three</R>
+* Four
+* Five
+"#,
+            20,
+        );
+        test_html_coloured(
+            br#"
+          <style>
+              li:nth-child(2) {
+                  color: #f00;
+              }
+          </style>
+          <body><ul>
+              <li>One</li>
+              <li>Two</li>
+              <li>Three</li>
+              <li>Four</li>
+              <li>Five</li>
+          </ul>"#,
+            r#"* One
+* <R>Two</R>
+* Three
+* Four
+* Five
+"#,
+            20,
+        );
+        test_html_coloured(
+            br#"
+          <style>
+              li:nth-child(n+3):nth-child(-n+5) {
+                  color: #f00;
+              }
+          </style>
+          <body><ul>
+              <li>One</li>
+              <li>Two</li>
+              <li>Three</li>
+              <li>Four</li>
+              <li>Five</li>
+              <li>Six</li>
+              <li>Seven</li>
+              <li>Eight</li>
+              <li>Nine</li>
+              <li>Ten</li>
+          </ul>"#,
+            r#"* One
+* Two
+* <R>Three</R>
+* <R>Four</R>
+* <R>Five</R>
+* Six
+* Seven
+* Eight
+* Nine
+* Ten
+"#,
+            20,
+        );
+    }
 }