Skip to content

Commit

Permalink
Added |truncate filter (#647)
Browse files Browse the repository at this point in the history
  • Loading branch information
yacir authored Nov 19, 2024
1 parent bd5e2aa commit c21dde9
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
95 changes: 94 additions & 1 deletion minijinja-contrib/src/filters/mod.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand Down Expand Up @@ -121,3 +122,95 @@ pub fn filesizeformat(value: f64, binary: Option<bool>) -> 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<String, Error> {
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::<Option<usize>>("length")?.unwrap_or(255);
let killwords = kwargs.get::<Option<bool>>("killwords")?.unwrap_or_default();
let end = kwargs.get::<Option<&str>>("end")?.unwrap_or("...");
let leeway = kwargs.get::<Option<usize>>("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::<String>()
} else {
let chars: Vec<char> = 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)
}
1 change: 1 addition & 0 deletions minijinja-contrib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
74 changes: 74 additions & 0 deletions minijinja-contrib/tests/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <string>:1)"
);
}

0 comments on commit c21dde9

Please sign in to comment.