NumberFormatter.java
/*
** Module : NumberFormatter.java
** Abstract : Format Progress numbers.
**
** Copyright (c) 2021-2022, Golden Code Development Corporation.
**
** -#- -I- --Number-- ---------------------------------Description---------------------------------
** 001 VVT 20210426 Created initial version.
** VVT 20210624 Javadoc fixed
** TJD 20220504 Java 11 compatibility minor changes
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
** Under Section 7 of the GNU Affero GPL version 3, the following additional
** terms apply to the works covered under the License. These additional terms
** are non-permissive additional terms allowed under Section 7 of the GNU
** Affero GPL version 3 and may not be removed by you.
**
** 0. Attribution Requirement.
**
** You must preserve all legal notices or author attributions in the covered
** work or Appropriate Legal Notices displayed by works containing the covered
** work. You may not remove from the covered work any author or developer
** credit already included within the covered work.
**
** 1. No License To Use Trademarks.
**
** This license does not grant any license or rights to use the trademarks
** Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
** of Golden Code Development Corporation. You are not authorized to use the
** name Golden Code, FWD, or the names of any author or contributor, for
** publicity purposes without written authorization.
**
** 2. No Misrepresentation of Affiliation.
**
** You may not represent yourself as Golden Code Development Corporation or FWD.
**
** You may not represent yourself for publicity purposes as associated with
** Golden Code Development Corporation, FWD, or any author or contributor to
** the covered work, without written authorization.
**
** 3. No Misrepresentation of Source or Origin.
**
** You may not represent the covered work as solely your work. All modified
** versions of the covered work must be marked in a reasonable way to make it
** clear that the modified work is not originating from Golden Code Development
** Corporation or FWD. All modified versions must contain the notices of
** attribution required in this license.
*/
package com.goldencode.p2j.util;
import java.math.*;
import com.goldencode.p2j.ui.client.format.*;
/**
* Format Progress numbers.
*
*/
public class NumberFormatter
extends NumberFormatParser
{
/** Collector for the output formatted value */
private StringBuilder sb;
/** The value being formatted */
private final BigDecimal value;
/** {@code true} if we are formatting a negative number. */
private final boolean isNegative;
/** The left (integer) part of the formatted number */
private final String leftDigits;
/** The right (fraction) part of the formatted number */
private final String rightDigits;
/** Set to {@code true} by if any of {@link #visitLeftDigits(int, int)} or
* {@link #visitRightDigits(int, int, int)} callbacks was called */
private boolean digitsVisited;
/** Set to {@code true} by {@link #visitSign(int, int)} digits callback */
private boolean signVisited;
/** The format string, used for error reporting from callbacks */
private final String format;
/** Any error related to a value being formatted occurred before the format parse is complete.
* This value is used to defer processing of value-related error until format string is
* completely parsed, and all format-related errors processed. */
private Runnable deferredError = null;
/**
* The constructor.
*
* @param value
* the value to format
* @param fmt
* the format string
*/
public NumberFormatter(final BigDecimal value, final String fmt)
{
this.value = value;
final String full = value.abs().toString();
final int pos = full.indexOf('.');
if (pos < 0)
{
// no decimal point
leftDigits = full;
rightDigits = "";
}
else
{
// standard case
leftDigits = full.substring(0, pos);
rightDigits = full.substring(pos + 1);
}
this.isNegative = value.signum() < 0;
this.format = fmt;
}
/**
* Do the formatting.
*
* @return the formatted number
*/
public final String format()
{
sb = new StringBuilder();
parse(format);
final String result = sb.toString();
if (isNegative && !signVisited)
{
DisplayFormat.genValueCannotBeDisplayedError(value.toString(), format);
// FIXME: generate string of question marks?
return "";
}
if (deferredError != null)
{
deferredError.run();
}
return result;
}
/**
* A fill character was parsed.
*
* @param c
* the character
*/
@Override
protected void visitFillChar(final char c)
{
if (deferredError != null)
{
return;
}
sb.append(c);
}
/**
* A sign spec was parsed.
*
* Sign specs are: '+', '-', '(', ')', DR, CR and DB.
*
* @param startIdx
* the section start index in format string, zero-based, inclusive
* @param endIdx
* the section end index in format string, zero-based, exclusive
*/
@Override
protected void visitSign(final int startIdx, final int endIdx)
{
if (deferredError != null)
{
return;
}
signVisited = true;
switch (endIdx - startIdx)
{
case 1:
// single format character
final char in;
final char c = format.charAt(startIdx);
switch (c)
{
case '+':
in = isNegative ? '-' : '+';
break;
case '-':
if (isNegative)
{
in = '-';
break;
}
if (!digitsVisited)
{
// If we are on left, insert spaces to the beginning of the output.
// Test the next format character: if 'z'
if (endIdx == format.length())
{
// Id we are at the end of the format string, then format is invalid:
// no digits. Just return and let the format parser handle this error.
return;
}
final char nextChar = format.charAt(endIdx);
if (nextChar == '>')
{
sb.insert(0, ' ');
return;
}
}
in = ' ';
break;
case '(':
case ')':
in = isNegative ? c : ' ';
break;
default:
// Should never happen
throw new ErrorConditionException(-1, "Invalid sign character");
}
sb.append(in);
break;
case 2:
// two-character spec: DR, CR, DB
sb.append(isNegative ? format.substring(startIdx, endIdx) : " ");
break;
default:
// Should never happen
throw new ErrorConditionException(-1, "Invalid sign specification");
}
}
/**
* Non-empty left digits section was parsed.
*
* @param startIdx
* the section start index in format string, zero-based, inclusive
* @param endIdx
* the section end index in format string, zero-based, exclusive
*/
@Override
protected void visitLeftDigits(final int startIdx, final int endIdx)
{
digitsVisited = true;
int insertionPoint = sb.length();
int formatIdx = endIdx - 1;
int valueIdx = leftDigits.length() - 1;
for (;; formatIdx--)
{
final boolean inLeadingZeroes = valueIdx < 0;
if (formatIdx < startIdx && inLeadingZeroes)
{
return;
}
if (formatIdx < 0)
{
setDeferredError(leftDigits);
return;
}
char formatChar = format.charAt(formatIdx);
if (!inLeadingZeroes)
{
if (formatChar == ',')
{
sb.insert(insertionPoint, ',');
continue;
}
if (formatIdx < startIdx)
{
if (formatChar == '+')
{
insertionPoint--;
sb.deleteCharAt(insertionPoint);
}
else
{
setDeferredError(leftDigits);
return;
}
}
sb.insert(insertionPoint, leftDigits.charAt(valueIdx--));
continue;
}
// If we are here, we are in leading zeroes
// If a comma is preceded by 'z' or '>', it acts as the
// preceding character, so just replace comma to the preceding character.
if (formatChar == ',' && formatIdx > 0)
{
final char preceding = format.charAt(formatIdx - 1);
switch (preceding)
{
case '>':
case 'z':
case 'Z':
case '*':
formatChar = preceding;
break;
default:
}
}
if (formatChar == ',')
{
sb.insert(insertionPoint, ',');
continue;
}
final char insertChar;
switch (formatChar)
{
case '9':
insertChar = '0';
break;
case '*':
insertChar = '*';
break;
case 'z':
case 'Z':
insertChar = ' ';
break;
case '>':
// We are inserting spaces to the beginning of the output.
sb.insert(0, ' ');
continue;
case '(':
insertChar = isNegative ? '(' : ' ';
break;
default:
// Should never happen
throw new ErrorConditionException(-1, "Invalid digit");
}
sb.insert(insertionPoint, insertChar);
}
}
/**
* Right digits section was parsed (may be empty).
*
* @param startIdx
* the section start index in format string, zero-based, inclusive
* @param endIdx
* the section end index in format string, zero-based, exclusive
* @param separators
* the number of digit separator ',' character in right digits
*/
@Override
protected void visitRightDigits(final int startIdx, final int endIdx, final int separators)
{
if (deferredError != null)
{
return;
}
digitsVisited = true;
sb.append('.');
if (startIdx == endIdx)
{
// Special case, check it here to prevent endless loop below
return;
}
// Test if all fraction digits will fit, otherwise round the value
// The number of outstanding digits
final int unfitDigits = rightDigits.length() - (endIdx - startIdx - separators);
final String src;
if (unfitDigits > 0)
{
// If we are here, then some digits do not fit, do rounding and repeat
final BigDecimal rounded = value.setScale(value.scale() - unfitDigits,
RoundingMode.HALF_UP);
final String roundedString = rounded.toString();
final int dotIdx = roundedString.indexOf('.');
assert dotIdx >= 0;
src = roundedString.substring(dotIdx + 1);
}
else
{
src = rightDigits;
}
final int srcLength = src.length();
int digitsIdx = 0;
for (int fmtIdx = startIdx; fmtIdx < endIdx; fmtIdx++)
{
final char fmtChar = format.charAt(fmtIdx);
if (fmtChar == ',')
{
sb.append(',');
continue;
}
if (digitsIdx < srcLength)
{
final int nextChar = src.charAt(digitsIdx++);
sb.append((char) nextChar);
continue;
}
switch (fmtChar)
{
case '9':
sb.append('0');
break;
case '<':
break;
default:
// Must never be here
throw new ErrorConditionException(-1, "");
}
}
}
/**
* Set the deferred error action.
* <p>
* Implementation should call this method only once when the first value-related
* error is detected.
*
* @param reportedValue
* the value as string to use in the error message
*/
private final void setDeferredError(final String reportedValue)
{
// Implementation should assert we never set it twice
assert deferredError == null;
deferredError = () -> DisplayFormat.genValueCannotBeDisplayedError(reportedValue, format);
}
}