/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB 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 General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
/* eslint-disable no-irregular-whitespace */
import { FormatParser } from './format_parser';
import { NumberFormatSection } from './number_format_section';
import type { TextPart, Complex, DimensionedQuantity, CellValue} from 'treb-base-types';
import {
Localization, TextPartFlag, IsDimensionedQuantity,
} from 'treb-base-types';
//
// excel time is explicitly universal, so we need all dates in and out
// to be UTC. we can't use local time because of daylight savings (which
// excel ignores)
//
// the actual epoch is "January 0" -- I suppose that === Dec 31?
//
// const base_date = -2209075200000; // new Date('1899-12-31 00:00:00 Z').getTime();
//
// excel time is 1 == 1 day, so relative to js time (millis), we need
// to scale by 1000 * 60 * 60 * 24
//
// const date_scale = 86400000;
//
// one last thing -- Excel incorrectly treats 1900 as a leap year. this was
// for compatibility with Lotus 1-2-3, which handled it incorrectly. we will
// join the party and treat it incorrectly as well.
//
// ref:
// https://docs.microsoft.com/en-us/office/troubleshoot/excel/wrongly-assumes-1900-is-leap-year
//
// what about backwards?
//
// OK, I can answer that now: Excel just doesn't handle dates before 1900
// at all. can't parse them; can't handle negative numbers as dates.
/** convert cell value -> date, using the rules above */
export const LotusDate = (value: number): Date => {
if (value >= 60) value--; // March 1, 1900
return new Date(-2209075200000 + 86400000 * value);
};
/** convert date (as number, utc millis) -> lotus date value */
export const UnlotusDate = (value: number, local = true): number => {
// if the passed value is local, we need to convert it to UTC
if (local) {
const local_date = new Date(value);
const utc_date = new Date(
local_date.getFullYear(),
local_date.getMonth(),
local_date.getDate(),
local_date.getHours(),
local_date.getMinutes(),
local_date.getSeconds(),
local_date.getMilliseconds(),
);
// console.info("Converting local", utc_date.toUTCString());
value = utc_date.getTime();
}
value = (value + 2209075200000) / 86400000;
if (value >= 60) { value++; }
return value;
};
/**
* unifying date format and number format (really just bolting dates
* on the side). dates have only a single section, constant pattern, and
* are immutable.
*/
export class NumberFormat {
public static grouping_regexp = /\d{1,3}(?=(\d{3})+(?!\d))/g;
public static fraction_limits = [9, 99, 999, 9999];
/**
* this is now exposed so it can be changed, for rendering; some options are
*
* "i" - regular i, and the default
* "𝑖" - mathematical italic small i", U+1D456
* " 𝑖" - the same, with a leading hair space (U+200A)
*/
public static imaginary_character = '𝑖'; // 'i';
/**
* also for complex rendering, the minus sign. there's a unicode
* symbol U+2212 which (at least in calibri) is wider than the regular minus
* sign/hyphen. I like this but it looks a bit odd if negative numbers are
* rendered using the other one.
*
* "-" - hyphen
* "−" - minus
*/
public static minus_character = '-'; // hyphen
// public static minus_character = '−'; // minus
/* * for the "General" format, a magic decimal point */
// public magic_decimal = false;
/**
* (testing) transformer. this is not rendered or persisted, like magic
* decimal it needs to be applied in code. ATM this is only applied in
* formatting DQ, but it might turn out to be more universal...
*
* NOTE that atm this transforms value back into the same type; we don't
* cross types (at least for now). perhaps we should support that? that
* might mean switching in here and removing the "special" format calls
* for complex and DQ.
*/
public transform_value?: (value: CellValue) => CellValue;
// tslint:disable-next-line:variable-name
protected _pattern = '';
protected sections: NumberFormatSection[];
protected decimal_zero_regexp: Array = [];
// this is a flag for string representation
protected cloned: boolean[] = [];
// NumberFormat.decimal_mark = Localization.decimal_separator;
// if (NumberFormat.decimal_mark === ',') NumberFormat.grouping_separator = ' ';
// public static decimal_mark: '.'|',' = Localization.decimal_separator;
// public static grouping_separator = (Localization.decimal_separator === '.') ? ',' : ' ';
public get pattern(): string {
return this._pattern;
}
/** flag indicates if this is a date format */
public get date_format(): boolean {
return this.sections[0] && this.sections[0].date_format;
}
constructor(pattern: string) {
this._pattern = pattern;
this.sections = FormatParser.Parse(pattern);
// nothing?
if (!this.sections.length) this.sections = [];
// check zero. we were previously assuming this stepped, but we
// now support gaps in format sections (although not at 0?)
if (!this.sections[0]) {
this.sections[0] = new NumberFormatSection(); // pretty sure this cannot happen atm
}
// do we have a negative section? if not, use the positive
// section and prepend a - sign.
if (!this.sections[1]) {
this.sections[1] = { ...this.sections[0] };
this.sections[1].prefix = JSON.parse(JSON.stringify(this.sections[1].prefix));
this.sections[1].suffix = JSON.parse(JSON.stringify(this.sections[1].suffix));
this.sections[1].prefix.push({ text: '-' }); // at end of prefix, before number
this.cloned[1] = true;
}
// do we have a zero section? if not, clone the positive section.
if (!this.sections[2]) {
this.sections[2] = { ...this.sections[0] };
this.cloned[2] = true;
}
// string section, default just reflects the string. we could perhaps
// skip this and just have default behavior if there's no section, which
// might simplify rendering
// UPDATE, special case: unless a string section is explicitly
// provided, we use a default '@' section (it's implicit). however,
// if there's a literatal '@' in the first section, we want to
// propogate that to all empty sections, including the string section.
// note that we should not support literal AND numeric sections in
// the same block... it will fail silently here... [FIXME: at least warn]
if (!this.sections[3]) {
for (const part of this.sections[0].prefix) {
if (part.flag === TextPartFlag.literal) {
this.sections[3] = { ...this.sections[0] };
this.sections[3].string_format = true;
this.cloned[3] = true;
break;
}
}
}
/*
if (!this.sections[3]) {
this.sections[3] = new NumberFormatSection();
this.sections[3].string_format = true;
this.sections[3].prefix = [{ text: '@', flag: TextPartFlag.literal }];
// obviously not cloned, but we want the behavior. FIXME: change flag name
this.cloned[3] = true;
}
*/
this.decimal_zero_regexp = this.sections.map((section) => {
if (section.decimal_max_digits > section.decimal_min_digits) {
return new RegExp(`0{1,${section.decimal_max_digits - section.decimal_min_digits}}(?:$|e)`);
}
return undefined;
});
}
/**
* render text parts to string
* FIXME: move
*/
public static FormatPartsAsText(parts: TextPart[], text_width = 0): string {
let padded = -1;
const formatted = parts.map((part, index) => {
switch (part.flag) {
case TextPartFlag.padded:
padded = index;
return part.text;
case TextPartFlag.hidden:
return part.text.replace(/./g, ' ');
case TextPartFlag.formatting:
return '';
default:
return part.text;
}
});
if (padded >= 0 && text_width) {
const total_length = formatted.reduce((a, str, index) => (index === padded) ? a : a + str.length, 0);
let tmp = '';
for (let i = 0; i < text_width - total_length; i++) {
tmp += formatted[padded];
}
formatted[padded] = tmp;
}
return formatted.join('');
}
/** for decimal only, set an explicit number of digits */
public SetDecimal(digits: number): void {
for (const section of this.sections) {
if (!section.fraction_format) {
section.decimal_min_digits = digits;
section.decimal_max_digits = digits;
}
}
}
/**
* mutate
* UPDATE: for fractional formats, increase the denominator digits
* (doing something weird with fixed denominators...)
*/
public IncreaseDecimal(): void {
this.sections.forEach((section) => {
if (section.fraction_format) {
if (!section.fraction_denominator) {
section.fraction_denominator_digits = Math.min(section.fraction_denominator_digits + 1, 4);
}
}
else {
section.decimal_min_digits++;
section.decimal_max_digits = section.decimal_min_digits;
}
});
}
/**
* mutate
* UPDATE: for fractional formats, decrease the denominator digits
* (doing something weird with fixed denominators...)
*/
public DecreaseDecimal(): void {
this.sections.forEach((section) => {
if (section.fraction_format) {
if (!section.fraction_denominator) {
section.fraction_denominator_digits = Math.max(section.fraction_denominator_digits - 1, 1);
}
}
else {
section.decimal_min_digits = Math.max(0, section.decimal_min_digits - 1);
section.decimal_max_digits = section.decimal_min_digits;
}
});
}
/** mutate */
public AddGrouping(): void {
this.sections.forEach((section) => {
section.grouping = true;
});
}
/** mutate */
public RemoveGrouping(): void {
this.sections.forEach((section) => {
section.grouping = false;
});
}
/** mutate */
public ToggleGrouping(): void {
// set all to ! the value of the first one
const grouping = !this.sections[0].grouping;
this.sections.forEach((section) => {
section.grouping = grouping;
});
}
/**
* generates a string representation. we use this because we are (now)
* allowing mutation of formats; therefore we need to serialize them back
* to the basic format.
*/
public toString(): string {
if (this.sections[0].date_format) {
return this._pattern; // immutable
}
return this.sections.filter((section, i) => {
return !this.cloned[i];
}).map((section) => {
let nf = '';
let i = 0;
if (section.fraction_format) {
if (section.fraction_integer) {
nf += '? ';
}
let pattern = '';
for (let j = 0; j < section.fraction_denominator_digits; j++) {
pattern += '#';
}
nf += pattern;
nf += '/';
if (section.fraction_denominator) {
nf += section.fraction_denominator;
}
else {
nf += pattern;
}
}
else if (section.has_number_format) {
for (i = 0; i < section.integer_min_digits; i++) {
nf += '0';
}
if (section.grouping) {
if (nf.length < 4) nf = ('####' + nf).slice(-4);
nf = nf.replace(/[\d#]{1,3}(?=([\d#]{3})+(?![\d#]))/g, '$&' + ','); // Localization.grouping_separator);
}
if (section.decimal_max_digits || section.decimal_min_digits) {
nf += '.'; // Localization.decimal_separator;
for (i = 0; i < section.decimal_min_digits; i++) { nf += '0'; }
for (; i < section.decimal_max_digits; i++) { nf += '#'; }
}
if (section.scaling) {
const count = Math.log10(section.scaling) / 3;
for (i = 0; i < count; i++) { nf += ','; }
}
if (section.exponential) {
nf += 'e';
}
}
return section.prefix.map((part) => {
if (part.flag === TextPartFlag.hidden) {
return part.text === '0' ? '?' : '_' + part.text;
}
else if (part.flag === TextPartFlag.padded) {
return '*' + part.text;
}
else if (part.flag === TextPartFlag.formatting) {
return '[' + part.text + ']';
}
return part.text;
}).join('') + nf +
section.suffix.map((part) => {
if (part.flag === TextPartFlag.hidden) {
return part.text === '0' ? '?' : '_' + part.text;
}
else if (part.flag === TextPartFlag.padded) { return '*' + part.text; }
return part.text;
}).join('');
}).join(';');
}
/** also temporary? why not switch in here? */
public FormatDimensionedQuantity(value: DimensionedQuantity): TextPart[]|string {
if (this.transform_value) {
const result = this.transform_value(value);
if (IsDimensionedQuantity(result)) {
value = result;
}
else if (typeof result === 'string') {
// so this is new, but we want string semantics here; rendering
// is different because strings can wrap
return result;
}
else {
// could be a complex (not likely now, but things change), in which
// case this is not the right method -- we need a method that checks
// and switches.
return this.FormatParts(result);
}
}
const parts: TextPart[] = this.FormatParts(value.value || 0);
// anything fancy we want to do in here...
if (value.unit) {
parts.push({text: ' '}, {
text: value.unit
});
}
return parts;
}
/**
* temporary
*
* FIXME: merge with FormatParts, use a test to check if it's complex?
* OTOH that adds a test to every format which is probably wasteful...
* although we can check for 'number' first
*
*/
public FormatComplex(value: Complex): TextPart[] {
// formatting complex value (note for searching)
// this needs some work. some thoughts:
//
// (1) allow fractions and decimals, but not percent or exponential notation
// (2) change default for General to have fewer decimal places
// (3) use the section's integer specification to decide on whether to
// write "1i" or just "i"
// (4) drop either real or imaginary part (but not both) if it renders as
// all zeros
// (5) change default fraction to #/## (actually we should do that always)
if (value.imaginary === Infinity || value.imaginary === -Infinity ||
value.real === Infinity || value.real === -Infinity) {
return [
{
text: 'Infinity',
}
]
}
// check if the imaginary format will render as 0.00i -- we want to
// handle this differently.
let imaginary_format: TextPart[] = [];
let real_format: TextPart[] = [];
let drop_imaginary_coefficient = false;
let has_imaginary_value = !!value.imaginary;
if (has_imaginary_value) {
imaginary_format = this.FormatParts(value.imaginary);
has_imaginary_value = imaginary_format.some(element => /[1-9]/.test(element.text));
// special case: if the integer is not required and the value is
// either "1" or "-1", drop the integer. do this for integer length
// <= 1, because you want 0, i, 2i, 3i, &c.
if (imaginary_format.length === 1 &&
this.sections[0].integer_min_digits <= 1 &&
imaginary_format[0].text === '1' ) {
imaginary_format[0].text = '';
drop_imaginary_coefficient = true;
}
else if (imaginary_format.length === 1 &&
this.sections[1].integer_min_digits <= 1 &&
imaginary_format[0].text === '-1' ) {
imaginary_format[0].text = '-';
drop_imaginary_coefficient = true;
}
}
let has_real_value = !!value.real;
if (has_real_value) {
real_format = this.FormatParts(value.real);
has_real_value = real_format.some(element => /[1-9]/.test(element.text));
}
const parts: TextPart[] = [];
if (has_real_value || (!has_real_value && !has_imaginary_value)) {
// has real part, or is === 0
parts.push(...real_format);
if (has_imaginary_value) {
// also has imaginary part
// const i = Math.abs(value.imaginary);
parts.push({ text: value.imaginary < 0 ? ` ${NumberFormat.minus_character} ` : ' + ' });
const reformatted_imaginary = drop_imaginary_coefficient ?
[] : this.FormatParts(Math.abs(value.imaginary));
parts.push(...reformatted_imaginary, { text: NumberFormat.imaginary_character });
}
}
else if (has_imaginary_value) {
// only imaginary part
parts.push(...imaginary_format, { text: NumberFormat.imaginary_character });
}
return parts;
}
/**
* this method composes the format as a set of parts with various
* states. it's intended for graphical representation where things
* like hidden characters and padding require multiple passes or measurement.
*/
public FormatParts(value: CellValue): TextPart[] {
// new, shortcut
if (typeof value !== 'number' && !this.sections[3]) {
// NOTE: that note (next line) seems to be incorrect, not sure why
// ofc if that was true there'd be no point to this block...
return [{ text: (value??'').toString() }] as TextPart[]; // unreachable because we ensure 4 sections
}
const { parts, section } = this.BaseFormat(value);
let text_parts: TextPart[] = [];
if (section.date_format || section.string_format) {
for (const part of parts) {
if (typeof part === 'string') {
text_parts.push({ text: part });
}
else text_parts.push(part);
}
}
else {
/*
if (this.magic_decimal && parts[1] === '') {
parts.splice(1, 1);
}
*/
text_parts = [
...(section.prefix.map((text_part) => {
return { ...text_part };
})),
{ text: section.has_number_format ? parts.join(Localization.decimal_separator) : '' },
...(section.suffix.map((text_part) => {
return { ...text_part };
})),
];
}
for (let i = 1; i < text_parts.length; i++) {
if (text_parts[i].flag === text_parts[i - 1].flag) {
text_parts[i].text = text_parts[i - 1].text + text_parts[i].text;
text_parts[i - 1].text = '';
}
}
return text_parts.filter((text_part) => text_part.text); // remove empty
}
/**
* formats a number as text.
*
* this method will use a single space to replace hidden (leading-underscore)
* characters. if a text width is provided, it will use that for padding;
* otherwise the padding character (we only allow a single padding character)
* is rendered once.
*
* FIXME: date, string (this is lagging)
* UPDATE: unifying, basing this on the text part functionality
*/
public Format(value: CellValue, text_width = 0): string {
/*
const parts = this.FormatParts(value);
let padded = -1;
const formatted = parts.map((part, index) => {
switch (part.flag) {
case TextPartFlag.padded:
padded = index;
return part.text;
case TextPartFlag.hidden:
return part.text.replace(/./g, ' ');
case TextPartFlag.formatting:
return '';
default:
return part.text;
}
});
if (padded >= 0 && text_width) {
const total_length = formatted.reduce((a, str, index) => (index === padded) ? a : a + str.length, 0);
let tmp = '';
for (let i = 0; i < text_width - total_length; i++){
tmp += formatted[padded];
}
formatted[padded] = tmp;
}
return formatted.join('');
*/
return NumberFormat.FormatPartsAsText(this.FormatParts(value), text_width);
}
public ZeroPad(text: string, length: number): string {
while (text.length < length) text = '0' + text;
return text;
}
public DateFormat(value: number) {
const date = LotusDate(value);
const section = this.sections[0];
let hours = date.getUTCHours();
if (section.twelve_hour) {
if (hours > 12) hours -= 12;
if (hours === 0) hours = 12;
}
const parts: TextPart[] = section.prefix.map((part) => {
if (part.flag === TextPartFlag.date_component_minutes) {
if (part.text === 'mm') {
return { text: this.ZeroPad((date.getUTCMinutes()).toString(), 2) };
}
return { text: this.ZeroPad((date.getUTCMinutes()).toString(), 1) };
}
else if (part.flag === TextPartFlag.date_component) {
switch (part.text.toLowerCase()) {
case 'am/pm':
case 'a/p':
{
const elements = part.text.split('/');
return { text: date.getUTCHours() > 12 ? elements[1] : elements[0] };
}
case 'mmmmm':
return { text: Localization.date_components.long_months[date.getUTCMonth()][0] };
case 'mmmm':
if (part.text === 'MMMM') {
return { text: Localization.date_components.long_months[date.getUTCMonth()].toUpperCase() };
}
return { text: Localization.date_components.long_months[date.getUTCMonth()] };
case 'mmm':
if (part.text === 'MMM') {
return { text: Localization.date_components.short_months[date.getUTCMonth()].toUpperCase() };
}
return { text: Localization.date_components.short_months[date.getUTCMonth()] };
case 'mm':
return { text: this.ZeroPad((date.getUTCMonth() + 1).toString(), 2) };
case 'm':
return { text: this.ZeroPad((date.getUTCMonth() + 1).toString(), 1) };
case 'ddddd':
case 'dddd':
if (part.text === 'DDDDD' || part.text === 'DDDD') {
return { text: Localization.date_components.long_days[date.getUTCDay()].toUpperCase() };
}
return { text: Localization.date_components.long_days[date.getUTCDay()] };
case 'ddd':
if (part.text === 'DDD') {
return { text: Localization.date_components.short_days[date.getUTCDay()].toUpperCase() };
}
return { text: Localization.date_components.short_days[date.getUTCDay()] };
case 'dd':
return { text: this.ZeroPad((date.getUTCDate()).toString(), 2) };
case 'd':
return { text: this.ZeroPad((date.getUTCDate()).toString(), 1) };
case 'yyyy':
case 'yyy':
return { text: date.getUTCFullYear().toString() };
case 'yy':
case 'y':
// return { text: (date.getUTCFullYear() % 100).toString() };
return { text: this.ZeroPad((date.getUTCFullYear() % 100).toString(), 2) };
case 'hh':
return { text: this.ZeroPad(hours.toString(), 2) };
case 'h':
return { text: this.ZeroPad(hours.toString(), 1) };
case 'ss':
return { text: this.ZeroPad((date.getUTCSeconds()).toString(), 2) };
case 's':
return { text: this.ZeroPad((date.getUTCSeconds()).toString(), 1) };
}
const match = part.text.match(/^(s+)\.(0+)$/);
if (match) {
return {
text: this.ZeroPad(date.getUTCSeconds().toString(), match[1].length) +
Localization.decimal_separator +
(date.getUTCMilliseconds() / 1000).toFixed(match[2].length).substr(2),
};
}
}
return { ...part }; // text: part.text, state: part.state};
});
return { parts, section };
}
public StringFormat(value: string, section: NumberFormatSection) {
const parts: TextPart[] = [];
for (const part of section.prefix) {
if (part.flag === TextPartFlag.literal) {
parts.push({ text: value });
}
else parts.push({ ...part });
}
return {
parts, section,
};
}
/*
public DecimalAdjustRound(value: number, exp: number) {
if (!exp) { return Math.round(value); }
value = +value;
// exp = +exp;
// Shift
let values = value.toString().split('e');
value = Math.round(+(values[0] + 'e' + (values[1] ? (+values[1] - exp) : -exp)));
// Shift back
values = value.toString().split('e');
return +(values[0] + 'e' + (values[1] ? (+values[1] + exp) : exp));
}
*/
public Round2(value: number, digits: number): number {
const m = Math.pow(10, digits);
return Math.round(m * value) / m;
}
public FormatFraction(value: number, section: NumberFormatSection): string {
if (section.percent) {
value *= 100;
}
let candidate = {
denominator: 1,
numerator: Math.round(value),
error: Math.abs(Math.round(value) - value),
};
if (section.fraction_denominator) {
candidate.denominator = section.fraction_denominator;
candidate.numerator = Math.round(value * candidate.denominator);
}
else {
if (candidate.error) {
const limit = NumberFormat.fraction_limits[section.fraction_denominator_digits - 1] || NumberFormat.fraction_limits[0];
for (let denominator = 2; denominator <= limit; denominator++) {
const numerator = Math.round(value * denominator);
const error = Math.abs(numerator / denominator - value);
if (error < candidate.error) {
candidate = {
numerator, denominator, error,
};
if (!error) { break; }
}
}
}
}
const text: string[] = [];
if (section.fraction_integer) {
const integer = Math.floor(candidate.numerator / candidate.denominator);
candidate.numerator %= candidate.denominator;
if (integer || !candidate.numerator) {
text.push(integer.toString());
if (candidate.numerator) {
text.push(' ');
}
}
}
else if (!candidate.numerator) {
text.push('0');
}
if (candidate.numerator) {
text.push(candidate.numerator.toString());
text.push('/');
text.push(candidate.denominator.toString());
}
return text.join('');
}
public BaseFormat(value: CellValue) {
if (this.sections[0].date_format) {
return this.DateFormat(Number(value));
}
if (typeof value !== 'number') {
return this.StringFormat((value??'').toString(), this.sections[3]);
}
let section = this.sections[0];
let zero_regexp = this.decimal_zero_regexp[0];
if (value < 0) {
section = this.sections[1];
}
const max_digits = section.percent ?
section.decimal_max_digits + 2 :
section.decimal_max_digits;
const epsilon = Math.pow(10, -max_digits) / 2;
let abs_value = Math.abs(value);
if (abs_value < epsilon) {
section = this.sections[2];
zero_regexp = this.decimal_zero_regexp[2];
}
// there's kind of a weird thing here where we might have
// a non-zero number but scaling turns it into zero...
if (section.scaling) {
abs_value /= section.scaling;
if (abs_value < epsilon) {
section = this.sections[2];
zero_regexp = this.decimal_zero_regexp[2];
}
}
if (section.string_format) {
return this.StringFormat(value.toString(), section);
}
let representation = '';
// special handling for fractions skips most of the other bits
if (section.fraction_format) {
return { parts: [this.FormatFraction(abs_value, section)], section };
}
if (section.exponential) {
representation = abs_value.toExponential(section.decimal_max_digits);
}
else {
if (section.percent) {
abs_value *= 100;
}
representation = this.Round2(abs_value, section.decimal_max_digits).toFixed(section.decimal_max_digits);
}
if (zero_regexp) {
representation = representation.replace(zero_regexp, '');
}
const parts = representation.split('.');
if (parts.length === 2 && parts[1] === '' && section.decimal_min_digits === 0) {
parts.splice(1, 1);
}
while (parts[0].length < section.integer_min_digits) {
// this is sloppy
parts[0] = ('0000000000000000' + parts[0]).slice(-section.integer_min_digits);
}
if (section.integer_min_digits === 0 && parts[0] === '0') {
parts[0] = ''; // not sure why anyone would want that
}
if (section.grouping) {
parts[0] = parts[0].replace(NumberFormat.grouping_regexp, '$&' + Localization.grouping_separator);
}
return { parts, section };
}
}