File Coverage

blib/lib/Convert/Base81.pm
Criterion Covered Total %
statement 170 186 91.4
branch 24 34 70.5
condition 0 3 0.0
subroutine 15 16 93.7
pod 10 10 100.0
total 219 249 87.9


line stmt bran cond sub pod time code
1             package Convert::Base81;
2              
3 8     8   480639 use 5.016001;
  8         90  
4 8     8   40 use warnings;
  8         13  
  8         283  
5 8     8   37 use strict;
  8         13  
  8         159  
6              
7 8     8   36 use Carp;
  8         22  
  8         567  
8 8         990 use Math::Int128 qw(uint128 uint128_to_number
9 8     8   3612 uint128_add uint128_divmod uint128_left uint128_mul);
  8         57149  
10              
11             #use Smart::Comments q(###);
12              
13             our $VERSION = '1.00';
14              
15 8     8   79 use Exporter qw(import);
  8         16  
  8         18797  
16              
17             our %EXPORT_TAGS = (
18             pack => [ qw(b3_pack81 b9_pack81 b27_pack81) ],
19             unpack => [ qw(b3_unpack81 b9_unpack81 b27_unpack81) ],
20             );
21              
22             our @EXPORT_OK = (
23             qw(base81_check base81_encode base81_decode rwsize),
24             @{ $EXPORT_TAGS{pack} },
25             @{ $EXPORT_TAGS{unpack} },
26             );
27              
28             #
29             # Add an :all tag automatically.
30             #
31             $EXPORT_TAGS{all} = [@EXPORT_OK];
32              
33             =head1 NAME
34              
35             Convert::Base81 - Encoding and decoding to and from Base 81 strings
36              
37             =head1 SYNOPSIS
38              
39             use Convert::Base81;
40            
41             my $encoded = Convert::Base81::encode($data);
42             my $decoded = Convert::Base81::decode($encoded);
43              
44             or
45              
46             use Convert::Base81 qw(base81_encode base81_decode);
47            
48             my $encoded = base81_encode($data);
49             my $decoded = base81_decode($encoded);
50              
51             =head1 DESCRIPTION
52              
53             This module implements a I conversion for encoding binary
54             data as text. This is done by interpreting each group of fifteen bytes
55             as a 120-bit integer, which is then converted to a seventeen-digit base 81
56             representation using the alphanumeric characters 0-9, A-Z, and a-z, in
57             addition to the punctuation characters !, #, $, %, (, ), *,
58             +, -, ;, =, ?, @, ^, _, {, |, }, and ~, in that order, characters that
59             are safe to use in JSON and XML formats.
60              
61             This creates a string that is (1.2666) larger than the original
62             data, making it more efficient than L's 3-to-4 ratio (1.3333)
63             but slightly less so than the efficiency of L's 4-to-5 ratio (1.25).
64              
65             It does have the advantage of a natural ternary system: if your data is
66             composed of only three, or nine, or twenty-seven distinct values, its
67             size can be compressed instead of expanded, and this module has functions
68             that will do that.
69              
70             use Convert::Base81 qw(b3_pack81 b3_unpack81);
71              
72             my $input_string = q(rrgrbgggggrrgbrrbbbbrbrgggrggggg);
73             my $b81str = b3_pack81("rgb", $input_string);
74              
75             The returned string will be one-fourth the size of the original. Equivalent
76             functions exist for 9-digit and 27-digit values, which will return strings
77             one-half and three-fourths the size of the original, respectively.
78              
79             =cut
80              
81             #
82             # character value
83             # 0..9: 0..9
84             # A..Z: 10..35
85             # a..z: 36..61
86             # punc: 62..80
87             #
88             # Or, in a 9x9 tabular form, displaying the trits (0, 1, -):
89             #
90             # | 0 1 2 3 4 5 6 7 8
91             # +-------------------------------------------------------------
92             # ('0'..'8') 0 | 0000 0001 000- 0010 0011 001- 00-0 00-1 00--
93             # ('9'..'H') 9 | 0100 0101 010- 0110 0111 011- 01-0 01-1 01--
94             # ('I'..'Q') 18 | 0-00 0-01 0-0- 0-10 0-11 0-1- 0--0 0--1 0---
95             # ('R'..'Z') 27 | 1000 1001 100- 1010 1011 101- 10-0 10-1 10--
96             # ('a'..'i') 36 | 1100 1101 110- 1110 1111 111- 11-0 11-1 11--
97             # ('j'..'r') 45 | 1-00 1-01 1-0- 1-10 1-11 1-1- 1--0 1--1 1---
98             # ('s'..'!') 54 | -000 -001 -00- -010 -011 -01- -0-0 -0-1 -0--
99             # ('#'..';') 63 | -100 -101 -10- -110 -111 -11- -1-0 -1-1 -1--
100             # ('='..'~') 72 | --00 --01 --0- --10 --11 --1- ---0 ---1 ----
101             #
102             #
103             # Take a number from 0 to 80, and turn it into a character.
104             #
105              
106             my @b81_encode = ('0' .. '9', 'A' .. 'Z', 'a' .. 'z',
107             '!', '#', '$', '%', '(', ')', '*', '+', '-', ';',
108             '=', '?', '@', '^', '_', '{', '|', '}', '~');
109              
110              
111             my @b81_decode = (
112             -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
113             -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
114             -1, 62, -1, 63, 64, 65, -1, -1, 66, 67, 68, 69, -1, 70, -1, -1,
115             0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, 71, -1, 72, -1, 73,
116             74, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
117             25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, 75, 76,
118             -1, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50,
119             51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 77, 78, 79, 80, -1,
120             );
121              
122             my %rwsizes = (I128 => [15, 19], I64 => [7, 9]);
123             my $rwkey = "I128";
124              
125             =head1 FUNCTIONS
126              
127             =head3 base81_check
128              
129             Examine a string for characters that fall outside the Base 81 character set.
130              
131             Returns the first character position that fails the test, or -1 if no characters fail.
132              
133             if (my $d = base81_check($base81str) != -1)
134             {
135             carp "Incorrect character at position $d; cannot decode input string";
136             return undef;
137             }
138              
139             =cut
140              
141             sub base81_check
142             {
143 0     0 1 0 my($str) = @_;
144 0         0 my(@chars) = split(//, $str);
145              
146             #
147             ### Check validity of: $str
148             ### Which becomes array: @chars
149             #
150 0         0 for my $j (0 .. $#chars)
151             {
152 0         0 my $o = ord($chars[$j]);
153 0 0 0     0 return $j if ($o > 0x7f or $b81_decode[$o] == -1);
154             }
155 0         0 return -1;
156             }
157              
158             =head3 base81_encode
159              
160             =head3 Convert::Base81::encode
161              
162             Converts input data to Base81 test.
163              
164             This function may be exported as C into the caller's namespace.
165              
166             my $datalen = length($data);
167             my $encoded = base81_encode($data);
168              
169             Or, if you want to have managable lines, read 45 bytes at a time and
170             write 57-character lines (remembering that C takes 15 bytes
171             at a time and encodes to 19 bytes). Remember to save the original length
172             in case the data had to be padded out to a multiple of 15.
173              
174             =cut
175              
176             sub encode
177             {
178 10     10 1 5692 my($plain) = @_;
179 10         17 my @mlist;
180 10         25 my($readsize, $writesize) = rwsize();
181 10         52 my $imod = uint128(81);
182 10         23 my $rem = uint128();
183              
184             #
185             # Extra zero bytes to bring the length up to the read size.
186             #
187 10         27 my $extra = -length($plain) % $readsize;
188 10         33 $plain .= "\0" x $extra;
189              
190 10         61 for my $str7 (unpack "(a${readsize})*", $plain)
191             {
192 44         79 my $ptotal = uint128(0);
193 44         85 my @tmplist = (0) x $writesize;
194              
195             #
196             # Calculate $ptotal = ($ptotal << 8) + $c;
197             #
198 44         111 for my $c (unpack('C*', $str7))
199             {
200 524         1161 uint128_left($ptotal, $ptotal, 8);
201 524         865 uint128_add($ptotal, $ptotal, uint128($c));
202             }
203              
204             #
205             ### rtotal: "$ptotal"
206             #
207             # Calculate the mod 81 list.
208             #
209 44         79 for my $j (reverse 0 .. $writesize - 1)
210             {
211 666         1009 uint128_divmod($ptotal, $rem, $ptotal, $imod);
212 666         1098 $tmplist[$j] = uint128_to_number($rem);
213             }
214              
215 44         118 push @mlist, @tmplist;
216             }
217              
218 10         22 return join "", map{$b81_encode[$_]} @mlist;
  666         927  
219             }
220              
221             *base81_encode = \&encode;
222              
223             =head3 base81_decode
224              
225             =head3 Convert::Base81::decode
226              
227             Converts the Base81-encoded string back to bytes. Any spaces, linebreaks, or
228             other whitespace are stripped from the string before decoding.
229              
230             This function may be exported as C into the caller's namespace.
231              
232             If your original data wasn't an even multiple of fifteen in length, the
233             decoded data will have some padding with null bytes ('\0'), which can be removed.
234              
235             #
236             # Decode the string and compare its length with the length of the original data.
237             #
238             my $decoded = base81_decode($data);
239             my $padding = length($decoded) - $datalen;
240             chop $decoded while ($padding-- > 0);
241              
242             =cut
243              
244             sub decode
245             {
246 10     10 1 6663 my($encoded) = @_;
247 10         35 my($readsize, $writesize) = rwsize();
248 10         34 my $imul = uint128(81);
249 10         24 my $rem = uint128();
250              
251 10         42 $encoded =~ tr[ \t\r\n\f][]d;
252              
253 10         26 my $extra = -length($encoded) % $writesize;
254 10 50       25 $encoded .= '0' x $extra if ($extra != 0);
255              
256 10         16 my @mlist;
257              
258 10         56 for my $str9 (unpack "(a${writesize})*", $encoded)
259             {
260 44         69 my $etotal = uint128(0);
261 44         105 my @tmplist = (q(0)) x $readsize;
262              
263 44         88 for my $c (unpack('C*', $str9))
264             {
265 666         980 my $iadd = uint128($b81_decode[$c]);
266 666         1158 uint128_mul($etotal, $etotal, $imul);
267 666         940 uint128_add($etotal, $etotal, $iadd);
268             }
269              
270             #
271             ### Read string: $str9
272             ### total = sprintf("0x%0x", $etotal)
273             #
274 44         88 for my $j (reverse 0 .. $readsize - 1)
275             {
276 524         1066 uint128_divmod($etotal, $rem, $etotal, uint128(256));
277 524         763 $tmplist[$j] = uint128_to_number($rem);
278             }
279 44         153 push @mlist, @tmplist;
280             }
281              
282 10         23 return join "", map{chr($_)} @mlist;
  524         776  
283             }
284              
285             *base81_decode = \&decode;
286              
287             =head3 rwsize
288              
289             By default, the C function reads 15 bytes, and writes 19,
290             resulting in an expansion ratio of 1.2666. It does require 128-bit
291             integers to calculate this, which is simulated in a library. If your
292             decoding destination doesn't have a library available, the encode
293             function can be reduced to reading 7 bytes and writing 9, giving an
294             expansion ratio of 1.2857. This only requires 64-bit integers, which
295             many environments can handle.
296              
297             Note that this does not affect the operation of this module, which
298             will use 128-bit integers regardless.
299              
300             To set the smaller size, use:
301              
302             my($readsize, $writesize) = rwsize("I64");
303              
304             To set it back:
305              
306             my($readsize, $writesize) = rwsize("I128");
307              
308             To simply find out the current read/write sizes:
309              
310             my($readsize, $writesize) = rwsize();
311              
312             Obviously, if you use the smaller sized encoding, you need to
313             send that information along with the encoded data.
314              
315             =cut
316              
317             sub rwsize
318             {
319 21 100   21 1 147 if (scalar @_ > 0)
320             {
321 1         2 my $key = $_[0];
322              
323 1 50       4 if (exists $rwsizes{$key})
324             {
325 1         3 $rwkey = $key;
326             }
327             else
328             {
329 0         0 carp "Unknown key '$key'";
330             }
331             }
332              
333 21         24 return @{$rwsizes{$rwkey}};
  21         62  
334             }
335              
336             =head2 the 'pack' tag
337              
338             If your data falls into a domain of 3, 9, or 27 characters, then the Base81
339             format can compress your data to 1/4, 1/2, or 3/4, of its original size.
340              
341             =head3 b3_pack81
342              
343             $three_chars = "01-";
344              
345             b3_pack81($three_chars, $inputstring);
346              
347             or
348              
349             b3_pack81($three_chars, \@inputarray);
350              
351             Transform a string (or array) consisting of three and only three
352             characters into a Base 81 string.
353              
354             $packedstr = b3_pack81("01-", "01-0-1011000---1");
355              
356             or
357              
358             $packedstr = b3_pack81("01-", [qw(0 1 - 0 - 1 0 1 1 0 0 0 - - - 1)]);
359              
360             =cut
361              
362             sub b3_pack81
363             {
364 12     12 1 4783 my($c3, $data) = @_;
365 12         20 my @blist;
366              
367             #
368             # Set up the conversion hash and convert the column list
369             # into two-bit values.
370             #
371 12         17 my $ctr = 0;
372 12         34 my %convert3 = map{ $_ => $ctr++} split //, $c3;
  36         83  
373              
374 12 50       35 if (ref $data eq 'ARRAY')
375             {
376 0         0 @blist = map{$convert3{$_}} @{ $data };
  0         0  
  0         0  
377             }
378             else
379             {
380 12         62 @blist = map{$convert3{$_}} split //, $data;
  118         143  
381             }
382              
383 12         48 push @blist, (substr $c3, 0, 1) x (-scalar(@blist) % 4);
384              
385 12         19 my $str = "";
386              
387 12         36 for my $j (1 .. scalar(@blist) >> 2)
388             {
389 32         51 my($z, $y, $x, $w) = splice(@blist, 0, 4);
390 32         80 $str .= $b81_encode[27*$z + 9*$y + 3*$x + $w];
391             }
392              
393 12         65 return $str;
394             }
395              
396             =head3 b9_pack81
397              
398             b9_pack81("012345678", $inputstring);
399              
400             or
401              
402             b9_pack81("012345678", \@inputarray);
403              
404             Transform a string (or array) consisting of up to nine
405             characters into a Base 81 string.
406              
407             $packedstr = b9_pack81("012345678", "6354822345507611");
408              
409             or
410              
411             $packedstr = b9_pack81("012345678", [qw(6 3 5 4 8 2 2 3 4 5 5 0 7 6 1 1)]);
412              
413             =cut
414              
415             sub b9_pack81
416             {
417 8     8 1 2570 my($c9, $data) = @_;
418 8         12 my @blist;
419              
420             #
421             # Set up the conversion hash and collect the input data.
422             #
423 8         15 my $ctr = 0;
424 8         30 my %x9 = map{ $_ => $ctr++} split //, $c9;
  72         131  
425              
426 8 50       31 if (ref $data eq 'ARRAY')
427             {
428 0         0 @blist = map{$x9{$_}} @{ $data };
  0         0  
  0         0  
429             }
430             else
431             {
432 8         35 @blist = map{$x9{$_}} split //, $data;
  50         70  
433             }
434              
435             #
436             ### Input data: @blist
437             #
438             # Pad by a zero character if the data length is odd.
439             #
440 8 100       112 push @blist, substr $c9, 0, 1 if (scalar(@blist) % 2);
441              
442 8         17 my $str = "";
443              
444 8         25 for my $j (1 .. scalar(@blist) >> 1)
445             {
446 26         39 my($z, $y) = splice(@blist, 0, 2);
447 26         72 $str .= $b81_encode[9*$z + $y];
448             }
449              
450 8         29 return $str;
451             }
452              
453             =head3 b27_pack81
454              
455             b27_pack81($twenty7_chars, $inputstring);
456              
457             or
458              
459             b27_pack81($twenty7_chars, \@inputarray);
460              
461             Transform a string (or array) consisting of up to twenty-seven
462             characters into a Base 81 string.
463              
464             $base27str = join("", ('a' .. 'z', '_'));
465             $packedstr = b27_pack81($base27str, "anxlfqunxpkswqmei_qh_zkr");
466              
467             or
468              
469             $packedstr = b27_pack81($base27str, [qw(a n x l f q u n x p k s w q m e i _ q h _ z k r)]);
470              
471             =cut
472              
473             sub b27_pack81
474             {
475 8     8 1 2616 my($c27, $data) = @_;
476 8         13 my @blist;
477             my @clist;
478              
479             #
480             # Set up the conversion hash and collect the input data.
481             ### b27_pack81 input data: $data
482             #
483 8         12 my $ctr = 0;
484 8         41 my %x27 = map{ $_ => $ctr++} split //, $c27;
  216         444  
485              
486 8 50       41 if (ref $data eq 'ARRAY')
487             {
488 0         0 @blist = map{$x27{$_}} @{ $data };
  0         0  
  0         0  
489             }
490             else
491             {
492 8         34 @blist = map{$x27{$_}} split //, $data;
  69         90  
493             }
494              
495             #
496             ### Input data: @blist
497             #
498             # Save any leftover characters in advance of taking four at a time.
499             #
500 8         28 my @tail = splice(@blist, scalar @blist - scalar(@blist) % 4);
501              
502             #
503             # z0 y0 z1 y1 z2 y2 z3 y3
504             # |ooo p|pp qq|q rrr|sss t|tt uu|u vvv|
505             # | | | | | | |
506             #
507             #
508             # Take in four base-27 characters, write out three base-81 characters.
509             #
510 8         28 while (my(@x4) = splice(@blist, 0, 4))
511             {
512 14         25 my $x = 19683 * $x4[0] + 729 * $x4[1] + 27 * $x4[2] + $x4[3];
513 14         14 my @mods;
514              
515 14         20 for (1 .. 3)
516             {
517 42         65 unshift @mods, $b81_encode[$x % 81];
518 42         62 $x = int $x/81;
519             }
520              
521 14         36 push @clist, @mods;
522             }
523              
524             #
525             ### Remaining portion of input: @tail
526             #
527 8 100       19 if (scalar @tail)
528             {
529 6         13 my $x = $tail[0] * 3;
530              
531 6 100       15 if (scalar @tail == 2)
532             {
533 3         11 $x += $tail[1]/9;
534 3         7 push @clist, $b81_encode[$x];
535 3         7 $x = ($tail[1] % 9) * 9;
536             }
537              
538 6 100       25 if (scalar @tail == 3)
539             {
540 2         5 $x += $tail[1]/9;
541 2         3 push @clist, $b81_encode[$x];
542 2         7 $x = (($tail[1] % 9) * 9) + $tail[2]/3;
543 2         3 push @clist, $b81_encode[$x];
544 2         2 $x = $tail[2] % 3;
545             }
546 6 100       18 push @clist, $b81_encode[$x] if ($x != 0);
547             }
548              
549 8         46 return join "", @clist;
550             }
551              
552             =head2 the 'unpack' tag
553              
554             Naturally, data packed must needs be unpacked, and the following three functions
555             perform that duty.
556              
557             =head3 b3_unpack81
558              
559             Transform a Base81 string back into a string (or array) using
560             only three characters.
561              
562             $data = b3_unpack81("012", "d`+qxW?q");
563              
564             or
565              
566             @array = b3_unpack81("012", "d`+qxW?q");
567              
568             =cut
569              
570             sub b3_unpack81
571             {
572 7     7 1 3163 my($c3, $base81str) = @_;
573              
574 7         15 $base81str =~ tr[ \t\r\n\f][]d;
575              
576 7         22 my @char81 = split(//, $base81str);
577 7         15 my @val81 = map{$b81_decode[ord($_)]} @char81;
  25         41  
578              
579             #
580             # Set up the conversion array on the fly.
581             #
582 7         13 my(@convert3) = split(//, $c3);
583 7         9 my @clist;
584              
585 7         13 for my $x (@val81)
586             {
587 25         43 push @clist, $convert3[int($x/27)];
588 25         39 push @clist, $convert3[int(($x % 27)/9)];
589 25         35 push @clist, $convert3[int(($x % 9)/3)];
590 25         36 push @clist, $convert3[$x % 3];
591             }
592              
593 7 50       37 return wantarray? @clist: join "", @clist;
594             }
595              
596             =head3 b9_unpack81
597              
598             Transform a Base81 string back into a string (or array) using
599             only nine characters.
600              
601             $nine_chars = join "", ('0' .. '8'');
602              
603             $data = b27_unpack81($nine_chars, "d`+qxW?q");
604              
605             or
606              
607             @array = b27_unpack81($nine_chars, "d`+qxW?q");
608              
609             =cut
610              
611             sub b9_unpack81
612             {
613 6     6 1 2575 my($c9, $base81str) = @_;
614              
615 6         16 $base81str =~ tr[ \t\r\n\f][]d;
616              
617 6         20 my @char81 = split(//, $base81str);
618 6         13 my @val81 = map{$b81_decode[ord($_)]} @char81;
  22         37  
619              
620             #
621             # Set up the conversion array on the fly because
622             # the don't-care character is changeable.
623             #
624 6         16 my(@x9) = split(//, $c9);
625 6         10 my @clist;
626              
627 6         9 for my $x (@val81)
628             {
629 22         42 push @clist, $x9[int($x/9)];
630 22         36 push @clist, $x9[$x % 9];
631             }
632              
633 6 50       31 return wantarray? @clist: join "", @clist;
634             }
635              
636              
637             =head3 b27_unpack81
638              
639             Transform a Base81 string back into a string (or array) using
640             only twenty seven characters.
641              
642             $twenty7_chars = join("", ('a' .. 'z', '&'));
643              
644             $data = b27_unpack81($twenty7_chars, "d`+qxW?q");
645              
646             or
647              
648             @array = b27_unpack81($twenty7_chars, "d`+qxW?q");
649              
650             =cut
651              
652             sub b27_unpack81
653             {
654 6     6 1 2595 my($c27, $base81str) = @_;
655              
656 6         16 $base81str =~ tr[ \t\r\n\f][]d;
657              
658 6         23 my @char81 = split(//, $base81str);
659 6         12 my @val81 = map{$b81_decode[ord($_)]} @char81;
  48         64  
660 6         18 my @tail = splice(@val81, scalar @val81 - scalar(@val81) % 3);
661              
662 6         26 my(@x27) = split(//, $c27);
663 6         8 my @clist;
664              
665             #
666             # Take in 3 base-81 characters, write out four base-27 characters.
667             #
668 6         16 while (my(@x3) = splice(@val81, 0, 3))
669             {
670 14         24 my $x = 6561 * $x3[0] + 81 * $x3[1] + $x3[2];
671 14         13 my @mods;
672              
673 14         21 for (1 .. 4)
674             {
675 56         80 unshift @mods, $x27[$x % 27];
676 56         72 $x = int $x/27;
677             }
678              
679 14         44 push @clist, @mods;
680             }
681              
682 6 100       13 if (scalar @tail)
683             {
684 4         5 my $x = $tail[0];
685 4         10 push @clist, $x27[int $x/3];
686 4         7 $x = ($x % 3) * 9;
687              
688 4 100       9 if (scalar @tail == 2)
689             {
690 2         3 $x += int $tail[1]/9;
691 2         4 push @clist, $x27[$x];
692 2         3 $x = ($tail[1] % 9) * 3;
693             }
694 4         8 push @clist, $x27[$x];
695             }
696              
697 6 50       36 return wantarray? @clist: join "", @clist;
698             }
699              
700             1;
701              
702             __END__