format: add support for fraction formatting

This commit is contained in:
Todd 2017-09-19 16:21:01 -04:00
parent 32bc9e3a50
commit 1103aa1edb
4 changed files with 1676 additions and 728 deletions

View File

@ -12,28 +12,37 @@ import (
"log"
"math"
"strconv"
"strings"
"time"
)
// constants used when formatting generic values to determine when to start
// rounding
const maxGeneric = 1e11
const minGeneric = 1e-10
// Format is a parsed number format.
type Format struct {
Whole []PlaceHolder
Fractional []PlaceHolder
Exponent []PlaceHolder
Whole []Token
Fractional []Token
Exponent []Token
IsExponential bool
isFraction bool
isPercent bool
isGeneral bool
hasThousands bool
skipNext bool
seenDecimal bool
denom int64
}
// FmtType is the type of a format token.
//go:generate stringer -type=FmtType
type FmtType byte
// Format type constants.
const (
FmtTypeLiteral FmtType = iota
FmtTypeDigit
@ -46,15 +55,18 @@ const (
FmtTypeUnderscore
FmtTypeDate
FmtTypeTime
FmtTypeFraction
)
type PlaceHolder struct {
// Token is a format token in the Excel format string.
type Token struct {
Type FmtType
Literal byte
DateTime string
}
func (f *Format) AddPlaceholder(t FmtType, l []byte) {
// AddToken adds a format token to the format.
func (f *Format) AddToken(t FmtType, l []byte) {
if f.skipNext {
f.skipNext = false
return
@ -65,7 +77,7 @@ func (f *Format) AddPlaceholder(t FmtType, l []byte) {
case FmtTypeUnderscore:
f.skipNext = true
case FmtTypeDate, FmtTypeTime:
f.Whole = append(f.Whole, PlaceHolder{Type: t, DateTime: string(l)})
f.Whole = append(f.Whole, Token{Type: t, DateTime: string(l)})
case FmtTypePercent:
f.isPercent = true
t = FmtTypeLiteral
@ -79,15 +91,22 @@ func (f *Format) AddPlaceholder(t FmtType, l []byte) {
}
for _, c := range l {
if f.IsExponential {
f.Exponent = append(f.Exponent, PlaceHolder{Type: t, Literal: c})
f.Exponent = append(f.Exponent, Token{Type: t, Literal: c})
} else if !f.seenDecimal {
f.Whole = append(f.Whole, PlaceHolder{Type: t, Literal: c})
f.Whole = append(f.Whole, Token{Type: t, Literal: c})
} else {
f.Fractional = append(f.Fractional, PlaceHolder{Type: t, Literal: c})
f.Fractional = append(f.Fractional, Token{Type: t, Literal: c})
}
}
case FmtTypeDigitOptThousands:
f.hasThousands = true
case FmtTypeFraction:
sp := strings.Split(string(l), "/")
if len(sp) == 2 {
f.isFraction = true
f.denom, _ = strconv.ParseInt(sp[1], 10, 64)
// TODO: if anyone cares, parse and use the numerator format.
}
default:
log.Printf("unsupported ph type in parse %v", t)
}
@ -111,6 +130,8 @@ func Number(v float64, f string) string {
return number(v, fmts[0], false)
}
// String returns the string formatted according to the type.
// TODO: implement if anyone needs this.
func String(v string, f string) string {
return v
}
@ -124,9 +145,6 @@ func reverse(b []byte) []byte {
}
func number(vOrig float64, f Format, isNeg bool) string {
epoch := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
t := epoch.Add(time.Duration(vOrig * float64(24*time.Hour)))
t = asLocal(t)
if f.isGeneral {
return NumberGeneric(vOrig)
}
@ -134,10 +152,7 @@ func number(vOrig float64, f Format, isNeg bool) string {
wasNeg := math.Signbit(vOrig)
v := math.Abs(vOrig)
// percent symbol implies multiplying the value by 100
if f.isPercent {
v *= 100
}
fractNum := int64(0)
exp := int64(0)
if f.IsExponential {
for v >= 10 {
@ -148,6 +163,14 @@ func number(vOrig float64, f Format, isNeg bool) string {
exp--
v *= 10
}
} else if f.isPercent {
// percent symbol implies multiplying the value by 100
v *= 100
} else if f.isFraction {
fractNum = int64(v*float64(f.denom) + 0.5)
if len(f.Whole) > 0 {
fractNum = fractNum % f.denom
}
}
// round up now as this avoids rounding up on just the decimal portion which
@ -162,192 +185,17 @@ func number(vOrig float64, f Format, isNeg bool) string {
// split into whole and decimal portions
pre, post := math.Modf(v)
buf = append(buf, formatWholeNumber(pre, vOrig, f)...)
buf = append(buf, formatFractional(post, vOrig, f)...)
buf = append(buf, formatExponential(exp, f)...)
if len(f.Whole) > 0 {
raw := strconv.AppendFloat(nil, pre, 'f', -1, 64)
op := make([]byte, 0, len(raw))
consumed := 0
lastIdx := 1
lfor:
for i := len(f.Whole) - 1; i >= 0; i-- {
bidx := len(raw) - 1 - consumed
ph := f.Whole[i]
switch ph.Type {
// '0' consumes a digit or prints a '0' if there is no digit
case FmtTypeDigit:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
lastIdx = i
} else {
op = append(op, '0')
}
// '#' consumes a digit or prints nothing
case FmtTypeDigitOpt:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
lastIdx = i
} else {
// we don't skip everything, just #/,/?. This is used so
// that formats like (#,###) with '1' turn into '(1)' and
// not '1)'
for j := i; j >= 0; j-- {
c := f.Whole[j]
if c.Type == FmtTypeLiteral {
op = append(op, c.Literal)
}
}
break lfor
}
case FmtTypeDollar:
for i := consumed; i < len(raw); i++ {
op = append(op, raw[len(raw)-1-i])
consumed++
}
op = append(op, '$')
case FmtTypeComma:
if !f.hasThousands {
op = append(op, ',')
}
case FmtTypeLiteral:
op = append(op, ph.Literal)
case FmtTypeDate:
op = append(op, reverse(dDate(t, ph.DateTime))...)
case FmtTypeTime:
op = append(op, reverse(dTime(t, vOrig, ph.DateTime))...)
default:
log.Printf("unsupported type in whole %v", ph)
}
}
buf = append(buf, reverse(op)...)
// didn't consume all of the number characters, so insert the rest where
// we were last inserting
if consumed < len(raw) && (consumed != 0 || f.seenDecimal) {
rem := len(raw) - consumed
o := make([]byte, len(buf)+rem)
copy(o, buf[0:lastIdx])
copy(o[lastIdx:], raw[0:])
copy(o[lastIdx+rem:], buf[lastIdx:])
buf = o
}
if f.hasThousands {
b := bytes.Buffer{}
nonTerm := 0
for i := len(buf) - 1; i >= 0; i-- {
if !(buf[i] >= '0' && buf[i] <= '9') {
nonTerm++
} else {
break
}
}
for i := 0; i < len(buf); i++ {
idx := (len(buf) - i - nonTerm)
if idx%3 == 0 && idx != 0 && i != 0 {
b.WriteByte(',')
}
b.WriteByte(buf[i])
}
buf = b.Bytes()
}
}
if len(f.Fractional) != 0 {
buf = append(buf, '.')
raw := strconv.AppendFloat(nil, post, 'f', -1, 64)
if len(raw) > 2 {
raw = raw[2:] // skip the decimal portion (ie. '0.')
} else {
raw = nil
}
op := make([]byte, 0, len(raw))
consumed := 0
lforPost:
for i := 0; i < len(f.Fractional); i++ {
bidx := i
ph := f.Fractional[i]
switch ph.Type {
// '0' consumes a digit or prints a '0' if there is no digit
case FmtTypeDigit:
if bidx < len(raw) {
op = append(op, raw[bidx])
consumed++
} else {
op = append(op, '0')
}
// '#' consumes a digit or prints nothing
case FmtTypeDigitOpt:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
} else {
break lforPost
}
case FmtTypeLiteral:
op = append(op, ph.Literal)
default:
log.Printf("unsupported type in fractional %v", ph)
}
}
// remaining digits are truncated
buf = append(buf, op...)
}
if f.IsExponential {
if len(f.Exponent) > 0 {
buf = append(buf, 'E')
if exp >= 0 {
buf = append(buf, '+')
} else {
buf = append(buf, '-')
exp *= -1
}
raw := strconv.AppendInt(nil, exp, 10)
op := make([]byte, 0, len(raw))
consumed := 0
lexfor:
for i := len(f.Exponent) - 1; i >= 0; i-- {
bidx := len(raw) - 1 - consumed
ph := f.Exponent[i]
switch ph.Type {
// '0' consumes a digit or prints a '0' if there is no digit
case FmtTypeDigit:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
} else {
op = append(op, '0')
}
// '#' consumes a digit or prints nothing
case FmtTypeDigitOpt:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
} else {
for j := i; j >= 0; j-- {
c := f.Exponent[j]
if c.Type == FmtTypeLiteral {
op = append(op, c.Literal)
}
}
break lexfor
}
case FmtTypeLiteral:
op = append(op, ph.Literal)
default:
log.Printf("unsupported type in exp %v", ph)
}
}
// remaining non-consumed digits in the exponent
if consumed < len(raw) {
op = append(op, raw[len(raw)-consumed-1:consumed-1]...)
}
buf = append(buf, reverse(op)...)
}
// fractions are special, the whole number portion is handled above (if
// len(f.whole) > 0). This is for the fractional portion, or in the case if
// no whole portion, the numerator will be greater than the denominator.
if f.isFraction {
buf = strconv.AppendInt(buf, fractNum, 10)
buf = append(buf, '/')
buf = strconv.AppendInt(buf, f.denom, 10)
}
// if the number was negative, but this isn't a 'negative' format, then
// we need to prepend a negative sign
@ -357,6 +205,216 @@ func number(vOrig float64, f Format, isNeg bool) string {
return string(buf)
}
func formatWholeNumber(pre, vOrig float64, f Format) []byte {
if len(f.Whole) == 0 {
return nil
}
epoch := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
t := epoch.Add(time.Duration(vOrig * float64(24*time.Hour)))
t = asLocal(t)
raw := strconv.AppendFloat(nil, pre, 'f', -1, 64)
op := make([]byte, 0, len(raw))
consumed := 0
lastIdx := 1
lfor:
for i := len(f.Whole) - 1; i >= 0; i-- {
bidx := len(raw) - 1 - consumed
ph := f.Whole[i]
switch ph.Type {
// '0' consumes a digit or prints a '0' if there is no digit
case FmtTypeDigit:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
lastIdx = i
} else {
op = append(op, '0')
}
// '#' consumes a digit or prints nothing
case FmtTypeDigitOpt:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
lastIdx = i
} else {
// we don't skip everything, just #/,/?. This is used so
// that formats like (#,###) with '1' turn into '(1)' and
// not '1)'
for j := i; j >= 0; j-- {
c := f.Whole[j]
if c.Type == FmtTypeLiteral {
op = append(op, c.Literal)
}
}
break lfor
}
case FmtTypeDollar:
for i := consumed; i < len(raw); i++ {
op = append(op, raw[len(raw)-1-i])
consumed++
}
op = append(op, '$')
case FmtTypeComma:
if !f.hasThousands {
op = append(op, ',')
}
case FmtTypeLiteral:
op = append(op, ph.Literal)
case FmtTypeDate:
op = append(op, reverse(dDate(t, ph.DateTime))...)
case FmtTypeTime:
op = append(op, reverse(dTime(t, vOrig, ph.DateTime))...)
default:
log.Printf("unsupported type in whole %v", ph)
}
}
buf := reverse(op)
// didn't consume all of the number characters, so insert the rest where
// we were last inserting
if consumed < len(raw) && (consumed != 0 || f.seenDecimal) {
rem := len(raw) - consumed
o := make([]byte, len(buf)+rem)
copy(o, buf[0:lastIdx])
copy(o[lastIdx:], raw[0:])
copy(o[lastIdx+rem:], buf[lastIdx:])
buf = o
}
if f.hasThousands {
b := bytes.Buffer{}
nonTerm := 0
for i := len(buf) - 1; i >= 0; i-- {
if !(buf[i] >= '0' && buf[i] <= '9') {
nonTerm++
} else {
break
}
}
for i := 0; i < len(buf); i++ {
idx := (len(buf) - i - nonTerm)
if idx%3 == 0 && idx != 0 && i != 0 {
b.WriteByte(',')
}
b.WriteByte(buf[i])
}
buf = b.Bytes()
}
return buf
}
func formatFractional(post, vOrig float64, f Format) []byte {
if len(f.Fractional) == 0 {
return nil
}
raw := strconv.AppendFloat(nil, post, 'f', -1, 64)
if len(raw) > 2 {
raw = raw[2:] // skip the decimal portion (ie. '0.')
} else {
raw = nil
}
op := make([]byte, 0, len(raw))
op = append(op, '.')
consumed := 0
lforPost:
for i := 0; i < len(f.Fractional); i++ {
bidx := i
ph := f.Fractional[i]
switch ph.Type {
// '0' consumes a digit or prints a '0' if there is no digit
case FmtTypeDigit:
if bidx < len(raw) {
op = append(op, raw[bidx])
consumed++
} else {
op = append(op, '0')
}
// '#' consumes a digit or prints nothing
case FmtTypeDigitOpt:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
} else {
break lforPost
}
case FmtTypeLiteral:
op = append(op, ph.Literal)
default:
log.Printf("unsupported type in fractional %v", ph)
}
}
// remaining digits are truncated
return op
}
func absi64(i int64) int64 {
if i < 0 {
return -i
}
return i
}
func formatExponential(exp int64, f Format) []byte {
if !f.IsExponential || len(f.Exponent) == 0 {
return nil
}
raw := strconv.AppendInt(nil, absi64(exp), 10)
op := make([]byte, 0, len(raw)+2)
op = append(op, 'E')
if exp >= 0 {
op = append(op, '+')
} else {
op = append(op, '-')
exp *= -1
}
consumed := 0
lexfor:
for i := len(f.Exponent) - 1; i >= 0; i-- {
bidx := len(raw) - 1 - consumed
ph := f.Exponent[i]
switch ph.Type {
// '0' consumes a digit or prints a '0' if there is no digit
case FmtTypeDigit:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
} else {
op = append(op, '0')
}
// '#' consumes a digit or prints nothing
case FmtTypeDigitOpt:
if bidx >= 0 {
op = append(op, raw[bidx])
consumed++
} else {
for j := i; j >= 0; j-- {
c := f.Exponent[j]
if c.Type == FmtTypeLiteral {
op = append(op, c.Literal)
}
}
break lexfor
}
case FmtTypeLiteral:
op = append(op, ph.Literal)
default:
log.Printf("unsupported type in exp %v", ph)
}
}
// remaining non-consumed digits in the exponent
if consumed < len(raw) {
op = append(op, raw[len(raw)-consumed-1:consumed-1]...)
}
reverse(op[2:])
return op
}
// NumberGeneric formats the number with the generic format which attemps to
// mimic Excel's general formatting.
func NumberGeneric(v float64) string {
@ -440,6 +498,7 @@ func performCarries(b []byte) []byte {
return b
}
// dDate formats a time with an Excel format date string.
func dDate(t time.Time, f string) []byte {
ret := []byte{}
beg := 0
@ -505,6 +564,7 @@ func dDate(t time.Time, f string) []byte {
return ret
}
// dTime formats a time with an Excel format time string.
func dTime(t time.Time, v float64, f string) []byte {
ret := []byte{}
beg := 0

View File

@ -93,7 +93,11 @@ func TestCellFormattingNumber(t *testing.T) {
{-4, "#,##0_);[Red](#,##0)", "(4)"},
// fractions
// {1.5, `0/100`, "150/100"},
{1.5, `0/100`, "150/100"},
{0.5, "0/1000", "500/1000"},
{1.25, "0 0/100", "1 25/100"},
{0.5, "0/10", "5/10"},
{0.25, "0/10", "3/10"},
// dates & times
{42996.6996269676, "d-mmm-yy", "18-Sep-17"},

File diff suppressed because it is too large Load Diff

View File

@ -68,28 +68,28 @@ import (
NFTime = (NFTimeToken | ':')+;
cond = '[' any+ ']';
main := |*
'#,#' => { l.fmt.AddPlaceholder(FmtTypeDigitOptThousands,nil) };
'0' => { l.fmt.AddPlaceholder(FmtTypeDigit,nil) };
'#' => { l.fmt.AddPlaceholder(FmtTypeDigitOpt,nil) };
'#,#' => { l.fmt.AddToken(FmtTypeDigitOptThousands,nil) };
'0' => { l.fmt.AddToken(FmtTypeDigit,nil) };
'#' => { l.fmt.AddToken(FmtTypeDigitOpt,nil) };
'?' => { }; # ignore for now
'.' => { l.fmt.AddPlaceholder(FmtTypeDecimal,nil) };
',' => { l.fmt.AddPlaceholder(FmtTypeComma,nil) };
'%' => { l.fmt.AddPlaceholder(FmtTypePercent,nil) };
'$' => { l.fmt.AddPlaceholder(FmtTypeDollar,nil) };
'_' => { l.fmt.AddPlaceholder(FmtTypeUnderscore,nil) };
'.' => { l.fmt.AddToken(FmtTypeDecimal,nil) };
',' => { l.fmt.AddToken(FmtTypeComma,nil) };
'%' => { l.fmt.AddToken(FmtTypePercent,nil) };
'$' => { l.fmt.AddToken(FmtTypeDollar,nil) };
'_' => { l.fmt.AddToken(FmtTypeUnderscore,nil) };
';' => { l.nextFmt() };
NFGeneral => { l.fmt.isGeneral = true };
#NFFraction => {fmt.Println("FRACTION",string(data[ts:te]))};
NFFraction => { l.fmt.AddToken(FmtTypeFraction,data[ts:te]) };
# we have to keep date/time separate as 'mm' is both minutes and month
NFDate => { l.fmt.AddPlaceholder(FmtTypeDate,data[ts:te]) };
NFTime => { l.fmt.AddPlaceholder(FmtTypeTime,data[ts:te]) };
NFAbsTimeToken => { l.fmt.AddPlaceholder(FmtTypeTime,data[ts:te]) };
NFDate => { l.fmt.AddToken(FmtTypeDate,data[ts:te]) };
NFTime => { l.fmt.AddToken(FmtTypeTime,data[ts:te]) };
NFAbsTimeToken => { l.fmt.AddToken(FmtTypeTime,data[ts:te]) };
NFPartExponential => { l.fmt.IsExponential = true };
cond => {}; # ignoring
# escaped
'\\' any => { l.fmt.AddPlaceholder(FmtTypeLiteral,data[ts+1:te]) };
any => { l.fmt.AddPlaceholder(FmtTypeLiteral,data[ts:te]) };
dquote ( not_dquote | dquote dquote)* dquote => { l.fmt.AddPlaceholder(FmtTypeLiteral,data[ts+1:te-1])};
'\\' any => { l.fmt.AddToken(FmtTypeLiteral,data[ts+1:te]) };
any => { l.fmt.AddToken(FmtTypeLiteral,data[ts:te]) };
dquote ( not_dquote | dquote dquote)* dquote => { l.fmt.AddToken(FmtTypeLiteral,data[ts+1:te-1])};
*|;