AXLR / Catalog / data

xlsx_read

Read the sheets of an Excel .xlsx workbook into CSV and JSON with no package: the file (hex or a relative path) is opened as a zip and its SpreadsheetML parsed: shared strings (rich text runs joined), inline strings, numbers, booleans, errors and formulas' cached values. Numbers with a date or time format (built-in or custom) become ISO dates with the workbook's 1900 or 1904 date system, including Excel's phantom 1900-02-29; merged ranges are reported. Rows as arrays, records keyed by the header, and CSV.

Install

/tools install xlsx_read
/tools install xlsx_read@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · xlsx_read 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool xlsx_read; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
hexstringnoThe .xlsx file as hex
pathstringnoOr a relative path to the .xlsx
sheetstring | integernoSheet name or 1-based index (default: all)
headerbooleannotrueAlso return records keyed by the first row
max_rowsintegerno10000≥ 1; ≤ 1000000
datesstringno"iso"one of "iso", "serial"
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "hex": {
      "type": "string",
      "description": "The .xlsx file as hex"
    },
    "path": {
      "type": "string",
      "description": "Or a relative path to the .xlsx"
    },
    "sheet": {
      "type": [
        "string",
        "integer"
      ],
      "description": "Sheet name or 1-based index (default: all)"
    },
    "header": {
      "type": "boolean",
      "default": true,
      "description": "Also return records keyed by the first row"
    },
    "max_rows": {
      "type": "integer",
      "minimum": 1,
      "maximum": 1000000,
      "default": 10000
    },
    "dates": {
      "type": "string",
      "enum": [
        "iso",
        "serial"
      ],
      "default": "iso"
    }
  }
}

Example

Input

{
  "hex": "504b03041400000008000000525d52f509ab0e0100001e030000130000005b43 6f6e74656e745f54797065735d2e786d6cad92cb4ec33010457fc5f2b68a9db2 400825ed82c71290281f609c4962c52f79dc92fe3d8e5b1042856cb21ad973e7 9eabd154dbd168728080cad99aae59490958e91a65bb9abeed1e8b1bbadd54bb a30724496ab1a67d8cfe9673943d1881cc79b0a9d3ba60444ccfd0712fe4203a e0576579cda5b3116c2ce2e44137d53db462af237918d3f7091b4023257727e1 c4aaa9f05e2b2962eaf3836d7e518a3381a5c9acc15e795c2501e5170953e76f c079ee39ed21a806c88b08f14998a4e2a3e61f2e0cefce0dec7f930b295ddb2a 098d937b934618fa00a2c11e201acd72654628bb9ae76731f25cd60b07f9f69f c981bd08d0bcc6906e03175fc60fefb91cf1a861f100d9f48bccf3c16f3e0150 4b03041400000008000000525d1c49f7bea4000000160100000b0000005f7265 6c732f2e72656c738dcfc10ec2200c06e05721bd3ba60763ccd82ec66457331f 0059c7c80625803adf5e8ece78f0d8f4ffbfa655b3d8993d3044434ec0b62881 a153d41ba7055cbbf3e6004d5d5d70962927e2687c64b9e2a28031257fe43caa 11ad8c057974793350b032e53168eea59aa446be2bcb3d0f9f06ac4dd6f60242 db6f81752f8fffd8340c46e189d4dda24b3f4e7c25b22c83c6246099f993c274 239a8a8c02af2bbe7ab07e03504b03041400000008000000525de40c74acbc00 0000210100000f000000786c2f776f726b626f6f6b2e786d6c8d8fcb8ec2300c 457f25f21e52460831555b36232416482ce00342e2d28826aeecf0f87c4219c4 96955fd7c7d7d5ea1e7a7545164fb186d9b40085d192f3f154c361bf9e2c61d5 5437e2f391e8acb23a4a0d5d4a43a9b5d80e8391290d18f3a4250e26e5924f5a 0646e3a4434ca1d73f45b1d0c1f8082f42c9df30a86dbdc53fb2978031bd208c bd49d9ab747e10f838dbb17226e1ecb798e7374037d5785bfea38a26600d5b63 418d8d8d7bca14973e27bc71e38a7eefe837b67900504b030414000000080000 00525dc46e79ffcb000000230200001a000000786c2f5f72656c732f776f726b 626f6f6b2e786d6c2e72656c73ad91c16ac3300c865fc5e8be28e9a08c51b797 31e8756b1fc0384a1c9ad846d2b6f6ed6b0adb1a2863879e8c24fbfb3fe4d5e6 388de6935886142d34550d86a24fed107b0bfbddebc3136cd6ab371a9d961b12 862ca63c896221a8e66744f181262755ca14cba44b3c392d25f7989d3fb89e70 51d74be46b06cc9966db5ae06ddb80d99d32fd879dba6ef0f492fcc744516f44 e057e28304222d50c73da9859f96e0e568aa4205bc2db3b8a78c04c7d4be2b97 c5caafd0acfd97cce35d65f434d2b5c5a5fe8ec7d96fafcf504b030414000000 08000000525dd56bb0b1b90000000b01000014000000786c2f73686172656453 7472696e67732e786d6c5d8f410ac2301045af12b270a753454434890b4570a7 a0078875da064c5293a9e8913c871733ad28e866e0bdff9961c4e266cfec8a21 1aef241f0e32ced0e5fe645c29f961bfee4ff942891889a5a28b925744f50c20 e6155a1d07be469792c207ab29612821d601f529568864cf30cab209586d1c67 b96f1c493ee6ac71e6d2e0f2c3e9805182d486d00a2025a0e5b75b69c27fb7a3 fbaf0aad5deae26d4327c2368d23b4bcedf2e783f5b4ade78c9e8f6fb15b01e9 3ff502504b03041400000008000000525dee262b38bf0000003c0100000d0000 00786c2f7374796c65732e786d6c65904d0bc2300c86ff4ac9dd65ca18225d77 1004cf2a781d6b66074d3bd64ef4df5b3ff0037369fa34795ea8ac2f6cc599c6 d07b57c13ccb41906bbdeedda982c37e335b42ad6488574b3b4314459a77a102 13e3b0420cad216e42e60772e9a5f32337315dc7138661a44687fb125b5ce479 89dcf40e9474136f3806d1fac9c514fa46e2796c75826501e2a95b7b4d15688d cc784d258c5931032a892f93922d597becdeca22292fdd972ebf8fff9079f18f ca7fb678e4bcf4a9fbfc84ba01504b03041400000008000000525d6865fe219d 000000e300000018000000786c2f776f726b7368656574732f7368656574312e 786d6c4d8edd0ac2300c855fa5e4dea5131591ae43119f401fa074711bae3fb4 65f3f1ad53743721e77cc94944fd34031b29c4ded90aca820323ab5dd3dbb682 dbf5b2da432dc5e4c223764489e5711b2be852f207c4a83b322a16ce93cde4ee 825129cbd062f48154332f9901d79cefd0a8de8214b37756494911dcc4423e9b 5dfd6e8e25b0f8d1a3dc6c39e7024729507ff969c9ff0c734eae8b60fc7d2c5f 504b010214031400000008000000525d52f509ab0e0100001e03000013000000 00000000000000008001000000005b436f6e74656e745f54797065735d2e786d 6c504b010214031400000008000000525d1c49f7bea4000000160100000b0000 00000000000000000080013f0100005f72656c732f2e72656c73504b01021403 1400000008000000525de40c74acbc000000210100000f000000000000000000 000080010c020000786c2f776f726b626f6f6b2e786d6c504b01021403140000 0008000000525dc46e79ffcb000000230200001a000000000000000000000080 01f5020000786c2f5f72656c732f776f726b626f6f6b2e786d6c2e72656c7350 4b010214031400000008000000525dd56bb0b1b90000000b0100001400000000 000000000000008001f8030000786c2f736861726564537472696e67732e786d 6c504b010214031400000008000000525dee262b38bf0000003c0100000d0000 0000000000000000008001e3040000786c2f7374796c65732e786d6c504b0102 14031400000008000000525d6865fe219d000000e30000001800000000000000 000000008001cd050000786c2f776f726b7368656574732f7368656574312e78 6d6c504b05060000000007000700c2010000a00600000000"
}

Output (stdout)

{
  "date_system": 1904,
  "sheet_names": [
    "Mac"
  ],
  "sheets": [
    {
      "name": "Mac",
      "values": [
        [
          "2027-03-16",
          "1904-01-01"
        ]
      ],
      "csv": "2027-03-16,1904-01-01\n"
    }
  ]
}

Files

The command the install card registers is python3 xlsx_read.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

xlsx_read.py · sha256 285a82ec09b525c14921f6c4caa935005a5290ebfdd87436af1ef0b4f65cf5c2
#!/usr/bin/env python3
"""Read the sheets of an Excel .xlsx workbook into CSV and JSON with the standard library:
the file is opened as a zip and its SpreadsheetML parts are parsed (shared strings, inline
strings, numbers, booleans, errors, formulas' cached values), dates are recognised by their
number format and converted with the workbook's 1900 or 1904 date system, and merged cells
are reported.

Input (JSON on stdin):
  hex        the .xlsx file as hex (or path: a relative file in the working directory)
  sheet      a sheet name or 1-based index (default: every sheet)
  header     true: also return rows as objects keyed by the first row (default true)
  max_rows   rows read per sheet, default 10000
  dates      "iso" (default) converts date-formatted numbers; "serial" keeps the numbers
"""
import csv
import datetime as dt
import io
import json
import pathlib
import re
import sys
import zipfile
import xml.etree.ElementTree as ET

NS = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
      "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
      "rel": "http://schemas.openxmlformats.org/package/2006/relationships"}
STRICT = {"m": "http://purl.oclc.org/ooxml/spreadsheetml/main",
          "r": "http://purl.oclc.org/ooxml/officeDocument/relationships"}
# ECMA-376 Part 1, 18.8.30: built-in number formats that are dates or times.
BUILTIN_DATES = {14: "date", 15: "date", 16: "date", 17: "date", 18: "time", 19: "time", 20: "time", 21: "time",
                 22: "datetime", 27: "date", 30: "date", 36: "date", 45: "time", 46: "duration", 47: "time", 50: "date", 57: "date"}
MAX_BYTES = 20_000_000
MAX_UNZIPPED = 200_000_000


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def load_bytes(request):
    if ("hex" in request) == ("path" in request):
        fail("give exactly one of hex (the .xlsx as hex) or path (relative)")
    if "hex" in request:
        text = request["hex"]
        if not isinstance(text, str):
            fail("hex must be a string")
        try:
            data = bytes.fromhex(re.sub(r"\s", "", text))
        except ValueError:
            fail("hex is not valid hexadecimal")
    else:
        path = request["path"]
        if not isinstance(path, str) or not path or path.startswith(("/", "\\", "~")) or re.match(r"^[A-Za-z]:", path) or ".." in re.split(r"[\\/]", path):
            fail("path must be a relative path inside the working directory, without '..'")
        target = pathlib.Path(path)
        if not target.is_file():
            fail(f"no such file: {path}")
        if target.stat().st_size > MAX_BYTES:
            fail("the file is larger than 20 MB")
        data = target.read_bytes()
    if len(data) > MAX_BYTES:
        fail("the file is larger than 20 MB")
    return data


def tag(element):
    return element.tag.rsplit("}", 1)[-1]


def children(element, name):
    return [c for c in element if tag(c) == name]


def descendants(element, name):
    return [c for c in element.iter() if tag(c) == name]


def column_index(ref):
    letters = re.match(r"^([A-Z]+)", ref)
    if not letters:
        return None
    index = 0
    for char in letters.group(1):
        index = index * 26 + ord(char) - 64
    return index - 1


def column_letters(index):
    letters = ""
    index += 1
    while index:
        index, rest = divmod(index - 1, 26)
        letters = chr(65 + rest) + letters
    return letters


def format_kind(code):
    """date, time, datetime, duration or None for a custom number format code."""
    cleaned = re.sub(r'"[^"]*"|\\.|\[(?!h\]|m\]|s\])[^\]]*\]', "", code.lower())
    cleaned = cleaned.split(";")[0]
    if re.search(r"\[(h|m|s)\]", cleaned):
        return "duration"
    has_date = re.search(r"[dy]", cleaned) or re.search(r"(^|[^h:])m{3,}", cleaned)
    has_time = re.search(r"[hs]", cleaned) or "am/pm" in cleaned or "a/p" in cleaned
    if has_date and has_time:
        return "datetime"
    if has_date:
        return "date"
    if has_time:
        return "time"
    return None


def serial_to_datetime(serial, date1904):
    """ECMA-376 Part 1, 18.17.4: serial day numbers in the 1900 or 1904 date system."""
    if date1904:
        base = dt.datetime(1904, 1, 1)
        return base + dt.timedelta(days=serial), None
    if serial < 0:
        return None, "negative serial in the 1900 date system"
    if 60 <= serial < 61:
        return None, "serial 60 is 1900-02-29, a day that does not exist (kept for Lotus 1-2-3 compatibility)"
    if serial < 60:
        return dt.datetime(1899, 12, 31) + dt.timedelta(days=serial), None
    return dt.datetime(1899, 12, 30) + dt.timedelta(days=serial), None


def iso(moment, kind):
    seconds = round(moment.second + moment.microsecond / 1e6)
    moment = moment.replace(microsecond=0) + dt.timedelta(seconds=seconds - moment.second)
    if kind == "date":
        return moment.date().isoformat()
    if kind == "time":
        return moment.time().isoformat()
    return moment.isoformat(sep="T")


def read_xml(archive, name):
    try:
        with archive.open(name) as handle:
            return ET.fromstring(handle.read())
    except KeyError:
        return None
    except ET.ParseError as error:
        fail(f"{name} is not well-formed XML: {error}")


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("stdin must be a JSON object")
    unknown = sorted(set(request) - {"hex", "path", "sheet", "header", "max_rows", "dates"})
    if unknown:
        fail(f"unknown field: {unknown[0]}")
    header = request.get("header", True)
    if not isinstance(header, bool):
        fail("header must be true or false")
    max_rows = request.get("max_rows", 10000)
    if isinstance(max_rows, bool) or not isinstance(max_rows, int) or not 1 <= max_rows <= 1_000_000:
        fail("max_rows must be an integer from 1 to 1000000")
    dates = request.get("dates", "iso")
    if dates not in ("iso", "serial"):
        fail("dates must be iso or serial")
    data = load_bytes(request)
    try:
        archive = zipfile.ZipFile(io.BytesIO(data))
    except zipfile.BadZipFile:
        fail("not an .xlsx file: it is not a zip archive (an old .xls is not supported)")
    if sum(info.file_size for info in archive.infolist()) > MAX_UNZIPPED:
        fail("the workbook expands to more than 200 MB")
    names = set(archive.namelist())
    workbook = read_xml(archive, "xl/workbook.xml")
    if workbook is None:
        fail("not an .xlsx workbook: xl/workbook.xml is missing")
    pr = descendants(workbook, "workbookPr")
    date1904 = bool(pr) and pr[0].get("date1904", "0").lower() in ("1", "true")
    rels = read_xml(archive, "xl/_rels/workbook.xml.rels")
    targets = {}
    if rels is not None:
        for rel in descendants(rels, "Relationship"):
            target = rel.get("Target", "")
            target = target.lstrip("/") if target.startswith("/") else "xl/" + target
            targets[rel.get("Id")] = target
    sheets = []
    for sheet in descendants(workbook, "sheet"):
        rid = sheet.get(f"{{{NS['r']}}}id") or sheet.get(f"{{{STRICT['r']}}}id")
        sheets.append({"name": sheet.get("name"), "target": targets.get(rid), "state": sheet.get("state", "visible")})
    if not sheets:
        fail("the workbook has no sheets")

    shared = []
    sst = read_xml(archive, "xl/sharedStrings.xml")
    if sst is not None:
        for si in children(sst, "si"):
            # Text of t and of the runs' t, but not phonetic guides (rPh).
            parts = [t.text or "" for t in children(si, "t")]
            for run in children(si, "r"):
                parts += [t.text or "" for t in children(run, "t")]
            shared.append("".join(parts))
    xf_kinds = []
    styles = read_xml(archive, "xl/styles.xml")
    custom = {}
    if styles is not None:
        for fmt in descendants(styles, "numFmt"):
            try:
                custom[int(fmt.get("numFmtId"))] = fmt.get("formatCode", "")
            except (TypeError, ValueError):
                continue
        xfs = descendants(styles, "cellXfs")
        for xf in children(xfs[0], "xf") if xfs else []:
            try:
                fid = int(xf.get("numFmtId", "0"))
            except ValueError:
                fid = 0
            xf_kinds.append(format_kind(custom[fid]) if fid in custom else BUILTIN_DATES.get(fid))

    wanted = request.get("sheet")
    if wanted is None:
        chosen = sheets
    elif isinstance(wanted, int) and not isinstance(wanted, bool):
        if not 1 <= wanted <= len(sheets):
            fail(f"sheet index must be from 1 to {len(sheets)}")
        chosen = [sheets[wanted - 1]]
    elif isinstance(wanted, str):
        chosen = [s for s in sheets if s["name"] == wanted]
        if not chosen:
            fail(f"no sheet named {wanted!r}; sheets: {', '.join(s['name'] for s in sheets)}")
    else:
        fail("sheet must be a name or a 1-based index")

    results = []
    for sheet in chosen:
        target = sheet["target"]
        if not target or target not in names:
            fail(f"sheet {sheet['name']!r} points to a missing part {target!r}")
        root = read_xml(archive, target)
        cells = {}
        notes = []
        truncated = False
        max_col = -1
        row_number = 0
        for row in descendants(root, "row"):
            row_number = int(row.get("r", row_number + 1))
            if row_number > max_rows:
                truncated = True
                break
            next_col = 0
            for c in children(row, "c"):
                ref = c.get("r")
                col = column_index(ref) if ref else next_col
                if col is None:
                    col = next_col
                next_col = col + 1
                kind_attr = c.get("t", "n")
                v = children(c, "v")
                raw = v[0].text if v else None
                value = None
                if kind_attr == "s":
                    try:
                        value = shared[int(raw)]
                    except (TypeError, ValueError, IndexError):
                        value = None
                        notes.append(f"{column_letters(col)}{row_number}: shared string index out of range")
                elif kind_attr == "inlineStr":
                    inline = children(c, "is")
                    if inline:
                        parts = [t.text or "" for t in children(inline[0], "t")]
                        for run in children(inline[0], "r"):
                            parts += [t.text or "" for t in children(run, "t")]
                        value = "".join(parts)
                elif kind_attr == "str":
                    value = raw or ""
                elif kind_attr == "b":
                    value = raw == "1"
                elif kind_attr == "e":
                    value = raw
                elif kind_attr == "d":
                    value = raw
                elif raw is not None:
                    try:
                        number = float(raw)
                    except ValueError:
                        number = None
                        value = raw
                    if number is not None:
                        style = c.get("s")
                        kind = xf_kinds[int(style)] if style and style.isdigit() and int(style) < len(xf_kinds) else None
                        if kind and kind != "duration" and dates == "iso":
                            if kind == "time" and 0 <= number < 1:
                                moment = dt.datetime(1900, 1, 1) + dt.timedelta(days=number)
                                value = iso(moment, "time")
                            else:
                                moment, problem = serial_to_datetime(number, date1904)
                                if moment is None:
                                    value = int(number) if number.is_integer() else number
                                    notes.append(f"{column_letters(col)}{row_number}: {problem}")
                                else:
                                    value = iso(moment, "date" if kind == "date" and number.is_integer() else "datetime")
                        else:
                            value = int(number) if number.is_integer() and abs(number) < 2 ** 53 and "." not in raw and "E" not in raw.upper() else number
                if value is not None:
                    cells[(row_number, col)] = value
                    max_col = max(max_col, col)
        merged = [m.get("ref") for m in descendants(root, "mergeCell")]
        last_row = max((r for r, _ in cells), default=0)
        grid = [[cells.get((r, c)) for c in range(max_col + 1)] for r in range(1, last_row + 1)]
        out = io.StringIO()
        writer = csv.writer(out, lineterminator="\n")
        for line in grid:
            writer.writerow(["" if v is None else ("TRUE" if v is True else "FALSE" if v is False else v) for v in line])
        entry = {"name": sheet["name"], "state": sheet["state"], "rows": len(grid), "columns": max_col + 1,
                 "dimension": f"A1:{column_letters(max_col)}{last_row}" if grid and max_col >= 0 else None,
                 "values": grid, "csv": out.getvalue(), "merged_cells": merged, "truncated": truncated}
        if merged:
            notes.append("merged cells: only the top-left cell of each merged range holds the value; the others are empty")
        if header and grid:
            keys = [str(k) if k is not None else f"column_{i + 1}" for i, k in enumerate(grid[0])]
            entry["records"] = [{keys[i]: v for i, v in enumerate(line)} for line in grid[1:]]
        entry["notes"] = notes
        results.append(entry)
    print(json.dumps({"date_system": 1904 if date1904 else 1900, "sheet_names": [s["name"] for s in sheets],
                      "sheets": results, "formulas": "cached values only; formulas are not recalculated"}, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/xlsx_read/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/xlsx_read/1.0.0/xlsx_read.py