File Coverage

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


line stmt bran cond sub pod time code
1             package Lingua::Slavic::Numbers;
2 7     7   106055 use strict;
  7         21  
  7         2655  
3              
4 7     7   53 use Carp qw(carp);
  7         16  
  7         480  
5 7     7   47 use List::Util qw(max);
  7         27  
  7         922  
6 7     7   18283 use Data::Dumper;
  7         153364  
  7         711  
7 7     7   8941 use Regexp::Common qw /number/;
  7         36839  
  7         39  
8 7     7   41917 use Exporter;
  7         16  
  7         405  
9 7     7   8498 use utf8;
  7         72  
  7         69  
10 7     7   310 use vars qw( $VERSION $DEBUG @ISA @EXPORT_OK @EXPORT);
  7         16  
  7         1417  
11 7         543 use vars qw(
12             %INFLEXIONS
13             %NUMBER_NAMES
14             %ORDINALS
15             $OUTPUT_DECIMAL_DELIMITER
16             $MINUS
17 7     7   42 );
  7         16  
18              
19 7     7   154 use constant LANG_BG => 'bg';
  7         15  
  7         511  
20              
21 7     7   34 use constant NO_CONJUNCTIONS => 'noconj';
  7         13  
  7         444  
22 7     7   34 use constant FEMININE_GENDER => 'fem';
  7         13  
  7         296  
23 7     7   435 use constant MASCULINE_GENDER => 'man';
  7         13  
  7         408  
24 7     7   42 use constant NEUTRAL_GENDER => 'neu';
  7         10  
  7         46545  
25            
26             $VERSION = 0.03;
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 4337 sub deb { print @_ if $DEBUG }
174              
175             sub ordinate_to_slavic
176             {
177 148     148 1 5632 my $lang = shift;
178 148         636 my $number = shift;
179 148   50     759 my $options = shift @_ || {};
180              
181 148 50       1032 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         267 my $hash = $ORDINALS{$lang};
188              
189 148 100       3090 unless ( $number >= 0 )
190             {
191 2         369 carp("Ordinates must not be negative");
192 2         12 return undef;
193             }
194              
195 146 50       395 unless ( int $number == $number )
196             {
197 0         0 carp("Ordinates can only be integers");
198 0         0 return undef;
199             }
200              
201 146 100       2998 return $hash->{$number} if exists $hash->{$number};
202              
203 9         3323 my $max = max(keys %$hash);
204 9 50       44 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       30 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         14 my $out = '';
221            
222 9         23 my $bot = $number % 10;
223 9         15 my $top = $number - $bot;
224 9         27 return interpolate_string($lang, "{{$top}@{[NO_CONJUNCTIONS()]}} и [$bot]");
  9         57  
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 210 my $lang = LANG_BG;
234 145         207 my $hash = shift;
235 145         189 my $tri = shift;
236 145   50     389 my $options = shift @_ || {};
237              
238 145         31836 my $pow = 0;
239 145         517 foreach my $t (@$tri) # this is a triplet
240             {
241 275         644 my $some_left = scalar @$tri > $pow/3; # true if we're not at end of @$tri yet
242             # convert to scientific notation
243 275         366 my $canon_power = $pow;
244 275         322 my $canon_t = $t;
245 275 50       1495 if ($t =~ m/$RE{num}{real}{-sep=>'[,.]?'}{-keep}/)
246             {
247 275   50     71253 $canon_power = $8 || 0;
248 275         540 $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         3255 my $canon = "${canon_t}e$canon_power";
260            
261 275         942 deb("Working on triplet $t (power $pow, canonical $canon)\n");
262 275 50       1046 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       201 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         138 $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         336 $t =~ s/(\d+)e(\d+)/$1 . 0 x $2/e;
  0         0  
284            
285             # first, set up the qualifier
286 187         469 deb("getting qualifier and gender for $t\n");
287            
288 187         313 my $qualifier = '';
289 187         244 my $inflexion = '';
290 187         262 my $extra_а = '';
291              
292 187 100       444 if ($pow)
293             {
294 84         271 $qualifier = number_to_slavic($lang, "1e$pow");
295 84         210 $inflexion = MASCULINE_GENDER; # all but thousands are masculine
296 84         126 $extra_а = 'а'; # and all have 'a' when plural (singular cases are caught by the %NUMBER_NAMES hash)
297            
298 84 100       221 if ($pow eq 3) # thousands are a special case for gender, being feminine
299             {
300 62         141 $qualifier = 'хиляди';
301 62         129 $inflexion = FEMININE_GENDER;
302 62         103 $extra_а = ''; # no extra 'a' for thousands
303             }
304             }
305              
306 187         316 $qualifier .= $extra_а;
307            
308 187         692 my @n = split //, $t;
309 187         671 shift @n while 0 == $n[0]; # remove the leading zeroes
310 187         690 deb("decomposing $t, result [@n]\n");
311 187         297 my @inter;
312 187         439 while (@n)
313             {
314 358         647 my $decompose_num = shift @n;
315 358         647 my $decompose_pow = scalar @n;
316              
317             # grab the next digit for numbers 10 .. 20
318 358 100 100     2005 if (($decompose_num == 1 && scalar @n == 1) ||
      100        
      100        
      66        
319             ($decompose_num == 2 && scalar @n == 1 && $n[0] == 0))
320             {
321 32         102 $decompose_num .= shift @n;
322 32         51 $decompose_pow = 0;
323             }
324              
325 358 100       741 next unless $decompose_num; # skip zeroes
326              
327 301         375 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 66     2278 if (
      33        
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         262 $extra_и = ' ';
340             }
341            
342 301         1810 push @inter, sprintf("%s{%s%s}", $extra_и, $decompose_num, '0'x$decompose_pow);
343             }
344              
345 187         2789 my @inter_options = (NO_CONJUNCTIONS);
346 187 100       413 push @inter_options, $inflexion if $inflexion;
347 187         359 my $inter_options = join ':', @inter_options;
348            
349 187         1570 $inter[-1] =~ s/({.*})/{$1$inter_options}/;
350              
351 187         425 my $inter = join(' ', @inter);
352 187         528 deb("bulgarian_triplets calling interpolate_string with [$inter]\n");
353 187         437 $inter = interpolate_string($lang, $inter);
354              
355 187 50       516 if (defined $inter)
356             {
357 187         424 $t = $inter;
358             # add the final conjunction if requested
359 187 100       2397 $t =~ s/\s(\w+)$/ и $1/ unless $options->{NO_CONJUNCTIONS()};
360 187 100       626 $t .= " ${qualifier}" if $qualifier; # add the qualifier
361 187         724 $t =~ s/^\s+//g; # replace leading/ending spaces
362 187         1205 $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         932 $pow+=3;
371             }
372              
373 145         561 @$tri = reverse @$tri;
374            
375 145         634 return "@$tri";
376             }
377              
378             sub find_known
379             {
380 2358     2358 0 4393 my $lang = shift;
381 2358         3828 my $hash = shift;
382 2358         3170 my $number = shift;
383 2358   50     5697 my $options = shift @_ || {};
384              
385 2358         4086 foreach my $gender (FEMININE_GENDER(), MASCULINE_GENDER())
386             {
387 4672 100 100     20048 return $INFLEXIONS{$lang}->{$gender}->{$number}
388             if (exists $options->{$gender} &&
389             exists $INFLEXIONS{$lang}->{$gender}->{$number});
390             }
391            
392 2308 100       20345 return $hash->{$number} if exists $hash->{$number};
393              
394 156         486 return undef;
395             }
396              
397             sub number_to_slavic
398             {
399 1258     1258 1 38442 my $lang = shift;
400 1258         2586 my $number = shift;
401 1258   100     22442 my $options = shift @_ || {};
402              
403             # carp("Language $lang, number $number");
404              
405 1258 100 66     17658 if ($number !~ m/^$RE{num}{int}$/ && $number !~ m/^$RE{num}{real}$/)
406             {
407 2         1086 carp("Number $number doesn't appear to be a real number, sorry");
408 2         14 return undef;
409             }
410              
411 1256         247756 $number =~ s/\+//g;
412 1256 50       3864 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 1256         2123 my $hash = $NUMBER_NAMES{$lang};
419            
420 1256         42205 my $max = max(keys %$hash);
421 1256 100       8512 if ($number > $max)
422             {
423 1         227 carp("Number $number is above maximum $max and not supported, sorry");
424 1         9 return undef;
425             }
426              
427 1255 100       3119 return find_known($lang, $hash, $number, $options) if defined find_known($lang, $hash, $number, $options);
428              
429 154 100       550 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       695 if ($number =~ m/$RE{num}{real}{-sep=>'[,.]?'}{-keep}/)
433             {
434 145         37951 my $power = $8;
435 145         323 my $num = $3;
436 145 100       414 if ($power)
437             {
438 1         5 while ($num >= 10)
439             {
440 1         3 $num /= 10;
441 1         4 $power++;
442             }
443              
444 1 50       3 return find_known($lang, $hash, $number, $options) if defined find_known($lang, $hash, $number, $options);
445              
446 1   66     9 while ($num && int $num != $num)
447             {
448 1         3 $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         8 deb("finally, got power $power and number $num => $number\n");
457             }
458             }
459            
460 145 50       14463 if (LANG_BG eq $lang)
461             {
462             # build the intepretation from the number's digits
463 145         227 my @components;
464 145         617 my @parts = split /[.,]/, $number, 2;
465 145   50     706 $parts[1] ||= ''; # always provide a floating part if it doesn't come with the number
466              
467 145         223 my $n = $parts[0];
468 145         175 my @n;
469 145         427 while ($n)
470             {
471 275         377 my $old_n = $n;
472 275         531 my $triplet = substr $n, -3, 3, '';
473 275         859 deb("grabbing triplet from $old_n resulting in $n and $triplet\n");
474 275         893 push @n, $triplet;
475             }
476              
477 145         374 my $out = bulgarian_triplets($hash, \@n, $options);
478             # clean spaces
479 145         690 $out =~ s/^\s*//;
480 145         1102 $out =~ s/\s*$//;
481 145         914 $out =~ s/\s+/ /g;
482             # fix annoying bugs
483            
484             # remove leading и
485 145         796 $out =~ s/^и\s+//g;
486             # fix една хиляди
487 145         291 $out =~ s/^една хиляди/хиляда/;
488 145         1033 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 31958 my $class = shift;
502 70         107 my $number = shift;
503 70         107 my $lang = shift;
504 70         487 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 14797 my $self = shift;
523 70         270 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 1321 my $lang = shift;
536 861         1241 my $data = shift;
537              
538            
539 861   100     5445 while ($data =~ m/\[$RE{num}{real}{-sep=>'[,.]?'}\]+/ || # [number]
540             $data =~ m/{$RE{num}{real}{-sep=>'[,.]?'}}+/) # {number}
541             {
542 683         274392 $data =~ s/{
543             {
544             $RE{num}{dec}{-sep=>'[,.]?'}{-keep}
545             }
546             ([:\w]+)?
547             }
548             /
549 280         1127 number_to_slavic($lang,
550             $1,
551 196         43492 { map { $_ => 1 } split(':', $11) }
552             )
553             /giex;
554              
555 683         147494 $data =~ s/
556             {
557 755         179725 $RE{num}{dec}{-sep=>'[,.]?'}{-keep}
558             }
559             /number_to_slavic($lang, $1)/giex;
560              
561 683         38235 $data =~ s/
562             \[
563             \[
564             $RE{num}{real}{-sep=>'[,.]?'}{-keep}
565             \]
566             ([:\w]+)?
567             \]
568             /
569 0         0 ordinate_to_slavic(
570             $lang,
571             $1,
572 0         0 { map { $_ => 1 } split(':', $2) }
573             )
574             /giex;
575              
576 683         191225 $data =~ s/
577             \[
578 128         52930 $RE{num}{real}{-sep=>'[,.]?'}{-keep}
579             \]
580             /ordinate_to_slavic($lang, $1)/giex;
581             }
582 861         543851 return $data;
583             }
584              
585              
586             1;
587              
588             __END__