package com.baselet.diagram.draw;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.baselet.control.StringStyle;
import com.baselet.control.enums.AlignHorizontal;
import com.baselet.control.enums.AlignVertical;
import com.baselet.control.enums.FormatLabels;
import com.baselet.diagram.draw.helper.Style;
import com.baselet.util.LRUCache;
/**
* Based on the old TextSplitter, but offers additional features.
* e.g. splitting a whole line into the parts
* calculate the height
* calculate minimum width of an text so it can be fully drawn
*
* If not stated otherwise all Strings supplied to the TextSplitter are interpreted and
* the found markup is used to format the String (see {@link StringStyle}).
* <br>
* <i>Hint: The old TextSplitter can be found in the git history</i>
* <pre>e.g. hash: 1320ae5858446795bccda8d2bb12d18664278886</pre>
*/
@SuppressWarnings("unused")
public class TextSplitter {
private static final Logger log = LoggerFactory.getLogger(TextSplitter.class);
// since the 2nd and 3rd cache use the value of the 1st as a partial key, the size shouldn't be too different
// especially for the 2nd, the 3rd is bigger because there will be many different width value because of resize operations
private static final int WORD_CACHE_SIZE = 180;
private static final int MIN_WIDTH_CACHE_SIZE = 190;
private static final int WORDWRAP_CACHE_SIZE = 400;
private static final String SPLIT_CHARS = " \t";
// 3 Caches are used
// String line -> WordRegion[] words
// WordRegion[] words + Style style + FormatLabels -> Double minWidth
// WordRegion[] words + Style style + FormatLabels + Double width -> String[] wrappedLines + double height
private static LinkedHashMap<String, WordRegion[]> wordCache = new LRUCache<String, WordRegion[]>(WORD_CACHE_SIZE);
private static LinkedHashMap<MinWidthCacheKey, Double> minWidthCache = new LRUCache<MinWidthCacheKey, Double>(MIN_WIDTH_CACHE_SIZE);
private static LinkedHashMap<WordwrapCacheKey, WordwrapCacheValue> wordwrapCache = new LRUCache<WordwrapCacheKey, WordwrapCacheValue>(WORDWRAP_CACHE_SIZE);
/**
*
* @param drawer
* @param textLines each element is a line, must fit into width,height Rectangle
* @param topLeftX
* @param topLeftY
* @param width
* @param height
* @param hAlignment
* @param vAlignment
*/
public static void drawText(DrawHandler drawer, String[] textLines, double topLeftX, double topLeftY, double width, double height, AlignHorizontal hAlignment, AlignVertical vAlignment) {
double textHeight = getSplitStringHeight(textLines, width, drawer);
if (textHeight > height) {
throw new IllegalArgumentException("The text needs more height then specified in the parameter");
}
switch (vAlignment) {
case TOP:
break;
case CENTER:
topLeftY += (height - textHeight) / 2.0;
break;
case BOTTOM:
topLeftY += height - textHeight;
break;
default:
log.error("Encountered unhandled enumeration value '" + vAlignment + "'.");
break;
}
topLeftY += drawer.textHeightMax();
switch (hAlignment) {
case LEFT:
break;
case CENTER:
topLeftX += width / 2.0;
break;
case RIGHT:
topLeftX += width;
break;
default:
log.error("Encountered unhandled enumeration value '" + hAlignment + "'.");
break;
}
for (String l : textLines) {
for (StringStyle wl : splitStringAlgorithm(l, width, drawer)) {
drawer.print(wl, topLeftX, topLeftY, hAlignment);
topLeftY += drawer.textHeightMaxWithSpace();
}
}
}
/**
* checks if the whole string would fit into the width
* @param text
* @param width
* @param drawer
* @return true if the whole string would fit into the width
*/
public static boolean checkifStringFitsNoWordwrap(String text, double width, DrawHandler drawer) {
StringStyle analyzedText = StringStyle.analyzeFormatLabels(StringStyle.replaceNotEscaped(text));
WordRegion[] words = getCachedWords(analyzedText.getStringWithoutMarkup()); // only check cache because we don't need the words
if (words == null) {
return drawer.textWidth(analyzedText.getStringWithoutMarkup()) + endBuffer(drawer) + 0.01 < width;
}
else {
WordwrapCacheValue wwValue = getCachedWordwrap(words, width, drawer.getStyleClone(), analyzedText.getFormat());
if (wwValue == null) {
return drawer.textWidth(analyzedText.getStringWithoutMarkup()) + endBuffer(drawer) + 0.01 < width;
}
else {
return wwValue.getWrappedLines().length < 2; // if only 1 line was generated then it fits
}
}
}
/**
* checks if the minimum width exceeds the given width
* @param text
* @param width
* @param drawer
* @return true if the minimum width does not exceed the given width
*/
public static boolean checkifStringFitsWithWordwrap(String text, double width, DrawHandler drawer) {
return getTextMinWidth(text, drawer) < width; // generate the words and min width (or take them from cache)
}
/**
* Splits the text so it can be drawn with the given width and then the height is calculated.
* @param text a single line (no \r \n)
* @param width
* @param drawer
* @return the split text, each line as an element
*
* @see #splitStringAndHeightAlgorithm(String, double, DrawHandler)
*/
public static double getSplitStringHeight(String text, double width, DrawHandler drawer) {
return splitStringAndHeightAlgorithm(text, width, drawer).getHeight();
}
/**
* Splits each line so it can be drawn with the given width and then the height is calculated.
* It only call getSplitStringHeight(String, double, DrawHanlder) for each string and adds up the height
* @param textLines each element is a single line (no \r \n)
* @param width
* @param drawer
* @return the split text, each line as an element
*
* @see #splitStringAndHeightAlgorithm(String, double, DrawHandler)
*/
public static double getSplitStringHeight(String[] textLines, double width, DrawHandler drawer) {
double height = 0;
for (String l : textLines) {
height += getSplitStringHeight(l, width, drawer);
}
return height;
}
/**
*
* @param text a single line (no \r \n)
* @param width in which the text should be fitted, need to be > the width of the 'n' character
* @param drawer
* @return the wrapped lines
*/
public static StringStyle[] splitStringAlgorithm(String text, double width, DrawHandler drawer) {
return splitStringAndHeightAlgorithm(text, width, drawer).getWrappedLines();
}
/**
*
* @param text1 a single line (no \r \n)
* @param maxWidth in which the text should be fitted, need to be > the width of the 'n' character
* @param drawer
* @param runtimeException if true then a runtime exception is thrown if a single word is to big for the given width
* @return
*/
private static WordwrapCacheValue splitStringAndHeightAlgorithm(String text, double maxWidth, DrawHandler drawer) {
StringStyle analyzedText = StringStyle.analyzeFormatLabels(StringStyle.replaceNotEscaped(text));
String finalText = analyzedText.getStringWithoutMarkup();
WordRegion[] words = splitIntoWords(finalText);
WordwrapCacheKey key = new WordwrapCacheKey(words, maxWidth, drawer.getStyleClone(), analyzedText.getFormat());
WordwrapCacheValue cachedWordwrap = getCachedWordwrap(key);
if (cachedWordwrap != null) {
log.trace("got value from cache " + cachedWordwrap);
return cachedWordwrap;
}
else {
maxWidth -= endBuffer(drawer); // subtract a buffer to make sure no character is hidden at the end
List<StringStyle> wrappedText = new LinkedList<StringStyle>();
List<WordRegion> wordsList = new ArrayList<TextSplitter.WordRegion>(Arrays.asList(words));
for (ListIterator<WordRegion> iter = wordsList.listIterator(); iter.hasNext();) {
WordRegion currentRegion = iter.next();
String currentWord = finalText.substring(currentRegion.getBegin(), currentRegion.getEnd());
log.trace("current word: " + currentWord);
// Case1: current word is too long for available width space
if (!wordFits(maxWidth, drawer, currentWord)) {
// remove character by character from the word until it fits the line
int endIndex = currentRegion.getEnd();
String partialWord = finalText.substring(currentRegion.getBegin(), endIndex);
while (endIndex > 0 && !wordFits(maxWidth, drawer, partialWord)) {
partialWord = finalText.substring(currentRegion.getBegin(), --endIndex);
}
// if there is space for at least one character (ie: beginIdx != endIdx), add the portion of the current word which fits to the wrappedText and handle the rest of the word in the next iteration
if (currentRegion.getBegin() != endIndex) {
wrappedText.add(new StringStyle(analyzedText.getFormat(), partialWord));
iter.set(new WordRegion(endIndex, currentRegion.getEnd())); // overwrite this Wordregion with the new partial wordregion and handle it again
iter.previous();
}
}
// Case2: word fits the line but possibly more words would fit the line, therefore merge current WordRegion with next region until it's too long or there is no next region
else {
String mergedWord = currentWord;
while (iter.hasNext() && wordFits(maxWidth, drawer, mergedWord)) {
mergedWord = finalText.substring(currentRegion.getBegin(), iter.next().getEnd());
}
// Case2.1: all remaining words are merged and it still fits the width, therefore add the mergedWord
if (wordFits(maxWidth, drawer, mergedWord)) {
wrappedText.add(new StringStyle(analyzedText.getFormat(), mergedWord));
}
// Case2.2: the merged word grew too long, therefore add the merged word without the last merge and handle the remaining words in the next iteration of the main loop
else {
iter.previous(); // afterwards iter is between the fitting WordRegion and the one which makes the mergedWord too long
WordRegion lastFittingRegion = iter.previous(); // gives the fitting WordRegion but also moves the iter before the region
String substring = finalText.substring(currentRegion.getBegin(), lastFittingRegion.getEnd());
wrappedText.add(new StringStyle(analyzedText.getFormat(), substring));
iter.next(); // call next() to move the iter back between the fitting WordRegion and the one which makes the mergedWord too long
}
}
}
double height = wrappedText.size() * drawer.textHeightMaxWithSpace();
WordwrapCacheValue wordwrapValue = new WordwrapCacheValue(wrappedText.toArray(new StringStyle[0]), height);
setCachedWordwrap(key, wordwrapValue);
if (log.isTraceEnabled()) {
log.trace("split result: " + Arrays.toString(wordwrapValue.getWrappedLines()));
}
return wordwrapValue;
}
}
private static boolean wordFits(double maxWidth, DrawHandler drawer, String word) {
double width = drawer.textWidth(word);
log.trace("checking if \"" + word + "\" with width " + width + " fits available space of " + maxWidth);
return width <= maxWidth;
}
/**
*
* @param text a single line (no \r \n)
* @param drawer
* @return the minimum width, which is needed to draw the text. This is based on the biggest word.
*/
public static double getTextMinWidth(String text, DrawHandler drawer) {
StringStyle analyzedText = StringStyle.analyzeFormatLabels(StringStyle.replaceNotEscaped(text));
MinWidthCacheKey key = new MinWidthCacheKey(splitIntoWords(analyzedText.getStringWithoutMarkup()),
drawer.getStyleClone(), analyzedText.getFormat());
if (getCachedMinWidth(key) != null) {
return getCachedMinWidth(key);
}
else {
double minWidth = 0;
if (analyzedText.getStringWithoutMarkup().trim().length() > 0) {
for (WordRegion wr : key.getWords()) {
minWidth = Math.max(minWidth, drawer.textWidth(
analyzedText.getStringWithoutMarkup().substring(wr.getBegin(), wr.getEnd())));
}
}
// add the Buffer and small number, so the text can be drawn with the returned width (see splitStringAlgorithm)
minWidth += endBuffer(drawer) + 0.01;
setCachedMinWidth(key, minWidth);
return minWidth;
}
}
/**
* Returns the minimum width which is needed to draw the given lines
* @param textLines each element must be a single line (no \r \n)
* @param drawer
* @return the minimum width which is needed to draw the given lines
*/
public static double getTextMinWidth(String[] textLines, DrawHandler drawer) {
double minWidth = 0;
for (String line : textLines) {
minWidth = Math.max(minWidth, getTextMinWidth(line, drawer));
}
return minWidth;
}
/**
*
* @param text
* @return all the words which are separated by whitespaces (first word contains all leading whitespaces)
*/
private static WordRegion[] splitIntoWords(String text) {
WordRegion[] words = getCachedWords(text);
if (words == null) {
words = new WordRegion[0];
if (text.trim().length() > 0) {
int wordStart = 0;
int current = 0;
// add the leading white spaces to the first word to keep indentation
while (isWhitespace(text.charAt(current))) {
current++;
}
current++;
boolean inWord = true;
for (; current < text.length(); current++) {
if (inWord) {
if (isWhitespace(text.charAt(current))) {
words = Arrays.copyOf(words, words.length + 1);
words[words.length - 1] = new WordRegion(wordStart, current);
inWord = false;
}
}
else {
if (!isWhitespace(text.charAt(current))) {
wordStart = current;
inWord = true;
}
}
}
// if the last word isn't followed by a whitespace it won't get added in the loop
if (inWord) {
words = Arrays.copyOf(words, words.length + 1);
words[words.length - 1] = new WordRegion(wordStart, current);
}
}
setCachedWords(text, words);
}
return words;
}
private static boolean isWhitespace(char c) {
for (int i = 0; i < SPLIT_CHARS.length(); i++) {
if (SPLIT_CHARS.charAt(i) == c) {
return true;
}
}
return false;
}
private static double endBuffer(DrawHandler drawer) {
// used to subtract a buffer to make sure no character is hidden at the end (borrowed from TextSplitter)
return drawer.textWidth("n");
}
// cache functions, so that the implementation (structure) of the cache can be changed
// all functions will return null if no element was found
private static WordRegion[] getCachedWords(String lineKey) {
return wordCache.get(lineKey);
}
private static void setCachedWords(String lineKey, WordRegion[] words) {
wordCache.put(lineKey, words);
}
private static Double getCachedMinWidth(MinWidthCacheKey key) {
return minWidthCache.get(key);
}
private static void setCachedMinWidth(MinWidthCacheKey key, Double value) {
minWidthCache.put(key, value);
}
private static WordwrapCacheValue getCachedWordwrap(WordwrapCacheKey key) {
return wordwrapCache.get(key);
}
private static WordwrapCacheValue getCachedWordwrap(WordRegion[] words, double width, Style style, Set<FormatLabels> format) {
return getCachedWordwrap(new WordwrapCacheKey(words, width, style, format));
}
private static void setCachedWordwrap(WordwrapCacheKey key, WordwrapCacheValue value) {
wordwrapCache.put(key, value);
}
/**
* Contains the start and end of a word, can be directly used with substring
*/
private static class WordRegion {
private final int begin;
private final int end; // last character at end - 1. Thus the length is end-begin.
public WordRegion(int begin, int end) {
super();
this.begin = begin;
this.end = end;
}
public int getBegin() {
return begin;
}
public int getEnd() {
return end;
}
@Override
public String toString() {
return "WordRegion [begin=" + begin + ", end=" + end + "]";
}
}
private static class MinWidthCacheKey {
private final WordRegion[] words;
private final Style style; // must be part of key, because text width also depends on styling like fontsize
private final Set<FormatLabels> format;
public MinWidthCacheKey(WordRegion[] words, Style style, Set<FormatLabels> format) {
super();
this.words = words;
this.style = style;
this.format = format;
}
public WordRegion[] getWords() {
return words;
}
public Style getStyle() {
return style;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (format == null ? 0 : format.hashCode());
result = prime * result + (style == null ? 0 : style.hashCode());
result = prime * result + Arrays.hashCode(words);
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
MinWidthCacheKey other = (MinWidthCacheKey) obj;
if (format == null) {
if (other.format != null) {
return false;
}
}
else if (!format.equals(other.format)) {
return false;
}
if (style == null) {
if (other.style != null) {
return false;
}
}
else if (!style.equals(other.style)) {
return false;
}
if (!Arrays.equals(words, other.words)) {
return false;
}
return true;
}
}
private static class WordwrapCacheKey {
private final WordRegion[] words;
private final double width;
private final Style style; // must be part of key, because text width also depends on styling like fontsize
private final Set<FormatLabels> format;
public WordwrapCacheKey(WordRegion[] words, double width, Style style, Set<FormatLabels> format) {
super();
this.words = words;
this.width = width;
this.style = style;
this.format = format;
}
public WordRegion[] getWords() {
return words;
}
public double getWidth() {
return width;
}
public Style getStyle() {
return style;
}
public Set<FormatLabels> getFormat() {
return format;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (format == null ? 0 : format.hashCode());
result = prime * result + (style == null ? 0 : style.hashCode());
long temp;
temp = Double.doubleToLongBits(width);
result = prime * result + (int) (temp ^ temp >>> 32);
result = prime * result + Arrays.hashCode(words);
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
WordwrapCacheKey other = (WordwrapCacheKey) obj;
if (format == null) {
if (other.format != null) {
return false;
}
}
else if (!format.equals(other.format)) {
return false;
}
if (style == null) {
if (other.style != null) {
return false;
}
}
else if (!style.equals(other.style)) {
return false;
}
if (Double.doubleToLongBits(width) != Double.doubleToLongBits(other.width)) {
return false;
}
if (!Arrays.equals(words, other.words)) {
return false;
}
return true;
}
}
private static class WordwrapCacheValue {
// Style is included although the format is already included in the key, because nearly every time the line are retrieved the style is needed
private final StringStyle[] wrappedLines;
private final double height;
public WordwrapCacheValue(StringStyle[] wrappedLines, double height) {
super();
this.wrappedLines = wrappedLines;
this.height = height;
}
public WordwrapCacheValue(String[] wrappedLines, Set<FormatLabels> format, double height) {
super();
this.wrappedLines = new StringStyle[wrappedLines.length];
for (int i = 0; i < wrappedLines.length; i++) {
this.wrappedLines[i] = new StringStyle(format, wrappedLines[i]);
}
this.height = height;
}
public StringStyle[] getWrappedLines() {
return wrappedLines;
}
public double getHeight() {
return height;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
long temp;
temp = Double.doubleToLongBits(height);
result = prime * result + (int) (temp ^ temp >>> 32);
result = prime * result + Arrays.hashCode(wrappedLines);
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
WordwrapCacheValue other = (WordwrapCacheValue) obj;
if (Double.doubleToLongBits(height) != Double.doubleToLongBits(other.height)) {
return false;
}
if (!Arrays.equals(wrappedLines, other.wrappedLines)) {
return false;
}
return true;
}
}
}