gov.noaa.pfel.coastwatch.sgt.SgtUtil.java Source code

Java tutorial

Introduction

Here is the source code for gov.noaa.pfel.coastwatch.sgt.SgtUtil.java

Source

/* 
 * SgtUtil Copyright 2005, NOAA.
 * See the LICENSE.txt file in this file's directory.
 */
package gov.noaa.pfel.coastwatch.sgt;

import com.cohort.array.DoubleArray;
import com.cohort.array.StringArray;
import com.cohort.util.Calendar2;
import com.cohort.util.File2;
import com.cohort.util.Image2;
import com.cohort.util.Math2;
import com.cohort.util.MustBe;
import com.cohort.util.ResourceBundle2;
import com.cohort.util.String2;
import com.cohort.util.Test;
import com.cohort.util.XML;

//from itext-1.3.1.jar:
import com.lowagie.text.Document;
//import com.lowagie.text.DocumentException;
//import com.lowagie.text.FontFactory;
import com.lowagie.text.PageSize;
//import com.lowagie.text.pdf.DefaultFontMapper;
import com.lowagie.text.pdf.PdfContentByte;
import com.lowagie.text.pdf.PdfTemplate;
import com.lowagie.text.pdf.PdfWriter;

import gov.noaa.pfel.coastwatch.griddata.DataHelper;
import gov.noaa.pfel.coastwatch.griddata.Grid;
import gov.noaa.pfel.coastwatch.util.AttributedString2;
import gov.noaa.pfel.coastwatch.util.SSR;

import gov.noaa.pmel.sgt.*;
import gov.noaa.pmel.sgt.demo.*;
import gov.noaa.pmel.sgt.dm.*;
import gov.noaa.pmel.util.*;

import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GraphicsEnvironment;
import java.awt.geom.GeneralPath;
import java.awt.Image;
import java.awt.ImageCapabilities;
import java.awt.image.BufferedImage;
import java.awt.RenderingHints;
import java.io.File;
import java.io.*;
import java.text.DecimalFormat;
import java.util.ArrayList;
import javax.imageio.ImageIO;

/**
 * This class has utilities for SgtMap and SgtGraph.
 * A note about coordinates:
 *   <ul>
 *   <li> Graph - uses "user" coordinates (e.g., lat and lon).
 *   <li> Layer - uses "physical" coordinates (doubles, 0,0 at lower left).
 *   <li> JPane - uses "device" coordinates (ints, 0,0 at upper left).
 *   </ul>
 */
public class SgtUtil {

    /**
     * Set this to true (by calling verbose=true in your program, not by changing the code here)
     * if you want lots of diagnostic messages sent to String2.log.
     */
    public static boolean verbose = false;
    public static boolean reallyVerbose = false;

    /** For the legend position. */
    public final static int LEGEND_RIGHT = 0;
    public final static int LEGEND_BELOW = 1;

    public final static com.lowagie.text.Rectangle PDF_LANDSCAPE = PageSize.LETTER.rotate();
    public final static com.lowagie.text.Rectangle PDF_PORTRAIT = PageSize.LETTER;

    public static final double DEFAULT_AXIS_LABEL_HEIGHT = 0.12;
    public static final double DEFAULT_LABEL_HEIGHT = 0.09; //in the legend    .08 causes problems with 'w' 'm'...

    public static final Color TRANSPARENT = new Color(0, 0, 0, 0); //4th 0 is alpha value   //Hmmm, it may not be this simple

    public static double AVG_CHAR_WIDTH = 4.5;

    public static String isBufferedImageAccelerated;

    /**
     * This returns the maxBoldCharsPerLine based on charsPerLine.
     *
     * @param legendTextWidth in pixels
     * @param fontScale 
     */
    public static int maxCharsPerLine(int legendTextWidth, double fontScale) {
        //lessen the effect of small fonts (they stay wide to stay legible)
        if (fontScale < 1)
            fontScale = (1 + fontScale) / 2;
        int m = Math2.roundToInt(legendTextWidth / (SgtUtil.AVG_CHAR_WIDTH * fontScale));
        //String2.log("\n***maxCharsPerLine=" + m + " legendWidth=" + legendTextWidth + " fontScale=" + fontScale);
        return m;
    }

    /**
     * This returns the maxBoldCharsPerLine based on charsPerLine.
     */
    public static int maxBoldCharsPerLine(int maxCharsPerLine) {
        return maxCharsPerLine * 9 / 10;
    }

    /**
     * This creates a font and throws exception if font family not available
     *
     * @param fontFamily
     * @throws Exception if fontFamily not available
     */
    public static Font getFont(String fontFamily) {
        //minor or major failures return a default font ("Dialog"!)
        Font font = new Font(fontFamily, Font.PLAIN, 10); //Font.ITALIC
        if (!font.getFamily().equals(fontFamily))
            Test.error(String2.ERROR + " in SgtUtil.getFont: " + fontFamily + " not available.\n"
                    + String2.javaInfo() + "\n" + "Fonts available: "
                    + String2.noLongLinesAtSpace(String2.toCSSVString(
                            GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()), 80,
                            "  "));
        return font;
    }

    /**
     * This converts the titles into a StringArray of non-"", non-null, not-too-long lines.
     * 
     * @return a StringArray of non-"", non-null, not-too-long lines.
     */
    public static StringArray makeShortLines(int maxCharsPerLine, String title2, String title3, String title4) {

        StringArray sa = new StringArray();
        splitLine(maxCharsPerLine, sa, title2);
        splitLine(maxCharsPerLine, sa, title3);
        splitLine(maxCharsPerLine, sa, title4);
        return sa;
    }

    /**
     * This draws the standard legend text for a BELOW legend.
     *
     * @param g2
     * @param legentTextX
     * @param legendTextY
     * @param fontFamily
     * @param labelHeightPixels
     * @param shortBoldLines must be valid  (if null, nothing will be drawn, and 
     *     return value will be legendTextY unchanged)
     * @param shortLines  from makeShortLines
     * @return the new legendTextY (adjusted so there is a gap after
     *   the current text).
     */
    public static int belowLegendText(Graphics2D g2, int legendTextX, int legendTextY, String fontFamily,
            int labelHeightPixels, StringArray shortBoldLines, StringArray shortLines) {

        //String2.log("belowLegendText boldTitle=" + boldTitle);
        if (shortBoldLines == null)
            return legendTextY;

        //draw the boldShortLines
        int n = shortBoldLines.size();
        for (int i = 0; i < n; i++)
            legendTextY = drawHtmlText(g2, legendTextX, legendTextY, 0, fontFamily, labelHeightPixels, false,
                    "<strong>" + encodeAsHtml(shortBoldLines.get(i)) + "</strong>");

        //draw the shortLines
        n = shortLines.size();
        for (int i = 0; i < n; i++)
            legendTextY = drawHtmlText(g2, legendTextX, legendTextY, 0, fontFamily, labelHeightPixels, i == n - 1,
                    encodeAsHtml(shortLines.get(i)));

        return legendTextY;
    }

    /**
     * This is creates "(units) date, title2", and deals with nulls and ""'s.
     *
     * @param units  e.g., m s^-1
     * @param date  e.g., "1998-02-28 14:00:00"
     * @param title2  e.g., "Horizontal line is mean."
     */
    public static String getNewTitle2(String units, String date, String title2) {
        StringBuilder sb = new StringBuilder();
        if (units != null && units.length() > 0)
            sb.append("(" + units + ") ");
        if (date != null && date.length() > 0)
            sb.append(date + " ");
        if (title2 != null)
            sb.append(title2);
        return sb.toString();
    }

    /**
     * If the line is short, this adds the line to StringArray.
     * If the line is long, this splits the line in 2 and
     * adds both to StringArray.
     *
     * @param limit is the maximum number of characters per line
     * @param stringArray to capture the parts of s
     * @param s the string to be split (if needed)  
     *    (if s == null or "", stringArray is unchanged).
     */
    private static void splitLine(int limit, StringArray stringArray, String s) {

        int limit10 = limit * 10;
        while (true) {
            if (s == null || s.length() == 0)
                return;

            int sLength = s.length();
            if (sLength <= limit * 2 / 3) { //short line is okay even if all caps
                stringArray.add(s);
                return;
            }

            //count through chars   noting more width of cap letters and digits, than avg letter
            int lastSpace = -1;
            int lastNonDigitChar = -1;
            int po = 0;
            int sum10 = 0;
            while (po < sLength && sum10 < limit10) {
                char ch = s.charAt(po);
                if (String2.isDigit(ch)) {
                    sum10 += 14;
                } else if (String2.isLetter(ch)) {
                    sum10 += ch == 'C' || ch == 'M' || ch == 'S' || ch == 'W' ? 16
                            : ch == 'c' || ch == 'm' || ch == 's' || ch == 'w' || ch == Character.toUpperCase(ch)
                                    ? 15
                                    : 10;
                } else if (ch == ' ') {
                    sum10 += 8;
                    lastSpace = po;
                    lastNonDigitChar = po;
                } else if ("<>=_".indexOf(ch) >= 0) {
                    sum10 += 17;
                } else {
                    sum10 += 10;
                    lastNonDigitChar = po;
                }
                po++;
            }

            //po == sLength is success
            //if just a few chars more, let it go
            if (po + 4 >= sLength) {
                stringArray.add(s);
                return;
            }

            //break at last space (or nonDigitLetter) before limit
            po = lastSpace >= limit * 3 / 4 ? lastSpace : //preferred
                    lastNonDigitChar >= limit / 2 ? lastNonDigitChar : //next best
                            po; //worst case

            //add the string
            stringArray.add(s.substring(0, po + 1));

            //revamp s
            s = s.substring(po + 1).trim(); //remove leading space, if any
        }
    }

    /**
     * drawHtmlText draws simple HTML text to g2d.
     * drawHtmlText benefits greatly from setting non-text antialising ON:
     * <tt>g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, 
     *         RenderingHints.VALUE_ANTIALIAS_ON); </tt>
     *
     * @param g2d
     * @param x the base x for the text  (in pixels)
     * @param y the base y for the text  (in pixels)
     * @param hAlign one of SGLabel.LEFT|CENTER|RIGHT (0=left 1=center 2=right).
     * @param fontFamily
     * @param labelHeight (in pixels)
     * @param extraGapBelow adds an extra labelHeight to the returned yAdjusted
     *   (even if htmlText is null or "")
     * @param htmlText  Unless modified by a <color=#ffffff> tag, the text will be black.
     * @return y adjusted to prepare for the text below this text
     */
    public static int drawHtmlText(Graphics2D g2d, int x, int y, int hAlign, String fontFamily, int labelHeight,
            boolean extraGapBelow, String htmlText) {

        if (htmlText == null || htmlText.length() == 0)
            return y + (extraGapBelow ? labelHeight : 0);

        //quick fix red affecting whole string?
        //htmlText= "<color=#000000> " + htmlText;
        //String2.log("drawHtmlText=" + htmlText);
        AttributedString2.drawHtmlText(g2d, htmlText, x, y, fontFamily, labelHeight, Color.black, hAlign);
        return y + labelHeight + (extraGapBelow ? labelHeight : 0);
    }

    /**
     * This is a special version of XML.encodeAsHTML that  
     * displays any occurence of "EXPERIMENTAL PRODUCT" or "EXPERIMENTAL" in red.
     * @param plainText
     * @return htmlText
     */
    public static String encodeAsHtml(String plainText) {
        if (plainText == null || plainText.length() == 0)
            return "";
        int po = plainText.indexOf("EXPERIMENTAL PRODUCT");
        if (po >= 0)
            return XML.encodeAsHTML(plainText.substring(0, po)) + "<color=#ff0000>EXPERIMENTAL PRODUCT</color>"
                    + XML.encodeAsHTML(plainText.substring(po + 20));

        po = plainText.indexOf("EXPERIMENTAL");
        if (po >= 0)
            return XML.encodeAsHTML(plainText.substring(0, po)) + "<color=#ff0000>EXPERIMENTAL</color>"
                    + XML.encodeAsHTML(plainText.substring(po + 12));

        return XML.encodeAsHTML(plainText);
    }

    /**
     * This makes a new bufferedImage suitable for SgtMap.makeMap or SgtGraph.makeGraph.
     * The background is white.
     *
     * @param gifWidth
     * @param gifHeight
     * @return a bufferedImage of the requested size
     * @throws Exception if trouble
     */
    public static BufferedImage getBufferedImage(int gifWidth, int gifHeight) {

        //  Work with BufferedImage requires the following line be added to
        //  beginning of startup.sh:
        //    export JAVA_OPTS=-Djava.awt.headless=true
        BufferedImage bi = new BufferedImage(gifWidth, gifHeight, BufferedImage.TYPE_INT_RGB);
        Graphics g = bi.getGraphics();
        Graphics2D g2 = (Graphics2D) g;
        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g2.setColor(Color.white); //I'm not sure why necessary, but it is
        g2.fillRect(0, 0, gifWidth, gifHeight); //I'm not sure why necessary, but it is

        return bi;
    }

    /** This returns a message indicating if graphics operations on bufferedImages
     * are hardware accelerated. */
    public static String isBufferedImageAccelerated() {
        if (isBufferedImageAccelerated == null) {
            try {
                BufferedImage bi = getBufferedImage(10, 10);
                ImageCapabilities imCap = bi.getCapabilities(null);
                isBufferedImageAccelerated = "bufferedImage isAccelerated="
                        + (imCap == null ? "[unknown]" : imCap.isAccelerated());
            } catch (Throwable t) {
                String2.log(MustBe.throwableToString(t));
                isBufferedImageAccelerated = "bufferedImage isAccelerated=[unknown]";
            }
        }
        return isBufferedImageAccelerated;
    }

    /**
     * This reads a image using Java's ImageIO routines.
     *
     * 
     * @param fullName with directory and extension
     * @return a BufferedImage
     * @throws Exception if trouble
     */
    public static BufferedImage readImage(String fullName) throws Exception {
        return ImageIO.read(new File(fullName));
    }

    /**
     * Saves an image as a non-transparent .gif or .png based on the fullImageName's extension.
     * This will overwrite an existing file.
     * Gif's are saved with ImageMagick's convert (which does great color reduction).
     *
     * @param bi
     * @param fullName with directory and extension
     * @throws Exception if trouble
     */
    public static void saveImage(BufferedImage bi, String fullName) throws Exception {
        String shortName = fullName.substring(0, fullName.length() - 4); //currently, all extensions are 4 char
        if (fullName.endsWith(".gif"))
            saveAsGif(bi, shortName);
        else if (fullName.endsWith(".png"))
            saveAsPng(bi, shortName);
        //else if (fullName.endsWith(".jpg"))
        //    saveAsJpg(bi, shortName);
        else
            Test.error(
                    String2.ERROR + " in SgtUtil.saveImage: " + "Unsupported image type for fileName=" + fullName);
    }

    /**
     * Saves an image as a gif.
     * Currently this uses ImageMagick's "convert" (Windows or Linux) because it 
     * does the best job at color reduction (and is fast and is cross-platform).
     * This will overwrite an existing file.
     *
     * @param bi
     * @param fullGifName but without the .gif at the end
     * @throws Exception if trouble
     */
    public static void saveAsGif(BufferedImage bi, String fullGifName) throws Exception {

        //POLICY: because this procedure may be used in more than one thread,
        //do work on unique temp files names using randomInt, then rename to proper file name.
        //If procedure fails half way through, there won't be a half-finished file.
        int randomInt = Math2.random(Integer.MAX_VALUE);

        //save as .bmp     (note: doesn't support transparent pixels)
        long time = System.currentTimeMillis();
        if (verbose)
            String2.log("SgtUtil.saveAsGif");
        ImageIO.write(bi, "bmp", new File(fullGifName + randomInt + ".bmp"));
        if (verbose)
            String2.log("  make .bmp done. time=" + (System.currentTimeMillis() - time) + "ms");

        //"convert" to .gif 
        SSR.dosOrCShell("convert " + fullGifName + randomInt + ".bmp" + " " + fullGifName + randomInt + ".gif", 30);
        File2.delete(fullGifName + randomInt + ".bmp");

        //try fancy color reduction algorithms
        //Image2.saveAsGif(Image2.reduceTo216Colors(bi), fullGifName + randomInt + ".gif");

        //try dithering 
        //Image2.saveAsGif216(bi, fullGifName + randomInt + ".gif", true);

        //last step: rename to final gif name
        File2.rename(fullGifName + randomInt + ".gif", fullGifName + ".gif");

        if (verbose)
            String2.log("SgtUtil.saveAsGif done. TOTAL TIME=" + (System.currentTimeMillis() - time) + "ms\n");
    }

    /**
     * Saves an image as a gif.
     * Currently this uses ImageMagick's "convert" (Windows or Linux) because it 
     * does the best job at color reduction (and is fast and is cross-platform).
     * This will overwrite an existing file.
     *
     * @param bi
     * @param transparent the color to be made transparent
     * @param fullGifName but without the .gif at the end
     * @throws Exception if trouble
     */
    public static void saveAsTransparentGif(BufferedImage bi, Color transparent, String fullGifName)
            throws Exception {

        //POLICY: because this procedure may be used in more than one thread,
        //do work on unique temp files names using randomInt, then rename to proper file name.
        //If procedure fails half way through, there won't be a half-finished file.
        int randomInt = Math2.random(Integer.MAX_VALUE);

        //convert transparent color to be transparent
        long time = System.currentTimeMillis();
        Image image = Image2.makeImageBackgroundTransparent(bi, transparent, 10000);

        //convert image back to bufferedImage
        bi = new BufferedImage(bi.getWidth(), bi.getHeight(), BufferedImage.TYPE_INT_ARGB);
        Graphics g = bi.getGraphics();
        g.drawImage(image, 0, 0, bi.getWidth(), bi.getHeight(), null);
        image = null; //encourage garbage collection

        //save as png
        int random = Math2.random(Integer.MAX_VALUE);
        ImageIO.write(bi, "png", new File(fullGifName + randomInt + ".png"));

        //"convert" to .gif 
        SSR.dosOrCShell("convert " + fullGifName + randomInt + ".png" + " " + fullGifName + randomInt + ".gif", 30);
        File2.delete(fullGifName + randomInt + ".png");

        //try fancy color reduction algorithms
        //Image2.saveAsGif(Image2.reduceTo216Colors(bi), fullGifName + randomInt + ".gif");

        //try dithering 
        //Image2.saveAsGif216(bi, fullGifName + randomInt + ".gif", true);

        //last step: rename to final gif name
        File2.rename(fullGifName + randomInt + ".gif", fullGifName + ".gif");

        if (verbose)
            String2.log("SgtUtil.saveAsTransparentGif TIME=" + (System.currentTimeMillis() - time) + "ms\n");
    }

    /**
     * Saves an image as a png.
     * This will overwrite an existing file.
     *
     * @param bi
     * @param fullPngName but without the .png at the end
     * @throws Exception if trouble
     */
    public static void saveAsPng(BufferedImage bi, String fullPngName) throws Exception {
        saveAsTransparentPng(bi, null, fullPngName);
    }

    /**
     * Saves an image as a png.
     * This will overwrite an existing file.
     *
     * @param bi
     * @param transparent the color to be made transparent  (or null if none)
     * @param fullPngName but without the .png at the end
     * @throws Exception if trouble
     */
    public static void saveAsTransparentPng(BufferedImage bi, Color transparent, String fullPngName)
            throws Exception {

        //POLICY: because this procedure may be used in more than one thread,
        //do work on unique temp files names using randomInt, then rename to proper file name.
        //If procedure fails half way through, there won't be a half-finished file.
        int randomInt = Math2.random(Integer.MAX_VALUE);

        //create fileOutputStream
        BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fullPngName + randomInt + ".png"));

        //save the image
        saveAsTransparentPng(bi, transparent, bos);
        bos.close();

        //last step: rename to final Png name
        File2.rename(fullPngName + randomInt + ".png", fullPngName + ".png");

    }

    /**
     * Saves an image as a png.
     * This will overwrite an existing file.
     *
     * @param bi
     * @param outputStream
     * @throws Exception if trouble
     */
    public static void saveAsPng(BufferedImage bi, OutputStream outputStream) throws Exception {
        saveAsTransparentPng(bi, null, outputStream);
    }

    /**
     * Saves an image as a png.
     * This will overwrite an existing file.
     *
     * @param bi
     * @param transparent the color to be made transparent  (or null if none)
     * @param outputStream  (it is flushed at the end)
     * @throws Exception if trouble
     */
    public static void saveAsTransparentPng(BufferedImage bi, Color transparent, OutputStream outputStream)
            throws Exception {

        //convert transparent color to be transparent
        long time = System.currentTimeMillis();
        if (transparent != null) {
            Image image = Image2.makeImageBackgroundTransparent(bi, transparent, 10000);

            //convert image back to bufferedImage
            bi = new BufferedImage(bi.getWidth(), bi.getHeight(), BufferedImage.TYPE_INT_ARGB);
            Graphics g = bi.getGraphics();
            g.drawImage(image, 0, 0, bi.getWidth(), bi.getHeight(), null);
        }

        //save as png
        ImageIO.write(bi, "png", outputStream);
        outputStream.flush();

        if (verbose)
            String2.log("SgtUtil.saveAsPng TIME=" + (System.currentTimeMillis() - time) + "ms\n");
    }

    /**
     * This creates a file to capture the pdf output generated by calls to 
     * graphics2D (e.g., use makeMap).
     * This will overwrite an existing file.
     * 
     * @param pageSize e.g, PageSize.LETTER or PageSize.LETTER.rotate() (or A4, or, ...)
     * @param width the bounding box width, in 1/144ths of an inch
     * @param height the bounding box height, in 1/144ths of an inch
     * @param fullFileName (with the extension .pdf)
     * @return an object[] with 0=g2D, 1=document, 2=pdfContentByte, 3=pdfTemplate
     * @throws Exception if trouble
     */
    public static Object[] createPdf(com.lowagie.text.Rectangle pageSize, int bbWidth, int bbHeight,
            String fullFileName) throws Exception {
        return createPdf(pageSize, bbWidth, bbHeight, new FileOutputStream(fullFileName));
    }

    /**
     * This creates a file to capture the pdf output generated by calls to 
     * graphics2D (e.g., use makeMap).
     * This will overwrite an existing file.
     * 
     * @param pageSize e.g, PageSize.LETTER or PageSize.LETTER.rotate() (or A4, or, ...)
     * @param width the bounding box width, in 1/144ths of an inch
     * @param height the bounding box height, in 1/144ths of an inch
     * @param outputStream
     * @return an object[] with 0=g2D, 1=document, 2=pdfContentByte, 3=pdfTemplate
     * @throws Exception if trouble
     */
    public static Object[] createPdf(com.lowagie.text.Rectangle pageSize, int bbWidth, int bbHeight,
            OutputStream outputStream) throws Exception {
        //currently, this uses itext
        //see the sample program:
        //  file://localhost/C:/programs/iText/examples/com/lowagie/examples/directcontent/graphics2D/G2D.java
        //Document.compress = false; //for test purposes only
        Document document = new Document(pageSize);
        document.addCreationDate();
        document.addCreator("gov.noaa.pfel.coastwatch.SgtUtil.createPdf");

        document.setPageSize(pageSize);
        PdfWriter writer = PdfWriter.getInstance(document, outputStream);
        document.open();

        //create contentByte and template and Graphics2D objects
        PdfContentByte pdfContentByte = writer.getDirectContent();
        PdfTemplate pdfTemplate = pdfContentByte.createTemplate(bbWidth, bbHeight);
        Graphics2D g2D = pdfTemplate.createGraphics(bbWidth, bbHeight);

        return new Object[] { g2D, document, pdfContentByte, pdfTemplate };
    }

    /**
     * This closes the pdf file created by createPDF, after you have
     * written things to g2D.
     *
     * @param oar the object[] returned from createPdf
     * @throwsException if trouble
     */
    public static void closePdf(Object oar[]) throws Exception {
        Graphics2D g2D = (Graphics2D) oar[0];
        Document document = (Document) oar[1];
        PdfContentByte pdfContentByte = (PdfContentByte) oar[2];
        PdfTemplate pdfTemplate = (PdfTemplate) oar[3];

        g2D.dispose();

        //center it
        if (verbose)
            String2.log("SgtUtil.closePdf" + " left=" + document.left() + " right=" + document.right() + " bottom="
                    + document.bottom() + " top=" + document.top() + " template.width=" + pdfTemplate.getWidth()
                    + " template.height=" + pdfTemplate.getHeight());
        //x device = ax user + by user + e
        //y device = cx user + dy user + f
        pdfContentByte.addTemplate(pdfTemplate, //a,b,c,d,e,f      //x,y location in points 
                0.5f, 0, 0, 0.5f,
                document.left() + (document.right() - document.left() - pdfTemplate.getWidth() / 2) / 2,
                document.bottom() + (document.top() - document.bottom() - pdfTemplate.getHeight() / 2) / 2);

        /*
        //if boundingBox is small, center it
        //if boundingBox is large, shrink and center it
        //document.left/right/top/bottom include 1/2" margins
        float xScale = (document.right() - document.left())   / pdfTemplate.getWidth();   
        float yScale = (document.top()   - document.bottom()) / pdfTemplate.getHeight();  
        float scale = Math.min(Math.min(xScale, yScale), 1);
        float xSize = pdfTemplate.getWidth()  / scale;
        float ySize = pdfTemplate.getHeight() / scale;
        //x device = ax user + by user + e
        //y device = cx user + dy user + f
        pdfContentByte.addTemplate(pdfTemplate, //a,b,c,d,e,f
        scale, 0, 0, scale, 
        document.left()   + (document.right() - document.left()   - xSize) / 2,
        document.bottom() + (document.top()   - document.bottom() - ySize) / 2); 
        */

        document.close();
    }

    /**
     * This returns a whiter color than c.
     *
     * @param color
     * @return a whiter color than c
     */
    public static Color whiter(Color color) {
        int r = color.getRed();
        int g = color.getGreen();
        int b = color.getBlue();
        return new Color(r + (255 - r) / 4, //little changes close to 255 have big effect
                g + (255 - g) / 4, b + (255 - b) / 4);
    }

    /**
     * This returns a blacker color than c.
     *
     * @param color
     * @return a blacker color than c
     */
    public static Color blacker(Color color) {
        int r = color.getRed();
        int g = color.getGreen();
        int b = color.getBlue();
        return new Color(Math.max(0, r - (255 - r) / 4), //little changes close to 255 have big effect
                Math.max(0, g - (255 - g) / 4), Math.max(0, b - (255 - b) / 4));
    }

    /** 
     * The default palette (aka color bar) range ([0]=min, [1]=max). 
     * The values are also suitable for the axis range on a graph.
     * 
     * @param dataMin the raw minimum value of the data
     * @param dataMax the raw maximum value of the data
     * @return the default palette (aka color bar) range ([0]=min, [1]=max).
     */
    public static double[] suggestPaletteRange(double dataMin, double dataMax) {

        double lowHigh[] = Math2.suggestLowHigh(dataMin, dataMax);

        //log axis?
        if (suggestPaletteScale(dataMin, dataMax).equals("Log")) { //yes, use dataMin,dataMax,  not lowHigh
            lowHigh[0] = Math2.suggestLowHigh(dataMin, 2 * dataMin)[0]; //trick to get nice suggested min>0
            return lowHigh;
        }

        //axis is linear
        //suggest symmetric around 0 (symbolized by BlueWhiteRed)?
        if (suggestPalette(dataMin, dataMax).equals("BlueWhiteRed")) { //yes, use dataMin,dataMax,  not lowHigh
            double rangeMax = Math.max(-lowHigh[0], lowHigh[1]);
            lowHigh[0] = -rangeMax;
            lowHigh[1] = rangeMax;
        }

        //standard Rainbow Linear
        return lowHigh;
    }

    /** 
     * The name of the suggested palette (aka color bar), e.g., Rainbow or BlueWhiteRed. 
     * Must be one of the palettes available to PointDataSets in the browser.
     *
     * @param min the raw minimum value of the data (preferred) or the refined minimum value for the palette
     * @param max the raw maximum value of the data (preferred) or the refined maximum value for the palette
     * @return the name of the suggested palette (aka color bar), e.g., Rainbow. 
     *    "BlueWhiteRed" is suggested if the palette should be centered on 0.
     */
    public static String suggestPalette(double min, double max) {
        if (min < 0 && max > 0 && -min / max >= .5 && -min / max <= 2)
            return "BlueWhiteRed";
        if (min >= 0 && min < max / 5)
            return "WhiteRedBlack";
        return "Rainbow";
    }

    /** 
     * The name of the suggested palette scale, e.g., Linear or Log. 
     *
     * @param min the raw minimum value of the data (preferred) or the refined minimum value for the palette
     * @param max the raw maximum value of the data (preferred) or the refined maximum value for the palette
     * @return the name of the suggested palette (aka color bar) scale, e.g., Linear or Log. 
     */
    public static String suggestPaletteScale(double min, double max) {
        if (min > 0 && min < 1 && max / min > 100)
            return "Log";
        return "Linear";
    }

    /** This find the low and high pixels with the legend
     * (assuming the legend is near the bottom and is along the left edge,
     * and spans the width of the image).
     *
     * @return the low (a smaller number) and high y (a bigger number) of the legend.
     *   They are the y's of the edges.
     *   If trouble, this returns {0, 0}.
     */
    public static int[] findLegendLH(BufferedImage bufferedImage) {
        int black = 0xFF000000;
        int height = bufferedImage.getHeight();
        int lh[] = new int[] { 0, 0 };

        //find bottom edge
        for (int y = height - 1; y >= 0; y--) {
            if (bufferedImage.getRGB(0, y) == black) {
                lh[1] = y;
                break;
            }
        }

        //find top edge
        for (int y = lh[1] - 1; y >= 0; y--) {
            if (bufferedImage.getRGB(0, y) != black) {
                lh[0] = y + 1;
                break;
            }
        }
        //String2.log("findLegendLH low=" + lh[0] + " high=" + lh[1]);
        return lh;
    }

    /**
     * Given a bufferedImage with a legend near the bottom (entire width of image),
     * this replaces the legend with white.
     *
     * @param bufferedImage
     * @return a bufferedImage without the legend.
     *    If trouble, this returns the original image.
     */
    public static BufferedImage removeLegend(BufferedImage bufferedImage) {
        try {
            int lh[] = findLegendLH(bufferedImage);
            if (lh[0] == 0 && lh[1] == 0)
                return bufferedImage;

            Graphics g = bufferedImage.getGraphics();
            g.setColor(Color.white); //white
            g.fillRect(0, lh[0], bufferedImage.getWidth(), lh[1] - lh[0] + 1);
        } catch (Throwable t) {
            String2.log(MustBe.throwableToString(t));
        }
        return bufferedImage;
    }

    /**
     * Given a bufferedImage with a legend near the bottom (entire width of image),
     * this returns an image with just the legend.
     *
     * @param bufferedImage
     * @return a bufferedImage with just the legend.
     * @throws RuntimeException if trouble (e.g., no legend found)
     */
    public static BufferedImage extractLegend(BufferedImage bufferedImage) {
        int lh[] = findLegendLH(bufferedImage);
        if (lh[0] == 0 && lh[1] == 0)
            throw new RuntimeException("Legend not found.");

        int width = bufferedImage.getWidth();
        int height = lh[1] - lh[0] + 1;
        BufferedImage newBI = getBufferedImage(width, height);
        Graphics g = newBI.getGraphics();
        g.drawImage(bufferedImage, 0, 0, width, height, //dest     params are exclusive
                0, lh[0], width, lh[1] + 1, //source   
                null); //documentation differs, but I think it blocks till finished if no observer
        return newBI;
    }

    /**
     * Given a bufferedImage, this removes any whitespace more than 10 lines at the bottom.
     *
     * @param bufferedImage
     * @param borderWidth  in pixels
     * @return a trimmed bufferedImage.
     *    If trouble, this returns the original image.
     */
    public static BufferedImage trimBottom(BufferedImage bufferedImage, int borderWidth) {
        try {

            if (borderWidth < 0)
                borderWidth = 0;
            if (borderWidth > 1000)
                return bufferedImage;

            //find the first non-white pixel above bottom edge
            int width = bufferedImage.getWidth();
            int height = bufferedImage.getHeight();
            int y;
            Y_LOOP: for (y = height - 1; y >= 0; y--) {
                for (int x = 0; x < width; x++) {
                    if (bufferedImage.getRGB(x, y) != 0xFFFFFFFF)
                        break Y_LOOP;
                }
            }

            //if (verbose) String2.log("trimBottom y=" + y + " height=" + height);
            int newHeight = y + borderWidth + 1;
            if (y < 0 || newHeight >= height)
                return bufferedImage;

            BufferedImage newBI = getBufferedImage(width, newHeight);
            Graphics g = newBI.getGraphics();
            g.drawImage(bufferedImage, 0, 0, width, newHeight, //dest     params are exclusive
                    0, 0, width, newHeight, //source   
                    null); //documentation differs, but I think it blocks till finished if no observer
            return newBI;

        } catch (Throwable t) {
            String2.log(MustBe.throwableToString(t));
        }
        return bufferedImage;
    }

    /**
     * Given a bufferedImage with a rectangular graph/map at the top, 
     * this returns the left, right, bottom, top of the graph (from human perspective).
     * Since y=0 at top of image, the returned top value will be a lower value than bottom.
     *
     * @param bufferedImage
     * @return int[4] with left, right, bottom, top of the graph.
     *    If trouble, this returns null.
     */
    public static int[] findGraph(BufferedImage bufferedImage) {
        //rely on this try/catch to catch errors
        try {

            int width = bufferedImage.getWidth();
            int height = bufferedImage.getHeight();
            int centerX = width / 2;

            //starting at top center, go down to first back pixel
            int top = 0;
            while (bufferedImage.getRGB(centerX, top) != 0xff000000 || //look at main pixel
                    bufferedImage.getRGB(centerX - 1, top) != 0xff000000 || //and to left
                    bufferedImage.getRGB(centerX + 1, top) != 0xff000000) { //and to right
                top++;
                //String2.log("top=" + top + " 0x" + Integer.toHexString(bufferedImage.getRGB(centerX, top)));
            }

            //go left to left  (may be fooled by tic mark)
            int left = centerX - 1;
            while (bufferedImage.getRGB(left - 1, top) == 0xff000000) {
                left--;
                //String2.log("left=" + left + " 0x" + Integer.toHexString(bufferedImage.getRGB(left-1, top)));
            }

            //backtrack left if it was tic mark
            while (bufferedImage.getRGB(left, top + 1) != 0xff000000) {
                left++;
                //String2.log("backtrack left=" + left + " 0x" + Integer.toHexString(bufferedImage.getRGB(left, top-1)));
            }

            //go right to right
            int right = centerX + 1;
            while (bufferedImage.getRGB(right + 1, top) == 0xff000000) {
                right++;
                //String2.log("right=" + right + " 0x" + Integer.toHexString(bufferedImage.getRGB(right+1, top)));
            }

            //go down to bottom (may be fooled by tick mark)
            int bottom = top;
            while (bufferedImage.getRGB(left, bottom + 1) == 0xff000000
                    && bufferedImage.getRGB(right, bottom + 1) == 0xff000000) {
                bottom++;
                //String2.log("bottom=" + bottom + " 0x" + Integer.toHexString(bufferedImage.getRGB(left, bottom-1)));
            }

            //backtrack bottom if it was tick mark
            while (bufferedImage.getRGB(left + 1, bottom) != 0xff000000
                    || bufferedImage.getRGB(right - 1, bottom) != 0xff000000) {
                bottom--;
                //String2.log("backtrack bottom=" + bottom + " 0x" + Integer.toHexString(bufferedImage.getRGB(left+1, bottom)));
            }

            //don't bother to check integrity of bottom edge
            //String2.log("success " + left + " " + right + " " + bottom + " " + top);
            return new int[] { left, right, bottom, top };

        } catch (Throwable t) {
            String2.log("SgtUtil.findGraph failed.\n" + MustBe.throwableToString(t));
            return null;
        }
    }

    /**
     * This is used to convert an image'x x,y location into lonLat values.
     *
     * @param x  x pixel of user click on image
     * @param y  y pixel of user click on image
     * @param intWESN is the WESN of the graph on the image.
     *    Note that S will be numerically greater than N.
     * @param doubleWESN is the lon lat WESN of the graph on the image
     * @param extentWESN is the maximum extent allowed (so center not shifted too far)
     * @return double[2] 0=lon 1=lat.
     *    or null if trouble (e.g., intWESN is null)
     */
    public static double[] xyToLonLat(int x, int y, int[] intWESN, double[] doubleWESN, double[] extentWESN) {
        if (intWESN == null || doubleWESN == null || intWESN[0] >= intWESN[1] || intWESN[2] <= intWESN[3])
            return null;

        double xRange = doubleWESN[1] - doubleWESN[0];
        double yRange = doubleWESN[3] - doubleWESN[2];
        double newX, newY;

        if (x < intWESN[0] || x > intWESN[1]) {
            //a click outside of graph shifts center to (theoretical) adjacent panel
            newX = x < intWESN[0] ? doubleWESN[0] - xRange / 2 : doubleWESN[1] + xRange / 2;
            newY = y < intWESN[3] ? doubleWESN[3] + yRange / 2 : //N
                    y > intWESN[2] ? doubleWESN[2] - yRange / 2 : //S
                            doubleWESN[2] + yRange / 2;

            //ensure not too far
            newX = Math.max(newX, extentWESN[0] + xRange / 2);
            newX = Math.min(newX, extentWESN[1] - xRange / 2);
            newY = Math.max(newY, extentWESN[2] + yRange / 2);
            newY = Math.min(newY, extentWESN[3] - yRange / 2);

        } else if (y < intWESN[3] || y > intWESN[2]) {
            //y is outside of graph, but x must be within graph
            newX = doubleWESN[0] + xRange / 2;
            newY = y < intWESN[3] ? doubleWESN[3] + yRange / 2 : doubleWESN[2] - yRange / 2;

            //ensure not too far
            newX = Math.max(newX, extentWESN[0] + xRange / 2);
            newX = Math.min(newX, extentWESN[1] - xRange / 2);
            newY = Math.max(newY, extentWESN[2] + yRange / 2);
            newY = Math.min(newY, extentWESN[3] - yRange / 2);

        } else {
            //click within the graph
            newX = doubleWESN[0] + (x - intWESN[0]) * xRange / (intWESN[1] - intWESN[0]);
            newY = doubleWESN[2] + (y - intWESN[2]) * yRange / (intWESN[3] - intWESN[2]);
        }

        return new double[] { newX, newY };
    }

    /** This tests SgtUtil. */
    public static void test() throws Exception {
        //test splitLine
        String2.log("\n*** SgtUtil.test");
        StringArray sa = new StringArray();

        //wide  
        sa.clear();
        splitLine(38, sa, "This is a test of splitline.");
        Test.ensureEqual(sa.size(), 1, "");
        Test.ensureEqual(sa.get(0), "This is a test of splitline.", "");

        //narrow 
        sa.clear();
        splitLine(12, sa, "This is a test of splitline.");
        Test.ensureEqual(sa.size(), 3, "");
        Test.ensureEqual(sa.get(0), "This is a ", "");
        Test.ensureEqual(sa.get(1), "test of ", "");
        Test.ensureEqual(sa.get(2), "splitline.", "");

        //narrow and can't split, so chop at limit
        sa.clear();
        splitLine(12, sa, "This1is2a3test4of5splitline.");
        Test.ensureEqual(sa.size(), 3, "");
        Test.ensureEqual(sa.get(0), "This1is2a3t", "");
        Test.ensureEqual(sa.get(1), "est4of5split", "");
        Test.ensureEqual(sa.get(2), "line.", "");

        //caps
        sa.clear();
        splitLine(12, sa, "THESE ARE a a REALLY WIDE.");
        Test.ensureEqual(sa.size(), 3, "");
        Test.ensureEqual(sa.get(0), "THESE ARE ", "");
        Test.ensureEqual(sa.get(1), "a a REALLY ", "");
        Test.ensureEqual(sa.get(2), "WIDE.", "");

        //test suggestPaletteRange
        Test.ensureEqual(suggestPaletteRange(.3, 8.9), new double[] { 0, 10 }, ""); //typical Rainbow Linear
        Test.ensureEqual(suggestPaletteRange(.11, 890), new double[] { .1, 1000 }, ""); //typical Rainbow Log
        Test.ensureEqual(suggestPaletteRange(-7, 8), new double[] { -10, 10 }, ""); //typical BlueWhiteRed Linear symmetric

        //test suggestPalette
        Test.ensureEqual(suggestPalette(.3, 8.9), "WhiteRedBlack", ""); //small positive, large positive
        Test.ensureEqual(suggestPalette(300, 890), "Rainbow", ""); //typical Rainbow Log
        Test.ensureEqual(suggestPalette(-7, 8), "BlueWhiteRed", ""); //typical BlueWhiteRed Linear symmetric

        //test suggestPaletteScale
        Test.ensureEqual(suggestPaletteScale(.3, 8.9), "Linear", ""); //typical Rainbow Linear
        Test.ensureEqual(suggestPaletteScale(.11, 890), "Log", ""); //typical Rainbow Log
        Test.ensureEqual(suggestPaletteScale(-7, 8), "Linear", ""); //typical BlueWhiteRed Linear symmetric

        BufferedImage bi = ImageIO.read(new File(String2.unitTestDataDir + "graphs/erdBAssta5day.png"));
        long time = System.currentTimeMillis();
        Test.ensureEqual(findGraph(bi), new int[] { 24, 334, 150, 21 }, "");
        String2.log("findGraph time=" + (System.currentTimeMillis() - time) + "ms");
    }

    //*** Junk Yard *******
    //create the colorbar for the legend
    /*ColorKey colorKey = new ColorKey(new Point2D.Double(4.5, 3), //location 
        new Dimension2D(0.25, 2.5), //size
        ColorKey.TOP, ColorKey.LEFT); //valign, halign
    colorKey.setOrientation(ColorKey.VERTICAL);
    colorKey.setBorderStyle(ColorKey.NO_BORDER);
    colorKey.setColorMap(colorMap);
    Ruler ruler = colorKey.getRuler();
    ruler.setLabelFont(labelFont);
    ruler.setLabelHeightP(0.15);
    ruler.setLabelInterval(2); //temp
    ruler.setLargeTicHeightP(0.04);
    ruler.setRangeU(colorMap.getRange());
    String2.log("colorMap start=" + colorMap.getRange().start + " end=" +
        colorMap.getRange().end + " delta=" + colorMap.getRange().delta);
    layer.addChild(colorKey);
    */

}