File Coverage

blib/lib/Lingua/RO/Numbers.pm
Criterion Covered Total %
statement 142 154 92.2
branch 93 110 84.5
condition 24 36 66.6
subroutine 12 12 100.0
pod 3 3 100.0
total 274 315 86.9


line stmt bran cond sub pod time code
1             package Lingua::RO::Numbers;
2              
3             #
4             ## See: http://ro.wikipedia.org/wiki/Sistem_zecimal#Denumiri_ale_numerelor
5             #
6              
7 3     3   194858 use utf8;
  3         37  
  3         18  
8 3     3   91 use strict;
  3         6  
  3         58  
9 3     3   15 use warnings;
  3         6  
  3         2564  
10              
11             require Exporter;
12             our @ISA = qw(Exporter);
13             our @EXPORT_OK = qw(number_to_ro ro_to_number);
14              
15             =encoding utf8
16              
17             =head1 NAME
18              
19             Lingua::RO::Numbers - Convert numeric values into their Romanian string equivalents and viceversa
20              
21             =head1 VERSION
22              
23             Version 0.22
24              
25             =cut
26              
27             our $VERSION = '0.22';
28              
29             # Numbers => text
30             our %DIGITS;
31             @DIGITS{'0' .. '19'} = qw(
32             zero unu doi trei patru cinci șase șapte opt nouă zece
33             unsprezece
34             doisprezece
35             treisprezece
36             paisprezece
37             cincisprezece
38             șaisprezece
39             șaptesprezece
40             optsprezece
41             nouăsprezece
42             );
43              
44             # Text => numbers
45             our %WORDS;
46             @WORDS{map { _remove_diacritics($_) } values %DIGITS} = keys %DIGITS;
47             @WORDS{qw(o un doua cin sai ob)} = (1, 1, 2, 5, 6, 8);
48              
49             # Colocvial
50             @WORDS{qw(unspe doispe treispe paispe cinspe cinsprezece saispe saptespe saptuspe optspe optuspe nouaspe)} =
51             (11, 12, 13, 14, 15, 15, 16, 17, 17, 18, 18, 19);
52              
53             # This array contains number greater than 99 and it's used to convert numbers into text
54             our @BIGNUMS = (
55             {num => 10**2, sg => 'suta', pl => 'sute', fem => 1},
56             {num => 10**3, sg => 'mie', pl => 'mii', fem => 1},
57             {num => 10**6, sg => 'milion', pl => 'milioane'},
58             {num => 10**9, sg => 'miliard', pl => 'miliarde'},
59             {num => 10**12, sg => 'bilion', pl => 'bilioane'},
60             {num => 10**15, sg => 'biliard', pl => 'biliarde'},
61             {num => 10**18, sg => 'trilion', pl => 'trilioane'},
62             {num => 10**21, sg => 'triliard', pl => 'triliarde'},
63             {num => 10**24, sg => 'cvadrilion', pl => 'cvadrilioane'},
64             {num => 10**27, sg => 'cvadriliard', pl => 'cvadriliarde'},
65             {num => 'inf', sg => 'inifinit', pl => 'infinit'},
66             );
67              
68             # This hash is a reversed version of the above array and it's used to convert text into numbers
69             our %BIGWORDS = (map { $_->{sg} => $_->{num}, $_->{pl} => $_->{num} } @BIGNUMS);
70              
71             # Change 'suta' to 'sută'
72             $BIGNUMS[0]{'sg'} = 'sută';
73              
74             =head1 SYNOPSIS
75              
76             use Lingua::RO::Numbers qw(number_to_ro ro_to_number);
77             print number_to_ro(315);
78             # prints: 'trei sute cincisprezece'
79              
80             print ro_to_number('trei sute douazeci si cinci virgula doi');
81             # prints: 325.2
82              
83             =head1 DESCRIPTION
84              
85             Lingua::RO::Numbers converts arbitrary numbers into human-readable
86             Romanian text and viceversa, converting arbitrary Romanian text
87             into its corresponding numerical value.
88              
89             =head2 EXPORT
90              
91             Nothing is exported by default.
92             Only the functions B and B are exportable.
93              
94             =over
95              
96             =item B
97              
98             Initialize an object.
99              
100             my $obj = Lingua::RO::Numbers->new();
101              
102             is equivalent with:
103              
104             my $obj = Lingua::RO::Numbers->new(
105             diacritics => 1,
106             invalid_number => undef,
107             negative_sign => 'minus',
108             decimal_point => 'virgulă',
109             thousands_separator => '',
110             infinity => 'infinit',
111             not_a_number => 'NaN',
112             );
113              
114             =item B
115              
116             Converts a number to its Romanian string representation.
117              
118             # Functional oriented usage
119             $string = number_to_ro($number);
120             $string = number_to_ro($number, %opts);
121              
122             # Object oriented usage
123             my $obj = Lingua::RO::Numbers->new(%opts);
124             $string = $obj->number_to_ro($number);
125              
126             # Example:
127             print number_to_ro(98_765, thousands_separator => q{,});
128             #=> 'nouăzeci și opt de mii, șapte sute șaizeci și cinci'
129              
130             =item B
131              
132             Converts a Romanian text into its numeric value.
133              
134             # Functional oriented usage
135             $number = ro_to_number($text);
136             $number = ro_to_number($text, %opts);
137              
138             # Object oriented usage
139             my $obj = Lingua::RO::Numbers->new(%opts);
140             $number = $obj->ro_to_number($text);
141              
142             # Example:
143             print ro_to_number('patruzeci si doi'); #=> 42
144              
145             =back
146              
147             =cut
148              
149             sub new {
150 4     4 1 994 my ($class, %opts) = @_;
151              
152 4         19 my $self = bless {
153             diacritics => 1,
154             invalid_number => undef,
155             negative_sign => 'minus',
156             decimal_point => 'virgulă',
157             thousands_separator => '',
158             infinity => 'infinit',
159             not_a_number => 'NaN',
160             }, $class;
161              
162 4         8 foreach my $key (keys %{$self}) {
  4         18  
163 28 100       58 if (exists $opts{$key}) {
164 4         9 $self->{$key} = delete $opts{$key};
165             }
166             }
167              
168 4         8 foreach my $invalid_key (keys %opts) {
169 0         0 warn "Invalid option: <$invalid_key>";
170             }
171              
172 4         12 return $self;
173             }
174              
175             # This function it's an interface to a private function
176             # which converts a mathematical number into its Romanian equivalent text.
177             sub number_to_ro {
178 34     34 1 4972 my ($self, $number, %opts);
179              
180 34 100       96 if (ref $_[0] eq __PACKAGE__) {
181 33         65 ($self, $number) = @_;
182             }
183             else {
184 1         3 ($number, %opts) = @_;
185 1         3 $self = __PACKAGE__->new(%opts);
186             }
187              
188 34         93 my $word_number = $self->_number_to_ro($number + 0);
189              
190 34 50       87 if (not $self->{diacritics}) {
191 0         0 $word_number = _remove_diacritics($word_number);
192             }
193              
194             # Return the text-number
195 34         145 $word_number;
196             }
197              
198             # This function it's an interface to a private function
199             # which converts a Romanian text-number into its mathematical value.
200             sub ro_to_number {
201 33     33 1 596 my ($self, $text, %opts);
202              
203 33 100       82 if (ref $_[0] eq __PACKAGE__) {
204 32         66 ($self, $text) = @_;
205             }
206             else {
207 1         3 ($text, %opts) = @_;
208 1         4 $self = __PACKAGE__->new(%opts);
209             }
210              
211             ( # Decode the text unless it is already UTF-8
212             $] >= 5.0080001 ? utf8::is_utf8($text) : do {
213 0         0 require Encode;
214 0         0 Encode::is_utf8($text);
215             }
216             )
217 33 50       113 || do {
    100          
218 3         668 require Encode;
219 3         9674 $text = Encode::decode_utf8($text);
220             };
221              
222             # Return the number
223 33         160 $self->_ro_to_number($text);
224             }
225              
226             # This function removes the Romanian diacritics from a given text.
227             sub _remove_diacritics {
228 165     165   22305 my ($text) = @_;
229 3     3   23 $text =~ tr{ăâșțî}{aasti};
  3         6  
  3         49  
  165         509  
230 165         357 $text;
231             }
232              
233             # This functions removes irrelevant characters from a text
234             sub _normalize_text {
235              
236             # Lowercase and remove the diacritics
237 105     105   336 my $text = _remove_diacritics(lc(shift));
238              
239             # Replace irrelevant characters with a space
240 105         222 $text =~ tr/a-z / /c;
241              
242             # Return the normalized text
243 105         277 $text;
244             }
245              
246             # This function adds together a list of numbers
247             sub _add_numbers {
248 38     38   78 my (@nums) = @_;
249              
250 38         93 my $num = 0;
251 38         81 while (defined(my $i = shift @nums)) {
252              
253             # When the current number is lower than the next number
254 82 100 100     233 if (@nums and $i < $nums[0]) {
255 23         34 my $n = shift @nums;
256              
257             # This is a special case, where: int(log(1000)/log(10)) == 2
258 23         68 my $l = log($n) / log(10);
259 23 100       128 if (length($l) == length(int($l))) {
260 1         6 $l = sprintf('%.0f', $l);
261             }
262              
263             # Factor (e.g.: 400 -> 4)
264 23         59 my $f = int($i / (10**int(log($i) / log(10))));
265              
266             # When the next number is not really next to the current number
267             # e.g.: $i == 400 and $n == 5000 # should produce 405_000 not 45_000
268 23 50       59 if ((my $mod = length($n) % 3) != 0) {
269 23         36 $f *= 10**(3 - $mod);
270             }
271              
272             # Join the numbers and continue
273 23         39 $num += 10**int($l) * $f + $n;
274 23         57 next;
275             }
276              
277 59         125 $num += $i;
278             }
279              
280 38         68 $num;
281             }
282              
283             # This function converts a Romanian
284             # text-number into a mathematical number.
285             sub _ro_to_number {
286 33     33   56 my ($self, $text) = @_;
287              
288             # When no text has been provided
289 33 50       67 if (not defined $text) {
290 0         0 return;
291             }
292              
293             # If a thousand separator is defined, remove it from text
294 33 50 33     148 if (defined($self->{thousands_separator}) and length($self->{thousands_separator})) {
295 0         0 $text =~ s/\Q$self->{thousands_separator}\E/ /g;
296             }
297              
298             # Split the text into words
299 33         70 my @words = split(' ', _normalize_text($text));
300              
301 33         94 my $dec_point = _normalize_text($self->{decimal_point});
302 33         64 my $neg_sign = _normalize_text($self->{negative_sign});
303              
304 33         55 my @nums; # numbers
305             my @decs; # decimal numbers
306              
307 33         44 my $neg = 0; # bool -- true when the number is negative
308 33         41 my $adec = 0; # bool -- true after the decimal point
309              
310 33         45 my $amount = 0; # int -- current number
311 33         44 my $factor = 1; # int -- multiplication factor
312              
313 33 50       76 if (@words) {
314              
315             # Check for negative numbers
316 33 100       67 if ($words[0] eq $neg_sign) {
317 2         6 $neg = 1;
318 2         3 shift @words;
319             }
320              
321             # Check for infinity and NaN
322 33 100       75 if (@words == 1) {
323              
324             # Infinity
325 3         7 my $inf = _normalize_text($self->{infinity});
326 3 50       8 if ($words[0] eq $inf) {
327 0 0       0 return $neg ? -9**9**9 : 9**9**9;
328             }
329              
330             # Not a number
331 3         6 my $nan = _normalize_text($self->{not_a_number});
332 3 50       9 if ($words[0] eq $nan) {
333 0         0 return -sin(9**9**9);
334             }
335             }
336             }
337              
338             # Iterate over the @words
339 33   0     80 while (
      66        
340             @words and (
341              
342             # It's a small number (lower than 100)
343             do {
344             $factor = exists($WORDS{$words[0]}) ? 1 : $words[0] =~ s/zeci\z// ? 10 : 0;
345             $factor && do { $amount = shift @words };
346             $factor;
347             }
348              
349             # It's a big number (e.g.: milion)
350             or @words && exists($BIGWORDS{$words[0]}) && do {
351             $factor = $BIGWORDS{shift @words};
352             }
353              
354             # Ignore invalid words
355             or do {
356             shift @words;
357             next;
358             }
359             )
360             ) {
361              
362             # Take and multiply the current number
363             my $num =
364             exists($WORDS{$amount})
365 107 50       249 ? $WORDS{$amount} * $factor
366             : next; # skip invalid words
367              
368             # Check for some word-joining tokens
369 107 100       185 if (@words) {
370 99 100       181 if ($words[0] eq 'si') { # e.g.: patruzeci si doi
371 27         36 shift @words;
372 27         54 $num += $WORDS{shift @words};
373             }
374              
375 99 100       165 if (@words) {
376             {
377 86 100       111 if ($words[0] eq 'de') { # e.g.: o suta de mii
  88         147  
378 19         22 shift @words;
379             }
380              
381 88 100       184 if (exists $BIGWORDS{$words[0]}) {
382 81         130 $num *= $BIGWORDS{shift @words};
383             }
384              
385 88 100 100     257 if (@words && $words[0] eq 'de') {
386 2         5 redo;
387             }
388             }
389             }
390             }
391              
392             # If we are after the decimal point, store the
393             # numbers in @decs, otherwise store them in @nums.
394 107 100       209 $adec ? push(@decs, $num) : push(@nums, $num);
395              
396             # Check for the decimal point
397 107 100 100     332 if (@words and $words[0] eq $dec_point) {
398 5         7 $adec = 1;
399 5         13 shift @words;
400             }
401             }
402              
403             # Return undef when no number has been converted
404 33 50       66 @nums || return;
405              
406             # Add all the numbers together (if any)
407 33         66 my $num = _add_numbers(@nums);
408              
409             # If the number contains decimals,
410             # add them at the end of the number
411 33 100       64 if (@decs) {
412              
413             # Special case -- check for leading zeros
414 5         11 my $zeros = '';
415 5   66     20 while (@decs and $decs[0] == 0) {
416 2         11 $zeros .= shift(@decs);
417             }
418              
419 5         13 $num .= '.' . $zeros . _add_numbers(@decs);
420             }
421              
422             # Return the number
423 33 100       227 $neg ? -$num : $num + 0;
424             }
425              
426             # This function converts numbers
427             # into their Romanian equivalent text.
428             sub _number_to_ro {
429 151     151   258 my ($self, $number) = @_;
430              
431 151         194 my @words;
432 151 100 33     710 if (exists $DIGITS{$number}) { # example: 8
    50          
    50          
    100          
    100          
    100          
    50          
433 18         39 push @words, $DIGITS{$number};
434             }
435             elsif (lc($number) eq 'nan') { # not a number (NaN)
436 0         0 return $self->{not_a_number};
437             }
438             elsif ($number == 9**9**9) { # number is infinit
439 0         0 return $self->{infinity};
440             }
441             elsif ($number < 0) { # example: -43
442 2         6 push @words, $self->{negative_sign};
443 2         6 push @words, $self->_number_to_ro(abs($number));
444             }
445             elsif ($number != int($number)) { # example: 0.123 or 12.43
446 5         18 my $l = length($number) - 2;
447              
448 5 50       18 if ((length($number) - length(int $number) - 1) < 1) { # special case
449 0         0 push @words, $self->_number_to_ro(sprintf('%.0f', $number));
450             }
451             else {
452 5         14 push @words, $self->_number_to_ro(int $number);
453 5         10 push @words, $self->{decimal_point};
454              
455 5         10 $number -= int $number;
456              
457 5         12 until ($number == int($number)) {
458 16         21 $number *= 10;
459 16         65 $number = sprintf('%.*f', --$l, $number); # because of imprecise multiplication
460 16 100       57 push @words, $DIGITS{0} if $number < 1;
461             }
462 5         14 push @words, $self->_number_to_ro(int $number);
463             }
464             }
465             elsif ($number >= $BIGNUMS[0]{num}) { # i.e.: >= 100
466 88         188 foreach my $i (0 .. $#BIGNUMS - 1) {
467 807         1083 my $j = $#BIGNUMS - $i;
468              
469 807 100 66     1784 if ($number >= $BIGNUMS[$j - 1]{num} && $number <= $BIGNUMS[$j]{num}) {
470 88         174 my $cat = int $number / $BIGNUMS[$j - 1]{num};
471 88         167 $number -= $BIGNUMS[$j - 1]{num} * int($number / $BIGNUMS[$j - 1]{num});
472              
473 88 100       171 my @of = $cat <= 2 ? () : do {
474 64 100       186 my @w = exists $DIGITS{$cat} ? $DIGITS{$cat} : ($self->_number_to_ro($cat), 'de');
475 64 100       136 if (@w > 2) {
476 31 100       79 $w[-2] = 'două' if $w[-2] eq $DIGITS{2};
477             }
478 64         157 @w;
479             };
480              
481 88 100 66     237 if ($cat >= 100 && $cat < 1_000) {
482 28         91 my $rest = $cat - 100 * int($cat / 100);
483 28 100 66     131 if (@of and $rest != 0 and exists $DIGITS{$rest}) {
      100        
484 8         18 splice @of, -1; # remove 'de'
485             }
486             }
487              
488             push @words,
489             $cat == 1 ? ($BIGNUMS[$j - 1]{fem} ? 'o' : 'un', $BIGNUMS[$j - 1]{sg})
490             : $cat == 2 ? ('două', $BIGNUMS[$j - 1]{pl})
491 88 100       272 : (@of, $BIGNUMS[$j - 1]{pl});
    100          
    100          
492              
493 88 100       159 if ($number > 0) {
494 74 100       169 $words[-1] .= $self->{thousands_separator} if $BIGNUMS[$j]{num} > 1_000;
495 74         186 push @words, $self->_number_to_ro($number);
496             }
497              
498 88         170 last;
499             }
500             }
501             }
502             elsif ($number > 19 && $number < 100) { # example: 42
503 38         71 my $cat = int $number / 10;
504             push @words, ($cat == 2 ? 'două' : $cat == 6 ? 'șai' : $DIGITS{$cat}) . 'zeci',
505 38 100       205 ($number % 10 != 0 ? ('și', $DIGITS{$number % 10}) : ());
    100          
    100          
506             }
507             else { # doesn't look like a number
508 0         0 return $self->{invalid_number};
509             }
510              
511 151 50       560 return wantarray ? @words : @words ? join(' ', @words) : ();
    100          
512             }
513              
514             =head1 AUTHOR
515              
516             Daniel Șuteu, C<< >>
517              
518             =head1 SUPPORT
519              
520             You can find documentation for this module with the perldoc command.
521              
522             perldoc Lingua::RO::Numbers
523              
524             =head1 REPOSITORY
525              
526             L
527              
528             =head1 REFERENCES
529              
530             L
531              
532             =head1 LICENSE AND COPYRIGHT
533              
534             Copyright 2013-2018 Daniel Șuteu.
535              
536             This program is free software; you can redistribute it and/or modify it
537             under the terms of the the Artistic License (2.0). You may obtain a
538             copy of the full license at:
539              
540             L
541              
542             =cut
543              
544             1; # End of Lingua::RO::Numbers
545              
546             __END__