package nxm.sys.lib;

import java.awt.Color;
import java.io.Serializable;
import java.lang.reflect.Array;
import java.text.DecimalFormat;
import java.text.FieldPosition;
import java.text.ParseException;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.TimeZone;
import java.util.regex.Pattern;

import nxm.sys.inc.Constants;
import nxm.sys.inc.DataTypes;
import nxm.sys.inc.MidasColor;
import nxm.sys.inc.UnicodeMap;

/** This will format an object into a printable string. This class is an extension of
    {@link java.text.Format} that supports Midas data types (such as arrays). Common formats can
    be obtained using:
    <pre>
      Numbers                 - {@link #getNumberFormatFor(String,char,boolean) getNumberFormatFor(..)}
      Time/Date               - {@link #getDateFormatFor(String,String,int) getDateFormatFor(..)}
      Angle, Lat/Lon          - {@link #getAngleFormatFor(String)}
      Variable-Length String  - {@link #getStringFormat()}
      Fixed-Length String     - {@link #getAsciiFormat(int)}
      Byte Sizes              - {@link #getByteFormat()}
      Enumerated Type         - {@link #getEnumFormat(String[],String[],Color[]) getEnumFormat(..)}
    </pre>
     All of the formats used in nxm.sys.prim.list2 come from one of the above methods.

    @author  J. Czechowski IV
    @since   NeXtMidas 1.9.5
 */
public abstract class MFormat extends java.text.Format {
  /** NOTE: this should be changed if the structure/fields of this class changes. */
  private static final long serialVersionUID = 2012012600311L; // for Serializable interface

  /** The string to use in the event of an error. */
  private static final String ERROR_STRING = "--ERROR--";

  /** The delegate that handles non-special-case values. */
  protected java.text.Format format;

  /** The name of the format. */
  protected String name;

  /** Creates a new instance. */
  protected MFormat (String name, java.text.Format format) {
    super();
    this.name   = name;
    this.format = format;
  }

  /** Describes the format, this is intended for debugging purposes only. */
  @Override
  public String toString () {
    return name;
  }

  /** Gets the text color to use with the given object. <i>This is used by <code>LIST2</code>.</i>
      @param obj object for which to get text color
      @return The color or null if the default should be used.
   */
  public Color getColor (Object obj) {
    return null;
  }

  @Override
  public final StringBuffer format (Object obj, StringBuffer toAppendTo, FieldPosition pos) {
    String str = null;

    try {
      if (obj instanceof Data) {
        Object[] array = ((Data)obj).toVector().toArray();

        if (array.length == 1) {
          str = doFormat(array[0]);
        } else {
          str = doFormat(array);
        }
      } else if (obj instanceof Object[]) {
        str = doFormat((Object[])obj);
      } else if ((obj != null) && obj.getClass().isArray()) { // support for primitive arrays (since 2.5.0)
        Object[] array = new Object[Array.getLength(obj)];
        for (int i = 0; i < array.length; i++) {
          array[i] = Array.get(obj,i);
        }
        str = doFormat(array);
      } else {
        str = doFormat(obj);
      }
    } catch(Exception e) {
      str = ERROR_STRING;
    }

    return toAppendTo.append(str);
  }

  @Override
  public final Object parseObject (String source, ParsePosition pos) {
    try {
      pos.setIndex(source.length());
      return doParseObject(source);
    } catch(ParseException e) {
      return null;
    }
  }

  /** Formats an array of values. The default implementation iterates through the array and uses
      {@link #doFormat(Object)} to format each element then it combines them into a comma separated
      list. Examples:
      <pre>
        doFormat(null)                           = ""
        doFormat(new String[0])                  = "()"
        doFormat(new String[] { "a", "b", "c" }) = "(a, b, c)"
      </pre>
      @param array The array of objects to format.
      @return The formatted object set.
   */
  protected String doFormat (Object[] array) {
    final StringBuilder str = new StringBuilder();

    if (array != null) {
      str.append('(');

      for(int i = 0; i < array.length; i++) {
        if (i != 0) { str.append(", "); }
        str.append(doFormat(array[i]));
      }

      str.append(')');
    }

    return str.toString();
  }

  /** Formats a single object. The default implementation simply calls
      <code>format.format(obj)</code> if a format delegate has been defined; otherwise it returns
      <code>obj.toString()</code>.
      @param obj The object to format.
      @return The formatted object.
   */
  protected String doFormat (Object obj) {
    String str = null;

    if (format != null)   { str = format.format(obj); }
    else if (obj != null) { str = obj.toString();     }
    else                  { str = "";                 }

    return str;
  }

  /** Parses a formatted object. The default implementation simply calls
      <code>format.parseObject(source)</code> if a format delegate has been defined; otherwise it
      returns <code>source</code>.
      @param source The object to parse.
      @return The parsed object.
   */
  protected Object doParseObject (String source) throws ParseException {
    if (format != null) {
      return format.parseObject(source);
    } else {
      return source;
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // public static methods that return a MFormat
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Gets a numerical format as specified by the given pattern.
      This is identical to <code>getNumberFormatFor(pattern,true)</code>.
      @param pattern The numerical pattern to use or the name of a named pattern.
      @return An appropriate formatter.
   */
  public static MFormat getNumberFormatFor (String pattern) {
    return getNumberFormatFor(pattern, true);
  }

  /** Gets a numerical format as specified by the given pattern.
      This is identical to <code>getNumberFormatFor(pattern,'D',trim)</code>.
      @param pattern The numerical pattern to use or the name of a named pattern.
      @param trim    Indicates if the the number of decimal places should be "trimmed" down to a
                     more manageable length.
      @return An appropriate formatter.
   */
  public static MFormat getNumberFormatFor (String pattern, boolean trim) {
    return getNumberFormatFor(pattern, 'D', trim);
  }

  /** Gets a scientific numerical format as specified by the given type.
      This is identical to <code>getNumberFormatFor("SCI",type,true)</code>.
      @param type The Midas data type ('D', 'F', 'X', 'I', 'B').
      @return An appropriate formatter.
   */
  public static MFormat getNumberFormatFor (char type) {
    return getNumberFormatFor("SCI", type, true);
  }

  /** Gets a numerical format as specified by the given pattern. This is the same as
      {@link #getNumberFormatFor(String,char,boolean)} except that it takes in a full
      format string rather than just the type letter.
      @param pattern The numerical pattern to use or the name of a named pattern.
      @param fmt     The Midas data format ("SF", "VD", etc). (If null, 'D' is used.)
      @param trim    Indicates if the the number of decimal places should be "trimmed" down.
      @return An appropriate formatter.
      @since NeXtMidas 2.5.0
   */
  public static MFormat getNumberFormatFor (String pattern, String fmt, boolean trim) {
    char numType = ((fmt!=null) && (fmt.length()==2))? fmt.charAt(1): 'D';
    return MFormat.getNumberFormatFor(pattern,numType,trim);
  }

  /** Gets a numerical format as specified by the given pattern. The following are pre-defined
      patterns:
      <pre>
        Numeric:
          GEN      - X-Midas GENeral format (no exponent if between 1E-3, 1E15).
          VIS      - X-Midas VISual  format (no exponent if between 1E-3, 1E7).
          SCI      - SCIentific notation    (always uses exponents).
          ENG      - ENGineering notation   (exponent is a multiple of 3).
          MAN      - MANtissa notation      (no exponents).

        Lat/Lon:   (see also {@link #getAngleFormatFor(String)})
          DMS      - Deg-Min-Sec angular format.            ddd'mm'ss  or -ddd'mm'ss
          LAT      - Deg-Min-Sec format for latitude.        dd'mm'ssN or   dd'mm'ssS
          LON      - Deg-Min-Sec format for longitude.      ddd'mm'ssE or  ddd'mm'ssW

        Time/Date: (see also {@link #getDateFormatFor(String)})
          STD      - STanDard time code format.             yyyy:mm:dd::hh:mm:ss.sss
          ACQ      - ACQisition time code format.           [yy.ddd]:[hh:mm:ss.sss]
          EPOCH    - EPOCH quadwords for time code.         yyyy:sec_in_year
          NORAD    - NORAD timecode format.                 yyddd.frac_of_day
          TCR      - TimeCode Reader format.                ddd:hh:mm:ss.sss
          VAX      - VAX time format.                       dd-MMM-yyyy:hh:mm:ss.sss
          FILENAME - Filename time format.                  yyyymmdd_hhmmssuuu
          HMS      - Hour-Min-Sec time format.              hh:mm:ss.sss
          YMD      - Year-Month-Day time format.            yyyy:mm:dd

        Special:
          NET      - 32-bit integer formatted as URL.       127.0.0.1

      </pre>
      Note that most of the Numeric and Time/Date formats match X-Midas:
      <pre>
        GEN,VIS,SCI,ENG,MAN,DMS,HMS  - Based on M$D2A_FORM in X-Midas
        STD,ACQ,EPOCH,NORAD,TCR,VAX  - Based on M$TIME_FORMAT in X-Midas (see {@link Time})
      </pre>
      To create a pattern for a fixed array size, specify the pattern to use for each element in the
      array separated by an ampersand ("&amp;") character. For example:
      <pre>
        <span style="color:blue">DMS</span>&amp;<span style="color:blue">DMS</span>
        <span style="color:blue">VIS</span>&amp;<span style="color:blue">DMS</span>&amp;<span style="color:blue">DMS</span>
        <span style="color:blue">###ft</span>&amp;<span style="color:blue">DMS</span>&amp;<span style="color:blue">DMS</span>
      </pre>
      If none of the above patterns is desired, one of the {@link DecimalFormat} patterns may be
      used (provided it does not match one of the above patterns - which is unlikely). See the
      documentation for the {@link java.text.DecimalFormat} class for details.
      @param pattern The {@link DecimalFormat} pattern or the name of a named patterns.
      @param type    The Midas data type ('D', 'F', 'X', 'I', 'B'). Used by some of the named
                     patterns to adjust the format to better match the data type (e.g. a double, has
                     a three-digit exponent while the exponent for a float is only two digits long).
      @param trim    Indicates if the the number of decimal places should be "trimmed" down to a
                     more manageable length (e.g. for types 'D', 'F', 'X' when this is
                     <code>true</code> the mantissa will have 7 digits of precision, rather
                     than the full 17 or 19). This is only used by some of the named patterns.
      @return An appropriate formatter.
   */
  public static MFormat getNumberFormatFor (String pattern, char type, boolean trim) {
    if (pattern.indexOf('&') < 0) {
      String name = pattern.toUpperCase();

      // Numeric
      if (name.equals("GEN")) { return new VisualFormat("General ", 1e-3d, 1e15d, type); }
      if (name.equals("VIS")) { return new VisualFormat("Visual ",  1e-3d, 1e07d, type); }
      if (name.equals("SCI")) { pattern = getSciStringFor(type, trim); }
      if (name.equals("ENG")) { pattern = "##" + getSciStringFor(type, trim); }
      if (name.equals("MAN")) { pattern = "0"; }

      // Lat/Lon (matches getAngleFormatFor(..))
      if (name.equals("DMS")) { return new DmsFormat(null); }
      if (name.equals("LAT")) { return new DmsFormat("NS"); }
      if (name.equals("LON")) { return new DmsFormat("EW"); }

      // Time/Date (matches getDateFormatFor(..))
      int tfmt = Parser.find(Time.formatList, name, -1);
      if (tfmt > 0) {
        return new TimeFormat(tfmt);
      }

      // Special
      if (name.equals("NET")) { return new NetworkFormat(); }

      return new NumericalFormat(pattern);
    } else {
      return new ArrayFormat(pattern.split("&"), trim);
    }
  }

  /** Gets a date format for the given pattern. This is the same as
      <code>getDateFormatFor(pattern,"UTC",-1)</code>.
      @param pattern The {@link SimpleDateFormat} pattern.
      @return An appropriate formatter.
      @since NeXtMidas 2.5.0
      @see #getDateFormatFor(String,String)
   */
  public static final MFormat getDateFormatFor (String pattern) {
    return getDateFormatFor(pattern,"UTC",-1);
  }

  /** Gets a date format for the given pattern. This is the same as
      <code>getDateFormatFor(pattern,timeZone,-1)</code>.
      @param pattern  The {@link SimpleDateFormat} pattern.
      @param timeZone The time zone to use with{@link SimpleDateFormat} . This should match the the
                      strings used by {@link TimeZone#getTimeZone} (null=UseSystemDefault). Use
                      <code>"UTC"</code> for UTC.
      @return An appropriate formatter.
      @since NeXtMidas 2.5.0
   */
  public static final MFormat getDateFormatFor (String pattern, String timeZone) {
    return getDateFormatFor(pattern,timeZone,-1);
  }

  /** Gets a date format for the given pattern. This is the same as
      <code>getDateFormatFor(pattern,"UTC",dp)</code>.
      @param pattern The {@link SimpleDateFormat} pattern.
      @param dp      Digits of precision for the "standard patterns."
      @return An appropriate formatter.
      @since NeXtMidas 2.5.0
      @see #getDateFormatFor(String,String)
   */
  public static final MFormat getDateFormatFor (String pattern, int dp) {
    return getDateFormatFor(pattern,"UTC",dp);
  }

  /** Gets a date format for the given pattern. The following standard patterns are recognized
      (these match {@link #getNumberFormatFor(String,char,boolean)}):
      <pre>
        STD, ACQ, EPOCH, NORAD, TCR, VAX, FILENAME, HMS, YMD, ISO8601, FULL_STD
      </pre>
      All other patterns are processed via the {@link SimpleDateFormat} class, a few examples:
      <pre>
        "yyyy.MM.dd G 'at' HH:mm:ss z"  --&gt;  2001.07.04 AD at 12:08:56 PDT
        "EEE, MMM d, ''yy"              --&gt;  Wed, Jul 4, '01
        "h:mm a"                        --&gt;  12:08 PM
        "hh 'o''clock' a, zzzz"         --&gt;  12 o'clock PM, Pacific Daylight Time
        "K:mm a, z"                     --&gt;  0:08 PM, PDT
        "yyyyy.MMMMM.dd GGG hh:mm aaa"  --&gt;  02001.July.04 AD 12:08 PM
        "EEE, d MMM yyyy HH:mm:ss Z"    --&gt;  Wed, 4 Jul 2001 12:08:56 -0700
        "yyMMddHHmmssZ"                 --&gt;  010704120856-0700
        "yyyy-MM-dd'T'HH:mm:ss.SSSZ"    --&gt;  2001-07-04T12:08:56.235-0700
      RFC 1123 &amp; HTTP-date date/time format (rfc1123-date):
        "EEE, dd MMM yyyy HH:mm:ss GMT" --&gt;  Wed, 04 Jul 2001 12:08:56 GMT
      </pre>
      <b>Important Note:</b> When using a {@link SimpleDateFormat}, if you parse an object
      (string-to-value) any format that has no associated date (e.g. <code>"hh:mm:ss"</code>) will
      be marked as starting on 1970/01/01 rather than 1950-01-01. To prevent problems, any times
      in this range will be automatically adjusted to 1950-01-01 to conform to the standard Midas
      time which starts on 1950-01-01 (see {@link Time}).
      @param pattern  The {@link SimpleDateFormat} pattern.
      @param timeZone The time zone to use with{@link SimpleDateFormat} . This should match the the
                      strings used by {@link TimeZone#getTimeZone} (null=UseSystemDefault). Use
                      <code>"UTC"</code> for UTC.
      @param dp       Digits of precision for the "standard patterns" (otherwise ignored). Use
                      <code>dp=-1</code> for default precision.
      @return An appropriate formatter.
      @since NeXtMidas 2.5.0
   */
  public static MFormat getDateFormatFor (String pattern, String timeZone, int dp) {
    String name = pattern.toUpperCase();

    // This matches getNumberFormatFor(..)
    int tfmt = Parser.find(Time.formatList, name, -1);
    if (tfmt > 0) {
      return new TimeFormat(tfmt,dp);
    }

    return new DateFormat(pattern,timeZone);
  }

  /** Gets an angle format for the given pattern. The pattern is similar to that used in
      {@link SimpleDateFormat} but with deg/min/sec rather than hour/min/sec. The following
      patterns letters are recognized:
      <pre>
          d      - deg (0..180)
          m      - min (0..59)
          s      - sec (0..59)
          N      - Cardinal Direction ('N' or 'S')
          E      - Cardinal Direction ('E' or 'W')
          -      - Sign ('-' for negative, ' ' for positive)
          +      - Sign ('-' for negative, '+' for positive)
          o      - Includes the Unicode degrees symbol (see {@link UnicodeMap#DEGREE_SIGN})
          ''     - Includes a tic mark ("'")
         &lt;sym&gt;   - Includes a symbol &lt;sym&gt; in the output (can not be a tic mark or a period)
        '&lt;str&gt;'  - Includes a string &lt;str&gt; in the output (can not have embedded tic marks)
      </pre>
      Notes:
      <pre>
        o Repeated letters indicate <i>minimum</i> number of digits (e.g. "ddd" --&gt; "090")
        o Decimal places can be used ("ss.sss" --&gt; "27.385")
        o If no decimal place is used, the number is always rounded down
      </pre>
      Examples (given angle is <code>123.75342933333333</code>):
      <pre>
        "+d.dddd"                  --&gt;  "+123.7534"
        "ddd:mm:ss.sssE"           --&gt;  "123:45:12.346E"
        "+ddd'd' mm'm' ss.sss's'"  --&gt;  "+123d 45m 12.346s"
        "dddmmssE"                 --&gt;  "1234512E"
      </pre>
      The following standard patterns are also recognized (these match
      {@link #getNumberFormatFor(String,char,boolean)}):
      <pre>
        DMS, LAT, LON
      </pre>
      @param pattern  The pattern to be used.
      @return An appropriate formatter.
      @since NeXtMidas 2.5.0
   */
  public static MFormat getAngleFormatFor (String pattern) {
    String name = pattern.toUpperCase();

    // This list matches getNumberFormatFor(..)
    if (name.equals("DMS")) { return new DmsFormat(null); }
    if (name.equals("LAT")) { return new DmsFormat("NS"); }
    if (name.equals("LON")) { return new DmsFormat("EW"); }

    return new AngleFormat(pattern);
  }

  /** Gets a generic string format.
      @return An appropriate formatter.
   */
  public static MFormat getStringFormat () {
    return new StringFormat();
  }

  /** Gets a string format that formats a string of a pre-defined length. This is useful when
      dealing with files that have a fixed length ASCII text value.
      @param length The fixed maximum length for the string (in characters).
      @return An appropriate formatter.
   */
  public static MFormat getAsciiFormat (int length) {
    return new AsciiFormat(length);
  }

  /** Gets a format for byte sizes. Used for displaying file sizes that are in an integral number of
      bytes that need to be displayed in megabytes ('M'), gigabytes ('G'), etc, as appropriate.
      @return An appropriate formatter.
      @since NeXtMidas 2.1.0
   */
  public static MFormat getByteFormat () {
    return new BinaryFormat();
  }

  /** Gets a format for an enumerated type. <i>This method is only intended for use by
      <code>LIST2</code>.</i>
      @param values The values (can not be null).
      @param labels The labels (must have same length as values; can not be null).
      @param colors The colors (must have same length as values; can be null).
      @return An appropriate formatter.
      @since NeXtMidas 2.1.0
   */
  public static MFormat getEnumFormat (String[] values, String[] labels, Color[] colors) {
    return new EnumFormat(values, labels, colors);
  }

  /** Gets a format for an enumerated type. <i>This method is only intended for use by
      <code>LIST2</code>.</i>
      @param tbl A table with N entries each with VALUE=, LABEL= and (optionally) COLOR=.
      @return An appropriate formatter.
      @since NeXtMidas 2.1.0
   */
  public static MFormat getEnumFormat (Table tbl) {
    String[] keys   = tbl.getKeys();
    String[] values = new String[keys.length];
    String[] labels = new String[keys.length];
    Color[]  colors = new Color[keys.length];

    for(int i = 0; i < keys.length; i++) {
      values[i] = tbl.getS(keys[i]+".VALUE");
      labels[i] = tbl.getS(keys[i]+".LABEL");
      colors[i] = getColor(tbl.getS(keys[i]+".COLOR"));
    }

    return new EnumFormat(values, labels, colors);
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // other public static methods
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Converts a Fortran format pattern to a Java one. For details on Fortran format patterns please
      see section 10.8 of the "Professional Programmer's Guide to Fortran77" by Clive G. Page
      (<a href="http://www.star.le.ac.uk/%7Ecgp/prof77.html#tth_sEc10.8">
      http://www.star.le.ac.uk/%7Ecgp/prof77.html#tth_sEc10.8</a>).
      @param pattern The Fortran format pattern.
      @return The Java format pattern.
   */
  public static String convertFortranPattern (String pattern) {
    String jpattern = null;

    pattern = pattern.trim().toUpperCase();
    if (pattern.startsWith("(") && pattern.endsWith(")")) {
      pattern = pattern.substring(1,pattern.length()-1).trim();
    }

    int comma = pattern.indexOf(',');

    if (comma > 0) {
      // MULTIPLE COMMA-SEPARATED PATTERNS
      jpattern = convertFortranPattern(pattern.substring(0,comma)) + "&"
               + convertFortranPattern(pattern.substring(comma+1));

    } else if ((pattern.length() > 2) && (pattern.charAt(0) >= '0') && (pattern.charAt(0) <= '9')) {
      int i = 1;

      while((pattern.length() > i+1) && (pattern.charAt(i) >= '0') && (pattern.charAt(i) <= '9')) {
        i++;
      }

      int    mult = Convert.o2l(pattern.substring(0,i));
      String pat  = pattern.substring(i);

      if (pat.startsWith("P")) {
        // SCALE FACTOR: for not just ignore it
        jpattern = convertFortranPattern(pat.substring(1));

      } else {
        // REPEAT PATTERN N TIMES
        String jpat = convertFortranPattern(pat);

        jpattern = jpat;
        for(int j = 1; j < mult; j++) {
          jpattern = jpattern + "&" + jpat;
        }
      }

    } else {
      // NUMERIC FORMAT
      int dot = pattern.indexOf('.');
      int exp = pattern.indexOf('E', 1);
      int w   =  0; // total field width
      int m   =  1; // min number of digits
      int d   = -1; // num of digits after decimal point (-1=no decimal point)
      int e   = -1; // num of digits after exponent (-1 = no exponent)

      if (pattern.startsWith("I")) {
        // I = INTEGER FORMAT
        if (dot < 0) {
          w = Convert.o2l(pattern.substring(1));
        } else {
          w = Convert.o2l(pattern.substring(1,dot));
          m = Convert.o2l(pattern.substring(dot+1));
        }

      } else if (pattern.startsWith("F")) {
        // F = FLOATING POINT FORMAT
        if (dot < 0) {
          w = Convert.o2l(pattern.substring(1));
        } else {
          w = Convert.o2l(pattern.substring(1,dot));
          d = Convert.o2l(pattern.substring(dot+1));
        }

      } else if (pattern.startsWith("E") || pattern.startsWith("G")) {
        // E = EXPONENTIAL FORMAT
        // G = GENERAL FORMAT: For now just use exponential
        if (exp > 0) {
          w = Convert.o2l(pattern.substring(1,dot));
          d = Convert.o2l(pattern.substring(dot+1,exp));
          e = Convert.o2l(pattern.substring(exp+1));
        } else {
          w = Convert.o2l(pattern.substring(1,dot));
          d = Convert.o2l(pattern.substring(dot+1));
          e = 2;
        }
      }

      String whole = StringUtil.padLeft("", m, '0');
      String fract = "";
      String expon = "";

      if (d >= 0) { fract = "." + StringUtil.padLeft("", d, '0'); }
      if (e >= 0) { expon = "E" + StringUtil.padLeft("", e, '0'); }

      jpattern = whole + fract + expon;

      if (e < 0) { jpattern = StringUtil.padLeft(jpattern, w, '#'); }
    }

    return jpattern;
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // private methods
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Indicates if working with an integer type. */
  private static boolean isIntType (char type) {
    // note: added unsigned types to list in 3.9.3 for NXM-3021 - is this safe - it is if we know this is a number type
    return (type == DataTypes.XLONG) || (type == DataTypes.LONG) || (type == DataTypes.INT) ||
           (type == DataTypes.BYTE) || (type == DataTypes.UINT) || (type == DataTypes.ULONG) ||
           (type == DataTypes.JINT); // 12-bit 'J' support added for NXM-3894
  }

  /** Get the scientific format string for a given data type.
      @param type The numeric type ('D', 'F', 'X', 'L', 'I', 'B').
      @param trim Indicates if the the number of decimal places should be "trimmed" down to a
                  more manageable length.
      @return A formatting string for the given type.
   */
  private static String getSciStringFor (char type, boolean trim) {
    String fmt = null;
    if (isIntType(type)) {
      if (type == DataTypes.XLONG) {
        fmt = ((trim)? "0.000000E000": "0"); // longs can be quite long, so use exponents.
      } else {
        fmt = "0";
      }
    } else if (type == 'F') {
      fmt = ((trim)? "0.000000E00":  "0.0000000000000000E00");
    } else { // 'D' or "unknown"
      fmt = ((trim)? "0.000000E000": "0.0000000000000000E000");
    }
    return fmt;
  }

  /** Converts a string to a color. */
  private static Color getColor (String str) {
    return (StringUtil.isNull(str))? null: MidasColor.getColor(str);
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs formatting for a String. */
  private static class StringFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012012700311L; // for Serializable interface

    /** Creates a new instance. */
    protected StringFormat (String name) {
      super(name, null);
    }

    /** Creates a new instance. */
    public StringFormat () {
      this("String Format");
    }

    @Override
    public String doFormat (Object obj) {
      return (obj != null)? obj.toString(): "";
    }

    @Override
    public Object doParseObject (String source) throws ParseException {
      return source;
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs formatting for a String. */
  private static class AsciiFormat extends StringFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012012800311L; // for Serializable interface

    /** The formats to use. */ private final int length;

    /** Creates a new instance.
        @param length The fixed maximum length for the string (-1 for no fixed length).
     */
    public AsciiFormat (int length) {
      super(((length < 0)? "Ascii Format": "Ascii Format of Length " + length));

      this.length = length;
    }

    @Override
    public String doFormat (Object obj) {
      return (obj != null)? StringUtil.toAsciiString(fixLength(obj.toString(), length)): "";
    }

    @Override
    public Object doParseObject (String source) throws ParseException {
      return StringUtil.toAsciiString(fixLength(source, length));
    }

    /** Forces a string to a given length.
        @param str  The string.
        @param lgth The length of the string.
        @return A string with the fixed length.
     */
    private String fixLength (String str, int lgth) {
      if (lgth < 0) {
        return str; /* Ignore length */
      } else if (str.length() > lgth) {
        return str.substring(0, lgth);
      } else {
        return str;
      }
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs formatting for an array. */
  private static class ArrayFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012012900311L; // for Serializable interface

    /** The formats to use. */ private final MFormat[] formats;

    /** Creates a new instance.
        @param patterns The patterns for each element of the array.
        @param trim     Indicates if the the number of decimal places should be "trimmed" down to a
                        more manageable length.
     */
    public ArrayFormat (String[] patterns, boolean trim) {
      super("Format for an array of length " + patterns.length, null);
      formats = new MFormat[patterns.length];

      for(int i = 0; i < patterns.length; i++) {
        formats[i] = getNumberFormatFor(patterns[i], trim);
      }
    }

    @Override
    public String doFormat (Object[] array) {
      if (array.length == formats.length) {
        StringBuilder str = new StringBuilder();

        str.append('(');

        for (int i = 0; i < formats.length; i++) {
          if (i != 0) { str.append(", "); }
          str.append(formats[i].format(array[i]));
        }
        str.append(')');

        return str.toString();

      } else {
        return ERROR_STRING;
      }
    }

    @Override
    public String doFormat (Object obj) {
      return ERROR_STRING;
    }

    @Override
    public Object doParseObject (String source) throws ParseException {
      return source;
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs numerical formatting. <br>
      <br>
      This extends the standard {@link DecimalFormat} in that it makes the positive sign mandatory
      within the exponent and it will prefix positive values with a single space. This is necessary
      when trying to get values to line up.
   */
  private static class NumericalFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012013000311L; // for Serializable interface

    /** The pattern for a numerical string in the form "#.#e#" or "##e#". */
    private static final Pattern unsignedExpNum
      = Pattern.compile("([0-9]+([.][0-9]+)?)([eE])([0-9]+.*)");

    private static final Pattern negExpNum
      = Pattern.compile("([0-9]+([.][0-9]+)?)([eE][-])([0-9]+.*)");

    /** The pattern for a numerical string in the form "#.#e+#" or "##e+#". */
    private static final Pattern posExpNum
      = Pattern.compile("([0-9]+([.][0-9]+)?)([eE][+])([0-9]+.*)");

    /** Creates a new instance. */
    public NumericalFormat (String format) {
      super("Numerical Format (" + format + ")", new DecimalFormat(format));
    }

    @Override
    public String doFormat (Object obj) {
      String str = null;

      if (obj == null) {
        str = "";
      } else if (obj instanceof Number) {
        str = format.format(obj);
      } else if (StringUtil.isNumber(obj.toString())) {
        obj = Double.valueOf(Convert.o2d(obj));
        str = format.format(obj);
      } else {
        str = obj.toString();
      }

      if (!negExpNum.matcher(str).matches()) {
        str = unsignedExpNum.matcher(str).replaceAll("$1$3+$4");
      }

      if (!str.startsWith("-")) {
        str = " " + str;
      }

      return str;
    }

    @Override
    public Object doParseObject (String source) throws ParseException {
      if (posExpNum.matcher(source).matches()) {
        source = posExpNum.matcher(source).replaceFirst("E");
      }

      return format.parseObject(source);
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////


  /** Performs visual formatting. */
  private static class VisualFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012013100311L; // for Serializable interface

    /** The lower limit for non-exponential formatting. */ private final double  lowerLimit;
    /** The upper limit for non-exponential formatting. */ private final double  upperLimit;
    /** The exponential formatter.                      */ private final MFormat expFormat;

    /** Creates a new instance.
        @param lower The lower limit on non-exponential formatting.
        @param upper The upper limit on non-exponential formatting.
        @param type  The numeric type ('D', 'F', 'X', 'I', 'I').
     */
    public VisualFormat (String name, double lower, double upper, char type) {
      super(name+" Format", getVisNumFormat(type));
      expFormat  = new NumericalFormat(getSciStringFor(type, true));
      lowerLimit = lower;
      upperLimit = upper;
    }

    /** Only used by the constructor for VisualFormat */
    private static NumericalFormat getVisNumFormat(char type) {
      return (isIntType(type))? new NumericalFormat("0"): new NumericalFormat("0.0000");
    }

    @Override
    public String doFormat (Object obj) {
      double absValue = Math.abs(Convert.o2d(obj));

      if ((absValue == 0) || ((absValue > lowerLimit) && (absValue < upperLimit))) {
        return format.format(obj);
      } else {
        return expFormat.format(obj);
      }
    }

    @Override
    public Object doParseObject (String source) throws ParseException  {
      if (source.indexOf('e') < 0) {
        return format.parseObject(source);
      } else {
        return this.expFormat.parseObject(source);
      }
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs Deg-Min-Sec formatting. */
  private static class DmsFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012020100311L; // for Serializable interface

    private String direction;

    /** Creates a new instance.
        @param direction The direction "NS" or "EW" or null to use +/-.
     */
    private DmsFormat (String direction) {
      super("DMS Format", null);
      this.direction = direction;
    }

    @Override
    public String doFormat (Object obj) {
      String str = Convert.deg2dmsUnicode(Convert.o2d(obj));

      int degIndex = str.indexOf(UnicodeMap.DEGREE_SIGN);

      if (direction == null) {
        if (str.startsWith("-")) {
          if (degIndex == 2) {
            str = "  " + str;
          } else if (degIndex == 3) {
            str = " " + str;
          }
        } else {
          if (degIndex == 1) {
            str = "   " + str;
          } else if (degIndex == 2) {
            str = "  " + str;
          } else {
            str = " " + str;
          }
        }
      } else {
        if (str.startsWith("-")) {
          if (degIndex == 2) {
            str = "  " + str.substring(1) + direction.charAt(1);
          } else if (degIndex == 3) {
            str = " " + str.substring(1) + direction.charAt(1);
          } else {
            str = "" + str.substring(1) + direction.charAt(1);
          }
        } else {
          if (degIndex == 1) {
            str = "  " + str + direction.charAt(0);
          } else if (degIndex == 2) {
            str = " " + str + direction.charAt(0);
          } else {
            str = "" + str + direction.charAt(0);
          }
        }
      }
      return str;
    }

    @Override
    public Object doParseObject (String source) {
      return Double.valueOf(Convert.dms2deg(source.trim()));
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs date and time formatting. */
  private static class TimeFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012020200311L; // for Serializable interface

    private final int fmt; // Time format
    private final int dp;  // Time precision

    /** Creates a new instance. */
    private TimeFormat (int fmt) {
      this(fmt,-1);
    }

    /** Creates a new instance. */
    private TimeFormat (int fmt, int dp) {
      super(Time.getFormat(fmt) + " Time Format (dp="+dp+")", null);
      this.fmt = fmt;
      this.dp  = dp;
    }

    @Override
    public String doFormat (Object obj) {
      return Time.toTime(obj).toString(fmt,dp);
    }

    @Override
    public Object doParseObject (String source) {
      return new Time(source,fmt);
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs formatting for file sizes (using binary exponents). Assumes all values give an
      integral number of bytes.

      @since NeXtMidas 2.1.0
   */
  private static class BinaryFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012020300311L; // for Serializable interface

    /** Creates a new instance. */
    private BinaryFormat () {
      super("Binary Time Format", new DecimalFormat("#.#"));
    }

    @Override
    public String doFormat (Object obj) {
      DecimalFormat dFormat = (DecimalFormat)super.format;
      long   val  = Convert.o2x(obj);
      String str = "";


      if (val >= Constants.B1T)      { str = dFormat.format(val / Constants.B1T) + "T"; }
      else if (val >= Constants.B1G) { str = dFormat.format(val / Constants.B1G) + "G"; }
      else if (val >= Constants.B1M) { str = dFormat.format(val / Constants.B1M) + "M"; }
      else if (val >= Constants.B1K) { str = dFormat.format(val / Constants.B1K) + "k"; }
      else                          { str = Long.toString(val);                        }

      return str;
    }

    @Override
    public Object doParseObject (String source) {
      long mult = 1;
      int index = source.length()-1;

      if (source.endsWith("T"))      { mult = Constants.B1T; source = source.substring(0,index); }
      else if (source.endsWith("G")) { mult = Constants.B1G; source = source.substring(0,index); }
      else if (source.endsWith("M")) { mult = Constants.B1M; source = source.substring(0,index); }
      else if (source.endsWith("K")) { mult = Constants.B1K; source = source.substring(0,index); }
      else                           { mult = 1;                                                 }

      return Long.valueOf(Convert.o2x(source) * mult);
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs formatting for an enumerated set of values. */
  private static class EnumFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012020400311L; // for Serializable interface

    private String[] values;   // The actual values
    private String[] labels;   // The display values
    private Color[]  colors;   // The colors

    /** Creates a new instance.
        @param values String array of actual values
        @param labels String array of display values
        @param colors Color array of the colors to use
     */
    public EnumFormat (String[] values, String[] labels, Color[] colors) {
      super("Enum Format: LABELS="+StringUtil.join(labels,",")
                       +" VALUES="+StringUtil.join(values,",")
                       +" COLORS="+StringUtil.join(colors,","), null);
      this.values = values;
      this.labels = labels;
      this.colors = colors;

      if ((values == null) || (labels == null)) {
        throw new NullPointerException("MFormat: VALUES and LABELS must be non-null.");
      }

      if (values.length != labels.length) {
        throw new IllegalArgumentException("MFormat: VALUES and LABELS must have same length.");
      }

      if ((colors != null) && (values.length != colors.length)) {
        throw new IllegalArgumentException("MFormat: VALUES and COLORS must have same length.");
      }
    }

    @Override
    public Color getColor (Object obj) {
      int index = getIndex(obj.toString());

      return (index < 0)? null: colors[index];
    }

    @Override
    public String doFormat (Object obj) {
      int index = getIndex(obj.toString());

      return (index < 0)? obj.toString(): labels[index];
    }

    @Override
    public Object doParseObject (String source) throws ParseException {
      Object val = source;

      for (int i = 0; i < labels.length; i++) {
        if (labels[i].equals(source)) {
          val = values[i];
          break;
        }
      }

      return val;
    }

    /** Get the index of the given string. */
    private int getIndex (String str) {
      int index = -1;

      if (str != null) {
        for(int i = 0; (index < 0) && (i < values.length); i++) {
          if (values[i].equals(str)) {
            index = i;

          } else if (StringUtil.isNumber(values[i]) && StringUtil.isNumber(str)
                && (Convert.o2d(values[i]) == Convert.o2d(str))) {
            index = i;
          }
        }
      }

      return index;
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs network address formatting. */
  private static class NetworkFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012020500311L; // for Serializable interface

    /** Creates a new instance. */
    public NetworkFormat() {
      super("Network Format", null);
    }

    @Override
    public String doFormat (Object obj) {
      return StringUtil.getIpAddressString(Convert.o2l(obj));
    }

    @Override
    public Object doParseObject (String source) throws ParseException {
      int addr = 0;

      try {
        addr = StringUtil.getIpv4AddressNumber(source);
      } catch (Exception e) {
        throw new ParseException("Unable to parse '"+source+"' into a valid IPv4 address.",0);
      }

      return Integer.valueOf(addr);
    }
  }

  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs date formatting. */
  private static class DateFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012020600311L; // for Serializable interface

    /** Creates a new instance. */
    public DateFormat (String pattern, String timeZone) {
      this("Date Format ("+pattern+")",
           new SimpleDateFormat(pattern),
           (StringUtil.isNull(timeZone))? null: TimeZone.getTimeZone(timeZone));
    }

    /** Creates a new instance. */
    public DateFormat (java.text.DateFormat dateFormat, TimeZone timeZone) {
      this("Custom Date Format",dateFormat,timeZone);
    }

    /** Creates a new instance. */
    private DateFormat(String descr, java.text.DateFormat dateFormat, TimeZone timeZone) {
      super(descr, dateFormat);
      if (timeZone != null) {
        dateFormat.setTimeZone(timeZone);
      }
    }

    @Override
    public String doFormat (Object obj) {
      Date date = Time.toTime(obj).toDateObject();
      return format.format(date);
    }

    @Override
    public Object doParseObject (String source) throws ParseException {
      double time = Double.NaN;

      try {
        Date date = (Date)format.parseObject(source);
        if (date.getTime() < 86401000L) {
          time = (date.getTime() / 1000.0);
        } else {
          time = Time.fromDateObject(date);
        }
      } catch(Exception e) {
        throw new ParseException("Unable to parse '"+source+"' into a valid date.",0);
      }
      return new Time(time);
    }
  }


  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** Performs angle formatting. */
  private static class AngleFormat extends MFormat {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2012020700311L; // for Serializable interface

    /** FYI: zero-length arrays are immutable. For more info see Effective Java,
    Item 27: Return zero-length arrays, not null. */
    static final AngleFormatCode[] EMPTY_ANGLEFORMATCODE_ARRAY = new AngleFormatCode[0];

    private AngleFormatCode[] parts;

    /** Creates a new instance. */
    public AngleFormat (String pattern) {
      super("Angle Format ("+pattern+")",null);
      this.parts = applyPattern(pattern);
    }

    @Override
    public String doFormat (Object obj) {
      StringBuilder sb = new StringBuilder();
      double       deg = Convert.o2d(obj);

      for (int i = 0; i < parts.length; i++) {
        sb.append(parts[i].format(deg));
      }
      return sb.toString();
    }

    @Override
    public Object doParseObject (String source) throws ParseException {
      ParsePosition pos = new ParsePosition(0);
      double        sec = 0.0;
      boolean       neg = false;

      for (int i = 0; i < parts.length; i++) {
        double val = parts[i].parse(source, pos);

             if (val == Double.POSITIVE_INFINITY) { neg = false;     }
        else if (val == Double.NEGATIVE_INFINITY) { neg = true;      }
        else                                      { sec = sec + val; }
      }
      return (neg)? Double.valueOf(-sec) : Double.valueOf(sec);
    }

    /** Converts pattern into something we can use. Valid symbols are described in the docs for
        getAngleFormatFor(..).
     */
    private AngleFormatCode[] applyPattern (String pattern) {
      ArrayList<AngleFormatCode> parts = new ArrayList<>();
      int    len   = pattern.length();

      for (int i = 0; i < len; i++) {
        AngleFormatCode fmt = null;
        char c = pattern.charAt(i);

        if ((c == 'd') || (c == 'm') || (c == 's')) {
          StringBuilder subPattern = new StringBuilder("0");

          while ((i+1 < len) && (c == pattern.charAt(i+1))) {
            subPattern.append('0'); i++;
          }
          if ((i+2 < len) && ('.' == pattern.charAt(i+1)) && (c == pattern.charAt(i+2))) {
            subPattern.append('.'); i++;
            while ((i+1 < len) && (c == pattern.charAt(i+1))) {
              subPattern.append('0'); i++;
            }
          }
          fmt = new AngleFormatCode(subPattern.toString(), c);

        } else if (c == '\'') {
          int end = pattern.indexOf('\'', i+1);

          if (end == i+1) {
            fmt = new AngleFormatText('\'');
          } else if (end > 0) {
            fmt = new AngleFormatText(pattern.substring(i+1,end), false);
          }
          i = end;
        } else {
          switch (c) {
            case 'N': fmt = new AngleFormatSign('N', 'S');                                 break;
            case 'E': fmt = new AngleFormatSign('E', 'W');                                 break;
            case '+': fmt = new AngleFormatSign('+', '-');                                 break;
            case '-': fmt = new AngleFormatSign(' ', '-');                                 break;
            case 'o': fmt = new AngleFormatText(UnicodeMap.DEGREE_SIGN);                   break;
            case '.': fmt = null;                                                          break;
            default:  fmt = (Character.isLetterOrDigit(c))? null : new AngleFormatText(c); break;
          }
        }
        if (fmt == null) {
          throw new IllegalArgumentException("AngleFormat: Invalid pattern '"+pattern+"'");
        } else {
          parts.add(fmt);
        }
      }
      AngleFormatCode[] array = parts.toArray(EMPTY_ANGLEFORMATCODE_ARRAY);
      for (int i=0,j=1; j < array.length; i++,j++) {
        // If two numbers immediately follow each other, set them to be fixed length
        if (array[i].isNumber() && array[j].isNumber()) {
          array[i].setFixLen(true);
          array[j].setFixLen(true);
        }
      }

      return array;
    }
  }

  /** Only used by AngleFormat */
  private static class AngleFormatCode implements Serializable{
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2022101415501L; // for Serializable interface

    protected final DecimalFormat format;
    protected final char          type;
    protected final boolean       floor;
    protected final int           length;
    protected       boolean       fixLen;

    protected AngleFormatCode () {
      this.format = null;
      this.type   = ' ';
      this.floor  = false;
      this.length = -1;
      this.fixLen = false;
    }

    public AngleFormatCode (String pattern, char type) {
      this.format = new DecimalFormat(pattern);
      this.type   = type;
      this.floor  = (pattern.indexOf('.') < 0);
      this.length = pattern.length();
      this.fixLen = false;
    }

    public boolean isNumber () { return (length != -1); }
    public void    setFixLen (boolean f) { fixLen = f; }

    public String format (double deg) {
      if (deg < 0) deg = -deg;

      double val  = Double.NaN;
      int    d    = (int)deg;
      double min  = (deg - d) * 60;
      int    m    = (int)min;
      double sec  = (min - m) * 60;
      int    s    = (int)sec;

      if (floor) {
        switch (type) {
          case 'd': val = d; break;
          case 'm': val = m; break;
          case 's': val = s; break;
        }
      } else {
        switch (type) {
          case 'd': val = deg; break;
          case 'm': val = min; break;
          case 's': val = sec; break;
        }
      }
      return format.format(Double.valueOf(val));
    }

    public double parse (String str, ParsePosition pos)  throws ParseException {
      double val = 0.0;
      double deg = 0.0;

      if (!fixLen) {
        val = Convert.o2d(format.parseObject(str,pos));
      } else {
        int index = pos.getIndex();
        if (str.length() < index+length) {
          throw new ParseException("AngleFormat: Expected "+length+"-digit value at i="+index
                                  +" in '"+str+"'", index);
        }
        val = Convert.o2d(format.parse(str.substring(index,index+length)));
        pos.setIndex(index+length);
      }

      switch (type) {
        case 'd': deg = val;        break;
        case 'm': deg = val /   60; break;
        case 's': deg = val / 3600; break;
      }
      return deg;
    }
  }

  /** Only used by AngleFormat */
  private static class AngleFormatSign extends AngleFormatCode {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2022101415502L; // for Serializable interface
    private final char pos;
    private final char neg;
    private final char posU;
    private final char negU;
    private final char posL;
    private final char negL;

    public AngleFormatSign (char pos, char neg) {
      super();
      this.pos  = pos;
      this.neg  = neg;
      this.posU = Character.toUpperCase(pos);
      this.negU = Character.toUpperCase(neg);
      this.posL = Character.toLowerCase(pos);
      this.negL = Character.toLowerCase(neg);
    }

    @Override
    public String format (double deg) {
      char c = (deg < 0)? neg : pos;

      return String.valueOf(c);
    }

    @Override
    public double parse (String str, ParsePosition position)  throws ParseException {
      int     index = position.getIndex();
      boolean ok    = false;
      double  deg   = Double.NaN;

      if (str.length() > index) {
        char c = str.charAt(index);

             if ((c == posU) || (c == posL)) { deg = Double.POSITIVE_INFINITY; ok  = true; }
        else if ((c == negU) || (c == negL)) { deg = Double.NEGATIVE_INFINITY; ok  = true; }
      }
      if (!ok) {
        throw new ParseException("AngleFormat: Expected '"+pos+"' or '"+neg+"' at i="+index
                                +" in '"+str+"'", index);
      }
      position.setIndex(index + 1);
      return deg;
    }
  }

  /** Only used by AngleFormat */
  private static class AngleFormatText extends AngleFormatCode {
    /** NOTE: this should be changed if the structure/fields of this class changes. */
    private static final long serialVersionUID = 2022101415503L; // for Serializable interface
    private final String  text;
    private final boolean ignoreCase;

    public AngleFormatText (char c) {
      this(String.valueOf(c), false);
    }

    public AngleFormatText (String text, boolean ignoreCase) {
      super();
      this.text       = text;
      this.ignoreCase = ignoreCase;
    }

    @Override
    public String format (double deg) {
      return text;
    }

    @Override
    public double parse (String str, ParsePosition pos)  throws ParseException {
      int     start = pos.getIndex();
      int     end   = start + text.length();
      boolean ok    = false;

      if (str.length() >= end) {
        String txt = str.substring(start, end);
        ok = txt.equals(text) || (ignoreCase && txt.equalsIgnoreCase(text));
      }
      if (!ok) {
        throw new ParseException("AngleFormat: Expected '"+text+"' at i="+start+" in '"+str+"'", start);
      }
      pos.setIndex(end);
      return 0.0;
    }
  }
}
