Lire un fichier généré par le (vieux !) logiciel MS Works

Résolu
Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   -  
mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   -

Bonjour,

J'avais dans mes archives un ensemble de fichiers .wps, et, ne disposant plus de MS Works depuis longtemps, je me suis amusé à rédiger un code pour afficher leur contenu (en mode console pour le moment) :

import sys
import os
import string

files_dir = os.getcwd()+'\\wps_files\\'
specs = [
    'é', 'è', 'à', 'ù', 'ô', 'â', 'ê',
    'û', 'î', 'ç', ' ', '\n', '\t'
]

filename = input('Nom du fichier (sans extension) : ')
filename = files_dir + filename + '.wps'

try:
    with open(filename, 'rb') as ofi:
        block = ofi.read()

    one_row = '\n'
    for k in range(len(block)):
        if (
            chr(block[k]) in string.digits
            or chr(block[k]) in string.ascii_letters
            or chr(block[k]) in specs
            or chr(block[k]) in string.punctuation
        ):
            one_row += chr(block[k])
            if chr(block[k]) == '\n':
                print(one_row, end = '')
                one_row = ''
except:
    print('''\nErreur :''', sys.exc_info()[1])

Ça fonctionne, dans l'ensemble, mais je voudrais ne pas afficher les codes de début et de fin de document (qui sont en principe des indications pour MS Works, fontes de caractères, tailles, etc...).

J'ai essayé de voir si il y a un ou des caractères particuliers juste avant le début du texte et juste après la fin, mais sans résultats probants

Si quelqu'un à une idée (à part appeler Bill Gates pour lui demander la doc du format de fichier .wps :-) )

Exemple de début de fichier:

13 réponses

  1. mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   7 944
     

    Bonjour,

    Peux-tu partager quelques fichier wps afin que l'on comprenne mieux et puisse tester ton code ? Je suspecte que les informations sont organisées comme suit :

    • un header de taille variable, contenant la taille dudit header et les meta données
    • le segment de donnée (ce que tu cherches à afficher) et qui visiblement contient des caractères spéciaux servant pour la mise en forme (ceux que tu filtres dans ton code)
    • un footer -- je ne vois pas trop à quoi il sert, mais d'après ce que tu dis il existe ; quoi qu'il en soit, cela laisse penser que la taille du segment de données est spécifiées dans le header.

    Bref, ce ne sont que des conjectures, sans voir dans le détail un tel fichier, difficile d'en dire plus puisque comme tu l'indique (ainsi que ce lien), les spécifications du format wps ne sont pas publiques.

    Bonne chance

    0
  2. brucine Messages postés 25111 Date d'inscription   Statut Membre Dernière intervention   4 160
     

    Bonjour,

    Comme à l'école polytechnique, pour la Patrie, les Sciences et la Gloire?

    https://archive.org/details/works-8_fr    

    0
  3. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169
     

    Bonjour Brucine,

    Bah, ça m'amuse de faire ce genre de trucs ...

    Bonjour Mamiemando,

    Comment faire pour partager des fichiers ?

    0
    1. mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   7 944
       

      En passant par un site tiers comme par exemple cjoint.com. Ceci dit le lien de Brucine semble être intéressant, peux-tu le tester et nous indiquer s'il ne résout pas d'ores et déjà ton problème ?

      De ce que je comprends du code

      • Un document WPS est un document OLE, ce qui signifie qu'il faut traiter tout le header OLE avant de traiter la partie WPS a proprement parler. C'est le rôle de la classe OleDocument qui organise le fichier binaire en dossier (self.directories). Cet attribut est peuplé après avoir parsé les données OLE (méthode parse_contents). Une fois les dossiers déterminés, on cherche les données WPS dans le dossier CONTENTS.
      • Le format WPS est une composé d'un header composé de plusieurs entries (voir la méthode _process_entries de la classe WpsReader). Celle qui nous intéresse vérifie text_size > 0. Elle permet aussi de localiser dans le fichier le segment de données qui stocke le texte (text_header_offset).
      • Comme aucun traitement n'est fait sur le texte ainsi extrait, le traitement proposé dans le message initial reste nécessaire.

      Bonne chance

      0
  4. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169
     

    J'ai testé le code sur un de mes .wps

    erreur: Not a valid Ole Storage Document

    Forcément, je pense que c'est une ancienne version de Python

    (il y a un print sans () à la fin), et il faut écrire

    if sig != b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
        raise ReaderError("Not a valid Ole Storage Document")

    au lieu de 

    if sig != "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1"
        raise ReaderError("Not a valid Ole Storage Document")

    avec fd.read() on obtient un format byte

    Après correction, évidemment, on a une autre erreur

    buff += fd.read(self.sector_size)
    TypeError: can only concatenate str (not "bytes") to str

    puisqu'on ne manipule plus des strings

    Après, il faut examiner le code en détails pour essayer de comprendre

    ce qu'il fait et corriger les erreurs au fur et à mesure : vaste tache

    J'ai même du corriger ça:

    for i in range(int(self.sector_size / 4)):

    au lieu de :

    for i in range(self.sector_size / 4):
    0
    1. mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   7 944
       

      Bonjour,

      Effectivement le code a été prévu pour python2 (cela se voit au niveau du print) et du coup il y a plein de petites adaptations à apporter, donc celles que tu as reportées.

      J'ai même du corriger ça:  
      for i in range(int(self.sector_size / 4))
      au lieu de  
      for i in range(self.sector_size / 4):

      Oui c'est normal : en python 2, / se comporte comme en C : vu que le type de gauche est entier, c'est donc implicitement la division entière (qui s'écrit // en python3). Tu aurais donc pu traduire cette instruction par :

      for i in range(self.sector_size // 4):

      Après, je ne pense pas qu'il y ait tant que ça à corriger. Je regarderai un peu plus en détail quand j'aurai plus de temps.

      0
  5. Vous n’avez pas trouvé la réponse que vous recherchez ?

    Posez votre question
  6. mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   7 944
     

    Hello,

    J'ai commencé à retaper le script (en incluant certaines des modifications dont tu as parlé), mais ça n'est pas encore fonctionnel.

    #!/usr/bin/env python3
    import os
    import sys
    import struct
    import re
    from collections import namedtuple
    
    WPSSTRIPPATTERN = re.compile(r"\r")
    
    def unicode(s: str, fmt: str) -> str:
        return s.decode(fmt, "strict")
    
    class ReaderError(Exception):
        pass
    
    class OleDocument(object):
    
        def __init__(self, file_name):
            self.file_name = file_name
            self.sectors = []
            self.directories = {}
            self._parse_contents()
    
        def _read_fat_sector(self, fat_sector, fd):
            fd.seek(self.sector_size * (fat_sector + 1), os.SEEK_SET)
            for i in range(self.sector_size // 4):
                sector = struct.unpack("<I", fd.read(4))[0]
                yield sector
    
        def _parse_contents(self):
            with open(self.file_name, "rb") as fd:
                data = fd.read()
            with open(self.file_name, "rb") as fd:
                sig = fd.read(8)
                expected_sig = bytes("\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "iso-8859-1")
                if sig != expected_sig:
                    raise ReaderError(f"Not a valid Ole Storage Document:\nobtained: {sig}\nexpected: {expected_sig}")
                header = fd.read(68)
                (
                    sector_shift,
                    mini_sector_shift,
                    fat_sector_count,
                    first_dir_sector,
                    first_mini_sector,
                    mini_sector_count,
                ) = struct.unpack("<22xHH10xII8xII8x", header)
                self.sector_size = 1 << sector_shift
                self.mini_sector_size = 1 << mini_sector_shift
                fat_sectors = []
    
                for i in range(fat_sector_count):
                    fat_sectors.append(struct.unpack("<I", fd.read(4))[0])
    
                for fat_sector in fat_sectors:
                    for sector in self._read_fat_sector(fat_sector, fd):
                        self.sectors.append(sector)
    
                # Now read the directories
                buff = bytes()
                for count, dir_sector in enumerate(self._get_sectors(first_dir_sector)):
                    fd.seek(
                        self.sector_size * dir_sector + self.sector_size,
                        os.SEEK_SET
                    )
                    buff += fd.read(self.sector_size)
                for i in range((count + 1) * 4):
                    print(f"{i * 128 }:{(i + 1) * 128} -- {len(buff)}")
                    print(buff[i * 128 : (i + 1) * 128])
                    name, sector, size = struct.unpack(
                        "<64s52xII4x",
                        buff[i * 128 : (i + 1) * 128]
                    )
                    name = re.sub(
                        "\x00", "",
                        unicode(name, "UTF16")
                    )
                    self.directories[name] = (sector, size)
                    print(f"{name} -> {sector, size}")
    
        def _get_sectors(self, sector):
            while True:
                if sector == 0xFFFFFFFE: #Last directory
                    break
                yield sector
                sector = self.sectors[sector]
    
        def read_stream(self, name):
            #name = unicode(name)
            if name not in self.directories:
                raise ReaderError(f"No stream called {name}: known streams are {list(self.directories.keys())}")
            start, size = self.directories[name]
            buff = bytes()
            with open(self.file_name, "rb") as fd:
                for sector in self._get_sectors(start):
                    fd.seek(self.sector_size * sector + self.sector_size, os.SEEK_SET)
                    buff += fd.read(self.sector_size)
                    size -= self.sector_size
                    if size <= 0:
                        break
            return buff
    
    class WPSReader(object):
        def __init__(self, file_name):
            self.document = OleDocument(file_name)
            self.strip_pattern = WPSSTRIPPATTERN
    
        def _process_entries(self, entry_buff):
            magic, local, next_offset = struct.unpack("<HHI", entry_buff[:8])
            if magic != 0x01F8:
                raise ReaderError("Invalid format - Entry magic tag incorrect")
            entry_pos = 0x08 #2 WORDS & 1 DWORD
            for i in range(local):
                size = struct.unpack("<H", entry_buff[entry_pos:entry_pos+0x2])[0]
                name, offset, entry_size = struct.unpack("<2x4s10xII",
                                            entry_buff[entry_pos:entry_pos+size])
                if name == "TEXT": #Success!
                    return (local, 0x00, offset, entry_size)
                entry_pos += size
            return (local, next_offset, 0x00, 0x00) #Needs to be run again
    
        def extract_text(self):
            buff = self.document.read_stream("CONTENTS")
            total_entries = struct.unpack("<12xH",  buff[:14])[0]
            entries_pos = 24
            while True:
                entries, next_offset, text_header_offset, text_size = \
                    self._process_entries(buff[entries_pos:])
                if text_size: #TEXT found
                    break
                total_entries -= entries
                if total_entries and next_offset:
                    entries_pos = next_offset #Move to next block
                else:
                    raise ReaderError("Unable to find TEXT secion. File corrupt?")
            text = buff[text_header_offset:text_header_offset+text_size]
            return self.strip_pattern.sub("\r\n", unicode(text, "UTF16"))
    
    
    if __name__ == '__main__':
        reader = WPSReader(sys.argv[1])
        print(reader.extract_text())

    Le problème c'est qu'il n'y a pas de dossier "CONTENTS". Et j'avoue que quand on regarde le contenu hexadécimal de ton fichier WPS (premier lien), je n'en ai pas vu.

    Le segment de données qui nous intéresse figure entre les deux "balises" MN0 et CompObj dans le fichier, et il ne semble pas y avoir de balises qui précède les données texte plus proche que MN0. Or dans le code que je propose, j'affiche les buffers associés à chaque dossier, mais aucun ne couvre le texte.

    Il faudrait passer un peu plus de temps et regarder les différentes valeurs (secteurs, tailles, etc.).

    À ce stade j'ai des soupçons sur la fonction _read_fat_sector qui retourne des valeurs étranges... et sur l'absence de la chaîne CONTENTS (au format unicode) dans le fichier WPS.

    Toujours sur ce fichier d'exemple, on peut repérer de manière empirique le segment de données qui contient du texte. Pour obtenir ces valeurs, j'ai regardé le fichier WPS dans vim et repérer le premier mot (Judo) et le dernier mot (DURIAUX) et j'en ai déduis que la plage qui nous intéressait se situait entre les octets 2368 et 3121.

    with open(sys.argv[1], "rb") as fd:
       data = fd.read()
       start = data.find(bytes("Judo", "iso-8859-1"))
       end = data.find(bytes("DURIAUX", "iso-8859-1")) + len("DURIAUX") + 4
       print(data[start:end].decode("iso-8859-1"))

    En admettant qu'on parvienne à trouver start et end de manière automatique, on pourrait complètement ignorer l'analyse du header.

    Bonne chance

    0
  7. [Dal] Messages postés 6122 Date d'inscription   Statut Contributeur Dernière intervention   1 108
     

    Salut Phil_1857,

    En dehors du défi de réaliser cela en Python, si tu cherches juste à lire le contenu avec un logiciel actuel et libre supportant ce format, tu peux télécharger LibreOffice et lire les fichiers .wps avec LibreOffice Writer.

    (testé avec succès sur les 2 fichiers que tu as mis à disposition)


    0
    1. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169
       

      Merci Dal

      En fait, avec mon code, on lit parfaitement le contenu des fichiers

      Il y a simplement des caractères bizarres en début et en fin de fichier

      mais ça n'est pas gênant, je voulais juste m'amuser avec la lecture

      de fichiers binaires et améliorer ça...

      Le résultat avec interface Tkinter

      0
  8. mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   7 944
     

    En poursuivant la piste amorcée dans #9, on aboutit à :

    #!/usr/bin/env python3
    import sys  
    import string
    
    def main(filename):
        with open(filename, "rb") as fd:
            data = fd.read()
            specs = str([
                'â', 'à', 'Â', 'À',
                'ç', 'Ç',
                'é', 'ê', 'è', 'É', 'Ê', 'È',
                'î', 'Î',
                'ô', 'Ô',
                'û', 'ù', 'Û', 'Ù',
            ])
            valids = set(specs + string.digits + string.ascii_letters + string.punctuation)
            i = j = None
            segments = list()
            for (k, byte) in enumerate(data):
                if chr(byte) in valids or byte in {9, 10, 13}:
                    if i is None:
                        i = j = k
                    j += 1
                else:
                    if (i, j) != (None, None):
                        segments.append((i, j))
                        i = j = None
            assert segments
            (start, end) = sorted(
                segments,
                key=lambda segment: segment[1] - segment[0],
                reverse=True
            )[0]        
            return data[start:end].decode("iso-8859-1")
    
    if __name__ == '__main__':
        print(main(sys.argv[1]))

    L'idée est de reconstruire les segments contenant des séquences de caractères ASCII valides et de prendre le segment le plus long (les autres correspondent par exemple aux noms des polices impliquées dans le document). Puis on décode les octets associés à ce segment de données...

    On peut probablement améliorer la manière dont sont définis les caractères valides, notamment dans certaines langues, d'autres caractères spéciaux peuvent apparaître.

    Bonne chance

    0
  9. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169
     

    Bonjour Mamiemando,

    Merci d'avoir passé un peu de temps là-dessus

    Ca à l'air de marcher sur plusieurs fichiers testés !

    Je vais maintenant analyser ton code en détails ...

    0
  10. mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   7 944
     

    Hello,

    Bon j'ai trouvé 10 minutes pour peaufiner le code proposé dans #11, et qui maintenant est agnostique sur les caractères (latins) impliqués dans le document.

    #!/usr/bin/env python3
    import sys  
    
    def find_longest_segment(data: bytes) -> tuple:
        i = j = None
        segments = list()
        for (k, byte) in enumerate(data):
            a = chr(byte)
            if a.isprintable() or a.isspace():
                if i is None:
                    i = j = k
                j += 1
            else:
                if (i, j) != (None, None):
                    segments.append((i, j))
                    i = j = None
        if (i, j) != (None, None):
            segments.append((i, j)) 
            i = j = None
        assert segments
        return max(
            segments,
            key=lambda segment: segment[1] - segment[0]
        )
    
    def decode_wps_data(
        data: bytes,
        encoding: str = "iso-8859-1"
    ) -> str:
        (start, end) = find_longest_segment(data)
        return data[start:end].decode(encoding)
    
    def decode_wps_file(
        filename: str,
        encoding: str = "iso-8859-1"
    ) -> str:
        with open(filename, "rb") as fd:
            data = fd.read()
            return decode_wps_data(data, encoding)
                 
    if __name__ == "__main__":
        text = decode_wps_file(sys.argv[1], "iso-8859-1")
        print(text)

    Note que si tu as des documents qui reposent sur un autre alphabet (genre du cyrillique, des caractères chinois, etc...), ceux-ci ne sont pas supportés par un encodage iso-8859-1 (qui est l'encodage traditionnellement utilisé sous Windows pour un document écrit en français). Bref, si on veut être perfectionniste, il faudrait sans doute permettre à l'utilisateur de préciser l'encodage et envisager d'utiliser argparse.

    Bonne continuation

    0
    1. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169
       

      Bonjour Mamiemando,

      Ah, effectivement, je vois que l'on n'a plus besoin des listes

      specs et valids, par contre l'ensemble des 62 fichiers ne concerne que

      des courriers ou des comptes rendus en français, donc pas de cyrillique

      ou de chinois

      Merci encore

      0
  11. [Dal] Messages postés 6122 Date d'inscription   Statut Contributeur Dernière intervention   1 108
     

    Avec un éditeur hexadécimal on peut voir, sur les deux fichiers que Phil_1857 a mis en ligne, que les données afférentes au texte commencent au déplacement 940 en hexa (2368 en décimal) et que les données de texte se terminent par une série de 00.

    Du coup, le programme suivant utilisant les regex Python pour capturer les données à partir du byte 2368 et jusqu'à rencontrer le premier caractère null fonctionne chez moi sur les deux fichiers d'exemple, la regex faisant le travail de capture en une ligne (ligne 11 ci-dessous) :

    #!/usr/bin/env python3
    import sys
    import re
    
    def decode_wps_file(
        filename: str,
        encoding: str = "iso-8859-1"
    ) -> str:
        with open(filename, "rb") as fd:
            data = fd.read()
            result = re.search(b"([^\x00]+)", data[2368:])
            return result.group(1).decode(encoding)
    
    if __name__ == "__main__":
        text = decode_wps_file(sys.argv[1], "iso-8859-1")
        print(text)
    

    Cela devrait fonctionner à condition que l'entête ait une taille fixe, comme sur les deux exemples que Phil_1857 a donné et quelle que soit la taille du texte, même s'il ne fait que quelques caractères, à condition que le format supposé soit exact et que le texte se termine bien par le caractère null.

    0
    1. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169
       

      Merci Dal,

      mais les en-têtes n'ont pas tous la même longueur ...

      0
  12. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169
     

    il me faut surement une autre paire de lunettes, je ne vois pas de bouton

    "marquer comme résolu"   :-)

    0
    1. [Dal] Messages postés 6122 Date d'inscription   Statut Contributeur Dernière intervention   1 108
       

      C'est en bas de ton premier message :

      0
  13. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169
     

    ?????????????????????????

    0
    1. [Dal] Messages postés 6122 Date d'inscription   Statut Contributeur Dernière intervention   1 108
       

      Je ne sais pourquoi cela ne s'affiche pas chez toi.

      En tant que "contributeur", je suppose que je dispose du droit de marquer une discussion comme étant résolue.

      J'ai marqué la discussion résolue.

      0
      1. mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   7 944 > [Dal] Messages postés 6122 Date d'inscription   Statut Contributeur Dernière intervention  
         

        Normalement, comme Phil_1857 est créateur du message il aurait dû voir le bouton "Résolu", comme l'indique le tutoriel officiel. Ça ressemble à un bug, je remonte le problème et on verra ce qu'il en est.

        0
      2. Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention   169 > mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention  
         

        Je ne sais pas si il faut bien continuer sur ce fil de discussion, mais en

        fait de bug, je trouve bizarre que je n'aie pas à me connecter lorsque

        j'ouvre le site, la preuve: je peux répondre directement à un message

        autre preuve, le menu déroulant en haut de page affiche bien

        "se déconnecter": (donc je suis connecté)

         Autre chose:

        je me suis inscrit en mars 2019, mais, depuis peu, l'infobulle sur mon pseudo

        dit que je me suis inscrit le 23 mars 2020 ....

        0
      3. mamiemando Messages postés 33228 Date d'inscription   Statut Modérateur Dernière intervention   7 944 > Phil_1857 Messages postés 1883 Date d'inscription   Statut Membre Dernière intervention  
         

        Bizarre, essaye de vider tes cookies. Concernant la date d'inscription, je ne peux pas te dire, mais j'ai remonté le problème aussi.

        0