Fork me on GitHub

Word, Excel and PowerPoint
in Go, with no C anywhere.

A .docx, an .xlsx and a .pptx read into richdoc โ€” one document model the whole fleet shares โ€” and Word and Excel written back out, and a deck of rendered slides written as a .pptx with its speaker notes. The container underneath is its own library, so .xps and anything else built on OPC start from the same place. Pure Go, CGO_ENABLED=0, GOOS=js included. BSD-3-Clause.

2 libraries6 architectures, tests run971:1 bomb measured, not feared
main.go
// Three formats, three readers, one model.
doc, _  := ooxml.ParseWord(docx, opc.Limits{})
book, _ := ooxml.ParseWorkbook(xlsx, opc.Limits{})
deck, _ := ooxml.ParsePresentation(pptx, opc.Limits{})

// And back out again.
docx, _ = ooxml.WriteWord(doc)
xlsx, _ = ooxml.WriteWorkbook(doc)
pptx, _ = ooxml.WritePresentation(slides) // a picture per slide, with notes

// The container on its own, for .xps and the rest.
p, _ := opc.Open(bytes, opc.Limits{})
main, _ := p.Main()
word/document.xml โ€” reached through a RELATIONSHIP,
never by guessing a filename
One organisation, 2 libraries, both of them pure Goand the whole suite runs on 6 of them, big-endian included
Measured, not assumed

Nothing here is found by its path.

The main part of a package is reached through a relationship. Measured on three files LibreOffice wrote in the same minute, the one relationship type that names it points at three different places โ€” and a reader that hardcoded word/document.xml would work on most Word documents and on nothing else.

๐Ÿ”—

Three files, three targets

The officeDocument relationship named word/document.xml in the .docx, xl/workbook.xml in the .xlsx and ppt/presentation.xml in the .pptx โ€” and the standard lets a producer choose another name again.

๐Ÿ” 

A real producer wrote its own URI in the wrong case

In that same .xlsx, the core-properties relationship type came out with officedocument in lower case while the .docx and the .pptx from the same program, the same version and the same minute wrote officeDocument. Types are compared without regard to case, and a test fails if a future fixture stops showing it โ€” so the rule cannot quietly become folklore.

๐Ÿ“

A target is relative to the part that declared it

styles.xml from word/document.xml is word/styles.xml, and media\image1.png is one too. A target that climbs out of the package is refused rather than cleaned into something harmless, and an external one is an error rather than a path โ€” http://example.com/x cleaned into a part name becomes a lookup that fails for a reason with nothing to do with what went wrong.

๐Ÿงฑ

One container, three vocabularies

Which is the opposite of ODF, where one vocabulary spans every kind of document โ€” and the reason there are three readers here and not one. A heading is a paragraph wearing a style; a cell's value is usually not in the cell but in a shared strings table; DrawingML carries its formatting as attributes where WordprocessingML uses elements.

Writing one

What comes back is a package, not a rendering.

Word decides how wide a column is, where a page breaks and what a heading looks like. These libraries say which paragraph is a heading and which cells are a row, and the styles part gives those names something to mean. Three things the file has to get right, where the program refuses the whole document rather than the part:

๐Ÿงฉ

Every cell needs a paragraph

A w:tc with nothing in it is what makes Word call a document corrupt, so a short row is filled out and an empty cell still gets one.

โฃ

xml:space="preserve" on every run

Without it a reader that follows the standard trims a run, and a document rebuilt from runs that each lost their edges reads as onewordafteranother.

๐Ÿ“‘

A sheet name is built, never taken

Excel refuses a name over 31 characters, one holding : \ / ? * [ ], or a duplicate โ€” and it refuses the file, not the name.

๐Ÿท๏ธ

The heading styles are defined, not borrowed

A w:pStyle naming a style the package does not define works in Word, which knows Heading1 already โ€” and shows as plain text in a reader that does not, which is most of them. Defining them costs a few hundred bytes and is the difference between a heading and a paragraph everywhere but Word.

โ›” It is not a round trip. A document read from Word and written back loses everything richdoc has no word for โ€” the fonts, the margins, the section properties, the revision history โ€” because richdoc is a document model and not a .docx model. Said here because "write what you read" is the obvious thing to expect and it is not what this does.

How it is checked

The judge for a writer cannot be its own reader.

A package our own reader is delighted with can be one Word refuses. Where LibreOffice is installed the tests ask it: it opens what we wrote and converts it, and a file it will not open fails the test. Where it is not installed the test says so and skips, rather than passing quietly.

๐ŸŽฏ

The same package twice is the same bytes

A map walk is random, and a writer whose output changes between runs cannot be diffed, cached or signed. The determinism test builds eight relationship sources and compares five times: with three sources a map walk comes out in the right order one time in six, and the test reported a determinism that was not there.

๐Ÿ“‚

The control for the writer is the reader

Every package a test builds is opened again with Open. A writer checked against a string of expected XML is checked against what its author believed; the thing that has to be true is that it can be read.

โœ๏ธ

Two things are asserted by their spelling, and we say which

An Override's PartName starts with a slash, and the .rels Default is present. Our own reader accepts a package without either, so reading it back proves nothing โ€” and the programs that refuse such a package are the ones nothing here can run. The control is a .docx Word itself produced, which carries both.

๐Ÿ’ฏ

100% of statements, both libraries

Including the branches that report a bad file, which are most of the interesting ones in a format reader โ€” and the race detector is on for the lane that runs them.

What a file may ask for

A ZIP says how big it will be once opened. That is a number in a file.

It costs the author of that file nothing to claim four gigabytes, and a reader that believes it allocates them. Every limit has a number beside it, and the bomb in the tests is measured rather than feared: 8 638 bytes on disk open to 8 MiB, a ratio of 971:1, and the test fails if the fixture stops being a bomb.

64 MiB โ€” one part, opened

Refused from its declared size, before a byte is inflated โ€” then the read is bounded again, in case the declaration was a lie the other way.

384 MiB โ€” every part together

One part under the ceiling says nothing about a thousand of them.

8 192 parts ยท 4 096 relationships per part

Each decides an allocation made before anything is read, from a count the file chose.

256 MiB โ€” the file itself

And opc.Limits{} means every default, so the bounds apply to a caller who passed nothing.

The organisation

The container is one library, the vocabularies another.

Each is its own module with its own release line, so a program that only needs to walk an OPC package โ€” an .xps, a .vsdx, anything built on the same conventions โ€” takes opc and nothing else.

opc ready

The Open Packaging Conventions โ€” the ZIP, the content types and the relationships that .docx, .xlsx, .pptx and .xps are all built on, read and written. โ›” Nothing here is found by its path: the main part of a package is reached through a relationship, and measured on three files LibreOffice wrote in the same minute the one relationship type that names it points at three different places. A bomb is measured rather than feared โ€” 8 638 bytes expanding to 8 MiB, 971:1 โ€” so every limit has a number beside it. No dependencies outside the standard library.

repository ยท pkg.go.dev

ooxml ready

Word, Excel and PowerPoint into richdoc, and Word, Excel and PowerPoint back out โ€” a .pptx as one picture per slide with its speaker notes, the way marp-cli --pptx writes one. โ›” One container, three vocabularies โ€” the opposite of ODF, where one vocabulary spans every kind of document โ€” which is why there are three readers and not one. A heading is a paragraph wearing a style; a cell’s value is usually not in the cell but in a shared strings table; DrawingML carries its formatting as attributes where WordprocessingML uses elements. The writer is the fleet’s first: it defines its heading styles rather than relying on the latent ones only Word has, and where LibreOffice is installed the tests ask it, because a package our own reader is delighted with can be one Word refuses.

repository ยท pkg.go.dev

Where it runs

6 architectures, and the tests run on every one.

Cross-compiling proves a program builds. It says nothing about what it does when a byte order or a word size differs โ€” and that is where a format parser lives. Both repositories run their whole suite on each of these on every push: amd64, arm64, riscv64, loong64, ppc64le and s390x โ€” with cross-builds besides for js/wasm, Windows and macOS.

๐Ÿ”„

s390x is big-endian

A ZIP is full of little-endian integers, and so is every ZIP64 extra field. The standard library reads them for us โ€” which is exactly the kind of assumption a big-endian lane exists to check rather than to trust.

๐ŸŒ

GOOS=js is a build target, not an afterthought

Nothing here touches a file: Open takes bytes and Build returns them. A document converter in a browser tab needs no server, and nothing it opens leaves the machine.

๐Ÿ“ฆ

opc has no dependencies at all

Outside the standard library, nothing. ooxml adds exactly two: this container, and richdoc.

๐Ÿ”

Vet and gofmt are gates, not habits

On every push, on every lane, and the coverage gate reads the profile rather than the percentage โ€” total: 99.95% prints as 100.0%.

The container, the three vocabularies, and one model the rest of the fleet already reads.

From here a .docx becomes Markdown, reStructuredText, LaTeX or a PDF with no format-specific code in between โ€” because richdoc is what every one of those reads and writes.

Read the source on GitHub โ†’