Read a file’s metadata
Get the date, the author and whatever else a document carries, before publishing it or filing it away.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the document carries these values in plain text, in two XML files named by a published specification, inside a ZIP. The standard library is enough on the Python side — zipfile and xml.etree — and on the JavaScript side two dependency-free packages were needed, fflate for the archive and fast-xml-parser, which was already there. Three things carry this entry beyond the unzip. The parts not read are named: a document whose authors are in its comments must not answer “no author”. Only the two metadata parts are decompressed, and only under a declared cap: a ZIP entry can promise a hundred megabytes in a kilobyte. And one gap between the two languages was closed rather than endured: JavaScript’s XML parser leaves “è” as written where Python’s decodes it, which would have made two different authors for the same file.
The container’s two metadata parts, read, and the others named
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
What an office document says about itself, before you send it out.
Rung N0. A .docx, an .xlsx or a .pptx is a ZIP, and two of its entries are
nothing but metadata: `docProps/core.xml` carries the author, whoever saved it
last, the revision count and the two dates; `docProps/app.xml` carries the
company, the manager, the template and the total editing time. Reading them is
unzipping and parsing, with the standard library on both sides.
The interesting part is not the reading, it is what the reading is for. A
document sent to a client carries the name of everyone who touched it, the
internal template it was built from, and how long it took. None of that is
visible on screen, all of it travels with the file, and it is the reason this
entry exists.
One rule the code keeps. It reads two parts and it names the others: a
document carrying comments, tracked changes or session identifiers has more to
say than these two files, and answering « no author » about a document whose
authors are in `word/comments.xml` would be worse than saying nothing. What is
not read is listed, not ignored.
"""
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
/**
* What an office document says about itself, before you send it out.
*
* Rung N0. A .docx, an .xlsx or a .pptx is a ZIP, and two of its entries are
* nothing but metadata: `docProps/core.xml` carries the author, whoever saved
* it last, the revision count and the two dates; `docProps/app.xml` carries the
* company, the manager, the template and the total editing time. Reading them
* is unzipping and parsing.
*
* The interesting part is not the reading, it is what the reading is for. A
* document sent to a client carries the name of everyone who touched it, the
* internal template it was built from, and how long it took. None of that is
* visible on screen, all of it travels with the file, and it is the reason
* this entry exists.
*
* One rule the code keeps. It reads two parts and it names the others: a
* document carrying comments, tracked changes or session identifiers has more
* to say than these two files, and answering « no author » about a document
* whose authors are in `word/comments.xml` would be worse than saying nothing.
* What is not read is listed, not ignored.
*/
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,
};
}Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
The document is built here, when the site is built, then read by the snippet as it is. Nothing is sent anywhere, and the field above shows the parts of the ZIP file, which is what a .docx is.
A contract ready to go out
Given
[Content_Types].xml docProps/core.xml docProps/app.xml word/document.xml
What it returns
11 field(s) read across the two metadata parts
In detail
Field What the document declares Title Contrat de prestation Author Marie Martin Last saved by Jean Dupont Revision 7 Created 2026-09-14T09:12:00Z Modified 2026-10-10T13:55:00Z Application Microsoft Office Word Company Cabinet Lumière Manager Claire Bernard Template contrat-interne.dotx Editing time (minutes) 413 The same, without the application part
Given
[Content_Types].xml docProps/core.xml word/document.xml
What it returns
6 field(s) read across the two metadata parts
In detail
Field What the document declares Title Contrat de prestation Author Marie Martin Last saved by Jean Dupont Revision 7 Created 2026-09-14T09:12:00Z Modified 2026-10-10T13:55:00Z A document whose metadata was removed
Given
[Content_Types].xml word/document.xml
What it returns
No metadata declared
In detail
Field What the document declares A ZIP file that is not a document
Given
lisezmoi.txt
What it returns
This file is not an office document
a ZIP, but not an OOXML document
The contract, with its comments and settings
Given
[Content_Types].xml docProps/core.xml docProps/app.xml word/document.xml word/comments.xml word/people.xml word/settings.xml
What it returns
11 field(s) read across the two metadata parts
Parts not read, which also carry names or identifiers: word/comments.xml, word/people.xml, word/settings.xml.
In detail
Field What the document declares Title Contrat de prestation Author Marie Martin Last saved by Jean Dupont Revision 7 Created 2026-09-14T09:12:00Z Modified 2026-10-10T13:55:00Z Application Microsoft Office Word Company Cabinet Lumière Manager Claire Bernard Template contrat-interne.dotx Editing time (minutes) 413 Here it breaksThe fields read are the same as in the first case, and yet this document says a great deal more: the comments carry the name of whoever wrote them and the date, `word/people.xml` the list of reviewers, and `word/settings.xml` session identifiers that tie together the versions of one file. This level does not read those parts — it names them, under the table. That is the only honest answer: saying “here is the metadata” while stopping at two files would suggest there is nothing else.
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the document is opened inside your own process and is sent to nobody
- What this entry makes you read is exactly what a published document passes on unnoticed: people’s names, an organisation, timestamps
Breaking point
The two parts read are not all of a document’s metadata, and the scope stops at OOXML: the same contract with its comments returns the same fields, `word/comments.xml` being named and not read, and an ODT comes back “a ZIP, but not an OOXML document”. Witness in the same test: the document without those parts returns an empty list.
When to move up a level
There is no level above this one: reading what is written does not get better. The day the job is no longer to read but to remove, that is other work — rebuilding the archive without those parts — and it starts by knowing which ones are there, which is what this entry returns.
The four options
- RecommendedN0The container’s two metadata parts, read, and the others namedFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is nothing to learn: the field names are fixed by the OOXML specification, and so is where they sit. A model trained on annotated documents would recover, at best, what the XML parser reads exactly, and it would be wrong about the fields your office suite writes and its sample did not contain.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with budget on top. The useful work here is measured in milliseconds and in two files of a few hundred bytes; running a resident model to read an attribute named by a published specification has nothing to do with the question asked.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
Sending the document to a third party to find out what it reveals about you is the most direct contradiction of this entry: the file you hesitate to publish would be handed over whole, names, company and internal template included, to obtain a list the XML parser returns exactly. And the result would be a paraphrase of fields you would have to check one by one.
Further reading
- 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
Where the figures come from
- 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
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.