BZ2 : l'outsider entre DEFLATE et LZMA

S’il y a un grand trio des formats de compression que l’on utilise « couramment » sous Linux, c’est : GZip (DEFLATE), XZ (LZMA) et BZ2 (BZip2).

BZip2, c’est un algorithme qui compresse un peu mieux que DEFLATE, et moins bien que LZMA. Il a eu son heure de gloire avant que LZMA n’apparaisse - il est aussi plus lent (car plus compliqué) que DEFLATE et LZMA.

Quand je dis plus compliqué, c’est qu’il combine plusieurs étapes différentes pour « mieux » compresser. Mais il y a deux étapes vraiment importantes : celle qui fait le codage des bits, c’est le codage Huffman, même chose que pour DEFLATE. Mais pour coder les octets, point de fenêtre glissante ou de dictionnaire à la LZW. L’étape qui contribue le plus à permettre la compression des données, vous ne la connaissez pas : c’est Burrows-Wheeler.

Les raisons derrière Burrows-Wheeler

Dans le premier chapitre, nous avons parlé d’une technique d’encodage très simple : le Run-Length Encoding (RLE). Elle consiste, à chaque fois qu’il y a une répétition, à préfixer la valeur à répéter par le nombre de répétitions.

Par exemple : au lieu d’écrire « AAAA BBB », je vais écrire « 4xA 3xB ».

Imaginons que je veuille compresser un texte en français. Comme vous le savez, les répétitions de lettres (à part certaines comme « ll ») sont très rares. Par contre, il est très courant de trouver une consonne avant une voyelle, et le nombre de paires de consonnes/voyelles est limité.

Mais mon RLE, cela, il ne comprendra pas. Ça compressera mal. Comment faire pour que dans mon texte compressé, les lettres qui se trouvent souvent avant une lettre donnée apparaissent à la suite ? Est-ce que idéalement, je ne pourrais pas avoir un moyen de changer l’ordre, mais aussi pouvoir retrouver celui d’origine ?

Description de Burrows-Wheeler

Nous allons prendre cette chaîne comme exemple : « BACDCDYXZ ». Elle n’a pas été choisie au hasard.

De base, notre chaîne est constituée de deux types d’information : des bits de position et des bits de valeurs.

Je vais en ajouter un troisième : la position des valeurs une fois notre tableau passé par un algorithme de tri alphabétique, sur chaque valeur qu’on accolerait aux valeurs qui suivent dans la chaîne.

Comme vous le voyez, il y a autant de bits de « position une fois le tri alphabétique effectué » que de bits de « position d’origine » dans notre tableau (chaque position se code de 0 à 8).

Qu’est-ce qui se passe si j’intervertis la colonne de tri alphabétique et la colonne de la position d’origine dans notre tableau ? Ça ne sert absolument à rien : on obtient un résultat ininterprétable, « ABCCDDXYZ ». En plus, on ne peut pas revenir en arrière (les lettres sont déjà triées).

Est-ce que tu crois qu’on pourrait utiliser ces bits de tri alphabétique pour stocker quelque chose ? En donnant aux résultats obtenus une fois notre chaîne triée par ordre alphabétique une signification particulière, par exemple ?

C’est exactement l’idée de Burrows-Wheeler : on va donner une signification particulière au tri alphabétique. On va commencer par ajouter une quatrième colonne à notre tableau : on va encore effectuer un tri alphabétique, mais cette fois en partant de la valeur qui suit, dans la chaîne d’origine, la valeur qu’on a pris pour faire notre premier tri.

(Comme vous l’avez remarqué, si on veut savoir la lettre qui vient après la dernière, on se rabat sur la première lettre du mot)

Qu’est-ce qui passe si on trie nos valeurs en utilisant cette quatrième colonne, plutôt que la position d’origine ? Eh bien, on a… effectué l’encodage Burrows-Wheeler.

On obtient une chaîne où plusieurs caractères sont répétés : « BZADCCYDX ». En effet, les caractères qui précèdent un « D » ont été groupés ensemble par l’algorithme de tri, et il s’agit souvent d’un « C ». Par contre, maintenant, il va nous falloir revenir en arrière. Comment faire ? La position d’origine n’existe plus, on a écrasé ses bits par les bits du second tri alphabétique !

Eh bien, c’est très simple : pour une valeur donnée, grâce à ce qu’on a mis comme nouvelle position, on sait qu’à la nouvelle position se trouve la lettre qui se trouvait juste après la lettre qu’on obtiendrait, à la même position, en faisant un tri alphabétique. Du coup, il va suffire qu’on re-fasse un tri alphabétique, sur nos valeurs, pour connaître la valeur qui se trouve après, à position équivalente. On fait le cheminement dans l’autre sens.

Comme ça, on obtient des paires de valeurs (qui sont réellement présentes dans le texte d’origine) ! En fait, on a reconstitué chaque paire de caractères existante du texte.

On peut se servir des positions d’origine pour savoir quelle lettre vient avant l’autre. Après la paire « B (0) -> A (2) », on a « A (2) -> C (4) », puis ensuite, on a « C (4) -> D (3) »…

Vous noterez également que l’on profite du fait que le contenu à décoder soit trié selon toutes les valeurs qui suivent dans le texte d’origine pour deviner, considérant deux lettres qui ont la même valeur et qui seront triées pareil dans l’ordre alphabétique, à quelle position encodée elle correspondra :

Comment sait-on que la position d’origine du « C » après « A (2) » est « 4 » est pas « 5 » ? On a deux lettres qui précédent un « C » : « A (2) » et « D (3) ». Le critère de tri de « A » était « n° CDC… » et le critère de tri de « D » était « n° CDY… » (soit les lettres suivantes : du coup, on peut comprendre qu’une partie de l’information de tri alphabétique est partie dans la valeur encodée, et que la suite de l’information est partie dans la position encodée).

On vient de « A (2) », 2 se trouve avant 3. Sur les deux correspondances de gauches possibles (« A (2) » et « D (3) »), on vient de la première, on prendra donc également la première correspondance à droite (« C (4) » plutôt que « C (5) »).

Illustré autrement, notre tableau de correspondances donne :

Suivez les flèches depuis « B » : on obtient bien « BACDCDYXZ »
Suivez les flèches depuis « B » : on obtient bien « BACDCDYXZ »

Et donc :

C'est plus lisible comme ça, hein ?
C'est plus lisible comme ça, hein ?

Au final, en enchaînant les correspondances, on a pu recoller des paires de valeurs qui se suivent entre elles, et effectuer un sacré sac de nœuds qui, lorsqu’on le parcourt, va nous faire suivre A -> C -> D -> C -> D -> Y -> X -> Z -> B -> A -> C -> D…

Comment savoir quelle lettre venait en premier « à la base » ? Notre référence pour savoir comment « commence » la chaîne, c’est un tri qu’on a effectué sur des valeurs par ordre alphabétique. Sauf que la chaîne d’origine qu’on avait encodée ne commençait pas par son premier caractère dans l’ordre alphabétique à l’origine.

Du coup, il faut préciser quelque part la position du caractère dans la chaîne triée par ordre alphabétique, qui correspond au premier caractère de la chaîne décodée. C’est une information qu’il faudra transmettre à part : du coup, notre information encodée sera à la fois « ma chaîne encodée est « BZADCCYDX » » et « la position du premier caractère d’origine dans la chaîne triée par ordre alphabétique est 1 » (ça fait deux choses distinctes à dire).

Ces deux informations sont encodées à part, mais la position du caractère de départ ne prend pas beaucoup de place.
Ces deux informations sont encodées à part, mais la position du caractère de départ ne prend pas beaucoup de place.

Lors de l’encodage, on a atteint notre objectif : stocker et des informations dans les bits de tri alphabétique, et dans les bits de position, et dans les bits de valeurs ! On a pu optimiser l’agencement de la chaîne pour la rendre plus encline au RLE, mais sans faire grossir « réellement » le nombre d’informations par octet.

À la place, on l’a fait grossir artificiellement puisque c’est l’algorithme de tri qui nous fournit des informations en plus.

Les étapes de BZ2

Au contraire de DEFLATE ou LZMA qui sont composées de deux étapes (une compression des bits et une compression des octets), BZ2 se décompose en un plus grand nombre d’étapes. Les voici :

Position dans l’encodage Position dans le décodage Nom Description
1 5 RLE1 (Run-length encoding n° 1) Une première passe de RLE, censée éviter certains cas probématiques de BWT. A posteriori, l’auteur de BZ2 a estimé qu’elle n’aurait pas été dû être ajoutée. Son principe : à chaque fois que quatre octets consécutifs seront répétés, le décodeur s’attendra à trouver un octet juste après qui dicte le nombre de fois supplémentaires où il faudra les répéter. Par exemple, AAABBB restera AAABBB, mais AAAAAAABBB deviendra AAAA\x03BBB pour dire qu’il faut 3 autres A ensuite. S’il n’y a que quatres octets semblables, eh bien on indique quand même qu’il n’y a pas d’autres répétitions : AAAABBB devient AAAA\x00BBB.
2 4 BWT (Burrows-Wheeler Transform) Ce dont je vous ai parlé depuis le début de l’article, et ce qui rendra le RLE d’ensuite efficace. Cette étape est aussi appelée « block sorting » (tri de blocs) et l’algorithme exact n’est pas forcément représenté par les schémas que je vous ai mis plus haut, mais aux détails près.
3 3 MTF (Move-to-Front) Une autre optimisation, après BWT, qui vous permet d’améliorer l’efficacité du second RLE. Vous avez un dictionnaire de 256 entrées (ou moins, dans le cas où certains octets ne seraient pas utilisés dans le message), chaque entrée corespond à un octet. On n’en ajoutera pas d’autres, sauf que, dans ce dictionnaire, les entrées qui auront été utilisées en dernier reviendront au début : par exemple, si vos données à encoder sont « 32 32 32 32 », alors vous écrirez d’abord « 32 », puis 32 reviendra au début du dictionnaire (il deviendra « 0 »). Vous aurez donc à écrire « 32 0 0 0 ». Puis si vous écrivez « 31 », alors « 31 » deviendra « 0 » et « 32 » deviendra « 1 » : pour encoder « 31 31 32 32 32 », vous écrirez « 31 0 1 0 0 » (vu que « 32 » sera revenu à nouveau au début après avoir été ré-utilisé). etc.
4 2 RLE2 (Run-length encoding n° 2) Une seconde passe de RLE. Les groupes de « 0 » sont remplacés par des nombres de « 0 » successifs à écrire (ils auront leurs codes Huffman). Après avoir été réorganisée, la taille de notre entrée se réduit enfin !
5 1 HUFF (Huffman coding) Le codage Huffman en lui-même. Attention, des différents algorithmes de compression existants, presque personne n’a choisi de gérer la question de l’encodage du dictionnaire initial pareil. BZip2 utilisera, soit un dictionnaire pré-défini, soit un dictionnaire encodé avec du delta-encoding (un langage simple composé de trois codes Huffman où on peut trouver : « 10 » pour ajouter 1 à la dernière valeur, « 11 » pour enlever 1, « 0 » pour passer à la valeur suivante) afin de réduire la taille en bits de chaque entrée.

Le format BZ2

Un flux BZ2, s’il est composé d’une suite de bits, utilise l’ordre MSB (Most significant bit) pour mettre plusieurs séquences de bits au sein d’octet, ce qui signifie que vous lisez les bits les plus à gauche d’un octet d’abord pour extraire un entier. Cela, contrairement à la plupart des autres algortihmes qui sont en LSB.

Cela signifie aussi que pour concaténer deux octets, vous ajoutez le deuxième octet à droite du premier, et non à gauche (logique, les premières données à lire seront au début comme ça).

L’en-tête d’un flux BZ2 (un fichier peut contenir plusieurs flux à la suite, chacun aligné sur un octet) est construite comme ce qui suit :

Nom du champ Taille du champ Description
HeaderMagic 2 octets (ASCII) Toujours "BZ"
Version 1 octet (ASCII) "0" pour BZip1 (peu utilisé), "h" pour BZip2
Level 1 octet (ASCII) Niveau de compression (compromis efficacité/vitesse de compression/décompession) : entre "0" et "9" (c’est un chiffre ASCII, pas une valeur d’octet)

S’en suit une suite de blocs.

À la fin du flux, se trouve une en-pied construite comme ce qui suit (attention, elle n’est pas alignée sur les octets qui précèdent, et la position du premier bit dépendera du contenu des blocs écrits auparavant) :

Nom du champ Taille du champ Description
FooterMagic 6 octets = 48 bits Valeur constante : "17 72 45 38 50 90". Il s’agit de la racine carrée de Pi écrite en BCD (BCD = Binary coded decimal = ce terme désigne le fait d’écrire un nombre décimal, mais avec les chiffres 0 à 9 de l’alphabet hexadécimal, de façon à qu’il soit directement lisible sans conversion par une personne utilisant un éditeur hexadécimal)
StreamCRC 4 octets = 32 bits Le hash du flux décompressé. Ici, point d’Adler32, mais un CRC-32, un autre hash semblable mas plus utilisé. Il ne s’agit pas exactement de CRC-32 standard, le caractère MSB de BZip2 fait que l’ordre des bits est inversé au sein des octets par rapport à la lecture habituelle.

Entre l’en-tête et l’en-pied, chaque bloc dispose d’un en-tête, de définitions d’arbres de Huffman, puis de contenu Huffmanisé. L’en-tête de bloc, plus copieuse qu’avec GZip, commence comme il suit :

Nom du champ Taille du champ Description
BlockMagic 6 octets = 48 bits Valeur constante : "31 41 59 26 53 59". Il s’agit du début de Pi écrit en BCD.
BlockCRC 4 octets = 32 bits Le CRC-32 du contenu du bloc décompressé.
Randomized 1 bit Un flag utilisé dans les anciennes versions du format qui permettait, dans certains cas, de dire que le contenu a été partiellement randomisé pour gérer le cas de certains flux à compresser qui causaient une utilisation de mémoire et de CPU excessive de la part de BZip2 (des bombes de décompression, intentionnelles ou non, en quelques sortes). Il n’est plus utilisé avec les implémentations récentes et doit être mis à « 0 ».
OrigPtr 3 octets = 24 bits Vous vous souvez du post-it jaune qui indiquait la position d’origine des données avec Burrows-Wheeler ? Il est là. Après avoir reconstitué l’ordre alphabétique, on s’en sert pour savoir « où » se trouve le début du flux sur lequel appliquer le décodage BWT.

Viennent ensuite les champs relatifs à Huffman.

Huffman avec BZ2

Venons-en au codage Huffman.

Première chose, vous avez un seul alphabet à définir, ce qui est appréciable. Néanmoins, et pour des raisons d’optimisation, cet alphabet peut être défini sous la forme de plusieurs arbres différents (on utilisera l’arbre le plus adapté pour une section du contenu à décompresser donné).

L’alphabet correspond à la totalité des symboles (octets) que vous avez obtenus après la transformation MTF (Move-to-Front), à quelques différences prêt :

  • Vous n’avez pas de symbole pour « 0 » : à la place de chaque suite de « 0 », vous avez deux symboles qui sont utilisés pour le codage RLE, RUNA (position 0 dans l’alphabet Huffman) et RUNB (position 1). RUNA et RUNB, mis à la suite, sont en réalité utilisés pour coder des tailles de RLE : RUNA est un bit 0 et RUNB est un bit 1. Voici comment convertir les convertir en suite de zéros :
    • Admettons que vous ayez par exemple : « RUNA RUNB RUNB »
    • Transformez-les en 0 et en 1 : « 011 »
    • Retournez-les : « 110 »
    • Ajoutez un « 1 » devant (puisque la taille de ces suites de symboles est variable, on peut supposer qu’il y en a toujours un à mettre) : « 1110 »
    • Faîtes la conversion du binaire vers le décimal : « 14 »
    • Enlever un (c’est nécessaire parce que, si on ne voulait encoder qu’un seul zéro, « RUNA » ferait autrement 10 soit « 2 ») : « 13 »
    • S’il n’y a pas d’autre « RUNA » ou « RUNB » ensuite, vous savez que vous devez écrire 13 zéros à cet endroit-là.
  • Vous avez un symbole « EOB » (End of block, fin du bloc) en dernière position de l’alphabet Huffman.

Voici la suite de l’en-tête de bloc, qui contient les champs relatifs à MTF et Huffman.

Nom du champ Taille du champ Description
MapL1 16 bits Un bit field (une suite de flags) qui désigne, sur les 256 symboles MTF possibles, quelles tranches de 16 valeurs possibles contiennent des valeurs réellement utilisée. Si n’importe quelle valeur dans la tranche 0..15 est utilisée, alors le premier bit sera à 1. Si n’importe quelle valeur dans la tranche 16..31 est utilisé, alors le second bit sera à 1. etc.
MapL2 16 bits × nombre de bits à 1 dans MapL1 Un bit field (une suite de flags) qui désigne, pour une tranche de 16 symboles MTF possibles, quels symboles sont utilisés. Chaque instance de MapL2 correspond à une tranche mise à 1 au sein de MapL1.
NumTrees 3 bits Le nombre d’arbres de Huffman différents qui seront définis, et pourront encoder notre alphabet. Obligatoirement compris entre 2 et 6 (même si le champ commence à 0). Même si vous définissez 2 arbres, vous n’êtes pas obligé d’utiliser les deux.
NumSelectors 15 bits Par la suite, un champ désignant quel arbre de Huffman utiliser sera présent pour chaque tranche de 50 codes de Huffman à décoder. Ce champ dit combien il y aura de tels champs. Cela nous limite à environ 1,6 millions de codes par bloc ((1 << 15) * 50).
Selectors NumSelectors × Valeurs Huffman encodées en MTF Vous pensiez que DEFLATE était tordu ? BZip2 un peu moins, mais il a quand même un champ qui fait correspond des valeurs Huffman encodées en MTF à des noms d’arbre Huffman. Pour chaque tranche de 50 codes qu’on devra décoder ensuite, on a un nombre qui renvoie à un de nos 6 arbres possibles. L’alphabet Huffman n’est pas trop compliqué : 0, 10, 110, 1110, 11110, 111110 (pour 1, 2, 3, 4, 5, 6). Il y a un MTF d’appliqué ensuite.
Trees NumTrees × (BitLen de 5 bits, suivi de "NumSyms" valeurs delta encodées) Pour chaque arbre défini, encode la taille en bits de chaque code Huffman possible, dans l’ordre. Chaque taille permet de deviner le code ensuite (même chose que pour DEFLATE). Ces tailles ont été delta encodées (la base de la première valeur est écrite dans le champ "BitLen", puis on lit "NumSyms" valeurs delta encodées, voir la définition plus haut). Le nombre de tailles (NumSyms) correspond au nombre de bits à 1 dans MapL2 + 2 (on ajoute 2 pour convertir le nombre de symboles MTF en nombre de symboles Huffman : la position « 0 » devient « RUNA » et « RUNB » et « EOB » apparaît).

S’en suit le contenu Huffman-encodé. Hop là, vous savez tout, on ferme le bouquin. On peut passer à l’implémentation.

BZip2 en Python

Nous allons essayer de décoder cette chaîne :

425a68393141592653593a1fb6180000285f940010408600008a0804002e21be00402008003000ac6193547a8d32681a7a20a0006819320488809313434c876f22a986198370606d342d7738f4e832122e26e1116dd24651715bc577384c4616e99a08ac9ca7b2e86b51648248e28ae6143b968594c582c2a6b43619a06461457633aeaa545f44ce49c372aebe6849774b04d957b4e2177301f92f45564f8bb9229c28481d0fdb0c00

Nous commençons par adapter notre lecteur de bits à l’ordre MSB.

"""
    Variante de notre classe LecteurDeBits qui lit les valeurs encodées dans
    chaque octet d'un flux de bits de gauche à droite (MSB), plutôt que de
    droite à gauche (LSB). Taillée pour BZip2.
"""

class LecteurDeBitsMSB:
    
    def __init__(self, entree : bytes):
        
        self.octets = BytesIO(entree)
        
        # On initialise nos attributs.
        
        self.bits_non_lus = 0
        self.taille_bits_non_lus = 0
        
    """
        Lire un entier composé d'un certain nombre de bits,
        en commençant par les bits les plus à gauche
    """
    
    def lire_bits(self, nombre_bits : int) -> int:

        while self.taille_bits_non_lus < nombre_bits:
            
            prochain_octet = self.octets.read(1)
            
            """
                Si notre lecture du BytesIO a retourné une chaîne vide, alors
                ça veut dire qu'il n'y a plus d'octets de disponibles, on va
                déclencher une erreur
            """
            
            if not prochain_octet:
                raise EOFError
            
            """
                On n'oublie pas de convertir notre chaîne d'un octet en
                entier en ne prenant explicitement que le premier octet: "[0]"
                
                On l'ajoute à nos bits non lus
            """
            
            self.bits_non_lus <<= 8
            self.bits_non_lus |= prochain_octet[0]
            self.taille_bits_non_lus += 8
        
        # Les bits à lire sont les bits encore non lus les
        # plus à gauche (MSB = Most significant bits)
        
        bits_lus = self.bits_non_lus >> (self.taille_bits_non_lus - nombre_bits)
        
        self.taille_bits_non_lus -= nombre_bits
        
        masque_bits_non_lus = (1 << self.taille_bits_non_lus) - 1
        self.bits_non_lus &= masque_bits_non_lus
        
        return bits_lus
    
    """
        Lire simplement des octets
    """
    
    def lire_octets(self, nombre_octets : int) -> bytes:
        
        self.aligner_bits_sur_octet()
        
        octets_lus = self.octets.read(nombre_octets)
        
        if len(octets_lus) < nombre_octets:
            raise EOFError # Pas assez d'octets, déclencher une erreur, EOF = End of file = Fin de fichier
        
        return octets_lus
    
    """
        Se rendre au début de l'octet suivant
    """
    
    def aligner_bits_sur_octet(self):
        
        self.bits_non_lus = 0
        self.taille_bits_non_lus = 0
    

Ensuite, nous pourrons réutiliser tel quel, sans modification, le décodeur Huffman que nous avons écrit pour DEFLATE.

Nous allons par contre écrire un décodeur MTF basique :

"""
    Notre décodeur pour l'encodage MTF (Move-to-Front)
"""

class DecodeurMTF:
    
    def decode(self, entree : List[int], dictionnaire_initial : List[int]) -> List[int]:
        
        dictionnaire_mtf : List[int] = list(dictionnaire_initial)
        
        sortie : List[int] = []
        
        for index_dictionnaire in entree:
            
            entree_dictionnaire = dictionnaire_mtf.pop(index_dictionnaire)
            dictionnaire_mtf.insert(0, entree_dictionnaire)
            
            sortie.append(entree_dictionnaire)
        
        return sortie

Et une fonction de CRC-32 rectifiée pour fonctionner en ordre MSB, qui fait appel à celle de Python (le fonctionnement de CRC-32 est assez simple, vous trouverez de la documentation dessus facilement si ça vous intéresse)

"""
    Notre CRC32 compatible BZ2
"""


def mon_crc32_msb(entree : bytes) -> int:
    
    def retourner_bits(bits : int, nombre_bits : int):
    
        return int(''.join(reversed(format(bits, 'b').zfill(nombre_bits))), 2)
    
    
    return retourner_bits(crc32(
        bytes(retourner_bits(octet, 8) for octet in entree)
    ) & 0xffffffff, 32)


Enfin, le décodeur BZ2 :

"""
    Notre décodeur pour le format BZ2
"""

class DecodeurBZ2:
    
    def decompress(self, entree : bytes):
    
        lecteur_octets = BytesIO(entree)
        
        fichier_decompresse = b''

        # Lecture de chaque flux au sein du fichier

        while True:
            
            flux_decompresse = b''
        
            header_magic = lecteur_octets.read(2)
            version = lecteur_octets.read(1)
            compression_level : bytes = lecteur_octets.read(1)
            
            if not header_magic:
                
                break
            
            if header_magic != b'BZ' or version != b'h' or not (b'0' <= compression_level <= b'9'):
                
                raise ValueError('En-tête de flux BZ2 invalide')
            
            compression_level : int = int(compression_level)
            
            # Lecture de chaque bloc
            
            lecteur_de_bits = LecteurDeBitsMSB(lecteur_octets.read())
            
            while True:
                next_magic = lecteur_de_bits.lire_bits(48)
                
                BLOCK_MAGIC = 0x314159265359
                FOOTER_MAGIC = 0x177245385090
                
                if next_magic == BLOCK_MAGIC:
                    block_crc = lecteur_de_bits.lire_bits(32)
                    randomized = lecteur_de_bits.lire_bits(1)
                    orig_ptr = lecteur_de_bits.lire_bits(24)
                    
                    assert randomized == 0
                    
                    # Lire les symboles MTF utilisés au sein du bloc
                    
                    map_l1 = lecteur_de_bits.lire_bits(16) # Par exemple : 0b00110011
                    map_l1_bitfield : str = format(map_l1, 'b').zfill(16) # Par exemple : '00110011'
                    
                    
                    symboles_mtf_presents : List[str] = []
                    
                    for tranche_de_16_octets, tranche_presente in enumerate(map(int, map_l1_bitfield)):
                        
                        if tranche_presente:
                            map_l2 = lecteur_de_bits.lire_bits(16)
                            map_l2_bitfield : str = format(map_l2, 'b').zfill(16)
                            
                            for symbole_au_sein_de_la_tranche, symbole_present in enumerate(map(int, map_l2_bitfield)):
                                
                                if symbole_present:
                                    
                                    symboles_mtf_presents.append(tranche_de_16_octets * 16 + symbole_au_sein_de_la_tranche)
                                            
                    """
                        Décodage des informations relatives à l'arbre Huffman
                    """
                    
                    """
                        Décodage des correspondances entre sélecteur (groupe de
                        50 codes) et arbre
                    """
                    
                    num_trees = lecteur_de_bits.lire_bits(3)
                    num_selecteurs = lecteur_de_bits.lire_bits(15)
                    
                    decodeur_tree_index = DecodeurHuffman(
                        valeur_vers_taille_de_code = {tree_index: tree_index + 1 for tree_index in range(6)},
                        lecteur_de_bits = lecteur_de_bits)
                    
                    selecteur_vers_tree_index_avec_mtf = [decodeur_tree_index.lire_prochaine_valeur() for i in range(num_selecteurs)]
                    
                    selecteur_vers_tree_index = DecodeurMTF().decode(
                        entree = selecteur_vers_tree_index_avec_mtf,
                        dictionnaire_initial = [0, 1, 2, 3, 4, 5]
                    )
                    
                    
                    """
                        Décodage des tailles de code de chaque arbre
                    """
                    
                    # Les symboles Huffman spéciaux :
                    
                    self.RUNA = -2 # Bit "0" pour coder une taille de répétation RLE
                    self.RUNB = -1 # Bit "1" pour coder une taille de répétition RLE
                    self.EOB = 99999999 # End of block - marqueur de fin de bloc
                    
                    symboles_huffman_presents = [self.RUNA, self.RUNB]
                    symboles_huffman_presents += [index for index in range(1, len(symboles_mtf_presents))]
                    symboles_huffman_presents += [self.EOB]
                    
                    # Les symboles Huffman qui codent le delta encoding :
                    
                    DELTA_ENCODING_FIN_DE_TAILLE = 0b0
                    DELTA_ENCODING_INCREMENTER_TAILLE = 0b10
                    DELTA_ENCODING_DECREMENTER_TAILLE = 0b11
                    
                    decodeur_delta_encoding = DecodeurHuffman(
                        valeur_vers_taille_de_code = {
                            DELTA_ENCODING_FIN_DE_TAILLE: 1,
                            DELTA_ENCODING_INCREMENTER_TAILLE: 2,
                            DELTA_ENCODING_DECREMENTER_TAILLE: 2
                        },
                        lecteur_de_bits = lecteur_de_bits)
                    
                    tree_index_vers_arbre : List[DecodeurHuffman] = []
                    
                    for num_tree in range(num_trees):
                        
                        bit_len = lecteur_de_bits.lire_bits(5)
                        
                        tailles_symboles : List[int] = []
                        
                        for num_sym in range(len(symboles_huffman_presents)):
                            
                            taille_symbole = tailles_symboles[-1] if tailles_symboles else bit_len
                            
                            while True:
                                prochaine_instruction_delta = decodeur_delta_encoding.lire_prochaine_valeur()
                                
                                if prochaine_instruction_delta == DELTA_ENCODING_FIN_DE_TAILLE:
                                    break
                                elif prochaine_instruction_delta == DELTA_ENCODING_INCREMENTER_TAILLE:
                                    taille_symbole += 1
                                elif prochaine_instruction_delta == DELTA_ENCODING_DECREMENTER_TAILLE:
                                    taille_symbole -= 1
                            
                            tailles_symboles.append(taille_symbole)
                    
                        # En Python, la fonction "zip" permet de créer une liste de tuples à partir de
                        # plusieurs itérables. Ici, elle nous permet de créer un dictionnaire à partir
                        # de d'un côté les clefs, de l'autre côté les valeurs.
                    
                        tree_index_vers_arbre.append(DecodeurHuffman(
                            valeur_vers_taille_de_code = dict(zip(symboles_huffman_presents, tailles_symboles)),
                            lecteur_de_bits = lecteur_de_bits))
                        
                    """
                        On a décodé notre en-tête de bloc, on peut se mettre à décompresser
                        le contenu du flux !
                    """
                    
                    self.selecteur_vers_tree_index : List[int] = selecteur_vers_tree_index
                    
                    self.tree_index_vers_arbre : List[DecodeurHuffman] = tree_index_vers_arbre
                    self.symboles_mtf_presents : List[int] = symboles_mtf_presents
                    self.orig_ptr : int = orig_ptr
                    
                    self.decompresser_HUFF()
                    self.decompresser_RLE2()
                    self.decompresser_MTF()
                    self.decompresser_BWT()
                    self.decompresser_RLE1()
                    
                    
                    flux_decompresse += self.sortie_decompressee
                        
                    
                    assert mon_crc32_msb(self.sortie_decompressee) == block_crc
                
                elif next_magic == FOOTER_MAGIC:
                    stream_crc = lecteur_de_bits.lire_bits(32)
                    
                    
                    assert mon_crc32_msb(flux_decompresse) == stream_crc
                    break
                
                else:
                    raise ValueError('En-tête de bloc ou en-pied de flux BZ2 invalide')
                
            
            fichier_decompresse += flux_decompresse
            
            lecteur_de_bits.aligner_bits_sur_octet()
            entree = lecteur_de_bits.octets.read()
        
        return fichier_decompresse


    def decompresser_HUFF(self):
        
        symboles_lus : List[int] = []
        
        while True:
            selecteur = len(symboles_lus) // 50
            
            symbole = self.tree_index_vers_arbre[self.selecteur_vers_tree_index[selecteur]].lire_prochaine_valeur()
            
            symboles_lus.append(symbole)
            
            if symbole == self.EOB:
                break
        
        self.symboles_lus = symboles_lus
        

    def decompresser_RLE2(self):
        
        symboles_lus_apres_rle : List[int] = []
        
        symboles_runa_et_runb_a_traiter : List[int] = []
        
        for symbole_lu in self.symboles_lus:
            
            if symbole_lu in (self.RUNA, self.RUNB):
                symboles_runa_et_runb_a_traiter.append(symbole_lu)
            
            else:
                
                if symboles_runa_et_runb_a_traiter:
                    
                    nombre_de_zeros_a_repeter = 0b1
                    
                    for runa_ou_runb in reversed(symboles_runa_et_runb_a_traiter): # TODO reversed est-il bien nécessaire ici ?
                        
                        nombre_de_zeros_a_repeter <<= 1
                        nombre_de_zeros_a_repeter |= 1 if runa_ou_runb == self.RUNB else 0
                    
                    nombre_de_zeros_a_repeter -= 1
                    
                    symboles_lus_apres_rle += [0] * nombre_de_zeros_a_repeter
                    
                    
                    symboles_runa_et_runb_a_traiter = []
                
                if symbole_lu != self.EOB:
                    symboles_lus_apres_rle.append(symbole_lu)
                
                        
        
        self.symboles_lus : List[int] = symboles_lus_apres_rle
        

    def decompresser_MTF(self):
        
        self.symboles_lus : List[int] = DecodeurMTF().decode(
            entree = self.symboles_lus,
            dictionnaire_initial = self.symboles_mtf_presents
        )

    def decompresser_BWT(self):
        
        avant_tri = bytes(self.symboles_lus)
        apres_tri = sorted(avant_tri)
        
        
        # Nos paires Burrows-Wheeler à remettre dans l'ordre :
        
        class PaireBurrowsWheeler:
            
            char_1_valeur : str = None # Cette chaîne fera toujours un caractère
            char_1_position_origine : int = None # Cette position sera connue dès le début
            
            char_2_valeur : str = None # Cette chaîne fera toujours un caractère 
            char_2_position_origine : Optional[int] = None # Cette position sera déterminée après tri
        
        paires_a_trier = []
        
        for char_1_position_origine, (char_1_valeur, char_2_valeur) in enumerate(zip(avant_tri, apres_tri)):
            
            paire = PaireBurrowsWheeler()
            
            paire.char_1_valeur = bytes([char_1_valeur])
            paire.char_1_position_origine = char_1_position_origine
            
            paire.char_2_valeur = bytes([char_2_valeur])
            
            paires_a_trier.append(paire)
        
        
        # Reconstituer les positions d'origine des deuxièmes caractères de chaque paire
        
        paires_triees = sorted(paires_a_trier, key = lambda paire: (paire.char_1_valeur, paire.char_1_position_origine))
        
        for position_tri, paire in enumerate(paires_triees):
            paires_a_trier[position_tri].char_2_position_origine = paire.char_1_position_origine
        
        
        # Reconstituer la chaîne, maintenant qu'on a associé les paires entre elles
        
        donnees_sortie = b''
        
        premiere_paire = paires_triees[self.orig_ptr]
        paire_suivante = premiere_paire
        
        while not donnees_sortie or premiere_paire != paire_suivante:
            
            donnees_sortie += paire_suivante.char_1_valeur
            
            paire_suivante = paires_a_trier[paire_suivante.char_2_position_origine]
        

        self.sortie_decompressee : bytes = donnees_sortie
        
        
        """
            C'est tout bon, on a fait notre BWT !
        """
        

    def decompresser_RLE1(self):
        
        # Par concision, on va se permettre d'effectuer cette passe de RLE
        # avec des expressions régulières :
        # https://docs.python.org/3/library/re.html
        
        def effectuer_repetition(match):
            return match.group(1) * (ord(match.group(2)) + 4)
        
        self.sortie_decompressee : bytes = sub(rb'(.)\1\1\1(.)', effectuer_repetition, self.sortie_decompressee, flags = DOTALL)
        
        
        

Puis on décompresse notre chaîne :

print(DecodeurBZ2().decompress(bytes.fromhex('425a68393141592653593a1fb6180000285f940010408600008a0804002e21be00402008003000ac6193547a8d32681a7a20a0006819320488809313434c876f22a986198370606d342d7738f4e832122e26e1116dd24651715bc577384c4616e99a08ac9ca7b2e86b51648248e28ae6143b968594c582c2a6b43619a06461457633aeaa545f44ce49c372aebe6849774b04d957b4e2177301f92f45564f8bb9229c28481d0fdb0c00')).decode('utf8'))

Mis bout à bout, cela donne :

#!/usr/bin/python3
#-*- encoding: Utf-8 -*-

from typing import List, Dict, Set, Sequence, Union, Optional
from binascii import crc32
from re import sub, DOTALL
from io import BytesIO

"""
    Variante de notre classe LecteurDeBits qui lit les valeurs encodées dans
    chaque octet d'un flux de bits de gauche à droite (MSB), plutôt que de
    droite à gauche (LSB). Taillée pour BZip2.
"""

class LecteurDeBitsMSB:
    
    def __init__(self, entree : bytes):
        
        self.octets = BytesIO(entree)
        
        # On initialise nos attributs.
        
        self.bits_non_lus = 0
        self.taille_bits_non_lus = 0
        
    """
        Lire un entier composé d'un certain nombre de bits,
        en commençant par les bits les plus à gauche
    """
    
    def lire_bits(self, nombre_bits : int) -> int:

        while self.taille_bits_non_lus < nombre_bits:
            
            prochain_octet = self.octets.read(1)
            
            """
                Si notre lecture du BytesIO a retourné une chaîne vide, alors
                ça veut dire qu'il n'y a plus d'octets de disponibles, on va
                déclencher une erreur
            """
            
            if not prochain_octet:
                raise EOFError
            
            """
                On n'oublie pas de convertir notre chaîne d'un octet en
                entier en ne prenant explicitement que le premier octet: "[0]"
                
                On l'ajoute à nos bits non lus
            """
            
            self.bits_non_lus <<= 8
            self.bits_non_lus |= prochain_octet[0]
            self.taille_bits_non_lus += 8
        
        # Les bits à lire sont les bits encore non lus les
        # plus à gauche (MSB = Most significant bits)
        
        bits_lus = self.bits_non_lus >> (self.taille_bits_non_lus - nombre_bits)
        
        self.taille_bits_non_lus -= nombre_bits
        
        masque_bits_non_lus = (1 << self.taille_bits_non_lus) - 1
        self.bits_non_lus &= masque_bits_non_lus
        
        return bits_lus
    
    """
        Lire simplement des octets
    """
    
    def lire_octets(self, nombre_octets : int) -> bytes:
        
        self.aligner_bits_sur_octet()
        
        octets_lus = self.octets.read(nombre_octets)
        
        if len(octets_lus) < nombre_octets:
            raise EOFError # Pas assez d'octets, déclencher une erreur, EOF = End of file = Fin de fichier
        
        return octets_lus
    
    """
        Se rendre au début de l'octet suivant
    """
    
    def aligner_bits_sur_octet(self):
        
        self.bits_non_lus = 0
        self.taille_bits_non_lus = 0
    

# Notre décodeur de Huffman, repris de DEFLATE.

class DecodeurHuffman:
    
    """
        La méthode "__init__" (en jargon technique, on l'appelle « notre
        constructeur » prendra comme premier argument une association (un
        dictionnaire Python) entre des valeurs (des entiers), et leurs tailles 
        de codes respectives d'autres entiers). À partir de ça, les codes
        Huffman seront calculés automatiquement.
    """
    
    def __init__(self, valeur_vers_taille_de_code : Dict[int, int], lecteur_de_bits : LecteurDeBitsMSB):
        
        # Chaîne de code = chaîne représentant un code écrit en chiffres binaires
        
        self.chaine_de_code_vers_valeur : Dict[str, int] = {}
        
        self.lecteur_de_bits : LecteurDeBitsMSB = lecteur_de_bits
        
        derniere_taille_de_code : int = None
        dernier_code : int = None
        
        """
            Comme expliqué plus haut dans le tutoriel, on va numéroter nos
            codes selon leurs tailles de codes respectives, puis selon leurs
            valeurs.
            
            Du coup, il va falloir passer sur les valeurs dans l'ordre, en
            prenent d'abord en compte la taille de code.
            
            Cette petite fonction, que l'on va passer à la fonction de tri de
            Python ("sorted"), devrait nous être très utile pour trier d'abord
            par tailles de codes, et ensuite par valeurs.
        """
        
        def tri_valeurs(valeur_et_taille_de_code):
            valeur, taille_de_code = valeur_et_taille_de_code # On prend la tuple...
            return (taille_de_code, valeur) # ... et on la change de sens pour dire qu'on trie par tailles de code d'abord
        
        for valeur, taille_de_code in sorted(valeur_vers_taille_de_code.items(), key = tri_valeurs):
            
            if not taille_de_code:
                continue # Taille de code de 0 ! On passe !
            
            if dernier_code is None:
                code = 0
                
            else:
                if taille_de_code < derniere_taille_de_code:
                    # Vu qu'on passe sur les tailles de code dans l'ordre, de
                    # la plus petite à la plus grande, ça n'arrive très
                    # logiquement jamais.
                    
                    raise Exception
                
                elif taille_de_code > derniere_taille_de_code:
                    code = (dernier_code + 1) << (taille_de_code - derniere_taille_de_code)
                
                else:
                    code = dernier_code + 1
            
            # On transforme notre code en chaîne contenant des chiffres
            # binaires : on utilise "format" pour créer la chaîne (qui
            # sera la plus courte possible), puis "zfill" qui va nous
            # ajouter le bon nombre de zéros à gauche
            
            chaine_de_code = format(code, 'b').zfill(taille_de_code)
            
            self.chaine_de_code_vers_valeur[chaine_de_code] = valeur
            
            dernier_code = code
            derniere_taille_de_code = taille_de_code
        
        """
            Maintenant qu'on a constitué tous nos codes dans 
            "chaine_de_code_vers_valeur", il va falloir créer la structure
            représentant notre arbre.
            
            Notre arbre sera fait de dictionnaires Python imbriqués entre eux,
            avec pour chacun au plus deux clefs, "0" et "1", et dont la valeur
            correspond soit à notre valeur Huffman (un entier), soit à un
            autre dictionnaire s'il faut continuer dans l'arbre.
        """
        
        # L'anotation de type "Union" signifie « une de ces valeurs » (comme
        # toutes les annotations de type, elle a uniquement une valeur
        # d'information)
        
        self.mon_arbre : Dict[int, Union[dict, int]] = {}
        
        for chaine_de_code, valeur in self.chaine_de_code_vers_valeur.items():
            
            # Pour chaque code, on va descendre dans l'arbre, en créant au
            # passage les dictionaires imbiqués successifs pour chaque bit
            # (sauf le dernier), et assigner le dernier bit à la valeur
            # associée au code
            
            noeud_actuel_dans_l_arbre = self.mon_arbre
            
            # Descendre dans l'arbre...
            
            for chiffre_bit in chaine_de_code[:-1]:
                bit = int(chiffre_bit) # Conversion de chaîne en entier
                
                if bit not in noeud_actuel_dans_l_arbre:
                    
                    noeud_actuel_dans_l_arbre[bit] = {} # Création de dictionnaire
                
                noeud_actuel_dans_l_arbre = noeud_actuel_dans_l_arbre[bit]
            
            # ...on est au bout
            
            dernier_bit = int(chaine_de_code[-1])
            
            noeud_actuel_dans_l_arbre[dernier_bit] = valeur
        
        # On a créé notre arbre, c'est tout bon !
    
    """
        Maintenant : voici la fonction qu'on va appeler tout le temps pour
        faire le décodage : elle va lire le code à partir du lecteur de bits.
        
        Elle commence en haut de l'arbre, puis elle avance d'un bit à la fois,
        jusqu'à avoir trouvé la valeur.
    """
    
    def lire_prochaine_valeur(self) -> int:
        
        noeud_actuel_dans_l_arbre = self.mon_arbre
        
        while True:
            prochain_bit : int = self.lecteur_de_bits.lire_bits(1)
            
            noeud_actuel_dans_l_arbre = noeud_actuel_dans_l_arbre[prochain_bit]
            
            # On est tombé sur un entier ? Chouette, on a trouvé notre valeur !
            
            if type(noeud_actuel_dans_l_arbre) == int:
                
                return noeud_actuel_dans_l_arbre


"""
    Notre décodeur pour l'encodage MTF (Move-to-Front)
"""

class DecodeurMTF:
    
    def decode(self, entree : List[int], dictionnaire_initial : List[int]) -> List[int]:
        
        dictionnaire_mtf : List[int] = list(dictionnaire_initial)
        
        sortie : List[int] = []
        
        for index_dictionnaire in entree:
            
            entree_dictionnaire = dictionnaire_mtf.pop(index_dictionnaire)
            dictionnaire_mtf.insert(0, entree_dictionnaire)
            
            sortie.append(entree_dictionnaire)
        
        return sortie


"""
    Notre CRC32 compatible BZ2
"""

def mon_crc32_msb(entree : bytes) -> int:
    
    def retourner_bits(bits : int, nombre_bits : int):
    
        return int(''.join(reversed(format(bits, 'b').zfill(nombre_bits))), 2)
    
    
    return retourner_bits(crc32(
        bytes(retourner_bits(octet, 8) for octet in entree)
    ) & 0xffffffff, 32)


"""
    Notre décodeur pour le format BZ2
"""

class DecodeurBZ2:
    
    def decompress(self, entree : bytes):
    
        lecteur_octets = BytesIO(entree)
        
        fichier_decompresse = b''

        # Lecture de chaque flux au sein du fichier

        while True:
            
            flux_decompresse = b''
        
            header_magic = lecteur_octets.read(2)
            version = lecteur_octets.read(1)
            compression_level : bytes = lecteur_octets.read(1)
            
            if not header_magic:
                
                break
            
            if header_magic != b'BZ' or version != b'h' or not (b'0' <= compression_level <= b'9'):
                
                raise ValueError('En-tête de flux BZ2 invalide')
            
            compression_level : int = int(compression_level)
            
            # Lecture de chaque bloc
            
            lecteur_de_bits = LecteurDeBitsMSB(lecteur_octets.read())
            
            while True:
                next_magic = lecteur_de_bits.lire_bits(48)
                
                BLOCK_MAGIC = 0x314159265359
                FOOTER_MAGIC = 0x177245385090
                
                if next_magic == BLOCK_MAGIC:
                    block_crc = lecteur_de_bits.lire_bits(32)
                    randomized = lecteur_de_bits.lire_bits(1)
                    orig_ptr = lecteur_de_bits.lire_bits(24)
                    
                    assert randomized == 0
                    
                    # Lire les symboles MTF utilisés au sein du bloc
                    
                    map_l1 = lecteur_de_bits.lire_bits(16) # Par exemple : 0b00110011
                    map_l1_bitfield : str = format(map_l1, 'b').zfill(16) # Par exemple : '00110011'
                    
                    
                    symboles_mtf_presents : List[str] = []
                    
                    for tranche_de_16_octets, tranche_presente in enumerate(map(int, map_l1_bitfield)):
                        
                        if tranche_presente:
                            map_l2 = lecteur_de_bits.lire_bits(16)
                            map_l2_bitfield : str = format(map_l2, 'b').zfill(16)
                            
                            for symbole_au_sein_de_la_tranche, symbole_present in enumerate(map(int, map_l2_bitfield)):
                                
                                if symbole_present:
                                    
                                    symboles_mtf_presents.append(tranche_de_16_octets * 16 + symbole_au_sein_de_la_tranche)
                                            
                    """
                        Décodage des informations relatives à l'arbre Huffman
                    """
                    
                    """
                        Décodage des correspondances entre sélecteur (groupe de
                        50 codes) et arbre
                    """
                    
                    num_trees = lecteur_de_bits.lire_bits(3)
                    num_selecteurs = lecteur_de_bits.lire_bits(15)
                    
                    decodeur_tree_index = DecodeurHuffman(
                        valeur_vers_taille_de_code = {tree_index: tree_index + 1 for tree_index in range(6)},
                        lecteur_de_bits = lecteur_de_bits)
                    
                    selecteur_vers_tree_index_avec_mtf = [decodeur_tree_index.lire_prochaine_valeur() for i in range(num_selecteurs)]
                    
                    selecteur_vers_tree_index = DecodeurMTF().decode(
                        entree = selecteur_vers_tree_index_avec_mtf,
                        dictionnaire_initial = [0, 1, 2, 3, 4, 5]
                    )
                    
                    
                    """
                        Décodage des tailles de code de chaque arbre
                    """
                    
                    # Les symboles Huffman spéciaux :
                    
                    self.RUNA = -2 # Bit "0" pour coder une taille de répétation RLE
                    self.RUNB = -1 # Bit "1" pour coder une taille de répétition RLE
                    self.EOB = 99999999 # End of block - marqueur de fin de bloc
                    
                    symboles_huffman_presents = [self.RUNA, self.RUNB]
                    symboles_huffman_presents += [index for index in range(1, len(symboles_mtf_presents))]
                    symboles_huffman_presents += [self.EOB]
                    
                    # Les symboles Huffman qui codent le delta encoding :
                    
                    DELTA_ENCODING_FIN_DE_TAILLE = 0b0
                    DELTA_ENCODING_INCREMENTER_TAILLE = 0b10
                    DELTA_ENCODING_DECREMENTER_TAILLE = 0b11
                    
                    decodeur_delta_encoding = DecodeurHuffman(
                        valeur_vers_taille_de_code = {
                            DELTA_ENCODING_FIN_DE_TAILLE: 1,
                            DELTA_ENCODING_INCREMENTER_TAILLE: 2,
                            DELTA_ENCODING_DECREMENTER_TAILLE: 2
                        },
                        lecteur_de_bits = lecteur_de_bits)
                    
                    tree_index_vers_arbre : List[DecodeurHuffman] = []
                    
                    for num_tree in range(num_trees):
                        
                        bit_len = lecteur_de_bits.lire_bits(5)
                        
                        tailles_symboles : List[int] = []
                        
                        for num_sym in range(len(symboles_huffman_presents)):
                            
                            taille_symbole = tailles_symboles[-1] if tailles_symboles else bit_len
                            
                            while True:
                                prochaine_instruction_delta = decodeur_delta_encoding.lire_prochaine_valeur()
                                
                                if prochaine_instruction_delta == DELTA_ENCODING_FIN_DE_TAILLE:
                                    break
                                elif prochaine_instruction_delta == DELTA_ENCODING_INCREMENTER_TAILLE:
                                    taille_symbole += 1
                                elif prochaine_instruction_delta == DELTA_ENCODING_DECREMENTER_TAILLE:
                                    taille_symbole -= 1
                            
                            tailles_symboles.append(taille_symbole)
                    
                        # En Python, la fonction "zip" permet de créer une liste de tuples à partir de
                        # plusieurs itérables. Ici, elle nous permet de créer un dictionnaire à partir
                        # de d'un côté les clefs, de l'autre côté les valeurs.
                    
                        tree_index_vers_arbre.append(DecodeurHuffman(
                            valeur_vers_taille_de_code = dict(zip(symboles_huffman_presents, tailles_symboles)),
                            lecteur_de_bits = lecteur_de_bits))
                        
                    """
                        On a décodé notre en-tête de bloc, on peut se mettre à décompresser
                        le contenu du flux !
                    """
                    
                    self.selecteur_vers_tree_index : List[int] = selecteur_vers_tree_index
                    
                    self.tree_index_vers_arbre : List[DecodeurHuffman] = tree_index_vers_arbre
                    self.symboles_mtf_presents : List[int] = symboles_mtf_presents
                    self.orig_ptr : int = orig_ptr
                    
                    self.decompresser_HUFF()
                    self.decompresser_RLE2()
                    self.decompresser_MTF()
                    self.decompresser_BWT()
                    self.decompresser_RLE1()
                    
                    
                    flux_decompresse += self.sortie_decompressee
                        
                    
                    assert mon_crc32_msb(self.sortie_decompressee) == block_crc
                
                elif next_magic == FOOTER_MAGIC:
                    stream_crc = lecteur_de_bits.lire_bits(32)
                    
                    
                    assert mon_crc32_msb(flux_decompresse) == stream_crc
                    break
                
                else:
                    raise ValueError('En-tête de bloc ou en-pied de flux BZ2 invalide')
                
            
            fichier_decompresse += flux_decompresse
            
            lecteur_de_bits.aligner_bits_sur_octet()
            entree = lecteur_de_bits.octets.read()
        
        return fichier_decompresse


    def decompresser_HUFF(self):
        
        symboles_lus : List[int] = []
        
        while True:
            selecteur = len(symboles_lus) // 50
            
            symbole = self.tree_index_vers_arbre[self.selecteur_vers_tree_index[selecteur]].lire_prochaine_valeur()
            
            symboles_lus.append(symbole)
            
            if symbole == self.EOB:
                break
        
        self.symboles_lus = symboles_lus
        

    def decompresser_RLE2(self):
        
        symboles_lus_apres_rle : List[int] = []
        
        symboles_runa_et_runb_a_traiter : List[int] = []
        
        for symbole_lu in self.symboles_lus:
            
            if symbole_lu in (self.RUNA, self.RUNB):
                symboles_runa_et_runb_a_traiter.append(symbole_lu)
            
            else:
                
                if symboles_runa_et_runb_a_traiter:
                    
                    nombre_de_zeros_a_repeter = 0b1
                    
                    for runa_ou_runb in reversed(symboles_runa_et_runb_a_traiter): # TODO reversed est-il bien nécessaire ici ?
                        
                        nombre_de_zeros_a_repeter <<= 1
                        nombre_de_zeros_a_repeter |= 1 if runa_ou_runb == self.RUNB else 0
                    
                    nombre_de_zeros_a_repeter -= 1
                    
                    symboles_lus_apres_rle += [0] * nombre_de_zeros_a_repeter
                    
                    
                    symboles_runa_et_runb_a_traiter = []
                
                if symbole_lu != self.EOB:
                    symboles_lus_apres_rle.append(symbole_lu)
                
                        
        
        self.symboles_lus : List[int] = symboles_lus_apres_rle
        

    def decompresser_MTF(self):
        
        self.symboles_lus : List[int] = DecodeurMTF().decode(
            entree = self.symboles_lus,
            dictionnaire_initial = self.symboles_mtf_presents
        )

    def decompresser_BWT(self):
        
        avant_tri = bytes(self.symboles_lus)
        apres_tri = sorted(avant_tri)
        
        
        # Nos paires Burrows-Wheeler à remettre dans l'ordre :
        
        class PaireBurrowsWheeler:
            
            char_1_valeur : str = None # Cette chaîne fera toujours un caractère
            char_1_position_origine : int = None # Cette position sera connue dès le début
            
            char_2_valeur : str = None # Cette chaîne fera toujours un caractère 
            char_2_position_origine : Optional[int] = None # Cette position sera déterminée après tri
        
        paires_a_trier = []
        
        for char_1_position_origine, (char_1_valeur, char_2_valeur) in enumerate(zip(avant_tri, apres_tri)):
            
            paire = PaireBurrowsWheeler()
            
            paire.char_1_valeur = bytes([char_1_valeur])
            paire.char_1_position_origine = char_1_position_origine
            
            paire.char_2_valeur = bytes([char_2_valeur])
            
            paires_a_trier.append(paire)
        
        
        # Reconstituer les positions d'origine des deuxièmes caractères de chaque paire
        
        paires_triees = sorted(paires_a_trier, key = lambda paire: (paire.char_1_valeur, paire.char_1_position_origine))
        
        for position_tri, paire in enumerate(paires_triees):
            paires_a_trier[position_tri].char_2_position_origine = paire.char_1_position_origine
        
        
        # Reconstituer la chaîne, maintenant qu'on a associé les paires entre elles
        
        donnees_sortie = b''
        
        premiere_paire = paires_triees[self.orig_ptr]
        paire_suivante = premiere_paire
        
        while not donnees_sortie or premiere_paire != paire_suivante:
            
            donnees_sortie += paire_suivante.char_1_valeur
            
            paire_suivante = paires_a_trier[paire_suivante.char_2_position_origine]
        

        self.sortie_decompressee : bytes = donnees_sortie
        
        
        """
            C'est tout bon, on a fait notre BWT !
        """
        

    def decompresser_RLE1(self):
        
        # Par concision, on va se permettre d'effectuer cette passe de RLE
        # avec des expressions régulières :
        # https://docs.python.org/3/library/re.html
        
        def effectuer_repetition(match):
            return match.group(1) * (ord(match.group(2)) + 4)
        
        self.sortie_decompressee : bytes = sub(rb'(.)\1\1\1(.)', effectuer_repetition, self.sortie_decompressee, flags = DOTALL)
        
        
        
print(DecodeurBZ2().decompress(bytes.fromhex('425a68393141592653593a1fb6180000285f940010408600008a0804002e21be00402008003000ac6193547a8d32681a7a20a0006819320488809313434c876f22a986198370606d342d7738f4e832122e26e1116dd24651715bc577384c4616e99a08ac9ca7b2e86b51648248e28ae6143b968594c582c2a6b43619a06461457633aeaa545f44ce49c372aebe6849774b04d957b4e2177301f92f45564f8bb9229c28481d0fdb0c00')).decode('utf8'))

        
        
       

Lorsqu’on l’exécute, cela donne :

Tic-tac tic-tac
Ta Katie t'a quitté
Tic-tac tic-tac
Ta Katie t'a quitté
Tic-tac tic-tac
T'es cocu, qu'attends-tu?

Cuite-toi, t'es cocu
T'as qu'à, t'as qu'à t' cuiter
Et quitter ton quartier
Ta Katie t'a quitté
Ta tactique était toc
Ta tactique était toc

Il s’agit de ce célèbre hit de Boby Lapointe ! :D

Au passage, voici ce que donne notre contenu après passage par la transformation de Burrows-Wheeler :

b"?\nrucrcc\xa9c\xa9\xa9caaass',ssntaaa\xa0eee,,aacccttreetttttTutTuuu\xa0iccccccesu\n\n \n\n\n\n\n\n\n\n\n\nTTT'''TTttttttttttu''KKK'aaaooaaaiiiiii aaoo niiiuutttit''otttTTTttttttaauuuuuuoettcctt ii eeeaeeaadEii ------ \xa9\xa9itit aaarcc - aiiii-tttcqqqctqqqCcqqqq\xc3\xc3\xc3\xc3\xc3\xc3\xc3''ttt "

(Les caractères tels que \xa9 ou \xc3 sont issus de l’encodage de caractères accentués tels que « à » ou « é » avec UTF-8.) Pas étonnant que le second RLE ait été efficace !

En combinant chaque position du contenu transformé, avec les mêmes positions triées par ordre alphabétique, on obtient bien les différentes paires de caractères possibles :

[b'?\n', b'\n\n', b'r\n', b'u\n', b'c\n', b'r\n', b'c\n', b'c\n', b'\xa9\n', b'c\n', b'\xa9\n', b'\xa9\n', b'c\n', b'a ', b'a ', b'a ', b's ', b's ', b"' ", b', ', b's ', b's ', b'n ', b't ', b'a ', b'a ', b'a ', b'\xa0 ', b'e ', b'e ', b'e ', b', ', b', ', b'a ', b'a ', b'c ', b'c ', b'c ', b't ', b't ', b'r ', b'e ', b'e ', b"t'", b"t'", b"t'", b"t'", b"t'", b"T'", b"u'", b"t'", b"T'", b"u'", b"u'", b'u,', b'\xa0,', b'i,', b'c-', b'c-', b'c-', b'c-', b'c-', b'c-', b'e-', b's-', b'u?', b'\nC', b'\nE', b' K', b' K', b' K', b'\nT', b'\nT', b'\nT', b'\nT', b'\nT', b'\nT', b'\nT', b'\nT', b'\nT', b'\nT', b'Ta', b'Ta', b'Ta', b"'a", b"'a", b"'a", b'Ta', b'Ta', b'ta', b'ta', b'ta', b'ta', b'ta', b'ta', b'ta', b'ta', b'ta', b'ta', b'ua', b"'a", b"'a", b'Ka', b'Ka', b'Ka', b"'a", b'ac', b'ac', b'ac', b'oc', b'oc', b'ac', b'ac', b'ac', b'ic', b'ic', b'ic', b'ic', b'ic', b'ic', b' c', b' c', b'ac', b'ac', b'oc', b'oc', b' c', b'nd', b'ie', b'ie', b'ie', b'ue', b'ue', b'te', b'te', b'te', b'ie', b'te', b"'e", b"'e", b'oi', b'ti', b'ti', b'ti', b'Ti', b'Ti', b'Ti', b'ti', b'ti', b'ti', b'ti', b'ti', b'ti', b'ai', b'ai', b'ui', b'ui', b'ui', b'ui', b'ui', b'ui', b'on', b'en', b'to', b'to', b'co', b'co', b'to', b'to', b' q', b' q', b' q', b' q', b'iq', b'iq', b' q', b' q', b' q', b' q', b'er', b'er', b'er', b'ar', b'es', b'es', b'as', b'as', b'ds', b'Et', b'it', b'it', b' t', b' t', b' t', b' t', b' t', b' t', b'-t', b'-t', b'-t', b'-t', b'-t', b'-t', b' t', b' t', b'\xa9t', b'\xa9t', b'it', b'tt', b'it', b'tt', b' t', b' t', b' t', b'at', b'at', b'at', b'rt', b'ct', b'ct', b' t', b' t', b'-t', b' t', b'at', b'it', b'it', b'it', b'it', b'-t', b'tt', b'tt', b'tt', b'cu', b'qu', b'qu', b'qu', b'cu', b'tu', b'qu', b'qu', b'qu', b'Cu', b'cu', b'qu', b'qu', b'qu', b'qu', b'\xc3\xa0', b'\xc3\xa0', b'\xc3\xa9', b'\xc3\xa9', b'\xc3\xa9', b'\xc3\xa9', b'\xc3\xa9', b"'\xc3", b"'\xc3", b't\xc3', b't\xc3', b't\xc3', b' \xc3', b' \xc3']

Le saviez-vous ? Avoir écrit des chansons qui se compressent bien n’est pas le seul fait d’armes qu’on peut reconnaître à Boby Lapointe. C’était aussi un mathématicien qui a inventé son propre système pour représentér l’hexadécimal par des syllabes ou des formes, le Bibi-binaire :

Même compressées, il aurait pu chanter ses paroles !


Ressources utiles :