Repository: jugglerchris/rust-html2text (rust)

Issue:
Support CSS white-space: pre-wrap
In default HTML with no CSS, `<p>` and `<pre>` differ in two separate ways:
1. `<p>` wraps paragraphs at word boundaries, whereas `<pre>` either doesn't wrap at all (creating arbitrarily long lines), or if that isn't possible in the output medium (like here), wraps in the simplest possible way, breaking lines at the very right-hand edge of the display without regard to whitespace.
2. `<p>` canonicalises whitespace so that you see just one space between words, and no space at the start of a paragraph, no matter what mixture of space and newline characters appeared in the input HTML.

The CSS option `white-space: pre-wrap` defines a half-way point between the two. Words are still wrapped as in `<p>`, but whitespace is not canonicalised. This makes it a good choice when HTML is autogenerated from simple plain text with no markup: you get a reasonable balance between paragraphs being readable, and things like code snippets also being readable (because the indentation is preserved), without the HTML creator having to judge which paragraphs should be `<p>` and which `<pre>`. In particular, the Mastodon web UI sets this CSS option when displaying the HTML of a toot.

It would be useful for `html2text` to support this CSS setting, injected via `Config::add_css()`, so that (for example) toots received as HTML from a Mastodon server could be formatted in a way that didn't lose information intended to be preserved.

(I understand that CSS also has independent settings for `text-wrap-mode` and `white-space-collapse`, so that `white-space: pre-wrap` is just a shorthand for setting both of those to the appropriate values.)


Pull request description:
Support white-space: pre-wrap
Add support for CSS `white-space` values `normal`, `pre`, and `pre-wrap`.
Change the implementation of `<pre>` - it's now treated the same as `<p>` with `white-space: pre` (even with CSS disabled - the style is constructed directly rather than going through a CSS parser).

Closes #157 

Patch:
diff --git a/src/css.rs b/src/css.rs
index a7bb8a6..5471fea 100644
--- a/src/css.rs
+++ b/src/css.rs
@@ -10,7 +10,8 @@ use crate::{
         Handle,
         NodeData::{self, Comment, Document, Element},
     },
-    tree_map_reduce, Colour, Result, TreeMapResult,
+    tree_map_reduce, Colour, ComputedStyle, Result, Specificity, StyleOrigin, TreeMapResult,
+    WhiteSpace,
 };
 
 use self::parser::Importance;
@@ -25,43 +26,6 @@ pub(crate) enum SelectorComponent {
     CombDescendant,
 }
 
-#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
-pub(crate) struct Specificity {
-    inline: bool,
-    id: u16,
-    class: u16,
-    typ: u16,
-}
-
-impl Specificity {
-    fn inline() -> Self {
-        Specificity {
-            inline: true,
-            id: 0,
-            class: 0,
-            typ: 0,
-        }
-    }
-}
-
-impl PartialOrd for Specificity {
-    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
-        match self.inline.partial_cmp(&other.inline) {
-            Some(core::cmp::Ordering::Equal) => {}
-            ord => return ord,
-        }
-        match self.id.partial_cmp(&other.id) {
-            Some(core::cmp::Ordering::Equal) => {}
-            ord => return ord,
-        }
-        match self.class.partial_cmp(&other.class) {
-            Some(core::cmp::Ordering::Equal) => {}
-            ord => return ord,
-        }
-        self.typ.partial_cmp(&other.typ)
-    }
-}
-
 #[derive(Debug, Clone, PartialEq)]
 pub(crate) struct Selector {
     // List of components, right first so we match from the leaf.
@@ -176,6 +140,7 @@ pub(crate) enum Style {
     Colour(Colour),
     BgColour(Colour),
     DisplayNone,
+    WhiteSpace(WhiteSpace),
 }
 
 #[derive(Debug, Clone)]
@@ -184,88 +149,6 @@ pub(crate) struct StyleDecl {
     importance: Importance,
 }
 
-#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, PartialOrd)]
-pub(crate) enum StyleOrigin {
-    #[default]
-    None,
-    Agent,
-    User,
-    Author,
-}
-
-#[derive(Clone, Copy, Debug)]
-pub(crate) struct WithSpec<T: Copy + Clone> {
-    val: Option<T>,
-    origin: StyleOrigin,
-    specificity: Specificity,
-    important: bool,
-}
-impl<T: Copy + Clone> WithSpec<T> {
-    fn maybe_update(
-        &mut self,
-        important: bool,
-        origin: StyleOrigin,
-        specificity: Specificity,
-        val: T,
-    ) {
-        if self.val.is_some() {
-            // We already have a value, so need to check.
-            if self.important && !important {
-                // important takes priority over not important.
-                return;
-            }
-            // importance is the same.  Next is checking the origin.
-            {
-                use StyleOrigin::*;
-                match (self.origin, origin) {
-                    (Agent, Agent) | (User, User) | (Author, Author) => {
-                        // They're the same so continue the comparison
-                    }
-                    (mine, theirs) => {
-                        if (important && theirs > mine) || (!important && mine > theirs) {
-                            return;
-                        }
-                    }
-                }
-            }
-            // We're now from the same origin an importance
-            if specificity < self.specificity {
-                return;
-            }
-        }
-        self.val = Some(val);
-        self.origin = origin;
-        self.specificity = specificity;
-        self.important = important;
-    }
-
-    pub fn val(&self) -> Option<T> {
-        self.val
-    }
-}
-
-impl<T: Copy + Clone> Default for WithSpec<T> {
-    fn default() -> Self {
-        WithSpec {
-            val: None,
-            origin: StyleOrigin::None,
-            specificity: Default::default(),
-            important: false,
-        }
-    }
-}
-
-#[derive(Debug, Copy, Clone, Default)]
-pub(crate) struct ComputedStyle {
-    /// The computed foreground colour, if any
-    pub(crate) colour: WithSpec<Colour>,
-    /// The specificity for colour
-    /// The computed background colour, if any
-    pub(crate) bg_colour: WithSpec<Colour>,
-    /// If set, indicates whether `display: none` or something equivalent applies
-    pub(crate) display_none: WithSpec<bool>,
-}
-
 #[derive(Debug, Clone)]
 struct Ruleset {
     selector: Selector,
@@ -354,6 +237,12 @@ fn styles_from_properties(decls: &[parser::Declaration]) -> Vec<StyleDecl> {
                         importance: decl.important,
                     });
                 }
+            }
+            parser::Decl::WhiteSpace { value } => {
+                styles.push(StyleDecl {
+                    style: Style::WhiteSpace(*value),
+                    importance: decl.important,
+                });
             } /*
               _ => {
                   html_trace_quiet!("CSS: Unhandled property {:?}", decl);
@@ -386,7 +275,7 @@ impl StyleData {
                         styles: styles.clone(),
                     };
                     html_trace_quiet!("Adding ruleset {ruleset:?}");
-                    rules.push(ruleset);
+                    rules.push(dbg!(ruleset));
                 }
             }
         }
@@ -527,6 +416,11 @@ impl StyleData {
                     .display_none
                     .maybe_update(important, origin, specificity, true);
             }
+            Style::WhiteSpace(ws) => {
+                result
+                    .white_space
+                    .maybe_update(important, origin, specificity, ws);
+            }
         }
     }
 }
@@ -609,7 +503,7 @@ pub(crate) fn dom_to_stylesheet<T: Write>(handle: Handle, err_out: &mut T) -> Re
 
 #[cfg(test)]
 mod tests {
-    use crate::css::Specificity;
+    use crate::Specificity;
 
     use super::parser::parse_selector;
 
diff --git a/src/css/parser.rs b/src/css/parser.rs
index 29450c1..607c341 100644
--- a/src/css/parser.rs
+++ b/src/css/parser.rs
@@ -64,6 +64,7 @@ pub enum Display {
 }
 
 #[derive(Debug, PartialEq)]
+#[non_exhaustive]
 pub enum Decl {
     Color {
         value: Colour,
@@ -86,6 +87,9 @@ pub enum Decl {
     Display {
         value: Display,
     },
+    WhiteSpace {
+        value: WhiteSpace,
+    },
     Unknown {
         name: PropertyName,
         //        value: Vec<Token>,
@@ -161,7 +165,7 @@ pub struct Declaration {
     pub important: Importance,
 }
 
-use super::{Selector, SelectorComponent};
+use super::{Selector, SelectorComponent, WhiteSpace};
 
 #[derive(Debug, PartialEq)]
 pub(crate) struct RuleSet {
@@ -427,6 +431,10 @@ pub fn parse_declaration(text: &str) -> IResult<&str, Option<Declaration>> {
             let value = parse_display(&value)?;
             Decl::Display { value }
         }
+        "white-space" => {
+            let value = parse_white_space(&value)?;
+            Decl::WhiteSpace { value }
+        }
         _ => Decl::Unknown {
             name: prop,
             //            value: /*value*/"".into(),
@@ -679,6 +687,22 @@ fn parse_display(value: &RawValue) -> Result<Display, nom::Err<nom::error::Error
     Ok(Display::Other)
 }
 
+fn parse_white_space(
+    value: &RawValue,
+) -> Result<WhiteSpace, nom::Err<nom::error::Error<&'static str>>> {
+    for tok in &value.tokens {
+        if let Token::Ident(word) = tok {
+            match word.deref() {
+                "normal" => return Ok(WhiteSpace::Normal),
+                "pre" => return Ok(WhiteSpace::Pre),
+                "pre-wrap" => return Ok(WhiteSpace::PreWrap),
+                _ => (),
+            }
+        }
+    }
+    Ok(WhiteSpace::Normal)
+}
+
 pub fn parse_rules(text: &str) -> IResult<&str, Vec<Declaration>> {
     separated_list0(
         tuple((tag(";"), skip_optional_whitespace)),
diff --git a/src/lib.rs b/src/lib.rs
index abbc164..48ca823 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -64,13 +64,6 @@ mod macros;
 pub mod css;
 pub mod render;
 
-#[cfg(feature = "css")]
-use css::ComputedStyle;
-
-#[cfg(not(feature = "css"))]
-#[derive(Copy, Clone, Debug, Default)]
-struct ComputedStyle;
-
 use render::text_renderer::{
     RenderLine, RenderOptions, RichAnnotation, SubRenderer, TaggedLine, TextRenderer,
 };
@@ -96,6 +89,27 @@ use std::io;
 use std::io::Write;
 use std::iter::{once, repeat};
 
+#[derive(Debug, Copy, Clone, Default, PartialEq)]
+pub(crate) enum WhiteSpace {
+    #[default]
+    Normal,
+    // NoWrap,
+    Pre,
+    #[allow(unused)]
+    PreWrap,
+    // PreLine,
+    // BreakSpaces,
+}
+
+impl WhiteSpace {
+    pub fn preserve_whitespace(&self) -> bool {
+        match self {
+            WhiteSpace::Normal => false,
+            WhiteSpace::Pre | WhiteSpace::PreWrap => true,
+        }
+    }
+}
+
 /// An RGB colour value
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
 pub struct Colour {
@@ -107,6 +121,132 @@ pub struct Colour {
     pub b: u8,
 }
 
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, PartialOrd)]
+pub(crate) enum StyleOrigin {
+    #[default]
+    None,
+    Agent,
+    #[allow(unused)]
+    User,
+    #[allow(unused)]
+    Author,
+}
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
+pub(crate) struct Specificity {
+    inline: bool,
+    id: u16,
+    class: u16,
+    typ: u16,
+}
+
+impl Specificity {
+    #[cfg(feature = "css")]
+    fn inline() -> Self {
+        Specificity {
+            inline: true,
+            id: 0,
+            class: 0,
+            typ: 0,
+        }
+    }
+}
+
+impl PartialOrd for Specificity {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        match self.inline.partial_cmp(&other.inline) {
+            Some(core::cmp::Ordering::Equal) => {}
+            ord => return ord,
+        }
+        match self.id.partial_cmp(&other.id) {
+            Some(core::cmp::Ordering::Equal) => {}
+            ord => return ord,
+        }
+        match self.class.partial_cmp(&other.class) {
+            Some(core::cmp::Ordering::Equal) => {}
+            ord => return ord,
+        }
+        self.typ.partial_cmp(&other.typ)
+    }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct WithSpec<T: Copy + Clone> {
+    val: Option<T>,
+    origin: StyleOrigin,
+    specificity: Specificity,
+    important: bool,
+}
+impl<T: Copy + Clone> WithSpec<T> {
+    pub(crate) fn maybe_update(
+        &mut self,
+        important: bool,
+        origin: StyleOrigin,
+        specificity: Specificity,
+        val: T,
+    ) {
+        if self.val.is_some() {
+            // We already have a value, so need to check.
+            if self.important && !important {
+                // important takes priority over not important.
+                return;
+            }
+            // importance is the same.  Next is checking the origin.
+            {
+                use StyleOrigin::*;
+                match (self.origin, origin) {
+                    (Agent, Agent) | (User, User) | (Author, Author) => {
+                        // They're the same so continue the comparison
+                    }
+                    (mine, theirs) => {
+                        if (important && theirs > mine) || (!important && mine > theirs) {
+                            return;
+                        }
+                    }
+                }
+            }
+            // We're now from the same origin an importance
+            if specificity < self.specificity {
+                return;
+            }
+        }
+        self.val = Some(val);
+        self.origin = origin;
+        self.specificity = specificity;
+        self.important = important;
+    }
+
+    pub fn val(&self) -> Option<T> {
+        self.val
+    }
+}
+
+impl<T: Copy + Clone> Default for WithSpec<T> {
+    fn default() -> Self {
+        WithSpec {
+            val: None,
+            origin: StyleOrigin::None,
+            specificity: Default::default(),
+            important: false,
+        }
+    }
+}
+
+#[derive(Debug, Copy, Clone, Default)]
+pub(crate) struct ComputedStyle {
+    #[cfg(feature = "css")]
+    /// The computed foreground colour, if any
+    pub(crate) colour: WithSpec<Colour>,
+    #[cfg(feature = "css")]
+    /// The computed background colour, if any
+    pub(crate) bg_colour: WithSpec<Colour>,
+    #[cfg(feature = "css")]
+    /// If set, indicates whether `display: none` or something equivalent applies
+    pub(crate) display_none: WithSpec<bool>,
+    /// The CSS white-space property
+    pub(crate) white_space: WithSpec<WhiteSpace>,
+}
+
 /// Errors from reading or rendering HTML
 #[derive(thiserror::Error, Debug)]
 #[non_exhaustive]
@@ -419,8 +559,6 @@ enum RenderNodeInfo {
     Header(usize, Vec<RenderNode>),
     /// A Div element with children
     Div(Vec<RenderNode>),
-    /// A preformatted region.
-    Pre(Vec<RenderNode>),
     /// A blockquote
     BlockQuote(Vec<RenderNode>),
     /// An unordered list
@@ -532,8 +670,7 @@ impl RenderNode {
             }
 
             Container(ref v) | Em(ref v) | Strong(ref v) | Strikeout(ref v) | Code(ref v)
-            | Block(ref v) | Div(ref v) | Pre(ref v) | Dl(ref v) | Dt(ref v) | ListItem(ref v)
-            | Sup(ref v) => v
+            | Block(ref v) | Div(ref v) | Dl(ref v) | Dt(ref v) | ListItem(ref v) | Sup(ref v) => v
                 .iter()
                 .map(recurse)
                 .fold(Default::default(), SizeEstimate::add),
@@ -629,7 +766,6 @@ impl RenderNode {
             | Block(ref v)
             | ListItem(ref v)
             | Div(ref v)
-            | Pre(ref v)
             | BlockQuote(ref v)
             | Dl(ref v)
             | Dt(ref v)
@@ -670,7 +806,6 @@ fn precalc_size_estimate<'a, 'b: 'a, D: TextDecorator>(
         | Block(ref v)
         | ListItem(ref v)
         | Div(ref v)
-        | Pre(ref v)
         | BlockQuote(ref v)
         | Ul(ref v)
         | Ol(_, ref v)
@@ -1138,7 +1273,6 @@ fn prepend_marker(prefix: RenderNode, mut orig: RenderNode) -> RenderNode {
         Block(ref mut children)
         | ListItem(ref mut children)
         | Div(ref mut children)
-        | Pre(ref mut children)
         | BlockQuote(ref mut children)
         | Container(ref mut children)
         | TableCell(RenderTableCell {
@@ -1335,7 +1469,14 @@ fn process_dom_node<'a, T: Write>(
                     Ok(Some(RenderNode::new_styled(Div(cs), computed)))
                 }),
                 expanded_name!(html "pre") => pending(input, move |_, cs| {
-                    Ok(Some(RenderNode::new_styled(Pre(cs), computed)))
+                    let mut computed = computed;
+                    computed.white_space.maybe_update(
+                        false,
+                        StyleOrigin::Agent,
+                        Default::default(),
+                        WhiteSpace::Pre,
+                    );
+                    Ok(Some(RenderNode::new_styled(Block(cs), computed)))
                 }),
                 expanded_name!(html "br") => Finished(RenderNode::new_styled(Break, computed)),
                 expanded_name!(html "table") => table_to_render_tree(input, computed, err_out),
@@ -1491,22 +1632,33 @@ fn pending2<
 struct PushedStyleInfo {
     colour: bool,
     bgcolour: bool,
+    white_space: bool,
 }
 
 impl PushedStyleInfo {
     fn apply<D: TextDecorator>(render: &mut TextRenderer<D>, style: &ComputedStyle) -> Self {
         #[allow(unused_mut)]
         let mut result: PushedStyleInfo = Default::default();
-        #[cfg(feature = "css")]
         {
+            #[cfg(feature = "css")]
             if let Some(col) = style.colour.val() {
                 render.push_colour(col);
                 result.colour = true;
             }
+            #[cfg(feature = "css")]
             if let Some(col) = style.bg_colour.val() {
                 render.push_bgcolour(col);
                 result.bgcolour = true;
             }
+            if let Some(ws) = style.white_space.val() {
+                match ws {
+                    WhiteSpace::Normal => {}
+                    WhiteSpace::Pre | WhiteSpace::PreWrap => {
+                        render.push_ws(ws);
+                        result.white_space = true;
+                    }
+                }
+            }
         }
         #[cfg(not(feature = "css"))]
         {
@@ -1522,6 +1674,9 @@ impl PushedStyleInfo {
         if self.colour {
             renderer.pop_colour();
         }
+        if self.white_space {
+            renderer.pop_ws();
+        }
     }
 }
 
@@ -1628,16 +1783,6 @@ fn do_render_node<T: Write, D: TextDecorator>(
                 Ok(Some(None))
             })
         }
-        Pre(children) => {
-            renderer.new_line()?;
-            renderer.start_pre();
-            pending2(children, |renderer: &mut TextRenderer<D>, _| {
-                renderer.new_line()?;
-                renderer.end_pre();
-                pushed_style.unwind(renderer);
-                Ok(Some(None))
-            })
-        }
         BlockQuote(children) => {
             let prefix = renderer.quote_prefix();
             debug_assert!(size_estimate.prefix_size == prefix.len());
diff --git a/src/render/mod.rs b/src/render/mod.rs
index 574622e..5437920 100644
--- a/src/render/mod.rs
+++ b/src/render/mod.rs
@@ -3,6 +3,7 @@
 
 use crate::Colour;
 use crate::Error;
+use crate::WhiteSpace;
 
 pub(crate) mod text_renderer;
 
@@ -45,11 +46,11 @@ pub(crate) trait Renderer {
     }
 
     /// Begin a preformatted block.  Until the corresponding end,
-    /// whitespace will used verbatim.  Pre regions can nest.
-    fn start_pre(&mut self);
+    /// whitespace handlinng will be modified.  Can be nested.
+    fn push_ws(&mut self, ws: WhiteSpace);
 
-    /// Finish a preformatted block started with `start_pre`.
-    fn end_pre(&mut self);
+    /// Finish a preformatted block started with `push_ws`.
+    fn pop_ws(&mut self);
 
     /// Add some inline text (which should be wrapped at the
     /// appropriate width) to the current block.
diff --git a/src/render/text_renderer.rs b/src/render/text_renderer.rs
index dd511e5..5b9a986 100644
--- a/src/render/text_renderer.rs
+++ b/src/render/text_renderer.rs
@@ -5,6 +5,7 @@
 
 use crate::Colour;
 use crate::Error;
+use crate::WhiteSpace;
 
 use super::Renderer;
 use std::cell::Cell;
@@ -332,7 +333,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
         }
     }
 
-    fn flush_word(&mut self) -> Result<(), Error> {
+    fn flush_word(&mut self, with_space: bool) -> Result<(), Error> {
         use self::TaggedLineElement::Str;
 
         /* Finish the word. */
@@ -343,7 +344,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
             let space_needed = self.wordlen + if self.linelen > 0 { 1 } else { 0 }; // space
             if space_needed <= space_in_line {
                 html_trace!("Got enough space");
-                if self.linelen > 0 {
+                if self.linelen > 0 && with_space {
                     self.line.push(Str(TaggedString {
                         s: " ".into(),
                         tag: self.spacetag.clone().unwrap_or_else(|| Default::default()),
@@ -458,7 +459,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
     }
 
     fn flush(&mut self) -> Result<(), Error> {
-        self.flush_word()?;
+        self.flush_word(true)?;
         self.flush_line();
         Ok(())
     }
@@ -492,7 +493,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
         for c in text.chars() {
             if c.is_whitespace() {
                 /* Whitespace is mostly ignored, except to terminate words. */
-                self.flush_word()?;
+                self.flush_word(true)?;
                 self.spacetag = Some(tag.clone());
             } else if let Some(charwidth) = UnicodeWidthChar::width(c) {
                 /* Not whitespace; add to the current word. */
@@ -518,7 +519,7 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
         );
         // Make sure that any previous word has been sent to the line, as we
         // bypass the word buffer.
-        self.flush_word()?;
+        self.flush_word(true)?;
 
         for c in text.chars() {
             if let Some(charwidth) = UnicodeWidthChar::width(c) {
@@ -569,6 +570,52 @@ impl<T: Clone + Eq + Debug + Default> WrappedBlock<T> {
         Ok(())
     }
 
+    fn add_text_prewrap(&mut self, text: &str, tag: &T) -> Result<(), Error> {
+        html_trace!("WrappedBlock::add_text_prewrap({}), {:?}", text, tag);
+        for c in text.chars() {
+            if c.is_whitespace() {
+                // Wrap if needed
+                self.flush_word(false)?;
+                match c {
+                    '\n' => {
+                        self.force_flush_line();
+                    }
+                    '\t' => {
+                        let tab_stop = 8;
+                        let mut at_least_one_space = false;
+                        while self.linelen % tab_stop != 0 || !at_least_one_space {
+                            if self.linelen >= self.width {
+                                self.flush_line();
+                            } else {
+                                self.line.push_char(' ', tag);
+                                self.linelen += 1;
+                                at_least_one_space = true;
+                            }
+                        }
+                    }
+                    _ => {
+                        if let Some(width) = UnicodeWidthChar::width(c) {
+                            if self.width - self.linelen < width {
+                                self.flush_line();
+                            }
+                            if self.width - self.linelen >= width {
+                                // Check for space again to avoid pathological issues
+                                self.line.push_char(c, tag);
+                                self.linelen += width;
+                            }
+                        }
+                    }
+                }
+            } else if let Some(charwidth) = UnicodeWidthChar::width(c) {
+                /* Not whitespace; add to the current word. */
+                self.word.push_char(c, tag);
+                self.wordlen += charwidth;
+            }
+            html_trace_quiet!("  Added char {:?}, wordlen={}", c, self.wordlen);
+        }
+        Ok(())
+    }
+
     fn add_element(&mut self, elt: TaggedLineElement<T>) {
         self.word.push(elt);
     }
@@ -900,7 +947,7 @@ pub(crate) struct SubRenderer<D: TextDecorator> {
     ann_stack: Vec<D::Annotation>,
     text_filter_stack: Vec<fn(&str) -> Option<String>>,
     /// The depth of `<pre>` block stacking.
-    pre_depth: usize,
+    ws_stack: Vec<WhiteSpace>,
 }
 
 impl<D: TextDecorator> std::fmt::Debug for SubRenderer<D> {
@@ -910,7 +957,7 @@ impl<D: TextDecorator> std::fmt::Debug for SubRenderer<D> {
             .field("lines", &self.lines)
             //.field("decorator", &self.decorator)
             .field("ann_stack", &self.ann_stack)
-            .field("pre_depth", &self.pre_depth)
+            .field("ws_stack", &self.ws_stack)
             .field("wrapping", &self.wrapping)
             .finish()
     }
@@ -993,7 +1040,7 @@ impl<D: TextDecorator> SubRenderer<D> {
             wrapping: None,
             decorator,
             ann_stack: Vec::new(),
-            pre_depth: 0,
+            ws_stack: Vec::new(),
             text_filter_stack: Vec::new(),
         }
     }
@@ -1104,6 +1151,10 @@ impl<D: TextDecorator> SubRenderer<D> {
         }
         Ok(new_width.max(min_width))
     }
+
+    fn ws_mode(&self) -> WhiteSpace {
+        self.ws_stack.last().cloned().unwrap_or(WhiteSpace::Normal)
+    }
 }
 
 fn filter_text_strikeout(s: &str) -> Option<String> {
@@ -1186,16 +1237,12 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
         Ok(())
     }
 
-    fn start_pre(&mut self) {
-        self.pre_depth += 1;
+    fn push_ws(&mut self, ws: WhiteSpace) {
+        self.ws_stack.push(ws);
     }
 
-    fn end_pre(&mut self) {
-        if self.pre_depth > 0 {
-            self.pre_depth -= 1;
-        } else {
-            panic!("Attempt to end a preformatted block which wasn't opened.");
-        }
+    fn pop_ws(&mut self) {
+        self.ws_stack.pop();
     }
 
     fn end_block(&mut self) {
@@ -1204,7 +1251,10 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
 
     fn add_inline_text(&mut self, text: &str) -> crate::Result<()> {
         html_trace!("add_inline_text({}, {})", self.width, text);
-        if self.pre_depth == 0 && self.at_block_end && text.chars().all(char::is_whitespace) {
+        if !self.ws_mode().preserve_whitespace()
+            && self.at_block_end
+            && text.chars().all(char::is_whitespace)
+        {
             // Ignore whitespace between blocks.
             return Ok(());
         }
@@ -1220,16 +1270,23 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
             }
         }
         let filtered_text = s.as_deref().unwrap_or(text);
-        if self.pre_depth == 0 {
-            get_wrapping_or_insert::<D>(&mut self.wrapping, &self.options, self.width)
-                .add_text(filtered_text, &self.ann_stack)?;
-        } else {
-            let mut tag_first = self.ann_stack.clone();
-            let mut tag_cont = self.ann_stack.clone();
-            tag_first.push(self.decorator.decorate_preformat_first());
-            tag_cont.push(self.decorator.decorate_preformat_cont());
-            get_wrapping_or_insert::<D>(&mut self.wrapping, &self.options, self.width)
-                .add_preformatted_text(filtered_text, &tag_first, &tag_cont)?;
+        let ws_mode = self.ws_mode();
+        let wrapping = get_wrapping_or_insert::<D>(&mut self.wrapping, &self.options, self.width);
+        match ws_mode {
+            WhiteSpace::Normal => {
+                wrapping.add_text(filtered_text, &self.ann_stack)?;
+            }
+            WhiteSpace::Pre => {
+                let mut tag_first = self.ann_stack.clone();
+                let mut tag_cont = self.ann_stack.clone();
+                tag_first.push(self.decorator.decorate_preformat_first());
+                tag_cont.push(self.decorator.decorate_preformat_cont());
+                get_wrapping_or_insert::<D>(&mut self.wrapping, &self.options, self.width)
+                    .add_preformatted_text(filtered_text, &tag_first, &tag_cont)?;
+            }
+            WhiteSpace::PreWrap => {
+                wrapping.add_text_prewrap(filtered_text, &self.ann_stack)?;
+            }
         }
         Ok(())
     }


Test patch:
diff --git a/src/tests.rs b/src/tests.rs
index 0092de3..f93e6af 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -2074,7 +2074,9 @@ foo
 
 #[cfg(feature = "css")]
 mod css_tests {
-    use super::{test_html_coloured, test_html_coloured_conf, test_html_css, test_html_style};
+    use super::{
+        test_html_coloured, test_html_coloured_conf, test_html_conf, test_html_css, test_html_style,
+    };
 
     #[test]
     fn test_disp_none() {
@@ -2551,4 +2553,28 @@ Row│Three
             },
         );
     }
+
+    #[test]
+    fn test_pre_wrap() {
+        test_html_conf(
+            br#"<p class="prewrap">Hi
+ a
+  b
+   x  longword
+c  d  e
+</p>"#,
+            r#"Hi
+ a
+  b
+   x  
+longword
+c  d  e
+"#,
+            10,
+            |conf| {
+                conf.add_css(r#".prewrap { white-space: pre-wrap; }"#)
+                    .unwrap()
+            },
+        );
+    }
 }