runlocally

runlocally engineering notes

Extract Images from Excel

How Extract Images from Excel is built

By Geppetto · · Open Extract Images from Excel →

Extract Images from Excel collects every picture embedded in an .xlsx or .xlsm workbook and hands them back as a ZIP. This post is about the one fact that makes that possible without any spreadsheet-parsing library at all: the modern Excel file format is itself a ZIP archive.

Tech used

OOXML: a workbook is a ZIP of XML parts

Since Office 2007, .xlsx and .xlsm use the Office Open XML (OOXML) format — a ZIP archive containing a tree of XML files ([Content_Types].xml, xl/workbook.xml, xl/worksheets/sheet1.xml, and so on) plus any binary assets the workbook holds. This is the same container idea used by .docx and .pptx. Practically, it means a workbook can be opened with any general-purpose ZIP reader — no OOXML-specific parser is required to get at what’s packaged inside it, only to make sense of the XML.

The tool uses @zip.js/zip.js 2.8.8 (BSD-3), the same ZIP engine already shipped in Unzip, ZIP Viewer, and the rest of the ZIP-family tools in this catalog. ZipReader opens the uploaded file as a BlobReader, and getEntries() returns every entry’s path — no new dependency, no bundle-size cost beyond what those tools already carry.

xl/media/: where every embedded picture lives

Regardless of which sheet a picture is pasted into, or whether it’s a floating image, a background, or embedded in a chart, Excel stores the actual image bytes in one place: the xl/media/ folder, as plain image1.png, image2.jpeg, and so on. The XML elsewhere in the package (xl/drawings/drawing1.xml, the worksheet’s <drawing> reference) only records where on the sheet each picture is anchored — the pixels themselves are just files sitting in xl/media/.

That means extracting “every image in this workbook” doesn’t require walking drawing XML or resolving sheet-to-picture relationships at all. src/utils/xlsxImageExtractEngine.ts filters the entry list down to non-directory entries whose path starts with xl/media/:

const MEDIA_PREFIX = 'xl/media/';

function isMediaFile(entry: { directory: boolean; filename: string }): entry is FileEntry {
  return !entry.directory && entry.filename.startsWith(MEDIA_PREFIX);
}

Every matching entry’s raw bytes are copied — via entry.getData(new Uint8ArrayWriter()) — into a fresh ZipWriter, unchanged. There’s no decode and no re-encode: the tool never interprets the image data as a PNG or JPEG at all, it just relocates already-compressed bytes from one ZIP into another.

Implementation & operational notes

Reading someone else’s archive path requires a path-traversal check. xl/media/ entries are supposed to be flat filenames, but nothing stops a hand-crafted .xlsx from declaring an entry like xl/media/../../../../etc/passwd. outputName() strips the xl/media/ prefix and then rejects the result if it’s empty, starts with /, or contains a .. segment:

function outputName(filename: string): string {
  const name = filename.slice(MEDIA_PREFIX.length);
  const segments = name.split('/');

  if (!name || name.startsWith('/') || segments.includes('..')) {
    throw new AppError('errCannotOpenWorkbook');
  }

  return name;
}

This matters even though the output ZIP is only ever opened by the same user’s own browser (there’s no server writing these names to a filesystem) — it keeps the tool’s behavior predictable and auditable rather than relying on “nothing bad happens to happen” against untrusted input.

A password-to-open workbook and a corrupted file fail the same way, on purpose. Encryption-at-open for OOXML wraps the entire package in a Compound File Binary (CFB) container — a completely different, non-ZIP format — so ZipReader.getEntries() simply throws on such a file rather than returning misleading partial results. The tool doesn’t try to distinguish “this is CFB-encrypted” from “this is not a valid workbook at all”; both surface as the same clear, generic message, since from the user’s perspective the actionable fact is identical either way: this specific file can’t be opened here.

Zero images found is a normal result, not an error. A workbook with no pictures produces an empty mediaEntries list, and the engine returns { blob: null, imageCount: 0, totalSize: 0 } rather than throwing — the UI reports “no embedded images” and simply skips the download, instead of treating an empty result as a failure.

Individual encrypted entries are checked too, separately from the CFB case. OOXML also supports per-part encryption inside an otherwise-normal ZIP structure; entry.encrypted is checked for each media entry before reading its data, so that case is rejected explicitly rather than producing a corrupt output ZIP.

Try it / source

Extract Images from Excel

Open the tool → All posts →