Lire les métadonnées d’un fichier
Obtenir la date, l’auteur et ce qu’un document transporte d’autre, avant de le publier ou de l’archiver.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0, parce que le document porte ces valeurs en clair, dans deux fichiers XML nommés par une norme publiée, à l’intérieur d’un ZIP. La bibliothèque standard suffit côté Python — `zipfile` et `xml.etree` — et côté JavaScript il fallait deux paquets sans dépendance, `fflate` pour l’archive et `fast-xml-parser` qui était déjà là. Trois choses portent la fiche au-delà du décompactage. Les parties non lues sont nommées : un document dont les auteurs sont dans ses commentaires ne doit pas répondre « aucun auteur ». Seules les deux parties de métadonnées sont décompressées, et seulement sous un plafond déclaré : une entrée de ZIP peut promettre cent mégaoctets dans un kilooctet. Et un écart entre les deux langages a été fermé plutôt que subi : l’analyseur XML de JavaScript laisse « è » tel quel là où celui de Python le décode, ce qui aurait fait deux auteurs différents pour le même fichier.
Les deux parties de métadonnées du conteneur, lues et les autres nommées
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Ce qu'un document bureautique dit de lui-même, avant que vous ne l'envoyiez.
Niveau N0. Un .docx, un .xlsx ou un .pptx est un ZIP, et deux de ses entrées
ne sont que des métadonnées : `docProps/core.xml` porte l'auteur, la dernière
personne à l'avoir enregistré, le numéro de révision et les deux dates ;
`docProps/app.xml` porte l'entreprise, le responsable, le modèle et le temps
d'édition total. Les lire, c'est décompresser et analyser, avec la
bibliothèque standard des deux côtés.
L'intéressant n'est pas la lecture, c'est ce à quoi elle sert. Un document
envoyé à un client porte le nom de tous ceux qui y ont touché, le modèle
interne dont il est issu, et le temps qu'il a pris. Rien de cela ne se voit à
l'écran, tout cela voyage avec le fichier, et c'est la raison d'être de cette
fiche.
Une règle que le code tient. Il lit deux parties et il nomme les autres : un
document qui porte des commentaires, des marques de révision ou des
identifiants de session en dit plus que ces deux fichiers, et répondre
« aucun auteur » d'un document dont les auteurs sont dans
`word/comments.xml` serait pire que de ne rien dire. Ce qui n'est pas lu est
listé, pas ignoré.
Deux listes le disent, parce qu'une réponse silencieuse ne doit jamais passer
pour un document vide. `other_parts` nomme les parties que ce niveau n'ouvre
pas du tout — les commentaires, les marques de révision, les réglages.
`unread_parts` nomme celles qu'il voulait ouvrir et n'a pas pu, avec la
raison : un XML mal formé, une partie au-dessus du plafond de taille. Sans
elles, un document dont le `docProps/core.xml` a été tronqué en transit
répond « aucun auteur », c'est-à-dire la seule réponse que cette fiche existe
pour refuser.
Le nom des parties est fixé par ECMA-376, donc la marque n'est cherchée que
dans les dossiers du format : une image que l'auteur a nommée
`media/settings-du-client.png` n'est pas une partie de réglages.
"""
from __future__ import annotations
import io
import xml.etree.ElementTree as ElementTree
import zipfile
# The two parts read, and the fields taken from each. The names are the local
# names of the elements: the namespaces differ between the two files and say
# nothing more than which file you are in.
CORE_PART, APP_PART = "docProps/core.xml", "docProps/app.xml"
FIELDS = {
"title": "title", "creator": "author", "lastModifiedBy": "last_modified_by",
# « revision » is the number of times the document was saved, as OOXML
# defines it — not a version number, although it is read as one.
"revision": "revision", "created": "created", "modified": "modified",
"keywords": "keywords", "subject": "subject", "description": "description",
"category": "category", "Company": "company", "Manager": "manager",
"Template": "template", "Application": "application", "AppVersion": "app_version",
# « TotalTime » is the total editing time in minutes, as the OOXML
# specification defines it — not a duration this code computed.
"TotalTime": "editing_minutes",
}
# Parts that carry names, dates or identifiers this rung does not read. They
# are listed so that a quiet answer is never mistaken for an empty document.
# ECMA-376 fixes where those parts live, so the mark is only looked for inside
# those folders: a picture the author named `media/settings-du-client.png` is
# not a settings part.
OOXML_FOLDERS = ("word/", "xl/", "ppt/", "docProps/")
OTHER_PARTS = ("comments", "people", "settings", "custom.xml", "revisions")
# A ZIP entry can promise far more than the archive weighs. Only the two parts
# read are decompressed, and only below this size.
MAX_PART_BYTES = 4_000_000
def read_document_metadata(data) -> dict:
"""
The fields an OOXML document declares about itself, and what was not read.
`data` is the bytes of the file. Nothing is written, and nothing but the
two metadata parts is decompressed.
Two lists say what was not read, because a quiet answer must never pass for
an empty document. `other_parts` names the parts this rung does not open at
all — the comments, the tracked changes, the settings. `unread_parts` names
the ones it meant to open and could not, with why: a part whose XML is
malformed, and a part above the size cap. Without them, a document whose
`docProps/core.xml` was truncated in transit answers « no author », which
is the one answer this entry exists to refuse.
"""
if not isinstance(data, (bytes, bytearray)):
return _report(None, {}, [], f"expected bytes, not {type(data).__name__}")
try:
archive = zipfile.ZipFile(io.BytesIO(data))
names = archive.namelist()
except (zipfile.BadZipFile, OSError):
return _report(None, {}, [], "not a ZIP container, so not an OOXML document")
if not any(name.startswith("docProps/") or name.endswith("/document.xml")
for name in names):
return _report(None, {}, [], "a ZIP, but not an OOXML document")
fields, unread = {}, []
for part in (CORE_PART, APP_PART):
if part not in names:
continue # the document does not carry it, which is not a failure
if archive.getinfo(part).file_size > MAX_PART_BYTES:
unread.append({"part": part, "why": "over the size cap"})
continue
try:
root = ElementTree.fromstring(archive.read(part))
except (ElementTree.ParseError, zipfile.BadZipFile, OSError, ValueError):
# A part we cannot read is a part we do not claim — and we say so.
unread.append({"part": part, "why": "malformed XML"})
continue
for element in root:
local = element.tag.split("}")[-1]
if local in FIELDS and (element.text or "").strip():
fields[FIELDS[local]] = element.text.strip()
others = sorted(name for name in names
if name.startswith(OOXML_FOLDERS)
and any(mark in name for mark in OTHER_PARTS)
and name not in (CORE_PART, APP_PART))
return _report("ooxml", fields, others, None, unread)
def _report(kind, fields: dict, others: list, reason, unread=()) -> dict:
return {"format": kind, "fields": fields, "other_parts": others,
"unread_parts": list(unread), "reason": reason}JavaScript
/**
* Ce qu'un document bureautique dit de lui-même, avant que vous ne l'envoyiez.
*
* Niveau N0. Un .docx, un .xlsx ou un .pptx est un ZIP, et deux de ses entrées
* ne sont que des métadonnées : `docProps/core.xml` porte l'auteur, la dernière
* personne à l'avoir enregistré, le numéro de révision et les deux dates ;
* `docProps/app.xml` porte l'entreprise, le responsable, le modèle et le temps
* d'édition total. Les lire, c'est décompresser et analyser, avec la
* bibliothèque standard des deux côtés.
*
* L'intéressant n'est pas la lecture, c'est ce à quoi elle sert. Un document
* envoyé à un client porte le nom de tous ceux qui y ont touché, le modèle
* interne dont il est issu, et le temps qu'il a pris. Rien de cela ne se voit à
* l'écran, tout cela voyage avec le fichier, et c'est la raison d'être de cette
* fiche.
*
* Une règle que le code tient. Il lit deux parties et il nomme les autres : un
* document qui porte des commentaires, des marques de révision ou des
* identifiants de session en dit plus que ces deux fichiers, et répondre
* « aucun auteur » d'un document dont les auteurs sont dans
* `word/comments.xml` serait pire que de ne rien dire. Ce qui n'est pas lu est
* listé, pas ignoré.
*
* Deux listes le disent, parce qu'une réponse silencieuse ne doit jamais passer
* pour un document vide. `other_parts` nomme les parties que ce niveau n'ouvre
* pas du tout — les commentaires, les marques de révision, les réglages.
* `unread_parts` nomme celles qu'il voulait ouvrir et n'a pas pu, avec la
* raison : un XML mal formé, une partie au-dessus du plafond de taille. Sans
* elles, un document dont le `docProps/core.xml` a été tronqué en transit
* répond « aucun auteur », c'est-à-dire la seule réponse que cette fiche existe
* pour refuser.
*
* Le nom des parties est fixé par ECMA-376, donc la marque n'est cherchée que
* dans les dossiers du format : une image que l'auteur a nommée
* `media/settings-du-client.png` n'est pas une partie de réglages.
*/
import { XMLParser, XMLValidator } from 'fast-xml-parser';
import { strFromU8, unzipSync } from 'fflate';
// The two parts read, and the fields taken from each. The names are the local
// names of the elements: the namespaces differ between the two files and say
// nothing more than which file you are in.
export const CORE_PART = 'docProps/core.xml';
export const APP_PART = 'docProps/app.xml';
export const FIELDS = {
title: 'title',
creator: 'author',
lastModifiedBy: 'last_modified_by',
// « revision » is the number of times the document was saved, as OOXML
// defines it — not a version number, although it is read as one.
revision: 'revision',
created: 'created',
modified: 'modified',
keywords: 'keywords',
subject: 'subject',
description: 'description',
category: 'category',
Company: 'company',
Manager: 'manager',
Template: 'template',
Application: 'application',
AppVersion: 'app_version',
// « TotalTime » is the total editing time in minutes, as the OOXML
// specification defines it — not a duration this code computed.
TotalTime: 'editing_minutes',
};
// Parts that carry names, dates or identifiers this rung does not read. They
// are listed so that a quiet answer is never mistaken for an empty document.
// ECMA-376 fixes where those parts live, so the mark is only looked for inside
// those folders: a picture the author named `media/settings-du-client.png` is
// not a settings part.
const OOXML_FOLDERS = ['word/', 'xl/', 'ppt/', 'docProps/'];
const OTHER_PARTS = ['comments', 'people', 'settings', 'custom.xml', 'revisions'];
// A ZIP entry can promise far more than the archive weighs. Only the two parts
// read are decompressed, and only below this size.
export const MAX_PART_BYTES = 4_000_000;
// `htmlEntities` is what decodes « è »: without it the parser leaves
// numeric character references as written, and the Python side, which decodes
// them, would not return the same author.
const PARSER = new XMLParser({
removeNSPrefix: true, ignoreAttributes: true, parseTagValue: false,
trimValues: true, htmlEntities: true,
});
/**
* The fields an OOXML document declares about itself, and what was not read.
*
* `data` is the bytes of the file. Nothing is written, and nothing but the two
* metadata parts is decompressed.
*
* Two lists say what was not read, because a quiet answer must never pass for
* an empty document. `other_parts` names the parts this rung does not open at
* all — the comments, the tracked changes, the settings. `unread_parts` names
* the ones it meant to open and could not, with why: a part whose XML is
* malformed, and a part above the size cap. Without them, a document whose
* `docProps/core.xml` was truncated in transit answers « no author », which is
* the one answer this entry exists to refuse.
*
* `XMLValidator` is what makes the first case an answer here. `XMLParser` is
* lenient: on a truncated part it returns an object with an empty author
* rather than throwing, and the Python side, whose parser raises, would not
* say the same thing.
*/
export function readDocumentMetadata(data) {
if (!(data instanceof Uint8Array)) {
return report(null, {}, [], `expected bytes, not ${typeof data}`);
}
const names = [];
const sizes = new Map();
let parts;
try {
parts = unzipSync(data, {
filter: (file) => {
names.push(file.name);
sizes.set(file.name, file.originalSize);
return (file.name === CORE_PART || file.name === APP_PART)
&& file.originalSize <= MAX_PART_BYTES;
},
});
} catch {
return report(null, {}, [], 'not a ZIP container, so not an OOXML document');
}
if (!names.some((name) => name.startsWith('docProps/') || name.endsWith('/document.xml'))) {
return report(null, {}, [], 'a ZIP, but not an OOXML document');
}
const fields = {};
const unread = [];
for (const part of [CORE_PART, APP_PART]) {
if (!names.includes(part)) continue; // the document does not carry it
if (!parts[part]) {
unread.push({ part, why: 'over the size cap' });
continue;
}
const texteXml = strFromU8(parts[part]);
let root;
try {
if (XMLValidator.validate(texteXml) !== true) throw new Error('malformed');
const parsed = PARSER.parse(texteXml);
// The first key is the XML declaration when the part carries one.
root = Object.entries(parsed).find(([key]) => !key.startsWith('?'))?.[1];
} catch {
// A part we cannot read is a part we do not claim — and we say so.
unread.push({ part, why: 'malformed XML' });
continue;
}
for (const [local, value] of Object.entries(root ?? {})) {
const texte = typeof value === 'object' ? '' : String(value).trim();
if (local in FIELDS && texte) fields[FIELDS[local]] = texte;
}
}
const others = names
.filter((name) => OOXML_FOLDERS.some((folder) => name.startsWith(folder))
&& OTHER_PARTS.some((mark) => name.includes(mark))
&& name !== CORE_PART && name !== APP_PART)
.sort();
return report('ooxml', fields, others, null, unread);
}
function report(format, fields, otherParts, reason, unread = []) {
return {
format, fields, other_parts: otherParts, unread_parts: unread, reason,
};
}Essayer
Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.
Le document est fabriqué ici, à la construction du site, puis lu par l’extrait tel quel. Rien n’est envoyé nulle part, et le champ ci-dessus montre les parties du fichier ZIP, qui est ce qu’un .docx est.
Un contrat prêt à partir
Donné
[Content_Types].xml docProps/core.xml docProps/app.xml word/document.xml
Ce qu’il renvoie
11 champ(s) lus dans les deux parties de métadonnées
Le détail
Champ Ce que le document déclare Titre Contrat de prestation Auteur Marie Martin Dernier enregistrement Jean Dupont Révision 7 Créé le 2026-09-14T09:12:00Z Modifié le 2026-10-10T13:55:00Z Logiciel Microsoft Office Word Entreprise Cabinet Lumière Responsable Claire Bernard Modèle contrat-interne.dotx Temps d’édition (minutes) 413 Le même, sans la partie « application »
Donné
[Content_Types].xml docProps/core.xml word/document.xml
Ce qu’il renvoie
6 champ(s) lus dans les deux parties de métadonnées
Le détail
Champ Ce que le document déclare Titre Contrat de prestation Auteur Marie Martin Dernier enregistrement Jean Dupont Révision 7 Créé le 2026-09-14T09:12:00Z Modifié le 2026-10-10T13:55:00Z Un document dont les métadonnées ont été retirées
Donné
[Content_Types].xml word/document.xml
Ce qu’il renvoie
Aucune métadonnée déclarée
Le détail
Champ Ce que le document déclare Un fichier ZIP qui n’est pas un document
Donné
lisezmoi.txt
Ce qu’il renvoie
Ce fichier n’est pas un document bureautique
a ZIP, but not an OOXML document
Le contrat, avec ses commentaires et ses réglages
Donné
[Content_Types].xml docProps/core.xml docProps/app.xml word/document.xml word/comments.xml word/people.xml word/settings.xml
Ce qu’il renvoie
11 champ(s) lus dans les deux parties de métadonnées
Parties non lues, qui portent aussi des noms ou des identifiants : word/comments.xml, word/people.xml, word/settings.xml.
Le détail
Champ Ce que le document déclare Titre Contrat de prestation Auteur Marie Martin Dernier enregistrement Jean Dupont Révision 7 Créé le 2026-09-14T09:12:00Z Modifié le 2026-10-10T13:55:00Z Logiciel Microsoft Office Word Entreprise Cabinet Lumière Responsable Claire Bernard Modèle contrat-interne.dotx Temps d’édition (minutes) 413 Là, ça casseLes champs lus sont les mêmes que dans le premier cas, et pourtant ce document en dit beaucoup plus : les commentaires portent le nom de qui les a écrits et la date, `word/people.xml` la liste des relecteurs, et `word/settings.xml` des identifiants de session qui relient entre elles les versions d’un même fichier. Ce niveau ne lit pas ces parties — il les nomme, sous le tableau. C’est la seule façon honnête de répondre : dire « voici les métadonnées » en s’arrêtant à deux fichiers laisserait croire qu’il n’y a rien d’autre.
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Dépend d’une bibliothèque
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : le document est ouvert dans votre processus et n’est transmis à personne
- Ce que cette fiche fait lire est précisément ce qu’un document publié transmet sans qu’on le sache : des noms de personnes, une organisation, des horodatages
Point de rupture
Les deux parties lues ne sont pas toutes les métadonnées, et le périmètre s’arrête à OOXML : le même contrat avec ses commentaires rend les mêmes champs, `word/comments.xml` étant nommé et non lu, et un ODT revient « a ZIP, but not an OOXML document ». Témoin dans le même test : le document sans ces parties rend une liste vide.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus : lire ce qui est écrit ne s’améliore pas. Le jour où il ne s’agit plus de lire mais de retirer, c’est un autre travail — reconstruire l’archive sans ces parties —, et il commence par savoir lesquelles sont là, ce que cette fiche rend.
Les quatre options
- RecommandéN0Les deux parties de métadonnées du conteneur, lues et les autres nomméesGratuit · Instantané
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N1Niveau sans objet ici
Niveau sans objet ici
Modèle classique légerN1
Il n’y a rien à apprendre : les noms des champs sont fixés par la norme OOXML, et leur emplacement aussi. Un modèle appris sur des documents annotés retrouverait, au mieux, ce que l’analyseur XML lit exactement, et il se tromperait sur les champs que votre suite bureautique écrit et que son échantillon ne contenait pas.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Même argument, avec le budget en plus. Le travail utile de cette fiche se compte en millisecondes et en deux fichiers de quelques centaines d’octets ; faire tourner un modèle résident pour lire un attribut nommé par une norme publiée est sans rapport avec la question posée.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Envoyer le document chez un tiers pour savoir ce qu’il révèle de vous est la contradiction la plus directe de cette fiche : le fichier qu’on hésite à publier serait transmis en entier, avec les noms, l’entreprise et le modèle interne, pour obtenir une liste que l’analyseur XML rend exactement. Et le résultat serait une paraphrase de champs qu’il faudrait vérifier un par un.
Pour aller plus loin
- ECMA-376 — la norme OOXML, et les propriétés du paquet
- Microsoft — l’inspecteur de document, qui retire ces champs depuis le logiciel
- fflate — le lecteur d’archive employé côté JavaScript
- Savoir si un fichier est vraiment du format qu’il annonce — la fiche voisine, quand la question est le conteneur lui-même
Source des chiffres
- ECMA-376, partie 2 (consultée) : les propriétés de paquet — `docProps/core.xml` pour l’auteur, la révision et les dates, `docProps/app.xml` pour l’entreprise, le modèle et `TotalTime`, défini comme le temps d’édition total en minutes
- Dublin Core (consulté) : `dc:creator`, `dc:title` et `dc:description` de `core.xml` sont les éléments du Dublin Core, ce qui explique leurs noms
- fflate sur son dépôt (consulté) : version 0.8.3, publiée le 16 mai 2026, licence MIT, aucune dépendance ; son option de filtrage permet de ne décompresser que les deux parties lues
- fast-xml-parser sur son dépôt (consulté) : version 5.2.5, licence MIT ; l’option `htmlEntities` est ce qui décode les références numériques, que l’analyseur laisse sinon telles quelles
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.