You are here

format_number.module in Format Number API 6

Same filename and directory in other branches
  1. 7 format_number.module

This module provides a method to configure number formats (site default and user defined) with configurable decimal point and thousand separators. It also exposes several functions that can be used by other contributed or custom modules to display numbers accordingly.

File

format_number.module
View source
<?php

/**
 * @file
 * This module provides a method to configure number formats (site default and
 * user defined) with configurable decimal point and thousand separators.
 * It also exposes several functions that can be used by other contributed or
 * custom modules to display numbers accordingly.
 */

/**
 * Maximum allowed decimal digits.
 */
define('FORMAT_NUMBER_MAX_PRECISION', 8);

/**
 * Implementation of hook_help().
 */
function format_number_help($path, $arg) {
  switch ($path) {
    case 'admin/help#format_number':
      return '<p>' . t('The <em>Format Number API</em> module provides a method to configure number formats (site default and user defined) with configurable decimal point and thousand separators. It also exposes several functions that can be used by other contributed or custom modules to display numbers accordingly.') . '</p>';
  }
}

/**
 * Implementation of hook_perm().
 */
function format_number_perm() {
  return array(
    'configure default number format',
  );
}

/**
 * Implementation of hook_menu().
 */
function format_number_menu() {
  $items = array();
  $items['admin/settings/format_number'] = array(
    'title' => 'Number format',
    'description' => 'Configure site wide number format settings.',
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'format_number_settings_site',
    ),
    'access arguments' => array(
      'configure default number format',
    ),
    'file' => 'format_number.settings.inc',
  );
  return $items;
}

/**
 * Implementation of hook_theme().
 */
function format_number_theme() {
  return array(
    'numericfield' => array(
      'arguments' => array(
        'element' => NULL,
      ),
    ),
  );
}

/**
 * Implementation of hook_user().
 *
 * Allows users to individually set their number format.
 */
function format_number_user($type, &$edit, &$user, $category = NULL) {
  if ($type == 'form' && $category == 'account' && variable_get('format_number_user_configurable', 0)) {
    module_load_include('inc', 'format_number', 'format_number.settings');
    return format_number_settings_user($edit);
  }
  elseif ($type == 'validate' && $category == 'account' && variable_get('format_number_user_configurable', 0)) {
    module_load_include('inc', 'format_number', 'format_number.settings');
    format_number_settings_user_validate($edit);
    return;
  }
}

/**
 * Get decimal point options.
 *
 * @see http://www.unicode.org/cldr/data/charts/by_type/number.symbol.html
 */
function format_number_get_decimal_point_options() {
  return array(
    "." => t('full stop (.)'),
    "," => t('comma (,)'),
    "٫" => t('arabic decimal separator (٫)'),
  );
}

/**
 * Get thousands separator options.
 *
 * @see http://www.unicode.org/cldr/data/charts/by_type/number.symbol.html
 */
function format_number_get_thousands_separator_options() {
  return array(
    '' => t('none'),
    "." => t('full stop (.)'),
    "," => t('comma (,)'),
    "'" => t("apostrophe (')"),
    " " => t('no-break space ( )'),
    "," => t('full width comma (,)'),
    "٬" => t('arabic thousands separator (٬)'),
    "ወ" => t('ethiopic syllable wa (ወ)'),
  );
}

/**
 * Get the site/user defined thousands separator and decimal point characters.
 *
 * @param string $name
 *   The name of the option to retrieve (optional). Available options:
 *   - 'thousands_sep'  A one character string (it could be empty).
 *   - 'decimal_point'  A one character string.
 * @return mixed
 *   If name is not specified, an array with all options is returned.
 *   If name does not exist, NULL is returned.
 *   If name exists, its value is returned.
 */
function format_number_get_options($name = NULL) {
  static $format_options;
  global $user;
  if (!isset($format_options)) {
    $format_options = array(
      'thousands_sep' => variable_get('format_number_thousands_sep', ','),
      'decimal_point' => variable_get('format_number_decimal_point', '.'),
    );
    if (variable_get('format_number_user_configurable', 0) && $user->uid) {
      if (drupal_strlen($user->thousands_sep)) {
        $format_options['thousands_sep'] = $user->thousands_sep;
      }
      if (drupal_strlen($user->decimal_point)) {
        $format_options['decimal_point'] = $user->decimal_point;
      }
    }
  }
  if (isset($name)) {
    return isset($format_options[$name]) ? $format_options[$name] : NULL;
  }
  return $format_options;
}

/**
 * Expose a javascript version of the Format Number API.
 */
function format_number_add_js() {
  static $ready;
  if (!isset($ready)) {
    $ready = TRUE;
    $module_path = drupal_get_path('module', 'format_number');
    drupal_add_css($module_path . '/format_number.css');
    drupal_add_js($module_path . '/format_number.js');
    drupal_add_js(array(
      'format_number' => format_number_get_options(),
    ), 'setting');
  }
}

/**
 * Format a number with (site default or user defined) thousands separator and
 * decimal point.
 *
 * @param float $number
 *   The number being formatted.
 * @param int $decimals
 *   Number of decimal digits. Use -1 for any number if decimals.
 * @return string
 *   The formatted number.
 */
function format_number($number, $decimals = 0) {
  static $format_options;
  if (!isset($format_options)) {
    $format_options = format_number_get_options();
  }

  // Perform an initial conversion using PHP's number_format() that
  // seems to work better than sprintf().
  $number = number_format((double) $number, FORMAT_NUMBER_MAX_PRECISION, '.', '');
  if ($decimals < 0) {

    // Count decimal places (ignoring trailing zeros to the right of the decimal point).
    $decimals = strpos($number, '.') === FALSE ? 0 : drupal_strlen(preg_replace('#^.*\\.([0-9]*?)0*$#', '\\1', $number));
  }

  // Avoid issues caused by PHP rounding limitations.
  if ($decimals > FORMAT_NUMBER_MAX_PRECISION) {
    $decimals = FORMAT_NUMBER_MAX_PRECISION;
  }

  // number_format() can only deal with one chararcter symbols, so that
  // we tell him to use the placeholders X and Z that we replace later.
  return str_replace(array(
    'X',
    'Z',
  ), array(
    $format_options['decimal_point'],
    $format_options['thousands_sep'],
  ), number_format($number, $decimals, 'X', 'Z'));
}

/**
 * Formats numbers to a specified number of significant figures.
 *
 * @param number $number
 *   The number to format.
 * @param integer $significant_figures
 *   The number of significant figures to round and format the number to.
 * @return string
 *   The rounded and formatted number.
 */
function format_number_significant_figures($number, $significant_figures) {

  // Compute the number of decimal places we need to round and format the
  // number to (may be negative).
  $decimals = floor($significant_figures - log10(abs($number)));

  // Round as a regular number.
  $number = round($number, $decimals);

  // We do not want decimals if the resulting number is zero.
  if ($number == 0) {
    $decimals = 0;
  }

  // Leave the formatting to format_number().
  return format_number($number, $decimals);
}

/**
 * Parse a formatted number.
 *
 * This function implements lenient parsing when possible, and only falls
 * back to site/user defined symbols when in doubt.
 * See http://www.unicode.org/reports/tr35/tr35-11.html#Lenient_Parsing
 *
 * @todo
 *  The algorithm probably needs optimization (using regular expressions?).
 *
 * @param string $formatted_number
 *   A number formatted with localized thousands separator and decimal point.
 * @param boolean $required
 *   If input is empty string, return FALSE when number is strictly required,
 *   otherwise an empty string is returned as 0.
 * @return number
 *   A valid PHP number. FALSE when input cannot be deciphered.
 */
function parse_formatted_number($formatted_number, $required = TRUE) {
  static $format_options, $decimal_point_options, $thousands_separator_options;
  if (!isset($format_options)) {
    $format_options = format_number_get_options();
    $decimal_point_options = format_number_get_decimal_point_options();
    $thousands_separator_options = format_number_get_thousands_separator_options();
  }

  // Trim input.
  $formatted_number = trim($formatted_number);
  if ($formatted_number === '') {
    return $required ? FALSE : 0;
  }

  // When no-break space is the site/user defined thousands separator, then
  // ascii space may also be accepted.
  if ($format_options['thousands_sep'] == " ") {
    $thousands_separator_options[' '] = TRUE;
  }

  // Extract the sign.
  $is_negative = FALSE;
  if ($formatted_number[0] == '-' || $formatted_number[0] == '+') {
    $is_negative = $formatted_number[0] == '-' ? TRUE : FALSE;
    $formatted_number = drupal_substr($formatted_number, 1);
  }
  else {
    $last_char = $formatted_number[drupal_strlen($formatted_number) - 1];
    if ($last_char == '-' || $last_char == '+') {
      $is_negative = $last_char == '-' ? TRUE : FALSE;
      $formatted_number = drupal_substr($formatted_number, 0, -1);
    }
  }

  // Extract non-numeric symbols.
  preg_match_all('#[^0-9]#u', $formatted_number, $matches);
  $non_numeric_symbols = array_count_values($matches[0]);
  $non_numeric_symbols_count = count($non_numeric_symbols);
  if ($non_numeric_symbols_count > 2) {

    // More than two different non-numeric symbols.
    return FALSE;
  }

  // When 2 non-numeric symbols are present, the first one should be the
  // thousands separator, the second one should be a decimal separator.
  if ($non_numeric_symbols_count == 2) {

    // Extract and validate thousands separator.
    $thousands_sep = array_keys($non_numeric_symbols);
    $thousands_sep = array_shift($thousands_sep);
    if (!isset($thousands_separator_options[$thousands_sep])) {

      // This is not a valid thousands separator symbol.
      return FALSE;
    }

    // Strip out thousands separators.
    $formatted_number = str_replace($thousands_sep, '', $formatted_number);

    // Extract and validate decimal point.
    unset($non_numeric_symbols[$thousands_sep]);
    $decimal_point = array_keys($non_numeric_symbols);
    $decimal_point = array_shift($decimal_point);
    if ($non_numeric_symbols[$decimal_point] > 1) {

      // Decimal point symbol is used more than once.
      return FALSE;
    }
    if (!isset($decimal_point_options[$decimal_point])) {

      // This is not a valid decimal point symbol.
      return FALSE;
    }

    // Convert decimal point into dot, if necessary.
    if ($decimal_point != '.') {
      $formatted_number = str_replace($decimal_point, '.', $formatted_number);
    }
  }
  elseif ($non_numeric_symbols_count == 1) {
    $unknown_symbol = array_keys($non_numeric_symbols);
    $unknown_symbol = array_shift($unknown_symbol);

    // When unknown symbol is used more than once, it can only be a
    // thousands separator, but it should be valid one.
    if ($non_numeric_symbols[$unknown_symbol] > 1) {
      if (!isset($thousands_separator_options[$unknown_symbol])) {

        // This symbol is not a valid thousands separator.
        return FALSE;
      }

      // Strip out unknown symbol (aka. thousands separators in this case).
      $formatted_number = str_replace($unknown_symbol, '', $formatted_number);
    }
    elseif ($unknown_symbol != '.' && $unknown_symbol != ',') {
      if (isset($decimal_point_options[$unknown_symbol])) {

        // This is a valid decimal point symbol.
        $formatted_number = str_replace($unknown_symbol, '.', $formatted_number);
      }
      if (isset($thousands_separator_options[$unknown_symbol])) {

        // This is a valid thousands separator symbol.
        $formatted_number = str_replace($unknown_symbol, '', $formatted_number);
      }
      else {

        // This is an invalid symbol.
        return FALSE;
      }
    }
    else {
      if ($unknown_symbol == $format_options['decimal_point']) {

        // This is a valid decimal point symbol.
        $formatted_number = str_replace($unknown_symbol, '.', $formatted_number);
      }
      elseif ($unknown_symbol == $format_options['thousands_sep']) {

        // This is a valid thousands separator symbol.
        $formatted_number = str_replace($unknown_symbol, '', $formatted_number);
      }
      else {

        // This is an invalid symbol.
        return FALSE;
      }
    }
  }
  return ($is_negative && 0 != $formatted_number ? '-' : '') . $formatted_number;
}

/**
 * Implementation of hook_elements().
 */
function format_number_elements() {
  return array(
    'numericfield' => array(
      '#input' => TRUE,
      '#precision' => 12,
      '#decimals' => 0,
      '#process' => array(
        'format_number_numericfield_process',
      ),
    ),
  );
}

/**
 * Compute the lower or upper bounds of a number.
 *
 * @param $direction
 *   'lower' to compute the minimum possible value.
 *   'upper' to compute the maximum possible value.
 * @param $precision
 *   Integer that indicates the total number of digits available to store the
 *   number, including the digits to the right of the decimal point.
 * @param $decimals
 *   Integer that indicates the number of available digits to the right of
 *   the decimal point.
 * @return
 *   The minimum or maximum possible value.
 */
function format_number_compute_boundary($direction, $precision = 0, $decimals = 0) {
  return (double) (($direction == 'lower' ? '-' : '') . str_repeat('9', $precision - $decimals) . '.' . str_repeat('9', $decimals));
}

/**
 * Process an individual numeric form element.
 *
 * @param $element
 *   The form element being processed.
 * @param $edit
 *   The incoming POST data to populate the form element.
 * @param $form_state
 *   A keyed array containing the current state of the form.
 * @param $form
 *   An associative array containing the structure of the form.
 *
 * @ingroup forms
 */
function format_number_numericfield_process($element, $edit, $form_state, $form) {
  $element_precision = isset($element['#precision']) && (int) $element['#precision'] > 0 ? (int) $element['#precision'] : 12;
  $element_decimals = isset($element['#decimals']) && (int) $element['#decimals'] >= 0 ? (int) $element['#decimals'] : 0;
  $element_minimum = isset($element['#minimum']) ? parse_formatted_number($element['#minimum']) : NULL;
  if (!is_numeric($element_minimum)) {
    $element_minimum = format_number_compute_boundary('lower', $element_precision, $element_decimals);
  }

  // Compute size and maxlength for the input element, but still allow the
  // user specify these values in the form definition.
  if (empty($element['#size']) || empty($element['#maxlength'])) {
    $element_maxlength = $element_precision;
    if ($element_decimals > 0) {
      $element_maxlength++;
    }
    if (isset($element_minimum) && $element_minimum < 0) {
      $element_maxlength++;
    }
    $thousands_sep = format_number_get_options('thousands_sep');
    if (!empty($thousands_sep)) {
      $element_maxlength += ceil(($element_precision - $element_decimals) / 3) - 1;
    }
    if (empty($element['#size'])) {
      $element['#size'] = $element_maxlength + 1;
    }
    if (empty($element['#maxlength'])) {
      $element['#maxlength'] = $element_maxlength;
    }
  }

  // Format the element value. A valid PHP number is expected. It may come from
  // user defined form or from element value callback. In any case, our element
  // validation callback will generate a valid PHP number, or flag an error. Our
  // goal now is generate a number formatted with site or user defined options,
  // if value is a valid PHP number.
  $value = !empty($element['#value']) && is_string($element['#value']) ? $element['#value'] : '';
  if (is_numeric($value)) {
    $value = format_number($value, $element_decimals);
  }
  $element['#value'] = $value;

  // Pass decimal places to the client-side javascript using HTML attribute.
  if (isset($element['#attributes']) && is_array($element['#attributes'])) {
    $element['#attributes']['decimals'] = $element_decimals;
  }
  else {
    $element['#attributes'] = array(
      'decimals' => $element_decimals,
    );
  }

  // Attach a validation callback to the form element.
  if (isset($element['#element_validate']) && is_array($element['#element_validate'])) {
    array_shift($element['#element_validate'], 'format_number_numericfield_validate');
  }
  else {
    $element['#element_validate'] = array(
      'format_number_numericfield_validate',
    );
  }
  return $element;
}

/**
 * Helper function to determine the value for a numeric form element.
 *
 * @param $element
 *   The form element whose value is being populated.
 * @param $edit
 *   The incoming POST data to populate the form element. If this is FALSE,
 *   the element's default value should be returned.
 * @return
 *   The data that will appear in the $form_state['values'] collection
 *   for this element. Return nothing to use the default.
 *
 * @ingroup forms
 */
function form_type_numericfield_value($element, $edit = FALSE) {
  if ($edit !== FALSE) {

    // Equate $edit to the form value to ensure it's marked for validation.
    $value = trim(str_replace(array(
      "\r",
      "\n",
    ), '', $edit));

    // If input is not empty, we want a valid PHP number now.
    // If input cannot be parsed as a valid PHP number, our element validation
    // callback will take care of it.
    if ($value != '' && ($parsed = parse_formatted_number($value)) !== FALSE) {
      $value = $parsed;
    }
    return $value;
  }
}

/**
 * Validation of an individual numeric form element.
 *
 * @param $element
 *   The form element being processed.
 * @param $form_state
 *   A keyed array containing the current state of the form.
 *
 * @ingroup forms
 */
function format_number_numericfield_validate($element, &$form_state) {
  $value = $element['#value'];
  if ($element['#required'] || $value != '') {
    $value = parse_formatted_number($value, $element['#required']);

    // Validate number format.
    if (!is_numeric($value)) {
      form_error($element, t('%name: The specified number @num is invalid.', array(
        '%name' => $element['#title'],
        '@num' => $element['#value'],
      )));
      return;
    }

    // Validate number boundaries.
    $element_precision = isset($element['#precision']) && (int) $element['#precision'] > 0 ? (int) $element['#precision'] : 12;
    $element_decimals = isset($element['#decimals']) && (int) $element['#decimals'] >= 0 ? (int) $element['#decimals'] : 0;
    $element_minimum = isset($element['#minimum']) ? parse_formatted_number($element['#minimum']) : NULL;
    if (!is_numeric($element_minimum)) {
      $element_minimum = format_number_compute_boundary('lower', $element_precision, $element_decimals);
    }
    $element_maximum = isset($element['#maximum']) ? parse_formatted_number($element['#maximum']) : NULL;
    if (!is_numeric($element_maximum)) {
      $element_maximum = format_number_compute_boundary('upper', $element_precision, $element_decimals);
    }
    if ($value < $element_minimum) {
      form_error($element, t('%name: The value may be no smaller than @minimum.', array(
        '%name' => $element['#title'],
        '@minimum' => $element_minimum,
      )));
      return;
    }
    elseif ($value > $element_maximum) {
      form_error($element, t('%name: The value may be no larger than @maximum.', array(
        '%name' => $element['#title'],
        '@maximum' => $element_maximum,
      )));
      return;
    }
  }

  // Update the form element with parsed number, so it gets a valid PHP number
  // that can be processed in math operations or store in the database.
  if ($element['#value'] != $value) {
    form_set_value($element, $value, $form_state);
  }
}

/**
 * Render a numeric form element.
 *
 * @param $element
 *   An associative array containing the properties of the element.
 * @return
 *   A themed HTML string representing the input element.
 *
 * @ingroup themeable
 */
function theme_numericfield($element) {
  format_number_add_js();
  $size = empty($element['#size']) ? '' : ' size="' . $element['#size'] . '"';
  $maxlength = empty($element['#maxlength']) ? '' : ' maxlength="' . $element['#maxlength'] . '"';
  $output = '';
  _form_set_class($element, array(
    'form-numeric',
  ));
  if (isset($element['#field_prefix'])) {
    $output .= '<span class="field-prefix">' . $element['#field_prefix'] . '</span> ';
  }
  $output .= '<input type="text"' . $maxlength . ' name="' . $element['#name'] . '" id="' . $element['#id'] . '"' . $size . ' value="' . check_plain($element['#value']) . '"' . drupal_attributes($element['#attributes']) . ' />';
  if (isset($element['#field_suffix'])) {
    $output .= ' <span class="field-suffix">' . $element['#field_suffix'] . '</span>';
  }
  return theme('form_element', $element, $output);
}

Functions

Namesort descending Description
format_number Format a number with (site default or user defined) thousands separator and decimal point.
format_number_add_js Expose a javascript version of the Format Number API.
format_number_compute_boundary Compute the lower or upper bounds of a number.
format_number_elements Implementation of hook_elements().
format_number_get_decimal_point_options Get decimal point options.
format_number_get_options Get the site/user defined thousands separator and decimal point characters.
format_number_get_thousands_separator_options Get thousands separator options.
format_number_help Implementation of hook_help().
format_number_menu Implementation of hook_menu().
format_number_numericfield_process Process an individual numeric form element.
format_number_numericfield_validate Validation of an individual numeric form element.
format_number_perm Implementation of hook_perm().
format_number_significant_figures Formats numbers to a specified number of significant figures.
format_number_theme Implementation of hook_theme().
format_number_user Implementation of hook_user().
form_type_numericfield_value Helper function to determine the value for a numeric form element.
parse_formatted_number Parse a formatted number.
theme_numericfield Render a numeric form element.

Constants

Namesort descending Description
FORMAT_NUMBER_MAX_PRECISION Maximum allowed decimal digits.