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:24:17 UTC

[freemarker] branch 3 updated: Forward ported from 2.3-gae: Added ?truncate built-in and related setting

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

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


The following commit(s) were added to refs/heads/3 by this push:
     new 69c2f71  Forward ported from 2.3-gae: Added ?truncate built-in and related setting
69c2f71 is described below

commit 69c2f71271a54fa74f2e2a95f3b772ae2f567e87
Author: ddekany <dd...@apache.org>
AuthorDate: Wed Jan 16 20:24:07 2019 +0100

    Forward ported from 2.3-gae: Added ?truncate built-in and related setting
---
 ...tableParsingAndProcessingConfigurationTest.java |  62 ++
 .../freemarker/core/TemplateConfigurationTest.java |   2 +
 .../freemarker/core/TruncateBuiltInTest.java       | 135 ++++
 .../impl/DefaultTruncateBuiltinAlgorithmTest.java  | 672 ++++++++++++++++++
 .../org/apache/freemarker/core/ASTExpBuiltIn.java  |   8 +-
 .../freemarker/core/BuiltInsForStringsBasic.java   | 141 ++++
 .../org/apache/freemarker/core/Configuration.java  |  25 +-
 .../org/apache/freemarker/core/Environment.java    |   6 +
 .../core/MutableProcessingConfiguration.java       |  80 ++-
 .../freemarker/core/ProcessingConfiguration.java   |  20 +
 .../java/org/apache/freemarker/core/Template.java  |  11 +
 .../freemarker/core/TemplateConfiguration.java     |  25 +
 .../core/_ObjectBuilderSettingEvaluator.java       |   3 +
 .../pluggablebuiltin/TruncateBuiltinAlgorithm.java | 126 ++++
 .../impl/DefaultTruncateBuiltinAlgorithm.java      | 755 +++++++++++++++++++++
 15 files changed, 2067 insertions(+), 4 deletions(-)

diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/MutableParsingAndProcessingConfigurationTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/MutableParsingAndProcessingConfigurationTest.java
index 56f8581..394e060 100644
--- a/freemarker-core-test/src/test/java/org/apache/freemarker/core/MutableParsingAndProcessingConfigurationTest.java
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/MutableParsingAndProcessingConfigurationTest.java
@@ -32,6 +32,7 @@ import java.util.TimeZone;
 import org.apache.freemarker.core.outputformat.impl.HTMLOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.UndefinedOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.XMLOutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.impl.DefaultTruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.util._DateUtils;
 import org.apache.freemarker.core.util._NullArgumentException;
 import org.junit.Test;
@@ -278,6 +279,67 @@ public class MutableParsingAndProcessingConfigurationTest {
         assertTrue(cfgB.isTimeZoneSet());
     }
 
+
+    public void testTruncateBuiltinAlgorithm() throws TemplateException {
+        Configuration.Builder cfgB = new Configuration.Builder(Configuration.VERSION_3_0_0);
+        assertSame(DefaultTruncateBuiltinAlgorithm.ASCII_INSTANCE, cfgB.getTruncateBuiltinAlgorithm());
+
+        cfgB.setSetting("truncateBuiltinAlgorithm", "unicodE");
+        assertSame(DefaultTruncateBuiltinAlgorithm.UNICODE_INSTANCE, cfgB.getTruncateBuiltinAlgorithm());
+
+        cfgB.setSetting("truncate_builtin_algorithm", "ASCII");
+        assertSame(DefaultTruncateBuiltinAlgorithm.ASCII_INSTANCE, cfgB.getTruncateBuiltinAlgorithm());
+
+        {
+            cfgB.setSetting("truncate_builtin_algorithm",
+                    "DefaultTruncateBuiltinAlgorithm('...', false)");
+            DefaultTruncateBuiltinAlgorithm alg =
+                    (DefaultTruncateBuiltinAlgorithm) cfgB.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());
+        }
+
+        {
+            cfgB.setSetting("truncate_builtin_algorithm",
+                    "DefaultTruncateBuiltinAlgorithm(" +
+                            "'...', " +
+                            "markup(HTMLOutputFormat(), '<span class=trunc>...</span>'), " +
+                            "true)");
+            DefaultTruncateBuiltinAlgorithm alg =
+                    (DefaultTruncateBuiltinAlgorithm) cfgB.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());
+        }
+
+        {
+            cfgB.setSetting("truncate_builtin_algorithm",
+                    "DefaultTruncateBuiltinAlgorithm(" +
+                            "DefaultTruncateBuiltinAlgorithm.STANDARD_ASCII_TERMINATOR, null, null, " +
+                            "DefaultTruncateBuiltinAlgorithm.STANDARD_M_TERMINATOR, null, null, " +
+                            "true, 0.5)");
+            DefaultTruncateBuiltinAlgorithm alg =
+                    (DefaultTruncateBuiltinAlgorithm) cfgB.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(), 0);
+        }
+    }
+
     // ------------
 
     @Test
diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java
index 145e347..ea6477b 100644
--- a/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/TemplateConfigurationTest.java
@@ -50,6 +50,7 @@ import org.apache.freemarker.core.model.impl.RestrictedObjectWrapper;
 import org.apache.freemarker.core.outputformat.impl.HTMLOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.UndefinedOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.XMLOutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.impl.DefaultTruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.templateresolver.ConditionalTemplateConfigurationFactory;
 import org.apache.freemarker.core.templateresolver.FileExtensionMatcher;
 import org.apache.freemarker.core.templateresolver.FileNameGlobMatcher;
@@ -188,6 +189,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("templateLanguage", UnparsedTemplateLanguage.F3UU);
diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/TruncateBuiltInTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/TruncateBuiltInTest.java
new file mode 100644
index 0000000..33ddd3f
--- /dev/null
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/TruncateBuiltInTest.java
@@ -0,0 +1,135 @@
+package org.apache.freemarker.core;
+
+import java.io.IOException;
+
+import org.apache.freemarker.core.outputformat.impl.HTMLOutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.impl.DefaultTruncateBuiltinAlgorithm;
+import org.apache.freemarker.test.TemplateTest;
+import org.apache.freemarker.test.TestConfigurationBuilder;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TruncateBuiltInTest extends TemplateTest {
+
+    private static final String M_TERM_SRC = "<span class=trunc>&hellips;</span>";
+
+    @Override
+    protected Configuration createDefaultConfiguration() throws Exception {
+        return createConfigurationBuilder().build();
+    }
+
+    private TestConfigurationBuilder createConfigurationBuilder() {
+        return new TestConfigurationBuilder().outputFormat(HTMLOutputFormat.INSTANCE);
+    }
+
+    @Before
+    public void setup() throws TemplateException {
+        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)}", "2nd", "string", "markupOutput");
+        assertErrorContains("${t?truncate(-1)}", "1st", "at least 0");
+        assertErrorContains("${t?truncate(200, 'x', -1)}", "3rd", "at least 0");
+    }
+
+    @Test
+    public void testTruncateM() throws IOException, TemplateException {
+        assertOutput("${t?truncateM(15)}", "Some text <span class='truncateTerminator'>[&#8230;]</span>"); // String arg allowed...
+        assertOutput("${t?truncateM(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?truncateC(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)}", "2nd", "string", "markupOutput");
+
+        assertOutput("${t?truncateC(20)?isMarkupOutput?c}", "false");
+    }
+
+    @Test
+    public void testTruncateCM() throws IOException, TemplateException {
+        assertOutput("${t?truncateCM(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?truncateW(20)}", "Some text for [...]");
+        assertOutput("${u?truncateW(20)}", "[...]");  // Proof of no fallback to C
+
+        assertErrorContains("${t?truncateW(200, mTerm)}", "2nd", "string", "markupOutput");
+
+        assertOutput("${t?truncateW(20)?isMarkupOutput?c}", "false");
+        assertOutput("${t?truncateW(20, '|')?isMarkupOutput?c}", "false");
+    }
+
+    @Test
+    public void testTruncateWM() throws IOException, TemplateException {
+        assertOutput("${t?truncateWM(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[...]");
+        setConfiguration(createConfigurationBuilder().truncateBuiltinAlgorithm(DefaultTruncateBuiltinAlgorithm.UNICODE_INSTANCE).build());
+        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>");
+        setConfiguration(createConfigurationBuilder().truncateBuiltinAlgorithm(
+                new DefaultTruncateBuiltinAlgorithm("|...", HTMLOutputFormat.INSTANCE.fromMarkup(M_TERM_SRC), true))
+                .build());
+        assertOutput("${t?truncate(20)}", "Some text for |...");
+        assertOutput("${t?truncateM(20)}", "Some text for " + M_TERM_SRC);
+    }
+
+}
diff --git a/freemarker-core-test/src/test/java/org/apache/freemarker/core/pluggablebuiltin/impl/DefaultTruncateBuiltinAlgorithmTest.java b/freemarker-core-test/src/test/java/org/apache/freemarker/core/pluggablebuiltin/impl/DefaultTruncateBuiltinAlgorithmTest.java
new file mode 100644
index 0000000..f567825
--- /dev/null
+++ b/freemarker-core-test/src/test/java/org/apache/freemarker/core/pluggablebuiltin/impl/DefaultTruncateBuiltinAlgorithmTest.java
@@ -0,0 +1,672 @@
+/*
+ * 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 org.apache.freemarker.core.pluggablebuiltin.impl;
+
+import static org.apache.freemarker.core.pluggablebuiltin.impl.DefaultTruncateBuiltinAlgorithm.*;
+import static org.hamcrest.CoreMatchers.*;
+import static org.junit.Assert.*;
+
+import org.apache.freemarker.core.Configuration;
+import org.apache.freemarker.core.Environment;
+import org.apache.freemarker.core.Template;
+import org.apache.freemarker.core.TemplateException;
+import org.apache.freemarker.core.model.TemplateMarkupOutputModel;
+import org.apache.freemarker.core.model.TemplateModel;
+import org.apache.freemarker.core.model.TemplateStringModel;
+import org.apache.freemarker.core.model.impl.SimpleNumber;
+import org.apache.freemarker.core.model.impl.SimpleString;
+import org.apache.freemarker.core.outputformat.impl.HTMLOutputFormat;
+import org.apache.freemarker.core.outputformat.impl.TemplateHTMLOutputModel;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
+import org.junit.Test;
+
+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 (TemplateException 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 SimpleString("."), 1, env);
+
+        try {
+            ASCII_INSTANCE.truncate("", -1, new SimpleString("."), 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 SimpleString("."), -1, env);
+            fail();
+        } catch (IllegalArgumentException e) {
+            assertThat(e.getMessage(), containsString("terminatorLength"));
+        }
+    }
+
+    private Environment createEnvironment() {
+        try {
+            return new Template("", "", new Configuration.Builder(Configuration.VERSION_3_0_0).build())
+                    .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(TemplateStringModel.class));
+                    assertEquals(alg.getDefaultTerminator(), ((TemplateStringModel) truncated).getAsString());
+                }
+                SimpleString stringTerminator = new SimpleString("|");
+                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 {
+            TemplateStringModel 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 {
+            TemplateStringModel 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 {
+            TemplateStringModel 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();
+        TemplateStringModel ellipsis = new SimpleString("\u2026");
+        TemplateStringModel squEllipsis = new SimpleString("[\u2026]");
+
+        // Length detection
+        {
+            TemplateStringModel actual = ASCII_INSTANCE.truncate("abcd", 3, ellipsis, null, env);
+            assertEquals("ab\u2026", actual.getAsString());
+        }
+        {
+            TemplateStringModel actual = ASCII_INSTANCE.truncate("abcdef", 5, squEllipsis, null, env);
+            assertEquals("ab[\u2026]", actual.getAsString());
+        }
+        {
+            TemplateStringModel actual = ASCII_INSTANCE.truncate("abcdef", 5, squEllipsis, 1, env);
+            assertEquals("abcd[\u2026]", actual.getAsString());
+        }
+
+        // Dot removal
+        {
+            TemplateStringModel actual = ASCII_INSTANCE.truncate("a.cd", 3, ellipsis, null, env);
+            assertEquals("a\u2026", actual.getAsString());
+        }
+        {
+            TemplateStringModel actual = ASCII_INSTANCE.truncate("a.cdef", 5, squEllipsis, null, env);
+            assertEquals("a.[\u2026]", actual.getAsString());
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/ASTExpBuiltIn.java b/freemarker-core/src/main/java/org/apache/freemarker/core/ASTExpBuiltIn.java
index 6324cf4..958be29 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/ASTExpBuiltIn.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/ASTExpBuiltIn.java
@@ -75,7 +75,7 @@ abstract class ASTExpBuiltIn extends ASTExpression implements Cloneable {
     protected ASTExpression target;
     protected String key;
 
-    static final int NUMBER_OF_BIS = 265;
+    static final int NUMBER_OF_BIS = 271;
     static final HashMap<String, ASTExpBuiltIn> BUILT_INS_BY_NAME = new HashMap(NUMBER_OF_BIS * 3 / 2 + 1, 1f);
 
     static {
@@ -264,6 +264,12 @@ abstract class ASTExpBuiltIn extends ASTExpression implements Cloneable {
         putBI("time", new BuiltInsForMultipleTypes.dateBI(TemplateDateModel.TIME));
         putBI("timeIfUnknown", new BuiltInsForDates.dateType_if_unknownBI(TemplateDateModel.TIME));
         putBI("trim", new BuiltInsForStringsBasic.trimBI());
+        putBI("truncate", new BuiltInsForStringsBasic.truncateBI());
+        putBI("truncateW", new BuiltInsForStringsBasic.truncate_wBI());
+        putBI("truncateC", new BuiltInsForStringsBasic.truncate_cBI());
+        putBI("truncateM", new BuiltInsForStringsBasic.truncate_mBI());
+        putBI("truncateWM", new BuiltInsForStringsBasic.truncate_w_mBI());
+        putBI("truncateCM", new BuiltInsForStringsBasic.truncate_c_mBI());
         putBI("uncapFirst", new BuiltInsForStringsBasic.uncap_firstBI());
         putBI("upperAbc", new BuiltInsForNumbers.upper_abcBI());
         putBI("upperCase", new BuiltInsForStringsBasic.upper_caseBI());
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/BuiltInsForStringsBasic.java b/freemarker-core/src/main/java/org/apache/freemarker/core/BuiltInsForStringsBasic.java
index 1a5f1be..13f7327 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/BuiltInsForStringsBasic.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/BuiltInsForStringsBasic.java
@@ -29,10 +29,12 @@ import java.util.regex.Pattern;
 import org.apache.freemarker.core.model.ArgumentArrayLayout;
 import org.apache.freemarker.core.model.TemplateBooleanModel;
 import org.apache.freemarker.core.model.TemplateFunctionModel;
+import org.apache.freemarker.core.model.TemplateMarkupOutputModel;
 import org.apache.freemarker.core.model.TemplateModel;
 import org.apache.freemarker.core.model.TemplateStringModel;
 import org.apache.freemarker.core.model.impl.SimpleNumber;
 import org.apache.freemarker.core.model.impl.SimpleString;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.util.CallableUtils;
 import org.apache.freemarker.core.util._StringUtils;
 
@@ -745,6 +747,145 @@ class BuiltInsForStringsBasic {
         }
     }
 
+    static abstract class AbstractTruncateBI extends BuiltInForString {
+        @Override
+        TemplateModel calculateResult(final String s, final Environment env) {
+            return new TemplateFunctionModel() {
+
+                @Override
+                public TemplateModel execute(TemplateModel[] args, CallPlace callPlace, Environment env) throws
+                        TemplateException {
+                    int maxLength = CallableUtils.getIntArgument(args, 0, this);
+                    if (maxLength < 0) {
+                        throw newArgumentValueException(
+                                0, "must be at least 0, but was " + maxLength + ".", this);
+                    }
+
+                    TemplateModel terminator = args[1];
+                    Integer terminatorLength;
+                    if (terminator != null) {
+                        if (!(terminator instanceof  TemplateStringModel)) {
+                            if (allowMarkupTerminator()) {
+                                if (!(terminator instanceof TemplateMarkupOutputModel)) {
+                                    throw newArgumentValueTypeException(
+                                            terminator, 1,
+                                            new Class[] { TemplateStringModel.class, TemplateMarkupOutputModel.class },
+                                            "string or markup", this);
+                                }
+                            } else {
+                                throw newArgumentValueTypeException(terminator, 1, TemplateStringModel.class, this);
+                            }
+                        }
+
+                        terminatorLength = CallableUtils.getOptionalIntArgument(args, 2, this);
+                        if (terminatorLength != null && terminatorLength < 0) {
+                            throw newArgumentValueException(
+                                    2, "must be at least 0, but was " + terminatorLength + ".", this);
+                        }
+                    } else {
+                        terminatorLength = null;
+                    }
+                    try {
+                        TruncateBuiltinAlgorithm algorithm = env.getTruncateBuiltinAlgorithm();
+                        return truncate(algorithm, s, maxLength, terminator, terminatorLength, env);
+                    } catch (TemplateException e) {
+                        throw new TemplateException(e, env, "Truncation failed; see cause exception");
+                    }
+                }
+
+                @Override
+                public ArgumentArrayLayout getFunctionArgumentArrayLayout() {
+                    return ArgumentArrayLayout.THREE_POSITIONAL_PARAMETERS;
+                }
+            };
+        }
+
+        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, (TemplateStringModel) 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, (TemplateStringModel) 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, (TemplateStringModel) 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/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java b/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java
index 9a9d13b..03a8a55 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/Configuration.java
@@ -64,6 +64,8 @@ import org.apache.freemarker.core.outputformat.impl.RTFOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.UndefinedOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.XHTMLOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.XMLOutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
+import org.apache.freemarker.core.pluggablebuiltin.impl.DefaultTruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.templateresolver.CacheStorage;
 import org.apache.freemarker.core.templateresolver.GetTemplateResult;
 import org.apache.freemarker.core.templateresolver.MalformedTemplateNameException;
@@ -211,6 +213,7 @@ public final class Configuration implements TopLevelConfiguration, CustomStateSc
     private final TemplateClassResolver newBuiltinClassResolver;
     private final Boolean showErrorTips;
     private final Boolean apiBuiltinEnabled;
+    private final TruncateBuiltinAlgorithm truncateBuiltinAlgorithm;
     private final Map<String, TemplateDateFormatFactory> customDateFormats;
     private final Map<String, TemplateNumberFormatFactory> customNumberFormats;
     private final Map<String, String> autoImports;
@@ -374,6 +377,7 @@ public final class Configuration implements TopLevelConfiguration, CustomStateSc
         newBuiltinClassResolver = builder.getNewBuiltinClassResolver();
         showErrorTips = builder.getShowErrorTips();
         apiBuiltinEnabled = builder.getAPIBuiltinEnabled();
+        truncateBuiltinAlgorithm = builder.getTruncateBuiltinAlgorithm();
         customDateFormats = _CollectionUtils.mergeImmutableMaps(
                 builder.getImpliedCustomDateFormats(), builder.getCustomDateFormats(), false);
         customNumberFormats = _CollectionUtils.mergeImmutableMaps(
@@ -1116,6 +1120,20 @@ public final class Configuration implements TopLevelConfiguration, CustomStateSc
      * value in the {@link Configuration}.
      */
     @Override
+    public boolean isTruncateBuiltinAlgorithmSet() {
+        return true;
+    }
+
+    @Override
+    public TruncateBuiltinAlgorithm getTruncateBuiltinAlgorithm() {
+        return truncateBuiltinAlgorithm;
+    }
+
+    /**
+     * Always {@code true} in {@link Configuration}-s; even if this setting wasn't set in the builder, it gets a default
+     * value in the {@link Configuration}.
+     */
+    @Override
     public boolean isAPIBuiltinEnabledSet() {
         return true;
     }
@@ -1207,7 +1225,7 @@ public final class Configuration implements TopLevelConfiguration, CustomStateSc
     /**
      * {@inheritDoc}
      * <p>
-     * Because {@link Configuration} has on parent, the {@code includeInherited} parameter is ignored.
+     * Because {@link Configuration} has no parent, the {@code includeInherited} parameter is ignored.
      */
     @Override
     public Map<Serializable, Object> getCustomSettings(boolean includeInherited) {
@@ -2658,6 +2676,11 @@ public final class Configuration implements TopLevelConfiguration, CustomStateSc
         }
 
         @Override
+        protected TruncateBuiltinAlgorithm getDefaultTruncateBuiltinAlgorithm() {
+            return DefaultTruncateBuiltinAlgorithm.ASCII_INSTANCE;
+        }
+
+        @Override
         protected boolean getDefaultLazyImports() {
             return false;
         }
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java b/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java
index 637901e..e6a91b6 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/Environment.java
@@ -65,6 +65,7 @@ import org.apache.freemarker.core.model.TemplateStringModel;
 import org.apache.freemarker.core.model.impl.SimpleHash;
 import org.apache.freemarker.core.outputformat.MarkupOutputFormat;
 import org.apache.freemarker.core.outputformat.OutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.templateresolver.MalformedTemplateNameException;
 import org.apache.freemarker.core.templateresolver.TemplateResolver;
 import org.apache.freemarker.core.templateresolver.impl.DefaultTemplateNameFormat;
@@ -885,6 +886,11 @@ public final class Environment extends MutableProcessingConfiguration<Environmen
     }
 
     @Override
+    protected TruncateBuiltinAlgorithm getDefaultTruncateBuiltinAlgorithm() {
+        return getMainTemplate().getTruncateBuiltinAlgorithm();
+    }
+    
+    @Override
     protected boolean getDefaultLazyImports() {
         return getMainTemplate().getLazyImports();
     }
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java b/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java
index ab58924..d959251 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/MutableProcessingConfiguration.java
@@ -50,6 +50,8 @@ import org.apache.freemarker.core.outputformat.impl.PlainTextOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.RTFOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.UndefinedOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.XMLOutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
+import org.apache.freemarker.core.pluggablebuiltin.impl.DefaultTruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.templateresolver.AndMatcher;
 import org.apache.freemarker.core.templateresolver.ConditionalTemplateConfigurationFactory;
 import org.apache.freemarker.core.templateresolver.FileNameGlobMatcher;
@@ -102,6 +104,7 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
     public static final String NEW_BUILTIN_CLASS_RESOLVER_KEY = "newBuiltinClassResolver";
     public static final String SHOW_ERROR_TIPS_KEY = "showErrorTips";
     public static final String API_BUILTIN_ENABLED_KEY = "apiBuiltinEnabled";
+    public static final String TRUNCATE_BUILTIN_ALGORITHM_KEY = "truncateBuiltinAlgorithm";
     public static final String LAZY_IMPORTS_KEY = "lazyImports";
     public static final String LAZY_AUTO_IMPORTS_KEY = "lazyAutoImports";
     public static final String AUTO_IMPORTS_KEY = "autoImports";
@@ -131,6 +134,7 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
         TEMPLATE_EXCEPTION_HANDLER_KEY,
         TIME_FORMAT_KEY,
         TIME_ZONE_KEY,
+        TRUNCATE_BUILTIN_ALGORITHM_KEY,
         URL_ESCAPING_CHARSET_KEY
     );
     
@@ -154,6 +158,7 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
     private TemplateClassResolver newBuiltinClassResolver;
     private Boolean showErrorTips;
     private Boolean apiBuiltinEnabled;
+    private TruncateBuiltinAlgorithm truncateBuiltinAlgorithm;
     private Map<String, TemplateDateFormatFactory> customDateFormats;
     private Map<String, TemplateNumberFormatFactory> customNumberFormats;
     private Map<String, String> autoImports;
@@ -1049,6 +1054,46 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
         return apiBuiltinEnabled != null;
     }
 
+    /**
+     * Setter pair of {@link #getTruncateBuiltinAlgorithm()}
+     */
+    public void setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm value) {
+        _NullArgumentException.check("value", value);
+        truncateBuiltinAlgorithm = value;
+    }
+
+    /**
+     * Fluent API equivalent of {@link #setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm)}
+     */
+    public SelfT truncateBuiltinAlgorithm(TruncateBuiltinAlgorithm value) {
+        setTruncateBuiltinAlgorithm(value);
+        return self();
+    }
+
+    /**
+     * Resets the setting value as if it was never set (but it doesn't affect the value inherited from another
+     * {@link ProcessingConfiguration}).
+     */
+    public void unsetTruncateBuiltinAlgorithm() {
+        truncateBuiltinAlgorithm = null;
+    }
+
+    @Override
+    public TruncateBuiltinAlgorithm getTruncateBuiltinAlgorithm() {
+        return isTruncateBuiltinAlgorithmSet() ? truncateBuiltinAlgorithm : getDefaultTruncateBuiltinAlgorithm();
+    }
+
+    /**
+     * Returns the value the getter method returns when the setting is not set (possibly by inheriting the setting value
+     * from another {@link ProcessingConfiguration}), or throws {@link CoreSettingValueNotSetException}.
+     */
+    protected abstract TruncateBuiltinAlgorithm getDefaultTruncateBuiltinAlgorithm();
+
+    @Override
+    public boolean isTruncateBuiltinAlgorithmSet() {
+        return truncateBuiltinAlgorithm != null;
+    }
+
     @Override
     public boolean getLazyImports() {
          return isLazyImportsSet() ? lazyImports : getDefaultLazyImports();
@@ -1445,7 +1490,27 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
      *       See {@link #setAPIBuiltinEnabled(boolean)}.
      *       Since 2.3.22.
      *       <br>String value: {@code "true"}, {@code "false"}, {@code "y"},  etc.
-     *       
+     *
+     *   <li><p>{@code "truncateBuiltinAlgorithm"}:
+     *       See {@link #setTruncateBuiltinAlgorithm(TruncateBuiltinAlgorithm)}.
+     *       <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>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 MutableProcessingConfiguration}) also understands these:</p>
@@ -1622,7 +1687,8 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
      *     {@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 TemplateLanguage}, {@link TagSyntax}.
+     *     {@link UndefinedOutputFormat}, {@link Configuration}, {@link TemplateLanguage}, {@link TagSyntax},
+     *     {@link DefaultTruncateBuiltinAlgorithm}.
      *   </li>
      *   <li>
      *     <p>{@link TimeZone} objects can be created like {@code TimeZone("UTC")}, despite that there's no a such
@@ -1759,6 +1825,16 @@ public abstract class MutableProcessingConfiguration<SelfT extends MutableProces
                 setShowErrorTips(_StringUtils.getYesNo(value));
             } else if (API_BUILTIN_ENABLED_KEY.equals(name)) {
                 setAPIBuiltinEnabled(_StringUtils.getYesNo(value));
+            } else if (TRUNCATE_BUILTIN_ALGORITHM_KEY.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.equals(name)) {
                 if ("unrestricted".equals(value)) {
                     setNewBuiltinClassResolver(TemplateClassResolver.UNRESTRICTED);
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java b/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java
index 7e1a164..add8387 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/ProcessingConfiguration.java
@@ -28,10 +28,13 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Properties;
 import java.util.TimeZone;
 
 import org.apache.freemarker.core.arithmetic.ArithmeticEngine;
 import org.apache.freemarker.core.arithmetic.impl.BigDecimalArithmeticEngine;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
+import org.apache.freemarker.core.pluggablebuiltin.impl.DefaultTruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.valueformat.TemplateDateFormatFactory;
 import org.apache.freemarker.core.valueformat.TemplateNumberFormat;
 import org.apache.freemarker.core.valueformat.TemplateNumberFormatFactory;
@@ -751,4 +754,21 @@ public interface ProcessingConfiguration {
      */
     Map<Serializable, Object> getCustomSettings(boolean includeInherited);
 
+    /**
+     * 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 MutableProcessingConfiguration#setSetting(String, String)}.
+     */
+    public TruncateBuiltinAlgorithm getTruncateBuiltinAlgorithm();
+
+    /**
+     * Tells if this setting is set directly in this object. If not, then depending on the implementing class, reading
+     * the setting mights returns a default value, or returns the value of the setting from a parent object, or throws
+     * an {@link CoreSettingValueNotSetException}.
+     */
+    public boolean isTruncateBuiltinAlgorithmSet();
+
 }
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/Template.java b/freemarker-core/src/main/java/org/apache/freemarker/core/Template.java
index c184281..9f6a7b7 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/Template.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/Template.java
@@ -52,6 +52,7 @@ import org.apache.freemarker.core.model.TemplateModel;
 import org.apache.freemarker.core.model.TemplateNodeModel;
 import org.apache.freemarker.core.model.impl.SimpleHash;
 import org.apache.freemarker.core.outputformat.OutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.templateresolver.TemplateLoader;
 import org.apache.freemarker.core.templateresolver.TemplateLookupStrategy;
 import org.apache.freemarker.core.templateresolver.impl.DefaultTemplateResolver;
@@ -1046,6 +1047,16 @@ public class Template implements ProcessingConfiguration, CustomStateScope {
     }
 
     @Override
+    public TruncateBuiltinAlgorithm getTruncateBuiltinAlgorithm() {
+        return tCfg != null && tCfg.isTruncateBuiltinAlgorithmSet() ? tCfg.getTruncateBuiltinAlgorithm() : cfg.getTruncateBuiltinAlgorithm();
+    }
+
+    @Override
+    public boolean isTruncateBuiltinAlgorithmSet() {
+        return tCfg != null && tCfg.isTruncateBuiltinAlgorithmSet();
+    }
+
+    @Override
     public boolean getAutoFlush() {
         return tCfg != null && tCfg.isAutoFlushSet() ? tCfg.getAutoFlush() : cfg.getAutoFlush();
     }
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/TemplateConfiguration.java b/freemarker-core/src/main/java/org/apache/freemarker/core/TemplateConfiguration.java
index 1b1b9a9..3107890 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/TemplateConfiguration.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/TemplateConfiguration.java
@@ -28,6 +28,7 @@ import java.util.TimeZone;
 
 import org.apache.freemarker.core.arithmetic.ArithmeticEngine;
 import org.apache.freemarker.core.outputformat.OutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.util.CommonBuilder;
 import org.apache.freemarker.core.util._CollectionUtils;
 import org.apache.freemarker.core.valueformat.TemplateDateFormatFactory;
@@ -73,6 +74,7 @@ public final class TemplateConfiguration implements ParsingAndProcessingConfigur
     private final TemplateClassResolver newBuiltinClassResolver;
     private final Boolean showErrorTips;
     private final Boolean apiBuiltinEnabled;
+    private final TruncateBuiltinAlgorithm truncateBuiltinAlgorithm;
     private final Map<String, TemplateDateFormatFactory> customDateFormats;
     private final Map<String, TemplateNumberFormatFactory> customNumberFormats;
     private final Map<String, String> autoImports;
@@ -113,6 +115,8 @@ public final class TemplateConfiguration implements ParsingAndProcessingConfigur
         newBuiltinClassResolver = builder.isNewBuiltinClassResolverSet() ? builder.getNewBuiltinClassResolver() : null;
         showErrorTips = builder.isShowErrorTipsSet() ? builder.getShowErrorTips() : null;
         apiBuiltinEnabled = builder.isAPIBuiltinEnabledSet() ? builder.getAPIBuiltinEnabled() : null;
+        truncateBuiltinAlgorithm = builder.isTruncateBuiltinAlgorithmSet() ? builder.getTruncateBuiltinAlgorithm()
+                : null;
         customDateFormats = builder.isCustomDateFormatsSet() ? builder.getCustomDateFormats() : null;
         customNumberFormats = builder.isCustomNumberFormatsSet() ? builder.getCustomNumberFormats() : null;
         autoImports = builder.isAutoImportsSet() ? builder.getAutoImports() : null;
@@ -474,6 +478,19 @@ public final class TemplateConfiguration implements ParsingAndProcessingConfigur
     }
 
     @Override
+    public TruncateBuiltinAlgorithm getTruncateBuiltinAlgorithm() {
+        if (!isTruncateBuiltinAlgorithmSet()) {
+            throw new CoreSettingValueNotSetException("truncateBuiltinAlgorithm");
+        }
+        return truncateBuiltinAlgorithm;
+    }
+
+    @Override
+    public boolean isTruncateBuiltinAlgorithmSet() {
+        return truncateBuiltinAlgorithm != null;
+    }
+
+    @Override
     public boolean getAutoFlush() {
         if (!isAutoFlushSet()) {
             throw new CoreSettingValueNotSetException("autoFlush");
@@ -709,6 +726,11 @@ public final class TemplateConfiguration implements ParsingAndProcessingConfigur
         }
 
         @Override
+        protected TruncateBuiltinAlgorithm getDefaultTruncateBuiltinAlgorithm() {
+            throw new CoreSettingValueNotSetException("truncateBuiltinAlgorithm");
+        }
+        
+        @Override
         protected boolean getDefaultLazyImports() {
             throw new CoreSettingValueNotSetException("lazyImports");
         }
@@ -790,6 +812,9 @@ public final class TemplateConfiguration implements ParsingAndProcessingConfigur
             if (tc.isNewBuiltinClassResolverSet()) {
                 setNewBuiltinClassResolver(tc.getNewBuiltinClassResolver());
             }
+            if (tc.isTruncateBuiltinAlgorithmSet()) {
+                setTruncateBuiltinAlgorithm(tc.getTruncateBuiltinAlgorithm());
+            }
             if (tc.isNumberFormatSet()) {
                 setNumberFormat(tc.getNumberFormat());
             }
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/_ObjectBuilderSettingEvaluator.java b/freemarker-core/src/main/java/org/apache/freemarker/core/_ObjectBuilderSettingEvaluator.java
index 4377372..ec52865 100644
--- a/freemarker-core/src/main/java/org/apache/freemarker/core/_ObjectBuilderSettingEvaluator.java
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/_ObjectBuilderSettingEvaluator.java
@@ -47,6 +47,7 @@ import org.apache.freemarker.core.outputformat.impl.RTFOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.UndefinedOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.XHTMLOutputFormat;
 import org.apache.freemarker.core.outputformat.impl.XMLOutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.impl.DefaultTruncateBuiltinAlgorithm;
 import org.apache.freemarker.core.templateresolver.AndMatcher;
 import org.apache.freemarker.core.templateresolver.ConditionalTemplateConfigurationFactory;
 import org.apache.freemarker.core.templateresolver.FileExtensionMatcher;
@@ -682,6 +683,8 @@ public class _ObjectBuilderSettingEvaluator {
             addWithSimpleName(SHORTHANDS, PlainTextOutputFormat.class);
             addWithSimpleName(SHORTHANDS, UndefinedOutputFormat.class);
 
+            addWithSimpleName(SHORTHANDS, DefaultTruncateBuiltinAlgorithm.class);
+
             addWithSimpleName(SHORTHANDS, TemplateLanguage.class);
 
             addWithSimpleName(SHORTHANDS, Locale.class);
diff --git a/freemarker-core/src/main/java/org/apache/freemarker/core/pluggablebuiltin/TruncateBuiltinAlgorithm.java b/freemarker-core/src/main/java/org/apache/freemarker/core/pluggablebuiltin/TruncateBuiltinAlgorithm.java
new file mode 100644
index 0000000..b142649
--- /dev/null
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/pluggablebuiltin/TruncateBuiltinAlgorithm.java
@@ -0,0 +1,126 @@
+package org.apache.freemarker.core.pluggablebuiltin;
+
+import org.apache.freemarker.core.Environment;
+import org.apache.freemarker.core.TemplateException;
+import org.apache.freemarker.core.model.TemplateMarkupOutputModel;
+import org.apache.freemarker.core.model.TemplateModel;
+import org.apache.freemarker.core.model.TemplateStringModel;
+
+/**
+ * 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 TemplateStringModel}) 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 TemplateStringModel}. 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 TemplateStringModel} (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 TemplateStringModel truncate(
+            String s, int maxLength, TemplateStringModel 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 TemplateStringModel truncateW(
+            String s, int maxLength, TemplateStringModel 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 TemplateStringModel truncateC(
+            String s, int maxLength, TemplateStringModel 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/freemarker-core/src/main/java/org/apache/freemarker/core/pluggablebuiltin/impl/DefaultTruncateBuiltinAlgorithm.java b/freemarker-core/src/main/java/org/apache/freemarker/core/pluggablebuiltin/impl/DefaultTruncateBuiltinAlgorithm.java
new file mode 100644
index 0000000..c6ad312
--- /dev/null
+++ b/freemarker-core/src/main/java/org/apache/freemarker/core/pluggablebuiltin/impl/DefaultTruncateBuiltinAlgorithm.java
@@ -0,0 +1,755 @@
+package org.apache.freemarker.core.pluggablebuiltin.impl;
+
+import org.apache.freemarker.core.Environment;
+import org.apache.freemarker.core.ProcessingConfiguration;
+import org.apache.freemarker.core.TemplateException;
+import org.apache.freemarker.core.model.TemplateMarkupOutputModel;
+import org.apache.freemarker.core.model.TemplateModel;
+import org.apache.freemarker.core.model.TemplateStringModel;
+import org.apache.freemarker.core.model.impl.SimpleString;
+import org.apache.freemarker.core.outputformat.MarkupOutputFormat;
+import org.apache.freemarker.core.outputformat.impl.HTMLOutputFormat;
+import org.apache.freemarker.core.outputformat.impl.TemplateHTMLOutputModel;
+import org.apache.freemarker.core.outputformat.impl.XMLOutputFormat;
+import org.apache.freemarker.core.pluggablebuiltin.TruncateBuiltinAlgorithm;
+import org.apache.freemarker.core.util.TemplateLanguageUtils;
+import org.apache.freemarker.core.util._NullArgumentException;
+
+/**
+ * The default {@link TruncateBuiltinAlgorithm} implementation; see
+ * {@link ProcessingConfiguration#getTruncateBuiltinAlgorithm()}.
+ * 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.
+ */
+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 (TemplateException 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 ProcessingConfiguration#getTruncateBuiltinAlgorithm()}.
+     * 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 TemplateStringModel 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 SimpleString(defaultTerminator);
+        try {
+            this.defaultTerminatorLength = defaultTerminatorLength != null ? defaultTerminatorLength
+                    : defaultTerminator.length();
+
+            this.defaultTerminatorRemovesDots = defaultTerminatorRemovesDots != null ? defaultTerminatorRemovesDots
+                    : getTerminatorRemovesDots(defaultTerminator);
+        } catch (TemplateException 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 (TemplateException 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 TemplateStringModel truncate(
+            String s, int maxLength,
+            TemplateStringModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException {
+        return (TemplateStringModel) unifiedTruncate(
+                s, maxLength, terminator, terminatorLength,
+                TruncationMode.AUTO, false);
+    }
+
+    @Override
+    public TemplateStringModel truncateW(
+            String s, int maxLength,
+            TemplateStringModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException {
+        return (TemplateStringModel) unifiedTruncate(
+                s, maxLength, terminator, terminatorLength,
+                TruncationMode.WORD_BOUNDARY, false);
+    }
+
+    @Override
+    public TemplateStringModel truncateC(
+            String s, int maxLength,
+            TemplateStringModel terminator, Integer terminatorLength,
+            Environment env) throws TemplateException {
+        return (TemplateStringModel) 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 (TemplateException 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 TemplateException {
+        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 TemplateStringModel} or {@link TemplateMarkupOutputModel}. Not {@code null}.
+     */
+    protected boolean getTerminatorRemovesDots(String terminator) throws TemplateException {
+        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 TemplateException {
+        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 SimpleString(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 TemplateStringModel) {
+            truncatedS.append(((TemplateStringModel) terminator).getAsString());
+            return new SimpleString(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: "
+                    + TemplateLanguageUtils.getTypeDescription(terminator));
+        }
+    }
+
+    private StringBuilder unifiedTruncateWithoutTerminatorAdded(
+            String s, int maxLength,
+            TemplateModel terminator, int terminatorLength, Boolean terminatorRemovesDots,
+            TruncationMode mode) throws TemplateException {
+        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 TemplateException {
+        return terminator instanceof TemplateStringModel
+                ? ((TemplateStringModel) terminator).getAsString().length()
+                : getMTerminatorLength((TemplateMarkupOutputModel<?>) terminator);
+    }
+
+    private boolean getTerminatorRemovesDots(TemplateModel terminator) throws TemplateException {
+        return terminator instanceof TemplateStringModel
+                ? getTerminatorRemovesDots(((TemplateStringModel) 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;
+    }
+
+}