Beispiel #1
0
  private PdfWorksheet(OutputStream out, Collection<SubjectArea> subjectAreas, String courseNumber)
      throws IOException, DocumentException {
    iUseCommitedAssignments = ApplicationProperty.WorksheetPdfUseCommittedAssignments.isTrue();
    iSubjectAreas =
        new TreeSet<SubjectArea>(
            new Comparator<SubjectArea>() {
              @Override
              public int compare(SubjectArea s1, SubjectArea s2) {
                return s1.getSubjectAreaAbbreviation().compareTo(s2.getSubjectAreaAbbreviation());
              }
            });
    iSubjectAreas.addAll(subjectAreas);
    iCourseNumber = courseNumber;
    if (iCourseNumber != null
        && (iCourseNumber.trim().length() == 0 || "*".equals(iCourseNumber.trim().length())))
      iCourseNumber = null;
    iDoc = new Document(PageSize.LETTER.rotate());

    iOut = out;
    PdfWriter.getInstance(iDoc, iOut);

    String session = null;
    String subjects = "";
    for (SubjectArea sa : iSubjectAreas) {
      if (subjects.isEmpty()) subjects += ", ";
      subjects += sa.getSubjectAreaAbbreviation();
      if (session == null) session += sa.getSession().getLabel();
    }
    iDoc.addTitle(subjects + (iCourseNumber == null ? "" : " " + iCourseNumber) + " Worksheet");
    iDoc.addAuthor(
        ApplicationProperty.WorksheetPdfAuthor.value().replace("%", Constants.getVersion()));
    iDoc.addSubject(subjects + (session == null ? "" : " -- " + session));
    iDoc.addCreator("UniTime " + Constants.getVersion() + ", www.unitime.org");
    if (!iSubjectAreas.isEmpty()) iCurrentSubjectArea = iSubjectAreas.first();

    iDoc.open();

    printHeader();
  }
Beispiel #2
0
/**
 * 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 static final int LEGEND_RIGHT = 0;

  public static final int LEGEND_BELOW = 1;

  public static final com.lowagie.text.Rectangle PDF_LANDSCAPE = PageSize.LETTER.rotate();
  public static final 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,
              "<b>" + encodeAsHtml(shortBoldLines.get(i)) + "</b>");

    // 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));

    // "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) + "\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) + "\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) + "\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));
  }

  // *** 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);
  */

}