module H

Localized formatting for Human-iteraction

Constants

DMS_FACTORS
DMS_UNITS

include ActionView::Helpers::NumberHelper

REGEXPS

Date-parsing has been taken from github.com/clemens/delocalize

SEC_EPSILON
SEC_PRECISION

Public Class Methods

date_from(txt, options={}) click to toggle source
# File lib/h/h.rb, line 121
def date_from(txt, options={})
  options = number_format_options(options).merge(options)
  type = check_type(options[:type] || Date)

  return nil if txt.to_s.strip.empty? || txt==options[:blank]
  return txt if txt.respond_to?(:strftime)

  translate_month_and_day_names! txt, options[:locale]
  input_formats(type).each do |original_format|
    next unless txt =~ /^#{apply_regex(original_format)}$/

    txt = DateTime.strptime(txt, original_format)
    return Date == type ?
      txt.to_date :
      Time.zone.local(txt.year, txt.mon, txt.mday, txt.hour, txt.min, txt.sec)
  end
  default_parse(txt, type)
end
date_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 116
def date_to(value, options={})
  return options[:blank] || '' if value.nil?
  I18n.l(value, options)
end
datetime_from(txt, options={}) click to toggle source
# File lib/h/h.rb, line 152
def datetime_from(txt, options={})
  date_from value, options.reverse_merge(:type=>DateTime)
end
datetime_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 148
def datetime_to(value, options={})
  date_to value, options.reverse_merge(:type=>DateTime)
end
dms_from(txt, options={}) click to toggle source
# File lib/h/h.rb, line 171
def dms_from(txt, options={})
  original_txt = txt
  options = dms_format_options(options).merge(options)

  return nil if txt.to_s.strip.empty? || txt==options[:blank]

  neg_signs = [options[:south], options[:west]] << '-'
  pos_signs = [options[:north], options[:east]] << '+'
  neg_signs, pos_signs = [neg_signs, pos_signs].map {|signs|
    (signs.map{|s| s.mb_chars.upcase.to_s} + signs.map{|s| s.mb_chars.downcase.to_s}).uniq
  }
  signs = neg_signs + pos_signs
  seps = Array(options[:deg_seps]) + Array(options[:min_seps]) + Array(options[:sec_seps])

  neg = false

  txt = txt.to_s.strip
  neg_signs.each do |sign|
    if txt.start_with?(sign)
      txt = txt[sign.size..-1]
      neg = true
      break
    end
    if txt.end_with?(sign)
      txt = txt[0...-sign.size]
      neg = true
      break
    end
  end
  unless neg
    pos_signs.each do |sign|
      if txt.start_with?(sign)
        txt = txt[sign.size..-1]
        break
      end
      if txt.end_with?(sign)
        txt = txt[0...-sign.size]
        break
      end
    end
  end

  num_options = number_format_options(options).except(:precision).merge(options)
  txt = numbers_to_ruby(txt.strip, num_options)

  default_units = 0

  v = 0
  seps = (seps.map{|s| Regexp.escape(s)}<<"\\s+")*"|"
  scanned_txt = ""
  txt.scan(/((\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)(#{seps})?\s*)/) do |match|
    scanned_txt << match[0]
    number = match[1]
    sep = match[2]
    if Array(options[:deg_seps]).include?(sep)
      units = :deg
    elsif Array(options[:min_seps]).include?(sep)
      units = :min
    elsif Array(options[:sec_seps]).include?(sep)
      units = :sec
    else
      units = DMS_UNITS[default_units]
    end
    raise ArgumentError, "Invalid degrees-minutes-seconds value #{original_txt}" unless units
    default_units = DMS_UNITS.index(units) + 1
    x = number.to_f
    x *= DMS_FACTORS[units]
    v += x
  end
  raise ArgumentError, "Invalid degrees-minutes-seconds value #{original_txt} [#{txt}] [#{scanned_txt}]" unless txt==scanned_txt
  v = -v if neg
  v
end
dms_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 253
def dms_to(value, options={})
  longitude = options[:longitude]
  latitude = options[:latitude]
  latitude = true if longitude==false && !options.has_key?(:latitude)
  longitude = true if latitude==false && !options.has_key?(:longitude)
  options = dms_format_options(options).except(:precision).merge(options)
  precision = options[:precision]

  return options[:blank] || '' if value.nil?

  if value.kind_of?(String)
    # TODO: recognize nan/infinite values
    value = value.to_f
  else
    if value.respond_to?(:nan?) && value.nan?
      return options[:nan] || "--"
    elsif value.respond_to?(:infinite?) && value.infinite?
      inf = options[:inf] || '∞'
      return value<0 ? "-#{inf}" : inf
    end
  end
  if value.to_s.start_with?('-') # value<0 # we use to_s to handle negative zero
    value = -value
    neg = true
  end

  deg = value.floor
  value -= deg
  value *= 60
  min = value.floor
  value -= min
  value *= 60
  sec = value.round(SEC_PRECISION)

  txt = []

  txt << integer_to(deg, options.except(:precision)) + Array(options[:deg_seps]).first
  if min>0 || sec>0
    txt << integer_to(min, options.except(:precision)) + Array(options[:min_seps]).first
    if sec>0
      txt << number_to(sec, options) + Array(options[:sec_seps]).first
    end
  end

  txt = txt*" "

  if longitude || latitude
    if longitude
      letter = neg ? options[:west] : options[:east]
    else
      letter = neg ? options[:south] : options[:north]
    end
    txt = options[:prefix] ? "#{letter} #{txt}" : "#{txt} #{letter}"
  else
    txt = "-#{txt}" if neg
  end

  txt
end
from(txt, options={}) click to toggle source

Produce data from human-localized text (from user interface)

# File lib/h/h.rb, line 10
def from(txt, options={})
  type = options[:type] || Float
  type = Float if type==:number
  type = check_type(type)
  if type.ancestors.include?(Numeric)
    number_from(txt, options)
  elsif !(type.instance_methods & [:strftime, 'strftime']).empty?
    date_from(txt, options)
  elsif type==:logical || type==:boolean
    logical_from(txt, options)
  else
    nil
  end
end
integer_from(txt, options={}) click to toggle source
# File lib/h/h.rb, line 103
def integer_from(txt, options={})
  options = number_format_options(options).merge(options)
  if txt.to_s.strip.empty? || txt==options[:blank]
    nil
  else
    txt = txt.tr(' ','')
    txt = txt.tr(options[:delimiter],'') if options[:delimiter]
    txt = txt.tr(options[:separator],'.')
  end
  raise ArgumentError, "Invalid integer #{txt}" unless /\A[+-]?\d+(?:\.0*)?\Z/.match(txt)
  txt.to_i
end
integer_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 93
def integer_to(value, options={})
  options = number_format_options(options).merge(options)
  if value.nil?
    options[:blank] || ''
  else
    value = value.to_s
    digit_grouping value, 3, options[:delimiter], value.index(/\d/), value.size
  end
end
latitude_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 249
def latitude_to(value, options={})
  dms_to value, options.merge(:latitude=>true)
end
logical_from(txt, options={}) click to toggle source
# File lib/h/h.rb, line 161
def logical_from(txt, options={})
  options = logical_format_options(options).merge(options)
  txt = normalize_txt(txt)
  trues = options[:trues]
  trues ||= [normalize_txt(options[:true])]
  falses = options[:falses]
  falses ||= [normalize_txt(options[:falses])]
  trues.include?(txt) ? true : falses.include?(txt) ? false : nil
end
logical_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 156
def logical_to(value, options={})
  options = logical_format_options(options).merge(options)
  value.nil? ? options[:blank] : (value ? options[:true] : options[:false])
end
longitude_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 245
def longitude_to(value, options={})
  dms_to value, options.merge(:longitude=>true)
end
magnitude_from(txt, options={}) click to toggle source
# File lib/h/units.rb, line 64
def magnitude_from(txt, options={})
  return nil if txt.to_s.strip.empty? || txt==options[:blank]
  norm_units = options[:units]
  if txt.match(/^\s*([0-9\.,+-]+)\s*([a-zA-Z\"\'][a-zA-Z1-3\_\/\*\^\"\']*)\s*$/)
    txt = $1
    from_units = $2 || norm_units
  else
    from_units = norm_units
  end
  from_units = H::Units.normalize_units(from_units)
  raise ArgumentError, "Invalid units for #{norm_units}: #{from_units}}" unless from_units
  v = number_from(txt, options)
  v *= ::Units.u(from_units)
  v.in(norm_units)
end
magnitude_to(v, options={}) click to toggle source
# File lib/h/units.rb, line 56
def magnitude_to(v, options={})
  return options[:blank] || '' if v.nil?
  norm_units = options[:units]
  txt = number_to(v, options)
  txt << " #{H::Units.denormalize_units(norm_units)}"
  txt
end
number_from(txt, options={}) click to toggle source
# File lib/h/h.rb, line 76
def number_from(txt, options={})
  options = number_format_options(options).except(:precision).merge(options)
  type = check_type(options[:type] || (options[:precision]==0 ? Integer : Float))

  return nil if txt.to_s.strip.empty? || txt==options[:blank]

  txt = numbers_to_ruby(txt.tr(' ',''), options)
  raise ArgumentError, "Invalid number #{txt}" unless /\A[+-]?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?\Z/.match(txt)
  if type==Float
    txt.to_f
  elsif type==Integer
    txt.to_i
  else
    type.new txt
  end
end
number_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 39
def number_to(value, options={})
  options = number_format_options(options).except(:precision).merge(options)
  precision = options[:precision]

  return options[:blank] || '' if value.nil?
  unless value.kind_of?(String)
    value = round(value,precision)
    if value.respond_to?(:nan?) && value.nan?
      return options[:nan] || "--"
    elsif value.respond_to?(:infinite?) && value.infinite?
      inf = options[:inf] || '∞'
      return value<0 ? "-#{inf}" : inf
    else
      value = value.to_i if precision==0
      value = value.to_s
      value = value[0...-2] if value.end_with?('.0')
    end
  # else: TODO recognize nan/infinite values
  end
  if options[:delimiter]
    txt = value.to_s.tr(' ','').tr('.,',options[:separator]+options[:delimiter]).tr(options[:delimiter],'')
  else
    txt = value.to_s.tr(' ,','').tr('.',options[:separator])
  end
  raise ArgumentError, "Invalid number #{txt}" unless /\A[+-]?\d+(?:#{Regexp.escape(options[:separator])}\d*)?(?:[eE][+-]?\d+)?\Z/.match(txt)
  if precision && precision>0
    p = txt.index(options[:separator])
    if p.nil?
      txt << options[:separator]
      p = txt.size - 1
    end
    p += 1
    txt << "0"*(precision-txt.size+p) if txt.size-p < precision
  end
  digit_grouping txt, 3, options[:delimiter], txt.index(/\d/), txt.index(options[:separator]) || txt.size
end
time_from(txt, options={}) click to toggle source
# File lib/h/h.rb, line 144
def time_from(txt, options={})
  date_from value, options.reverse_merge(:type=>Time)
end
time_to(value, options={}) click to toggle source
# File lib/h/h.rb, line 140
def time_to(value, options={})
  date_to value, options.reverse_merge(:type=>Time)
end
to(value, options={}) click to toggle source

Generate human-localized text (for user interface) from data

# File lib/h/h.rb, line 26
def to(value, options={})
  case value
  when Numeric
    number_to(value, options)
  when Time, Date, DateTime
    date_to(value, options)
  when TrueClass, FalseClass
    logical_to(value, options)
  else
    options[:blank] || ''
  end
end

Private Class Methods

apply_regex(format) click to toggle source
# File lib/h/h.rb, line 413
def apply_regex(format)
  format.gsub(/(#{REGEXPS.keys.join('|')})/) { |s| REGEXPS[$1] }
end
check_type(type) click to toggle source
# File lib/h/h.rb, line 421
def check_type(type)
  orig_type = type
  type = type.to_s.camelcase.safe_constantize if type.kind_of?(Symbol)
  raise ArgumentError, "Invalid type #{orig_type}" unless type && type.class==Class
  type
end
default_parse(datetime, type) click to toggle source
# File lib/h/h.rb, line 386
def default_parse(datetime, type)
  return if datetime.blank?
  begin
    today = Date.current
    parsed = Date._parse(datetime)
    return if parsed.empty? # the datetime value is invalid
    # set default year, month and day if not found
    parsed.reverse_merge!(:year => today.year, :mon => today.mon, :mday => today.mday)
    datetime = Time.zone.local(*parsed.values_at(:year, :mon, :mday, :hour, :min, :sec))
    Date == type ? datetime.to_date : datetime
  rescue
    datetime
  end
end
digit_grouping(txt,n,sep,pos0,pos1) click to toggle source

pos0 first digit, pos1 one past last integral digit

# File lib/h/h.rb, line 364
def digit_grouping(txt,n,sep,pos0,pos1)
  txt[pos0...pos1] = txt[pos0...pos1].gsub(/(\d)(?=(#{'\\d'*n})+(?!\d))/, "\\1#{sep}") if sep
  txt
end
dms_format_options(options) click to toggle source
# File lib/h/h.rb, line 335
def dms_format_options(options)
  opt = I18n.translate(:'number.dms.format', :locale => options[:locale])
  opt.kind_of?(Hash) ? opt : {
    :deg_seps => ['°', 'º'],
    :min_seps => "'",
    :sec_seps => '"',
    :north => 'N',
    :south => 'S',
    :east => 'E',
    :west => 'W',
    :prefix => false
  }
end
input_formats(type, locale=nil) click to toggle source
# File lib/h/h.rb, line 407
def input_formats(type, locale=nil)
  # Date uses date formats, all others use time formats
  type = type == Date ? :date : :time
  I18n.t(:"#{type}.formats", :locale=>locale).slice(*I18n.t(:"#{type}.input.formats", :locale=>locale)).values
end
logical_format_options(options) click to toggle source
# File lib/h/h.rb, line 330
def logical_format_options(options)
  opt = I18n.translate(:'logical.format', :locale => options[:locale])
  opt.kind_of?(Hash) ? opt : {:separator=>'.'}
end
normalize_txt(txt) click to toggle source
# File lib/h/h.rb, line 417
def normalize_txt(txt)
  txt.mb_chars.normalize(:kd).gsub(/[^\x00-\x7F]/n,'').downcase.strip.to_s
end
number_format_options(options) click to toggle source
# File lib/h/h.rb, line 325
def number_format_options(options)
  opt = I18n.translate(:'number.format', :locale => options[:locale])
  opt.kind_of?(Hash) ? opt : {:separator=>'.'}
end
numbers_to_ruby(txt, options) click to toggle source
# File lib/h/h.rb, line 428
def numbers_to_ruby(txt, options)
  if options[:delimiter]
    txt = txt.tr(options[:delimiter]+options[:separator], ',.').tr(',','')
  else
    txt = txt.tr(options[:separator], '.')
  end
  txt
end
round(v, ndec) click to toggle source
# File lib/h/h.rb, line 349
def round(v, ndec)
  return v if (v.respond_to?(:nan?) && v.nan?) || (v.respond_to?(:infinite?) && v.infinite?)
  if ndec
    case v
      when BigDecimal
        v = v.round(ndec)
      when Float
        k = 10**ndec
        v = (k*v).round.to_f/k
    end
  end
  v
end
translate_month_and_day_names!(datetime, locale=nil) click to toggle source
# File lib/h/h.rb, line 401
def translate_month_and_day_names!(datetime, locale=nil)
  translated = I18n.t([:month_names, :abbr_month_names, :day_names, :abbr_day_names], :scope => :date, :locale=>locale).flatten.compact
  original = (Date::MONTHNAMES + Date::ABBR_MONTHNAMES + Date::DAYNAMES + Date::ABBR_DAYNAMES).compact
  translated.each_with_index { |name, i| datetime.gsub!(name, original[i]) }
end