Repository: jugglerchris/rust-html2text
Pull request #109 (2024-01-01 22:59:13)

Add background colour.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f87bc37..e644af6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,16 @@ Possible log types:
 - `[fixed]` for any bug fixes.
 - `[security]` to invite users to upgrade in case of vulnerabilities.
 
+### 0.10.0
+
+- [added] Simple support for `<i>`, `<ins>`, and `<del>` (thanks sgtatham)
+- [added] Added background-color support
+- [fixed] CSS support didn't work in some places, such as `<td>` elements.
+- [added] Add support for `style` attributes.
+- [added] Styles apply to table borders
+- [changed] Update some dependencies
+- [fixed] Fix a few places which caused excess blank lines or empty tables
+
 ### 0.9.4
 
 - [changed] Updated the termion dev-dependency to 2.0.
diff --git a/Cargo.toml b/Cargo.toml
index 8c7397f..2010017 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "html2text"
-version = "0.9.4"
+version = "0.10.0"
 authors = ["Chris Emerson <github@mail.nosreme.org>"]
 description = "Render HTML as plain text."
 repository = "https://github.com/jugglerchris/rust-html2text/"
@@ -22,6 +22,8 @@ unicode-width = "0.1.5"
 backtrace = { version = "0.3", optional=true }
 thiserror = "1.0.50"
 lightningcss = { version = "1.0.0-alpha.51", optional=true }
+# Keep dashmap back; 5.5 has a higher MSRV.
+dashmap = "~5.4"
 
 [features]
 html_trace = []
diff --git a/README.md b/README.md
index 024c637..9fc4e0f 100644
--- a/README.md
+++ b/README.md
@@ -96,14 +96,22 @@ read keys from stdin.
 
 ### CSS support
 
-When the `css` feature is enabled, some simple CSS handling is done.
+When the `css` feature is enabled, some simple CSS handling is available.
 
-* The contents of \<style\> elements are parsed and some colour rules are extracted
+Style rules are taken from:
+* If `Config::use_doc_css()` is called, then style from the document:
+  * `<style>` elements
+  * Inline `style` attributes (`<div style="...">`)
+  * `<font color=...>`
+* Independently of `use_doc_css`, extra rules can be added with `Config::add_css(...)`
+
+The following CSS features are implemented:
 * Basic selector matching (including child and descendents, classes and element
   types).
-* CSS colors will add `Coloured(...)` nodes to the render tree.
-* Rules with `display: none` will cause matching elements to be removed from the
-  render tree.
+* CSS colors (`color`/`background-color`) will add
+  `Coloured(...)`/`BgColoured(...)` nodes to the render tree.
+* Rules with `display: none` will cause matching elements to be removed from
+  the render tree.
 
 The CSS handling is expected to improve in future (PRs welcome), but not to a full-
 blown browser style system, which would be overkill for terminal output.
diff --git a/examples/html2text.rs b/examples/html2text.rs
index a9164eb..3728387 100644
--- a/examples/html2text.rs
+++ b/examples/html2text.rs
@@ -65,6 +65,10 @@ fn default_colour_map(annotations: &[RichAnnotation], s: &str) -> String {
                 finish.push(format!("{}", Fg(Reset)));
                 have_explicit_colour = true;
             }
+            BgColour(c) => {
+                start.push(format!("{}", Bg(Rgb(c.r, c.g, c.b))));
+                finish.push(format!("{}", Bg(Reset)));
+            }
             _ => {}
         }
     }
@@ -186,7 +190,7 @@ fn main() {
 
     match outfile {
         None => {
-            println!("{}", data);
+            print!("{}", data);
         }
         Some(name) => {
             let mut file = std::fs::File::create(name).expect("Tried to create file");
diff --git a/src/css.rs b/src/css.rs
index 2963b9d..8b9e8fe 100644
--- a/src/css.rs
+++ b/src/css.rs
@@ -4,8 +4,8 @@ use std::convert::TryFrom;
 use std::ops::Deref;
 
 use lightningcss::{stylesheet::{
-    ParserOptions, StyleSheet
-}, rules::CssRule, properties::{Property, display::{self, DisplayKeyword}}, values::color::CssColor};
+    ParserOptions, StyleSheet, StyleAttribute
+}, rules::CssRule, properties::{Property, display::{self, DisplayKeyword}}, values::color::CssColor, declaration::DeclarationBlock};
 
 use crate::{Result, TreeMapResult, markup5ever_rcdom::{Handle, NodeData::{Comment, Document, Element, self}}, tree_map_reduce};
 
@@ -161,6 +161,7 @@ impl<'r, 'i> TryFrom<&'r lightningcss::selector::Selector<'i>> for Selector {
 #[derive(Debug, Clone)]
 pub(crate) enum Style {
     Colour(CssColor),
+    BgColour(CssColor),
     DisplayNone,
 }
 
@@ -176,6 +177,38 @@ pub struct StyleData {
     rules: Vec<Ruleset>,
 }
 
+pub(crate) fn parse_style_attribute(text: &str) -> Result<Vec<Style>> {
+    let sattr = StyleAttribute::parse(text, ParserOptions::default())
+        .map_err(|_| crate::Error::CssParseError)?;
+
+    Ok(styles_from_properties(&sattr.declarations))
+}
+
+fn styles_from_properties(decls: &DeclarationBlock<'_>) -> Vec<Style> {
+    let mut styles = Vec::new();
+    for decl in &decls.declarations {
+        match decl {
+            Property::Color(color) => {
+                styles.push(Style::Colour(color.clone()));
+            }
+            Property::Background(bginfo) => {
+                let color = bginfo.last().unwrap().color.clone();
+                styles.push(Style::BgColour(color));
+            }
+            Property::BackgroundColor(color) => {
+                styles.push(Style::BgColour(color.clone()));
+            }
+            Property::Display(disp) => {
+                if let display::Display::Keyword(DisplayKeyword::None) = disp {
+                    styles.push(Style::DisplayNone);
+                }
+            }
+            _ => {}
+        }
+    }
+    styles
+}
+
 impl StyleData {
     /// Add some CSS source to be included.  The source will be parsed
     /// and the relevant and supported features extracted.
@@ -186,20 +219,7 @@ impl StyleData {
         for rule in &ss.rules.0 {
             match rule {
                 CssRule::Style(style) => {
-                    let mut styles = Vec::new();
-                    for decl in &style.declarations.declarations {
-                        match decl {
-                            Property::Color(color) => {
-                                styles.push(Style::Colour(color.clone()));
-                            }
-                            Property::Display(disp) => {
-                                if let display::Display::Keyword(DisplayKeyword::None) = disp {
-                                    styles.push(Style::DisplayNone);
-                                }
-                            }
-                            _ => {}
-                        }
-                    }
+                    let styles = styles_from_properties(&style.declarations);
                     if !styles.is_empty() {
                         for selector in &style.selectors.0 {
                             match Selector::try_from(selector) {
@@ -228,13 +248,26 @@ impl StyleData {
         self.rules.extend(other.rules);
     }
 
-    pub(crate) fn matching_rules(&self, handle: &Handle) -> Vec<Style> {
+    pub(crate) fn matching_rules(&self, handle: &Handle, use_doc_css: bool) -> Vec<Style> {
         let mut result = Vec::new();
         for rule in &self.rules {
             if rule.selector.matches(handle) {
                 result.extend(rule.styles.iter().cloned());
             }
         }
+        if use_doc_css {
+            // Now look for a style attribute
+            if let Element { attrs, .. } = &handle.data {
+                let borrowed = attrs.borrow();
+                for attr in borrowed.iter() {
+                    if &attr.name.local == "style" {
+                        let rules = parse_style_attribute(&attr.value).unwrap_or_default();
+                        result.extend(rules);
+                        break;
+                    }
+                }
+            }
+        }
 
         result
     }
diff --git a/src/lib.rs b/src/lib.rs
index 8d84150..66ff06f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -68,7 +68,7 @@ pub mod render;
 pub mod css;
 
 use render::text_renderer::{
-    PlainDecorator, RenderLine, RichAnnotation, RichDecorator, SubRenderer, TaggedLine,
+    PlainDecorator, RenderLine, RichAnnotation, RichDecorator, SubRenderer, TaggedLine, RenderOptions,
     TextDecorator, TextRenderer,
 };
 use render::Renderer;
@@ -439,8 +439,10 @@ pub enum RenderNodeInfo {
     TableCell(RenderTableCell),
     /// Start of a named HTML fragment
     FragStart(String),
-    /// A region with classes
+    /// A region with a foreground colour
     Coloured(Colour, Vec<RenderNode>),
+    /// A region with a background colour
+    BgColoured(Colour, Vec<RenderNode>),
     /// A list item
     ListItem(Vec<RenderNode>),
 }
@@ -545,6 +547,7 @@ impl RenderNode {
             Table(ref t) => t.get_size_estimate(),
             TableRow(..) | TableBody(_) | TableCell(_) => unimplemented!(),
             FragStart(_) => Default::default(),
+            BgColoured(_, ref v) |
             Coloured(_, ref v) => v
                 .iter()
                 .map(RenderNode::get_size_estimate)
@@ -588,6 +591,7 @@ impl RenderNode {
             Table(ref _t) => false,
             TableRow(..) | TableBody(_) | TableCell(_) => false,
             FragStart(_) => true,
+            BgColoured(_, ref v) |
             Coloured(_, ref v) => v.is_empty(),
         }
     }
@@ -648,6 +652,7 @@ fn precalc_size_estimate<'a>(node: &'a RenderNode) -> Result<TreeMapResult<(), &
             }
         }
         TableRow(..) | TableBody(_) | TableCell(_) => unimplemented!(),
+        BgColoured(_, ref v) |
         Coloured(_, ref v) => TreeMapResult::PendingChildren {
             children: v.iter().collect(),
             cons: Box::new(move |_, _cs| {
@@ -715,9 +720,12 @@ fn table_to_render_tree<'a, 'b, T: Write>(
                 html_trace!("Found in table: {:?}", bodynode.info);
             }
         }
-        Ok(Some(RenderNode::new(RenderNodeInfo::Table(RenderTable::new(
-            rows,
-        )))))
+        if rows.is_empty() {
+            Ok(None)
+        } else {
+            Ok(Some(RenderNode::new(RenderNodeInfo::Table(RenderTable::new(
+                                rows)))))
+        }
     })
 }
 
@@ -726,7 +734,7 @@ fn tbody_to_render_tree<'a, 'b, T: Write>(
     handle: Handle,
     _err_out: &'b mut T,
 ) -> TreeMapResult<'a, HtmlContext, Handle, RenderNode> {
-    pending(handle, |_, rowchildren| {
+    pending_noempty(handle, |_, rowchildren| {
         let mut rows = rowchildren
             .into_iter()
             .flat_map(|rownode| {
@@ -854,6 +862,50 @@ enum TreeMapResult<'a, C, N, R> {
     Nothing,
 }
 
+impl<'a, C: 'a, N> TreeMapResult<'a, C, N, RenderNode> {
+    #[cfg(feature = "css")]
+    fn wrap_render_nodes<F>(self, f: F)
+    -> Self 
+    where
+        F: Fn(Vec<RenderNode>) -> RenderNodeInfo + 'a
+    {
+        use TreeMapResult::*;
+        match self {
+            Finished(node) => Finished(RenderNode::new(f(vec![node]))),
+            PendingChildren { children, cons, prefn, postfn } => {
+                PendingChildren {
+                    children,
+                    prefn,
+                    postfn,
+                    cons: Box::new(move |ctx, ch| {
+                        Ok(cons(ctx, ch)?
+                           .map(|n| {
+                               // Special case: lists need to recognise ListItem nodes as
+                               // direct children.
+                               match n.info {
+                                   RenderNodeInfo::ListItem(children) => {
+                                       let wrapped = RenderNode::new(f(children));
+                                       RenderNode::new(
+                                           RenderNodeInfo::ListItem(vec![wrapped]))
+                                   }
+                                   RenderNodeInfo::TableCell(mut cellinfo) => {
+                                       let children = cellinfo.content;
+                                       let wrapped = RenderNode::new(f(children));
+                                       cellinfo.content = vec![wrapped];
+                                       RenderNode::new(
+                                           RenderNodeInfo::TableCell(cellinfo))
+                                   }
+                                   ni => RenderNode::new(f(vec![RenderNode::new(ni)])),
+                               }
+                           }))
+                    }),
+                }
+            }
+            Nothing => Nothing
+        }
+    }
+}
+
 fn tree_map_reduce<'a, C, N, R, M>(context: &mut C, top: N, mut process_node: M) -> Result<Option<R>>
 where
     M: for<'c> FnMut(&'c mut C, N) -> Result<TreeMapResult<'a, C, N, R>>,
@@ -948,6 +1000,7 @@ struct HtmlContext {
     use_doc_css: bool,
 
     max_wrap_width: Option<usize>,
+    pad_block_width: bool,
 }
 
 fn dom_to_render_tree_with_context<T: Write>(
@@ -988,6 +1041,24 @@ where
     }
 }
 
+fn pending_noempty<'a, F>(handle: Handle, f: F) -> TreeMapResult<'a, HtmlContext, Handle, RenderNode>
+where
+    for<'r> F: Fn(&'r mut HtmlContext, std::vec::Vec<RenderNode>) -> Result<Option<RenderNode>> + 'static,
+{
+    TreeMapResult::PendingChildren {
+        children: handle.children.borrow().clone(),
+        cons: Box::new(move |ctx, children| {
+            if children.is_empty() {
+                Ok(None)
+            } else {
+                f(ctx, children)
+            }
+        }),
+        prefn: None,
+        postfn: None,
+    }
+}
+
 /// Prepend a FragmentStart (or analogous) marker to an existing
 /// RenderNode.
 fn prepend_marker(prefix: RenderNode, mut orig: RenderNode) -> RenderNode {
@@ -1072,14 +1143,21 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
             #[cfg(feature = "css")]
             let mut css_colour = None;
             #[cfg(feature = "css")]
+            let mut css_bgcolour = None;
+            #[cfg(feature = "css")]
             {
-                for style in context.style_data.matching_rules(&handle) {
+                for style in context.style_data.matching_rules(&handle, context.use_doc_css) {
                     match style {
                         css::Style::Colour(col) => {
                             if let Ok(colour) = Colour::try_from(&col) {
                                 css_colour = Some(colour);
                             }
                         }
+                        css::Style::BgColour(col) => {
+                            if let Ok(colour) = Colour::try_from(&col) {
+                                css_bgcolour = Some(colour);
+                            }
+                        }
                         css::Style::DisplayNone => {
                             return Ok(Nothing);
                         }
@@ -1103,7 +1181,7 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
                 }
                 expanded_name!(html "span") => {
                     /* process children, but don't add anything */
-                    pending(handle, |_, cs| Ok(Some(RenderNode::new(Container(cs)))))
+                    pending_noempty(handle, |_, cs| Ok(Some(RenderNode::new(Container(cs)))))
                 }
                 #[cfg(feature = "css")]
                 expanded_name!(html "font") => {
@@ -1121,9 +1199,9 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
                         }
                     }
                     if let Some(colour) = colour {
-                        pending(handle, move |_, cs| Ok(Some(RenderNode::new(Coloured(colour, vec![RenderNode::new(Container(cs))])))))
+                        pending_noempty(handle, move |_, cs| Ok(Some(RenderNode::new(Coloured(colour, vec![RenderNode::new(Container(cs))])))))
                     } else {
-                        pending(handle, |_, cs| Ok(Some(RenderNode::new(Container(cs)))))
+                        pending_noempty(handle, |_, cs| Ok(Some(RenderNode::new(Container(cs)))))
                     }
                 }
                 expanded_name!(html "a") => {
@@ -1200,13 +1278,13 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
                     })
                 }
                 expanded_name!(html "p") => {
-                    pending(handle, |_, cs| Ok(Some(RenderNode::new(Block(cs)))))
+                    pending_noempty(handle, |_, cs| Ok(Some(RenderNode::new(Block(cs)))))
                 }
                 expanded_name!(html "li") => {
                     pending(handle, |_, cs| Ok(Some(RenderNode::new(ListItem(cs)))))
                 }
                 expanded_name!(html "div") => {
-                    pending(handle, |_, cs| Ok(Some(RenderNode::new(Div(cs)))))
+                    pending_noempty(handle, |_, cs| Ok(Some(RenderNode::new(Div(cs)))))
                 }
                 expanded_name!(html "pre") => {
                     pending(handle, |_, cs| Ok(Some(RenderNode::new(Pre(cs)))))
@@ -1221,10 +1299,10 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
                     td_to_render_tree(handle.clone(), err_out)
                 }
                 expanded_name!(html "blockquote") => {
-                    pending(handle, |_, cs| Ok(Some(RenderNode::new(BlockQuote(cs)))))
+                    pending_noempty(handle, |_, cs| Ok(Some(RenderNode::new(BlockQuote(cs)))))
                 }
                 expanded_name!(html "ul") => {
-                    pending(handle, |_, cs| Ok(Some(RenderNode::new(Ul(cs)))))
+                    pending_noempty(handle, |_, cs| Ok(Some(RenderNode::new(Ul(cs)))))
                 }
                 expanded_name!(html "ol") => {
                     let borrowed = attrs.borrow();
@@ -1236,15 +1314,14 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
                         }
                     }
 
-                    pending(handle, move |_, cs| {
-                        dbg!(&cs);
+                    pending_noempty(handle, move |_, cs| {
                         let cs = cs.into_iter()
                             .filter(|n| match &n.info {
                                 RenderNodeInfo::ListItem(..) => true,
                                 _ => false,
                             })
                             .collect();
-                        Ok(Some(RenderNode::new(Ol(start, dbg!(cs)))))
+                        Ok(Some(RenderNode::new(Ol(start, cs))))
                     })
 
                 }
@@ -1253,7 +1330,7 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
                 ))),
                 _ => {
                     html_trace!("Unhandled element: {:?}\n", name.local);
-                    pending(handle, |_, cs| Ok(Some(RenderNode::new(Container(cs)))))
+                    pending_noempty(handle, |_, cs| Ok(Some(RenderNode::new(Container(cs)))))
                     //None
                 }
             };
@@ -1300,32 +1377,15 @@ fn process_dom_node<'a, 'b, 'c, T: Write>(
 
             #[cfg(feature = "css")]
             let result = if let Some(colour) = css_colour {
-                match result {
-                    Finished(node) => Finished(RenderNode::new(Coloured(colour.into(), vec![node]))),
-                    PendingChildren { children, cons, prefn, postfn } => {
-                        PendingChildren {
-                            children,
-                            prefn,
-                            postfn,
-                            cons: Box::new(move |ctx, ch| {
-                                Ok(cons(ctx, ch)?
-                                    .map(|n| {
-                                        // Special case: lists need to recognise ListItem nodes as
-                                        // direct children.
-                                        match n.info {
-                                            ListItem(children) => {
-                                                let coloured = RenderNode::new(Coloured(colour.into(), children));
-                                                RenderNode::new(
-                                                    ListItem(vec![coloured]))
-                                            }
-                                            ni => RenderNode::new(Coloured(colour.into(), vec![RenderNode::new(ni)])),
-                                        }
-                                    }))
-                            }),
-                        }
-                    }
-                    Nothing => Nothing
-                }
+                let colour = colour.into();
+                result.wrap_render_nodes(move |children| Coloured(colour, children))
+            } else {
+                result
+            };
+            #[cfg(feature = "css")]
+            let result = if let Some(colour) = css_bgcolour {
+                let colour = colour.into();
+                result.wrap_render_nodes(move |children| BgColoured(colour, children))
             } else {
                 result
             };
@@ -1595,6 +1655,13 @@ fn do_render_node<'a, 'b, T: Write, D: TextDecorator>(
                 Ok(Some(None))
             })
         }
+        BgColoured(colour, children) => {
+            renderer.push_bgcolour(colour);
+            pending2(children, |renderer: &mut TextRenderer<D>, _| {
+                renderer.pop_bgcolour();
+                Ok(Some(None))
+            })
+        }
     })
 }
 
@@ -1682,8 +1749,6 @@ fn render_table_tree<T: Write, D: TextDecorator>(
         }
     }
 
-    renderer.start_block()?;
-
     let table_width = if vert_row {
         width
     } else {
@@ -1695,6 +1760,12 @@ fn render_table_tree<T: Write, D: TextDecorator>(
                 .saturating_sub(1)
     };
 
+    if table_width == 0 {
+        return Ok(TreeMapResult::Nothing);
+    }
+
+    renderer.start_block()?;
+
     renderer.add_horizontal_border_width(table_width)?;
 
     Ok(TreeMapResult::PendingChildren {
@@ -1789,6 +1860,8 @@ pub mod config {
         style: StyleData,
         #[cfg(feature = "css")]
         use_doc_css: bool,
+
+        pad_block_width: bool,
     }
 
     impl<D: TextDecorator> Config<D> {
@@ -1801,6 +1874,7 @@ pub mod config {
                 use_doc_css: self.use_doc_css,
 
                 max_wrap_width: self.max_wrap_width,
+                pad_block_width: self.pad_block_width,
             }
         }
         /// Parse with context.
@@ -1886,6 +1960,12 @@ pub mod config {
             self
         }
 
+        /// Pad lines out to the full render width.
+        pub fn pad_block_width(mut self) -> Self {
+            self.pad_block_width = true;
+            self
+        }
+
         /// Set the maximum text wrap width.
         /// When set, paragraphs will be wrapped to that width even if there
         /// is more total width available for rendering.
@@ -1963,6 +2043,7 @@ pub mod config {
             #[cfg(feature = "css")]
             use_doc_css: false,
             max_wrap_width: None,
+            pad_block_width: false,
         }
     }
 
@@ -1975,6 +2056,7 @@ pub mod config {
             #[cfg(feature = "css")]
             use_doc_css: false,
             max_wrap_width: None,
+            pad_block_width: false,
         }
     }
 
@@ -1987,6 +2069,7 @@ pub mod config {
             #[cfg(feature = "css")]
             use_doc_css: false,
             max_wrap_width: None,
+            pad_block_width: false,
         }
     }
 }
@@ -2004,7 +2087,10 @@ impl RenderTree {
         if width == 0 {
             return Err(Error::TooNarrow);
         }
-        let builder = SubRenderer::new(width, context.max_wrap_width, decorator);
+        let mut render_options = RenderOptions::default();
+        render_options.wrap_width = context.max_wrap_width;
+        render_options.pad_block_width = context.pad_block_width;
+        let builder = SubRenderer::new(width, render_options, decorator);
         let builder = render_tree_to_string(builder, self.0, &mut Discard {})?;
         Ok(RenderedText(builder))
     }
diff --git a/src/render/mod.rs b/src/render/mod.rs
index 5f6df7c..a7eb256 100644
--- a/src/render/mod.rs
+++ b/src/render/mod.rs
@@ -132,9 +132,15 @@ pub trait Renderer {
     /// Record the start of a named HTML fragment
     fn record_frag_start(&mut self, fragname: &str);
 
-    /// Push some CSS classes
+    /// Push a new foreground colour
     fn push_colour(&mut self, colour: Colour);
 
-    /// Pop the last CSS classes pushed
+    /// Pop the last foreground colour
     fn pop_colour(&mut self);
+
+    /// Push a new background colour
+    fn push_bgcolour(&mut self, colour: Colour);
+
+    /// Pop the last background colour
+    fn pop_bgcolour(&mut self);
 }
diff --git a/src/render/text_renderer.rs b/src/render/text_renderer.rs
index 0766cf3..c49b6fd 100644
--- a/src/render/text_renderer.rs
+++ b/src/render/text_renderer.rs
@@ -110,6 +110,17 @@ pub enum TaggedLineElement<T> {
     FragmentStart(String),
 }
 
+impl<T> TaggedLineElement<T> {
+    /// Return true if this element is non-empty.
+    /// FragmentStart is considered empty.
+    fn has_content(&self) -> bool {
+        match self {
+            TaggedLineElement::Str(_) => true,
+            TaggedLineElement::FragmentStart(_) => false,
+        }
+    }
+}
+
 /// A line of tagged text (composed of a set of `TaggedString`s).
 #[derive(Debug, Clone, PartialEq)]
 pub struct TaggedLine<T> {
@@ -145,7 +156,12 @@ impl<T: Debug + Eq + PartialEq + Clone + Default> TaggedLine<T> {
 
     /// Return true if the line is non-empty
     pub fn is_empty(&self) -> bool {
-        self.v.len() == 0
+        for elt in &self.v {
+            if elt.has_content() {
+                return false;
+            }
+        }
+        true
     }
 
     /// Add a new tagged string fragment to the line
@@ -262,14 +278,14 @@ impl<T: Debug + Eq + PartialEq + Clone + Default> TaggedLine<T> {
 
     /// Pad this line to width with spaces (or if already at least this wide, do
     /// nothing).
-    pub fn pad_to(&mut self, width: usize) {
+    pub fn pad_to(&mut self, width: usize, tag: &T) {
         use self::TaggedLineElement::Str;
 
         let my_width = self.width();
         if width > my_width {
             self.v.push(Str(TaggedString {
                 s: format!("{: <width$}", "", width = width - my_width),
-                tag: T::default(),
+                tag: tag.clone(),
             }));
         }
     }
@@ -288,10 +304,11 @@ struct WrappedBlock<T> {
     word: TaggedLine<T>, // The current word (with no whitespace).
     wordlen: usize,
     pre_wrapped: bool, // If true, we've been forced to wrap a <pre> line.
+    pad_blocks: bool,
 }
 
 impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
-    pub fn new(width: usize) -> WrappedBlock<T> {
+    pub fn new(width: usize, pad_blocks: bool) -> WrappedBlock<T> {
         WrappedBlock {
             width,
             text: Vec::new(),
@@ -302,6 +319,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
             word: TaggedLine::new(),
             wordlen: 0,
             pre_wrapped: false,
+            pad_blocks,
         }
     }
 
@@ -319,7 +337,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
                 if self.linelen > 0 {
                     self.line.push(Str(TaggedString {
                         s: " ".into(),
-                        tag: self.spacetag.take().unwrap_or_else(|| Default::default()),
+                        tag: self.spacetag.clone().unwrap_or_else(|| Default::default()),
                     }));
                     self.linelen += 1;
                     html_trace!("linelen incremented to {}", self.linelen);
@@ -341,7 +359,9 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
                 } else {
                     html_trace!("Splitting the word");
                     /* We need to split the word. */
-                    let mut wordbits = self.word.drain_all();
+                    let mut word = TaggedLine::new();
+                    mem::swap(&mut word, &mut self.word);
+                    let mut wordbits = word.drain_all();
                     /* Note: there's always at least one piece */
                     let mut opt_elt = wordbits.next();
                     let mut lineleft = self.width;
@@ -377,13 +397,8 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
                                     s: piece.s[..split_idx].into(),
                                     tag: piece.tag.clone(),
                                 }));
-                                {
-                                    let mut tmp_line = TaggedLine::new();
-                                    mem::swap(&mut tmp_line, &mut self.line);
-                                    self.text.push(tmp_line);
-                                }
+                                self.force_flush_line();
                                 lineleft = self.width;
-                                self.linelen = 0;
                                 html_trace!("linelen set to zero here");
                                 opt_elt = Some(Str(TaggedString {
                                     s: piece.s[split_idx..].into(),
@@ -411,6 +426,16 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
     fn force_flush_line(&mut self) {
         let mut tmp_line = TaggedLine::new();
         mem::swap(&mut tmp_line, &mut self.line);
+        if self.pad_blocks {
+            let tmp_tag;
+            let tag = if let Some(st) = self.spacetag.as_ref() {
+                st
+            } else {
+                tmp_tag = Default::default();
+                &tmp_tag
+            };
+            tmp_line.pad_to(self.width, tag);
+        }
         self.text.push(tmp_line);
         self.linelen = 0;
     }
@@ -451,7 +476,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
             if c.is_whitespace() {
                 /* Whitespace is mostly ignored, except to terminate words. */
                 self.flush_word()?;
-                self.spacetag = Some(tag.clone());
+        self.spacetag = Some(tag.clone());
             } else if let Some(charwidth) = UnicodeWidthChar::width(c) {
                 /* Not whitespace; add to the current word. */
                 self.word.push_char(c, tag);
@@ -621,6 +646,16 @@ pub trait TextDecorator {
         false
     }
 
+    /// Return an annotation corresponding to adding background colour, or none.
+    fn push_bgcolour(&mut self, _: Colour) -> Option<Self::Annotation> {
+        None
+    }
+
+    /// Pop the last background colour pushed if we pushed one.
+    fn pop_bgcolour(&mut self) -> bool {
+        false
+    }
+
     /// Finish with a document, and return extra lines (eg footnotes)
     /// to add to the rendered text.
     fn finalise(&mut self, links: Vec<String>) -> Vec<TaggedLine<Self::Annotation>>;
@@ -645,23 +680,27 @@ pub enum BorderSegHoriz {
 /// A dividing line between table rows which tracks intersections
 /// with vertical lines.
 #[derive(Clone, Debug)]
-pub struct BorderHoriz {
+pub struct BorderHoriz<T: Clone> {
     /// The segments for the line.
     pub segments: Vec<BorderSegHoriz>,
+    /// The tag associated with the lines
+    pub tag: T,
 }
 
-impl BorderHoriz {
+impl<T: Clone> BorderHoriz<T> {
     /// Create a new blank border line.
-    pub fn new(width: usize) -> BorderHoriz {
+    pub fn new(width: usize, tag: T) -> Self {
         BorderHoriz {
             segments: vec![BorderSegHoriz::Straight; width],
+            tag
         }
     }
 
     /// Create a new blank border line.
-    pub fn new_type(width: usize, linetype: BorderSegHoriz) -> BorderHoriz {
+    pub fn new_type(width: usize, linetype: BorderSegHoriz, tag: T) -> Self {
         BorderHoriz {
             segments: vec![linetype; width],
+            tag,
         }
     }
 
@@ -698,7 +737,7 @@ impl BorderHoriz {
     }
 
     /// Merge a (possibly partial) border line below into this one.
-    pub fn merge_from_below(&mut self, other: &BorderHoriz, pos: usize) {
+    pub fn merge_from_below(&mut self, other: &BorderHoriz<T>, pos: usize) {
         use self::BorderSegHoriz::*;
         for (idx, seg) in other.segments.iter().enumerate() {
             match *seg {
@@ -711,7 +750,7 @@ impl BorderHoriz {
     }
 
     /// Merge a (possibly partial) border line above into this one.
-    pub fn merge_from_above(&mut self, other: &BorderHoriz, pos: usize) {
+    pub fn merge_from_above(&mut self, other: &BorderHoriz<T>, pos: usize) {
         use self::BorderSegHoriz::*;
         for (idx, seg) in other.segments.iter().enumerate() {
             match *seg {
@@ -762,7 +801,7 @@ pub enum RenderLine<T: PartialEq + Eq + Clone + Debug + Default> {
     /// Some rendered text
     Text(TaggedLine<T>),
     /// A table border line
-    Line(BorderHoriz),
+    Line(BorderHoriz<T>),
 }
 
 impl<T: PartialEq + Eq + Clone + Debug + Default> RenderLine<T> {
@@ -783,9 +822,10 @@ impl<T: PartialEq + Eq + Clone + Debug + Default> RenderLine<T> {
             RenderLine::Text(tagged) => tagged,
             RenderLine::Line(border) => {
                 let mut tagged = TaggedLine::new();
+                let tag = border.tag.clone();
                 tagged.push(Str(TaggedString {
                     s: border.into_string(),
-                    tag: T::default(),
+                    tag: tag,
                 }));
                 tagged
             }
@@ -800,6 +840,15 @@ impl<T: PartialEq + Eq + Clone + Debug + Default> RenderLine<T> {
             RenderLine::Line(border) => border.to_string(),
         }
     }
+
+    /// Return whether this line has any text content
+    /// Borders do not count as text.
+    fn has_content(&self) -> bool {
+        match self {
+            RenderLine::Text(line) => !line.is_empty(),
+            RenderLine::Line(_) => false,
+        }
+    }
 }
 
 /// A renderer which just outputs plain text with
@@ -807,7 +856,7 @@ impl<T: PartialEq + Eq + Clone + Debug + Default> RenderLine<T> {
 #[derive(Clone)]
 pub struct SubRenderer<D: TextDecorator> {
     width: usize,
-    wrap_width: Option<usize>,
+    options: RenderOptions,
     lines: LinkedList<RenderLine<Vec<D::Annotation>>>,
     /// True at the end of a block, meaning we should add
     /// a blank line if any other text is added.
@@ -832,6 +881,21 @@ impl<D: TextDecorator + Debug> std::fmt::Debug for SubRenderer<D> {
     }
 }
 
+/// Rendering options.
+#[derive(Clone, Default)]
+#[non_exhaustive]
+pub struct RenderOptions {
+    /// The maximum text wrap width.  If set, paragraphs of text will only be wrapped
+    /// to that width or less, though the overall width can be larger (e.g. for indented
+    /// blocks or side-by-side table cells).
+    pub wrap_width: Option<usize>,
+
+    /// Whether to always pad lines out to the full width.
+    /// This may give a better output when the parent block
+    /// has a background colour set.
+    pub pad_block_width: bool,
+}
+
 impl<D: TextDecorator> SubRenderer<D> {
     /// Render links as lines
     pub fn finalise(&mut self, links: Vec<String>) -> Vec<TaggedLine<D::Annotation>> {
@@ -839,11 +903,11 @@ impl<D: TextDecorator> SubRenderer<D> {
     }
 
     /// Construct a new empty SubRenderer.
-    pub fn new(width: usize, wrap_width: Option<usize>, decorator: D) -> SubRenderer<D> {
+    pub fn new(width: usize, options: RenderOptions, decorator: D) -> SubRenderer<D> {
         html_trace!("new({})", width);
         SubRenderer {
             width,
-            wrap_width,
+            options,
             lines: LinkedList::new(),
             at_block_end: false,
             wrapping: None,
@@ -856,11 +920,11 @@ impl<D: TextDecorator> SubRenderer<D> {
 
     fn ensure_wrapping_exists(&mut self) {
         if self.wrapping.is_none() {
-            let wwidth = match self.wrap_width {
+            let wwidth = match self.options.wrap_width {
                 Some(ww) => ww.min(self.width),
                 None => self.width
             };
-            self.wrapping = Some(WrappedBlock::new(wwidth));
+            self.wrapping = Some(WrappedBlock::new(wwidth, self.options.pad_block_width));
         }
     }
 
@@ -918,7 +982,7 @@ impl<D: TextDecorator> SubRenderer<D> {
 
     #[cfg(feature = "html_trace")]
     /// Returns a string of the current builder contents (for testing).
-    fn to_string(&self) -> String {
+    pub fn to_string(&self) -> String {
         let mut result = String::new();
         for line in &self.lines {
             result += &line.to_string();
@@ -980,7 +1044,7 @@ impl<D: TextDecorator> SubRenderer<D> {
         Ok(self.lines)
     }
 
-    fn add_horizontal_line(&mut self, line: BorderHoriz) -> Result<(), Error> {
+    fn add_horizontal_line(&mut self, line: BorderHoriz<Vec<D::Annotation>>) -> Result<(), Error> {
         self.flush_wrapping()?;
         self.lines.push_back(RenderLine::Line(line));
         Ok(())
@@ -1015,13 +1079,16 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
         if width < 1 {
             return Err(Error::TooNarrow);
         }
-        Ok(SubRenderer::new(width, self.wrap_width, self.decorator.make_subblock_decorator()))
+        let mut result = SubRenderer::new(width, self.options.clone(), self.decorator.make_subblock_decorator());
+        // Copy the annotation stack
+        result.ann_stack = self.ann_stack.clone();
+        Ok(result)
     }
 
     fn start_block(&mut self) -> crate::Result<()> {
         html_trace!("start_block({})", self.width);
         self.flush_all()?;
-        if !self.lines.is_empty() {
+        if self.lines.iter().any(|l| l.has_content()) {
             self.add_empty_line()?;
         }
         html_trace_quiet!("start_block; at_block_end <- false");
@@ -1048,14 +1115,14 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
     fn add_horizontal_border(&mut self) -> Result<(), Error> {
         self.flush_wrapping()?;
         self.lines
-            .push_back(RenderLine::Line(BorderHoriz::new(self.width)));
+            .push_back(RenderLine::Line(BorderHoriz::new(self.width, self.ann_stack.clone())));
         Ok(())
     }
 
     fn add_horizontal_border_width(&mut self, width: usize) -> Result<(), Error> {
         self.flush_wrapping()?;
         self.lines
-            .push_back(RenderLine::Line(BorderHoriz::new(width)));
+            .push_back(RenderLine::Line(BorderHoriz::new(width, self.ann_stack.clone())));
         Ok(())
     }
 
@@ -1173,7 +1240,7 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
     {
         use self::TaggedLineElement::Str;
         html_trace!("append_columns_with_borders(collapse={})", collapse);
-        html_trace!("self=\n{}", self.to_string());
+        html_trace!("self=<<<\n{}>>>", self.to_string());
 
         self.flush_wrapping()?;
 
@@ -1193,7 +1260,7 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
                         .map(|mut line| {
                             match line {
                                 RenderLine::Text(ref mut tline) => {
-                                    tline.pad_to(width);
+                                    tline.pad_to(width, &self.ann_stack);
                                 }
                                 RenderLine::Line(ref mut border) => {
                                     border.stretch_to(width);
@@ -1208,7 +1275,7 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
 
         tot_width += line_sets.len().saturating_sub(1);
 
-        let mut next_border = BorderHoriz::new(tot_width);
+        let mut next_border = BorderHoriz::new(tot_width, self.ann_stack.clone());
 
         // Join the vertical lines to all the borders
         {
@@ -1352,7 +1419,7 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
             if first {
                 first = false;
             } else {
-                let border = BorderHoriz::new_type(width, BorderSegHoriz::StraightVert);
+                let border = BorderHoriz::new_type(width, BorderSegHoriz::StraightVert, self.ann_stack.clone());
                 self.add_horizontal_line(border)?;
             }
             self.append_subrender(col, std::iter::repeat(""))?;
@@ -1488,6 +1555,18 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
             self.ann_stack.pop();
         }
     }
+
+    fn push_bgcolour(&mut self, colour: Colour) {
+        if let Some(ann) = self.decorator.push_bgcolour(colour) {
+            self.ann_stack.push(ann);
+        }
+    }
+
+    fn pop_bgcolour(&mut self) {
+        if self.decorator.pop_bgcolour() {
+            self.ann_stack.pop();
+        }
+    }
 }
 
 /// A decorator for use with `SubRenderer` which outputs plain UTF-8 text
@@ -1712,6 +1791,8 @@ pub enum RichAnnotation {
     Preformat(bool),
     /// Colour information
     Colour(crate::Colour),
+    /// Background Colour information
+    BgColour(crate::Colour),
 }
 
 impl Default for RichAnnotation {
@@ -1814,4 +1895,12 @@ impl TextDecorator for RichDecorator {
     fn pop_colour(&mut self) -> bool {
         true
     }
+
+    fn push_bgcolour(&mut self, colour: Colour) -> Option<Self::Annotation> {
+        Some(RichAnnotation::BgColour(colour))
+    }
+
+    fn pop_bgcolour(&mut self) -> bool {
+        true
+    }
 }


Test patch:
diff --git a/src/tests.rs b/src/tests.rs
index 924be5c..28d9a4d 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -33,6 +33,7 @@ fn test_html_css(input: &[u8], expected: &str, width: usize) {
 fn test_colour_map(annotations: &[RichAnnotation], s: &str) -> String
 {
     let mut tags = ("", "");
+    let mut bgtags = ("", "");
     for ann in annotations {
         match ann {
             RichAnnotation::Colour(c) => {
@@ -44,15 +45,43 @@ fn test_colour_map(annotations: &[RichAnnotation], s: &str) -> String
                     } => {
                         tags = ("<R>", "</R>");
                     }
+                    crate::Colour{
+                        r: 0,
+                        g: 0xff,
+                        b: 0
+                    } => {
+                        tags = ("<G>", "</G>");
+                    }
                     _ => {
                         tags = ("<?>", "</?>");
                     }
                 }
             }
+            RichAnnotation::BgColour(c) => {
+                match c {
+                    crate::Colour{
+                        r: 0xff,
+                        g: 0,
+                        b: 0
+                    } => {
+                        bgtags = ("<r>", "</r>");
+                    }
+                    crate::Colour{
+                        r: 0,
+                        g: 0xff,
+                        b: 0
+                    } => {
+                        bgtags = ("<g>", "</g>");
+                    }
+                    _ => {
+                        bgtags = ("<.>", "</.>");
+                    }
+                }
+            }
             _ => ()
         }
     }
-    format!("{}{}{}", tags.0, s, tags.1)
+    format!("{}{}{}{}{}", bgtags.0, tags.0, s, tags.1, bgtags.1)
 }
 
 #[cfg(feature = "css")]
@@ -1170,7 +1199,7 @@ Bar
 
 #[test]
 fn test_pre_emptyline() {
-    test_html(br#"<pre>X<span id="i"> </span></pre>"#, "X  \n", 10);
+    test_html(br#"<pre>X<span id="i"> </span></pre>"#, "X \n", 10);
 }
 
 #[test]
@@ -1475,8 +1504,7 @@ fn test_empty_table() {
         br##"
    <table></table>
 "##,
-        r#"
-"#,
+        r#""#,
         12,
     );
 }
@@ -1487,8 +1515,7 @@ fn test_table_empty_single_row() {
         br##"
    <table><tr></tr></table>
 "##,
-        r#"
-"#,
+        r#""#,
         12,
     );
 }
@@ -1499,8 +1526,7 @@ fn test_table_empty_single_row_empty_cell() {
         br##"
    <table><tr><td></td></tr></table>
 "##,
-        r#"
-"#,
+        r#""#,
         12,
     );
 }
@@ -1660,7 +1686,7 @@ Test.
 End.
 </pre>"#;
     let decorator = crate::render::text_renderer::TrivialDecorator::new();
-    let text = from_read_with_decorator(html.as_bytes(), usize::MAX, decorator.clone());
+    let text = from_read_with_decorator(html.as_bytes(), 20, decorator.clone());
     assert_eq!(text, "Test.\n\n\nEnd.\n");
 }
 
@@ -1794,7 +1820,7 @@ There
     }
 
     #[test]
-    fn test_coloured()
+    fn test_coloured_a()
     {
         test_html_coloured(br##"
           <style>
@@ -1802,24 +1828,41 @@ There
                   color:#FF0000;
               }
           </style>
-        <p>Test <span class="red">red</span></p>
+        <p>Test <a class="red" href="foo">red</a></p>
         "##,
         r#"Test <R>red</R>
 "#, 20);
     }
 
     #[test]
-    fn test_coloured_a()
+    fn test_bgcoloured()
     {
         test_html_coloured(br##"
           <style>
               .red {
                   color:#FF0000;
+                  background-color:#00FF00;
               }
           </style>
-        <p>Test <a class="red" href="foo">red</a></p>
+        <p>Test <span class="red">red</span></p>
         "##,
-        r#"Test <R>red</R>
+        r#"Test <g><R>red</R></g>
+"#, 20);
+    }
+
+    #[test]
+    fn test_bgcoloured2()
+    {
+        test_html_coloured(br##"
+          <style>
+              .red {
+                  color:#FF0000;
+                  background-color:#00FF00;
+              }
+          </style>
+        <p>Test <span class="red">red</span> and <span style="color: #00ff00">green</span></p>
+        "##,
+        r#"Test <g><R>red</R></g> and <G>green</G>
 "#, 20);
     }