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

snippets/extract-metadata-from-a-file/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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

snippets/extract-metadata-from-a-file/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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.

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

    FieldWhat the document declares
    TitleContrat de prestation
    AuthorMarie Martin
    Last saved byJean Dupont
    Revision7
    Created2026-09-14T09:12:00Z
    Modified2026-10-10T13:55:00Z
    ApplicationMicrosoft Office Word
    CompanyCabinet Lumière
    ManagerClaire Bernard
    Templatecontrat-interne.dotx
    Editing time (minutes)413
  2. 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

    FieldWhat the document declares
    TitleContrat de prestation
    AuthorMarie Martin
    Last saved byJean Dupont
    Revision7
    Created2026-09-14T09:12:00Z
    Modified2026-10-10T13:55:00Z
  3. A document whose metadata was removed

    Given

    [Content_Types].xml word/document.xml

    What it returns

    No metadata declared

    In detail

    FieldWhat the document declares
  4. 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

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

    FieldWhat the document declares
    TitleContrat de prestation
    AuthorMarie Martin
    Last saved byJean Dupont
    Revision7
    Created2026-09-14T09:12:00Z
    Modified2026-10-10T13:55:00Z
    ApplicationMicrosoft Office Word
    CompanyCabinet Lumière
    ManagerClaire Bernard
    Templatecontrat-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

  1. RecommendedN0The container’s two metadata parts, read, and the others namedFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. 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

Where the figures come from

Metadata