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

snippets/extract-metadata-from-a-file/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
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

snippets/extract-metadata-from-a-file/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * 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.

  1. 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

    ChampCe que le document déclare
    TitreContrat de prestation
    AuteurMarie Martin
    Dernier enregistrementJean Dupont
    Révision7
    Créé le2026-09-14T09:12:00Z
    Modifié le2026-10-10T13:55:00Z
    LogicielMicrosoft Office Word
    EntrepriseCabinet Lumière
    ResponsableClaire Bernard
    Modèlecontrat-interne.dotx
    Temps d’édition (minutes)413
  2. 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

    ChampCe que le document déclare
    TitreContrat de prestation
    AuteurMarie Martin
    Dernier enregistrementJean Dupont
    Révision7
    Créé le2026-09-14T09:12:00Z
    Modifié le2026-10-10T13:55:00Z
  3. 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

    ChampCe que le document déclare
  4. 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

  5. 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

    ChampCe que le document déclare
    TitreContrat de prestation
    AuteurMarie Martin
    Dernier enregistrementJean Dupont
    Révision7
    Créé le2026-09-14T09:12:00Z
    Modifié le2026-10-10T13:55:00Z
    LogicielMicrosoft Office Word
    EntrepriseCabinet Lumière
    ResponsableClaire Bernard
    Modèlecontrat-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

  1. RecommandéN0Les deux parties de métadonnées du conteneur, lues et les autres nomméesGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. 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

Source des chiffres

Métadonnées