numfmt
Version:
Full Excel style number formatting
260 lines (243 loc) • 7.53 kB
JavaScript
import { u_YEAR, u_MONTH, u_DAY, u_HOUR, u_MIN, u_SEC, reCurrencySymbols } from './constants.js';
export function isPercent (partitions) {
return !!(
(partitions[0] && partitions[0].percent) ||
(partitions[1] && partitions[1].percent) ||
(partitions[2] && partitions[2].percent) ||
(partitions[3] && partitions[3].percent)
);
}
export function isDate (partitions) {
return !!(
(partitions[0] && partitions[0].date) ||
(partitions[1] && partitions[1].date) ||
(partitions[2] && partitions[2].date) ||
(partitions[3] && partitions[3].date)
);
}
export function isText (partitions) {
const [ part1, part2, part3, part4 ] = partitions;
return !!(
(!part1 || part1.generated) &&
(!part2 || part2.generated) &&
(!part3 || part3.generated) &&
(part4 && part4.text && !part4.generated)
);
}
const level = {
text: 15,
datetime: 10.8,
date: 10.8,
time: 10.8,
percent: 10.6,
currency: 10.4,
grouped: 10.2,
scientific: 6,
number: 4,
fraction: 2,
general: 0,
error: 0
};
const dateCodes = [
[ 'DMY', 1 ],
[ 'DM', 2 ],
[ 'MY', 3 ],
[ 'MDY', 4 ],
[ 'MD', 5 ],
[ 'hmsa', 6 ],
[ 'hma', 7 ],
[ 'hms', 8 ],
[ 'hm', 9 ]
];
/**
* @typedef {object} FormatInfo
* An object of information properties based on a format pattern.
* @property {(
* "currency" | "date" | "datetime" |
* "error" | "fraction" | "general" |
* "grouped" | "number" | "percent" |
* "scientific" | "text" | "time"
* )} type
* A string identifier for the type of the number formatter.
* @property {boolean} isDate
* Corresponds to the output from isDateFormat.
* @property {boolean} isText
* Corresponds to the output from isTextFormat.
* @property {boolean} isPercent
* Corresponds to the output from isPercentFormat.
* @property {number} maxDecimals
* The maximum number of decimals this format will emit.
* @property {0|1} color
* 1 if the format uses color on the negative portion of the string, else
* a 0. This replicates Excel's `CELL("color")` functionality.
* @property {0|1} parentheses
* 1 if the positive portion of the number format contains an open
* parenthesis, else a 0. This is replicates Excel's `CELL("parentheses")`
* functionality.
* @property {0|1} grouped
* 1 if the positive portion of the format uses a thousands separator,
* else a 0.
* @property {string} code
* Corresponds to Excel's `CELL("format")` functionality. It should match
* Excel's esoteric behaviour fairly well.
* [See Microsoft's documentation.](https://support.microsoft.com/en-us/office/cell-function-51bd39a5-f338-4dbe-a33f-955d67c2b2cf)
* @property {number} scale
* The multiplier used when formatting the number (100 for percentages).
* @property {number} level
* An arbirarty number that represents the format's specificity if you want
* to compare one to another. Integer comparisons roughly match Excel's
* resolutions when it determines which format wins out.
*/
export function info (partitions, currencyId = null) {
const [ partPos, partNeg ] = partitions;
const frac_max = partPos.frac_max;
const info = {
type: 'general',
isDate: isDate(partitions),
isText: isText(partitions),
isPercent: isPercent(partitions),
maxDecimals: partPos.general ? 9 : frac_max,
scale: partPos.scale ?? 1,
color: 0,
parentheses: 0,
grouped: partPos.grouping ? 1 : 0
};
// currency identifier may be passed in, but otherwise we report
// if we find any known glyph in the tokens
const isCurrency = (!info.isDate && !info.isText && !partPos.error) && partPos.tokens.some(tok => (
tok.type === 'string' &&
(currencyId
? tok.value === currencyId
: reCurrencySymbols.test(tok.value))
));
let codeType = 'G';
let codeNum = (frac_max >= 0) ? Math.min(15, frac_max) : '';
let codeParens = '';
let codeDash = '';
if (partNeg && partNeg.color) {
codeDash = '-';
info.color = 1;
}
if (partPos.parens) {
codeParens = '()';
info.parentheses = 1;
}
if (isCurrency) {
codeType = 'C';
info.type = 'currency';
}
else if (partPos.error) {
info.type = 'error';
info.maxDecimals = 0;
}
else if (info.isDate) {
let haveTime = 0;
let haveDate = 0;
let order = '';
// This is equivalent to how Excel does this.
// It has to get the order right but then ignores any trailing tokens.
// So: "mmm dd yyyy" matches "mdy" = D4
// But: "mmm dd dd yyyy" matches "md" = D5
partPos.tokens.forEach(tok => {
const type = tok.type;
// 'year' || 'year-short' || 'b-year' || 'b-year-short'
if (/^(b-)?year/.test(type)) {
order += 'Y';
haveDate++;
}
// 'month' || 'monthname-single' || 'monthname-short' || 'monthname'
else if (type.startsWith('month')) {
order += 'M';
haveDate++;
}
// 'weekday-short' || 'weekday' || 'day'
else if (/^(week)?day/.test(type)) {
order += 'D';
haveDate++;
}
else if (type === 'hour' || type === 'min' || type === 'sec' || type === 'ampm') {
order += type[0];
haveTime++;
}
});
info.type = 'date';
if (haveDate && haveTime) {
info.type = 'datetime';
}
else if (!haveDate && haveTime) {
info.type = 'time';
}
const code = dateCodes.find(d => order.startsWith(d[0]));
codeType = code ? 'D' : 'G';
codeNum = code ? code[1] : '';
}
else if (info.isText) {
codeType = 'G';
info.type = 'text';
codeNum = '';
info.maxDecimals = 0;
}
else if (partPos.general) {
codeType = 'G';
info.type = 'general';
codeNum = '';
}
else if (partPos.fractions) {
codeType = 'G';
info.type = 'fraction';
codeNum = '';
}
else if (partPos.exponential) {
codeType = 'S';
info.type = 'scientific';
}
else if (info.isPercent) {
codeType = 'P';
info.type = 'percent';
}
else if (partPos.grouping) {
codeType = ',';
info.type = 'grouped';
}
else if (partPos.int_max || frac_max) {
codeType = 'F';
info.type = 'number';
}
info.code = codeType + codeNum + codeDash + codeParens;
// Excel can combine some codes, but not all.
// When integer value of two is equal and it can't combine,
// the "first" one wins out.
info.level = level[info.type];
return Object.freeze(info);
}
/**
* @typedef {object} FormatDateInfo
* An object detailing which date specifiers are used in a format pattern.
* @property {boolean} year
* true if the pattern uses years else false.
* @property {boolean} month
* true if the pattern uses months else false.
* @property {boolean} day
* true if the pattern uses day of the month else false.
* @property {boolean} hours
* true if the pattern uses hours else false.
* @property {boolean} minutes
* true if the pattern uses minutes else false.
* @property {boolean} seconds
* true if the pattern uses seconds else false.
* @property {12|24} clockType
* 12 if the pattern uses AM/PM clock else 24.
*/
export function dateInfo (partitions) {
const [ partPos ] = partitions;
const r = {
year: !!(partPos.date & u_YEAR),
month: !!(partPos.date & u_MONTH),
day: !!(partPos.date & u_DAY),
hours: !!(partPos.date & u_HOUR),
minutes: !!(partPos.date & u_MIN),
seconds: !!(partPos.date & u_SEC),
clockType: partPos.clock === 12 ? 12 : 24
};
return r;
}