PowerQuery / Extraire les tableaux d’un document Word

mromain

XLDnaute Barbatruc
Bonjour à tous,

J’ai récemment eu à récupérer des informations issues de tableaux Word via PowerQuery.
Les seules solutions que j’ai alors trouvées consistaient à convertir au préalable le document Word dans un autre format (pdf, html) pour pouvoir le lire ensuite via PowerQuery.

La fonction ExtractWordTables ci-dessous permet de se passer de cette étape de conversion pour attaquer directement le fichier Word (au format Open XML).

Elle est sûrement imparfaite, un peu lente, mais elle a bien répondu à mon besoin, du coup je partage.
Code:
let
    fn = (wordFilePath as text) as table =>
        let
      
            // unzip function (source: https://github.com/ibarrau/PowerBi-code/blob/master/PowerQuery/ExtractZIP.pq)
            fnUnzipFile = (ZIPFile as binary) =>
                let
                    Header = BinaryFormat.Record([      Signature        = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger32,ByteOrder.LittleEndian),
                                                        Version          = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger16,ByteOrder.LittleEndian),
                                                        Flags            = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger16,ByteOrder.LittleEndian),
                                                        Compression      = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger16,ByteOrder.LittleEndian),
                                                        ModTime          = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger16,ByteOrder.LittleEndian),
                                                        ModDate          = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger16,ByteOrder.LittleEndian),
                                                        CRC32            = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger32,ByteOrder.LittleEndian),
                                                        CompressedSize   = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger32,ByteOrder.LittleEndian),
                                                        UncompressedSize = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger32,ByteOrder.LittleEndian),
                                                        FileNameLen      = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger16,ByteOrder.LittleEndian),
                                                        ExtraFieldLen    = BinaryFormat.ByteOrder(BinaryFormat.UnsignedInteger16,ByteOrder.LittleEndian)
                                                    ]),
                  
                    FileEntry = BinaryFormat.Choice(Header, each if _[Signature] <> 0x4034B50 then BinaryFormat.Null else
                                                             BinaryFormat.Record([
                                                                            Header           = _,
                                                                            FileName         = BinaryFormat.Text(_[FileNameLen]),
                                                                            ExtraField       = BinaryFormat.Text(_[ExtraFieldLen]),
                                                                            UncompressedData = BinaryFormat.Transform(BinaryFormat.Binary(_[CompressedSize]),(x) => try Binary.Buffer(Binary.Decompress(x, Compression.Deflate)) otherwise null)
                                                                            ]), type binary),
                    ZipFormat = BinaryFormat.List(FileEntry, each _<> null),
                  
                    Entries = List.Transform(
                                    List.RemoveLastN( ZipFormat(ZIPFile), 1),
                                    (e) => [FileName = e[FileName], Content = e[UncompressedData] ]
                                )
                in
                    Table.FromRecords(Entries),
          
            // function used to perform multiple Text.Replace
            fnReplace = (text as text, replacements as list) as text => let t=Text.Replace(text, replacements{0}{0}, replacements{0}{1}) in if List.Count(replacements)=1 then t else @fnReplace(t, List.RemoveFirstN(replacements, 1)),
          
            // function used to format a javascript string
            fnFormatJsString = (text as text) as text => fnReplace(text, {{"\", "\\"}, {"'", "\'"}, {"#(lf)", "\n"}}),
          
            // regex Replace
            fnRegExpReplace = (text as nullable text,pattern as nullable text,replace as nullable text) as text =>
                let
                    scriptJS = "var input = '" & fnFormatJsString(text) & "';
                                var regExp = " & pattern & ";
                                var replace = '" & fnFormatJsString(replace) & "';
                                var result = input.replace(regExp, replace);
                                document.write('""' + result + '""')",
                    webPage = Web.Page("<script>" & scriptJS & "</script>"),
                    resultJS = webPage{0}[Data]{0}[Children]{1}[Children]{0}[Text],
                    toText = Expression.Evaluate(resultJS)
                in
                    toText,
          
            // find all regex matches
            fnGetRegExpMatches = (text as text, pattern as text) as table =>
                let
                    scriptJS = "var input = '" & fnFormatJsString(text) & "';
                                var regExp = " & pattern & ";
                                var match;
                                var matchesInfos = [];
                                var result;
                                var nbSubMatches=0;
                                while (match = regExp.exec(input)){
                                  var matchInfos = [];
                                  if (match.length-1 > nbSubMatches){
                                    nbSubMatches = match.length-1;
                                  }
                                  matchInfos.push(match.index.toString());
                                  for (var i = 0; i < match.length; i++){
                                    matchInfos.push('""' + match[i].toString().replace(/""/g, '""""') + '""');
                                  }
                                  matchesInfos.push('{' + matchInfos.join(', ') + '}');
                                }
                                result = '#table(type table [Position=number, Match=text'
                                for (var i = 1; i <= nbSubMatches; i++){
                                  result += (', Group' + i.toString() + '=text');
                                }
                                result += '], {' + matchesInfos.join(', ') + '})';
                                result = result.replace(/[<>&\n]/g, function(x) {return {'<': '&lt;', '>': '&gt;', '&': '&amp;', '\n': '<br />'}[x];});
                                document.write(result)",
                webPage = Web.Page("<script>" & scriptJS & "</script>"),
                resultJS = webPage{0}[Data]{0}[Children]{1}[Children]{0}[Text],
                toTable = Expression.Evaluate(resultJS)
            in
                toTable,
          
            // function used to get 'word/document.xml' of an openXml Word document
            fnGetDocumentXml = (filePath as text) as text =>
                let
                    FileBinary = try Binary.Buffer(File.Contents(filePath)) otherwise error [Reason="Source file", Message="Source file was not found (" & filePath & ")."],
                    FileContent = fnUnzipFile(FileBinary) ,
                    FiltreDocContent = let rowDocumentXml = Table.SelectRows(FileContent, each ([FileName] = "word/document.xml")) in if Table.RowCount(rowDocumentXml) = 1 then rowDocumentXml else error [Reason="Source file", Message="Source file is not a valid OpenXML Word file (" & filePath & ")."],
                    TexteDocContent = Table.FromColumns({Lines.FromBinary(FiltreDocContent{0}[Content],null,null,65001)}){1}[Column1]
                in
                    TexteDocContent,
                      
            // function used to remove code tags (manage tabulation and new lines)
            fnCleanText = (xmlText as text) as text =>
                let
                    xmlParagraphs = Text.Split(Text.Replace(xmlText, "<w:br/>", "</w:p>"), "</w:p>"),
                    splitTabs = List.Transform(xmlParagraphs, each Text.Split(_, "<w:tab/>")),
                    cleanSplitTabs = List.Transform(splitTabs, (listXmls as list) as list => List.Transform(listXmls, each fnRegExpReplace(_, "/<((?!>).)*>/g", ""))),
                    joinTabs = let listParagraphs = List.Transform(cleanSplitTabs, each Text.Combine(_, "#(tab)")) in if List.Last(listParagraphs)="" then List.RemoveLastN(listParagraphs, 1) else listParagraphs,
                    joinParagraphs = Text.Combine(joinTabs, "#(lf)")
                in
                    joinParagraphs,
          
            // function used to manage a cell (even if merged)
            fnExtractCell = (cellXml as text) as list =>
                let
                    nbMerge = let val = Number.From(Text.BetweenDelimiters(cellXml, "<w:gridSpan w:val=""", """")) in if val = null then 1 else val,
                    cellValue = let rawVal = fnCleanText(cellXml) in if rawVal = "" then null else rawVal,
                    listMergedCells = List.Transform({1 .. nbMerge}, each if _=1 then cellValue else null)
                in
                    listMergedCells,
          
            // function used to split a row in columns items
            fnExtractColumnsValues = (xmlColumns as list, nbCol as number) as list =>
                let
                    cleanColumns = List.Transform(xmlColumns, fnExtractCell),
                    combineColumns = List.Combine(cleanColumns),
                    completeColumns = let cols = List.Count(combineColumns) in combineColumns & (if cols=nbCol then {} else List.Transform({1 .. nbCol-cols}, each null))
                in
                    completeColumns,
          
            // function used to convert a openXml table code in table
            fnExtractTable = (tableXml as text) as table =>
                let
                    nbCol = List.Count(Text.Split(tableXml, "<w:gridCol"))-1,
                    rowsMatches = fnGetRegExpMatches(tableXml, "/<w:tr[ >](((?!<w:tr[ >]).)*)<\/w:tr>/g"),
                    extractColumns = Table.AddColumn(rowsMatches, "Columns", each fnGetRegExpMatches([Match], "/<w:tc[ >](((?!<w:tc[ >]).)*)<\/w:tc>/g")[Match], type list),
                    extractColumnsValues = Table.TransformColumns(extractColumns,{{"Columns", each fnExtractColumnsValues(_, nbCol)}}),
                    ToTable = Table.FromRows(extractColumnsValues[Columns])
                in
                    ToTable,
          
            documentXml = fnGetDocumentXml(wordFilePath),
            tablesMatches = fnGetRegExpMatches(documentXml, "/<w:tbl>(((?!<w:tbl>).)*)<\/w:tbl>/g")[[Match]],
            colIndex = Table.AddIndexColumn(tablesMatches, "Index", 1, 1),
            colTable = Table.AddColumn(colIndex, "Table", each fnExtractTable([Match]), type table),
            result = Table.RemoveColumns(colTable,{"Match"})
        in
            result,


    documentationMetadata =
        [
            Documentation.Name = "ExtractWordTables",
            Documentation.Description = "This function extract tables of an OpenXml Word file (docx, docm, ...).",
            Documentation.Examples =
                {
                    [
                        Description = "List all tables of ""c:\folder\document.docx"" document:",
                        Code = "ExtractWordTables(""c:\folder\document.docx"")",
                        Result = "-- Table with all document tables. --"
                    ],
                    [
                        Description = "Extract first table of ""c:\folder\document.docx"" document:",
                        Code = "ExtractWordTables(""c:\folder\document.docx""){0}[Table]",
                        Result = "-- First table of the document. --"
                    ]
                }
        ]

in
    Value.ReplaceType(fn, Value.ReplaceMetadata(Value.Type(fn), documentationMetadata))

A+

Edit : compatibilité XL 2013
 
Dernière édition:

Staple1600

XLDnaute Barbatruc
Bonjour mromain

Je vois qu'il y a du javascript dans ton code
Cela fonctionnera avec quelle version d'Excel ?
365 ?
(ou en dessous à partir de 2013)

je suis pas sur que mon Excel 2013 (et l'addon Powerquery dont je dispose) soit capable de digérer ta création qui semble bien alléchante ;)
 

mromain

XLDnaute Barbatruc
Bonjour JM,

Merci pour ton retour. J'ai testé sur XL2013 et effectivement ça ne fonctionnait pas.
Cela était dû à la fonction Table.AddIndexColumn qui ne semble pas être tout à fait la même.

J'ai corrigé le code dans le premier post. Il marche bien chez moi sur 2013 et 2019.

Concernant le JavaScript, je l'utilise ici juste pour les expressions rationnelles. Il est interprété/exécuté avec la fonction Web.Page("<script>" & scriptJS & "</script>").
C'est bien compatible avec 2013.

A+
 

Staple1600

XLDnaute Barbatruc
Bonjour mromain

J'ai voulu testé mais je dois mal m'y prendre.
Voila ce que j'obtiens
(NB: le document Word contient trois tableaux distincts non vides)
ExPQUERY.png

Est-ce que tu peux donner plus d'explications sur comment utiliser ta fonction ?
Merci
 

mromain

XLDnaute Barbatruc
Bonjour JM,

Tu t'y es bien pris ! Et c'est bien comme ça que s'utilise la fonction.

La fonction t'a bien renvoyé les trois tableaux de ton document Word (un par ligne).
La colonne Table contient tes trois tableaux (élément de type Table dans PowerQuery).
Si tu clique sur la première Table, tu verras alors le contenu du premier tableau de ton document.

A+
 

Staple1600

XLDnaute Barbatruc
Bonjour mromain

Quand je clique dans PowerQuery (avant de charger le résultat dans un listObject)
Il me dit que la table est vide
(et ce pour les trois tables)

Ci-dessous le contenu du document Word.
tableaux.png
 

mromain

XLDnaute Barbatruc
Bonjour JM,

Ça m’intrigue un petit peu.
Du coup j’ai fait le test sur 2013 (création du doc Word avec 2 tableaux et requêtes PowerQuery) et je n’ai pas rencontré de soucis.

Le doc Word :
DocWord.png


Le premier tableau :
Table1.png


Le second :
Table2.png



Peux-tu s’il te plait m’envoyer le document Word que t’as utilisé ?
Merci et bonne journée.

A+
 

Staple1600

XLDnaute Barbatruc
Bonjour mromain

J'ai testé ce matin au boulot en récréant tes tableaux => test OK
(sauf qu'au boulot, je dispose d'Excel 2019)

Ce soir, je refait le test sur Excel Famille et Etudiant 2013
(version 15.0.5381)
Et toujours le même résultat
ex2PQ.png

Voila comment je procède pour faire le test
1) Je lance PowerQuery en créant une requête vide
2) J'affiche l'Editeur avancé et j'y colle ton code M
3) Je ferme PQ
4) J'affiche la rêquête en cliquant sur Modifier
5) Au dessus du bouton Appeler, je colle le chemin et le nom du fichier Word
6) Je clique sur Appeler
NB: je constate que nos copies d'écran diffèrent: moi, je ne vois le nom de la fonction mais juste Requête 1
Ci-dessous éléments d'information sur PQ
reglagesPQ.png
 
Dernière édition:

mromain

XLDnaute Barbatruc
Bonjour JM,

Merci pour ton retour :)
NB: je constate que nos copies d'écran diffèrent: moi, je ne vois le nom de la fonction mais juste Requête 1
Ci-dessous éléments d'information sur PQ
C’est juste dû au fait que j’ai renommé Requête1 en ExtractWordTables.

Sinon, ne reproduisant pas ce problème, c’est dur de savoir où ça coince…
De mon côté, cela marche bien sur mes 2 configurations :
  • Office Professionnel Plus 2013 version 15.0.5423.1000 / Power Query version 2.62.5222.701 32 bits
  • Office Professionnel Plus 2019 version 2202 build 14931.20120

En l’état, je ne vois pas d’où peut venir le souci.
Sur ta version 2013, la requête semble bien dézipper le document, lire le contenu de document.xml et exécuter des expressions régulières (vu qu’elle voit bien qu’il y a 2 tables).
Le traitement semble bugger après, alors qu’il n’y a que des fonctions basiques de PQ qui sont utilisées.

Peux-tu m’envoyer un document créé à partir de ton 2013 (où tu n’arrives pas à voir le contenu des tables) ?
Ainsi, je pourrai tester sur le même document et ça permettra de savoir si ça vient de la structure du document.

Bonne journée
 

Staple1600

XLDnaute Barbatruc
Bonsoir mromain

Ci-joint le document Word et le classeur Excel de test
(avec ta fonction inclue - je me suis permis de la franciser un chouia ;))
 

Pièces jointes

  • mromain.zip
    23.7 KB · Affichages: 7

Staple1600

XLDnaute Barbatruc
Précisions
Sur le site de Microsoft, on peut lire
La version celle ce que j'ai mis en jaune, non ? (*)
Version :Date de publication :
2.59.5135.20115/09/2020
Nom du fichier :Taille du fichier :
PowerQuery_2.62.5222.761 (32-bit) [fr-FR].msi17.9 MB
PowerQuery_2.62.5222.761 (64-bit) [fr-FR].msi18.0 MB
(*) ce qui correspond au numéro qui s'affiche quand dans PQ, on clique sur A propos de
 

Staple1600

XLDnaute Barbatruc
Précisions (bis)
J'ai testé ta fonction dans PowerBi avec un secret espoir chevillé au corps ;)
1) O joie (tu me diras c'es normal, il y a un Editeur PQ dans PBI)
Elle est reconnue.
2) O Malheur
Elle fonctionne comme sur Excel, elle voit les deux tables mais dit qu'elles sont vides.

NB: J'ai mis à jour Office et PQ
Office:15.0.5423.1000
PQ: 2.62.5222.701

J'ai désactivé les autres compléments.

Il me resterait la piste WD à tester.
 

mromain

XLDnaute Barbatruc
Bonjour JM,

Merci pour le fichier Word de test. J’ai le même comportement chez moi.
Après analyse, le XML n’a pas exactement la même structure.

J’ai donc mis à jour la fonction (les 2 patterns d’expressions régulières utilisées dans la sous-fonction fnExtractTable) dans le premier post.
Celle-ci devrait fonctionner également avec les fichiers Word créés avec ta version de 2013.

Merci pour ton intérêt, cela a permis d’améliorer la fonction !

Bonne journée
 

Staple1600

XLDnaute Barbatruc
Bonsoir mromain

Cette fois, cela fonctionne sur mon Excel 2013 ;)

Maintenant, comment se fait-il que le format d'un *.docx généré avec un Word 2013 Home soit différent d'un *.docx généré par un Word Pro ???

On cache tout, on nous dit rien ;)
 

mromain

XLDnaute Barbatruc
Bonjour JM
Bonsoir mromain

Cette fois, cela fonctionne sur mon Excel 2013 ;)

Maintenant, comment se fait-il que le format d'un *.docx généré avec un Word 2013 Home soit différent d'un *.docx généré par un Word Pro ???

On cache tout, on nous dit rien ;)
Héhé :)

Concrètement, le problème se situait au niveau de la balise d'identifications des lignes :

Sur mes versions d'Office :
XML:
<w:tr w:rsidR="00285861" w:rsidRPr="00285861" w14:paraId="7EBDA205" w14:textId="77777777" w:rsidTr="00285861">
Sur le document que t'as fourni, un simple :
XML:
<w:tr>

Bonne journée
 

Discussions similaires

Statistiques des forums

Discussions
315 098
Messages
2 116 193
Membres
112 679
dernier inscrit
Yupanki