lzbwd_file_compression.pl 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. #!/usr/bin/perl
  2. # Author: Trizen
  3. # Date: 07 September 2023
  4. # Edit: 11 April 2024
  5. # https://github.com/trizen
  6. # Compress/decompress files using LZ77 compression + DEFLATE integers encoding + Burrows-Wheeler Transform (BWT) + Huffman coding.
  7. # References:
  8. # Data Compression (Summer 2023) - Lecture 13 - BZip2
  9. # https://youtube.com/watch?v=cvoZbBZ3M2A
  10. #
  11. # Data Compression (Summer 2023) - Lecture 11 - DEFLATE (gzip)
  12. # https://youtube.com/watch?v=SJPvNi4HrWQ
  13. use 5.036;
  14. use Getopt::Std qw(getopts);
  15. use File::Basename qw(basename);
  16. use Compression::Util qw(:all);
  17. use constant {
  18. PKGNAME => 'LZBWD',
  19. VERSION => '0.01',
  20. FORMAT => 'lzbwd',
  21. COMPRESSED_BYTE => chr(1),
  22. UNCOMPRESSED_BYTE => chr(0),
  23. CHUNK_SIZE => 1 << 16, # higher value = better compression
  24. RANDOM_DATA_THRESHOLD => 1, # in ratio
  25. MAX_INT => oct('0b' . ('1' x 32)),
  26. };
  27. # Container signature
  28. use constant SIGNATURE => uc(FORMAT) . chr(1);
  29. # [distance value, offset bits]
  30. my @DISTANCE_SYMBOLS = (map { [$_, 0] } 0 .. 4);
  31. until ($DISTANCE_SYMBOLS[-1][0] > MAX_INT) {
  32. push @DISTANCE_SYMBOLS, [int($DISTANCE_SYMBOLS[-1][0] * (4 / 3)), $DISTANCE_SYMBOLS[-1][1] + 1];
  33. push @DISTANCE_SYMBOLS, [int($DISTANCE_SYMBOLS[-1][0] * (3 / 2)), $DISTANCE_SYMBOLS[-1][1]];
  34. }
  35. sub usage {
  36. my ($code) = @_;
  37. print <<"EOH";
  38. usage: $0 [options] [input file] [output file]
  39. options:
  40. -e : extract
  41. -i <filename> : input filename
  42. -o <filename> : output filename
  43. -r : rewrite output
  44. -v : version number
  45. -h : this message
  46. examples:
  47. $0 document.txt
  48. $0 document.txt archive.${\FORMAT}
  49. $0 archive.${\FORMAT} document.txt
  50. $0 -e -i archive.${\FORMAT} -o document.txt
  51. EOH
  52. exit($code // 0);
  53. }
  54. sub version {
  55. printf("%s %s\n", PKGNAME, VERSION);
  56. exit;
  57. }
  58. sub valid_archive {
  59. my ($fh) = @_;
  60. if (read($fh, (my $sig), length(SIGNATURE), 0) == length(SIGNATURE)) {
  61. $sig eq SIGNATURE || return;
  62. }
  63. return 1;
  64. }
  65. sub main {
  66. my %opt;
  67. getopts('ei:o:vhr', \%opt);
  68. $opt{h} && usage(0);
  69. $opt{v} && version();
  70. my ($input, $output) = @ARGV;
  71. $input //= $opt{i} // usage(2);
  72. $output //= $opt{o};
  73. my $ext = qr{\.${\FORMAT}\z}io;
  74. if ($opt{e} || $input =~ $ext) {
  75. if (not defined $output) {
  76. ($output = basename($input)) =~ s{$ext}{}
  77. || die "$0: no output file specified!\n";
  78. }
  79. if (not $opt{r} and -e $output) {
  80. print "'$output' already exists! -- Replace? [y/N] ";
  81. <STDIN> =~ /^y/i || exit 17;
  82. }
  83. decompress_file($input, $output)
  84. || die "$0: error: decompression failed!\n";
  85. }
  86. elsif ($input !~ $ext || (defined($output) && $output =~ $ext)) {
  87. $output //= basename($input) . '.' . FORMAT;
  88. compress_file($input, $output)
  89. || die "$0: error: compression failed!\n";
  90. }
  91. else {
  92. warn "$0: don't know what to do...\n";
  93. usage(1);
  94. }
  95. }
  96. sub encode_integers ($integers) {
  97. my @symbols;
  98. my $offset_bits = '';
  99. foreach my $dist (@$integers) {
  100. foreach my $i (0 .. $#DISTANCE_SYMBOLS) {
  101. if ($DISTANCE_SYMBOLS[$i][0] > $dist) {
  102. push @symbols, $i - 1;
  103. if ($DISTANCE_SYMBOLS[$i - 1][1] > 0) {
  104. $offset_bits .= sprintf('%0*b', $DISTANCE_SYMBOLS[$i - 1][1], $dist - $DISTANCE_SYMBOLS[$i - 1][0]);
  105. }
  106. last;
  107. }
  108. }
  109. }
  110. return (pack('C*', @symbols), pack('B*', $offset_bits));
  111. }
  112. sub decode_integers ($symbols, $fh) {
  113. my $bits_len = 0;
  114. foreach my $i (@$symbols) {
  115. $bits_len += $DISTANCE_SYMBOLS[$i][1];
  116. }
  117. my $bits = read_bits($fh, $bits_len);
  118. my @distances;
  119. foreach my $i (@$symbols) {
  120. push @distances, $DISTANCE_SYMBOLS[$i][0] + oct('0b' . substr($bits, 0, $DISTANCE_SYMBOLS[$i][1], ''));
  121. }
  122. return \@distances;
  123. }
  124. # Compress file
  125. sub compress_file ($input, $output) {
  126. open my $fh, '<:raw', $input
  127. or die "Can't open file <<$input>> for reading: $!";
  128. my $header = SIGNATURE;
  129. # Open the output file for writing
  130. open my $out_fh, '>:raw', $output
  131. or die "Can't open file <<$output>> for write: $!";
  132. # Print the header
  133. print $out_fh $header;
  134. my $lengths_str = '';
  135. my $uncompressed_str = '';
  136. my @sizes;
  137. my @distances_chunk;
  138. open my $uc_fh, '>:raw', \$uncompressed_str;
  139. open my $len_fh, '>:raw', \$lengths_str;
  140. my $create_bz2_block = sub {
  141. scalar(@sizes) > 0 or return;
  142. print $out_fh COMPRESSED_BYTE;
  143. print $out_fh delta_encode(\@sizes);
  144. my ($symbols, $offset_bits) = encode_integers(\@distances_chunk);
  145. print $out_fh bz2_compress($uncompressed_str);
  146. print $out_fh bz2_compress($lengths_str);
  147. print $out_fh bz2_compress($symbols);
  148. print $out_fh bz2_compress($offset_bits);
  149. @sizes = ();
  150. @distances_chunk = ();
  151. open $uc_fh, '>:raw', \$uncompressed_str;
  152. open $len_fh, '>:raw', \$lengths_str;
  153. };
  154. # Compress data
  155. while (read($fh, (my $chunk), CHUNK_SIZE)) {
  156. my ($literals, $distances, $lengths) = lz77_encode($chunk);
  157. my $est_ratio = length($chunk) / (4 * scalar(@$literals));
  158. say "Est. ratio: ", $est_ratio, " (", scalar(@$literals), " uncompressed bytes)";
  159. if ($est_ratio > RANDOM_DATA_THRESHOLD) {
  160. push @sizes, scalar(@$literals);
  161. print $uc_fh pack('C*', @$literals);
  162. print $len_fh pack('C*', @$lengths);
  163. push @distances_chunk, @$distances;
  164. }
  165. else {
  166. say "Random data detected...";
  167. $create_bz2_block->();
  168. print $out_fh UNCOMPRESSED_BYTE;
  169. print $out_fh create_huffman_entry([unpack 'C*', $chunk]);
  170. }
  171. if (length($uncompressed_str) >= CHUNK_SIZE) {
  172. $create_bz2_block->();
  173. }
  174. }
  175. $create_bz2_block->();
  176. close $out_fh;
  177. }
  178. # Decompress file
  179. sub decompress_file ($input, $output) {
  180. # Open and validate the input file
  181. open my $fh, '<:raw', $input
  182. or die "Can't open file <<$input>> for reading: $!";
  183. valid_archive($fh) || die "$0: file `$input' is not a \U${\FORMAT}\E v${\VERSION} archive!\n";
  184. # Open the output file
  185. open my $out_fh, '>:raw', $output
  186. or die "Can't open file <<$output>> for writing: $!";
  187. while (!eof($fh)) {
  188. my $compression_byte = getc($fh) // die "decompression error";
  189. if ($compression_byte eq UNCOMPRESSED_BYTE) {
  190. say "Decoding random data...";
  191. print $out_fh pack('C*', @{decode_huffman_entry($fh)});
  192. next;
  193. }
  194. elsif ($compression_byte ne COMPRESSED_BYTE) {
  195. die "decompression error";
  196. }
  197. my @sizes = @{delta_decode($fh)};
  198. my @uncompressed = unpack('C*', bz2_decompress($fh));
  199. my @lengths = unpack('C*', bz2_decompress($fh));
  200. my @symbols = unpack('C*', bz2_decompress($fh));
  201. my $offset_bits = bz2_decompress($fh);
  202. open my $offbits_fh, '<:raw', \$offset_bits;
  203. my @distances = @{decode_integers(\@symbols, $offbits_fh)};
  204. while (@uncompressed) {
  205. my $size = shift(@sizes) // die "decompression error";
  206. my @uncompressed_chunk = splice(@uncompressed, 0, $size);
  207. my @lengths_chunk = splice(@lengths, 0, $size);
  208. my @distances_chunk = splice(@distances, 0, $size);
  209. scalar(@uncompressed_chunk) == $size or die "decompression error";
  210. scalar(@lengths_chunk) == $size or die "decompression error";
  211. scalar(@distances_chunk) == $size or die "decompression error";
  212. print $out_fh lz77_decode(\@uncompressed_chunk, \@distances_chunk, \@lengths_chunk);
  213. }
  214. }
  215. close $fh;
  216. close $out_fh;
  217. }
  218. main();
  219. exit(0);