From c21dde92a929ba5bf21a9380ec59f208cb96069c Mon Sep 17 00:00:00 2001 From: Yassir Barchi Date: Tue, 19 Nov 2024 18:11:14 +0100 Subject: [PATCH] Added |truncate filter (#647) --- CHANGELOG.md | 1 + minijinja-contrib/src/filters/mod.rs | 95 +++++++++++++++++++++++++++- minijinja-contrib/src/lib.rs | 1 + minijinja-contrib/tests/filters.rs | 74 ++++++++++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d660784..05aa2b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to MiniJinja are documented here. ## 2.6.0 - Added `sum` filter. #648 +- Added `truncate` filter to `minijinja-contrib`. #647 ## 2.5.0 diff --git a/minijinja-contrib/src/filters/mod.rs b/minijinja-contrib/src/filters/mod.rs index 560b7784..a303ff28 100644 --- a/minijinja-contrib/src/filters/mod.rs +++ b/minijinja-contrib/src/filters/mod.rs @@ -1,6 +1,7 @@ use std::convert::TryFrom; -use minijinja::value::Value; +use minijinja::value::{Kwargs, Value, ValueKind}; +use minijinja::State; use minijinja::{Error, ErrorKind}; #[cfg(feature = "datetime")] @@ -121,3 +122,95 @@ pub fn filesizeformat(value: f64, binary: Option) -> String { unreachable!(); } } + +/// Returns a truncated copy of the string. +/// +/// The string will be truncated to the specified length, with an ellipsis +/// appended if truncation occurs. By default, the filter tries to preserve +/// whole words. +/// +/// ```jinja +/// {{ "Hello World"|truncate(length=5) }} +/// ``` +/// +/// The filter accepts a few keyword arguments: +/// * `length`: maximum length of the output string (defaults to 255) +/// * `killwords`: set to `true` if you want to cut text exactly at length; if `false`, +/// the filter will preserve last word (defaults to `false`) +/// * `end`: if you want a specific ellipsis sign you can specify it (defaults to "...") +/// * `leeway`: determines the tolerance margin before truncation occurs (defaults to 5) +/// +/// The truncation only occurs if the string length exceeds both the specified +/// length and the leeway margin combined. This means that if a string is just +/// slightly longer than the target length (within the leeway value), it will +/// be left unmodified. +/// +/// When `killwords` is set to false (default behavior), the function ensures +/// that words remain intact by finding the last complete word that fits within +/// the length limit. This prevents words from being cut in the middle and +/// maintains text readability. +/// +/// The specified length parameter is inclusive of the end string (ellipsis). +/// For example, with a length of 5 and the default ellipsis "...", only 2 +/// characters from the original string will be preserved. +/// +/// # Example with all attributes +/// ```jinja +/// {{ "Hello World"|truncate( +/// length=5, +/// killwords=true, +/// end='...', +/// leeway=2 +/// ) }} +/// ``` +pub fn truncate(state: &State, value: Value, kwargs: Kwargs) -> Result { + if matches!(value.kind(), ValueKind::None | ValueKind::Undefined) { + return Ok("".into()); + } + + let s = value.as_str().ok_or_else(|| { + Error::new( + ErrorKind::InvalidOperation, + format!("expected string, got {}", value.kind()), + ) + })?; + + let length = kwargs.get::>("length")?.unwrap_or(255); + let killwords = kwargs.get::>("killwords")?.unwrap_or_default(); + let end = kwargs.get::>("end")?.unwrap_or("..."); + let leeway = kwargs.get::>("leeway")?.unwrap_or_else(|| { + state + .lookup("TRUNCATE_LEEWAY") + .and_then(|x| usize::try_from(x.clone()).ok()) + .unwrap_or(5) + }); + + kwargs.assert_all_used()?; + + let end_len = end.chars().count(); + if length < end_len { + return Err(Error::new( + ErrorKind::InvalidOperation, + format!("expected length >= {}, got {}", end_len, length), + )); + } + + if s.chars().count() <= length + leeway { + return Ok(s.to_string()); + } + + let trunc_pos = length - end_len; + let truncated = if killwords { + s.chars().take(trunc_pos).collect::() + } else { + let chars: Vec = s.chars().take(trunc_pos).collect(); + match chars.iter().rposition(|&c| c == ' ') { + Some(last_space) => chars[..last_space].iter().collect(), + None => chars.iter().collect(), + } + }; + let mut result = String::with_capacity(truncated.len() + end.len()); + result.push_str(&truncated); + result.push_str(end); + Ok(result) +} diff --git a/minijinja-contrib/src/lib.rs b/minijinja-contrib/src/lib.rs index c8b6d677..2f9c6b9c 100644 --- a/minijinja-contrib/src/lib.rs +++ b/minijinja-contrib/src/lib.rs @@ -34,6 +34,7 @@ pub mod globals; pub fn add_to_environment(env: &mut Environment) { env.add_filter("pluralize", filters::pluralize); env.add_filter("filesizeformat", filters::filesizeformat); + env.add_filter("truncate", filters::truncate); #[cfg(feature = "datetime")] { env.add_filter("datetimeformat", filters::datetimeformat); diff --git a/minijinja-contrib/tests/filters.rs b/minijinja-contrib/tests/filters.rs index 1019f15c..0e2df55f 100644 --- a/minijinja-contrib/tests/filters.rs +++ b/minijinja-contrib/tests/filters.rs @@ -128,3 +128,77 @@ fn test_filesizeformat() { insta::assert_snapshot!(render!(in env, r"{{ (1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)|filesizeformat }}"), @"1.2 YB"); insta::assert_snapshot!(render!(in env, r"{{ (1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024)|filesizeformat }}"), @"1267650.6 YB"); } + +#[test] +fn test_truncate() { + use minijinja::render; + use minijinja_contrib::filters::truncate; + + const LONG_TEXT: &str = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."; + const SHORT_TEXT: &str = "Fifteen chars !"; + const SPECIAL_TEXT: &str = "Hello 👋 World"; + + let mut env = Environment::new(); + env.add_filter("truncate", truncate); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate }}", text=>SHORT_TEXT), + @"Fifteen chars !" + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate }}", text=>LONG_TEXT), + @"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It..." + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=10) }}", text=>LONG_TEXT), + @"Lorem..." + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=10, killwords=true) }}", text=>LONG_TEXT), + @"Lorem I..." + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=10, end='***') }}", text=>LONG_TEXT), + @"Lorem***" + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=10, killwords=true, end='') }}", text=>LONG_TEXT), + @"Lorem Ipsu" + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=10, leeway=5) }}", text=>SHORT_TEXT), + @"Fifteen chars !" + ); + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=10, leeway=0) }}", text=>SHORT_TEXT), + @"Fifteen..." + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=7, leeway=0, end='') }}", text=>SPECIAL_TEXT), + @"Hello" + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=7, leeway=0, end='', killwords=true) }}", text=>SPECIAL_TEXT), + @"Hello 👋" + ); + + insta::assert_snapshot!( + render!(in env, r"{{ text|truncate(length=8, leeway=0, end='') }}", text=>SPECIAL_TEXT), + @"Hello 👋" + ); + + assert_eq!( + env.render_str(r"{{ 'hello'|truncate(length=1) }}", context! {}) + .unwrap_err() + .to_string(), + "invalid operation: expected length >= 3, got 1 (in :1)" + ); +}