/*
 *  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.causeway.extensions.tabular.pdf.factory.internal;

import java.awt.Color;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;

import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;

import org.apache.causeway.extensions.tabular.pdf.factory.FontUtils;
import org.apache.causeway.extensions.tabular.pdf.factory.HorizontalAlignment;
import org.apache.causeway.extensions.tabular.pdf.factory.internal.Token.TokenType;

final class Paragraph {

    enum TextType {
        HIGHLIGHT,UNDERLINE,SQUIGGLY,STRIKEOUT;
    }

    /**
     * Data container for HTML ordered list elements.
     */
    record HTMLListNode(
            /** Element's current ordering number (e.g third element in the current list) */
            int orderingNumber,
            /** Element's whole ordering number value (e.g 1.1.2.1) */
            String value) {
    }

	private float width;
	private final String text;
	private float fontSize;
	private PDFont font;
	private final PDFont fontBold;
	private final PDFont fontItalic;
	private final PDFont fontBoldItalic;
	private final WrappingFunction wrappingFunction;
	private HorizontalAlignment align;
	private TextType textType;
	private Color color;
	private float lineSpacing;

	private final static int DEFAULT_TAB = 4;
	private final static int DEFAULT_TAB_AND_BULLET = 6;
	private final static int BULLET_SPACE = 2;

	private boolean drawDebug;
	private final Map<Integer, Float> lineWidths = new HashMap<>();
	private Map<Integer, List<Token>> mapLineTokens = new LinkedHashMap<>();
	private float maxLineWidth = Integer.MIN_VALUE;
	private List<Token> tokens;
	private List<String> lines;
	private Float spaceWidth;

	public Paragraph(final String text, final PDFont font, final float fontSize, final float width, final HorizontalAlignment align) {
		this(text, font, fontSize, width, align, null);
	}

	// This function exists only to preserve backwards compatibility for
	// the getWrappingFunction() method; it has been replaced with a faster implementation in the Tokenizer
	private static final WrappingFunction DEFAULT_WRAP_FUNC = t -> t.split("(?<=\\s|-|@|,|\\.|:|;)");

	public Paragraph(final String text, final PDFont font, final int fontSize, final int width) {
		this(text, font, fontSize, width, HorizontalAlignment.LEFT, null);
	}

	public Paragraph(final String text, final PDFont font, final float fontSize, final float width, final HorizontalAlignment align,
			final WrappingFunction wrappingFunction) {
		this(text, font, fontSize, width, align, Color.BLACK, (TextType) null, wrappingFunction);
	}

	public Paragraph(final String text, final PDFont font, final float fontSize, final float width, final HorizontalAlignment align,
			final Color color, final TextType textType, final WrappingFunction wrappingFunction) {
		this(text, font, fontSize, width, align, color, textType, wrappingFunction, 1);
	}

	public Paragraph(final String text, final PDFont font, final float fontSize, final float width, final HorizontalAlignment align,
			final Color color, final TextType textType, final WrappingFunction wrappingFunction, final float lineSpacing) {
		this.color = color;
		this.text = text;
		this.font = font;
		// check if we have different default font for italic and bold text
		if (FontUtils.getDefaultfonts().isEmpty()) {
			fontBold = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
			fontItalic = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE);
			fontBoldItalic = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD_OBLIQUE);
		} else {
			fontBold = FontUtils.getDefaultfonts().get("fontBold");
			fontBoldItalic = FontUtils.getDefaultfonts().get("fontBoldItalic");
			fontItalic = FontUtils.getDefaultfonts().get("fontItalic");
		}
		this.fontSize = fontSize;
		this.width = width;
		this.textType = textType;
		this.setAlign(align);
		this.wrappingFunction = wrappingFunction;
		this.lineSpacing = lineSpacing;
	}

	public List<String> getLines() {
		// memoize this function because it is very expensive
		if (lines != null) {
			return lines;
		}

		final List<String> result = new ArrayList<>();

		// text and wrappingFunction are immutable, so we only ever need to compute tokens once
		if (tokens == null) {
			tokens = Tokenizer.tokenize(text, wrappingFunction);
		}

		int lineCounter = 0;
		boolean italic = false;
		boolean bold = false;
		boolean listElement = false;
		PDFont currentFont = font;
		int orderListElement = 1;
		int numberOfOrderedLists = 0;
		int listLevel = 0;
		Stack<HTMLListNode> stack = new Stack<>();

		final PipelineLayer textInLine = new PipelineLayer();
		final PipelineLayer sinceLastWrapPoint = new PipelineLayer();

		for (final Token token : tokens) {
			switch (token.type()) {
			case OPEN_TAG:
				if (isBold(token)) {
					bold = true;
					currentFont = getFont(bold, italic);
				} else if (isItalic(token)) {
					italic = true;
					currentFont = getFont(bold, italic);
				} else if (isList(token)) {
					listLevel++;
					if (token.text().equals("ol")) {
						numberOfOrderedLists++;
						if(listLevel > 1){
							stack.add(new HTMLListNode(orderListElement-1, stack.isEmpty() ? String.valueOf(orderListElement-1)+"." : stack.peek().value() + String.valueOf(orderListElement-1) + "."));
						}
						orderListElement = 1;

						textInLine.push(sinceLastWrapPoint);
						// check if you have some text before this list, if you don't then you really don't need extra line break for that
						if (textInLine.trimmedWidth() > 0) {
							// this is our line
							result.add(textInLine.trimmedText());
							lineWidths.put(lineCounter, textInLine.trimmedWidth());
							mapLineTokens.put(lineCounter, textInLine.tokens());
							maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
							textInLine.reset();
							lineCounter++;
						}
					} else if (token.text().equals("ul")) {
						textInLine.push(sinceLastWrapPoint);
						// check if you have some text before this list, if you don't then you really don't need extra line break for that
						if (textInLine.trimmedWidth() > 0) {
							// this is our line
							result.add(textInLine.trimmedText());
							lineWidths.put(lineCounter, textInLine.trimmedWidth());
							mapLineTokens.put(lineCounter, textInLine.tokens());
							maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
							textInLine.reset();
							lineCounter++;
						}
					}
				}
				sinceLastWrapPoint.push(token);
				break;
			case CLOSE_TAG:
				if (isBold(token)) {
					bold = false;
					currentFont = getFont(bold, italic);
					sinceLastWrapPoint.push(token);
				} else if (isItalic(token)) {
					italic = false;
					currentFont = getFont(bold, italic);
					sinceLastWrapPoint.push(token);
				} else if (isList(token)) {
					listLevel--;
					if (token.text().equals("ol")) {
						numberOfOrderedLists--;
						// reset elements
						if(numberOfOrderedLists>0){
							orderListElement = stack.peek().orderingNumber()+1;
							stack.pop();
						}
					}
					// ensure extra space after each lists
					// no need to worry about current line text because last closing <li> tag already done that
					if(listLevel == 0){
						result.add(" ");
						lineWidths.put(lineCounter, 0.0f);
						mapLineTokens.put(lineCounter, new ArrayList<Token>());
						lineCounter++;
					}
				} else if (isListElement(token)) {
					// wrap at last wrap point?
					if (textInLine.width() + sinceLastWrapPoint.trimmedWidth() > width) {
						// this is our line
						result.add(textInLine.trimmedText());
						lineWidths.put(lineCounter, textInLine.trimmedWidth());
						mapLineTokens.put(lineCounter, textInLine.tokens());
						maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
						textInLine.reset();
						lineCounter++;
						// wrapping at last wrap point
						if (numberOfOrderedLists>0) {
							String orderingNumber = stack.isEmpty() ? String.valueOf(orderListElement) + "." : stack.pop().value() + ".";
							stack.add(new HTMLListNode(orderListElement, orderingNumber));
							try {
								float tab = indentLevel(DEFAULT_TAB);
								float orderingNumberAndTab = font.getStringWidth(orderingNumber) + tab;
								textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING, String
										.valueOf(orderingNumberAndTab / 1000 * getFontSize())));
							} catch (IOException e) {
								e.printStackTrace();
							}
							orderListElement++;
						} else {
							try {
								// if it's not left aligned then ignore list and list element and deal with it as normal text where <li> mimic <br> behaviour
								float tabBullet = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB_AND_BULLET) : indentLevel(DEFAULT_TAB);
								textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
										String.valueOf(tabBullet / 1000 * getFontSize())));
							} catch (IOException e) {
								e.printStackTrace();
							}
						}
						textInLine.push(sinceLastWrapPoint);
					}
					// wrapping at this must-have wrap point
					textInLine.push(sinceLastWrapPoint);
					// this is our line
					result.add(textInLine.trimmedText());
					lineWidths.put(lineCounter, textInLine.trimmedWidth());
					mapLineTokens.put(lineCounter, textInLine.tokens());
					maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
					textInLine.reset();
					lineCounter++;
					listElement = false;
				}
				if (isParagraph(token)) {
					if (textInLine.width() + sinceLastWrapPoint.trimmedWidth() > width) {
						// this is our line
						result.add(textInLine.trimmedText());
						lineWidths.put(lineCounter, textInLine.trimmedWidth());
						maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
						mapLineTokens.put(lineCounter, textInLine.tokens());
						lineCounter++;
						textInLine.reset();
					}
					// wrapping at this must-have wrap point
					textInLine.push(sinceLastWrapPoint);
					// this is our line
					result.add(textInLine.trimmedText());
					lineWidths.put(lineCounter, textInLine.trimmedWidth());
					mapLineTokens.put(lineCounter, textInLine.tokens());
					maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
					textInLine.reset();
					lineCounter++;

					// extra spacing because it's a paragraph
					result.add(" ");
					lineWidths.put(lineCounter, 0.0f);
					mapLineTokens.put(lineCounter, new ArrayList<Token>());
					lineCounter++;
				}
				break;
			case POSSIBLE_WRAP_POINT:
				if (textInLine.width() + sinceLastWrapPoint.trimmedWidth() > width) {
					// this is our line
					if (!textInLine.isEmpty()) {
						result.add(textInLine.trimmedText());
						lineWidths.put(lineCounter, textInLine.trimmedWidth());
						maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
						mapLineTokens.put(lineCounter, textInLine.tokens());
						lineCounter++;
						textInLine.reset();
					}
					// wrapping at last wrap point
					if (listElement) {
						if (numberOfOrderedLists>0) {
							try {
								float tab = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB) : indentLevel(DEFAULT_TAB);
								String orderingNumber = stack.isEmpty() ? String.valueOf(orderListElement) + "." : stack.peek().value() + "." + String.valueOf(orderListElement-1) + ".";
								textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
										String.valueOf((tab + font.getStringWidth(orderingNumber)) / 1000 * getFontSize())));
							} catch (IOException e) {
								e.printStackTrace();
							}
						} else {
							try {
								// if it's not left aligned then ignore list and list element and deal with it as normal text where <li> mimic <br> behavior
								float tabBullet = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB_AND_BULLET)  : indentLevel(DEFAULT_TAB);
								textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
										String.valueOf(tabBullet / 1000 * getFontSize())));
							} catch (IOException e) {
								// TODO Auto-generated catch block
								e.printStackTrace();
							}
						}
					}
					textInLine.push(sinceLastWrapPoint);
				} else {
					textInLine.push(sinceLastWrapPoint);
				}
				break;
			case WRAP_POINT:
				// wrap at last wrap point?
				if (textInLine.width() + sinceLastWrapPoint.trimmedWidth() > width) {
					// this is our line
					result.add(textInLine.trimmedText());
					lineWidths.put(lineCounter, textInLine.trimmedWidth());
					mapLineTokens.put(lineCounter, textInLine.tokens());
					maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
					textInLine.reset();
					lineCounter++;
					// wrapping at last wrap point
					if (listElement) {
						if(!getAlign().equals(HorizontalAlignment.LEFT)) {
							listLevel = 0;
						}
						if (numberOfOrderedLists>0) {
//							String orderingNumber = String.valueOf(orderListElement) + ". ";
							String orderingNumber = stack.isEmpty() ? String.valueOf("1") + "." : stack.pop().value() + ". ";
							try {
								float tab = indentLevel(DEFAULT_TAB);
								float orderingNumberAndTab = font.getStringWidth(orderingNumber) + tab;
								textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING, String
										.valueOf(orderingNumberAndTab / 1000 * getFontSize())));
							} catch (IOException e) {
								e.printStackTrace();
							}
						} else {
							try {
								// if it's not left aligned then ignore list and list element and deal with it as normal text where <li> mimic <br> behaviour
								float tabBullet = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB_AND_BULLET) : indentLevel(DEFAULT_TAB);
								textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
										String.valueOf(tabBullet / 1000 * getFontSize())));
							} catch (IOException e) {
								e.printStackTrace();
							}
						}
					}
					textInLine.push(sinceLastWrapPoint);
				}
				if (isParagraph(token)) {
					// check if you have some text before this paragraph, if you don't then you really don't need extra line break for that
					if (textInLine.trimmedWidth() > 0) {
						// extra spacing because it's a paragraph
						result.add(" ");
						lineWidths.put(lineCounter, 0.0f);
						mapLineTokens.put(lineCounter, new ArrayList<Token>());
						lineCounter++;
					}
				} else if (isListElement(token)) {
					listElement = true;
					// token padding, token bullet
					try {
						// if it's not left aligned then ignore list and list element and deal with it as normal text where <li> mimic <br> behaviour
						float tab = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB) : indentLevel(DEFAULT_TAB);
						textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
								String.valueOf(tab / 1000 * getFontSize())));
						if (numberOfOrderedLists>0) {
							// if it's ordering list then move depending on your: ordering number + ". "
							String orderingNumber;
							if(listLevel > 1){
								orderingNumber = stack.peek().value() + String.valueOf(orderListElement) + ". ";
							} else {
								orderingNumber = String.valueOf(orderListElement) + ". ";
							}
							textInLine.push(currentFont, fontSize, new Token(TokenType.ORDERING, orderingNumber));
							orderListElement++;
						} else {
							// if it's unordered list then just move by bullet character (take care of alignment!)
							textInLine.push(currentFont, fontSize, new Token(TokenType.BULLET, " "));
						}
					} catch (IOException e) {
						e.printStackTrace();
					}
				} else {
					// wrapping at this must-have wrap point
					textInLine.push(sinceLastWrapPoint);
					result.add(textInLine.trimmedText());
					lineWidths.put(lineCounter, textInLine.trimmedWidth());
					mapLineTokens.put(lineCounter, textInLine.tokens());
					maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
					textInLine.reset();
					lineCounter++;
					if(listLevel>0){
						// preserve current indent
						try {
							if (numberOfOrderedLists>0) {
								float tab = getAlign().equals(HorizontalAlignment.LEFT) ? indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0)) : indentLevel(DEFAULT_TAB);
								// if it's ordering list then move depending on your: ordering number + ". "
								String orderingNumber;
								if(listLevel > 1){
									orderingNumber = stack.peek().value() + String.valueOf(orderListElement) + ". ";
								} else {
									orderingNumber = String.valueOf(orderListElement) + ". ";
								}
								float tabAndOrderingNumber = tab + font.getStringWidth(orderingNumber);
								textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING, String.valueOf(tabAndOrderingNumber / 1000 * getFontSize())));
								orderListElement++;
							} else {
								if(getAlign().equals(HorizontalAlignment.LEFT)){
									float tab = indentLevel(DEFAULT_TAB*Math.max(listLevel - 1, 0) + DEFAULT_TAB + BULLET_SPACE);
									textInLine.push(currentFont, fontSize, new Token(TokenType.PADDING,
											String.valueOf(tab / 1000 * getFontSize())));
								}
							}
						} catch (IOException e) {
							e.printStackTrace();
						}
					}
				}
				break;
			case TEXT:
				try {
					String word = token.text();
					float wordWidth = token.getWidth(currentFont);
					if(wordWidth / 1000f * fontSize > width && width > font.getAverageFontWidth() / 1000f * fontSize) {
						// you need to check if you have already something in your line
						boolean alreadyTextInLine = false;
						if(textInLine.trimmedWidth()>0){
							alreadyTextInLine = true;
						}
						while (wordWidth / 1000f * fontSize > width) {
						float width = 0;
						float firstPartWordWidth = 0;
						float restOfTheWordWidth = 0;
						String lastTextToken = word;
						StringBuilder firstPartOfWord = new StringBuilder();
						StringBuilder restOfTheWord = new StringBuilder();
						for (int i = 0; i < lastTextToken.length(); i++) {
							char c = lastTextToken.charAt(i);
							try {
								width += (currentFont.getStringWidth(String.valueOf(c)) / 1000f * fontSize);
							} catch (IOException e) {
								e.printStackTrace();
							}
							if(alreadyTextInLine){
								if (width < this.width - textInLine.trimmedWidth()) {
									firstPartOfWord.append(c);
									firstPartWordWidth = Math.max(width, firstPartWordWidth);
								} else {
									restOfTheWord.append(c);
									restOfTheWordWidth = Math.max(width, restOfTheWordWidth);
								}
							} else {
								if (width < this.width) {
									firstPartOfWord.append(c);
									firstPartWordWidth = Math.max(width, firstPartWordWidth);
								} else {
									if(i==0){
										firstPartOfWord.append(c);
										for (int j = 1; j< lastTextToken.length(); j++){
											restOfTheWord.append(lastTextToken.charAt(j));
										}
										break;
									} else {
										restOfTheWord.append(c);
										restOfTheWordWidth = Math.max(width, restOfTheWordWidth);

									}
								}
							}
						}
						// reset
						alreadyTextInLine = false;
						sinceLastWrapPoint.push(currentFont, fontSize,
								Token.text(firstPartOfWord.toString()));
						textInLine.push(sinceLastWrapPoint);
						// this is our line
						result.add(textInLine.trimmedText());
						lineWidths.put(lineCounter, textInLine.trimmedWidth());
						mapLineTokens.put(lineCounter, textInLine.tokens());
						maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
						textInLine.reset();
						lineCounter++;
						word = restOfTheWord.toString();
						wordWidth = currentFont.getStringWidth(word);
						}
						sinceLastWrapPoint.push(currentFont, fontSize, Token.text(word));
					} else {
						sinceLastWrapPoint.push(currentFont, fontSize, token);
					}

				} catch (IOException e) {
					e.printStackTrace();
				}
				break;
            case BULLET, ORDERING, PADDING:
                break;
			}
		}
		if (sinceLastWrapPoint.trimmedWidth() + textInLine.trimmedWidth() > 0) {
			textInLine.push(sinceLastWrapPoint);
			result.add(textInLine.trimmedText());
			lineWidths.put(lineCounter, textInLine.trimmedWidth());
			mapLineTokens.put(lineCounter, textInLine.tokens());
			maxLineWidth = Math.max(maxLineWidth, textInLine.trimmedWidth());
		}

		lines = result;
		return result;
	}

	private static boolean isItalic(final Token token) {
		return "i".equals(token.text());
	}

	private static boolean isBold(final Token token) {
		return "b".equals(token.text());
	}

	private static boolean isParagraph(final Token token) {
		return "p".equals(token.text());
	}

	private static boolean isListElement(final Token token) {
		return "li".equals(token.text());
	}

	private static boolean isList(final Token token) {
		return "ul".equals(token.text()) || "ol".equals(token.text());
	}

	private float indentLevel(final int numberOfSpaces) throws IOException {
		if (spaceWidth == null) {
			spaceWidth = font.getSpaceWidth();
		}
		return numberOfSpaces * spaceWidth;
	}

	public PDFont getFont(final boolean isBold, final boolean isItalic) {
		if (isBold) {
			if (isItalic) {
				return fontBoldItalic;
			} else {
				return fontBold;
			}
		} else if (isItalic) {
			return fontItalic;
		} else {
			return font;
		}
	}

	public float write(final PageContentStreamOptimized stream, final float cursorX, float cursorY) {
		if (drawDebug) {
			PDStreamUtils.rectFontMetrics(stream, cursorX, cursorY, font, fontSize);

			// width
			PDStreamUtils.rect(stream, cursorX, cursorY, width, 1, Color.RED);
		}

		for (String line : getLines()) {
			line = line.trim();

			float textX = cursorX;
			switch (align) {
			case CENTER:
				textX += getHorizontalFreeSpace(line) / 2;
				break;
			case LEFT:
				break;
			case RIGHT:
				textX += getHorizontalFreeSpace(line);
				break;
			}

			PDStreamUtils.write(stream, line, font, fontSize, textX, cursorY, color);

			if (textType != null) {
				switch (textType) {
				case HIGHLIGHT:
				case SQUIGGLY:
				case STRIKEOUT:
					throw new UnsupportedOperationException("Not implemented.");
				case UNDERLINE:
					float y = (float) (cursorY - FontUtils.getHeight(font, fontSize)
							- FontUtils.getDescent(font, fontSize) - 1.5);
					try {
						float titleWidth = font.getStringWidth(line) / 1000 * fontSize;
						stream.moveTo(textX, y);
						stream.lineTo(textX + titleWidth, y);
						stream.stroke();
					} catch (final IOException e) {
						throw new IllegalStateException("Unable to underline text", e);
					}
					break;
				default:
					break;
				}
			}

			// move one "line" down
			cursorY -= getFontHeight();
		}

		return cursorY;
	}

	public float getHeight() {
		if (getLines().size() == 0) {
			return 0;
		} else {
			return (getLines().size() - 1) * getLineSpacing() * getFontHeight() + getFontHeight();
		}
	}

	public float getFontHeight() {
		return FontUtils.getHeight(font, fontSize);
	}

	// font, fontSize, width, and align are non-final and used in getLines(),
	// so if they are mutated, getLines() needs to be recomputed
	private void invalidateLineWrapping() {
		lines = null;
	}

	private float getHorizontalFreeSpace(final String text) {
		try {
			final float tw = font.getStringWidth(text.trim()) / 1000 * fontSize;
			return width - tw;
		} catch (IOException e) {
			throw new IllegalStateException("Unable to calculate text width", e);
		}
	}

	public float getWidth() {
		return width;
	}

	public String getText() {
		return text;
	}

	public float getFontSize() {
		return fontSize;
	}

	public PDFont getFont() {
		return font;
	}

	public HorizontalAlignment getAlign() {
		return align;
	}

	public void setAlign(final HorizontalAlignment align) {
		invalidateLineWrapping();
		this.align = align;
	}

	public boolean isDrawDebug() {
		return drawDebug;
	}

	public void setDrawDebug(final boolean drawDebug) {
		this.drawDebug = drawDebug;
	}

	public WrappingFunction getWrappingFunction() {
		return wrappingFunction == null ? DEFAULT_WRAP_FUNC : wrappingFunction;
	}

	public float getMaxLineWidth() {
		return maxLineWidth;
	}

	public float getLineWidth(final int key) {
		return lineWidths.get(key);
	}

	public Map<Integer, List<Token>> getMapLineTokens() {
		return mapLineTokens;
	}

	public float getLineSpacing() {
		return lineSpacing;
	}

	public void setLineSpacing(final float lineSpacing) {
		this.lineSpacing = lineSpacing;
	}

}
