Repository: mitsuhiko/minijinja
Pull request #651 (2024-11-30 19:56:39)

add `wordwrap` filter
following Jinja2 implementation as described here
https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.wordwrap


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd2d7b4..ebab82f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ All notable changes to MiniJinja are documented here.
 - Added `sum` filter.  #648
 - Added `truncate` filter to `minijinja-contrib`.  #647
 - Added `wordcount` filter to `minijinja-contrib`.  #649
+- Added `wordwrap` filter to `minijinja-contrib`.  #651
 
 ## 2.5.0
 
diff --git a/Cargo.lock b/Cargo.lock
index 8a974b4..b779986 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1973,6 +1973,7 @@ dependencies = [
  "rand",
  "serde",
  "similar-asserts",
+ "textwrap",
  "time",
  "time-tz",
 ]
@@ -2928,6 +2929,12 @@ version = "1.13.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
 
+[[package]]
+name = "smawk"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
+
 [[package]]
 name = "socket2"
 version = "0.5.7"
@@ -3073,6 +3080,17 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "textwrap"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
+dependencies = [
+ "smawk",
+ "unicode-linebreak",
+ "unicode-width",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.68"
@@ -3336,6 +3354,12 @@ version = "1.0.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
 
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
 [[package]]
 name = "unicode-segmentation"
 version = "1.12.0"
diff --git a/minijinja-cli/Cargo.toml b/minijinja-cli/Cargo.toml
index e6b3f91..d6521e4 100644
--- a/minijinja-cli/Cargo.toml
+++ b/minijinja-cli/Cargo.toml
@@ -46,7 +46,7 @@ minijinja = { version = "=2.5.0", path = "../minijinja", features = [
     "custom_syntax",
     "loop_controls"
 ] }
-minijinja-contrib = { version = "=2.5.0", optional = true, path = "../minijinja-contrib", features = ["pycompat", "datetime", "timezone", "rand"] }
+minijinja-contrib = { version = "=2.5.0", optional = true, path = "../minijinja-contrib", features = ["pycompat", "datetime", "timezone", "rand", "unicode_wordwrap"] }
 rustyline = { version = "14.0.0", optional = true }
 serde = { version = "1.0.183", features = ["derive", "rc"] }
 serde_json = "1.0.105"
diff --git a/minijinja-contrib/Cargo.toml b/minijinja-contrib/Cargo.toml
index 5973daa..556614f 100644
--- a/minijinja-contrib/Cargo.toml
+++ b/minijinja-contrib/Cargo.toml
@@ -21,11 +21,14 @@ pycompat = ["minijinja/builtins"]
 datetime = ["time"]
 timezone = ["time-tz"]
 rand = ["dep:rand"]
+wordwrap = ["textwrap"]
+unicode_wordwrap = ["wordwrap", "textwrap/unicode-linebreak", "textwrap/unicode-width"]
 
 [dependencies]
 minijinja = { version = "2.5.0", path = "../minijinja", default-features = false }
 rand = { version = "0.8.5", optional = true, default-features = false, features = ["std", "std_rng", "small_rng"] }
 serde = "1.0.164"
+textwrap = { version = "0.16.1", optional = true, default-features = false, features = ["smawk"] }
 time = { version = "0.3.35", optional = true, features = ["serde", "formatting", "parsing"] }
 time-tz = { version = "1.0.3", features = ["db"], optional = true }
 
diff --git a/minijinja-contrib/src/filters/mod.rs b/minijinja-contrib/src/filters/mod.rs
index 6b96685..9cdf001 100644
--- a/minijinja-contrib/src/filters/mod.rs
+++ b/minijinja-contrib/src/filters/mod.rs
@@ -238,3 +238,64 @@ pub fn wordcount(value: Value) -> Result<Value, Error> {
 
     Ok(Value::from(count))
 }
+
+/// Wrap a string to the given width.
+///
+/// By default this filter is not unicode aware (feature = `wordwrap`) but when the unicode
+/// feature is enabled (`unicode_wordwrap`) then it becomes so.  It's implemented on top of
+/// the `textwrap` crate.
+///
+/// **Keyword arguments:**
+///
+/// - `width`: Maximum length of wrapped lines (default: 79)
+/// - `break_long_words`: If a word is longer than width, break it across lines (default: true)
+/// - `break_on_hyphens`: If a word contains hyphens, it may be split across lines (default: true)
+/// - `wrapstring`: String to join each wrapped line (default: newline)
+#[cfg(feature = "wordwrap")]
+#[cfg_attr(docsrs, doc(any(cfg(feature = "wordwrap"), cfg = "unicode_wordwrap")))]
+pub fn wordwrap(value: Value, kwargs: Kwargs) -> Result<Value, Error> {
+    use textwrap::{wrap, Options as WrapOptions, WordSplitter};
+    let s = value.as_str().unwrap_or_default();
+
+    let width = kwargs.get::<Option<usize>>("width")?.unwrap_or(79);
+    let break_long_words = kwargs
+        .get::<Option<bool>>("break_long_words")?
+        .unwrap_or(true);
+    let break_on_hyphens = kwargs
+        .get::<Option<bool>>("break_on_hyphens")?
+        .unwrap_or(true);
+    let wrapstring = kwargs.get::<Option<&str>>("wrapstring")?.unwrap_or("\n");
+    kwargs.assert_all_used()?;
+
+    let mut options = WrapOptions::new(width).break_words(break_long_words);
+
+    if break_on_hyphens {
+        options = options.word_splitter(WordSplitter::HyphenSplitter);
+    }
+
+    // Handle empty/whitespace-only input
+    if s.trim().is_empty() {
+        return Ok(Value::from(""));
+    }
+
+    // Process paragraphs sequentially into final string
+    Ok(Value::from(s.lines().enumerate().fold(
+        String::new(),
+        |mut acc, (i, p)| {
+            if i > 0 {
+                acc.push_str(wrapstring);
+            }
+            if !p.trim().is_empty() {
+                // Wrap the paragraph and join with wrapstring
+                let wrapped = wrap(p, &options);
+                for (j, line) in wrapped.iter().enumerate() {
+                    if j > 0 {
+                        acc.push_str(wrapstring);
+                    }
+                    acc.push_str(line);
+                }
+            }
+            acc
+        },
+    )))
+}
diff --git a/minijinja-contrib/src/lib.rs b/minijinja-contrib/src/lib.rs
index c5f4063..e56ddbc 100644
--- a/minijinja-contrib/src/lib.rs
+++ b/minijinja-contrib/src/lib.rs
@@ -36,6 +36,10 @@ pub fn add_to_environment(env: &mut Environment) {
     env.add_filter("filesizeformat", filters::filesizeformat);
     env.add_filter("truncate", filters::truncate);
     env.add_filter("wordcount", filters::wordcount);
+    #[cfg(feature = "wordwrap")]
+    {
+        env.add_filter("wordwrap", filters::wordwrap);
+    }
     #[cfg(feature = "datetime")]
     {
         env.add_filter("datetimeformat", filters::datetimeformat);


Test patch:
diff --git a/minijinja-contrib/tests/filters.rs b/minijinja-contrib/tests/filters.rs
index 4184472..3649b8d 100644
--- a/minijinja-contrib/tests/filters.rs
+++ b/minijinja-contrib/tests/filters.rs
@@ -267,3 +267,84 @@ fn test_wordcount() {
         "3"
     );
 }
+
+#[test]
+#[cfg(feature = "wordwrap")]
+fn test_wordwrap() {
+    use minijinja_contrib::filters::wordwrap;
+
+    let mut env = minijinja::Environment::new();
+    env.add_filter("wordwrap", wordwrap);
+
+    // Test basic wrapping
+    assert_eq!(
+        env.render_str(
+            "{{ text|wordwrap(width=20) }}",
+            context! {
+                text => "This is a long piece of text that should be wrapped at a specific width."
+            }
+        )
+        .unwrap(),
+        "This is a long piece\nof text that should\nbe wrapped at a\nspecific width."
+    );
+
+    // Test custom wrap string
+    assert_eq!(
+        env.render_str(
+            "{{ text|wordwrap(width=10, wrapstring=' <br> ') }}",
+            context! {
+                text => "This is a test of custom wrap strings."
+            }
+        )
+        .unwrap(),
+        "This is <br> a test <br> of custom <br> wrap <br> strings."
+    );
+
+    // Test preserving newlines
+    assert_eq!(
+        env.render_str(
+            "{{ text|wordwrap(width=20) }}",
+            context! {
+                text => "First paragraph.\n\nSecond paragraph."
+            }
+        )
+        .unwrap(),
+        "First paragraph.\n\nSecond paragraph."
+    );
+
+    // Test breaking long words
+    assert_eq!(
+        env.render_str(
+            "{{ text|wordwrap(width=10, break_long_words=true) }}",
+            context! {
+                text => "ThisIsAVeryLongWordThatShouldBeBroken"
+            }
+        )
+        .unwrap(),
+        "ThisIsAVer\nyLongWordT\nhatShouldB\neBroken"
+    );
+
+    // Test not breaking long words
+    assert_eq!(
+        env.render_str(
+            "{{ text|wordwrap(width=10, break_long_words=false) }}",
+            context! {
+                text => "ThisIsAVeryLongWordThatShouldBeBroken"
+            }
+        )
+        .unwrap(),
+        "ThisIsAVeryLongWordThatShouldBeBroken"
+    );
+
+    // Test breaking on hyphens
+    assert_eq!(
+        env.render_str(
+            "{{ text|wordwrap(width=10, break_on_hyphens=true) }}",
+            context! {
+                text => "This-is-a-hyphenated-word"
+            }
+        )
+        .unwrap(),
+        "This-is-a-\nhyphenated\n-word"
+    );
+}