Repository: jugglerchris/rust-html2text
Pull request #199 (2025-02-04 06:59:59)

Use CSS for simple decorations.
Instead of using the decorator to add decorations around `<em>` and similar tags, we internally now use CSS features instead - this means it can be enabled or not independent of the decorator.

* Add new `config::plain_no_decorate()`, which leaves off the decoration around `*bold*` text etc.
* `config::plain()` is equivalent to `config::plain_no_decorate().do_decorate()`
* The `RichDecorator` no longer adds these decorations by default, but `config::rich().do_decorate()` is available.
* With the `css` feature enabled, `::before` and `::after` pseudo-elements are supported along with the `content: "foo"` style, which is used to implement the above.  This can be used to tweak the decoration with, for example, `add_agent_css()`.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dfe354e..6f16f91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,8 +14,8 @@ Possible log types:
 ### 0.14.0
 
 - [changed] Various small refactors (thanks sftse)
-- [added] New `config::rich_no_decorate`, to use annotations without '\*' markers around 
-  bold text etc.
+- [changed] `Config::rich()` no longer includes decorations around `<em>` etc. - 
+  use `Config::rich().do_decorate()` to get the old behaviour.
 
 ### 0.13.6
 
diff --git a/examples/html2text.rs b/examples/html2text.rs
index e2ff163..b69c288 100644
--- a/examples/html2text.rs
+++ b/examples/html2text.rs
@@ -109,11 +109,7 @@ where
     #[cfg(unix)]
     {
         if flags.use_colour {
-            let conf = if flags.no_decorate {
-                config::rich_no_decorate()
-            } else {
-                config::rich()
-            };
+            let conf = config::rich();
             let conf = update_config(conf, &flags);
             #[cfg(feature = "css")]
             let use_css_colours = !flags.ignore_css_colours;
@@ -167,8 +163,6 @@ struct Flags {
     wrap_width: Option<usize>,
     #[allow(unused)]
     use_colour: bool,
-    #[allow(unused)]
-    no_decorate: bool,
     #[cfg(feature = "css")]
     use_css: bool,
     #[cfg(feature = "css")]
@@ -191,7 +185,6 @@ fn main() {
         width: 80,
         wrap_width: None,
         use_colour: false,
-        no_decorate: false,
         #[cfg(feature = "css")]
         use_css: false,
         #[cfg(feature = "css")]
@@ -238,12 +231,6 @@ fn main() {
             StoreTrue,
             "Use ANSI terminal colours",
         );
-        #[cfg(unix)]
-        ap.refer(&mut flags.no_decorate).add_option(
-            &["--no-decorate"],
-            StoreTrue,
-            "Skip decorations (with --colour)",
-        );
         #[cfg(feature = "css")]
         ap.refer(&mut flags.use_css)
             .add_option(&["--css"], StoreTrue, "Enable CSS");
diff --git a/src/css.rs b/src/css.rs
index 68256e9..74ef1a3 100644
--- a/src/css.rs
+++ b/src/css.rs
@@ -1,22 +1,28 @@
 //! Some basic CSS support.
 use std::ops::Deref;
-use std::{io::Write, rc::Rc};
+use std::rc::Rc;
 
+#[cfg(feature = "css")]
 mod parser;
+pub(crate) mod types;
+
+#[cfg(feature = "css")]
+use crate::{Colour, Result, WhiteSpace};
+#[cfg(feature = "css")]
+use parser::parse_style_attribute;
+
+use types::Importance;
 
 use crate::{
-    css::parser::parse_rules,
     markup5ever_rcdom::{
         Handle,
         NodeData::{self, Comment, Document, Element},
     },
-    tree_map_reduce, Colour, ComputedStyle, Result, Specificity, StyleOrigin, TreeMapResult,
-    WhiteSpace,
+    ComputedStyle, Specificity, StyleOrigin,
 };
 
-use self::parser::Importance;
-
 #[derive(Debug, Clone, PartialEq, Eq)]
+#[allow(unused)]
 pub(crate) enum SelectorComponent {
     Class(String),
     Element(String),
@@ -32,6 +38,12 @@ pub(crate) enum SelectorComponent {
     },
 }
 
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum PseudoElement {
+    Before,
+    After,
+}
+
 impl std::fmt::Display for SelectorComponent {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
@@ -46,10 +58,11 @@ impl std::fmt::Display for SelectorComponent {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub(crate) struct Selector {
     // List of components, right first so we match from the leaf.
-    components: Vec<SelectorComponent>,
+    pub(crate) components: Vec<SelectorComponent>,
+    pub(crate) pseudo_element: Option<PseudoElement>,
 }
 
 impl std::fmt::Display for Selector {
@@ -57,6 +70,11 @@ impl std::fmt::Display for Selector {
         for comp in self.components.iter().rev() {
             comp.fmt(f)?;
         }
+        match self.pseudo_element {
+            Some(PseudoElement::Before) => write!(f, "::before")?,
+            Some(PseudoElement::After) => write!(f, "::after")?,
+            None => (),
+        }
         Ok(())
     }
 }
@@ -189,6 +207,7 @@ impl Selector {
     }
 }
 
+#[cfg(feature = "css")]
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub(crate) enum Display {
     /// display: none
@@ -198,29 +217,49 @@ pub(crate) enum Display {
     ExtRawDom,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct PseudoContent {
+    /// content: "foo"
+    pub(crate) text: String,
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub(crate) enum Style {
+    #[cfg(feature = "css")]
     Colour(Colour),
+    #[cfg(feature = "css")]
     BgColour(Colour),
+    #[cfg(feature = "css")]
     Display(Display),
+    #[cfg(feature = "css")]
     WhiteSpace(WhiteSpace),
+    Content(PseudoContent),
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub(crate) struct StyleDecl {
-    style: Style,
-    importance: Importance,
+    pub(crate) style: Style,
+    pub(crate) importance: Importance,
 }
 
 impl std::fmt::Display for StyleDecl {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self.style {
+        match &self.style {
+            #[cfg(feature = "css")]
             Style::Colour(col) => write!(f, "color: {}", col)?,
+            #[cfg(feature = "css")]
             Style::BgColour(col) => write!(f, "background-color: {}", col)?,
+            #[cfg(feature = "css")]
             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!(),
+            #[cfg(feature = "css")]
+            Style::WhiteSpace(ws) => match ws {
+                WhiteSpace::Normal => write!(f, "white-space: normal")?,
+                WhiteSpace::Pre => write!(f, "white-space: pre")?,
+                WhiteSpace::PreWrap => write!(f, "white-space: pre-wrap")?,
+            },
+            Style::Content(content) => write!(f, "content: \"{}\"", content.text)?,
         }
         match self.importance {
             Importance::Default => (),
@@ -231,9 +270,9 @@ impl std::fmt::Display for StyleDecl {
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]
-struct Ruleset {
-    selector: Selector,
-    styles: Vec<StyleDecl>,
+pub(crate) struct Ruleset {
+    pub(crate) selector: Selector,
+    pub(crate) styles: Vec<StyleDecl>,
 }
 
 impl std::fmt::Display for Ruleset {
@@ -255,15 +294,7 @@ pub(crate) struct StyleData {
     author_rules: Vec<Ruleset>,
 }
 
-pub(crate) fn parse_style_attribute(text: &str) -> Result<Vec<StyleDecl>> {
-    html_trace_quiet!("Parsing inline style: {text}");
-    let (_rest, decls) = parse_rules(text).map_err(|_| crate::Error::CssParseError)?;
-
-    let styles = styles_from_properties(&decls);
-    html_trace_quiet!("Parsed inline style: {:?}", styles);
-    Ok(styles)
-}
-
+#[cfg(feature = "css")]
 fn styles_from_properties(decls: &[parser::Declaration]) -> Vec<StyleDecl> {
     let mut styles = Vec::new();
     html_trace_quiet!("styles:from_properties2: {decls:?}");
@@ -343,6 +374,12 @@ fn styles_from_properties(decls: &[parser::Declaration]) -> Vec<StyleDecl> {
                     style: Style::WhiteSpace(*value),
                     importance: decl.important,
                 });
+            }
+            parser::Decl::Content { text } => {
+                styles.push(StyleDecl {
+                    style: Style::Content(PseudoContent { text: text.clone() }),
+                    importance: decl.important,
+                });
             } /*
               _ => {
                   html_trace_quiet!("CSS: Unhandled property {:?}", decl);
@@ -361,6 +398,7 @@ fn styles_from_properties(decls: &[parser::Declaration]) -> Vec<StyleDecl> {
 }
 
 impl StyleData {
+    #[cfg(feature = "css")]
     /// Add some CSS source to be included.  The source will be parsed
     /// and the relevant and supported features extracted.
     fn do_add_css(css: &str, rules: &mut Vec<Ruleset>) -> Result<()> {
@@ -381,21 +419,32 @@ impl StyleData {
         }
         Ok(())
     }
+
+    pub(crate) fn add_agent_rules(&mut self, rules: &[Ruleset]) {
+        for rule in rules {
+            self.agent_rules.push(rule.clone());
+        }
+    }
+
+    #[cfg(feature = "css")]
     /// Add some CSS source to be included as part of the user agent ("browser") CSS rules.
     pub fn add_agent_css(&mut self, css: &str) -> Result<()> {
         Self::do_add_css(css, &mut self.agent_rules)
     }
 
+    #[cfg(feature = "css")]
     /// Add some CSS source to be included as part of the user CSS rules.
     pub fn add_user_css(&mut self, css: &str) -> Result<()> {
         Self::do_add_css(css, &mut self.user_rules)
     }
 
+    #[cfg(feature = "css")]
     /// Add some CSS source to be included as part of the document/author CSS rules.
     pub fn add_author_css(&mut self, css: &str) -> Result<()> {
         Self::do_add_css(css, &mut self.author_rules)
     }
 
+    #[cfg(feature = "css")]
     /// Merge style data from other into this one.
     /// Data on other takes precedence.
     pub fn merge(&mut self, other: Self) {
@@ -406,11 +455,11 @@ impl StyleData {
 
     pub(crate) fn computed_style(
         &self,
-        parent_style: ComputedStyle,
+        parent_style: &ComputedStyle,
         handle: &Handle,
-        use_doc_css: bool,
+        _use_doc_css: bool,
     ) -> ComputedStyle {
-        let mut result = parent_style;
+        let mut result = parent_style.inherit();
 
         for (origin, ruleset) in [
             (StyleOrigin::Agent, &self.agent_rules),
@@ -425,6 +474,7 @@ impl StyleData {
                             style.importance == Importance::Important,
                             origin,
                             rule.selector.specificity(),
+                            rule.selector.pseudo_element.as_ref(),
                             style,
                         );
                     }
@@ -432,7 +482,8 @@ impl StyleData {
             }
         }
 
-        if use_doc_css {
+        #[cfg(feature = "css")]
+        if _use_doc_css {
             // Now look for a style attribute
             if let Element { attrs, .. } = &handle.data {
                 let borrowed = attrs.borrow();
@@ -445,6 +496,7 @@ impl StyleData {
                                 false,
                                 StyleOrigin::Author,
                                 Specificity::inline(),
+                                None,
                                 &style,
                             );
                         }
@@ -455,6 +507,7 @@ impl StyleData {
                                 false,
                                 StyleOrigin::Author,
                                 Specificity::inline(),
+                                None,
                                 &StyleDecl {
                                     style: Style::Colour(colour.into()),
                                     importance: Importance::Default,
@@ -468,6 +521,7 @@ impl StyleData {
                                 false,
                                 StyleOrigin::Author,
                                 Specificity::inline(),
+                                None,
                                 &StyleDecl {
                                     style: Style::BgColour(colour.into()),
                                     importance: Importance::Default,
@@ -487,8 +541,18 @@ impl StyleData {
         important: bool,
         origin: StyleOrigin,
         specificity: Specificity,
+        pseudo_selectors: Option<&PseudoElement>,
         style: &StyleDecl,
     ) {
+        let result_target = match pseudo_selectors {
+            None => result,
+            Some(PseudoElement::Before) => {
+                // TODO: ideally we should inherit from the parent; however we haven't finished
+                // computing the parent yet.
+                result.content_before.get_or_insert_with(Default::default)
+            }
+            Some(PseudoElement::After) => result.content_after.get_or_insert_with(Default::default),
+        };
         // The increasing priority is:
         // * agent
         // * user
@@ -500,27 +564,36 @@ impl StyleData {
         // replace the value if we haven't yet seen an !important rule, and
         // never afterwards.
         match style.style {
+            #[cfg(feature = "css")]
             Style::Colour(col) => {
-                result
+                result_target
                     .colour
                     .maybe_update(important, origin, specificity, col);
             }
+            #[cfg(feature = "css")]
             Style::BgColour(col) => {
-                result
+                result_target
                     .bg_colour
                     .maybe_update(important, origin, specificity, col);
             }
+            #[cfg(feature = "css")]
             Style::Display(disp) => {
                 // We don't have a "not DisplayNone" - we might need to fix this.
-                result
+                result_target
                     .display
                     .maybe_update(important, origin, specificity, disp);
             }
+            #[cfg(feature = "css")]
             Style::WhiteSpace(ws) => {
-                result
+                result_target
                     .white_space
                     .maybe_update(important, origin, specificity, ws);
             }
+            Style::Content(ref content) => {
+                result_target
+                    .content
+                    .maybe_update(important, origin, specificity, content.clone());
+            }
         }
     }
 }
@@ -549,82 +622,101 @@ impl std::fmt::Display for StyleData {
     }
 }
 
-fn pending<F>(handle: Handle, f: F) -> TreeMapResult<'static, (), Handle, Vec<String>>
-where
-    F: Fn(&mut (), Vec<Vec<String>>) -> Result<Option<Vec<String>>> + 'static,
-{
-    TreeMapResult::PendingChildren {
-        children: handle.children.borrow().clone(),
-        cons: Box::new(f),
-        prefn: None,
-        postfn: None,
+#[cfg(feature = "css")]
+pub(crate) mod dom_extract {
+    use std::io::Write;
+
+    use crate::{
+        markup5ever_rcdom::{
+            Handle,
+            NodeData::{self, Comment, Document, Element},
+        },
+        tree_map_reduce, Result, TreeMapResult,
+    };
+
+    use super::StyleData;
+
+    fn pending<F>(handle: Handle, f: F) -> TreeMapResult<'static, (), Handle, Vec<String>>
+    where
+        F: Fn(&mut (), Vec<Vec<String>>) -> Result<Option<Vec<String>>> + 'static,
+    {
+        TreeMapResult::PendingChildren {
+            children: handle.children.borrow().clone(),
+            cons: Box::new(f),
+            prefn: None,
+            postfn: None,
+        }
     }
-}
 
-fn combine_vecs(vecs: Vec<Vec<String>>) -> Vec<String> {
-    let mut it = vecs.into_iter();
-    let first = it.next();
-    match first {
-        None => Vec::new(),
-        Some(mut first) => {
-            for v in it {
-                first.extend(v.into_iter());
+    fn combine_vecs(vecs: Vec<Vec<String>>) -> Vec<String> {
+        let mut it = vecs.into_iter();
+        let first = it.next();
+        match first {
+            None => Vec::new(),
+            Some(mut first) => {
+                for v in it {
+                    first.extend(v.into_iter());
+                }
+                first
             }
-            first
         }
     }
-}
 
-fn extract_style_nodes<T: Write>(
-    handle: Handle,
-    _err_out: &mut T,
-) -> TreeMapResult<'static, (), Handle, Vec<String>> {
-    use TreeMapResult::*;
-
-    match handle.clone().data {
-        Document => pending(handle, |&mut (), cs| Ok(Some(combine_vecs(cs)))),
-        Comment { .. } => Nothing,
-        Element { ref name, .. } => {
-            match name.expanded() {
-                expanded_name!(html "style") => {
-                    let mut result = String::new();
-                    // Assume just a flat text node
-                    for child in handle.children.borrow().iter() {
-                        if let NodeData::Text { ref contents } = child.data {
-                            result += &contents.borrow();
+    fn extract_style_nodes<T: Write>(
+        handle: Handle,
+        _err_out: &mut T,
+    ) -> TreeMapResult<'static, (), Handle, Vec<String>> {
+        use TreeMapResult::*;
+
+        match handle.clone().data {
+            Document => pending(handle, |&mut (), cs| Ok(Some(combine_vecs(cs)))),
+            Comment { .. } => Nothing,
+            Element { ref name, .. } => {
+                match name.expanded() {
+                    expanded_name!(html "style") => {
+                        let mut result = String::new();
+                        // Assume just a flat text node
+                        for child in handle.children.borrow().iter() {
+                            if let NodeData::Text { ref contents } = child.data {
+                                result += &contents.borrow();
+                            }
                         }
+                        Finished(vec![result])
                     }
-                    Finished(vec![result])
+                    _ => pending(handle, |_, cs| Ok(Some(combine_vecs(cs)))),
                 }
-                _ => pending(handle, |_, cs| Ok(Some(combine_vecs(cs)))),
             }
-        }
-        NodeData::Text {
-            contents: ref _tstr,
-        } => Nothing,
-        _ => {
-            // NodeData doesn't have a Debug impl.
-            Nothing
+            NodeData::Text {
+                contents: ref _tstr,
+            } => Nothing,
+            _ => {
+                // NodeData doesn't have a Debug impl.
+                Nothing
+            }
         }
     }
-}
 
-/// Extract stylesheet data from document.
-pub(crate) fn dom_to_stylesheet<T: Write>(handle: Handle, err_out: &mut T) -> Result<StyleData> {
-    let styles = tree_map_reduce(&mut (), handle, |_, handle| {
-        Ok(extract_style_nodes(handle, err_out))
-    })?;
-
-    let mut result = StyleData::default();
-    if let Some(styles) = styles {
-        for css in styles {
-            // Ignore CSS parse errors.
-            let _ = result.add_author_css(&css);
+    /// Extract stylesheet data from document.
+    pub(crate) fn dom_to_stylesheet<T: Write>(
+        handle: Handle,
+        err_out: &mut T,
+    ) -> Result<StyleData> {
+        let styles = tree_map_reduce(&mut (), handle, |_, handle| {
+            Ok(extract_style_nodes(handle, err_out))
+        })?;
+
+        let mut result = StyleData::default();
+        if let Some(styles) = styles {
+            for css in styles {
+                // Ignore CSS parse errors.
+                let _ = result.add_author_css(&css);
+            }
         }
+        Ok(result)
     }
-    Ok(result)
 }
 
+#[cfg(feature = "css")]
 #[cfg(test)]
 mod tests {
     use crate::Specificity;
diff --git a/src/css/parser.rs b/src/css/parser.rs
index c4de5ef..0587ce8 100644
--- a/src/css/parser.rs
+++ b/src/css/parser.rs
@@ -92,6 +92,9 @@ pub(crate) enum Decl {
     WhiteSpace {
         value: WhiteSpace,
     },
+    Content {
+        text: String,
+    },
     Unknown {
         name: PropertyName,
         //        value: Vec<Token>,
@@ -167,7 +170,9 @@ pub(crate) struct Declaration {
     pub important: Importance,
 }
 
-use super::{Selector, SelectorComponent, WhiteSpace};
+use crate::css::styles_from_properties;
+
+use super::{types::Importance, PseudoElement, Selector, SelectorComponent, StyleDecl, WhiteSpace};
 
 #[derive(Debug, PartialEq)]
 pub(crate) struct RuleSet {
@@ -410,12 +415,6 @@ fn parse_faulty_color(
     Err(e)
 }
 
-#[derive(Copy, Clone, PartialEq, Eq, Debug)]
-pub(crate) enum Importance {
-    Default,
-    Important,
-}
-
 pub(crate) fn parse_declaration(text: &str) -> IResult<&str, Option<Declaration>> {
     let (rest, (prop, _ws1, _colon, _ws2, value)) = tuple((
         parse_property_name,
@@ -485,6 +484,13 @@ pub(crate) fn parse_declaration(text: &str) -> IResult<&str, Option<Declaration>
                 Decl::Unknown { name: prop }
             }
         }
+        "content" => {
+            if let Ok(value) = parse_content(&value) {
+                Decl::Content { text: value }
+            } else {
+                Decl::Unknown { name: prop }
+            }
+        }
         _ => Decl::Unknown {
             name: prop,
             //            value: /*value*/"".into(),
@@ -768,6 +774,19 @@ fn parse_white_space(
     Ok(WhiteSpace::Normal)
 }
 
+// Parse content - currently only support a single string.
+fn parse_content(value: &RawValue) -> Result<String, nom::Err<nom::error::Error<&'static str>>> {
+    let mut result = String::new();
+    for tok in &value.tokens {
+        if let Token::String(word) = tok {
+            result.push_str(word);
+        } else {
+            return Err(empty_fail());
+        }
+    }
+    Ok(result)
+}
+
 pub(crate) fn parse_rules(text: &str) -> IResult<&str, Vec<Declaration>> {
     separated_list0(
         tuple((tag(";"), skip_optional_whitespace)),
@@ -856,6 +875,7 @@ fn parse_nth_child_args(text: &str) -> IResult<&str, SelectorComponent> {
 
     let sel = Selector {
         components: vec![SelectorComponent::Star],
+        ..Default::default()
     };
     Ok((rest, SelectorComponent::NthChild { a, b, sel }))
 }
@@ -913,6 +933,13 @@ fn parse_selector_without_element(text: &str) -> IResult<&str, Vec<SelectorCompo
     many1(parse_simple_selector_component)(text)
 }
 
+pub(crate) fn parse_pseudo_element(text: &str) -> IResult<&str, Option<PseudoElement>> {
+    opt(alt((
+        map(tag("::before"), |_| PseudoElement::Before),
+        map(tag("::after"), |_| PseudoElement::After),
+    )))(text)
+}
+
 pub(crate) fn parse_selector(text: &str) -> IResult<&str, Selector> {
     let (rest, mut components) = alt((
         parse_selector_with_element,
@@ -928,7 +955,15 @@ pub(crate) fn parse_selector(text: &str) -> IResult<&str, Selector> {
     if let Some(&SelectorComponent::CombDescendant) = components.last() {
         components.pop();
     }
-    Ok((rest, Selector { components }))
+
+    let (rest, pseudo_element) = parse_pseudo_element(rest)?;
+    Ok((
+        rest,
+        Selector {
+            components,
+            pseudo_element,
+        },
+    ))
 }
 
 fn parse_ruleset(text: &str) -> IResult<&str, RuleSet> {
@@ -1040,11 +1075,20 @@ pub(crate) fn parse_stylesheet(text: &str) -> IResult<&str, Vec<RuleSet>> {
     Ok((rest, items.into_iter().flatten().collect()))
 }
 
+pub(crate) fn parse_style_attribute(text: &str) -> crate::Result<Vec<StyleDecl>> {
+    html_trace_quiet!("Parsing inline style: {text}");
+    let (_rest, decls) = parse_rules(text).map_err(|_| crate::Error::CssParseError)?;
+
+    let styles = styles_from_properties(&decls);
+    html_trace_quiet!("Parsed inline style: {:?}", styles);
+    Ok(styles)
+}
+
 #[cfg(test)]
 mod test {
     use crate::css::{
         parser::{Height, Importance, LengthUnit, RuleSet, Selector},
-        SelectorComponent,
+        PseudoElement, SelectorComponent,
     };
 
     use super::{Colour, Decl, Declaration, Overflow, PropertyName};
@@ -1170,6 +1214,7 @@ mod test {
                 vec![RuleSet {
                     selectors: vec![Selector {
                         components: vec![SelectorComponent::Element("foo".into()),],
+                        ..Default::default()
                     },],
                     declarations: vec![Declaration {
                         data: Decl::Color {
@@ -1198,6 +1243,7 @@ mod test {
                 vec![RuleSet {
                     selectors: vec![Selector {
                         components: vec![SelectorComponent::Class("foo".into()),],
+                        ..Default::default()
                     },],
                     declarations: vec![
                         Declaration {
@@ -1248,6 +1294,7 @@ mod test {
                 vec![RuleSet {
                     selectors: vec![Selector {
                         components: vec![SelectorComponent::Class("foo".into()),],
+                        ..Default::default()
                     },],
                     declarations: vec![
                         Declaration {
@@ -1321,6 +1368,7 @@ mod test {
                                 SelectorComponent::CombDescendant,
                                 SelectorComponent::Class("foo".into()),
                             ],
+                            ..Default::default()
                         },],
                         declarations: vec![Declaration {
                             data: Decl::Unknown {
@@ -1339,6 +1387,7 @@ mod test {
                                 SelectorComponent::CombDescendant,
                                 SelectorComponent::Class("foo".into()),
                             ],
+                            ..Default::default()
                         },],
                         declarations: vec![Declaration {
                             data: Decl::Color {
@@ -1352,6 +1401,114 @@ mod test {
         );
     }
 
+    #[test]
+    fn test_parse_comma_selector() {
+        assert_eq!(
+            super::parse_stylesheet(
+                "
+.foo a, p  { color: #112233; }
+            "
+            ),
+            Ok((
+                "",
+                vec![RuleSet {
+                    selectors: vec![
+                        Selector {
+                            components: vec![
+                                SelectorComponent::Element("a".into()),
+                                SelectorComponent::CombDescendant,
+                                SelectorComponent::Class("foo".into()),
+                            ],
+                            ..Default::default()
+                        },
+                        Selector {
+                            components: vec![SelectorComponent::Element("p".into()),],
+                            ..Default::default()
+                        },
+                    ],
+                    declarations: vec![Declaration {
+                        data: Decl::Color {
+                            value: Colour::Rgb(0x11, 0x22, 0x33)
+                        },
+                        important: Importance::Default,
+                    },],
+                },]
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_before_after() {
+        assert_eq!(
+            super::parse_stylesheet(
+                "
+.foo a::before, p::after  { color: #112233; }
+            "
+            ),
+            Ok((
+                "",
+                vec![RuleSet {
+                    selectors: vec![
+                        Selector {
+                            components: vec![
+                                SelectorComponent::Element("a".into()),
+                                SelectorComponent::CombDescendant,
+                                SelectorComponent::Class("foo".into()),
+                            ],
+                            pseudo_element: Some(PseudoElement::Before),
+                        },
+                        Selector {
+                            components: vec![SelectorComponent::Element("p".into()),],
+                            pseudo_element: Some(PseudoElement::After),
+                        },
+                    ],
+                    declarations: vec![Declaration {
+                        data: Decl::Color {
+                            value: Colour::Rgb(0x11, 0x22, 0x33)
+                        },
+                        important: Importance::Default,
+                    },],
+                },]
+            ))
+        );
+    }
+
+    #[test]
+    fn test_parse_content() {
+        assert_eq!(
+            super::parse_stylesheet(
+                r#"
+.foo a::before, p::after  { content: "blah*#"; }
+            "#
+            ),
+            Ok((
+                "",
+                vec![RuleSet {
+                    selectors: vec![
+                        Selector {
+                            components: vec![
+                                SelectorComponent::Element("a".into()),
+                                SelectorComponent::CombDescendant,
+                                SelectorComponent::Class("foo".into()),
+                            ],
+                            pseudo_element: Some(PseudoElement::Before),
+                        },
+                        Selector {
+                            components: vec![SelectorComponent::Element("p".into()),],
+                            pseudo_element: Some(PseudoElement::After),
+                        },
+                    ],
+                    declarations: vec![Declaration {
+                        data: Decl::Content {
+                            text: "blah*#".into()
+                        },
+                        important: Importance::Default,
+                    },],
+                },]
+            ))
+        );
+    }
+
     #[test]
     fn test_background() {
         assert_eq!(
@@ -1405,7 +1562,8 @@ mod test {
                         a: 2,
                         b: 0,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1418,7 +1576,8 @@ mod test {
                         a: 2,
                         b: 1,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1431,7 +1590,8 @@ mod test {
                         a: 0,
                         b: 17,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1444,7 +1604,8 @@ mod test {
                         a: 17,
                         b: 0,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1457,7 +1618,8 @@ mod test {
                         a: 10,
                         b: -1,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1470,7 +1632,8 @@ mod test {
                         a: 10,
                         b: 9,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1483,7 +1646,8 @@ mod test {
                         a: -1,
                         b: 3,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1496,7 +1660,8 @@ mod test {
                         a: 1,
                         b: 0,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1509,7 +1674,8 @@ mod test {
                         a: 1,
                         b: 0,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
@@ -1522,7 +1688,8 @@ mod test {
                         a: -1,
                         b: 0,
                         sel: sel_all.clone()
-                    }]
+                    }],
+                    ..Default::default()
                 }
             )
         );
diff --git a/src/css/types.rs b/src/css/types.rs
new file mode 100644
index 0000000..dc4ad89
--- /dev/null
+++ b/src/css/types.rs
@@ -0,0 +1,5 @@
+#[derive(Copy, Clone, PartialEq, Eq, Debug)]
+pub(crate) enum Importance {
+    Default,
+    Important,
+}
diff --git a/src/lib.rs b/src/lib.rs
index b0248c6..3c780e6 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -60,14 +60,13 @@ extern crate html5ever;
 #[macro_use]
 mod macros;
 
-#[cfg(feature = "css")]
 pub mod css;
 pub mod render;
 
 use render::text_renderer::{
     RenderLine, RenderOptions, RichAnnotation, SubRenderer, TaggedLine, TextRenderer,
 };
-use render::{Renderer, TextDecorator};
+use render::{Renderer, TextDecorator, TrivialDecorator};
 
 use html5ever::driver::ParseOpts;
 use html5ever::parse_document;
@@ -210,7 +209,7 @@ pub(crate) struct WithSpec<T> {
     specificity: Specificity,
     important: bool,
 }
-impl<T: Copy + Clone> WithSpec<T> {
+impl<T: Clone> WithSpec<T> {
     pub(crate) fn maybe_update(
         &mut self,
         important: bool,
@@ -249,12 +248,12 @@ impl<T: Copy + Clone> WithSpec<T> {
         self.important = important;
     }
 
-    pub fn val(&self) -> Option<T> {
-        self.val
+    pub fn val(&self) -> Option<&T> {
+        self.val.as_ref()
     }
 }
 
-impl<T: Copy + Clone> Default for WithSpec<T> {
+impl<T> Default for WithSpec<T> {
     fn default() -> Self {
         WithSpec {
             val: None,
@@ -265,7 +264,7 @@ impl<T: Copy + Clone> Default for WithSpec<T> {
     }
 }
 
-#[derive(Debug, Copy, Clone, Default)]
+#[derive(Debug, Clone, Default)]
 pub(crate) struct ComputedStyle {
     #[cfg(feature = "css")]
     /// The computed foreground colour, if any
@@ -278,11 +277,26 @@ pub(crate) struct ComputedStyle {
     pub(crate) display: WithSpec<css::Display>,
     /// The CSS white-space property
     pub(crate) white_space: WithSpec<WhiteSpace>,
+    /// The CSS content property
+    pub(crate) content: WithSpec<css::PseudoContent>,
+
+    /// The CSS content property for ::before
+    pub(crate) content_before: Option<Box<ComputedStyle>>,
+    /// The CSS content property for ::after
+    pub(crate) content_after: Option<Box<ComputedStyle>>,
 
     /// A non-CSS flag indicating we're inside a <pre>.
     pub(crate) internal_pre: bool,
 }
 
+impl ComputedStyle {
+    /// Return the style data inherited by children.
+    pub(crate) fn inherit(self: &Self) -> Self {
+        // TODO: clear fields that shouldn't be inherited
+        self.clone()
+    }
+}
+
 /// Errors from reading or rendering HTML
 #[derive(thiserror::Error, Debug)]
 #[non_exhaustive]
@@ -426,7 +440,7 @@ impl RenderTableRow {
             // Skip any zero-width columns
             if col_width > 0 {
                 cell.col_width = Some(col_width + cell.colspan - 1);
-                let style = cell.style;
+                let style = cell.style.clone();
                 result.push(RenderNode::new_styled(
                     RenderNodeInfo::TableCell(cell),
                     style,
@@ -514,7 +528,7 @@ impl RenderTable {
             .into_iter()
             .map(|mut tr| {
                 tr.col_sizes = Some(col_sizes.clone());
-                let style = tr.style;
+                let style = tr.style.clone();
                 RenderNode::new_styled(RenderNodeInfo::TableRow(tr, vert), style)
             })
             .collect()
@@ -593,7 +607,7 @@ enum RenderNodeInfo {
     Ol(i64, Vec<RenderNode>),
     /// A description list (containing Dt or Dd)
     Dl(Vec<RenderNode>),
-    /// A term (from a `<dl>`)
+    /// A term (from a `<dt>`)
     Dt(Vec<RenderNode>),
     /// A definition (from a `<dl>`)
     Dd(Vec<RenderNode>),
@@ -1013,51 +1027,6 @@ fn precalc_size_estimate<'a, D: TextDecorator>(
     }
 }
 
-/// Make a Vec of RenderNodes from the children of a node.
-fn children_to_render_nodes<T: Write>(
-    handle: Handle,
-    err_out: &mut T,
-    context: &mut HtmlContext,
-) -> Result<Vec<RenderNode>> {
-    /* process children, but don't add anything */
-    handle
-        .children
-        .borrow()
-        .iter()
-        .flat_map(|ch| dom_to_render_tree_with_context(ch.clone(), err_out, context).transpose())
-        .collect()
-}
-
-/// Make a Vec of DtElements from the `<dt>` and `<dd>` children of a node.
-fn desc_list_children_to_render_nodes<T: Write>(
-    handle: Handle,
-    err_out: &mut T,
-    context: &mut HtmlContext,
-) -> Result<Vec<RenderNode>> {
-    let mut children = Vec::new();
-
-    for child in handle.children.borrow().iter() {
-        match child.data {
-            Element { ref name, .. } => match name.expanded() {
-                expanded_name!(html "dt") => {
-                    let dt_children = children_to_render_nodes(child.clone(), err_out, context)?;
-                    children.push(RenderNode::new(RenderNodeInfo::Dt(dt_children)));
-                }
-                expanded_name!(html "dd") => {
-                    let dd_children = children_to_render_nodes(child.clone(), err_out, context)?;
-                    children.push(RenderNode::new(RenderNodeInfo::Dd(dd_children)));
-                }
-                _ => {}
-            },
-            Comment { .. } => {}
-            _ => {
-                html_trace!("Unhandled in list: {:?}\n", child);
-            }
-        }
-    }
-    Ok(children)
-}
-
 /// Convert a table into a RenderNode
 fn table_to_render_tree<'a, T: Write>(
     input: RenderInput,
@@ -1155,12 +1124,13 @@ fn tr_to_render_tree<'a, T: Write>(
                 }
             })
             .collect();
+        let style = computed.clone();
         Some(RenderNode::new_styled(
             RenderNodeInfo::TableRow(
                 RenderTableRow {
                     cells,
                     col_sizes: None,
-                    style: computed,
+                    style,
                 },
                 false,
             ),
@@ -1185,13 +1155,14 @@ fn td_to_render_tree<'a, T: Write>(
         }
     }
     pending(input, move |_, children| {
+        let style = computed.clone();
         Some(RenderNode::new_styled(
             RenderNodeInfo::TableCell(RenderTableCell {
                 colspan,
                 content: children,
                 size_estimate: Cell::new(None),
                 col_width: None,
-                style: computed,
+                style,
             }),
             computed,
         ))
@@ -1308,7 +1279,6 @@ where
 
 #[derive(Debug, PartialEq, Eq)]
 struct HtmlContext {
-    #[cfg(feature = "css")]
     style_data: css::StyleData,
     #[cfg(feature = "css")]
     use_doc_css: bool,
@@ -1351,7 +1321,7 @@ fn dom_to_render_tree_with_context<T: Write>(
     html_trace!("### dom_to_render_tree: HTML: {:?}", handle);
     #[cfg(feature = "css")]
     if context.use_doc_css {
-        let mut doc_style_data = css::dom_to_stylesheet(handle.clone(), err_out)?;
+        let mut doc_style_data = css::dom_extract::dom_to_stylesheet(handle.clone(), err_out)?;
         doc_style_data.merge(std::mem::take(&mut context.style_data));
         context.style_data = doc_style_data;
     }
@@ -1375,7 +1345,7 @@ fn dom_to_render_tree_with_context<T: Write>(
 /// 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())?;
+    let doc_style_data = css::dom_extract::dom_to_stylesheet(handle, &mut std::io::sink())?;
     Ok(doc_style_data.to_string())
 }
 
@@ -1384,7 +1354,7 @@ fn pending<F>(
     f: F,
 ) -> TreeMapResult<'static, HtmlContext, RenderInput, RenderNode>
 where
-    F: Fn(&mut HtmlContext, Vec<RenderNode>) -> Option<RenderNode> + 'static,
+    F: FnOnce(&mut HtmlContext, Vec<RenderNode>) -> Option<RenderNode> + 'static,
 {
     TreeMapResult::PendingChildren {
         children: input.children(),
@@ -1399,7 +1369,7 @@ fn pending_noempty<F>(
     f: F,
 ) -> TreeMapResult<'static, HtmlContext, RenderInput, RenderNode>
 where
-    F: Fn(&mut HtmlContext, Vec<RenderNode>) -> Option<RenderNode> + 'static,
+    F: FnOnce(&mut HtmlContext, Vec<RenderNode>) -> Option<RenderNode> + 'static,
 {
     let handle = &input.handle;
     let style = &input.parent_style;
@@ -1425,11 +1395,21 @@ where
     }
 }
 
-/// Prepend a FragmentStart (or analogous) marker to an existing
+#[derive(Copy, Clone, Eq, PartialEq, Debug)]
+enum ChildPosition {
+    Start,
+    End,
+}
+
+/// Prepend or append a FragmentStart (or analogous) marker to an existing
 /// RenderNode.
-fn prepend_marker(prefix: RenderNode, mut orig: RenderNode) -> RenderNode {
+fn insert_child(
+    new_child: RenderNode,
+    mut orig: RenderNode,
+    position: ChildPosition,
+) -> RenderNode {
     use RenderNodeInfo::*;
-    html_trace!("prepend_marker({:?}, {:?})", prefix, orig);
+    html_trace!("insert_child({:?}, {:?}, {:?})", new_child, orig, position);
 
     match orig.info {
         // For block elements such as Block and Div, we need to insert
@@ -1442,6 +1422,9 @@ fn prepend_marker(prefix: RenderNode, mut orig: RenderNode) -> RenderNode {
         // less pointlessly nested.
         Block(ref mut children)
         | ListItem(ref mut children)
+        | Dd(ref mut children)
+        | Dt(ref mut children)
+        | Dl(ref mut children)
         | Div(ref mut children)
         | BlockQuote(ref mut children)
         | Container(ref mut children)
@@ -1449,7 +1432,10 @@ fn prepend_marker(prefix: RenderNode, mut orig: RenderNode) -> RenderNode {
             content: ref mut children,
             ..
         }) => {
-            children.insert(0, prefix);
+            match position {
+                ChildPosition::Start => children.insert(0, new_child),
+                ChildPosition::End => children.push(new_child),
+            }
             // Now return orig, but we do that outside the match so
             // that we've given back the borrowed ref 'children'.
         }
@@ -1459,7 +1445,10 @@ fn prepend_marker(prefix: RenderNode, mut orig: RenderNode) -> RenderNode {
             // If the row is empty, then there isn't really anything
             // to attach the fragment start to.
             if let Some(cell) = rrow.cells.first_mut() {
-                cell.content.insert(0, prefix);
+                match position {
+                    ChildPosition::Start => cell.content.insert(0, new_child),
+                    ChildPosition::End => cell.content.push(new_child),
+                }
             }
         }
 
@@ -1468,20 +1457,26 @@ fn prepend_marker(prefix: RenderNode, mut orig: RenderNode) -> RenderNode {
             // to attach the fragment start to.
             if let Some(rrow) = rows.first_mut() {
                 if let Some(cell) = rrow.cells.first_mut() {
-                    cell.content.insert(0, prefix);
+                    match position {
+                        ChildPosition::Start => cell.content.insert(0, new_child),
+                        ChildPosition::End => cell.content.push(new_child),
+                    }
                 }
             }
         }
 
         // For anything else, just make a new Container with the
-        // prefix node and the original one.
+        // new_child node and the original one.
         _ => {
-            let result = RenderNode::new(Container(vec![prefix, orig]));
-            html_trace!("prepend_marker() -> {:?}", result);
+            let result = match position {
+                ChildPosition::Start => RenderNode::new(Container(vec![new_child, orig])),
+                ChildPosition::End => RenderNode::new(Container(vec![orig, new_child])),
+            };
+            html_trace!("insert_child() -> {:?}", result);
             return result;
         }
     }
-    html_trace!("prepend_marker() -> {:?}", &orig);
+    html_trace!("insert_child() -> {:?}", &orig);
     orig
 }
 
@@ -1505,21 +1500,26 @@ fn process_dom_node<T: Write>(
             let mut frag_from_name_attr = false;
 
             let RenderInput {
-                ref handle,
+                handle: ref _handle,
                 ref parent_style,
             } = input;
 
             #[cfg(feature = "css")]
+            let use_doc_css = context.use_doc_css;
+            #[cfg(not(feature = "css"))]
+            let use_doc_css = false;
+
             let computed = {
                 let computed =
                     context
                         .style_data
-                        .computed_style(**parent_style, handle, context.use_doc_css);
+                        .computed_style(&*parent_style, _handle, use_doc_css);
+                #[cfg(feature = "css")]
                 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 result_text = RcDom::node_as_dom_string(_handle);
                         let mut computed = computed;
                         computed.white_space.maybe_update(
                             false,
@@ -1537,8 +1537,9 @@ fn process_dom_node<T: Write>(
                 }
                 computed
             };
-            #[cfg(not(feature = "css"))]
-            let computed = **parent_style;
+
+            let computed_before = computed.content_before.clone();
+            let computed_after = computed.content_after.clone();
 
             let result = match name.expanded() {
                 expanded_name!(html "html") | expanded_name!(html "body") => {
@@ -1699,14 +1700,25 @@ fn process_dom_node<T: Write>(
                         Some(RenderNode::new_styled(Ol(start, cs), computed))
                     })
                 }
-                expanded_name!(html "dl") => Finished(RenderNode::new_styled(
-                    Dl(desc_list_children_to_render_nodes(
-                        handle.clone(),
-                        err_out,
-                        context,
-                    )?),
-                    computed,
-                )),
+                expanded_name!(html "dl") => {
+                    pending_noempty(input, move |_, cs| {
+                        // There can be extra nodes which aren't Dt or Dd (like whitespace text
+                        // nodes).  We need to filter those out to avoid messing up the rendering.
+                        let cs = cs
+                            .into_iter()
+                            .filter(|n| {
+                                matches!(n.info, RenderNodeInfo::Dt(..) | RenderNodeInfo::Dd(..))
+                            })
+                            .collect();
+                        Some(RenderNode::new_styled(Dl(cs), computed))
+                    })
+                }
+                expanded_name!(html "dt") => pending(input, move |_, cs| {
+                    Some(RenderNode::new_styled(Dt(cs), computed))
+                }),
+                expanded_name!(html "dd") => pending(input, move |_, cs| {
+                    Some(RenderNode::new_styled(Dd(cs), computed))
+                }),
                 _ => {
                     html_trace!("Unhandled element: {:?}\n", name.local);
                     pending_noempty(input, move |_, cs| {
@@ -1724,13 +1736,61 @@ fn process_dom_node<T: Write>(
                 }
             }
 
+            let result = if computed_before.is_some() || computed_after.is_some() {
+                let wrap_nodes = move |mut node: RenderNode| {
+                    if let Some(ref content) = computed_before {
+                        if let Some(ref pseudo_content) = content.content.val() {
+                            node = insert_child(
+                                RenderNode::new(Text(pseudo_content.text.clone())),
+                                node,
+                                ChildPosition::Start,
+                            );
+                        }
+                    }
+                    if let Some(ref content) = computed_after {
+                        if let Some(ref pseudo_content) = content.content.val() {
+                            node = insert_child(
+                                RenderNode::new(Text(pseudo_content.text.clone())),
+                                node,
+                                ChildPosition::End,
+                            );
+                        }
+                    }
+                    node
+                };
+                // Insert extra content nodes
+                match result {
+                    Finished(node) => Finished(wrap_nodes(node)),
+                    // Do we need to wrap a Nothing?
+                    Nothing => Nothing,
+                    PendingChildren {
+                        children,
+                        cons,
+                        prefn,
+                        postfn,
+                    } => PendingChildren {
+                        children,
+                        prefn,
+                        postfn,
+                        cons: Box::new(move |ctx, ch| match cons(ctx, ch)? {
+                            None => Ok(None),
+                            Some(node) => Ok(Some(wrap_nodes(node))),
+                        }),
+                    },
+                }
+            } else {
+                result
+            };
+
             let Some(fragname) = fragment else {
                 return Ok(result);
             };
             match result {
-                Finished(node) => {
-                    Finished(prepend_marker(RenderNode::new(FragStart(fragname)), node))
-                }
+                Finished(node) => Finished(insert_child(
+                    RenderNode::new(FragStart(fragname)),
+                    node,
+                    ChildPosition::Start,
+                )),
                 Nothing => Finished(RenderNode::new(FragStart(fragname))),
                 PendingChildren {
                     children,
@@ -1745,7 +1805,9 @@ fn process_dom_node<T: Write>(
                         let fragnode = RenderNode::new(FragStart(fragname));
                         match cons(ctx, ch)? {
                             None => Ok(Some(fragnode)),
-                            Some(node) => Ok(Some(prepend_marker(fragnode, node))),
+                            Some(node) => {
+                                Ok(Some(insert_child(fragnode, node, ChildPosition::Start)))
+                            }
                         }
                     }),
                 },
@@ -1824,17 +1886,17 @@ impl PushedStyleInfo {
         let mut result: PushedStyleInfo = Default::default();
         #[cfg(feature = "css")]
         if let Some(col) = style.colour.val() {
-            render.push_colour(col);
+            render.push_colour(col.clone());
             result.colour = true;
         }
         #[cfg(feature = "css")]
         if let Some(col) = style.bg_colour.val() {
-            render.push_bgcolour(col);
+            render.push_bgcolour(col.clone());
             result.bgcolour = true;
         }
         if let Some(ws) = style.white_space.val() {
             if let WhiteSpace::Pre | WhiteSpace::PreWrap = ws {
-                render.push_ws(ws);
+                render.push_ws(ws.clone());
                 result.white_space = true;
             }
         }
@@ -2319,9 +2381,10 @@ pub mod config {
     use std::io;
 
     use super::Error;
-    #[cfg(feature = "css")]
-    use crate::css::StyleData;
+    use crate::css::types::Importance;
+    use crate::css::{Ruleset, Selector, SelectorComponent, Style, StyleData};
     use crate::{
+        css::{PseudoContent, PseudoElement, StyleDecl},
         render::text_renderer::{
             PlainDecorator, RichAnnotation, RichDecorator, TaggedLine, TextDecorator,
         },
@@ -2334,7 +2397,6 @@ pub mod config {
 
         max_wrap_width: Option<usize>,
 
-        #[cfg(feature = "css")]
         style: StyleData,
         #[cfg(feature = "css")]
         use_doc_css: bool,
@@ -2352,7 +2414,6 @@ pub mod config {
         /// Make the HtmlContext from self.
         pub(crate) fn make_context(&self) -> HtmlContext {
             HtmlContext {
-                #[cfg(feature = "css")]
                 style_data: self.style.clone(),
                 #[cfg(feature = "css")]
                 use_doc_css: self.use_doc_css,
@@ -2539,6 +2600,41 @@ pub mod config {
             self.wrap_links = false;
             self
         }
+
+        /// Make a simple "contains" type rule for an element.
+        fn make_surround_rule(element: &str, after: bool, content: &str) -> Ruleset {
+            Ruleset {
+                selector: Selector {
+                    components: vec![SelectorComponent::Element(element.into())],
+                    pseudo_element: Some(if after {
+                        PseudoElement::After
+                    } else {
+                        PseudoElement::Before
+                    }),
+                },
+                styles: vec![StyleDecl {
+                    style: Style::Content(PseudoContent {
+                        text: content.into(),
+                    }),
+                    importance: Importance::Default,
+                }],
+            }
+        }
+
+        /// Decorate <em> etc. similarly to markdown
+        pub fn do_decorate(mut self) -> Self {
+            self.style.add_agent_rules(&[
+                Self::make_surround_rule("em", false, "*"),
+                Self::make_surround_rule("em", true, "*"),
+                Self::make_surround_rule("dt", false, "*"),
+                Self::make_surround_rule("dt", true, "*"),
+                Self::make_surround_rule("strong", false, "**"),
+                Self::make_surround_rule("strong", true, "**"),
+                Self::make_surround_rule("code", false, "`"),
+                Self::make_surround_rule("code", true, "`"),
+            ]);
+            self
+        }
     }
 
     impl Config<RichDecorator> {
@@ -2586,13 +2682,13 @@ pub mod config {
         with_decorator(RichDecorator::new())
     }
 
-    /// Return a Config initialized with a `RichDecorator` and decoration disabled.
-    pub fn rich_no_decorate() -> Config<RichDecorator> {
-        with_decorator(RichDecorator::new_undecorated())
+    /// Return a Config initialized with a `PlainDecorator`.
+    pub fn plain() -> Config<PlainDecorator> {
+        with_decorator(PlainDecorator::new()).do_decorate()
     }
 
     /// Return a Config initialized with a `PlainDecorator`.
-    pub fn plain() -> Config<PlainDecorator> {
+    pub fn plain_no_decorate() -> Config<PlainDecorator> {
         with_decorator(PlainDecorator::new())
     }
 
@@ -2600,7 +2696,6 @@ pub mod config {
     pub fn with_decorator<D: TextDecorator>(decorator: D) -> Config<D> {
         Config {
             decorator,
-            #[cfg(feature = "css")]
             style: Default::default(),
             #[cfg(feature = "css")]
             use_doc_css: false,
@@ -2679,7 +2774,7 @@ impl<D: TextDecorator> RenderedText<D> {
 
 /// Reads and parses HTML from `input` and prepares a render tree.
 pub fn parse(input: impl io::Read) -> Result<RenderTree> {
-    let cfg = config::plain();
+    let cfg = config::with_decorator(TrivialDecorator::new());
     cfg.do_parse(&mut cfg.make_context(), input)
 }
 
diff --git a/src/render/text_renderer.rs b/src/render/text_renderer.rs
index 145669d..9f2ae95 100644
--- a/src/render/text_renderer.rs
+++ b/src/render/text_renderer.rs
@@ -1733,19 +1733,19 @@ impl TextDecorator for PlainDecorator {
     }
 
     fn decorate_em_start(&self) -> (String, Self::Annotation) {
-        ("*".to_string(), ())
+        ("".to_string(), ())
     }
 
     fn decorate_em_end(&self) -> String {
-        "*".to_string()
+        "".to_string()
     }
 
     fn decorate_strong_start(&self) -> (String, Self::Annotation) {
-        ("**".to_string(), ())
+        ("".to_string(), ())
     }
 
     fn decorate_strong_end(&self) -> String {
-        "**".to_string()
+        "".to_string()
     }
 
     fn decorate_strikeout_start(&self) -> (String, Self::Annotation) {
@@ -1757,11 +1757,11 @@ impl TextDecorator for PlainDecorator {
     }
 
     fn decorate_code_start(&self) -> (String, Self::Annotation) {
-        ("`".to_string(), ())
+        ("".to_string(), ())
     }
 
     fn decorate_code_end(&self) -> String {
-        "`".to_string()
+        "".to_string()
     }
 
     fn decorate_preformat_first(&self) -> Self::Annotation {}
@@ -1892,10 +1892,7 @@ impl TextDecorator for TrivialDecorator {
 /// A decorator to generate rich text (styled) rather than
 /// pure text output.
 #[derive(Clone, Debug)]
-pub struct RichDecorator {
-    // Don't output decorations around '*bold*' text.
-    skip_decorations: bool,
-}
+pub struct RichDecorator {}
 
 /// Annotation type for "rich" text.  Text is associated with a set of
 /// these.
@@ -1929,17 +1926,7 @@ impl RichDecorator {
     /// Create a new `RichDecorator` with the default settings.
     #[allow(clippy::new_without_default)]
     pub fn new() -> RichDecorator {
-        RichDecorator {
-            skip_decorations: false,
-        }
-    }
-
-    /// Create a new `RichDecorator` which doesn't add decorations
-    /// when terminal formatting can be used.
-    pub fn new_undecorated() -> RichDecorator {
-        RichDecorator {
-            skip_decorations: true,
-        }
+        RichDecorator {}
     }
 }
 
@@ -1963,19 +1950,11 @@ impl TextDecorator for RichDecorator {
     }
 
     fn decorate_strong_start(&self) -> (String, Self::Annotation) {
-        if self.skip_decorations {
-            ("".to_string(), RichAnnotation::Strong)
-        } else {
-            ("*".to_string(), RichAnnotation::Strong)
-        }
+        ("".to_string(), RichAnnotation::Strong)
     }
 
     fn decorate_strong_end(&self) -> String {
-        if self.skip_decorations {
-            "".to_string()
-        } else {
-            "*".to_string()
-        }
+        "".to_string()
     }
 
     fn decorate_strikeout_start(&self) -> (String, Self::Annotation) {
@@ -1987,19 +1966,11 @@ impl TextDecorator for RichDecorator {
     }
 
     fn decorate_code_start(&self) -> (String, Self::Annotation) {
-        if self.skip_decorations {
-            ("".to_string(), RichAnnotation::Code)
-        } else {
-            ("`".to_string(), RichAnnotation::Code)
-        }
+        ("".to_string(), RichAnnotation::Code)
     }
 
     fn decorate_code_end(&self) -> String {
-        if self.skip_decorations {
-            "".to_string()
-        } else {
-            "`".to_string()
-        }
+        "".to_string()
     }
 
     fn decorate_preformat_first(&self) -> Self::Annotation {


Test patch:
diff --git a/src/tests.rs b/src/tests.rs
index 07828e0..1e5cbd4 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -1435,14 +1435,14 @@ fn test_read_rich() {
         .render_to_lines(parse(html).unwrap(), 80)
         .unwrap();
     let tag = vec![RichAnnotation::Strong];
-    let line = TaggedLine::from_string("*bold*".to_owned(), &tag);
+    let line = TaggedLine::from_string("bold".to_owned(), &tag);
     assert_eq!(vec![line], lines);
 }
 
 #[test]
 fn test_read_rich_nodecorate() {
     let html: &[u8] = b"<strong>bold</strong>";
-    let lines = config::rich_no_decorate()
+    let lines = config::rich()
         .render_to_lines(parse(html).unwrap(), 80)
         .unwrap();
     let tag = vec![RichAnnotation::Strong];
@@ -2820,4 +2820,25 @@ at  line  breaks
             20,
         );
     }
+
+    #[test]
+    fn test_before_after() {
+        test_html_coloured(
+            br#"
+        <style>
+          span.bracketed::before {
+              content: "[";
+          }
+          span.bracketed::after {
+              content: "]";
+          }
+        </style>
+        <body>
+        <p>Hello <span class="bracketed">world</span>!</p>
+        </body>"#,
+            r#"Hello [world]!
+"#,
+            80,
+        );
+    }
 }