package com.hubspot.jinjava.lib.filter; import static com.hubspot.jinjava.util.Logging.ENGINE_LOG; import java.util.Objects; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; import org.jsoup.select.NodeVisitor; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; import com.hubspot.jinjava.doc.annotations.JinjavaSnippet; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.lib.fn.Functions; @JinjavaDoc(value = "Truncates a given string, respecting html markup (i.e. will properly close all nested tags)", params = { @JinjavaParam(value = "html", desc = "HTML to truncate"), @JinjavaParam(value = "length", type = "number", defaultValue = "255", desc = "Length at which to truncate text (HTML characters not included)"), @JinjavaParam(value = "end", defaultValue = "...", desc = "The characters that will be added to indicate where the text was truncated"), @JinjavaParam(value = "breakword", type = "boolean", defaultValue = "false", desc = "If set to true, text will be truncated in the middle of words") }, snippets = { @JinjavaSnippet(code = "{{ \"<p>I want to truncate this text without breaking my HTML<p>\"|truncatehtml(28, '..', false) }}", output = "<p>I want to truncate this text without breaking my HTML</p>") }) public class TruncateHtmlFilter implements Filter { private static final int DEFAULT_TRUNCATE_LENGTH = 255; private static final String DEFAULT_END = "..."; @Override public String getName() { return "truncatehtml"; } @Override public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { if (var instanceof String) { int length = DEFAULT_TRUNCATE_LENGTH; String ends = DEFAULT_END; if (args.length > 0) { try { length = Integer.parseInt(Objects.toString(args[0])); } catch (Exception e) { ENGINE_LOG.warn("truncatehtml(): error setting length for {}, using default {}", args[0], DEFAULT_TRUNCATE_LENGTH); } } if (args.length > 1) { ends = Objects.toString(args[1]); } boolean killwords = false; if (args.length > 2) { killwords = BooleanUtils.toBoolean(args[2]); } Document dom = Jsoup.parseBodyFragment((String) var); ContentTruncatingNodeVisitor visitor = new ContentTruncatingNodeVisitor(length, ends, killwords); dom.select("body").traverse(visitor); dom.select(".__deleteme").remove(); return dom.select("body").html(); } return var; } private static class ContentTruncatingNodeVisitor implements NodeVisitor { private int maxTextLen; private int textLen; private String ending; private boolean killwords; public ContentTruncatingNodeVisitor(int maxTextLen, String ending, boolean killwords) { this.maxTextLen = maxTextLen; this.ending = ending; this.killwords = killwords; } @Override public void head(Node node, int depth) { if (node instanceof TextNode) { TextNode text = (TextNode) node; String textContent = text.text(); if (textLen >= maxTextLen) { text.text(""); } else if (textLen + textContent.length() > maxTextLen) { int ptr = maxTextLen - textLen; if (!killwords) { ptr = Functions.movePointerToJustBeforeLastWord(ptr, textContent) - 1; } text.text(textContent.substring(0, ptr) + ending); textLen = maxTextLen; } else { textLen += textContent.length(); } } } @Override public void tail(Node node, int depth) { if (node instanceof Element) { Element el = (Element) node; if (StringUtils.isBlank(el.text())) { el.addClass("__deleteme"); } } } } }