Repository: jugglerchris/rust-html2text
Pull request #101 (2023-12-22 16:20:06)

Add wrap width
Addresses #98 .


Patch:
diff --git a/src/lib.rs b/src/lib.rs
index 215cca7..6b8fed2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -935,22 +935,24 @@ struct HtmlContext {
     style_data: css::StyleData,
     #[cfg(feature = "css")]
     use_doc_css: bool,
+
+    max_wrap_width: Option<usize>,
 }
 
 fn dom_to_render_tree_with_context<T: Write>(
     handle: Handle,
     err_out: &mut T,
-    mut context: HtmlContext)
+    context: &mut HtmlContext)
 -> Result<Option<RenderNode>> {
     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)?;
-        doc_style_data.merge(context.style_data);
+        doc_style_data.merge(std::mem::take(&mut context.style_data));
         context.style_data = doc_style_data;
     }
 
-    let result = tree_map_reduce(&mut context, handle, |context, handle| {
+    let result = tree_map_reduce(context, handle, |context, handle| {
         process_dom_node(handle, err_out, context)
     });
 
@@ -960,7 +962,7 @@ fn dom_to_render_tree_with_context<T: Write>(
 
 /// Convert a DOM tree or subtree into a render tree.
 pub fn dom_to_render_tree<T: Write>(handle: Handle, err_out: &mut T) -> Result<Option<RenderNode>> {
-    dom_to_render_tree_with_context(handle, err_out, Default::default())
+    dom_to_render_tree_with_context(handle, err_out, &mut Default::default())
 }
 
 fn pending<'a, F>(handle: Handle, f: F) -> TreeMapResult<'a, HtmlContext, Handle, RenderNode>
@@ -1734,6 +1736,8 @@ pub mod config {
     pub struct Config<D: TextDecorator> {
         decorator: D,
 
+        max_wrap_width: Option<usize>,
+
         #[cfg(feature = "css")]
         style: StyleData,
         #[cfg(feature = "css")]
@@ -1741,29 +1745,40 @@ pub mod config {
     }
 
     impl<D: TextDecorator> Config<D> {
+        /// Make the HtmlContext from self.
+        fn make_context(&mut self) -> HtmlContext {
+            HtmlContext {
+                #[cfg(feature = "css")]
+                style_data: std::mem::take(&mut self.style),
+                #[cfg(feature = "css")]
+                use_doc_css: self.use_doc_css,
+
+                max_wrap_width: self.max_wrap_width,
+            }
+        }
         /// Parse with context.
-        fn do_parse<R: std::io::Read>(&mut self, input: R) -> Result<RenderTree> {
+        fn do_parse<R: std::io::Read>(&mut self, context: &mut HtmlContext, input: R) -> Result<RenderTree> {
             super::parse_with_context(
                 input,
-                HtmlContext {
-                    #[cfg(feature = "css")]
-                    style_data: std::mem::take(&mut self.style),
-                    #[cfg(feature = "css")]
-                    use_doc_css: self.use_doc_css,
-                })
+                context)
         }
 
         /// Reads HTML from `input`, and returns a `String` with text wrapped to
         /// `width` columns.
         pub fn string_from_read<R: std::io::Read>(mut self, input: R, width: usize) -> Result<String> {
-            Ok(self.do_parse(input)?.render(width, self.decorator)?.into_string()?)
+            let mut context = self.make_context();
+            Ok(self.do_parse(&mut context, input)?
+               .render_with_context(&mut context, width, self.decorator)?
+               .into_string()?)
         }
+
         /// Reads HTML from `input`, and returns text wrapped to `width` columns.
         /// The text is returned as a `Vec<TaggedLine<_>>`; the annotations are vectors
         /// of the provided text decorator's `Annotation`.  The "outer" annotation comes first in
         /// the `Vec`.
         pub fn lines_from_read<R: std::io::Read>(mut self, input: R, width: usize) -> Result<Vec<TaggedLine<Vec<D::Annotation>>>> {
-            Ok(self.do_parse(input)?
+            let mut context = self.make_context();
+            Ok(self.do_parse(&mut context, input)?
                 .render(width, self.decorator)?
                 .into_lines()?)
         }
@@ -1779,9 +1794,15 @@ pub mod config {
         #[cfg(feature = "css")]
         /// Parse CSS from any <style> elements and use supported rules.
         pub fn use_doc_css(mut self) -> Self {
-            {
-                self.use_doc_css = true;
-            }
+            self.use_doc_css = 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.
+        pub fn max_wrap_width(mut self, wrap_width: usize) -> Self {
+            self.max_wrap_width = Some(wrap_width);
             self
         }
     }
@@ -1803,7 +1824,8 @@ pub mod config {
         {
             use std::fmt::Write;
 
-            let lines = self.do_parse(input)?
+            let mut context = self.make_context();
+            let lines = self.do_parse(&mut context, input)?
                 .render(width, self.decorator)?
                 .into_lines()?;
 
@@ -1826,6 +1848,7 @@ pub mod config {
             style: Default::default(),
             #[cfg(feature = "css")]
             use_doc_css: false,
+            max_wrap_width: None,
         }
     }
 
@@ -1837,6 +1860,7 @@ pub mod config {
             style: Default::default(),
             #[cfg(feature = "css")]
             use_doc_css: false,
+            max_wrap_width: None,
         }
     }
 
@@ -1848,6 +1872,7 @@ pub mod config {
             style: Default::default(),
             #[cfg(feature = "css")]
             use_doc_css: false,
+            max_wrap_width: None,
         }
     }
 }
@@ -1861,15 +1886,20 @@ pub struct RenderTree(RenderNode);
 
 impl RenderTree {
     /// Render this document using the given `decorator` and wrap it to `width` columns.
-    pub fn render<D: TextDecorator>(self, width: usize, decorator: D) -> Result<RenderedText<D>> {
+    fn render_with_context<D: TextDecorator>(self, context: &mut HtmlContext, width: usize, decorator: D) -> Result<RenderedText<D>> {
         if width == 0 {
             return Err(Error::TooNarrow);
         }
-        let builder = SubRenderer::new(width, decorator);
+        let builder = SubRenderer::new(width, context.max_wrap_width, decorator);
         let builder = render_tree_to_string(builder, self.0, &mut Discard {})?;
         Ok(RenderedText(builder))
     }
 
+    /// Render this document using the given `decorator` and wrap it to `width` columns.
+    pub fn render<D: TextDecorator>(self, width: usize, decorator: D) -> Result<RenderedText<D>> {
+        self.render_with_context(&mut Default::default(), width, decorator)
+    }
+
     /// Render this document as plain text using the [`PlainDecorator`][] and wrap it to `width`
     /// columns.
     ///
@@ -1908,7 +1938,7 @@ impl<D: TextDecorator> RenderedText<D> {
 }
 
 fn parse_with_context(mut input: impl io::Read,
-                      context: HtmlContext,
+                      context: &mut HtmlContext,
                       ) -> Result<RenderTree> {
     let opts = ParseOpts {
         tree_builder: TreeBuilderOpts {
@@ -1928,7 +1958,7 @@ fn parse_with_context(mut input: impl io::Read,
 
 /// Reads and parses HTML from `input` and prepares a render tree.
 pub fn parse(input: impl io::Read) -> Result<RenderTree> {
-    parse_with_context(input, Default::default())
+    parse_with_context(input, &mut Default::default())
 }
 
 /// Reads HTML from `input`, decorates it using `decorator`, and
diff --git a/src/render/text_renderer.rs b/src/render/text_renderer.rs
index 76c1c2f..92b9d34 100644
--- a/src/render/text_renderer.rs
+++ b/src/render/text_renderer.rs
@@ -805,6 +805,7 @@ impl<T: PartialEq + Eq + Clone + Debug + Default> RenderLine<T> {
 #[derive(Clone)]
 pub struct SubRenderer<D: TextDecorator> {
     width: usize,
+    wrap_width: Option<usize>,
     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.
@@ -836,10 +837,11 @@ impl<D: TextDecorator> SubRenderer<D> {
     }
 
     /// Construct a new empty SubRenderer.
-    pub fn new(width: usize, decorator: D) -> SubRenderer<D> {
+    pub fn new(width: usize, wrap_width: Option<usize>, decorator: D) -> SubRenderer<D> {
         html_trace!("new({})", width);
         SubRenderer {
             width,
+            wrap_width,
             lines: LinkedList::new(),
             at_block_end: false,
             wrapping: None,
@@ -852,7 +854,11 @@ impl<D: TextDecorator> SubRenderer<D> {
 
     fn ensure_wrapping_exists(&mut self) {
         if self.wrapping.is_none() {
-            self.wrapping = Some(WrappedBlock::new(self.width));
+            let wwidth = match self.wrap_width {
+                Some(ww) => ww.min(self.width),
+                None => self.width
+            };
+            self.wrapping = Some(WrappedBlock::new(wwidth));
         }
     }
 
@@ -1007,7 +1013,7 @@ impl<D: TextDecorator> Renderer for SubRenderer<D> {
         if width < 1 {
             return Err(Error::TooNarrow);
         }
-        Ok(SubRenderer::new(width, self.decorator.make_subblock_decorator()))
+        Ok(SubRenderer::new(width, self.wrap_width, self.decorator.make_subblock_decorator()))
     }
 
     fn start_block(&mut self) -> crate::Result<()> {


Test patch:
diff --git a/src/tests.rs b/src/tests.rs
index 914b4cd..47c9d08 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -16,6 +16,12 @@ fn test_html(input: &[u8], expected: &str, width: usize) {
     let output = from_read(input, width);
     assert_eq_str!(output, expected);
 }
+fn test_html_maxwrap(input: &[u8], expected: &str, width: usize, wrap_width: usize) {
+    let result = config::plain()
+        .max_wrap_width(wrap_width)
+        .string_from_read(input, width).unwrap();
+    assert_eq_str!(result, expected);
+}
 #[cfg(feature = "css")]
 fn test_html_css(input: &[u8], expected: &str, width: usize) {
     let result = config::plain()
@@ -617,6 +623,56 @@ wrapped.
     );
 }
 
+#[test]
+fn test_wrap_max() {
+    test_html_maxwrap(br#"
+        <p>This is a bit of text to wrap<p>
+        <ul>
+          <li>This is a bit of text to wrap too</li>
+          </li>
+        </ul>"#,
+        r#"This is a
+bit of
+text to
+wrap
+
+
+* This is a
+  bit of
+  text to
+  wrap too
+"#, 20, 10)
+}
+
+#[test]
+fn test_wrap_max2() {
+    test_html_maxwrap(br#"
+        <p>plain para at the full screen width</p>
+        <ul>
+          <li>bullet point uses same width so its margin is 2 chars further right
+
+          <ul><li>nested bullets in turn move 2 chars right each time
+             <ul><li>result: you never get text squashed too narrow</li></ul>
+          </li></ul>
+        </li></ul>"#,
+        r#"plain para at the
+full screen width
+
+* bullet point uses
+  same width so its
+  margin is 2 chars
+  further right
+  
+  * nested bullets in
+    turn move 2 chars
+    right each time
+    
+    * result: you never
+      get text squashed
+      too narrow
+"#, 80, 17);
+}
+
 #[test]
 fn test_div() {
     test_html(