+ * The internal string buffer is bounded at 100k characters. If this write
+ * limit is reached, then a {@link SAXException} is thrown. The
+ * {@link #isWriteLimitReached(Throwable)} method can be used to detect this
+ * case.
+ */
+ public TruncateContentHandler() {
+ this(DEFAULT_LIMIT);
+ }
+
+ protected int getWriteLimit() {
+ return writeLimit;
+ }
+
+ protected boolean isWriteLimitReached() {
+ return writeLimit != NO_LIMIT && writeLimit < writeCount;
+ }
+
+ public boolean isCountingWithSpaces() {
+ return withSpaces;
+ }
+
+ public void setCountingWithSpaces(final boolean withSpaces) {
+ this.withSpaces = withSpaces;
+ }
+
+ public boolean isCountingWithAllSpaces() {
+ return withAllSpaces;
+ }
+
+ public void setCountingWithAllSpaces(final boolean withSpaces) {
+ this.withAllSpaces = withSpaces;
+ }
+
+ public boolean isWithSmartTruncation() {
+ return withSmartTruncation;
+ }
+
+ public void setWithSmartTruncation(final boolean smart) {
+ this.withSmartTruncation = smart;
+ }
+
+ public Unit getUnit() {
+ return unit;
+ }
+
+ public void setUnit(Unit unit) {
+ this.unit = unit;
+ }
+
+ /**
+ * Writes the given characters to the given character stream.
+ */
+ @Override
+ public void characters(final char[] ch, final int start, final int length)
+ throws SAXException {
+ if (unit == Unit.character) {
+ countingByCharacter(ch, start, length);
+ } else if (unit == Unit.codePoint) {
+ countingByCodePoint(ch, start, length);
+ } else {
+ countingByWord(ch, start, length);
+ }
+ }
+
+ private void countingByCharacter(final char[] ch, final int start, final int length)
+ throws SAXException {
+ if (writeLimit == NO_LIMIT) {
+ super.characters(ch, start, length);
+ final String origine = new String(Arrays.copyOfRange(ch, start, start + length));
+ final String stripped = isCountingWithSpaces() ? origine
+ : origine.replaceAll(SPACES_PATTERN, EMPTY_STRING);
+ writeCount += stripped.length();
+ return;
+ }
+
+ boolean reached = false;
+ int next = start;
+ for (int i = start; !reached && i < start + length; i++) {
+ next = i + 1;
+ if (isCountingWithSpaces() || ! isSpace(ch[i])) {
+ writeCount++;
+ if (writeCount >= writeLimit) reached = true;
+ }
+ }
+ final boolean reachedBeforeEnd = reached && (next < start + length);
+ if (reachedBeforeEnd && isWithSmartTruncation()
+ && ((!isSpace(ch[next - 1]) && !isSpace(ch[next])) || isSpace(ch[next - 1]))) {
+ final int smartLentgh = new String(
+ Arrays.copyOfRange(ch, start, next)).replaceFirst(TRUNCATED_LAST_WORD,
+ EMPTY_STRING).length();
+ next = start + smartLentgh;
+ }
+ super.characters(ch, start, next - start);
+ if (reachedBeforeEnd)
+ throw new WriteLimitReachedException("Your document contained more than " + writeLimit
+ + " Unicode unit points (characters), and so your requested limit has been"
+ + " reached. To receive the full text of the document,"
+ + " increase your limit. (Text up to the limit is" + " however available).", tag);
+
+ }
+
+ private void countingByCodePoint(final char[] ch, final int start, final int length) throws SAXException {
+ final String origine = new String(Arrays.copyOfRange(ch, start, start + length));
+ if (writeLimit == NO_LIMIT || length == 0) {
+ super.characters(ch, start, length);
+ final String stripped = isCountingWithSpaces() ? origine
+ : origine.replaceAll(SPACES_PATTERN, EMPTY_STRING);
+ writeCount += stripped.codePointCount(0, stripped.length());
+ return;
+ }
+
+ boolean reached = false;
+ int next = 0;
+ int current = 0;
+ for (int i = 0; ! reached && i < origine.codePointCount(0, origine.length()); i++) {
+ current = origine.offsetByCodePoints(0, i);
+ final int currentCodePoint = origine.codePointAt(current);
+ next = current + Character.charCount(currentCodePoint);
+ if (isCountingWithSpaces() || ! isSpace(currentCodePoint)) {
+ writeCount++;
+ if (writeCount >= writeLimit) reached = true;
+ }
+ }
+ final boolean reachedBeforeEnd = reached && (next < length);
+ if (reachedBeforeEnd
+ && isWithSmartTruncation()
+ && ((!isSpace(origine.codePointAt(current)) && !isSpace(origine.codePointAt(next)))
+ || isSpace(origine.codePointAt(current)))) {
+ final int smartLentgh = new String(
+ Arrays.copyOfRange(ch, start, start + next)).replaceFirst(TRUNCATED_LAST_WORD,
+ EMPTY_STRING).length();
+ next = smartLentgh;
+ }
+ super.characters(ch, start, next);
+ if (reachedBeforeEnd)
+ throw new WriteLimitReachedException("Your document contained more than " + writeLimit
+ + " Unicode code points (characters), and so your requested limit has been"
+ + " reached. To receive the full text of the document,"
+ + " increase your limit. (Text up to the limit is" + " however available).", tag);
+ }
+
+ private void countingByWord(final char[] ch, final int start, final int length)
+ throws SAXException {
+ final String origine = new String(Arrays.copyOfRange(ch, start, start + length));
+ if (writeLimit == NO_LIMIT || length == 0) {
+ super.characters(ch, start, length);
+ final String[] splitted = origine.split(SPACES_PATTERN);
+ if (splitted.length > 0) {
+ writeCount += splitted.length + (splitted[0].isEmpty() ? -1 : 0);
+ }
+ return;
+ }
+
+ final String[] splitted = origine.split(String.format(WITH_DELIMITER_SPLITTER_PATTERN, SPACE_PATTERN));
+ final StringBuilder result = new StringBuilder();
+ boolean reachedBeforeEnd = false;
+ for (int i = 0; ! reachedBeforeEnd && i < splitted.length; i++) {
+ final String token = splitted[i];
+ final int count = writeCount + (isSpace(token.codePointAt(0)) ? 0 : 1);
+ if (count > writeLimit) {
+ reachedBeforeEnd = true;
+ } else {
+ writeCount = count;
+ result.append(token);
+ }
+ }
+
+ final int size = !reachedBeforeEnd ? result.length()
+ : result.toString().replaceFirst(SPACES_TAIL, EMPTY_STRING).length();
+ super.characters(ch, start, size);
+ if (reachedBeforeEnd)
+ throw new WriteLimitReachedException("Your document contained more than " + writeLimit
+ + " words, and so your requested limit has been"
+ + " reached. To receive the full text of the document,"
+ + " increase your limit. (Text up to the limit is" + " however available).", tag);
+ }
+
+ @Override
+ public void ignorableWhitespace(final char[] ch, final int start, final int length)
+ throws SAXException {
+ final boolean counting = isCountingWithSpaces() && isCountingWithAllSpaces() && (getUnit() != Unit.word);
+ int writeCountInc = counting ? length : 0;
+ int next = length;
+ if (writeLimit == NO_LIMIT || !counting) {
+ super.ignorableWhitespace(ch, start, next);
+ writeCount += writeCountInc;
+ return;
+ }
+ final boolean reachedBeforeEnd = writeCount + writeCountInc > writeLimit;
+ if (reachedBeforeEnd) {
+ writeCountInc = writeLimit - writeCount;
+ next = writeCountInc;
+ }
+ writeCount += writeCountInc;
+ super.ignorableWhitespace(ch, start, next);
+ if (reachedBeforeEnd)
+ throw new WriteLimitReachedException("Your document contained more than " + writeLimit
+ + unit.name() + "s, and so your requested limit has been"
+ + " reached. To receive the full text of the document,"
+ + " increase your limit. (Text up to the limit is" + " however available).", tag);
+
+ }
+
+ /**
+ * Checks whether the given exception (or any of it's root causes) was
+ * thrown by this handler as a signal of reaching the write limit.
+ *
+ * @param t
+ * throwable
+ * @return true if the write limit was reached,
+ * false otherwise
+ */
+ public boolean isWriteLimitReached(final Throwable t) {
+ if (t instanceof WriteLimitReachedException) {
+ return tag.equals(((WriteLimitReachedException) t).tag);
+ } else {
+ return (t.getCause() != null) && isWriteLimitReached(t.getCause());
+ }
+ }
+
+ /**
+ * The exception used as a signal when the write limit has been reached.
+ */
+ private static class WriteLimitReachedException extends SAXException {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Serializable tag of the handler that caused this exception */
+ final Serializable tag;
+
+ WriteLimitReachedException(final String message, final Serializable tag) {
+ super(message);
+ this.tag = tag;
+ }
+
+ }
+
+ /*
+ * is equivalent to the regex [\\p{javaWhitespace}\\p{Z}] defined by SPACE_PATTERN_BASE
+ *
+ * \p{Z} will catch the no break spaces, i.e.: NO-BREAK SPACE (U+00A0),
+ * NARROW NO-BREAK SPACE (U+202F), ...
+ */
+ private static boolean isSpace(final char c) {
+ return Character.isWhitespace(c) || Character.isSpaceChar(c);
+ }
+
+ private static boolean isSpace(final int c) {
+ return Character.isWhitespace(c) || Character.isSpaceChar(c);
+ }
+
+}
diff --git a/src/main/java/org/jbake/app/excerpt/Truncator.java b/src/main/java/org/jbake/app/excerpt/Truncator.java
new file mode 100644
index 000000000..114197525
--- /dev/null
+++ b/src/main/java/org/jbake/app/excerpt/Truncator.java
@@ -0,0 +1,344 @@
+package org.jbake.app.excerpt;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static java.lang.String.format;
+import static org.jbake.app.excerpt.TruncateContentHandler.SPACE_PATTERN;
+import static org.jbake.app.excerpt.TruncateContentHandler.SPACE_PATTERN_BASE;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.sis.internal.jdk7.StandardCharsets; //java.nio.charset.StandardCharsets only since JDK 1.7
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.parser.AutoDetectParser;
+import org.apache.tika.parser.ParseContext;
+import org.jbake.app.excerpt.TruncateContentHandler.Unit;
+import org.xml.sax.SAXException;
+
+/**
+ * Create an excerpt of a HTML fragment.
+ *
+ * Wrap Tika HTML parser (based on Tagsoup) with TruncateContentHandler and
+ * ToXmlContentHandler.
+ *
+ * The generated excerpt is ready to be inserted as valid HTML in any document.
+ */
+public class Truncator {
+
+ private enum TruncationStatus {
+ unknown, truncated, unchanged
+ }
+
+ public final static int NO_LIMIT = TruncateContentHandler.NO_LIMIT;
+ public final static Unit DEFAULT_UNIT = Unit.word;
+ public final static String DEFAULT_ELLIPSIS = "...";
+ public final static String DEFAULT_READ_MORE = "";
+
+ private final static String HTML_CONTENT_TYPE = "text/html";
+ private final static String READ_MORE_TAG = "@@@readmore***tag@@@";
+ private final static int DEFAULT_LIMIT = 60;
+ private final static Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
+ private final static boolean DEFAULT_COUNTING_WITH_SPACES = false;
+ private final static boolean DEFAULT_SMART_TRUNCATION = true;
+ private final static TruncationStatus DEFAULT_TRUNCATION_STATUS = TruncationStatus.unknown;
+ private final static String EMPTY_STRING = "";
+ private final static String HTML_TAG_NAME_WITH_PREFIX = "^(?:[^:]+:)?a$";
+ private final static String HTML_BODY_TAG_CONTENT = "(?s)^.*