Skip to main content

pyOfficeEditor

Edit the document surface of Microsoft Office files in pure Python. No Office installation, no COM, no dependencies.

Its sister project pyOpenVBA edits the VBA project inside an Office file. This one edits the document: the cell, the formula, the paragraph, the slide, the table, the query.

Status: early, and growing. The Excel surface reads and writes cells, values, formulas, dates, sheets, formatting, merged ranges, tables, row and column dimensions, frozen panes and defined names, each verified against real Excel. Conditional formatting reads and writes every rule family Excel has. Data validation covers every rule Excel has, dropdowns included. Sheets protect, hide, colour their tabs, set their own view, and print the way you set them up. Hyperlinks, outline grouping, and the shapes on a sheet: AutoShapes, text boxes, lines and all nine Forms controls read, added, removed and pointed at a macro, with a control's linked cell, list range and value. Autofilters, on a sheet or a table, read and write every criterion but colour and icon, and hide the rows they exclude, because Excel does not recompute a filter when the workbook opens. A cell's text is what Excel shows for it, number format and all. Notes and threaded comments, replies included, read and write the way Excel writes them, and so do pictures, text in more than one font, and named cell styles, all of Excel's own among them. Charts are read, series by series, and added in eight kinds as Excel's Insert Chart writes them; their references move with the cells they read, and so do pivot tables and their caches, refusing the edits Excel refuses. Formulas calculate: 493 of Excel's 525 functions, held to 10,958 formulas Excel calculated, to the last bit for all but the few functions the tests name, with the results written into the cells as Excel's recalculation would write them. Rows and columns can be inserted and deleted, with every reference in the workbook following or breaking exactly as Excel breaks it, A:A and 2:4 included. Nothing about a sheet makes that refuse any more: validation, protected ranges, sorts, scenarios, shapes, form controls and comments all move with it. Word, PowerPoint and Access follow, in that order.

import datetime as dt
from pyofficeeditor.excel import (
    Border, Dxf, FilterColumn, Workbook, cell_is, criteria, gradient,
)

with Workbook.open("orders.xlsx") as book:
    sheet = book["Data"]

    sheet["A2"].value        # 'North'                  stored as an index
    sheet["F2"].value        # datetime.date(2026, 1, 15)   stored as 46037
    sheet["D3"].formula      # 'B3*C3'                  stored nowhere at all
    sheet["B8"].value        # CellError('#DIV/0!')     not the text of one

    sheet["B2"].value = 200
    sheet["G1"].value = dt.date(2026, 7, 4)
    sheet["G2"].formula = "=SUM(B2:B5)"

    sheet.range("A1:F1").apply_font(bold=True)   # each cell keeps its own rest
    sheet["B2"].fill = "FFFF00"
    sheet["B3"].border = Border.all_sides("thin", "FF0000")

    sheet["A20"].value = "wide heading"
    sheet.merge("A20:C20")
    sheet["B20"].merged_range                    # RangeRef('A20:C20')

    table = sheet.add_table("Sales", "A1:F20", totals_row=True)
    table.column_names                           # from the header row
    table.data_range                             # excludes header and totals
    sheet.set_table_filter("Sales", [FilterColumn(1, criteria(">100"))])
                                                 # and hides the rows it drops

    sheet.set_row_height(1, 24)                  # points, exact
    sheet.set_column_hidden(4, True)
    sheet.freeze_panes("B2")                     # pins row 1 and column A

    sheet.add_conditional_format(
        "B2:B20", cell_is("greaterThan", 100), dxf=Dxf.of(fill="FFC7CE", bold=True)
    )
    sheet.add_conditional_format("C2:C20", gradient())   # three-colour scale

    sheet.insert_rows(3, 2)                      # every reference follows
    sheet.delete_columns(5, 1)                   # SUM(E2:E9) -> #REF!

    summary = book.add_sheet("Summary", index=0)
    summary["A1"].formula = "=SUM(Data!D2:D5)"
    book.add_defined_name("Totals", "Data!$D$2:$D$5")
    book.rename_sheet("Data", "Q1 Data")     # formulas and names both follow

    summary.evaluate("SUM(Totals)/4")            # what a formula would give
    book.calculate()                             # every formula, results cached
    book.save()

Each of those four reads has a plausible wrong answer that a naive implementation gives instead: 6 for the string, 46037 for the date, an empty formula for D3, and a string that compares equal to "#DIV/0!" for the error. Getting them right is most of what the Excel modules do.

Why this exists

openpyxl already sets a cell, python-docx a paragraph, python-pptx a slide. Each covers one host. This library is aimed at the ground they leave uncovered:

  • One API across four hosts, including Access, which none of them touch.
  • The legacy binary formats, .xls, .doc and .ppt, which pyOpenVBA deliberately treats as opaque.
  • The analysis layer: Excel formula parsing and linting, legacy data connections, and Power Query.
  • Byte fidelity as a correctness property, not a nicety. See below.

Byte fidelity

Editing one cell of a worksheet must leave every other byte of the package alone. Otherwise a one-cell change produces a diff nobody can review, and a save that should be a no-op is not one.

That turns out to rule out the obvious building blocks, for reasons that are measurable rather than theoretical.

The XML. Excel writes a worksheet as a declaration, a CRLF, then a single line whose root element declares mc:Ignorable="x14ac xr xr2 xr3" before it declares the x14ac and xr prefixes themselves. Round-tripping that through xml.etree.ElementTree renames the prefixes to ns0 and ns1, reorders the declarations, and drops the CRLF. So parts are parsed into a tree where every node keeps the source text it was cut from. Serializing a node nobody touched copies those bytes; serializing a node that changed rebuilds it and recurses. Editing one cell rewrites that cell's row and copies the rest.

The container. In a freshly authored workbook, [Content_Types].xml carries a 520-byte extra field in its local ZIP header and none in the central directory. zipfile.ZipInfo.extra exposes only the central copy, so anything built on zipfile's writer silently drops those bytes. The field is the Microsoft Open Packaging Growth Hint (tag 0xa220): padding Excel reserves so it can grow a part in place. This library reads and writes the container itself, field for field, and copies each untouched member's stored bytes without inflating them.

The gate: reading a package and writing it back with nothing changed reproduces the input exactly. It holds for Excel-authored .xlsm, .xlsb and .xlsx, for openpyxl-authored .xlsx, and for archives whose members carry data descriptors.

Architecture

+--------------------------------------------------------+
| excel/        Workbook, Worksheet, Range, Cell         |
|   _reference  A1 notation, bijective base-26 columns   |
|   _values     the six cell encodings, and serial dates |
|   _styles     number formats, which is how a date is   |
|               told from a number                       |
|   _numfmt     what a number format shows, rounding and |
|               Excel's fictional 1900 leap day included |
|   _collate    Windows word sort, the order and the     |
|               equality a filter compares text by       |
|   _filters    autofilters, and which rows they hide:   |
|               Excel does not recompute one on open     |
|   _formats    fonts, fills, borders, alignment, as     |
|               immutable values                         |
|   _cellstyles named styles, and Excel's definitions of |
|               its own                                  |
|   _tables     ListObjects: their own parts and wiring  |
|   _dimensions widths, heights, hiding, frozen panes    |
|   _names      defined names, and the naming rules      |
|               tables share with them                   |
|   _shapes     shapes, and the grid that places a       |
|               control                                  |
|   _comments   notes and threads, and the box each gets |
|   _pictures   images, sized as Excel sizes them        |
|   _charts     what each chart plots, and chart sheets  |
|   _chartbuild charts written as Excel's Insert Chart   |
|               writes them                              |
|   _pivots     where each pivot table is, and what its  |
|               cache reads                              |
|   _richtext   runs of text in several fonts, read as   |
|               Excel shows them, not as marked up       |
|   _pagesetup  margins in inches, and the three header  |
|               boxes coded into one string              |
|   _validation what a cell accepts, and the inverted    |
|               attribute behind its dropdown            |
|   _conditional  cfRules, and the compatibility formula |
|                 that makes them fire                   |
|   _dxf        differential formats: what a rule paints |
|   _addresses  the five notations an address is spelled |
|               in, two of them zero-based               |
|   _rowcol     inserting and deleting rows and columns, |
|               and moving everything that records a     |
|               cell address                             |
|   _tokens     a formula, broken into editable pieces   |
|   _formulas   shifting and breaking references, for    |
|               shared formulas, a renamed sheet, and    |
|               the #REF! a deletion leaves behind       |
|   _calc/      the formula engine: a parser, an         |
|               evaluator with Excel's arithmetic, the   |
|               functions, and whole-workbook            |
|               calculation                              |
|   _sharedstrings   the per-workbook string table       |
|   _xstring    text as SpreadsheetML spells it: _xHHHH_ |
|               for what XML cannot carry                |
|   _schema     where a child element has to go          |
+--------------------------------------------------------+
| word / powerpoint / access   to follow, in that order  |
+--------------------------------------------------------+
| opc.py        Open Packaging Conventions               |
|   - parts, cached and flushed only when modified       |
|   - [Content_Types].xml: defaults and overrides        |
|   - relationships, resolved the way Office resolves    |
+--------------------------------------------------------+
| _xml.py       XML that reproduces its own source       |
|   - hand-written parser, no stdlib XML module          |
|   - per-node source spans and dirty propagation        |
|   - prefixes, attribute order and empty-tag form kept  |
+--------------------------------------------------------+
| _zip.py       the ZIP container, field for field       |
|   - local and central headers kept apart               |
|   - untouched members copied as stored bytes           |
+--------------------------------------------------------+

Each layer knows the one below it and not the one above. _zip.py knows nothing about OOXML; _xml.py knows nothing about packages.

docs/architecture.md is the contributor reference.

Untrusted input

A document is untrusted input, so the XML parser is deliberately less capable than a conforming XML processor:

  • <!DOCTYPE is refused outright. No DTD processing means no external entity resolution and no entity-expansion amplification.
  • Only the five predefined entities and numeric character references resolve. Any other &name; raises.
  • Nesting deeper than 256 elements raises instead of exhausting the stack.
  • zip64 archives and compression methods other than stored and deflate are refused by name rather than guessed at.

None of the five restricts anything OOXML is allowed to contain.

Install

pip install pyOfficeEditor

Python 3.10 or newer. No runtime dependencies.

Three things Excel does that catch readers out

Each is measured, each is pinned by a test, and each gives a wrong answer rather than an error if you miss it.

A formula assigned to a range is stored once. Excel writes the text on the group's first cell and leaves the rest pointing at it by index:

<c r="D2"><f t="shared" ref="D2:D5" si="0">B2*C2</f><v>510</v></c>
<c r="D3"><f t="shared" si="0"/><v>1445</v></c>

D3's formula is not in the file. It is D2's, shifted down a row. Finding the references to shift is the delicate part, because LOG10(x) contains G10, "A1" is a string literal, and 'My Sheet A1'!B2 has a reference inside a quoted sheet name.

A date is a number, and only its number format says otherwise. 2026-01-15 is stored as 46037. Confirming it is a date means following s="2" to cellXfs[2], its numFmtId to a format code, and the code to its date tokens, skipping the quoted, escaped and bracketed parts that only look like them: #,##0 "days" is not a date and [h]:mm is. Excel also numbers dates as though 1900 were a leap year, so serial 60 is a 29 February that never happened and is refused rather than reported as 1 March.

A changed cell invalidates cached results. A formula cell stores the value it last evaluated to, so setting B2 leaves D2's cached 510 behind. A workbook this library modified is saved with fullCalcOnLoad set and the calcChain part dropped, so Excel recalculates on open. The live gate proves it: after changing an input, Excel reports the recomputed number rather than the stale one still written in the file.

A worksheet's children are a sequence, not a set. SpreadsheetML declares them in order, and Excel refuses a file that breaks it rather than repairing one. The natural thing to do with a missing element is append it, and that is wrong whenever anything that must follow it is already there: a sheet Excel authored starts with sheetPr, so a missing dimension does not go at the front, and a workbook usually ends with extLst, so a missing calcPr does not go at the end. _schema.py writes all three orders down.

Formatting is shared, so it cannot be edited in place. A cell carries an index into cellXfs, and two hundred cells may carry the same one. Changing that entry restyles all of them, which is never what "embolden this cell" meant. So formats are immutable values: read the cell's, derive a new one, and the workbook finds or appends the entry that matches. Two consequences worth knowing:

  • A cell with no s attribute is not unformatted. It uses cellXfs[0], which names the workbook's default font. Resolving it to an empty format instead would make "add bold" silently change the typeface.
  • A solid fill's colour goes in fgColor, not bgColor. The names suggest otherwise, and putting it in bgColor produces a cell that looks unfilled.

Lower-level access

The packaging layer is public, for anything the host surfaces do not cover:

from pyofficeeditor import OpcPackage

with OpcPackage.open("book.xlsx") as package:
    # Navigate the way Office does: by relationship, not by path.
    workbook_part = package.main_document_part()          # 'xl/workbook.xml'
    document = package.xml(workbook_part)
    document.root.require("sheets")
    package.save()          # every untouched part keeps its original bytes

Development

python -m pip install -e ".[dev]"
python -m pytest -p no:randomly
pyright src tests

-p no:randomly keeps ordering reproducible. Strict Pyright must report zero errors on src and tests before anything merges, and a behavior change lands with its test in the same commit.

The suite needs no Office installation. It tests against six committed Excel-authored packages, an .xlsb among them, and against openpyxl-authored ones generated during the run, because a reader that only ever sees one producer's output encodes that producer's habits as rules.

There is also a live gate, which is the only check that can prove Excel accepts what this library writes. It drives real Excel through pyVBAharness, opens an edited workbook, and reads the cells back through Excel's own object model:

python -m pip install -e ".[dev]" --group live
python scripts/build_excel_fixtures.py
RUN_LIVE_EXCEL=1 python -m pytest -m live -p no:randomly

Windows and Excel only. Everything else in the suite runs anywhere.

Built with this

xlide-mcp is an MCP server built on this. This library is what answers when an agent asks it for a cell, a formula, a table or a conditional rule, and what writes the change back with the rest of the package left alone.

License

MIT. See LICENSE.md.

Release files for pyOfficeEditor 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyOfficeEditor 0.3.0
File Size Uploaded
pyofficeeditor-0.3.0.tar.gz 390.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyOfficeEditor 0.3.0
File Interpreter ABI Platform
pyofficeeditor-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 788.8 kB

Release files / pyofficeeditor-0.3.0.tar.gz

Download URL pyofficeeditor-0.3.0.tar.gz
Size 390.7 kB
Tags Source
SHA-256 checksum
How to use checksums
86f9b15d58eed5e6c1a10ffac8e605b0159fc3a2a1cc8f88f2036ed5549eb238
BLAKE2b-256 checksum
How to use checksums
6c18880b41f8196907034fd66c69dabf15b47feb97137b0f53684dc64ac473af
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / pyofficeeditor-0.3.0-py3-none-any.whl

Download URL pyofficeeditor-0.3.0-py3-none-any.whl
Size 398.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2a8759f3d9d6abee75872945a12cf5dbca74d029f55122da96cea0ec4a01742c
BLAKE2b-256 checksum
How to use checksums
b64c1a700993ecebb3cbaf8b9cd292e54bf5e145bed22ae78ca9fc32ef47cf61
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page