File Coverage

blib/lib/Lingua/Slavic/Numbers.pm
Criterion Covered Total %
statement 196 228 85.9
branch 51 70 72.8
condition 35 42 83.3
subroutine 22 24 91.6
pod 2 10 20.0
total 306 374 81.8


line stmt bran cond sub pod time code
1             package Lingua::Slavic::Numbers;
2 7     7   114420 use strict;
  7         16  
  7         361  
3              
4 7     7   40 use Carp qw(carp);
  7         14  
  7         649  
5 7     7   82 use List::Util qw(max);
  7         18  
  7         1191  
6 7     7   10755 use Data::Dumper;
  7         84717  
  7         792  
7 7     7   4731 use Regexp::Common qw /number/;
  7         49197  
  7         36  
8 7     7   29292 use Exporter;
  7         15  
  7         522  
9 7     7   3455 use utf8;
  7         2064  
  7         50  
10 7     7   342 use vars qw( $VERSION $DEBUG @ISA @EXPORT_OK @EXPORT);
  7         21  
  7         912  
11 7         485 use vars qw(
12             %INFLEXIONS
13             %NUMBER_NAMES
14             %ORDINALS
15             $OUTPUT_DECIMAL_DELIMITER
16             $MINUS
17 7     7   76 );
  7         13  
18              
19 7     7   45 use constant LANG_BG => 'bg';
  7         13  
  7         662  
20              
21 7     7   43 use constant NO_CONJUNCTIONS => 'noconj';
  7         13  
  7         362  
22 7     7   38 use constant FEMININE_GENDER => 'fem';
  7         13  
  7         343  
23 7     7   33 use constant MASCULINE_GENDER => 'man';
  7         14  
  7         369  
24 7     7   38 use constant NEUTRAL_GENDER => 'neu';
  7         14  
  7         32914  
25            
26             $VERSION = 0.06;
27             $DEBUG = 0;
28             @ISA = qw(Exporter);
29             @EXPORT_OK = qw( &number_to_slavic &ordinate_to_slavic LANG_BG);
30             @EXPORT = @EXPORT_OK;
31              
32             $MINUS = ('минус');
33             $OUTPUT_DECIMAL_DELIMITER = ('цяло');
34              
35             %INFLEXIONS =
36             (
37             LANG_BG,
38             {
39             FEMININE_GENDER,
40             {
41             1 => 'една',
42             },
43             MASCULINE_GENDER,
44             {
45             1 => 'един',
46             2 => 'два',
47             },
48             }
49             );
50              
51             %NUMBER_NAMES =
52             (
53             LANG_BG,
54             {
55             0 => 'нула',
56             1 => 'едно',
57             2 => 'две',
58             3 => 'три',
59             4 => 'четири',
60             5 => 'пет',
61             6 => 'шест',
62             7 => 'седем',
63             8 => 'осем',
64             9 => 'девет',
65             10 => 'десет',
66             11 => 'едина{10}',
67             12 => 'двана{10}',
68             13 => '{3}на{10}',
69             14 => '{4}на{10}',
70             15 => '{5}на{10}',
71             16 => '{6}на{10}',
72             17 => '{7}на{10}',
73             18 => '{8}на{10}',
74             19 => '{9}на{10}',
75             20 => 'два{10}',
76             30 => '{3}{10}',
77             40 => '{4}{10}',
78             50 => '{5}{10}',
79             60 => '{6}{10}',
80             70 => '{7}{10}',
81             80 => '{8}{10}',
82             90 => '{9}{10}',
83             100 => 'сто',
84             200 => '{2}ста',
85             300 => '{3}ста',
86             '1e3' => 'хиляда',
87             '1e4' => '{10} хиляди',
88             '1e5' => '{100} хиляди',
89             '1e6' => 'милион',
90             '1e7' => '{10} {1e6}а',
91             '1e8' => '{100} {1e6}а',
92             '1e9' => 'милиард', # USA English 'billion'
93             '1e10' => '{10} {1e9}а',
94             '1e11' => '{100} {1e9}а',
95             '1e12' => 'трилион', # sometimes 'билион' in older usage
96             '1e13' => '{10} {1e12}а',
97             '1e14' => '{100} {1e12}а',
98             '1e15' => 'квадрилион',
99             '1e16' => '{10} {1e15}а',
100             '1e17' => '{100} {1e15}а',
101             '1e18' => 'квинтилион',
102             '1e19' => '{10} {1e18}а',
103             '1e20' => '{100} {1e18}а',
104             '1e21' => 'секстилион',
105             '1e22' => '{10} {1e21}а',
106             }
107             );
108              
109              
110             $NUMBER_NAMES{LANG_BG()}->{"${_}00"} = "{$_}стотин" foreach qw/4 5 6 7 8 9/;
111             $NUMBER_NAMES{LANG_BG()}->{'1' . '0'x(3*$_)} = $NUMBER_NAMES{LANG_BG()}->{'1e'. 3*$_} foreach 1..7;
112              
113             # use Data::Dumper;
114             # print Dumper \%NUMBER_NAMES;
115             my $count = 1;
116              
117             %ORDINALS =
118             (
119             LANG_BG,
120             {
121             # given in male singular formal version only, inflection TODO. Nothing above 99 yet.
122             0 => 'нулев',
123             1 => 'първи',
124             2 => 'втори',
125             3 => 'трети',
126             4 => 'четвърти',
127             5 => '{5}и',
128             6 => '{6}и',
129             7 => 'седми',
130             8 => 'осми',
131             9 => '{9}и',
132             10 => '{10}и',
133             11 => 'едина[10]',
134             12 => 'двана[10]',
135             13 => '{3}на[10]',
136             13 => '{3}на[10]',
137             14 => '{4}на[10]',
138             15 => '{5}на[10]',
139             16 => '{6}на[10]',
140             17 => '{7}на[10]',
141             18 => '{8}на[10]',
142             19 => '{9}на[10]',
143             20 => 'два[10]',
144             30 => '{3}[10]',
145             40 => '{4}[10]',
146             50 => '{5}[10]',
147             60 => '{6}[10]',
148             70 => '{7}[10]',
149             80 => '{8}[10]',
150             90 => '{9}[10]',
151             100 => '{100}тен',
152             1000 => 'хиляден',
153             10e6 => '{1e6}ен',
154             }
155             );
156              
157             foreach my $lang (keys %ORDINALS)
158             {
159             foreach my $val (values %{$ORDINALS{$lang}})
160             {
161             $val = interpolate_string($lang, $val);
162             }
163             }
164              
165             foreach my $lang (keys %NUMBER_NAMES)
166             {
167             foreach my $val (values %{$NUMBER_NAMES{$lang}})
168             {
169             $val = interpolate_string($lang, $val);
170             }
171             }
172              
173 1112 100   1112 0 2562 sub deb { print @_ if $DEBUG }
174              
175             sub ordinate_to_slavic
176             {
177 148     148 1 218940 my $lang = shift;
178 148         338 my $number = shift;
179 148   50     696 my $options = shift @_ || {};
180              
181 148 50       423 unless ( exists $ORDINALS{$lang} )
182             {
183 0         0 carp("Ordinates for language $lang are unknown, sorry");
184 0         0 return undef;
185             }
186            
187 148         261 my $hash = $ORDINALS{$lang};
188              
189 148 100       409 unless ( $number >= 0 )
190             {
191 2         412 carp("Ordinates must not be negative");
192 2         20 return undef;
193             }
194              
195 146 50       343 unless ( int $number == $number )
196             {
197 0         0 carp("Ordinates can only be integers");
198 0         0 return undef;
199             }
200              
201 146 100       2341 return $hash->{$number} if exists $hash->{$number};
202              
203 9         261 my $max = max(keys %$hash);
204 9 50       52 if ($number > $max)
205             {
206 0         0 carp("Ordinate $number is above maximum $max and not supported, sorry");
207 0         0 return undef;
208             }
209              
210 9 50       32 if ($lang eq LANG_BG)
211             {
212             # we may have a partially expressible ordinate number, which in
213             # Bulgarian for a number of N digits is done with N-1 numbers (not
214             # ordinals) with no conjunctions, and an 'и' conjunction before the
215             # last one (N) as an ordinal. Effectively it turns out to be the
216             # number without the least significant digit, then 'и', then the
217             # ordinal of the least significant digit. The exceptions should be
218             # handled by $ORDINALS.
219              
220 9         19 my $out = '';
221            
222 9         21 my $bot = $number % 10;
223 9         18 my $top = $number - $bot;
224 9         19 return interpolate_string($lang, "{{$top}@{[NO_CONJUNCTIONS()]}} и [$bot]");
  9         63  
225             }
226              
227 0         0 carp("The ordinate for $number in language '$lang' couldn't be found, sorry");
228 0         0 return undef;
229             }
230              
231             sub bulgarian_triplets
232             {
233 145     145 0 275 my $lang = LANG_BG;
234 145         264 my $hash = shift;
235 145         227 my $tri = shift;
236 145   50     2048 my $options = shift @_ || {};
237              
238 145         283 my $pow = 0;
239 145         382 foreach my $t (@$tri) # this is a triplet
240             {
241 275         822 my $some_left = scalar @$tri > $pow/3; # true if we're not at end of @$tri yet
242             # convert to scientific notation
243 275         454 my $canon_power = $pow;
244 275         439 my $canon_t = $t;
245 275 50       1401 if ($t =~ m/$RE{num}{real}{-sep=>'[,.]?'}{-keep}/)
246             {
247 275   50     57756 $canon_power = $8 || 0;
248 275         700 $canon_t = $3;
249             }
250             else
251             {
252 0         0 while ($canon_t >= 10)
253             {
254 0         0 $canon_t /= 10;
255 0         0 $canon_power ++;
256             }
257             }
258            
259 275         2986 my $canon = "${canon_t}e$canon_power";
260            
261 275         1067 deb("Working on triplet $t (power $pow, canonical $canon)\n");
262 275 50       1108 if (exists $hash->{$canon})
    100          
263             {
264 0         0 $t = $hash->{$canon};
265             }
266             elsif ($t == 0) # handle 0 and '000' strings
267             {
268 88 50       217 if (scalar @$tri == 1) # is the zero the only number?
269             {
270 0         0 $t = 0;
271 0         0 redo;
272             }
273             else
274             {
275 88         150 $t = ''; # don't do anything with uninteresting zeroes
276             }
277             }
278             else
279             {
280             # try decomposing $t
281            
282             # get rid of scientific notation
283 187         448 $t =~ s/(\d+)e(\d+)/$1 . 0 x $2/e;
  0         0  
284            
285             # first, set up the qualifier
286 187         647 deb("getting qualifier and gender for $t\n");
287            
288 187         366 my $qualifier = '';
289 187         328 my $inflexion = '';
290 187         612 my $extra_а = '';
291              
292 187 100       532 if ($pow)
293             {
294 84         320 $qualifier = number_to_slavic($lang, "1e$pow");
295 84         229 $inflexion = MASCULINE_GENDER; # all but thousands are masculine
296 84         162 $extra_а = 'а'; # and all have 'a' when plural (singular cases are caught by the %NUMBER_NAMES hash)
297            
298 84 100       309 if ($pow eq 3) # thousands are a special case for gender, being feminine
299             {
300 62         123 $qualifier = 'хиляди';
301 62         111 $inflexion = FEMININE_GENDER;
302 62         124 $extra_а = ''; # no extra 'a' for thousands
303             }
304             }
305              
306 187         362 $qualifier .= $extra_а;
307            
308 187         654 my @n = split //, $t;
309 187         3284 shift @n while 0 == $n[0]; # remove the leading zeroes
310 187         865 deb("decomposing $t, result [@n]\n");
311 187         353 my @inter;
312 187         452 while (@n)
313             {
314 358         665 my $decompose_num = shift @n;
315 358         992 my $decompose_pow = scalar @n;
316              
317             # grab the next digit for numbers 10 .. 20
318 358 100 100     1883 if (($decompose_num == 1 && scalar @n == 1) ||
      100        
      100        
      100        
319             ($decompose_num == 2 && scalar @n == 1 && $n[0] == 0))
320             {
321 32         73 $decompose_num .= shift @n;
322 32         57 $decompose_pow = 0;
323             }
324              
325 358 100       874 next unless $decompose_num; # skip zeroes
326              
327 301         490 my $extra_и = '';
328             # numbers below 21 are one word, so in cases like 1001 (хиляда и едно) a conjunction is needed
329             # ditto for 100..900
330 301 100 100     1931 if (
      66        
331             # $some_left tells us there are more triplets to come
332             $some_left &&
333             (
334             ($decompose_num <= 20 && scalar @n == 0) || # 1..20
335             (scalar @n == 2 && $n[0] == 0 && $n[1] == 0) # N00
336             )
337             )
338             {
339 186         329 $extra_и = ' ';
340             }
341            
342 301         1248 push @inter, sprintf("%s{%s%s}", $extra_и, $decompose_num, '0'x$decompose_pow);
343             }
344              
345 187         496 my @inter_options = (NO_CONJUNCTIONS);
346 187 100       472 push @inter_options, $inflexion if $inflexion;
347 187         430 my $inter_options = join ':', @inter_options;
348            
349 187         1910 $inter[-1] =~ s/(\{.*\})/{$1$inter_options}/;
350              
351 187         1147 my $inter = join(' ', @inter);
352 187         685 deb("bulgarian_triplets calling interpolate_string with [$inter]\n");
353 187         653 $inter = interpolate_string($lang, $inter);
354              
355 187 50       610 if (defined $inter)
356             {
357 187         436 $t = $inter;
358             # add the final conjunction if requested
359 187 100       2526 $t =~ s/\s(\w+)$/ и $1/ unless $options->{NO_CONJUNCTIONS()};
360 187 100       682 $t .= " ${qualifier}" if $qualifier; # add the qualifier
361 187         817 $t =~ s/^\s+//g; # replace leading/ending spaces
362 187         1218 $t =~ s/\s+$//g; # replace leading/ending spaces
363             }
364             else
365             {
366 0         0 carp "Couldn't convert $canon";
367             }
368             }
369            
370 275         808 $pow+=3;
371             }
372              
373 145         682 @$tri = reverse @$tri;
374            
375 145         721 return "@$tri";
376             }
377              
378             sub find_known
379             {
380 2352     2352 0 4013 my $lang = shift;
381 2352         3451 my $hash = shift;
382 2352         3604 my $number = shift;
383 2352   50     5144 my $options = shift @_ || {};
384              
385 2352         4602 foreach my $gender (FEMININE_GENDER(), MASCULINE_GENDER())
386             {
387             return $INFLEXIONS{$lang}->{$gender}->{$number}
388             if (exists $options->{$gender} &&
389 4660 100 100     10868 exists $INFLEXIONS{$lang}->{$gender}->{$number});
390             }
391            
392 2302 100       14505 return $hash->{$number} if exists $hash->{$number};
393              
394 156         522 return undef;
395             }
396              
397             sub number_to_slavic
398             {
399 1255     1255 1 430550 my $lang = shift;
400 1255         2819 my $number = shift;
401 1255   100     5382 my $options = shift @_ || {};
402              
403             # carp("Language $lang, number $number");
404              
405 1255 100 100     6815 if ($number !~ m/^$RE{num}{int}$/ && $number !~ m/^$RE{num}{real}$/)
406             {
407 2         1163 carp("Number $number doesn't appear to be a real number, sorry");
408 2         72 return undef;
409             }
410              
411 1253         205806 $number =~ s/\+//g;
412 1253 50       3769 unless ( exists $NUMBER_NAMES{$lang} )
413             {
414 0         0 carp("Numbers for language $lang are unknown, sorry");
415 0         0 return undef;
416             }
417              
418 1253         2539 my $hash = $NUMBER_NAMES{$lang};
419            
420 1253         54162 my $max = max(keys %$hash);
421 1253 100       8967 if ($number > $max)
422             {
423 1         207 carp("Number $number is above maximum $max and not supported, sorry");
424 1         9 return undef;
425             }
426              
427 1252 100       3327 return find_known($lang, $hash, $number, $options) if defined find_known($lang, $hash, $number, $options);
428              
429 154 100       652 return "$MINUS " . number_to_slavic($lang, $1) if $number =~ m/-\s*(.*)/;
430              
431             # normalize to scientific notation if exponent is specified, then expand
432 145 50       776 if ($number =~ m/$RE{num}{real}{-sep=>'[,.]?'}{-keep}/)
433             {
434 145         32240 my $power = $8;
435 145         444 my $num = $3;
436 145 100       508 if ($power)
437             {
438 1         5 while ($num >= 10)
439             {
440 1         3 $num /= 10;
441 1         4 $power++;
442             }
443              
444 1 50       7 return find_known($lang, $hash, $number, $options) if defined find_known($lang, $hash, $number, $options);
445              
446 1   66     11 while ($num && int $num != $num)
447             {
448 1         4 $num *= 10;
449 1         6 $power--;
450             }
451            
452 1         6 $number = $num . '0' x $power;
453              
454 1 50       3 return find_known($lang, $hash, $number, $options) if defined find_known($lang, $hash, $number, $options);
455              
456 1         6 deb("finally, got power $power and number $num => $number\n");
457             }
458             }
459            
460 145 50       1751 if (LANG_BG eq $lang)
461             {
462             # build the intepretation from the number's digits
463 145         280 my @components;
464 145         660 my @parts = split /[.,]/, $number, 2;
465 145   50     861 $parts[1] ||= ''; # always provide a floating part if it doesn't come with the number
466              
467 145         362 my $n = $parts[0];
468 145         238 my @n;
469 145         430 while ($n)
470             {
471 275         464 my $old_n = $n;
472 275         666 my $triplet = substr $n, -3, 3, '';
473 275         1003 deb("grabbing triplet from $old_n resulting in $n and $triplet\n");
474 275         753 push @n, $triplet;
475             }
476              
477 145         443 my $out = bulgarian_triplets($hash, \@n, $options);
478             # clean spaces
479 145         681 $out =~ s/^\s*//;
480 145         1242 $out =~ s/\s*$//;
481 145         1053 $out =~ s/\s+/ /g;
482             # fix annoying bugs
483            
484             # remove leading и
485 145         594 $out =~ s/^и\s+//g;
486             # fix една хиляди
487 145         336 $out =~ s/^една хиляди/хиляда/;
488 145         1206 return $out;
489             }
490            
491 0         0 carp("The number representation of $number in language '$lang' couldn't be found, sorry");
492 0         0 my $opt_string = join '//', sort keys %$options;
493 0 0       0 $opt_string = "//$opt_string" if $opt_string;
494 0         0 return "$number$opt_string";
495             }
496              
497             #
498             # OO Methods
499             #
500             sub new {
501 70     70 0 153310 my $class = shift;
502 70         106 my $number = shift;
503 70         103 my $lang = shift;
504 70         307 bless { num => $number, lang => $lang}, $class;
505             }
506              
507             sub parse {
508 0     0 0 0 my $self = shift;
509 0 0       0 if ( $_[0] )
510             {
511 0         0 $self->{num} = shift;
512             }
513 0 0       0 if ( $_[1] )
514             {
515 0         0 $self->{lang} = shift;
516             }
517 0         0 $self;
518             }
519              
520             sub get_string
521             {
522 70     70 0 2675 my $self = shift;
523 70         199 return number_to_slavic($self->{lang}, $self->{num});
524             }
525              
526             sub get_ordinate
527             {
528 0     0 0 0 my $self = shift;
529 0         0 return ordinate_to_slavic($self->{lang}, $self->{num});
530             }
531              
532             ### cperl-mode doesn't like this, so I put it at the end
533             sub interpolate_string
534             {
535 861     861 0 1608 my $lang = shift;
536 861         1465 my $data = shift;
537              
538            
539 861   100     3955 while ($data =~ m/\[$RE{num}{real}{-sep=>'[,.]?'}\]+/ || # [number]
540             $data =~ m/\{$RE{num}{real}{-sep=>'[,.]?'}\}+/) # {number}
541             {
542 680         224943 $data =~ s/\{
543             \{
544             $RE{num}{dec}{-sep=>'[,.]?'}{-keep}
545             \}
546             ([:\w]+)?
547             \}
548             /
549             number_to_slavic($lang,
550             $1,
551 196         48639 { map { $_ => 1 } split(':', $11) }
  280         1406  
552             )
553             /giex;
554              
555 680         101531 $data =~ s/
556             {
557             $RE{num}{dec}{-sep=>'[,.]?'}{-keep}
558             }
559 752         115121 /number_to_slavic($lang, $1)/giex;
560              
561 680         33179 $data =~ s/
562             \[
563             \[
564             $RE{num}{real}{-sep=>'[,.]?'}{-keep}
565             \]
566             ([:\w]+)?
567             \]
568             /
569             ordinate_to_slavic(
570             $lang,
571             $1,
572 0         0 { map { $_ => 1 } split(':', $2) }
  0         0  
573             )
574             /giex;
575              
576 680         142379 $data =~ s/
577             \[
578             $RE{num}{real}{-sep=>'[,.]?'}{-keep}
579             \]
580 128         24604 /ordinate_to_slavic($lang, $1)/giex;
581             }
582 861         397663 return $data;
583             }
584              
585              
586             1;
587              
588             __END__