// vim: ts=4:sw=4
package de.steg0.bi.jr;

import com.lowagie.text.Chunk;
import java.util.logging.Logger;

import net.sf.jasperreports.engine.JRCommonText;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.fill.JRMeasuredText;
import net.sf.jasperreports.engine.util.JRStyledText;
import net.sf.jasperreports.engine.export.JRPdfExporter.NullOutputStream;

import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Phrase;
import com.lowagie.text.SplitCharacter;
import com.lowagie.text.pdf.ColumnText;
import com.lowagie.text.pdf.DefaultSplitCharacter;
import com.lowagie.text.pdf.PdfContentByte;
import com.lowagie.text.pdf.PdfWriter;
import java.util.List;
import java.util.logging.Level;
import net.sf.jasperreports.engine.JRTextElement;
import net.sf.jasperreports.engine.fill.JRTextMeasurer;

/**
 * This class can be used if Jasper Reports' default Java 2D-based text
 * measuring should be skipped entirely. It relies on iText's
 * <code>ColumnText.go</code> method to determine suitable bounding boxes for
 * text elements. Because <code>go</code> doesn't expose much information to
 * the caller in case a text does <em>not</em> fit in a certain given
 * box (as is the case when determining page breaks), at the moment a
 * binary search algorithm is used to optimize fit through recursive <code>go</code>
 * calls.
 * 
 * <p>This class should in any case yield a
 * better result than the original measurer in case the output is PDF,
 * though I'm not exactly sure how bidi text works out at the moment.
 * </p>
 * 
 * <p>
 * Before using this class, please understand the implications of doing so.
 * </p>
 *
 * <ul>
 * <li>This class does not calculate the same extent for text items compared to the
 * standard measurer. It is not safe to use it in place of the standard
 * measurer in boxes that are optimized towards that measurer. It should
 * however be fine to use it for elements that stretch, where it is
 * aimed to be used for. This means, it is normally not useful, at least 
 * not without sufficient testing, to configure this class as the standard measurer
 * in a top-level
 * <tt>jasperreports.properties</tt> file.</li>
 * <li>This measurer uses a configurable but <em>fixed</em> line spacing
 * factor. I guess that in most cases, the result is fine for typical text
 * elements that contain occasional symbol characters about the same size
 * as the surrounding text. Actually, the fixed approach was taken exactly
 * because of the irritating behavior of the standard measurer in such cases.
 * But there may be cases where varying font sizes and symbols
 * cause the approach taken in this class to fail.</li>
 * </ul>
 *
 * <p>
 * This file is available under the LGPL.
 * </p>
 * 
 * Raimund Steger 2010-04..2010-11
 *
 * @version 0.0.4
 */
/*
 * TODO:
 *
 * * RTL support (BreakIterator is one solution, but appears a bit complicated, this
 *   information ought to be available in the JR elements somewhere as well)
 *
 * * Using the BreakIterator-based SplitCharacter implementation (through
 *   FORCE_LINEBREAK_POLICY) is untested
 *
 * * Rotation support (swap top-bottom/left-right padding; what are the
 *   implication for w/h measurements?)
 */
public class JRiTextMeasurer
implements JRTextMeasurer
{
    private static final Logger LOG=Logger.getLogger("JRiTextMeasurer");

    /**
     * This is used as a test phrase to identify words that do not fit in
     * one line, to correctly
     * split those by force and not produce empty lines.
     * <p>Some background info:</p>
     * <ul>
     * <li>We rely on iText to determine whether a piece of text fits in a given
     * bounding box (confined by the bottom page border).<ol>
     * <li>If the text does not fit, we try splitting the text and using the first
     * half only. (We split along "split characters" as defined in the
     * exporter parameters.)</li>
     * <li>If the text does fit, we try splitting the remainder and appending its
     * first half
     * to the text we tried, to maximize
     * the amount of text that fits.</li>
     * </ol>
     * </li>
     * <li>This we do recursively, until we come to a point where we try a piece of
     * text that does not fit, but we cannot shorten it any further without going
     * back to one we already tried.</li>
     * </ul>
     * <p>If we're at that point, this normally simply means that we have optimized
     * the fit and the word we're looking at should be printed on the next page.
     * </p>
     * <p>However, if that word is actually too long for one line, and we
     * pass it on to the next page,
     * this means we're wasting a full line on our page, creating an empty line.
     * It also can mean that if our bounding box can only hold one line to begin
     * with, we might end up not terminating.</p>
     * <p>Now to find out whether we could actually fit an additional line, only
     * the word we're looking at is too long, we test appending a
     * newline and a very short character to our test phrase and see if it works. If it
     * does, we know that we must force a break in that very long word. In that
     * case we reenter the fitting algorithm for our long word in "forceBreak"
     * mode, i. e. in a mode where splitting words is done not only at
     * "split characters", but can occur at any position.</p>
     */
    private static final Chunk NEWLINE_TEST_CHUNK=new Chunk("\n.");

    private JRPdfExporterUtil eutil;
    private JRCommonText textElement;
    private Float lineSpacingFactor;
    private Level traceLevel=Level.FINEST;
    private String traceExpression=null;

    public JRiTextMeasurer(JRCommonText textElement,JRPdfExporterUtil eutil,
            Float lineSpacingFactor)
    {
        this.textElement=textElement;
        LOG.log(this.traceLevel,"JRiTextMeasurer("+textElement+")");
        this.eutil=eutil;
        this.lineSpacingFactor=lineSpacingFactor;
    }

    public String getTraceExpression() 
    {
		return traceExpression;
	}

	public void setTraceExpression(String traceExpression) 
	{
		this.traceExpression = traceExpression;
	}

	/*
     * This instance needs a dummy PDF document to check text heights
     * with iText:
     */
    private Document pdfDocument=new Document();
    private PdfWriter pdfWriter;
    {
        try
        {
            this.pdfWriter=PdfWriter.getInstance(this.pdfDocument,
                    new NullOutputStream());
        }
        catch(DocumentException e0)
        {
            LOG.severe("Error instantiating PDF buffer: "+e0);
        }
        this.pdfDocument.open();
        this.pdfDocument.newPage();
    }
    private PdfContentByte cb=this.pdfWriter.getDirectContent();

    public JRMeasuredText measure(JRStyledText styledText,
            int remainingTextStart,int availableStretchHeight,
            boolean canOverflow)
    {
        final String text=styledText.getText();
        if(this.traceExpression!=null&&
           text.indexOf(this.traceExpression)>=0)
        {
        	this.traceLevel=Level.INFO;
        }
        else
        {
        	this.traceLevel=Level.FINEST;
        }

        LOG.log(this.traceLevel,"measure(): remainingTextStart="+remainingTextStart+
        		"; availableStretchHeight="+availableStretchHeight+"; "+
                "textElement.height="+this.textElement.getHeight()+"; "+
        		"canOverflow="+canOverflow+"; text=\""+styledText.getText()+"\"");

        final MeasuredText t=new MeasuredText();

        if(this.lineSpacingFactor!=null)
        {
        	t.lineSpacingFactor = this.lineSpacingFactor.floatValue();
        }
        else
        {
            t.lineSpacingFactor = 1.15f;
        }
        if(this.textElement.getLineSpacing()==JRTextElement.LINE_SPACING_1_1_2)
        {
            t.lineSpacingFactor *= 1.5f;
        }
        else if(this.textElement.getLineSpacing()==JRTextElement.LINE_SPACING_DOUBLE)
        {
            t.lineSpacingFactor *= 2f;
        }

        t.textSuffix="";
        /* Set position of the first line relative to bounding box. */
        t.leadingOffset=textElement.getFontSize() -
                textElement.getFontSize()*t.lineSpacingFactor;

        /*
         * We need to consider the complete height of the text box, not
         * only the available stretch height, for iText. Thanks to
         * Guido Malpohl who reported this issue.
         */
        final int availableHeight=this.textElement.getHeight() +
                availableStretchHeight;

        final Bounds bounds=new Bounds(availableHeight);

        try
        {
            this.iTextFit(styledText,null,bounds,remainingTextStart,
                    availableHeight,t,text.length(),remainingTextStart,
                    text.length(),false);

            int suffixOfs=t.textOffset;
            /* Kill whitespace, but not if it's the only thing that's left */
            while(suffixOfs<text.length() &&
                  Character.isWhitespace(text.charAt(suffixOfs))) suffixOfs++;
            if(suffixOfs<text.length()) t.textOffset=suffixOfs;
        }
        catch(Exception e0)
        {
        	throw new IllegalStateException("Internal error layouting text: "+
        			e0.getMessage(),e0);
        }

        LOG.log(this.traceLevel,"result is: "+t);

        return t;
    }

    /**
     * Recursively tries to fit <code>styledText</code> in <code>availableHeight</code>
     * and sets <code>t</code> as side-effect.
     *
     * @param chars a char array containing all characters of the text.
     * This is passed along so that it doesn't need to be copied every time.
     * Should be <code>null</code> in the initial call.
     * @param bounds the bounding box to use
     * @param remainingTextStart the start position in the text
     * @param availableHeight the maximum height this run can allocate for the text
     * @param end the end position of the text
     * @param maxFit the maximum known "end" position in the text that fits
     * @param minNoFit the minimum known "end" position in the text that does not fit.
     * Should be equal to the text length in the inital call.
     */
    private void iTextFit(JRStyledText styledText,char[] chars,Bounds bounds,
            int remainingTextStart,int availableHeight,MeasuredText t,
            int end,int maxFit,int minNoFit,boolean forceBreak)
    throws DocumentException,JRException
    {
        if(maxFit==end) return;
        if(!(maxFit<=end&&end<=minNoFit))
        {
            throw new IllegalArgumentException("Boundaries illegal");
        }

    	final Phrase ph=this.eutil.getPhrase(styledText,remainingTextStart,
                end);
        final String text=styledText.getText();
        final String textabbr=LOG.isLoggable(this.traceLevel)?
            this.abbr(text.substring(remainingTextStart,end)):"";

        final SplitCharacter sc=getSplitCharacter(ph);
        if(chars==null) chars=new char[text.length()]; /* for SplitCharacter */
        text.getChars(0,text.length(),chars,0);

		if(LOG.isLoggable(this.traceLevel)) LOG.log(this.traceLevel,"iTextFit(): maxFit="+
                maxFit+", end="+end+", minNoFit="+minNoFit+". "+
                "FITTING [\""+text.substring(remainingTextStart,end)+"\"]");

        /*
         * Find the fit by trying the same call that JrPdfExporter uses,
         * including box padding. The position on the page doesn't matter,
         * we're only interested in text extent, so we position at (0,0).
         */

        final ColumnText colText=this.createColumnText(ph,t,bounds);
        final int result = colText.go(true);
        t.textHeight=availableHeight-colText.getYLine();
        
        if(ColumnText.hasMoreText(result))
        {
            LOG.log(this.traceLevel,"Max stretch height "+availableHeight+
                    " insufficient for text: "+textabbr+"; space left after "+
                    "attempt: "+colText.getYLine()+"; box height: "+t.textHeight);
            /* No success here -- try fewer text */
            int wordBoundary=this.getCenterWordBoundary(chars,
                    maxFit,end,sc,forceBreak);
            if(wordBoundary<0) 
            {
            	LOG.log(this.traceLevel,"No further split possible for: "+text
            			.substring(maxFit,end));
                if(forceBreak) return;
                
                /*
                 * See if an added newline would fit -- this means we're about
                 * to produce an empty line because we're at a word that is too
                 * long for the line. Forcibly split that one, as close to its end
                 * as possible.
                 */
                final Phrase nlph=this.eutil.getPhrase(styledText,
                        remainingTextStart,maxFit);
                nlph.add(NEWLINE_TEST_CHUNK);
                final ColumnText nltext=this.createColumnText(nlph,t,bounds);

                /* Newline didn't fit: normal case, no long word */
                if(ColumnText.hasMoreText(nltext.go(true))) return;
                
                /* Long word */
                LOG.log(this.traceLevel,"Long word, splitting by force");
                /* Enter next recursion with forceBreak set to true */
                wordBoundary=this.getCenterWordBoundary(chars,maxFit,end,sc,
                        forceBreak=true);
            }
            this.iTextFit(styledText,chars,bounds,remainingTextStart,
                    availableHeight,t,wordBoundary,maxFit,end,forceBreak);
        }
        else
        {
            /* This one fits... */
            t.textOffset=end;
            LOG.log(this.traceLevel,"Set box height to "+t.textHeight+", text offset to "+
                    t.textOffset+" (no page break, space sufficient) for text: "+
                    textabbr);
            
            /* But see if we couldn't fit more text. */
            final int wordBoundary=this.getCenterWordBoundary(chars,
                    end,minNoFit,sc,forceBreak);
            if(wordBoundary<0) 
            {
            	LOG.log(this.traceLevel,"No further split possible for: "+text
            			.substring(end,minNoFit));
            	return;
            }
            this.iTextFit(styledText,chars,bounds,remainingTextStart,
                    availableHeight,t,wordBoundary,end,minNoFit,forceBreak);
        }

    }

    protected ColumnText createColumnText(Phrase ph,JRMeasuredText t,Bounds b)
    {
        final ColumnText colText=new ColumnText(this.cb);
        LOG.log(this.traceLevel,"Calling setSimpleColumn() for phrase of len "+
                ph.getContent().length()+" with x0="+b.x+", y0="+b.y+", x1="+
                b.x1+", y1="+b.y1);
        /* Always use left aligned, doesn't matter */
        colText.setSimpleColumn(ph,b.x,b.y,b.x1,b.y1,0,Element.ALIGN_LEFT);
        colText.setLeading(0,t.getLineSpacingFactor());
        return colText;
    }

    /**
     * Determines the split position that is as exactly in the
     * center of the region denoted by <code>start</code> and <code>end</code>
     * in <code>s</code> as possible. If the string is only of length one or
     * less, or if no split character could be found, -1 is returned.
     */
    protected int getCenterWordBoundary(char[] s,int start,int end,
            SplitCharacter sc,boolean force)
    {
        assert start<end;
        int len=end-start;
        if(len<2) return -1;
        int p=start+len/2;
        if(sc.isSplitCharacter(start,p,end,s,null)) return p;

        /*
         * Search in both directions... this could be expensive with
         * the BreakIterator-based SplitCharacter implementation, though
         */
        int offset=1;
        do
        {
            if(p-offset>start)
            {
                if(sc.isSplitCharacter(start,p-offset,end,s,null)) return p-offset;
            }
            if(p+offset<end)
            {
                if(sc.isSplitCharacter(start,p+offset,end,s,null)) return p+offset;
            }
            offset++;
        }
        while(p+offset<end||p-offset>start);

        return force? p : -1;
    }

    protected static SplitCharacter getSplitCharacter(Phrase ph)
    {
        final List l=ph.getChunks();
        if(l.size()==0) return DefaultSplitCharacter.DEFAULT;
        final SplitCharacter c=(SplitCharacter)
                ((Chunk)l.get(0)).getAttributes().get(Chunk.SPLITCHARACTER);
        if(c==null) return DefaultSplitCharacter.DEFAULT;
        return c;
    }

    /**Abbreviates long texts for more compact log messages. */
    protected String abbr(String text)
    {
        if(text.length()>30)
        {
            return text.substring(0,14).replaceAll("\\n"," ")+".."+text.substring(
                    text.length()-14,text.length()).replaceAll("\\n"," ");
        }
        return text;
    }

    protected class Bounds
    {
        float x,x1,y,y1;

        Bounds(int availableHeight)
        {
            this.x=textElement.getLineBox().getLeftPadding().intValue();
            this.y=textElement.getLineBox().getBottomPadding().intValue();
            this.x1=textElement.getWidth()-textElement.getLineBox()
                    .getRightPadding().intValue();
            this.y1=availableHeight-textElement.getLineBox()
                    .getTopPadding().intValue();
        }
    }

}
