From 0baf16eaeec8fb472660b7461da436e7977c56c7 Mon Sep 17 00:00:00 2001 From: Petr Havel Date: Thu, 31 Aug 2023 17:24:57 +0200 Subject: [PATCH] #9 FIX: wrong conversion when numbers are formatted as currency See #9 comments. --- SheetConverter/Code.gs | 442 +---------------------------------------- 1 file changed, 11 insertions(+), 431 deletions(-) diff --git a/SheetConverter/Code.gs b/SheetConverter/Code.gs index a594cec..ca0c41b 100644 --- a/SheetConverter/Code.gs +++ b/SheetConverter/Code.gs @@ -8,11 +8,10 @@ * var converter = Converter.init(ss.getSpreadsheetTimeZone(), * ss.getSpreadsheetLocale()); * // Get formats from range - * var numberFormats = range.getNumberFormats(); * for (row=0;row'.replace('XXX',rowHeights[row])); for (col=0;col -Usage: -  Logger.log( Converter.convertCell(1234.56,"0.00E+00") ); -  ... logs "1.23E+3" - - * - * @param {Object} cellText Contents of a cell - * @param {String} format Spreadsheet format to use in conversion + * @param {String} cellDisplayValue * @param {Boolean} htmlReady (optional) Set true if strings should be html-friendly. * Default is false, output is plain text. * * @return {String} Formatted string. May contain HTML, depending on * htmlReady. */ -function convertCell(cellText,format,htmlReady) { +function convertCell(cellDisplayValue,htmlReady) { // Must have 2 or 3 parameters, format must be string - if (arguments.length < 2 || !objIsClass_(format,"String")) + if (arguments.length < 1) throw new Error( 'Invalid parameter(s)' ); htmlReady = htmlReady || false; thisInstance_.init(); // Ensure instance variables are set - if (cellText === null) return ''; // Not much to do with blank cells - just return an empty string + if (cellDisplayValue == "") return ''; // Not much to do with blank cells - just return an empty string - // Treat all dates & times the same; we can adapt the spreadsheet formats - if (objIsClass_(cellText,"Date")) { - return( convertDateTime_(cellText,format) ); - } - - // Numbers come in many flavours; which do we have? - if (objIsClass_(cellText,"Number")){ - // General - Not so much a format, more of a guideline. - if (format === "0.###############" || format === '') { - if (Math.abs(cellText) >= 1000000000000010 ) - return convertExponential_(cellText,5); // Overflow - automatic exponential, 5 fraction digits - else - return String(cellText); - } - // Padded decimal numbers - // Interpret Default number format as a padded decimal - if (format === '@') format = '0.###############'; - // Padded decimal numbers; zero or more of # and/or 0 and optional ',' - // separators, optionally followed by a radix '.' and one or more of # and/or 0. - // Also need to allow for misplaced ',' in fraction. - var re = /^([#0,]+)([\.]?)([#0,]*)$/ - var paddedDecimal = re.test(format); - if (paddedDecimal) { - var thous = format.match(/,/) ? ',' : ''; // Check for thousand separators, remember - format = format.replace(/,/g,''); // and remove them - var parts = format.match(re); // Parts[1] is integer part, parts[2] is radix (null if none), parts[3] is fraction - var whole = parts[1]; - var wholeMin = whole.replace(/[^0]/g,'').length; // minimum digits in whole part expressed by count of zeros - var wholeMax = whole.length; // max digits in whole is length of zeros & # - var fract = parts[3]; - var fractMin = fract.replace(/[^0]/g,'').length; // min digits in frac expressed by count of zeros - var fractMax = fract.length; // max digits in frac is length of zeros & # - return convertPadded_(cellText,fractMax,fractMin,wholeMin,thous); - } - // Currency - if (format.indexOf('$') !== -1) { - var options = {htmlReady:htmlReady}; - // find out position of currency symbol - if (format.slice(-1) === "]") options.symLoc = "after"; - // and what the symbol is - the default $ will be handled by the converter, - // here we are looking for the symbol mapper, e.g. [$€], and isolating the - // target currency symbol, e.g. €, along with any included text or punctuation. - var matches = format.match(/\[\$(.*?)\]/); - if (matches) options.symbol = matches[1]; - // find the fraction precision - matches = format.match(/\.(0*?)($|[^0])/); - var fract = matches ? matches[1].length : 0; - // are brackets in use? - matches = format.match(/\(.*\)/); - if (matches) options.negBrackets = true; - /* how about coloring negatives? - * Color# (where # is replaced by a number between 1-56 to choose from a different variety of colors) - * ... this is problematic. Should find out what the RGB of those 56 colors are, - * and map them. In the mean time, can result in browser console messages. - */ - matches = format.match(/;\[(.*?)\]/); - if (matches) options.negColor = matches[1]; - // Then call the currency converter - return convertCurrency_(cellText,fract,options); - } - // Percent - if (format.indexOf('%') !== -1) { - var matches = format.match(/\.(0*?)%/); - var fract = matches ? matches[1].length : 0; // Fractional part - return convertPercent_(cellText,fract); - } - // Exponentials - var expon = format.match(/\.(0*?)E\+/); - if (expon) { - //var fract = format.match(/\.(0*?)E\+/)[1].length; // Fractional part - var fract = expon[1].length; // Fractional part - return convertExponential_(cellText,fract); - } - // Fraction - if (format.indexOf('?\/?') !== -1) { - matches = format.match(/(\?*?)\//); - var precision = matches ? matches[1].length : 1; // Fractional part - return convertFraction_(cellText,precision); - } - if (this[format]) { // TODO: kill off, then stop calling stand-alone converters - return converter_[format](cellText); - } - else { - Logger.log("Unsupported format '"+format+"', cell='"+cellText+"'"); - return cellText; - } - } - // No previous condition met, cell contains a string. - var result = String(cellText); // Sanitize string if output is for html - if (htmlReady) result = result.replace(/ /g," ").replace(/"); - return result; -} - -function convertDateTime_(date,format) { - // The 'general' format for dates is blank - if ('' == format) format = 'M/d/yyyy'; // TODO: Should be getSpreadsheetLocale() based - // Translate spreadsheet date format elements to SimpleDateFormat - // http://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html - // From the documentation for the "TEXT" spreadsheet function... - // "mm for the month of the year as two digits or the number - // of minutes in a time. Month will be used unless this code - // is provided with hours or seconds as part of a time." - if (format.match(/am\/pm/i) == null) { - format = format.replace(/h/g,'H'); // Hour of day, 0-23 - } - - // Check for elapsed time - if (format.indexOf("[") !== -1) - format = updFormatElapsedTime_(date,format); - - var jsFormat = format - .replace(/am\/pm|AM\/PM/,'a') // Am/pm marker - .replace('dddd','EEEE') // Day name in week (long) - .replace('ddd','EEE') // Day name in week (short) - .replace(/S/g,'s') // In Sheets, upper & lower s means Seconds - .replace(/D/g,'d') // In Sheets, upper & lower d means Day - .replace(/M/g,'m') // In Sheets, upper & lower m are the same, what matters is quantity & neighbors - .replace(/([hH]+)"*(.)"*(m+)/g,tempMinute_) // ... so find "minutes" around "hours" - .replace(/(m+)"*(.)"*(s+)/g,tempMinute_) // ... or "seconds", and change to 'b' temporarily - .replace('mmmmm','"@"MMM"@"') // first letter in month - google-ism? - .replace(/m/g,"M") // All remaining "m"s are months, so M for SimpleDateFormat - .replace(/b/g,'m') // reassert temporary minutes - .replace(/0+/,'S') // Milliseconds are 0 in Sheets, upper S in SimpleDateFormat - .replace(/"/g,"'") // Change double to single quotes on filler - var result = Utilities.formatDate( - date, - thisInstance_.tzone, - jsFormat) - .replace(/@.*@/g,firstChOfMonth_); // Tidy first char in month, in post-processing - return result; -} - -/** - * Replace all occurences of m with b. To be used as a function parameter - * for String.replace(). Helper for convertDateTime_. - * - * @param {string} match Regex match containing 'm's and other stuff - * @returns {string} replacement for match. - */ -function tempMinute_(match){ - return match.replace(/m/g,'b'); -} - -/** - * Return first character of month. Helper for convertDateTime_. - * - * @param {string} match Regex match, formatted @Month@ - * @returns {string} replacement for match. - */ -function firstChOfMonth_(match){ - return match.charAt(1) -} - -/** - * Replace elapsed-time signatures in the given format with SimpleDateFormat - * compatible versions. SimpleDateFormat understands elapsed Hours (H), but - * not minutes or seconds, so we replace elapsed minutes & seconds with - * calculated values. - */ -function updFormatElapsedTime_(date,format) { - // For elapsed time, we are interested in the time since midnight. - var elapsedMs = getMsSinceMidnight_(date); - - // Generate elapsed seconds & minutes, just in case. While we could optimize these - // operations to be performed only when needed, the tests are as expensive. - // Check for elapsed second signature, determine its length for padding. - var matches = format.match(/\[([sS]+)\]/); - var pad = matches ? matches[1].length : 1; - var elapsedSec = convertPadded_(Math.floor(elapsedMs/1000),0,0,pad); - // Check for elapsed minute signature, determine its length for padding. - matches = format.match(/\[([mM]+)\]/); - pad = matches ? matches[1].length : 1; - var elapsedMin = convertPadded_(Math.floor(elapsedMs/60000),0,0,pad); - //var matches = format.match(/\[([^\]]+)\]/g); // Regex finds all elapsed time notations - var format = format.replace(/\[([hH]+)\]/,elapsedHours_) - .replace(/\[([mM]+)\]/,elapsedMin) - .replace(/\[([sS]+)\]/,elapsedSec) - return format; -} - -/** - * Replace all occurences of the elapsed hours pattern (e.g. "[h]") - * with 'H', and remove braces. To be used as a function parameter - * for String.replace(). Helper for convertDateTime_. - * - * @param {string} match Regex match containing '[h]'s and other stuff - * @returns {string} replacement for match. - */ -function elapsedHours_(match){ - return match.replace(/[hH]/g,'H').replace(/[\[\]]/g,''); -} - -/** - * Calculate elapsed time since midnight. Helper for updFormatElapsedTime_. - * From http://stackoverflow.com/a/10946213/1677912. - * - * @param {Date} d time to test - * @returns {Number} milliseconds elapsed since midnight - */ -function getMsSinceMidnight_(d) { - var e = new Date(d); - return d - e.setHours(0,0,0,0); -} - -/** - * Return string representation of a padded decimal number. - * - * @param {number} num Number to be converted - * @param {number} fractMax Max digits in fraction (round) - * @param {number} fractMin Min digits in fraction (pad) - * @param {number} wholeMin Min digits in whole, default 1 (pad) - * @param {char} thous Thousand separator char, blank default - */ -function convertPadded_(num,fractMax,fractMin,wholeMin,thous) { - fractMin = fractMin || 0; // Set defaults for optional parameters - wholeMin = wholeMin || 1; - thous = thous || ''; - var numStr = String(1*Utilities.formatString("%.Xf".replace('X',String(fractMax)), num)); - var parts = numStr.split('.'); - var whole = pad0_(parts[0],wholeMin,true); - var frac = pad0_((parts.length > 1) ? parts[1] : '',fractMin); - var thouGroups = /(\d+)(\d{3})/; - while (thous&&thouGroups.test(whole)) { - whole = whole.replace(thouGroups, '$1' + thous + '$2'); - } - var result = whole + (frac ? ('.'+frac) : ''); - return result; -} - -/** - * Pad an integer with leading or trailing zeros. - * - * @param {number} num Number to pad - * @param {number} width Final width of padded number - * @param {Boolean} leading (optional) true for leading zeros, - * default is false for trailing zeros - */ -function pad0_(num, width, left) { - var num = String(num); - // Check whether input is already wide enough - if (num.length >= width) return num; - var bunchazeros = '0000000000000000000000000000000000000'; - if (left) { - var result = (bunchazeros + num).substr(-width); - } else { - result = (num + bunchazeros).substr(0,width); - } - return result; -} - -function convertExponential_(num,fract) { return num.toExponential(fract).replace('e','E'); } - -function convertPercent_(num,fract) { return Utilities.formatString("%.Xf%".replace('X',String(fract)), 100*num); } - - -/** - * Options: an optional object with optional properties... - * symbol {string} default '$' - * symLoc {'before','after','none'} default before - * negBrackets {boolean} default false - * negColor {string} color for negative numbers - */ -function convertCurrency_(num,fract,options) { - options = options || {}; - var result = "#RESULT#"; - var symbol = options.symbol ? options.symbol : '$'; - if (!options.symLoc || options.symLoc === 'before') { - result = symbol + "#RESULT#"; - } - else if (options.symLoc === 'after') { - result = "#RESULT#" + symbol; - } - else { - // no symbol - } - if (num < 0) { - num = -num; - if (options.negBrackets) { - result = "("+result+")"; - } - else { - result = '-'+result; - } - } - if (options.negColor && options.htmlReady) { - result = (""+result+"").replace("XXX",options.negColor.toLowerCase()); - } - num = convertPadded_(num,fract); - return result.replace("#RESULT#",num); -} - -// "# ?/?", "# ??/??" -function convertFraction_(num,precision) { - if (!thisInstance_.fracEst) thisInstance_.fracEst = new FractionEstimator_(); - var sign = (num < 0) ? -1 : 1; - num = sign * num; - var whole = Math.floor(num); -// var whole = String(num).match(/(.*?)\./)[1]+' '; - var frac = num%1; // introduces small rounding errors - var result = ((whole === 0) ? '' : String(sign*whole) + ' ') + thisInstance_.fracEst.estimate(frac,precision); - return result -} - -/********************************************************************************************/ - -// Stand-alone converters, left-overs from a by-gone era. TODO: eliminate these -var converter_ = {}; - -// TODO: this is just so similar to currency, some refactoring would take care of it. -// should break out negBracket & color identification to be general -converter_["#,##0.00;(#,##0.00)"] = function(num) { - if (num > 0) { - var result = 'XXX'; - } - else { - num = -num; - result = '(XXX)'; - } - return result.replace('XXX', Utilities.formatString("%.2f", num)); -} - - -/********************************************************************************************/ - -/** - * A fraction estimator to provide fraction strings that are a close - * representations of given decimal values. - * - * To restrict outcomes to "friendly fractions" (i.e. with easily- - * read denominators), estimates are found by identifying them - * in a list. - * - * Caveat: Construction of a new estimator for fractions with 2 or more significant digit - * denominators is S.L.O.W. Subsequent estimates are very quick, O(Ln(N)). - * - *
- * Usage:
- *         var fracEst = new FractionEstimator_();
- *         var value = 0.56;
- *         var precision = 2; // 2 digit denominator
- *         var frac = fracEst.estimate(value,precision);  // "14/25"
- * 
- */ -function FractionEstimator_() { // constructor - this.fracList = {}; // Object holds lists of acceptable fractions -} - - -/** - * Get a string containing a fraction estimate for the given - * value, with indicated precision of denominator. - * - * @param {number} value Number to be estimated, 0 < value < 1 - * @param {number} precision # digits in denominator, 1 (default) or 2 - * - * @return {String} e.g. "14/25" - */ -FractionEstimator_.prototype.estimate = function(value,precision) { - if (1 <= value || 0 > value) throw new Error( 'invalid fraction, 0 < fraction < 1' ); - precision = precision || 1; - if (precision > 2) throw new Error('beyond max precision'); - - var list = this.fracList_(precision); // Get a handle on list of acceptable fractions - - // Use bisection to find first value equal to or larger than value - var lo=0,hi=list.length-1; - while (lo>1; - if (value < list[mid].val) hi=mid; - else lo = mid+1; - } - // pick the closer of the found 'lo', and the element before that - if (Math.abs(list[lo-1].val - value) < Math.abs(list[lo].val - value)) - var frac=list[lo-1].frac; - else - frac=list[lo].frac; - return frac; -} - -// Return acceptable fraction list for given precision, build if needed. -FractionEstimator_.prototype.fracList_ = function(precision) { - if (!this.fracList[precision]) { - var max = Math.pow(10, precision); - var list = []; - for (var denom=2; denom"); + return cellDisplayValue; }