From 98cd10dc49ccbfa40d6596e37bfc77c74b15038b Mon Sep 17 00:00:00 2001 From: Cathal O Cuimin Date: Thu, 5 Nov 2026 12:14:37 +0000 Subject: [PATCH] Preserve void elements before the truncation point in truncatehtml truncatehtml removed every element whose text was blank, to clean up the empty shells left after truncated text is cleared. Void elements such as
, and
never contain text, so they were removed wherever they appeared, even before the truncation point or when no text needed truncating at all. Line breaks and images disappeared from otherwise untouched content. Void elements are now removed only once the truncation point has been reached, and a blank element that still contains a kept void element (e.g.

) is kept. Other blank elements are removed as before. Co-Authored-By: Claude Opus 5.5 --- .../lib/filter/TruncateHtmlFilter.java | 31 ++++++-- .../lib/filter/TruncateHtmlFilterTest.java | 79 +++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java index defef913e..2ce2afa4e 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilter.java @@ -8,6 +8,8 @@ import com.hubspot.jinjava.interpret.TemplateError; import com.hubspot.jinjava.lib.fn.Functions; import com.hubspot.jinjava.objects.SafeString; +import java.util.ArrayDeque; +import java.util.Deque; import java.util.Map; import java.util.Objects; import org.apache.commons.lang3.BooleanUtils; @@ -52,6 +54,7 @@ public class TruncateHtmlFilter implements AdvancedFilter { private static final int DEFAULT_TRUNCATE_LENGTH = 255; private static final String DEFAULT_END = "..."; + private static final String DELETE_MARKER_CLASS = "__deleteme"; private static final String LENGTH_KEY = "length"; private static final String END_KEY = "end"; private static final String BREAKWORD_KEY = "breakword"; @@ -148,7 +151,7 @@ public Object filter(Object var, JinjavaInterpreter interpreter, String... args) killwords ); dom.select("body").traverse(visitor); - dom.select(".__deleteme").remove(); + dom.select("." + DELETE_MARKER_CLASS).remove(); return dom.select("body").html(); } @@ -162,6 +165,8 @@ private static class ContentTruncatingNodeVisitor implements NodeVisitor { private int textLen; private final String ending; private final boolean killwords; + private final Deque keptVoidElementsAtEntry = new ArrayDeque<>(); + private int keptVoidElements; ContentTruncatingNodeVisitor(int maxTextLen, String ending, boolean killwords) { this.maxTextLen = maxTextLen; @@ -188,17 +193,33 @@ public void head(Node node, int depth) { } else { textLen += textContent.length(); } + } else if (node instanceof Element el) { + keptVoidElementsAtEntry.push(keptVoidElements); + if (isVoidElement(el)) { + if (textLen >= maxTextLen) { + el.addClass(DELETE_MARKER_CLASS); + } else { + keptVoidElements++; + } + } } } @Override public void tail(Node node, int depth) { - if (node instanceof Element) { - Element el = (Element) node; - if (StringUtils.isBlank(el.text())) { - el.addClass("__deleteme"); + if (node instanceof Element el) { + boolean containsKeptVoidElement = + keptVoidElements > keptVoidElementsAtEntry.pop(); + if ( + !isVoidElement(el) && !containsKeptVoidElement && StringUtils.isBlank(el.text()) + ) { + el.addClass(DELETE_MARKER_CLASS); } } } + + private static boolean isVoidElement(Element el) { + return el.tag().isEmpty(); + } } } diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java index 963bc2e85..55bf267de 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/TruncateHtmlFilterTest.java @@ -78,6 +78,85 @@ public void itTakesKwargs() { ); } + @Test + public void itPreservesVoidElementsWhenContentIsUnderTheLimit() { + String result = (String) filter.filter( + "

Hey,
Great to hear!

", + interpreter, + "255" + ); + assertThat(result) + .isEqualTo("

Hey,\n
\n Great to hear!

\n

"); + } + + @Test + public void itPreservesVoidElementsBeforeTheTruncationPoint() { + String result = (String) filter.filter( + "

Hello
world, this is long text

", + interpreter, + "10", + "...", + "true" + ); + assertThat(result).isEqualTo("

Hello\n
\n world...

"); + } + + @Test + public void itRemovesVoidElementsAfterTheTruncationPoint() { + String result = (String) filter.filter( + "

Hello world

more
text

", + interpreter, + "5", + "...", + "true" + ); + assertThat(result).isEqualTo("

Hello...

"); + } + + @Test + public void itPreservesSelfClosingBrTagsBeforeTheTruncationPoint() { + String result = (String) filter.filter( + "

Hey,
Great to hear!

", + interpreter, + "255" + ); + assertThat(result).isEqualTo("

Hey,\n
\n Great to hear!

"); + } + + @Test + public void itRemovesSelfClosingBrTagsAfterTheTruncationPoint() { + String result = (String) filter.filter( + "

Hello world
more text


", + interpreter, + "5", + "...", + "true" + ); + assertThat(result).isEqualTo("

Hello...

"); + } + + @Test + public void itPreservesVoidElementsWithExplicitClosingTagsBeforeTheTruncationPoint() { + String result = (String) filter.filter( + "

Great to hear!

", + interpreter, + "255" + ); + assertThat(result).isEqualTo("

Great to hear!

"); + } + + @Test + public void itRemovesVoidElementsWithExplicitClosingTagsAfterTheTruncationPoint() { + String result = (String) filter.filter( + "

Hello world

", + interpreter, + "5", + "...", + "true" + ); + assertThat(result).isEqualTo("

Hello...

"); + } + private static String fixture(String name) { try { return Resources.toString(Resources.getResource(name), StandardCharsets.UTF_8);