You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2019/01/16 19:22:47 UTC

[freemarker] branch 2.3-gae updated: Added ?truncate built-ins and related setting

This is an automated email from the ASF dual-hosted git repository.

ddekany pushed a commit to branch 2.3-gae
in repository https://gitbox.apache.org/repos/asf/freemarker.git


The following commit(s) were added to refs/heads/2.3-gae by this push:
     new 7c5ef10  Added ?truncate built-ins and related setting
7c5ef10 is described below

commit 7c5ef10ef3da3b94fc5cdf9d61c966282b6cd8ac
Author: ddekany <dd...@apache.org>
AuthorDate: Wed Jan 16 20:22:36 2019 +0100

    Added ?truncate built-ins and related setting
---
 src/main/java/freemarker/core/BuiltIn.java         |   8 +-
 .../freemarker/core/BuiltInsForStringsBasic.java   | 135 ++++
 src/main/java/freemarker/core/Configurable.java    |  97 ++-
 .../core/DefaultTruncateBuiltinAlgorithm.java      | 769 +++++++++++++++++++++
 .../freemarker/core/TemplateConfiguration.java     |   7 +
 .../freemarker/core/TruncateBuiltinAlgorithm.java  | 143 ++++
 .../core/_ObjectBuilderSettingEvaluator.java       |   4 +-
 src/manual/en_US/book.xml                          | 240 ++++++-
 .../core/DefaultTruncateBuiltinAlgorithmTest.java  | 669 ++++++++++++++++++
 .../freemarker/core/TemplateConfigurationTest.java |   1 +
 .../java/freemarker/core/TruncateBuiltInTest.java  | 153 ++++
 .../freemarker/template/ConfigurationTest.java     |  63 +-
 12 files changed, 2277 insertions(+), 12 deletions(-)

diff --git a/src/main/java/freemarker/core/BuiltIn.java b/src/main/java/freemarker/core/BuiltIn.java
index 19af35e..fb2cb9a 100644
--- a/src/main/java/freemarker/core/BuiltIn.java
+++ b/src/main/java/freemarker/core/BuiltIn.java
@@ -84,7 +84,7 @@ abstract class BuiltIn extends Expression implements Cloneable {
 
     static final Set<String> CAMEL_CASE_NAMES = new TreeSet<String>();
     static final Set<String> SNAKE_CASE_NAMES = new TreeSet<String>();
-    static final int NUMBER_OF_BIS = 268;
+    static final int NUMBER_OF_BIS = 279;
     static final HashMap<String, BuiltIn> BUILT_INS_BY_NAME = new HashMap(NUMBER_OF_BIS * 3 / 2 + 1, 1f);
 
     static {
@@ -280,6 +280,12 @@ abstract class BuiltIn extends Expression implements Cloneable {
         putBI("time", new BuiltInsForMultipleTypes.dateBI(TemplateDateModel.TIME));
         putBI("time_if_unknown", "timeIfUnknown", new BuiltInsForDates.dateType_if_unknownBI(TemplateDateModel.TIME));
         putBI("trim", new BuiltInsForStringsBasic.trimBI());
+        putBI("truncate", new BuiltInsForStringsBasic.truncateBI());
+        putBI("truncate_w", "truncateW", new BuiltInsForStringsBasic.truncate_wBI());
+        putBI("truncate_c", "truncateC", new BuiltInsForStringsBasic.truncate_cBI());
+        putBI("truncate_m", "truncateM", new BuiltInsForStringsBasic.truncate_mBI());
+        putBI("truncate_w_m", "truncateWM", new BuiltInsForStringsBasic.truncate_w_mBI());
+        putBI("truncate_c_m", "truncateCM", new BuiltInsForStringsBasic.truncate_c_mBI());
         putBI("uncap_first", "uncapFirst", new BuiltInsForStringsBasic.uncap_firstBI());
         putBI("upper_abc", "upperAbc", new BuiltInsForNumbers.upper_abcBI());
         putBI("upper_case", "upperCase", new BuiltInsForStringsBasic.upper_caseBI());
diff --git a/src/main/java/freemarker/core/BuiltInsForStringsBasic.java b/src/main/java/freemarker/core/BuiltInsForStringsBasic.java
index 27a6212..7583984 100644
--- a/src/main/java/freemarker/core/BuiltInsForStringsBasic.java
+++ b/src/main/java/freemarker/core/BuiltInsForStringsBasic.java
@@ -642,6 +642,141 @@ class BuiltInsForStringsBasic {
         }
     }
 
+    static abstract class AbstractTruncateBI extends BuiltInForString {
+        @Override
+        TemplateModel calculateResult(final String s, final Environment env) {
+            return new TemplateMethodModelEx() {
+                public Object exec(java.util.List args) throws TemplateModelException {
+                    int argCount = args.size();
+                    checkMethodArgCount(argCount, 1, 3);
+
+                    int maxLength = getNumberMethodArg(args, 0).intValue();
+                    if (maxLength < 0) {
+                        throw new _TemplateModelException("?", key, "(...) argument #1 can't be negative.");
+                    }
+
+                    TemplateModel terminator;
+                    Integer terminatorLength;
+                    if (argCount > 1) {
+                        terminator = (TemplateModel) args.get(1);
+                        if (!(terminator instanceof  TemplateScalarModel)) {
+                            if (allowMarkupTerminator()) {
+                                if (!(terminator instanceof TemplateMarkupOutputModel)) {
+                                    throw _MessageUtil.newMethodArgMustBeStringOrMarkupOutputException(
+                                            "?" + key, 1, terminator);
+                                }
+                            } else {
+                                throw _MessageUtil.newMethodArgMustBeStringException(
+                                        "?" + key, 1, terminator);
+                            }
+                        }
+
+                        Number terminatorLengthNum = getOptNumberMethodArg(args, 2);
+                        terminatorLength = terminatorLengthNum != null ? terminatorLengthNum.intValue() : null;
+                        if (terminatorLength != null && terminatorLength < 0) {
+                            throw new _TemplateModelException("?", key, "(...) argument #3 can't be negative.");
+                        }
+                    } else {
+                        terminator = null;
+                        terminatorLength = null;
+                    }
+                    try {
+                        TruncateBuiltinAlgorithm algorithm = env.getTruncateBuiltinAlgorithm();
+                        return truncate(algorithm, s, maxLength, terminator, terminatorLength, env);
+                    } catch (TemplateException e) {
+                        throw new _TemplateModelException(
+                                AbstractTruncateBI.this, e, env, "Truncation failed; see cause exception");
+                    }
+                }
+            };
+        }
+
+        protected abstract TemplateModel truncate(
+                TruncateBuiltinAlgorithm algorithm, String s, int maxLength,
+                TemplateModel terminator, Integer terminatorLength, Environment env)
+                throws TemplateException;
+
+        protected abstract boolean allowMarkupTerminator();
+    }
+
+    static class truncateBI extends AbstractTruncateBI {
+        protected TemplateModel truncate(
+                TruncateBuiltinAlgorithm algorithm, String s, int maxLength,
+                TemplateModel terminator, Integer terminatorLength, Environment env)
+                throws TemplateException {
+            return algorithm.truncate(s, maxLength, (TemplateScalarModel) terminator, terminatorLength, env);
+        }
+
+        protected boolean allowMarkupTerminator() {
+            return false;
+        }
+    }
+
+    static class truncate_wBI extends AbstractTruncateBI {
+        protected TemplateModel truncate(
+                TruncateBuiltinAlgorithm algorithm, String s, int maxLength,
+                TemplateModel terminator, Integer terminatorLength, Environment env)
+                throws TemplateException {
+            return algorithm.truncateW(s, maxLength, (TemplateScalarModel) terminator, terminatorLength, env);
+        }
+
+        protected boolean allowMarkupTerminator() {
+            return false;
+        }
+    }
+
+    static class truncate_cBI extends AbstractTruncateBI {
+        protected TemplateModel truncate(
+                TruncateBuiltinAlgorithm algorithm, String s, int maxLength,
+                TemplateModel terminator, Integer terminatorLength, Environment env)
+                throws TemplateException {
+            return algorithm.truncateC(s, maxLength, (TemplateScalarModel) terminator, terminatorLength, env);
+        }
+
+        protected boolean allowMarkupTerminator() {
+            return false;
+        }
+    }
+
+    static class truncate_mBI extends AbstractTruncateBI {
+        protected TemplateModel truncate(
+                TruncateBuiltinAlgorithm algorithm, String s, int maxLength,
+                TemplateModel terminator, Integer terminatorLength, Environment env)
+                throws TemplateException {
+            return algorithm.truncateM(s, maxLength, terminator, terminatorLength, env);
+        }
+
+        protected boolean allowMarkupTerminator() {
+            return true;
+        }
+    }
+
+    static class truncate_w_mBI extends AbstractTruncateBI {
+        protected TemplateModel truncate(
+                TruncateBuiltinAlgorithm algorithm, String s, int maxLength,
+                TemplateModel terminator, Integer terminatorLength, Environment env)
+                throws TemplateException {
+            return algorithm.truncateWM(s, maxLength, terminator, terminatorLength, env);
+        }
+
+        protected boolean allowMarkupTerminator() {
+            return true;
+        }
+    }
+
+    static class truncate_c_mBI extends AbstractTruncateBI {
+        protected TemplateModel truncate(
+                TruncateBuiltinAlgorithm algorithm, String s, int maxLength,
+                TemplateModel terminator, Integer terminatorLength, Environment env)
+                throws TemplateException {
+            return algorithm.truncateCM(s, maxLength, terminator, terminatorLength, env);
+        }
+
+        protected boolean allowMarkupTerminator() {
+            return true;
+        }
+    }
+
     static class uncap_firstBI extends BuiltInForString {
         @Override
         TemplateModel calculateResult(String s, Environment env) {
diff --git a/src/main/java/freemarker/core/Configurable.java b/src/main/java/freemarker/core/Configurable.java
index 28803f1..70f2dfa 100644
--- a/src/main/java/freemarker/core/Configurable.java
+++ b/src/main/java/freemarker/core/Configurable.java
@@ -240,7 +240,14 @@ public class Configurable {
     public static final String API_BUILTIN_ENABLED_KEY_CAMEL_CASE = "apiBuiltinEnabled";
     /** Alias to the {@code ..._SNAKE_CASE} variation due to backward compatibility constraints. @since 2.3.22 */
     public static final String API_BUILTIN_ENABLED_KEY = API_BUILTIN_ENABLED_KEY_SNAKE_CASE;
-    
+
+    /** Legacy, snake case ({@code like_this}) variation of the setting name. @since 2.3.29 */
+    public static final String TRUNCATE_BUILTIN_ALGORITHM_KEY_SNAKE_CASE = "truncate_builtin_algorithm";
+    /** Modern, camel case ({@code likeThis}) variation of the setting name. @since 2.3.29 */
+    public static final String TRUNCATE_BUILTIN_ALGORITHM_KEY_CAMEL_CASE = "truncateBuiltinAlgorithm";
+    /** Alias to the {@code ..._SNAKE_CASE} variation due to backward compatibility constraints. */
+    public static final String TRUNCATE_BUILTIN_ALGORITHM_KEY = TRUNCATE_BUILTIN_ALGORITHM_KEY_SNAKE_CASE;
+
     /** Legacy, snake case ({@code like_this}) variation of the setting name. @since 2.3.23 */
     public static final String LOG_TEMPLATE_EXCEPTIONS_KEY_SNAKE_CASE = "log_template_exceptions";
     /** Modern, camel case ({@code likeThis}) variation of the setting name. @since 2.3.23 */
@@ -282,7 +289,7 @@ public class Configurable {
     public static final String AUTO_INCLUDE_KEY_CAMEL_CASE = "autoInclude";
     /** Alias to the {@code ..._SNAKE_CASE} variation due to backward compatibility constraints. */
     public static final String AUTO_INCLUDE_KEY = AUTO_INCLUDE_KEY_SNAKE_CASE;
-    
+
     /** @deprecated Use {@link #STRICT_BEAN_MODELS_KEY} instead. */
     @Deprecated
     public static final String STRICT_BEAN_MODELS = STRICT_BEAN_MODELS_KEY;
@@ -315,6 +322,7 @@ public class Configurable {
         TEMPLATE_EXCEPTION_HANDLER_KEY_SNAKE_CASE,
         TIME_FORMAT_KEY_SNAKE_CASE,
         TIME_ZONE_KEY_SNAKE_CASE,
+        TRUNCATE_BUILTIN_ALGORITHM_KEY_SNAKE_CASE,
         URL_ESCAPING_CHARSET_KEY_SNAKE_CASE,
         WRAP_UNCHECKED_EXCEPTIONS_KEY_SNAKE_CASE
     };
@@ -347,6 +355,7 @@ public class Configurable {
         TEMPLATE_EXCEPTION_HANDLER_KEY_CAMEL_CASE,
         TIME_FORMAT_KEY_CAMEL_CASE,
         TIME_ZONE_KEY_CAMEL_CASE,
+        TRUNCATE_BUILTIN_ALGORITHM_KEY_CAMEL_CASE,
         URL_ESCAPING_CHARSET_KEY_CAMEL_CASE,
         WRAP_UNCHECKED_EXCEPTIONS_KEY_CAMEL_CASE
     };
@@ -376,9 +385,10 @@ public class Configurable {
     private String urlEscapingCharset;
     private boolean urlEscapingCharsetSet;
     private Boolean autoFlush;
-    private TemplateClassResolver newBuiltinClassResolver;
     private Boolean showErrorTips;
+    private TemplateClassResolver newBuiltinClassResolver;
     private Boolean apiBuiltinEnabled;
+    private TruncateBuiltinAlgorithm truncateBuiltinAlgorithm;
     private Boolean logTemplateExceptions;
     private Boolean wrapUncheckedExceptions;
     private Map<String, ? extends TemplateDateFormatFactory> customDateFormats;
@@ -452,7 +462,9 @@ public class Configurable {
         
         newBuiltinClassResolver = TemplateClassResolver.UNRESTRICTED_RESOLVER;
         properties.setProperty(NEW_BUILTIN_CLASS_RESOLVER_KEY, newBuiltinClassResolver.getClass().getName());
-        
+
+        truncateBuiltinAlgorithm = DefaultTruncateBuiltinAlgorithm.ASCII_INSTANCE;
+
         showErrorTips = Boolean.TRUE;
         properties.setProperty(SHOW_ERROR_TIPS_KEY, showErrorTips.toString());
         
@@ -1476,7 +1488,10 @@ public class Configurable {
         }
         outputEncodingSet = true;
     }
-    
+
+    /**
+     * Getter pair of {@link #setOutputEncoding(String)}.
+     */
     public String getOutputEncoding() {
         return outputEncodingSet
                 ? outputEncoding
@@ -1666,7 +1681,41 @@ public class Configurable {
     public boolean isAPIBuiltinEnabledSet() {
         return apiBuiltinEnabled != null;
     }
-    
+
+    /**
+     * Specifies the algorithm used for {@code ?truncate}. Defaults to
+     * {@link DefaultTruncateBuiltinAlgorithm#ASCII_INSTANCE}. Most customization needs can be addressed by
+     * creating a new {@link DefaultTruncateBuiltinAlgorithm} with the proper constructor parameters. Otherwise users
+     * my use their own {@link TruncateBuiltinAlgorithm} implementation.
+     *
+     * <p>In case you need to set this with {@link Properties}, or a similar configuration approach that doesn't let you
+     * create the value in Java, see examples at {@link #setSetting(String, String)}.
+     *
+     * @since 2.3.29
+     */
+    public void setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm truncateBuiltinAlgorithm) {
+        NullArgumentException.check("truncateBuiltinAlgorithm", truncateBuiltinAlgorithm);
+        this.truncateBuiltinAlgorithm = truncateBuiltinAlgorithm;
+    }
+
+    /**
+     * See {@link #setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm)}
+     *
+     * @since 2.3.29
+     */
+    public TruncateBuiltinAlgorithm getTruncateBuiltinAlgorithm() {
+        return truncateBuiltinAlgorithm != null ? truncateBuiltinAlgorithm : parent.getTruncateBuiltinAlgorithm();
+    }
+
+    /**
+     * Tells if this setting is set directly in this object or its value is coming from the {@link #getParent() parent}.
+     *
+     * @since 2.3.29
+     */
+    public boolean isTruncateBuiltinAlgorithmSet() {
+        return truncateBuiltinAlgorithm != null;
+    }
+
     /**
      * Specifies if {@link TemplateException}-s thrown by template processing are logged by FreeMarker or not. The
      * default is {@code true} for backward compatibility, but that results in logging the exception twice in properly
@@ -2287,7 +2336,28 @@ public class Configurable {
      *       See {@link #setAPIBuiltinEnabled(boolean)}.
      *       Since 2.3.22.
      *       <br>String value: {@code "true"}, {@code "false"}, {@code "y"},  etc.
-     *       
+     *
+     *   <li><p>{@code "truncate_builtin_algorithm"}:
+     *       See {@link #setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm)}.
+     *       Since 2.3.19.
+     *       <br>String value: An
+     *       <a href="#fm_obe">object builder expression</a>, or one of the predefined values (case insensitive),
+     *       {@code ascii} (for {@link DefaultTruncateBuiltinAlgorithm#ASCII_INSTANCE}) and
+     *       {@code unicode} (for {@link DefaultTruncateBuiltinAlgorithm#UNICODE_INSTANCE}).
+     *       <br>Example object builder expressions:
+     *       <br>Use {@code "..."} as terminator (and same as markup terminator), and add space if the
+     *       truncation happened on word boundary:
+     *       <br>{@code DefaultTruncateBuiltinAlgorithm("...", true)}
+     *       <br>Use {@code "..."} as terminator, and also a custom HTML for markup terminator, and add space if the
+     *       truncation happened on word boundary:
+     *       <br>{@code DefaultTruncateBuiltinAlgorithm("...",
+     *       markup(HTMLOutputFormat(), "<span class=trunc>...</span>"), true)}
+     *       <br>Recreate default truncate algorithm, but with not preferring truncation at word boundaries (i.e.,
+     *       with {@code wordBoundaryMinLength} 1.0):
+     *       <br><code>freemarker.core.DefaultTruncateBuiltinAlgorithm(<br>
+     *       DefaultTruncateBuiltinAlgorithm.STANDARD_ASCII_TERMINATOR, null, null,<br>
+     *       DefaultTruncateBuiltinAlgorithm.STANDARD_M_TERMINATOR, null, null,<br>
+     *       true, 1.0)</code>
      * </ul>
      * 
      * <p>{@link Configuration} (a subclass of {@link Configurable}) also understands these:</p>
@@ -2498,7 +2568,7 @@ public class Configurable {
      *     {@link AndMatcher}, {@link OrMatcher}, {@link NotMatcher}, {@link ConditionalTemplateConfigurationFactory},
      *     {@link MergingTemplateConfigurationFactory}, {@link FirstMatchTemplateConfigurationFactory},
      *     {@link HTMLOutputFormat}, {@link XMLOutputFormat}, {@link RTFOutputFormat}, {@link PlainTextOutputFormat},
-     *     {@link UndefinedOutputFormat}, {@link Configuration}.
+     *     {@link UndefinedOutputFormat}, {@link Configuration}, {@link DefaultTruncateBuiltinAlgorithm}.
      *   </li>
      *   <li>
      *     <p>{@link TimeZone} objects can be created like {@code TimeZone("UTC")}, despite that there's no a such
@@ -2663,6 +2733,17 @@ public class Configurable {
             } else if (API_BUILTIN_ENABLED_KEY_SNAKE_CASE.equals(name)
                     || API_BUILTIN_ENABLED_KEY_CAMEL_CASE.equals(name)) {
                 setAPIBuiltinEnabled(StringUtil.getYesNo(value));
+            } else if (TRUNCATE_BUILTIN_ALGORITHM_KEY_SNAKE_CASE.equals(name)
+                    || TRUNCATE_BUILTIN_ALGORITHM_KEY_CAMEL_CASE.equals(name)) {
+                if ("ascii".equalsIgnoreCase(value)) {
+                    setTruncateBuiltinAlgorithm(DefaultTruncateBuiltinAlgorithm.ASCII_INSTANCE);
+                } else if ("unicode".equalsIgnoreCase(value)) {
+                    setTruncateBuiltinAlgorithm(DefaultTruncateBuiltinAlgorithm.UNICODE_INSTANCE);
+                } else {
+                    setTruncateBuiltinAlgorithm((TruncateBuiltinAlgorithm) _ObjectBuilderSettingEvaluator.eval(
+                            value, TruncateBuiltinAlgorithm.class, false,
+                            _SettingEvaluationEnvironment.getCurrent()));
+                }
             } else if (NEW_BUILTIN_CLASS_RESOLVER_KEY_SNAKE_CASE.equals(name)
                     || NEW_BUILTIN_CLASS_RESOLVER_KEY_CAMEL_CASE.equals(name)) {
                 if ("unrestricted".equals(value)) {
diff --git a/src/main/java/freemarker/core/DefaultTruncateBuiltinAlgorithm.java b/src/main/java/freemarker/core/DefaultTruncateBuiltinAlgorithm.java
new file mode 100644
index 0000000..279e037
--- /dev/null
+++ b/src/main/java/freemarker/core/DefaultTruncateBuiltinAlgorithm.java
@@ -0,0 +1,769 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import freemarker.template.SimpleScalar;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateScalarModel;
+import freemarker.template.utility.ClassUtil;
+import freemarker.template.utility.NullArgumentException;
+
+/**
+ * The default {@link TruncateBuiltinAlgorithm} implementation; see
+ * {@link Configurable#setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm)}.
+ * To know the properties of this {@link TruncateBuiltinAlgorithm} implementation, see the
+ * {@linkplain DefaultTruncateBuiltinAlgorithm#DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean,
+ * TemplateMarkupOutputModel, Integer, Boolean, boolean, Double) constructor}. You can find more explanation and
+ * examples in the documentation of the {@code truncate} built-in in the FreeMarker Manual.
+ *
+ * @since 2.3.29
+ */
+public class DefaultTruncateBuiltinAlgorithm extends TruncateBuiltinAlgorithm {
+
+    /** Used by {@link #ASCII_INSTANCE} as the terminator. */
+    public static final String STANDARD_ASCII_TERMINATOR = "[...]";
+
+    /** Used by {@link #UNICODE_INSTANCE} as the terminator. */
+    public static final String STANDARD_UNICODE_TERMINATOR = "[\u2026]";
+
+    /**
+     * Used by {@link #ASCII_INSTANCE} and {@link #UNICODE_INSTANCE} as the markup terminator;
+     * HTML {@code <span class='truncateTerminator'>[&#8230;]</span>}, where {@code &#8230;} is the ellipsis (&#8230;)
+     * character. Note that while the ellipsis character is not in US-ASCII, this still works safely regardless of
+     * output charset, as {@code &#8230;} itself only contains US-ASCII characters.
+     */
+    public static final TemplateHTMLOutputModel STANDARD_M_TERMINATOR;
+    static {
+        try {
+            STANDARD_M_TERMINATOR = HTMLOutputFormat.INSTANCE.fromMarkup(
+                    "<span class='truncateTerminator'>[&#8230;]</span>");
+        } catch (TemplateModelException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * The value used in the constructor of {@link #ASCII_INSTANCE} and  {@link #UNICODE_INSTANCE} as the
+     * {@code wordBoundaryMinLength} argument.
+     */
+    public static final double DEFAULT_WORD_BOUNDARY_MIN_LENGTH = 0.75;
+
+    /** Used if {@link #getMTerminatorLength(TemplateMarkupOutputModel)} can't detect the length. */
+    private static final int FALLBACK_M_TERMINATOR_LENGTH = 3;
+
+    private enum TruncationMode {
+        CHAR_BOUNDARY, WORD_BOUNDARY, AUTO
+    }
+
+    /**
+     * Instance that uses {@code "[...]"} as the {@code defaultTerminator} constructor argument, and thus is
+     * safe to use for all output charsets. Because of that, this is the default of
+     * {@link Configurable#setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm)}. The
+     * {@code defaultMTerminator} (markup terminator) is {@link #STANDARD_M_TERMINATOR}, and the
+     * {@code wordBoundaryMinLength} is {@link #DEFAULT_WORD_BOUNDARY_MIN_LENGTH}, and {@code addSpaceAtWordBoundary}
+     * is {@code true}.
+     */
+    public static final DefaultTruncateBuiltinAlgorithm ASCII_INSTANCE = new DefaultTruncateBuiltinAlgorithm(
+            STANDARD_ASCII_TERMINATOR, STANDARD_M_TERMINATOR, true);
+
+    /**
+     * Instance uses that {@code "[…]"} as the {@code defaultTerminator} constructor argument, which contains
+     * ellipsis character ({@code "…"}, U+2026), and thus only works with UTF-8, and the cp125x charsets (like
+     * cp1250), and with some other rarely used ones. It does not work (becomes to a question mark) with ISO-8859-x
+     * charsets (like ISO-8859-1), which are probably the most often used charsets after UTF-8.
+     *
+     * <p>The {@code defaultMTerminator} (markup terminator) is {@link #STANDARD_M_TERMINATOR}, and the
+     * {@code wordBoundaryMinLength} is {@link #DEFAULT_WORD_BOUNDARY_MIN_LENGTH}, and {@code addSpaceAtWordBoundary}
+     * is {@code true}.
+     */
+    public static final DefaultTruncateBuiltinAlgorithm UNICODE_INSTANCE = new DefaultTruncateBuiltinAlgorithm(
+            STANDARD_UNICODE_TERMINATOR, STANDARD_M_TERMINATOR, true);
+
+    private final TemplateScalarModel defaultTerminator;
+    private final int defaultTerminatorLength;
+    private final boolean defaultTerminatorRemovesDots;
+
+    private final TemplateMarkupOutputModel<?> defaultMTerminator;
+    private final Integer defaultMTerminatorLength;
+    private final boolean defaultMTerminatorRemovesDots;
+
+    private final double wordBoundaryMinLength;
+    private final boolean addSpaceAtWordBoundary;
+
+    /**
+     * Creates an instance with a string (plain text) terminator and a markup terminator.
+     * See parameters in {@link #DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean, TemplateMarkupOutputModel,
+     * Integer, Boolean, boolean, Double)}; the missing parameters will be {@code null}.
+     */
+    public DefaultTruncateBuiltinAlgorithm(
+            String defaultTerminator,
+            TemplateMarkupOutputModel<?> defaultMTerminator,
+            boolean addSpaceAtWordBoundary) {
+        this(
+                defaultTerminator, null, null,
+                defaultMTerminator, null, null,
+                addSpaceAtWordBoundary, null);
+    }
+
+    /**
+     * Creates an instance with string (plain text) terminator.
+     * See parameters in {@link #DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean, TemplateMarkupOutputModel,
+     * Integer, Boolean, boolean, Double)}; the missing parameters will be {@code null}.
+     */
+    public DefaultTruncateBuiltinAlgorithm(
+            String defaultTerminator,
+            boolean addSpaceAtWordBoundary) {
+        this(
+                defaultTerminator, null, null,
+                null, null, null,
+                addSpaceAtWordBoundary, null);
+    }
+
+    /**
+     * Creates an instance with markup terminator.
+     * @param defaultTerminator
+     *            The terminator to use if the invocation (like {@code s?truncate(20)}) doesn't specify it. The
+     *            terminator is the text appended after a truncated string, to indicate that it was truncated.
+     *            Typically it's {@code "[...]"} or {@code "..."}, or the same with UNICODE ellipsis character.
+     * @param defaultTerminatorLength
+     *            The assumed length of {@code defaultTerminator}, or {@code null} if it should be get via
+     *            {@code defaultTerminator.length()}.
+     * @param defaultTerminatorRemovesDots
+     *            Whether dots and ellipsis characters that the {@code defaultTerminator} touches should be removed. If
+     *            {@code null}, this will be auto-detected based on if {@code defaultTerminator} starts with dot or
+     *            ellipsis. The goal is to avoid outcomes where we have more dots next to each other than there are in
+     *            the terminator.
+     * @param defaultMTerminator
+     *            Similar to {@code defaultTerminator}, but is markup instead of plain text. This can be {@code null},
+     *            in which case {@code defaultTerminator} will be used even if {@code ?truncate_m} or similar built-in
+     *            is called.
+     * @param defaultMTerminatorLength
+     *            The assumed length of the terminator, or {@code null} if it should be get via
+     *            {@link #getMTerminatorLength}.
+     * @param defaultMTerminatorRemovesDots
+     *            Similar to {@code defaultTerminatorRemovesDots}, but for {@code defaultMTerminator}. If {@code
+     *            null}, and {@code defaultMTerminator} is HTML/XML/XHTML, then it will be examined of the
+     *            first character of the terminator that's outside a HTML/XML tag or comment is dot or ellipsis
+     *            (after resolving numerical character references). For other kind of markup it defaults to {@code
+     *            true}, to be on the safe side.
+     * @param addSpaceAtWordBoundary,
+     *            Whether to add a space before the terminator if the truncation happens directly after the end of a
+     *            word. For example, when "too long sentence" is truncated, it will be a like "too long [...]"
+     *            instead of "too long[...]". When the truncation happens inside a word, this has on effect, i.e., it
+     *            will be always like "too long se[...]" (no space before the terminator). Note that only whitespace is
+     *            considered to be a word separator, not punctuation, so if this is {@code true}, you get results
+     *            like "Some sentence. [...]".
+     * @param wordBoundaryMinLength
+     *            Used when {@link #truncate} or {@link #truncateM} has to decide between
+     *            word boundary truncation and character boundary truncation; it's the minimum length, given as
+     *            proportion of {@code maxLength}, that word boundary truncation has to produce. If the resulting
+     *            length is less, we do character boundary truncation instead. For example, if {@code maxLength} is
+     *            30, and this parameter is 0.85, then: 30*0.85 = 25.5, rounded up that's 26, so the resulting length
+     *            must be at least 26. The result of character boundary truncation will be always accepted, even if its
+     *            still too short. If this parameter is {@code null}, then {@link #DEFAULT_WORD_BOUNDARY_MIN_LENGTH}
+     *            will be used. If this parameter is 0, then truncation always happens at word boundary. If this
+     *            parameter is 1.0, then truncation doesn't prefer word boundaries over other places.
+     */
+    public DefaultTruncateBuiltinAlgorithm(
+            String defaultTerminator, Integer defaultTerminatorLength,
+            Boolean defaultTerminatorRemovesDots,
+            TemplateMarkupOutputModel<?> defaultMTerminator, Integer defaultMTerminatorLength,
+            Boolean defaultMTerminatorRemovesDots,
+            boolean addSpaceAtWordBoundary, Double wordBoundaryMinLength) {
+        NullArgumentException.check("defaultTerminator", defaultTerminator);
+        this.defaultTerminator = new SimpleScalar(defaultTerminator);
+        try {
+            this.defaultTerminatorLength = defaultTerminatorLength != null ? defaultTerminatorLength
+                    : defaultTerminator.length();
+
+            this.defaultTerminatorRemovesDots = defaultTerminatorRemovesDots != null ? defaultTerminatorRemovesDots
+                        : getTerminatorRemovesDots(defaultTerminator);
+        } catch (TemplateModelException e) {
+            throw new IllegalArgumentException("Failed to examine defaultTerminator", e);
+        }
+
+        this.defaultMTerminator = defaultMTerminator;
+        if (defaultMTerminator != null) {
+            try {
+                this.defaultMTerminatorLength = defaultMTerminatorLength != null ? defaultMTerminatorLength
+                        : getMTerminatorLength(defaultMTerminator);
+
+                this.defaultMTerminatorRemovesDots = defaultMTerminatorRemovesDots != null
+                        ? defaultMTerminatorRemovesDots
+                        : getMTerminatorRemovesDots(defaultMTerminator);
+            } catch (TemplateModelException e) {
+                throw new IllegalArgumentException("Failed to examine defaultMTerminator", e);
+            }
+        } else {
+            // There's no mTerminator, but these final fields must be set
+            this.defaultMTerminatorLength = null;
+            this.defaultMTerminatorRemovesDots = false;
+        }
+
+        if (wordBoundaryMinLength == null) {
+            wordBoundaryMinLength = DEFAULT_WORD_BOUNDARY_MIN_LENGTH;
+        } else if (wordBoundaryMinLength < 0 || wordBoundaryMinLength > 1) {
+            throw new IllegalArgumentException("wordBoundaryMinLength must be between 0.0 and 1.0 (inclusive)");
+        }
+        this.wordBoundaryMinLength = wordBoundaryMinLength;
+
+        this.addSpaceAtWordBoundary = addSpaceAtWordBoundary;
+    }
+
+    @Override
+    public TemplateScalarModel truncate(
+             String s, int maxLength,
+            TemplateScalarModel terminator, Integer terminatorLength,
+             Environment env) throws TemplateException {
+        return (TemplateScalarModel) unifiedTruncate(
+                s, maxLength, terminator, terminatorLength,
+                TruncationMode.AUTO, false);
+    }
+
+    @Override
+    public TemplateScalarModel truncateW(
+            String s, int maxLength,
+            TemplateScalarModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException {
+        return (TemplateScalarModel) unifiedTruncate(
+                s, maxLength, terminator, terminatorLength,
+                TruncationMode.WORD_BOUNDARY, false);
+    }
+
+    @Override
+    public TemplateScalarModel truncateC(
+            String s, int maxLength,
+            TemplateScalarModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException {
+        return (TemplateScalarModel) unifiedTruncate(
+                s, maxLength, terminator, terminatorLength,
+                TruncationMode.CHAR_BOUNDARY, false);
+    }
+
+    @Override
+    public TemplateModel truncateM(
+            String s, int maxLength,
+            TemplateModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException {
+        return unifiedTruncate(
+                s, maxLength, terminator, terminatorLength,
+                TruncationMode.AUTO, true);
+    }
+
+    @Override
+    public TemplateModel truncateWM(
+            String s, int maxLength,
+            TemplateModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException {
+        return unifiedTruncate(
+                s, maxLength, terminator, terminatorLength,
+                TruncationMode.WORD_BOUNDARY, true);
+    }
+
+    @Override
+    public TemplateModel truncateCM(
+            String s, int maxLength,
+            TemplateModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException {
+        return unifiedTruncate(
+                s, maxLength, terminator, terminatorLength,
+                TruncationMode.CHAR_BOUNDARY, true);
+    }
+
+    public String getDefaultTerminator() {
+        try {
+            return defaultTerminator.getAsString();
+        } catch (TemplateModelException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * See similarly named parameter of {@link #DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean,
+     * TemplateMarkupOutputModel, Integer, Boolean, boolean, Double)} the construction}.
+     */
+    public int getDefaultTerminatorLength() {
+        return defaultTerminatorLength;
+    }
+
+    /**
+     * See similarly named parameter of {@link #DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean,
+     * TemplateMarkupOutputModel, Integer, Boolean, boolean, Double)} the construction}.
+     */
+    public boolean getDefaultTerminatorRemovesDots() {
+        return defaultTerminatorRemovesDots;
+    }
+
+    /**
+     * See similarly named parameter of {@link #DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean,
+     * TemplateMarkupOutputModel, Integer, Boolean, boolean, Double)} the construction}.
+     */
+    public TemplateMarkupOutputModel<?> getDefaultMTerminator() {
+        return defaultMTerminator;
+    }
+
+    /**
+     * See similarly named parameter of {@link #DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean,
+     * TemplateMarkupOutputModel, Integer, Boolean, boolean, Double)} the construction}.
+     */
+    public Integer getDefaultMTerminatorLength() {
+        return defaultMTerminatorLength;
+    }
+
+    public boolean getDefaultMTerminatorRemovesDots() {
+        return defaultMTerminatorRemovesDots;
+    }
+
+    /**
+     * See similarly named parameter of {@link #DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean,
+     * TemplateMarkupOutputModel, Integer, Boolean, boolean, Double)} the construction}.
+     */
+    public double getWordBoundaryMinLength() {
+        return wordBoundaryMinLength;
+    }
+
+    /**
+     * See similarly named parameter of {@link #DefaultTruncateBuiltinAlgorithm(String, Integer, Boolean,
+     * TemplateMarkupOutputModel, Integer, Boolean, boolean, Double)} the construction}.
+     */
+    public boolean getAddSpaceAtWordBoundary() {
+        return addSpaceAtWordBoundary;
+    }
+
+    /**
+     * Returns the (estimated) length of the argument terminator. It should only count characters that are visible for
+     * the user (like in the web browser).
+     *
+     * <p>In the implementation in {@link DefaultTruncateBuiltinAlgorithm}, if the markup is HTML/XML/XHTML, then this
+     * counts the characters outside tags and comments, and inside CDATA sections (ignoring the CDATA section
+     * delimiters). Furthermore then it counts character and entity references as having length of 1. If the markup
+     * is not HTML/XML/XHTML (or subclasses of those {@link MarkupOutputFormat}-s) then it doesn't know how to
+     * measure it, and simply returns 3.
+     */
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    protected int getMTerminatorLength(TemplateMarkupOutputModel<?> mTerminator) throws TemplateModelException {
+        MarkupOutputFormat format = mTerminator.getOutputFormat();
+        return isHTMLOrXML(format) ?
+                getLengthWithoutTags(format.getMarkupString(mTerminator))
+                : FALLBACK_M_TERMINATOR_LENGTH;
+    }
+
+    /**
+     * Tells if the dots touched by the terminator text should be removed.
+     *
+     * <p>The implementation in {@link DefaultTruncateBuiltinAlgorithm} return {@code true} if the terminator starts
+     * with dot (or ellipsis).
+     *
+     * @param terminator
+     *            A {@link TemplateScalarModel} or {@link TemplateMarkupOutputModel}. Not {@code null}.
+     */
+    protected boolean getTerminatorRemovesDots(String terminator) throws TemplateModelException {
+        return terminator.startsWith(".") || terminator.startsWith("\u2026");
+    }
+
+    /**
+     * Same as {@link #getTerminatorRemovesDots(String)}, but invoked for a markup terminator.
+     *
+     * <p>The implementation in {@link DefaultTruncateBuiltinAlgorithm} will skip HTML/XML tags and comments,
+     * and resolves relevant character references to find out if the first character is dot or ellipsis. But it only
+     * does this for HTML/XMl/XHTML (or subclasses of those {@link MarkupOutputFormat}-s), otherwise it always
+     * returns {@code true} to be on the safe side.
+     */
+    protected boolean getMTerminatorRemovesDots(TemplateMarkupOutputModel terminator) throws TemplateModelException {
+        return isHTMLOrXML(terminator.getOutputFormat())
+                ? doesHtmlOrXmlStartWithDot(terminator.getOutputFormat().getMarkupString(terminator))
+                : true;
+    }
+
+    /**
+     * Deals with both CB and WB truncation, hence it's unified.
+     */
+    private TemplateModel unifiedTruncate(
+            String s, int maxLength,
+            TemplateModel terminator, Integer terminatorLength,
+            TruncationMode mode, boolean allowMarkupResult)
+            throws TemplateException {
+        if (s.length() <= maxLength) {
+            return new SimpleScalar(s);
+        }
+        if (maxLength < 0) {
+            throw new IllegalArgumentException("maxLength can't be negative");
+        }
+
+        Boolean terminatorRemovesDots;
+        if (terminator == null) {
+            if (allowMarkupResult && defaultMTerminator != null) {
+                terminator = defaultMTerminator;
+                terminatorLength = defaultMTerminatorLength;
+                terminatorRemovesDots = defaultMTerminatorRemovesDots;
+            } else {
+                terminator = defaultTerminator;
+                terminatorLength = defaultTerminatorLength;
+                terminatorRemovesDots = defaultTerminatorRemovesDots;
+            }
+        } else {
+            if (terminatorLength != null) {
+                if (terminatorLength < 0) {
+                    throw new IllegalArgumentException("terminatorLength can't be negative");
+                }
+            } else {
+                terminatorLength = getTerminatorLength(terminator);
+            }
+            terminatorRemovesDots = null; // lazily calculated
+        }
+
+        StringBuilder truncatedS = unifiedTruncateWithoutTerminatorAdded(
+                s,
+                maxLength,
+                terminator, terminatorLength, terminatorRemovesDots,
+                mode);
+
+        // The terminator is always shown, even if with that we exceed maxLength. Otherwise the user couldn't
+        // see that the string was truncated.
+        if (truncatedS == null || truncatedS.length() == 0) {
+            return terminator;
+        }
+
+        if (terminator instanceof TemplateScalarModel) {
+            truncatedS.append(((TemplateScalarModel) terminator).getAsString());
+            return new SimpleScalar(truncatedS.toString());
+        } else if (terminator instanceof TemplateMarkupOutputModel) {
+            TemplateMarkupOutputModel markup = (TemplateMarkupOutputModel) terminator;
+            MarkupOutputFormat outputFormat = markup.getOutputFormat();
+            return outputFormat.concat(outputFormat.fromPlainTextByEscaping(truncatedS.toString()), markup);
+        } else {
+            throw new IllegalArgumentException("Unsupported terminator type: "
+                    + ClassUtil.getFTLTypeDescription(terminator));
+        }
+    }
+
+    private StringBuilder unifiedTruncateWithoutTerminatorAdded(
+            String s, int maxLength,
+            TemplateModel terminator, int terminatorLength, Boolean terminatorRemovesDots,
+            TruncationMode mode) throws TemplateModelException {
+        final int cbInitialLastCIdx = maxLength - terminatorLength - 1;
+        int cbLastCIdx = cbInitialLastCIdx;
+
+        // Why we do this here: If both Word Boundary and Character Boundary truncation will be attempted, then this way
+        // we don't have to skip the WS twice.
+        cbLastCIdx = skipTrailingWS(s, cbLastCIdx);
+        if (cbLastCIdx < 0) {
+            return null;
+        }
+
+        if (mode == TruncationMode.AUTO && wordBoundaryMinLength < 1.0 || mode == TruncationMode.WORD_BOUNDARY) {
+            // Do word boundary truncation. Might not be possible due to minLength restriction (see below), in which
+            // case truncedS stays null.
+            StringBuilder truncedS = null;
+            {
+                final int wordTerminatorLength = addSpaceAtWordBoundary ? terminatorLength + 1 : terminatorLength;
+                final int minIdx = mode == TruncationMode.AUTO
+                        ? Math.max(((int) Math.ceil(maxLength * wordBoundaryMinLength)) - wordTerminatorLength - 1, 0)
+                        : 0;
+
+                int wbLastCIdx = Math.min(maxLength - wordTerminatorLength - 1, cbLastCIdx);
+                boolean followingCIsWS
+                        = s.length() > wbLastCIdx + 1 ? Character.isWhitespace(s.charAt(wbLastCIdx + 1)) : true;
+                executeTruncateWB:
+                while (wbLastCIdx >= minIdx) {
+                    char curC = s.charAt(wbLastCIdx);
+                    boolean curCIsWS = Character.isWhitespace(curC);
+                    if (!curCIsWS && followingCIsWS) {
+                        // Note how we avoid getMTerminatorRemovesDots until we absolutely need its result.
+                        if (!addSpaceAtWordBoundary && isDot(curC)) {
+                            if (terminatorRemovesDots == null) {
+                                terminatorRemovesDots = getTerminatorRemovesDots(terminator);
+                            }
+                            if (terminatorRemovesDots) {
+                                while (wbLastCIdx >= minIdx && isDotOrWS(s.charAt(wbLastCIdx))) {
+                                    wbLastCIdx--;
+                                }
+                                if (wbLastCIdx < minIdx) {
+                                    break executeTruncateWB;
+                                }
+                            }
+                        }
+
+                        truncedS = new StringBuilder(wbLastCIdx + 1 + wordTerminatorLength);
+                        truncedS.append(s, 0, wbLastCIdx + 1);
+                        if (addSpaceAtWordBoundary) {
+                            truncedS.append(' ');
+                        }
+                        break executeTruncateWB;
+                    }
+
+                    followingCIsWS = curCIsWS;
+                    wbLastCIdx--;
+                } // executeTruncateWB: while (...)
+            }
+            if (truncedS != null
+                    || mode == TruncationMode.WORD_BOUNDARY
+                    || mode == TruncationMode.AUTO && wordBoundaryMinLength == 0.0) {
+                return truncedS;
+            }
+            // We are in TruncationMode.AUTO. truncateW wasn't possible, so fall back to character boundary truncation.
+        }
+
+        // Do character boundary truncation.
+
+        // If the truncation point is a word boundary, and thus we add a space before the terminator, then we may run
+        // out of the maxLength by 1. In that case we have to truncate one character earlier.
+        if (cbLastCIdx == cbInitialLastCIdx && addSpaceAtWordBoundary  && isWordEnd(s, cbLastCIdx)) {
+            cbLastCIdx--;
+            if (cbLastCIdx < 0) {
+                return null;
+            }
+        }
+
+        // Skip trailing WS, also trailing dots if necessary.
+        boolean skippedDots;
+        do {
+            skippedDots = false;
+
+            cbLastCIdx = skipTrailingWS(s, cbLastCIdx);
+            if (cbLastCIdx < 0) {
+                return null;
+            }
+
+            // Note how we avoid getMTerminatorRemovesDots until we absolutely need its result.
+            if (isDot(s.charAt(cbLastCIdx)) && !(addSpaceAtWordBoundary && isWordEnd(s, cbLastCIdx))) {
+                if (terminatorRemovesDots == null) {
+                    terminatorRemovesDots = getTerminatorRemovesDots(terminator);
+                }
+                if (terminatorRemovesDots) {
+                    cbLastCIdx = skipTrailingDots(s, cbLastCIdx);
+                    if (cbLastCIdx < 0) {
+                        return null;
+                    }
+                    skippedDots = true;
+                }
+            }
+        } while (skippedDots);
+
+        boolean addWordBoundarySpace = addSpaceAtWordBoundary && isWordEnd(s, cbLastCIdx);
+        StringBuilder truncatedS = new StringBuilder(cbLastCIdx + 1 + (addWordBoundarySpace ? 1 : 0) + terminatorLength);
+        truncatedS.append(s, 0, cbLastCIdx + 1);
+        if (addWordBoundarySpace) {
+            truncatedS.append(' ');
+        }
+        return truncatedS;
+    }
+
+    private int getTerminatorLength(TemplateModel terminator) throws TemplateModelException {
+        return terminator instanceof TemplateScalarModel
+                ? ((TemplateScalarModel) terminator).getAsString().length()
+                : getMTerminatorLength((TemplateMarkupOutputModel<?>) terminator);
+    }
+
+    private boolean getTerminatorRemovesDots(TemplateModel terminator) throws TemplateModelException {
+        return terminator instanceof TemplateScalarModel
+                ? getTerminatorRemovesDots(((TemplateScalarModel) terminator).getAsString())
+                : getMTerminatorRemovesDots((TemplateMarkupOutputModel<?>) terminator);
+    }
+
+    private int skipTrailingWS(String s, int lastCIdx) {
+        while (lastCIdx >= 0 && Character.isWhitespace(s.charAt(lastCIdx))) {
+            lastCIdx--;
+        }
+        return lastCIdx;
+    }
+
+    private int skipTrailingDots(String s, int lastCIdx) {
+        while (lastCIdx >= 0 && isDot(s.charAt(lastCIdx))) {
+            lastCIdx--;
+        }
+        return lastCIdx;
+    }
+
+    private boolean isWordEnd(String s, int lastCIdx) {
+        return lastCIdx + 1 >= s.length() || Character.isWhitespace(s.charAt(lastCIdx + 1));
+    }
+
+    private static boolean isDot(char c) {
+        return c == '.' || c == '\u2026';
+    }
+
+    private static boolean isDotOrWS(char c) {
+        return isDot(c) || Character.isWhitespace(c);
+    }
+
+    private boolean isHTMLOrXML(MarkupOutputFormat<?> outputFormat) {
+        return outputFormat instanceof HTMLOutputFormat || outputFormat instanceof  XMLOutputFormat;
+    }
+
+    /**
+     * Returns the length of a string, ignoring HTML/XML tags and comments, also, character and entity references are
+     * count as having length of 1, and CDATA sections are counted in with the length of their content. So for
+     * example, the length of {@code "<span>x&amp;y</span>"} will be 3 (as visually it's {@code x&y}, which is 3
+     * characters).
+     */
+    // Not private for testability
+    static int getLengthWithoutTags(String s) {
+        // Fixes/improvements here should be also done here: doesHtmlOrXmlStartWithDot
+
+        int result = 0;
+        int i = 0;
+        int len = s.length();
+        countChars: while (i < len) {
+            char c = s.charAt(i++);
+            if (c == '<') {
+                if (s.startsWith("!--", i)) {
+                    // <!--...-->
+                    i += 3;
+                    while (i + 2 < len && !(s.charAt(i) == '-' && s.charAt(i + 1) == '-' && s.charAt(i + 2) == '>')) {
+                        i++;
+                    }
+                    i += 3;
+                    if (i >= len) {
+                        break countChars;
+                    }
+                } else if (s.startsWith("![CDATA[", i)) {
+                    // <![CDATA[...]]>
+                    i += 8;
+                    while (i < len
+                            && !(s.charAt(i) == ']'
+                            && i + 2 < len && s.charAt(i + 1) == ']' && s.charAt(i + 2) == '>')) {
+                        result++;
+                        i++;
+                    }
+                    i += 3;
+                    if (i >= len) {
+                        break countChars;
+                    }
+                } else {
+                    // <...>
+                    while (i < len && s.charAt(i) != '>') {
+                        i++;
+                    }
+                    i++;
+                    if (i >= len) {
+                        break countChars;
+                    }
+                }
+            } else if (c == '&') {
+                // &...;
+                while (i < len && s.charAt(i) != ';') {
+                    i++;
+                }
+                i++;
+                result++;
+                if (i >= len) {
+                    break countChars;
+                }
+            } else {
+                result++;
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Check if the specified HTML or XML starts with dot or ellipsis, if we ignore tags and comments.
+     */
+    // Not private for testability
+    static boolean doesHtmlOrXmlStartWithDot(String s) {
+        // Fixes/improvements here should be also done here: getLengthWithoutTags
+
+        int i = 0;
+        int len = s.length();
+        consumeChars: while (i < len) {
+            char c = s.charAt(i++);
+            if (c == '<') {
+                if (s.startsWith("!--", i)) {
+                    // <!--...-->
+                    i += 3;
+                    while (i + 2 < len
+                            && !((c = s.charAt(i)) == '-' && s.charAt(i + 1) == '-' && s.charAt(i + 2) == '>')) {
+                        i++;
+                    }
+                    i += 3;
+                    if (i >= len) {
+                        break consumeChars;
+                    }
+                } else if (s.startsWith("![CDATA[", i)) {
+                    // <![CDATA[...]]>
+                    i += 8;
+                    while (i < len
+                            && !((c = s.charAt(i)) == ']'
+                            && i + 2 < len
+                            && s.charAt(i + 1) == ']' && s.charAt(i + 2) == '>')) {
+                        return isDot(c);
+                    }
+                    i += 3;
+                    if (i >= len) {
+                        break consumeChars;
+                    }
+                } else {
+                    // <...>
+                    while (i < len && s.charAt(i) != '>') {
+                        i++;
+                    }
+                    i++;
+                    if (i >= len) {
+                        break consumeChars;
+                    }
+                }
+            } else if (c == '&') {
+                // &...;
+                int start = i;
+                while (i < len && s.charAt(i) != ';') {
+                    i++;
+                }
+                return isDotCharReference(s.substring(start, i));
+            } else {
+                return isDot(c);
+            }
+        }
+        return false;
+    }
+
+    // Not private for testability
+    static boolean isDotCharReference(String name) {
+        if (name.length() > 2 && name.charAt(0) == '#') {
+            int charCode = getCodeFromNumericalCharReferenceName(name);
+            return charCode == 0x2026 || charCode == 0x2e;
+        }
+        return name.equals("hellip") || name.equals("period");
+    }
+
+    // Not private for testability
+    static int getCodeFromNumericalCharReferenceName(String name) {
+        char c = name.charAt(1);
+        boolean hex = c == 'x' || c == 'X';
+        int code = 0;
+        for (int pos = hex ? 2 : 1; pos < name.length(); pos++) {
+            c = name.charAt(pos);
+            code *= hex ? 16 : 10;
+            if (c >= '0' && c <= '9') {
+                code += c - '0';
+            } else if (hex && c >= 'a' && c <= 'f') {
+                code += c - 'a' + 10;
+            } else if (hex && c >= 'A' && c <= 'F') {
+                code += c - 'A' + 10;
+            } else {
+                return -1;
+            }
+        }
+        return code;
+    }
+
+}
diff --git a/src/main/java/freemarker/core/TemplateConfiguration.java b/src/main/java/freemarker/core/TemplateConfiguration.java
index ad7b1f8..4fb907b 100644
--- a/src/main/java/freemarker/core/TemplateConfiguration.java
+++ b/src/main/java/freemarker/core/TemplateConfiguration.java
@@ -208,6 +208,9 @@ public final class TemplateConfiguration extends Configurable implements ParserC
         if (tc.isNewBuiltinClassResolverSet()) {
             setNewBuiltinClassResolver(tc.getNewBuiltinClassResolver());
         }
+        if (tc.isTruncateBuiltinAlgorithmSet()) {
+            setTruncateBuiltinAlgorithm(tc.getTruncateBuiltinAlgorithm());
+        }
         if (tc.isNumberFormatSet()) {
             setNumberFormat(tc.getNumberFormat());
         }
@@ -344,6 +347,9 @@ public final class TemplateConfiguration extends Configurable implements ParserC
         if (isNewBuiltinClassResolverSet() && !template.isNewBuiltinClassResolverSet()) {
             template.setNewBuiltinClassResolver(getNewBuiltinClassResolver());
         }
+        if (isTruncateBuiltinAlgorithmSet() && !template.isTruncateBuiltinAlgorithmSet()) {
+            template.setTruncateBuiltinAlgorithm(getTruncateBuiltinAlgorithm());
+        }
         if (isNumberFormatSet() && !template.isNumberFormatSet()) {
             template.setNumberFormat(getNumberFormat());
         }
@@ -669,6 +675,7 @@ public final class TemplateConfiguration extends Configurable implements ParserC
                 || isLogTemplateExceptionsSet()
                 || isWrapUncheckedExceptionsSet()
                 || isNewBuiltinClassResolverSet()
+                || isTruncateBuiltinAlgorithmSet()
                 || isNumberFormatSet()
                 || isObjectWrapperSet()
                 || isOutputEncodingSet()
diff --git a/src/main/java/freemarker/core/TruncateBuiltinAlgorithm.java b/src/main/java/freemarker/core/TruncateBuiltinAlgorithm.java
new file mode 100644
index 0000000..b56b2dc
--- /dev/null
+++ b/src/main/java/freemarker/core/TruncateBuiltinAlgorithm.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateScalarModel;
+
+/**
+ * Used for implementing the "truncate" family of built-ins. There are several variations of the "truncate" built-ins,
+ * each has a corresponding method here. See
+ * {@link #truncateM(String, int, TemplateModel, Integer, Environment)}
+ * as the starting point.
+ *
+ * <p>New methods may be added in later versions, whoever they won't be abstract for backward compatibility.
+ *
+ * @see Configurable#setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm)
+ *
+ * @since 2.3.29
+ */
+public abstract class TruncateBuiltinAlgorithm {
+
+    /**
+     * Corresponds to {@code ?truncate_m(...)} in templates. This method decides automatically if it will truncate at
+     * word boundary (see {@link #truncateWM}) or at character boundary (see {@link #truncateCM}). While it depends
+     * on the implementation, the idea is that it should truncate at word boundary, unless that gives a too short
+     * string, in which case it falls back to truncation at character duration.
+     *
+     * <p>The terminator and the return value can be {@link TemplateMarkupOutputModel} (FTL markup output type), not
+     * just {@link String} (FTL string type), hence the "m" in the name.
+     *
+     * @param s
+     *            The input string whose length need to be limited. The caller (the FreeMarker core normally) is
+     *            expected to guarantee that this won't be {@code null}.
+     *
+     * @param maxLength
+     *            The maximum length of the returned string, although the algorithm need not guarantee this strictly.
+     *            For example, if this is less than the length of the {@code terminator} string, then most algorithms
+     *            should still return the {@code terminator} string. Or, some sophisticated algorithm may counts in
+     *            letters differently depending on their visual width. The goal is usually to prevent unusually long
+     *            string values to ruin visual layout, while showing clearly to the user that the end of the string
+     *            was cut off. If the input string is not longer than the maximum length, then it should be returned
+     *            as is. The caller (the FreeMarker core normally) is expected to guarantee that this will be at
+     *            least 0.
+     *
+     * @param terminator
+     *            The string or markup to show at the end of the returned string if the string was actually truncated.
+     *            This can be {@code null}, in which case the default terminator of the algorithm will be used. It
+     *            can be an FTL string (a {@link TemplateScalarModel}) of any length (including 0), or a
+     *            {@link TemplateMarkupOutputModel} (typically HTML markup). If it's {@link TemplateMarkupOutputModel},
+     *            then the result is {@link TemplateMarkupOutputModel} of the same output format as well, otherwise
+     *            it can remain {@link TemplateScalarModel}. Note that the length of the terminator counts into the
+     *            result length that shouldn't be exceed ({@code maxLength}) (or at least the algorithm should make
+     *            an effort to avoid that).
+     *
+     * @param terminatorLength
+     *            The assumed length of the terminator. If this is {@code null} (and typically it is), then the method
+     *            decides the length of the terminator. If this is not {@code null}, then the method must pretend
+     *            that the terminator length is this. This can be used to specify the visual length of a terminator
+     *            explicitly, which can't always be decided well programmatically.
+     *
+     * @param env
+     *            The runtime environment from which this algorithm was called. The caller (the FreeMarker core
+     *            normally) is expected to guarantee that this won't be {@code null}.
+     *
+     * @return The truncated text, which is either a {@link TemplateScalarModel} (FTL string), or a
+     * {@link TemplateMarkupOutputModel}.
+     *
+     * @throws TemplateException
+     *             If anything goes wrong during truncating. It's unlikely that an implementation will need this though.
+     */
+    public abstract TemplateModel truncateM(
+            String s, int maxLength, TemplateModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException;
+
+    /**
+     * Corresponds to {@code ?truncate(...)} in templates.
+     * Similar to {@link #truncateM(String, int, TemplateModel, Integer, Environment)}, but only allows
+     * an FTL string as terminator, and thence the return value is always an FTL string as well (not
+     * {@link TemplateMarkupOutputModel}).
+     */
+    public abstract TemplateScalarModel truncate(
+            String s, int maxLength, TemplateScalarModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException;
+
+    /**
+     * Corresponds to {@code ?truncate_w(...)} in templates.
+     * Same as {@link #truncateWM(String, int, TemplateModel, Integer, Environment)}, but only allows
+     * an FTL string as terminator, and thence the return value is always an FTL string as well (not
+     * {@link TemplateMarkupOutputModel}).
+     */
+    public abstract TemplateScalarModel truncateW(
+            String s, int maxLength, TemplateScalarModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException;
+
+    /**
+     * Corresponds to {@code ?truncate_w_m(...)} in templates.
+     * Similar to {@link #truncateM(String, int, TemplateModel, Integer, Environment)}, but the
+     * truncation should happen at word boundary (hence the "w"). That is, the truncation isn't allowed to truncate a
+     * word. What counts as a word, is up to the implementation, but at least in {@link DefaultTruncateBuiltinAlgorithm}
+     * words are the sections that are separated by whitespace (so punctuation doesn't separate words).
+     */
+    public abstract TemplateModel truncateWM(
+            String s, int maxLength, TemplateModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException;
+
+    /**
+     * Corresponds to {@code ?truncate_c_m(...)} in templates.
+     * Same as {@link #truncateCM(String, int, TemplateModel, Integer, Environment)}, but only allows
+     * an FTL string as terminator, and thence the return value is always an FTL string as well (not markup).
+     */
+    public abstract TemplateScalarModel truncateC(
+            String s, int maxLength, TemplateScalarModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException;
+
+    /**
+     * Corresponds to {@code ?truncate_c_m(...)} in templates.
+     * Similar to {@link #truncateM(String, int, TemplateModel, Integer, Environment)}, but the
+     * truncation should not prefer truncating at word boundaries over the closer approximation of the desired {@code
+     * maxLength}. Hence, we say that it truncates at character boundary (hence the "c").
+     */
+    public abstract TemplateModel truncateCM(
+            String s, int maxLength, TemplateModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException;
+
+}
diff --git a/src/main/java/freemarker/core/_ObjectBuilderSettingEvaluator.java b/src/main/java/freemarker/core/_ObjectBuilderSettingEvaluator.java
index 6475444..7e4e18b 100644
--- a/src/main/java/freemarker/core/_ObjectBuilderSettingEvaluator.java
+++ b/src/main/java/freemarker/core/_ObjectBuilderSettingEvaluator.java
@@ -688,7 +688,9 @@ public class _ObjectBuilderSettingEvaluator {
             addWithSimpleName(SHORTHANDS, RTFOutputFormat.class);
             addWithSimpleName(SHORTHANDS, PlainTextOutputFormat.class);
             addWithSimpleName(SHORTHANDS, UndefinedOutputFormat.class);
-            
+
+            addWithSimpleName(SHORTHANDS, DefaultTruncateBuiltinAlgorithm.class);
+
             addWithSimpleName(SHORTHANDS, Locale.class);
             SHORTHANDS.put("TimeZone", "freemarker.core._TimeZone");
             SHORTHANDS.put("markup", "freemarker.core._Markup");
diff --git a/src/manual/en_US/book.xml b/src/manual/en_US/book.xml
index b7df2a0..acce261 100644
--- a/src/manual/en_US/book.xml
+++ b/src/manual/en_US/book.xml
@@ -13047,6 +13047,11 @@ grant codeBase "file:/path/to/freemarker.jar"
           </listitem>
 
           <listitem>
+            <para><link linkend="ref_builtin_truncate">truncate,
+            truncate_<replaceable>...</replaceable></link></para>
+          </listitem>
+
+          <listitem>
             <para><link
             linkend="ref_builtin_uncap_first">uncap_first</link></para>
           </listitem>
@@ -14635,6 +14640,228 @@ foobar</programlisting>
           <programlisting role="output">(green mouse)</programlisting>
         </section>
 
+        <section xml:id="ref_builtin_truncate">
+          <title>truncate, truncate_...</title>
+
+          <indexterm>
+            <primary>truncate built-in</primary>
+          </indexterm>
+
+          <indexterm>
+            <primary>truncate_c built-in</primary>
+          </indexterm>
+
+          <indexterm>
+            <primary>truncate_wbuilt-in</primary>
+          </indexterm>
+
+          <indexterm>
+            <primary>truncate_m built-in</primary>
+          </indexterm>
+
+          <indexterm>
+            <primary>truncate_c_m built-in</primary>
+          </indexterm>
+
+          <indexterm>
+            <primary>truncate_w_m built-in</primary>
+          </indexterm>
+
+          <para>Cuts off the end of a string if that's necessary to keep it
+          under a the length given as parameter, and appends a terminator
+          string (<literal>[...]</literal> by default) to indicate that the
+          string was truncated. Example (assuming default FreeMarker
+          configuration settings):</para>
+
+          <programlisting role="template">&lt;#assign shortName='This is short'&gt;
+&lt;#assign longName='This is a too long name'&gt;
+&lt;#assign difficultName='This isoneveryverylongword'&gt;
+
+No truncation needed:
+${shortName?truncate(16)} 
+
+Truncated at word boundary:
+${longName?truncate(16)}
+
+Truncated at "character boundary":
+${difficultName?truncate(16)}</programlisting>
+
+          <programlisting role="output">No truncation needed:
+This is short
+
+Truncated at word boundary:
+This is a [...]
+
+Truncated at "character boundary":
+This isonev[...]</programlisting>
+
+          <para>Things to note above:</para>
+
+          <itemizedlist>
+            <listitem>
+              <para>The string is returned as is if its length doesn't exceed
+              the specified length (16 in this case).</para>
+            </listitem>
+
+            <listitem>
+              <para>When the string exceeded that length, its end was cut off
+              in a way so that together with the added terminator string
+              (<literal>[...]</literal> here) its length won't exceed 16. The
+              result length is possibly shorter than 16, for the sake of
+              better look (see later). Actually, the result length can also be
+              longer than the parameter length, when the desired length is
+              shorter than the terminator string alone, in which case the
+              terminator is still returned as is. Also, an algorithms other
+              than the default might choses to return a longer string, as the
+              length parameter is in principle just hint for the desired
+              visual length.</para>
+            </listitem>
+
+            <listitem>
+              <para><literal>truncate</literal> prefers cutting at word
+              boundary, rather than mid-word, however, if doing so would give
+              a result that's shorter than the 75% of the length specified
+              with the argument, it falls back to cut mid-word. In the last
+              line of the above example, <quote>This [...]</quote> would be
+              too short (11 &lt; 16 * 75%), so it was cut mid-word
+              instead.</para>
+            </listitem>
+
+            <listitem>
+              <para>If the cut happened at word boundary, there's a space
+              between the word end and the terminator string, otherwise
+              there's no space between them. Only whitespace is treated as
+              word separator, not punctuation, so this generally gives
+              intuitive results.</para>
+            </listitem>
+          </itemizedlist>
+
+          <simplesect>
+            <title>Adjusting truncation rules</title>
+
+            <para>Truncation rules are highly configurable by setting the
+            <literal>truncate_builtin_algorithm</literal> configuration
+            setting. This can be done by the programmers, not template
+            authors, so for more details and examples please see the JavaDoc
+            of <link
+            xlink:href="api/freemarker/core/Configurable.html#setTruncateBuiltinAlgorithm-freemarker.core.TruncateBuiltinAlgorithm-">Configurable.setTruncateBuiltinAlgorithm</link>.</para>
+
+            <para>Truncation rules can also be influenced right in the
+            template to a smaller extent:</para>
+
+            <itemizedlist>
+              <listitem>
+                <para>Specifying if the truncation should happen at word
+                boundary or not: </para>
+
+                <itemizedlist>
+                  <listitem>
+                    <para><literal>truncate_w</literal> will always truncate
+                    at word boundary. For example,
+                    <literal>difficultName?truncate_w(16)</literal> returns
+                    <quote>This [...]</quote>, rather than <quote>This
+                    isonev[...]</quote> (as saw in earlier example).</para>
+                  </listitem>
+
+                  <listitem>
+                    <para><literal>truncate_c</literal> will truncate at any
+                    character, not just at word ends. For example,
+                    <literal>longName?truncate_c(16)</literal> returns
+                    <quote>This is a t[...]</quote>, rather than <quote>This
+                    is a [...]</quote> (as saw in earlier example). This tends
+                    to give a string length closer to the length specified,
+                    but still not an exact length, as it removes white-space
+                    before the terminator string, and re-adds a space if we
+                    are just after the end of a word, etc.</para>
+                  </listitem>
+                </itemizedlist>
+              </listitem>
+
+              <listitem>
+                <para>Specifying the terminator string (instead of relying on
+                its default): <literal>truncate</literal> and all
+                <literal>truncate_<replaceable>...</replaceable></literal>
+                built-ins have an additional optional parameter for it. After
+                that, a further optional parameter can specify the assumed
+                length of the terminator string (otherwise its real length
+                will be used). If you find yourself specifying the terminator
+                string often, then certainly the defaults should be configured
+                instead (via <literal>truncate_builtin_algorithm
+                configuration</literal> - see earlier). Example:</para>
+
+                <programlisting role="template">${longName?truncate(16, '...')}
+${longName?truncate(16, '...', 1)}</programlisting>
+
+                <programlisting role="output">This is a ...
+This is a too ...</programlisting>
+
+                <para>When the terminator string starts with dot
+                (<literal>.</literal>) or ellipsis (<literal>…</literal>), the
+                default algorithm will remove the dots and ellipses that the
+                terminator touches, to prevent ending up with more than 3 dots
+                at the end:</para>
+
+                <programlisting role="template">${'Foo bar.baaz'?truncate(11, '---')}
+${'Foo bar.baaz'?truncate(11, '...')} (Not "Foo bar....")
+${'Fo bar. baaz'?truncate(11, '...')} (Word separator space prevents touching)</programlisting>
+
+                <programlisting role="output">Foo bar.---
+Foo bar... (Not "Foo bar....")
+Fo bar. ... (Word separator space prevents touching)</programlisting>
+              </listitem>
+            </itemizedlist>
+          </simplesect>
+
+          <simplesect>
+            <title>Using markup as terminator string</title>
+
+            <para>Each truncation built-in has a variation whose name ends
+            with <literal>_m</literal> (for markup). These allow using markup
+            (like HTML) as terminator, which is useful if you want the
+            terminator to be styled differently than the truncated text. By
+            default the markup terminator is <literal>&lt;span
+            class='truncateTerminator'&gt;[&amp;#8230;]&lt;/span&gt;</literal>,
+            (where <literal>&amp;#8230;</literal> prints an ellipsis
+            character), but of course this can be changed with the
+            <literal>truncate_builtin_algorithm</literal> configuration
+            setting (see earlier). Example (see the variables used in earlier
+            example):</para>
+
+            <programlisting role="template">${longName?truncate_m(16)}
+${difficultName?truncate_w_m(16)}
+${longName?truncate_c_m(16)}</programlisting>
+
+            <programlisting role="output">This is a &lt;span class='truncateTerminator'&gt;[&amp;#8230;]&lt;/span&gt;
+This &lt;span class='truncateTerminator'&gt;[&amp;#8230;]&lt;/span&gt;
+This is a to&lt;span class='truncateTerminator'&gt;[&amp;#8230;]&lt;/span&gt;</programlisting>
+
+            <para>Note above that the terminator string was considered to be
+            only 3 characters long (<literal>'['</literal>,
+            <literal>'…'</literal>, <literal>']'</literal>) by the truncation
+            built-ins, because inside the terminator string they only count
+            the characters outside HTML/XML tags and comments, and they can
+            also interpret numeric character references (but not other entity
+            references). (The same applies when they decide if the terminator
+            starts with dot or ellipsis; preceding tags/comments are skipped,
+            etc.)</para>
+
+            <para>If a markup terminator is used (like above), the return
+            value of the
+            <literal>truncate<replaceable>...</replaceable>_m</literal>
+            built-in will be markup as well, which means that <link
+            linkend="dgui_misc_autoescaping">auto-escaping</link> won't escape
+            it. Of course, the content of the truncated string itself will be
+            still auto-escaped:</para>
+
+            <programlisting role="template">&lt;#ftl output_format='HTML'&gt;
+${'This is auto-escaped: &lt;span&gt;'}
+${'This is auto-escaped: &lt;span&gt;, but not the terminator string'?truncate_m(41)}</programlisting>
+
+            <programlisting role="output">This is auto-escaped: &amp;lt;span&amp;gt;
+This is auto-escaped: &amp;lt;span&amp;gt;, but not &lt;span class='truncateTerminator'&gt;[&amp;#8230;]&lt;/span&gt;</programlisting>
+          </simplesect>
+        </section>
+
         <section xml:id="ref_builtin_uncap_first">
           <title>uncap_first</title>
 
@@ -27623,7 +27850,18 @@ TemplateModel x = env.getVariable("x");  // get variable x</programlisting>
 
           <itemizedlist>
             <listitem>
-              <para>[FIXME]</para>
+              <para>Added new built-ins for truncating text.
+              <literal><replaceable>string</replaceable>?truncate(<replaceable>length</replaceable>)</literal>
+              truncates the text to the given length, and by default adds
+              <literal>[...]</literal> at the end if truncation has happened.
+              Truncation happens at word boundaries, unless the result is too
+              short that way, in which case it falls back to truncation mid
+              word. There's also <literal>?truncate_w</literal> to force Word
+              Boundary truncation, and <literal>?truncate_c</literal> (for
+              Character Boundary) that doesn't care about word boundaries. The
+              truncation algorithm is pluggable in the FreeMarker
+              configuration. See <link linkend="ref_builtin_truncate">the
+              reference</link> for more details.</para>
             </listitem>
           </itemizedlist>
         </section>
diff --git a/src/test/java/freemarker/core/DefaultTruncateBuiltinAlgorithmTest.java b/src/test/java/freemarker/core/DefaultTruncateBuiltinAlgorithmTest.java
new file mode 100644
index 0000000..533b3aa
--- /dev/null
+++ b/src/test/java/freemarker/core/DefaultTruncateBuiltinAlgorithmTest.java
@@ -0,0 +1,669 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import static freemarker.core.DefaultTruncateBuiltinAlgorithm.*;
+import static org.hamcrest.CoreMatchers.*;
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+import freemarker.template.Configuration;
+import freemarker.template.SimpleNumber;
+import freemarker.template.SimpleScalar;
+import freemarker.template.Template;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateModel;
+import freemarker.template.TemplateModelException;
+import freemarker.template.TemplateScalarModel;
+
+public class DefaultTruncateBuiltinAlgorithmTest {
+
+    private static final DefaultTruncateBuiltinAlgorithm EMPTY_TERMINATOR_INSTANCE =
+            new DefaultTruncateBuiltinAlgorithm("", false);
+    private static final DefaultTruncateBuiltinAlgorithm DOTS_INSTANCE =
+            new DefaultTruncateBuiltinAlgorithm("...", true);
+    private static final DefaultTruncateBuiltinAlgorithm DOTS_NO_W_SPACE_INSTANCE =
+            new DefaultTruncateBuiltinAlgorithm("...", false);
+    private static final DefaultTruncateBuiltinAlgorithm ASCII_NO_W_SPACE_INSTANCE =
+            new DefaultTruncateBuiltinAlgorithm("[...]", false);
+    private static final DefaultTruncateBuiltinAlgorithm M_TERM_INSTANCE;
+
+    static {
+        try {
+            M_TERM_INSTANCE = new DefaultTruncateBuiltinAlgorithm(
+                    "...", null, true,
+                    HTMLOutputFormat.INSTANCE.fromMarkup("<r>...</r>"), null, true,
+                    true, 0.75);
+        } catch (TemplateModelException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    public void testConstructorIllegalArguments() throws TemplateException {
+        try {
+            new DefaultTruncateBuiltinAlgorithm(
+                    null, null, true,
+                    HTMLOutputFormat.INSTANCE.fromMarkup("<r>...</r>"), null, true,
+                    true, 0.75);
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), containsString("defaultTerminator"));
+        }
+    }
+
+    @Test
+    public void testTruncateIllegalArguments() throws TemplateException {
+        Environment env = createEnvironment();
+
+        ASCII_INSTANCE.truncate("", 0, new SimpleScalar("."), 1, env);
+
+        try {
+            ASCII_INSTANCE.truncate("", -1, new SimpleScalar("."), 1, env);
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), containsString("maxLength"));
+        }
+
+        try {
+            ASCII_INSTANCE.truncateM("sss", 2, new SimpleNumber(1), 1, env);
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), containsString("SimpleNumber"));
+        }
+
+        try {
+            ASCII_INSTANCE.truncate("sss", 2, new SimpleScalar("."), -1, env);
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), containsString("terminatorLength"));
+        }
+    }
+
+    private Environment createEnvironment() {
+        try {
+            return new Template("", "", new Configuration(Configuration.VERSION_2_3_28)).createProcessingEnvironment(null,
+                    null);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    public void testCSimple() {
+        assertC(ASCII_INSTANCE, "12345678", 9, "12345678");
+        assertC(ASCII_INSTANCE, "12345678", 8, "12345678");
+        assertC(ASCII_INSTANCE, "12345678", 7, "12[...]");
+        assertC(ASCII_INSTANCE, "12345678", 6, "1[...]");
+        for (int maxLength = 5; maxLength >= 0; maxLength--) {
+            assertC(ASCII_INSTANCE, "12345678", maxLength, "[...]");
+        }
+
+        assertC(UNICODE_INSTANCE, "12345678", 9, "12345678");
+        assertC(UNICODE_INSTANCE, "12345678", 8, "12345678");
+        assertC(UNICODE_INSTANCE, "12345678", 7, "1234[\u2026]");
+        assertC(UNICODE_INSTANCE, "12345678", 6, "123[\u2026]");
+        assertC(UNICODE_INSTANCE, "12345678", 5, "12[\u2026]");
+        assertC(UNICODE_INSTANCE, "12345678", 4, "1[\u2026]");
+        for (int maxLength = 3; maxLength >= 0; maxLength--) {
+            assertC(UNICODE_INSTANCE, "12345678", maxLength, "[\u2026]");
+        }
+
+        assertC(EMPTY_TERMINATOR_INSTANCE, "12345678", 9, "12345678");
+        for (int length = 8; length >= 0; length--) {
+            assertC(EMPTY_TERMINATOR_INSTANCE, "12345678", length, "12345678".substring(0, length));
+        }
+    }
+
+    @Test
+    public void testCSpaceAndDot() {
+        assertC(ASCII_INSTANCE, "123456  ", 9, "123456  ");
+        assertC(ASCII_INSTANCE, "123456  ", 8, "123456  ");
+        assertC(ASCII_INSTANCE, "123456  ", 7, "12[...]");
+        assertC(ASCII_INSTANCE, "123456  ", 6, "1[...]");
+        assertC(ASCII_INSTANCE, "123456  ", 5, "[...]");
+        assertC(ASCII_INSTANCE, "123456  ", 4, "[...]");
+
+        assertC(ASCII_INSTANCE, "1 345        ", 13, "1 345        ");
+        assertC(ASCII_INSTANCE, "1 345        ", 12, "1 345 [...]"); // Not "1 345  [...]"
+        assertC(ASCII_INSTANCE, "1 345        ", 11, "1 345 [...]");
+        assertC(ASCII_INSTANCE, "1 345        ", 10, "1 34[...]"); // Not "12345[...]"
+        assertC(ASCII_INSTANCE, "1 345        ", 9,  "1 34[...]");
+        assertC(ASCII_INSTANCE, "1 345        ", 8,  "1 3[...]");
+        assertC(ASCII_INSTANCE, "1 345        ", 7,  "1 [...]");
+        assertC(ASCII_INSTANCE, "1 345        ", 6,  "[...]"); // Not "1[...]"
+        assertC(ASCII_INSTANCE, "1 345        ", 5,  "[...]");
+        assertC(ASCII_INSTANCE, "1 345        ", 4,  "[...]");
+
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 13, "1 345        ");
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 12, "1 345[...]"); // Differs!
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 11, "1 345[...]"); // Differs!
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 10, "1 345[...]"); // Differs!
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 9,  "1 34[...]");
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 8,  "1 3[...]");
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 7,  "1[...]"); // Differs!
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 6,  "1[...]"); // Differs!
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 5,  "[...]");
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1 345        ", 4,  "[...]");
+
+        assertC(ASCII_INSTANCE, "1  4567890", 9,  "1  4[...]");
+        assertC(ASCII_INSTANCE, "1  4567890", 8,  "1 [...]");
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1  4567890", 9,  "1  4[...]");
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "1  4567890", 8,  "1[...]");
+
+        assertC(ASCII_INSTANCE, "  3456789", 9,  "  3456789");
+        assertC(ASCII_INSTANCE, "  3456789", 8,  "  3[...]");
+        assertC(ASCII_INSTANCE, "  3456789", 7,  "[...]");
+        assertC(ASCII_INSTANCE, "  3456789", 6,  "[...]");
+
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "  3456789", 8,  "  3[...]");
+        assertC(ASCII_NO_W_SPACE_INSTANCE, "  3456789", 7,  "[...]");
+
+        // Dots aren't treated specially by default:
+        assertC(ASCII_INSTANCE, "1.  56...012345", 15, "1.  56...012345");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 14, "1.  56...[...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 13, "1.  56..[...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 12, "1.  56.[...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 11, "1.  56[...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 10, "1.  5[...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 9,  "1. [...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 8,  "1. [...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 7,  "1[...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 6,  "1[...]");
+        assertC(ASCII_INSTANCE, "1.  56...012345", 5,  "[...]");
+
+        // Dots are treated specially here:
+        assertC(DOTS_INSTANCE, "1.  56...012345", 15, "1.  56...012345");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 14, "1.  56...01...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 13, "1.  56...0...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 12, "1.  56...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 11, "1.  56...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 10, "1.  56...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 9,  "1.  56...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 8,  "1.  5...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 7,  "1. ...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 6,  "1. ...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 5,  "1...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 4,  "1...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 3,  "...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 2,  "...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 1,  "...");
+        assertC(DOTS_INSTANCE, "1.  56...012345", 0,  "...");
+
+        assertC(DOTS_NO_W_SPACE_INSTANCE, "1.  56...012345", 8,  "1.  5...");
+        assertC(DOTS_NO_W_SPACE_INSTANCE, "1.  56...012345", 7,  "1...");
+        assertC(DOTS_NO_W_SPACE_INSTANCE, "1.  56...012345", 6,  "1...");
+        assertC(DOTS_NO_W_SPACE_INSTANCE, "1.  56...012345", 5,  "1...");
+        assertC(DOTS_NO_W_SPACE_INSTANCE, "1.  56...012345", 4,  "1...");
+        assertC(DOTS_NO_W_SPACE_INSTANCE, "1.  56...012345", 3,  "...");
+
+        assertC(EMPTY_TERMINATOR_INSTANCE, "ab. cd", 6, "ab. cd");
+        assertC(EMPTY_TERMINATOR_INSTANCE, "ab. cd", 5, "ab. c");
+        assertC(EMPTY_TERMINATOR_INSTANCE, "ab. cd", 4, "ab.");
+        assertC(EMPTY_TERMINATOR_INSTANCE, "ab. cd", 3, "ab.");
+        assertC(EMPTY_TERMINATOR_INSTANCE, "ab. cd", 2, "ab");
+        assertC(EMPTY_TERMINATOR_INSTANCE, "ab. cd", 1, "a");
+        assertC(EMPTY_TERMINATOR_INSTANCE, "ab. cd", 0, "");
+    }
+
+    @Test
+    public void testWSimple() {
+        assertW(ASCII_INSTANCE, "word1 word2 word3", 18, "word1 word2 word3");
+        assertW(ASCII_INSTANCE, "word1 word2 word3", 17, "word1 word2 word3");
+        assertW(ASCII_INSTANCE, "word1 word2 word3", 16, "word1 [...]");
+        assertW(ASCII_INSTANCE, "word1 word2 word3", 11, "word1 [...]");
+        for (int maxLength = 10; maxLength >= 0; maxLength--) {
+            assertW(ASCII_INSTANCE, "word1 word2 word3", maxLength, "[...]");
+        }
+
+        assertW(UNICODE_INSTANCE, "word1 word2 word3", 18, "word1 word2 word3");
+        assertW(UNICODE_INSTANCE, "word1 word2 word3", 17, "word1 word2 word3");
+        assertW(UNICODE_INSTANCE, "word1 word2 word3", 16, "word1 word2 [\u2026]");
+        assertW(UNICODE_INSTANCE, "word1 word2 word3", 15, "word1 word2 [\u2026]");
+        assertW(UNICODE_INSTANCE, "word1 word2 word3", 14, "word1 [\u2026]");
+        assertW(UNICODE_INSTANCE, "word1 word2 word3", 9, "word1 [\u2026]");
+        for (int maxLength = 8; maxLength >= 0; maxLength--) {
+            assertW(UNICODE_INSTANCE, "word1 word2 word3", maxLength, "[\u2026]");
+        }
+
+        assertW(EMPTY_TERMINATOR_INSTANCE, "word1 word2 word3", 18, "word1 word2 word3");
+        assertW(EMPTY_TERMINATOR_INSTANCE, "word1 word2 word3", 17, "word1 word2 word3");
+        assertW(EMPTY_TERMINATOR_INSTANCE, "word1 word2 word3", 16, "word1 word2");
+        assertW(EMPTY_TERMINATOR_INSTANCE, "word1 word2 word3", 11, "word1 word2");
+        assertW(EMPTY_TERMINATOR_INSTANCE, "word1 word2 word3", 10, "word1");
+        assertW(EMPTY_TERMINATOR_INSTANCE, "word1 word2 word3", 5, "word1");
+        for (int maxLength = 4; maxLength >= 0; maxLength--) {
+            assertW(EMPTY_TERMINATOR_INSTANCE, "word1 word2 word3", maxLength, "");
+        }
+    }
+
+    @Test
+    public void testWSpaceAndDot() {
+        assertW(DOTS_INSTANCE, "  word1  word2  ", 16, "  word1  word2  ");
+        assertW(DOTS_INSTANCE, "  word1  word2  ", 15, "  word1 ...");
+        assertW(DOTS_INSTANCE, "  word1  word2  ", 11, "  word1 ...");
+        for (int maxLength = 10; maxLength >= 0; maxLength--) {
+            assertW(DOTS_INSTANCE, "  word1  word2  ", maxLength, "...");
+        }
+
+        assertW(DOTS_NO_W_SPACE_INSTANCE, "  word1  word2  ", 16, "  word1  word2  ");
+        assertW(DOTS_NO_W_SPACE_INSTANCE, "  word1  word2  ", 15, "  word1...");
+        assertW(DOTS_NO_W_SPACE_INSTANCE, "  word1  word2  ", 10, "  word1...");
+        for (int maxLength = 9; maxLength >= 0; maxLength--) {
+            assertW(DOTS_NO_W_SPACE_INSTANCE, "  word1  word2  ", maxLength, "...");
+        }
+
+        assertW(DOTS_INSTANCE, " . . word1..  word2    ", 23, " . . word1..  word2    ");
+        assertW(DOTS_INSTANCE, " . . word1..  word2    ", 22, " . . word1.. ...");
+        assertW(DOTS_INSTANCE, " . . word1..  word2    ", 16, " . . word1.. ...");
+        assertW(DOTS_INSTANCE, " . . word1..  word2    ", 15, " . . ...");
+        assertW(DOTS_INSTANCE, " . . word1..  word2    ", 8, " . . ...");
+        assertW(DOTS_INSTANCE, " . . word1..  word2    ", 7, " . ...");
+        assertW(DOTS_INSTANCE, " . . word1..  word2    ", 6, " . ...");
+        for (int maxLength = 5; maxLength >= 0; maxLength--) {
+            assertW(DOTS_INSTANCE, " . . word1..  word2    ", maxLength, "...");
+        }
+
+        assertW(DOTS_NO_W_SPACE_INSTANCE, " . . word1..  word2    ", 23, " . . word1..  word2    ");
+        assertW(DOTS_NO_W_SPACE_INSTANCE, " . . word1..  word2    ", 22, " . . word1..  word2...");
+        assertW(DOTS_NO_W_SPACE_INSTANCE, " . . word1..  word2    ", 21, " . . word1...");
+        for (int maxLength = 13; maxLength >= 0; maxLength--) {
+            assertW(DOTS_NO_W_SPACE_INSTANCE, " . . word1..  word2    ", maxLength, "...");
+        }
+    }
+
+    /**
+     * "Auto" means plain trunce(..) call, because the tested implementation chooses between CB and WB automatically.
+     */
+    @Test
+    public void testAuto() {
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 24, "1 234567 90ABCDEFGHIJKL");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 23, "1 234567 90ABCDEFGHIJKL");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 21, "1 234567 90ABCDE[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 20, "1 234567 90ABCD[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 19, "1 234567 90ABC[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 18, "1 234567 [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 17, "1 234567 [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 16, "1 234567 [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 15, "1 234567 [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 14, "1 234567 [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 13, "1 23456[...]"); // wb space
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJKL", 12, "1 23456[...]");
+
+        assertAuto(ASCII_INSTANCE, "1 234567  0ABCDEFGHIJKL", 22, "1 234567  0ABCDEF[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 9 ABCDEFGHIJKL", 22, "1 234567 9 ABCDEF[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90 BCDEFGHIJKL", 22, "1 234567 90 [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90A CDEFGHIJKL", 22, "1 234567 90A [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90AB DEFGHIJKL", 22, "1 234567 90AB [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABC EFGHIJKL", 22, "1 234567 90ABC [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCD FGHIJKL", 22, "1 234567 90ABCD [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDE GHIJKL", 22, "1 234567 90ABCDE [...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEF HIJKL", 22, "1 234567 90ABCDE[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFG IJKL", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGH JKL", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHI KL", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJ L", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_INSTANCE, "1 234567 90ABCDEFGHIJK ", 22, "1 234567 90ABCDEF[...]");
+
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567  0ABCDEFGHIJKL", 22, "1 234567  0ABCDEF[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 9 ABCDEFGHIJKL", 22, "1 234567 9 ABCDEF[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90 BCDEFGHIJKL", 22, "1 234567 90 BCDEF[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90A CDEFGHIJKL", 22, "1 234567 90A[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90AB DEFGHIJKL", 22, "1 234567 90AB[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABC EFGHIJKL", 22, "1 234567 90ABC[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABCD FGHIJKL", 22, "1 234567 90ABCD[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABCDE GHIJKL", 22, "1 234567 90ABCDE[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABCDEF HIJKL", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABCDEFG IJKL", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABCDEFGH JKL", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABCDEFGHI KL", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABCDEFGHIJ L", 22, "1 234567 90ABCDEF[...]");
+        assertAuto(ASCII_NO_W_SPACE_INSTANCE, "1 234567 90ABCDEFGHIJK ", 22, "1 234567 90ABCDEF[...]");
+
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 24, "12390ABCD..  . EFGHIJK .");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 23, "12390ABCD..  . ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 22, "12390ABCD..  . ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 21, "12390ABCD..  . ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 20, "12390ABCD..  . ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 19, "12390ABCD..  . ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 18, "12390ABCD..  . ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 17, "12390ABCD.. ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 16, "12390ABCD.. ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 15, "12390ABCD.. ...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 14, "12390ABCD...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 13, "12390ABCD...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 12, "12390ABCD...");
+        assertAuto(DOTS_INSTANCE, "12390ABCD..  . EFGHIJK .", 11, "12390ABC...");
+
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 27, "word0 word1. word2 w3 . ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 26, "word0 word1. word2 w3 ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 25, "word0 word1. word2 w3 ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 24, "word0 word1. word2 ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 22, "word0 word1. word2 ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 21, "word0 word1. ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 16, "word0 word1. ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 15, "word0 word1...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 14, "word0 word1...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 13, "word0 word...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 12, "word0 ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 9,  "word0 ...");
+        assertAuto(DOTS_INSTANCE, "word0 word1. word2 w3 . . w4", 8,  "word...");
+    }
+
+    @Test
+    public void testExtremeWordBoundaryMinLengths() {
+        assertC(ASCII_INSTANCE, "1 3456789", 8,  "1 3[...]");
+        assertW(ASCII_INSTANCE, "1 3456789", 8,  "1 [...]");
+        DefaultTruncateBuiltinAlgorithm wbMinLen1Algo = new DefaultTruncateBuiltinAlgorithm(
+                ASCII_INSTANCE.getDefaultTerminator(), null, null,
+                null, null, null,
+                true, 1.0);
+        assertAuto(wbMinLen1Algo, "1 3456789", 8,  "1 3[...]");
+
+        assertAuto(ASCII_INSTANCE, "123456789", 8,  "123[...]");
+        DefaultTruncateBuiltinAlgorithm wbMinLen0Algo = new DefaultTruncateBuiltinAlgorithm(
+                ASCII_INSTANCE.getDefaultTerminator(), null, null,
+                null, null, null,
+                true, 0.0);
+        assertAuto(wbMinLen0Algo, "123456789", 8,  "[...]");
+    }
+
+    @Test
+    public void testSimpleEdgeCases() throws TemplateException {
+        Environment env = createEnvironment();
+        for (final DefaultTruncateBuiltinAlgorithm alg : new DefaultTruncateBuiltinAlgorithm[] {
+                ASCII_INSTANCE, UNICODE_INSTANCE,
+                EMPTY_TERMINATOR_INSTANCE, DOTS_INSTANCE, ASCII_NO_W_SPACE_INSTANCE, M_TERM_INSTANCE }) {
+            for (TruncateCaller tc : new TruncateCaller[] {
+                    new TruncateCaller() {
+                        public TemplateModel truncate(String s, int maxLength, TemplateModel terminator,
+                                Integer terminatorLength, Environment env) throws
+                                TemplateException {
+                            return alg.truncateM(s, maxLength, terminator, terminatorLength, env);
+                        }
+                    },
+                    new TruncateCaller() {
+                        public TemplateModel truncate(String s, int maxLength, TemplateModel terminator,
+                                Integer terminatorLength, Environment env) throws
+                                TemplateException {
+                            return alg.truncateCM(s, maxLength, terminator, terminatorLength, env);
+                        }
+                    },
+                    new TruncateCaller() {
+                        public TemplateModel truncate(String s, int maxLength, TemplateModel terminator,
+                                Integer terminatorLength, Environment env) throws
+                                TemplateException {
+                            return alg.truncateWM(s, maxLength, terminator, terminatorLength, env);
+                        }
+                    }
+            }) {
+                assertEquals("", tc.truncate("", 0, null, null, env).toString(), "");
+                assertEquals("", tc.truncate("", 0, null, null, env).toString(), "");
+                if (alg.getDefaultMTerminator() != null) {
+                    TemplateModel truncated = tc.truncate("x", 0, null, null, env);
+                    assertThat(truncated, instanceOf(TemplateMarkupOutputModel.class));
+                    assertSame(alg.getDefaultMTerminator(), truncated);
+                } else {
+                    TemplateModel truncated = tc.truncate("x", 0, null, null, env);
+                    assertThat(truncated, instanceOf(TemplateScalarModel.class));
+                    assertEquals(alg.getDefaultTerminator(), ((TemplateScalarModel) truncated).getAsString());
+                }
+                SimpleScalar stringTerminator = new SimpleScalar("|");
+                assertSame(stringTerminator, tc.truncate("x", 0, stringTerminator, null, env));
+                TemplateHTMLOutputModel htmlTerminator = HTMLOutputFormat.INSTANCE.fromMarkup("<x>.</x>");
+                assertSame(htmlTerminator, tc.truncate("x", 0, htmlTerminator, null, env));
+            }
+        }
+    }
+
+    @Test
+    public void testStandardInstanceSettings() throws TemplateException {
+        Environment env = createEnvironment();
+
+        assertEquals(
+                "123[...]",
+                ASCII_INSTANCE.truncate("1234567890", 8, null, null, env)
+                    .getAsString());
+        assertEquals(
+                "12345<span class='truncateTerminator'>[&#8230;]</span>",
+                HTMLOutputFormat.INSTANCE.getMarkupString(
+                ((TemplateHTMLOutputModel) ASCII_INSTANCE
+                        .truncateM("1234567890", 8, null, null, env))
+                ));
+
+        assertEquals(
+                "12345[\u2026]",
+                UNICODE_INSTANCE.truncate("1234567890", 8, null, null, env)
+                        .getAsString());
+        assertEquals(
+                "12345<span class='truncateTerminator'>[&#8230;]</span>",
+                HTMLOutputFormat.INSTANCE.getMarkupString(
+                        ((TemplateHTMLOutputModel) UNICODE_INSTANCE
+                                .truncateM("1234567890", 8, null, null, env))
+                ));
+    }
+
+    private void assertC(TruncateBuiltinAlgorithm algorithm, String in, int maxLength, String expected) {
+        try {
+            TemplateScalarModel actual = algorithm.truncateC(in, maxLength, null, null, null);
+            assertEquals(expected, actual.getAsString());
+        } catch (TemplateException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private void assertW(TruncateBuiltinAlgorithm algorithm, String in, int maxLength, String expected) {
+        try {
+            TemplateScalarModel actual = algorithm.truncateW(in, maxLength, null, null, null);
+            assertEquals(expected, actual.getAsString());
+        } catch (TemplateException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private void assertAuto(TruncateBuiltinAlgorithm algorithm, String in, int maxLength, String expected) {
+        try {
+            TemplateScalarModel actual = algorithm.truncate(
+                    in, maxLength, null, null, null);
+            assertEquals(expected, actual.getAsString());
+        } catch (TemplateException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    interface TruncateCaller {
+        TemplateModel truncate(
+                String s, int maxLength,
+                TemplateModel terminator, Integer terminatorLength,
+                Environment env) throws TemplateException;
+    }
+
+    @Test
+    public void testGetLengthWithoutTags() {
+        assertEquals(0,  getLengthWithoutTags(""));
+        assertEquals(1,  getLengthWithoutTags("a"));
+        assertEquals(2,  getLengthWithoutTags("ab"));
+        assertEquals(0,  getLengthWithoutTags("<tag>"));
+        assertEquals(1,  getLengthWithoutTags("<tag>a"));
+        assertEquals(2,  getLengthWithoutTags("<tag>a</tag>b"));
+        assertEquals(4,  getLengthWithoutTags("ab<tag>cd</tag>"));
+        assertEquals(2,  getLengthWithoutTags("ab<tag></tag>"));
+
+        assertEquals(2,  getLengthWithoutTags("&chr;a"));
+        assertEquals(4,  getLengthWithoutTags("&chr;a&chr;b"));
+        assertEquals(6,  getLengthWithoutTags("ab&chr;cd&chr;"));
+        assertEquals(4,  getLengthWithoutTags("ab&chr;&chr;"));
+        assertEquals(4,  getLengthWithoutTags("ab<tag>&chr;</tag>&chr;"));
+
+        assertEquals(2,  getLengthWithoutTags("<!--c-->ab"));
+        assertEquals(2,  getLengthWithoutTags("a<!--c-->b<!--c-->"));
+        assertEquals(2,  getLengthWithoutTags("a<!-->--><!---->b"));
+
+        assertEquals(3,  getLengthWithoutTags("a<![CDATA[b]]>c"));
+        assertEquals(2,  getLengthWithoutTags("a<![CDATA[]]>b"));
+        assertEquals(0,  getLengthWithoutTags("<![CDATA[]]>"));
+        assertEquals(3,  getLengthWithoutTags("<![CDATA[123"));
+        assertEquals(4,  getLengthWithoutTags("<![CDATA[123]"));
+        assertEquals(5,  getLengthWithoutTags("<![CDATA[123]]"));
+        assertEquals(3,  getLengthWithoutTags("<![CDATA[123]]>"));
+
+        assertEquals(2,  getLengthWithoutTags("ab<!--"));
+        assertEquals(2,  getLengthWithoutTags("ab<tag"));
+        assertEquals(3,  getLengthWithoutTags("ab&chr"));
+        assertEquals(2,  getLengthWithoutTags("ab<!-"));
+        assertEquals(2,  getLengthWithoutTags("ab<"));
+        assertEquals(3,  getLengthWithoutTags("ab&"));
+        assertEquals(3,  getLengthWithoutTags("a&;c"));
+    }
+
+    @Test
+    public void testGetCodeFromNumericalCharReferenceName() {
+        assertEquals(0, getCodeFromNumericalCharReferenceName("#0"));
+        assertEquals(0, getCodeFromNumericalCharReferenceName("#00"));
+        assertEquals(0, getCodeFromNumericalCharReferenceName("#x0"));
+        assertEquals(0, getCodeFromNumericalCharReferenceName("#x00"));
+        assertEquals(1, getCodeFromNumericalCharReferenceName("#1"));
+        assertEquals(1, getCodeFromNumericalCharReferenceName("#01"));
+        assertEquals(1, getCodeFromNumericalCharReferenceName("#x1"));
+        assertEquals(1, getCodeFromNumericalCharReferenceName("#x01"));
+        assertEquals(1, getCodeFromNumericalCharReferenceName("#X1"));
+        assertEquals(1, getCodeFromNumericalCharReferenceName("#X01"));
+        assertEquals(123409, getCodeFromNumericalCharReferenceName("#123409"));
+        assertEquals(123409, getCodeFromNumericalCharReferenceName("#00123409"));
+        assertEquals(0x123A0F, getCodeFromNumericalCharReferenceName("#x123A0F"));
+        assertEquals(0x123A0F, getCodeFromNumericalCharReferenceName("#x123a0f"));
+        assertEquals(0x123A0F, getCodeFromNumericalCharReferenceName("#X00123A0f"));
+        assertEquals(-1, getCodeFromNumericalCharReferenceName("#x1G"));
+        assertEquals(-1, getCodeFromNumericalCharReferenceName("#1A"));
+    }
+
+    @Test
+    public void testIsDotCharReference() {
+        assertTrue(isDotCharReference("#46"));
+        assertTrue(isDotCharReference("#x2E"));
+        assertTrue(isDotCharReference("#x2026"));
+        assertTrue(isDotCharReference("hellip"));
+        assertTrue(isDotCharReference("period"));
+
+        assertFalse(isDotCharReference(""));
+        assertFalse(isDotCharReference("foo"));
+        assertFalse(isDotCharReference("#x46"));
+        assertFalse(isDotCharReference("#boo"));
+    }
+
+    @Test
+    public void testIsHtmlOrXmlStartsWithDot() {
+        assertTrue(doesHtmlOrXmlStartWithDot("."));
+        assertTrue(doesHtmlOrXmlStartWithDot(".etc"));
+        assertTrue(doesHtmlOrXmlStartWithDot("&hellip;"));
+        assertTrue(doesHtmlOrXmlStartWithDot("<tag x='y'/>&hellip;"));
+        assertTrue(doesHtmlOrXmlStartWithDot("<span class='t'>...</span>"));
+        assertTrue(doesHtmlOrXmlStartWithDot("<span class='t'>&#x2026;</span>"));
+        assertTrue(doesHtmlOrXmlStartWithDot("<span class='t'>&#46;</span>"));
+        assertTrue(doesHtmlOrXmlStartWithDot("<foo><!-- -->.etc"));
+
+        assertFalse(doesHtmlOrXmlStartWithDot(""));
+        assertFalse(doesHtmlOrXmlStartWithDot("[...]"));
+        assertFalse(doesHtmlOrXmlStartWithDot("etc."));
+        assertFalse(doesHtmlOrXmlStartWithDot("<span class='t'>[...]</span>"));
+        assertFalse(doesHtmlOrXmlStartWithDot("<span class='t'>etc.</span>"));
+        assertFalse(doesHtmlOrXmlStartWithDot("<span class='t'>&46;</span>"));
+    }
+
+    @Test
+    public void testTruncateAdhocHtmlTerminator() throws TemplateException {
+        Environment env = createEnvironment();
+        TemplateHTMLOutputModel htmlEllipsis = HTMLOutputFormat.INSTANCE.fromMarkup("<i>&#x2026;</i>");
+        TemplateHTMLOutputModel htmlSquEllipsis = HTMLOutputFormat.INSTANCE.fromMarkup("<i>[&#x2026;]</i>");
+
+        // Length detection
+        {
+            TemplateModel actual = ASCII_INSTANCE.truncateM("abcd", 3, htmlEllipsis, null, env);
+            assertThat(actual, instanceOf(TemplateHTMLOutputModel.class));
+            assertEquals(
+                    "ab<i>&#x2026;</i>",
+                    HTMLOutputFormat.INSTANCE.getMarkupString((TemplateHTMLOutputModel) actual));
+        }
+        {
+            TemplateModel actual = ASCII_INSTANCE.truncateM("abcdef", 5, htmlSquEllipsis, null, env);
+            assertThat(actual, instanceOf(TemplateHTMLOutputModel.class));
+            assertEquals(
+                    "ab<i>[&#x2026;]</i>",
+                    HTMLOutputFormat.INSTANCE.getMarkupString((TemplateHTMLOutputModel) actual));
+        }
+        {
+            TemplateModel actual = ASCII_INSTANCE.truncateM("abcdef", 5, htmlSquEllipsis, 1, env);
+            assertThat(actual, instanceOf(TemplateHTMLOutputModel.class));
+            assertEquals(
+                    "abcd<i>[&#x2026;]</i>",
+                    HTMLOutputFormat.INSTANCE.getMarkupString((TemplateHTMLOutputModel) actual));
+        }
+
+        // Dot removal
+        {
+            TemplateModel actual = ASCII_INSTANCE.truncateM("a.cd", 3, htmlEllipsis, null, env);
+            assertThat(actual, instanceOf(TemplateHTMLOutputModel.class));
+            assertEquals(
+                    "a<i>&#x2026;</i>",
+                    HTMLOutputFormat.INSTANCE.getMarkupString((TemplateHTMLOutputModel) actual));
+        }
+        {
+            TemplateModel actual = ASCII_INSTANCE.truncateM("a.cdef", 5, htmlSquEllipsis, null, env);
+            assertThat(actual, instanceOf(TemplateHTMLOutputModel.class));
+            assertEquals(
+                    "a.<i>[&#x2026;]</i>",
+                    HTMLOutputFormat.INSTANCE.getMarkupString((TemplateHTMLOutputModel) actual));
+        }
+    }
+
+    @Test
+    public void testTruncateAdhocPlainTextTerminator() throws TemplateException {
+        Environment env = createEnvironment();
+        TemplateScalarModel ellipsis = new SimpleScalar("\u2026");
+        TemplateScalarModel squEllipsis = new SimpleScalar("[\u2026]");
+
+        // Length detection
+        {
+            TemplateScalarModel actual = ASCII_INSTANCE.truncate("abcd", 3, ellipsis, null, env);
+            assertEquals("ab\u2026", actual.getAsString());
+        }
+        {
+            TemplateScalarModel actual = ASCII_INSTANCE.truncate("abcdef", 5, squEllipsis, null, env);
+            assertEquals("ab[\u2026]", actual.getAsString());
+        }
+        {
+            TemplateScalarModel actual = ASCII_INSTANCE.truncate("abcdef", 5, squEllipsis, 1, env);
+            assertEquals("abcd[\u2026]", actual.getAsString());
+        }
+
+        // Dot removal
+        {
+            TemplateScalarModel actual = ASCII_INSTANCE.truncate("a.cd", 3, ellipsis, null, env);
+            assertEquals("a\u2026", actual.getAsString());
+        }
+        {
+            TemplateScalarModel actual = ASCII_INSTANCE.truncate("a.cdef", 5, squEllipsis, null, env);
+            assertEquals("a.[\u2026]", actual.getAsString());
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/src/test/java/freemarker/core/TemplateConfigurationTest.java b/src/test/java/freemarker/core/TemplateConfigurationTest.java
index 76199ad..5be84d7 100644
--- a/src/test/java/freemarker/core/TemplateConfigurationTest.java
+++ b/src/test/java/freemarker/core/TemplateConfigurationTest.java
@@ -180,6 +180,7 @@ public class TemplateConfigurationTest {
                 ImmutableMap.of("dummy", HexTemplateNumberFormatFactory.INSTANCE));
         SETTING_ASSIGNMENTS.put("customDateFormats",
                 ImmutableMap.of("dummy", EpochMillisTemplateDateFormatFactory.INSTANCE));
+        SETTING_ASSIGNMENTS.put("truncateBuiltinAlgorithm", DefaultTruncateBuiltinAlgorithm.UNICODE_INSTANCE);
 
         // Parser-only settings:
         SETTING_ASSIGNMENTS.put("tagSyntax", Configuration.SQUARE_BRACKET_TAG_SYNTAX);
diff --git a/src/test/java/freemarker/core/TruncateBuiltInTest.java b/src/test/java/freemarker/core/TruncateBuiltInTest.java
new file mode 100644
index 0000000..2cfe037
--- /dev/null
+++ b/src/test/java/freemarker/core/TruncateBuiltInTest.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package freemarker.core;
+
+import java.io.IOException;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import freemarker.template.Configuration;
+import freemarker.template.TemplateException;
+import freemarker.template.TemplateModelException;
+import freemarker.test.TemplateTest;
+
+public class TruncateBuiltInTest extends TemplateTest {
+
+    private static final String M_TERM_SRC = "<span class=trunc>&hellips;</span>";
+
+    @Override
+    protected Configuration createConfiguration() throws Exception {
+        Configuration cfg = super.createConfiguration();
+        cfg.setOutputFormat(HTMLOutputFormat.INSTANCE);
+        return cfg;
+    }
+
+    @Before
+    public void setup() throws TemplateModelException {
+        addToDataModel("t", "Some text for truncation testing.");
+        addToDataModel("u", "CaNotBeBrokenAnywhere");
+        addToDataModel("mTerm", HTMLOutputFormat.INSTANCE.fromMarkup(M_TERM_SRC));
+    }
+
+    @Test
+    public void testTruncate() throws IOException, TemplateException {
+        assertOutput("${t?truncate(20)}", "Some text for [...]");
+        assertOutput("${t?truncate(20, '|')}", "Some text for |");
+        assertOutput("${t?truncate(20, '|', 7)}", "Some text |");
+
+        assertOutput("${u?truncate(20)}", "CaNotBeBrokenAn[...]");
+        assertOutput("${u?truncate(20, '|')}", "CaNotBeBrokenAnywhe|");
+        assertOutput("${u?truncate(20, '|', 3)}", "CaNotBeBrokenAnyw|");
+
+        assertOutput("${t?truncate(20)?isMarkupOutput?c}", "false");
+
+        // Edge cases that are still allowed:
+        assertOutput("${t?truncate(0)}", "[...]");
+        assertOutput("${u?truncate(3, '', 0)}", "CaN");
+
+        // Disallowed:
+        assertErrorContains("${t?truncate(200, mTerm)}", "#2", "string", "markup");
+        assertErrorContains("${t?truncate(-1)}", "#1", "negative");
+        assertErrorContains("${t?truncate(200, 'x', -1)}", "#3", "negative");
+    }
+
+    @Test
+    public void testTruncateM() throws IOException, TemplateException {
+        assertOutput("${t?truncateM(15)}", "Some text <span class='truncateTerminator'>[&#8230;]</span>"); // String arg allowed...
+        assertOutput("${t?truncate_m(15, mTerm)}", "Some text for " + M_TERM_SRC);
+        assertOutput("${t?truncateM(15, mTerm)}", "Some text for " + M_TERM_SRC);
+        assertOutput("${t?truncateM(15, mTerm, 3)}", "Some text " + M_TERM_SRC);
+
+        assertOutput("${u?truncateM(20, mTerm)}", "CaNotBeBrokenAnywhe" + M_TERM_SRC);
+        assertOutput("${u?truncateM(20, mTerm, 3)}", "CaNotBeBrokenAnyw" + M_TERM_SRC);
+
+        assertOutput("${t?truncateM(15, '|')}", "Some text for |"); // String arg allowed...
+        assertOutput("${t?truncateM(15, '|')?isMarkupOutput?c}", "false"); // ... and results in string.
+        assertOutput("${t?truncateM(15, mTerm)?isMarkupOutput?c}", "true");
+    }
+
+    @Test
+    public void testTruncateC() throws IOException, TemplateException {
+        assertOutput("${t?truncate_c(20)}", "Some text for t[...]");
+        assertOutput("${t?truncateC(20)}", "Some text for t[...]");
+        assertOutput("${t?truncateC(20, '|')}", "Some text for trunc|");
+        assertOutput("${t?truncateC(20, '|', 0)}", "Some text for trunca|");
+
+        assertErrorContains("${t?truncateC(200, mTerm)}", "#2", "string", "markup");
+
+        assertOutput("${t?truncateC(20)?isMarkupOutput?c}", "false");
+    }
+
+    @Test
+    public void testTruncateCM() throws IOException, TemplateException {
+        assertOutput("${t?truncate_c_m(20, mTerm)}", "Some text for trunc" + M_TERM_SRC);
+        assertOutput("${t?truncateCM(20, mTerm, 3)}", "Some text for tru" + M_TERM_SRC);
+
+        assertOutput("${t?truncateCM(20)?isMarkupOutput?c}", "true");
+        assertOutput("${t?truncateCM(20, '|')?isMarkupOutput?c}", "false");
+        assertOutput("${t?truncateCM(20, mTerm)?isMarkupOutput?c}", "true");
+    }
+
+    @Test
+    public void testTruncateW() throws IOException, TemplateException {
+        assertOutput("${t?truncate_w(20)}", "Some text for [...]");
+        assertOutput("${t?truncateW(20)}", "Some text for [...]");
+        assertOutput("${u?truncateW(20)}", "[...]");  // Proof of no fallback to C
+
+        assertErrorContains("${t?truncateW(200, mTerm)}", "#2", "string", "markup");
+
+        assertOutput("${t?truncateW(20)?isMarkupOutput?c}", "false");
+        assertOutput("${t?truncateW(20, '|')?isMarkupOutput?c}", "false");
+    }
+
+    @Test
+    public void testTruncateWM() throws IOException, TemplateException {
+        assertOutput("${t?truncate_w_m(15, mTerm)}", "Some text for " + M_TERM_SRC);
+        assertOutput("${t?truncateWM(15, mTerm)}", "Some text for " + M_TERM_SRC);
+        assertOutput("${t?truncateWM(15, mTerm, 3)}", "Some text " + M_TERM_SRC);
+
+        assertOutput("${u?truncateWM(20, mTerm)}", M_TERM_SRC); // Proof of no fallback to C
+
+        assertOutput("${t?truncateCM(20)?isMarkupOutput?c}", "true");
+        assertOutput("${t?truncateCM(20, '|')?isMarkupOutput?c}", "false");
+        assertOutput("${t?truncateCM(20, mTerm)?isMarkupOutput?c}", "true");
+    }
+
+    @Test
+    public void testSettingHasEffect() throws IOException, TemplateException {
+        assertOutput("${t?truncate(20)}", "Some text for [...]");
+        assertOutput("${t?truncateC(20)}", "Some text for t[...]");
+        getConfiguration().setTruncateBuiltinAlgorithm(DefaultTruncateBuiltinAlgorithm.UNICODE_INSTANCE);
+        assertOutput("${t?truncate(20)}", "Some text for [\u2026]");
+        assertOutput("${t?truncateC(20)}", "Some text for tru[\u2026]");
+    }
+
+    @Test
+    public void testDifferentMarkupSeparatorSetting() throws IOException, TemplateException {
+        assertOutput("${t?truncate(20)}", "Some text for [...]");
+        assertOutput("${t?truncateM(20)}", "Some text for <span class='truncateTerminator'>[&#8230;]</span>");
+        getConfiguration().setTruncateBuiltinAlgorithm(new DefaultTruncateBuiltinAlgorithm(
+                "|...", HTMLOutputFormat.INSTANCE.fromMarkup(M_TERM_SRC), true));
+        assertOutput("${t?truncate(20)}", "Some text for |...");
+        assertOutput("${t?truncateM(20)}", "Some text for " + M_TERM_SRC);
+    }
+
+}
diff --git a/src/test/java/freemarker/template/ConfigurationTest.java b/src/test/java/freemarker/template/ConfigurationTest.java
index f3e8be9..cd71ca1 100644
--- a/src/test/java/freemarker/template/ConfigurationTest.java
+++ b/src/test/java/freemarker/template/ConfigurationTest.java
@@ -59,6 +59,7 @@ import freemarker.core.Configurable.SettingValueAssignmentException;
 import freemarker.core.Configurable.UnknownSettingException;
 import freemarker.core.ConfigurableTest;
 import freemarker.core.CustomHTMLOutputFormat;
+import freemarker.core.DefaultTruncateBuiltinAlgorithm;
 import freemarker.core.DummyOutputFormat;
 import freemarker.core.Environment;
 import freemarker.core.EpochMillisDivTemplateDateFormatFactory;
@@ -1801,7 +1802,67 @@ public class ConfigurationTest extends TestCase {
         cfg.setSetting(Configuration.NEW_BUILTIN_CLASS_RESOLVER_KEY_SNAKE_CASE, "allows_nothing");
         assertSame(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER, cfg.getNewBuiltinClassResolver());
     }
-    
+
+    public void testTruncateBuiltinAlgorithm() throws TemplateException {
+        Configuration cfg = new Configuration(Configuration.VERSION_2_3_0);
+        assertSame(DefaultTruncateBuiltinAlgorithm.ASCII_INSTANCE, cfg.getTruncateBuiltinAlgorithm());
+
+        cfg.setSetting("truncateBuiltinAlgorithm", "unicodE");
+        assertSame(DefaultTruncateBuiltinAlgorithm.UNICODE_INSTANCE, cfg.getTruncateBuiltinAlgorithm());
+
+        cfg.setSetting("truncate_builtin_algorithm", "ASCII");
+        assertSame(DefaultTruncateBuiltinAlgorithm.ASCII_INSTANCE, cfg.getTruncateBuiltinAlgorithm());
+
+        {
+            cfg.setSetting("truncate_builtin_algorithm",
+                    "DefaultTruncateBuiltinAlgorithm('...', false)");
+            DefaultTruncateBuiltinAlgorithm alg =
+                    (DefaultTruncateBuiltinAlgorithm) cfg.getTruncateBuiltinAlgorithm();
+            assertEquals("...", alg.getDefaultTerminator());
+            assertFalse(alg.getAddSpaceAtWordBoundary());
+            assertEquals(3, alg.getDefaultTerminatorLength());
+            assertNull(alg.getDefaultMTerminator());
+            assertNull(alg.getDefaultMTerminatorLength());
+            assertEquals(DefaultTruncateBuiltinAlgorithm.DEFAULT_WORD_BOUNDARY_MIN_LENGTH,
+                    alg.getWordBoundaryMinLength());
+        }
+
+        {
+            cfg.setSetting("truncate_builtin_algorithm",
+                    "DefaultTruncateBuiltinAlgorithm(" +
+                            "'...', " +
+                            "markup(HTMLOutputFormat(), '<span class=trunc>...</span>'), " +
+                            "true)");
+            DefaultTruncateBuiltinAlgorithm alg =
+                    (DefaultTruncateBuiltinAlgorithm) cfg.getTruncateBuiltinAlgorithm();
+            assertEquals("...", alg.getDefaultTerminator());
+            assertTrue(alg.getAddSpaceAtWordBoundary());
+            assertEquals(3, alg.getDefaultTerminatorLength());
+            assertEquals("markupOutput(format=HTML, markup=<span class=trunc>...</span>)",
+                    alg.getDefaultMTerminator().toString());
+            assertEquals(Integer.valueOf(3), alg.getDefaultMTerminatorLength());
+            assertEquals(DefaultTruncateBuiltinAlgorithm.DEFAULT_WORD_BOUNDARY_MIN_LENGTH,
+                    alg.getWordBoundaryMinLength());
+        }
+
+        {
+            cfg.setSetting("truncate_builtin_algorithm",
+                    "DefaultTruncateBuiltinAlgorithm(" +
+                            "DefaultTruncateBuiltinAlgorithm.STANDARD_ASCII_TERMINATOR, null, null, " +
+                            "DefaultTruncateBuiltinAlgorithm.STANDARD_M_TERMINATOR, null, null, " +
+                            "true, 0.5)");
+            DefaultTruncateBuiltinAlgorithm alg =
+                    (DefaultTruncateBuiltinAlgorithm) cfg.getTruncateBuiltinAlgorithm();
+            assertEquals(DefaultTruncateBuiltinAlgorithm.STANDARD_ASCII_TERMINATOR, alg.getDefaultTerminator());
+            assertTrue(alg.getAddSpaceAtWordBoundary());
+            assertEquals(5, alg.getDefaultTerminatorLength());
+            assertEquals(DefaultTruncateBuiltinAlgorithm.STANDARD_M_TERMINATOR.toString(),
+                    alg.getDefaultMTerminator().toString());
+            assertEquals(Integer.valueOf(3), alg.getDefaultMTerminatorLength());
+            assertEquals(0.5, alg.getWordBoundaryMinLength());
+        }
+    }
+
     @Test
     public void testGetSettingNamesAreSorted() throws Exception {
         Configuration cfg = new Configuration(Configuration.VERSION_2_3_22);