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

Résolu
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 - Modifié le 22 sept. 2023 à 15:50
mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 - 28 sept. 2023 à 17:49

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:

A voir également:

13 réponses

mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 7 749
22 sept. 2023 à 16:00

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
brucine Messages postés 14333 Date d'inscription lundi 22 février 2021 Statut Membre Dernière intervention 27 avril 2024 1 817
22 sept. 2023 à 16:09

Bonjour,

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

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

0
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
22 sept. 2023 à 16:45

Bonjour Brucine,

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

Bonjour Mamiemando,

Comment faire pour partager des fichiers ?

0
brucine Messages postés 14333 Date d'inscription lundi 22 février 2021 Statut Membre Dernière intervention 27 avril 2024 1 817
22 sept. 2023 à 17:37

Je n'y comprends rien (pas à l'anglais, à Python), un peu de lecture en anglais si ça t'inspire, mais à part le blabla, le code n'est de toute façon pas en français.

http://blog.digitally-disturbed.co.uk/2012/04/ive-started-work-on-pulling-text-from.html

0
mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 7 749
Modifié le 22 sept. 2023 à 18:23

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
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
Modifié le 22 sept. 2023 à 19:05

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
mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 7 749
Modifié le 25 sept. 2023 à 14:26

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

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

Posez votre question
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
23 sept. 2023 à 17:28

Bonjour Mamiemando,

Ci-dessous 2 liens vers 2 fichiers .wps, comme tu me le demandais

hier

https://www.cjoint.com/c/MIxpyFYDoTr

https://www.cjoint.com/c/MIxpzZkhkEr

Dis-moi ce que l'on peut en tirer ....

0
mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 7 749
Modifié le 27 sept. 2023 à 17:18

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
[Dal] Messages postés 6174 Date d'inscription mercredi 15 septembre 2004 Statut Contributeur Dernière intervention 2 février 2024 1 083
Modifié le 27 sept. 2023 à 17:55

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
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
27 sept. 2023 à 18:22

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
mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 7 749
Modifié le 27 sept. 2023 à 17:51

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
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
27 sept. 2023 à 18:28

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
mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 7 749
Modifié le 27 sept. 2023 à 20:20

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
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
28 sept. 2023 à 10:17

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
[Dal] Messages postés 6174 Date d'inscription mercredi 15 septembre 2004 Statut Contributeur Dernière intervention 2 février 2024 1 083
Modifié le 28 sept. 2023 à 00:38

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
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
28 sept. 2023 à 11:29

Merci Dal,

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

0
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
28 sept. 2023 à 11:32

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

"marquer comme résolu"   :-)

0
[Dal] Messages postés 6174 Date d'inscription mercredi 15 septembre 2004 Statut Contributeur Dernière intervention 2 février 2024 1 083
28 sept. 2023 à 11:55

C'est en bas de ton premier message :

0
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178
28 sept. 2023 à 12:02

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

0
[Dal] Messages postés 6174 Date d'inscription mercredi 15 septembre 2004 Statut Contributeur Dernière intervention 2 février 2024 1 083
28 sept. 2023 à 13:05

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
mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 7 749 > [Dal] Messages postés 6174 Date d'inscription mercredi 15 septembre 2004 Statut Contributeur Dernière intervention 2 février 2024
28 sept. 2023 à 16:48

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
Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024 178 > mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024
Modifié le 28 sept. 2023 à 17:01

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
mamiemando Messages postés 33081 Date d'inscription jeudi 12 mai 2005 Statut Modérateur Dernière intervention 27 avril 2024 7 749 > Phil_1857 Messages postés 1883 Date d'inscription lundi 23 mars 2020 Statut Membre Dernière intervention 28 février 2024
Modifié le 28 sept. 2023 à 17:57

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