ooxml

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: BSD-3-Clause Imports: 8 Imported by: 0

README

ooxml

Word, Excel and PowerPoint into richdoc. Pure Go, CGO-free, including GOOS=js. The container is go-ooxml/opc; the only other dependency is richdoc itself.

doc, err := ooxml.ParseWord(docx, opc.Limits{})          // .docx
deck, err := ooxml.ParsePresentation(pptx, opc.Limits{}) // .pptx
book, err := ooxml.ParseWorkbook(xlsx, opc.Limits{})     // .xlsx

Word, PowerPoint and Excel are all read. The reason they are separate readers rather than one is the first thing worth knowing about this format.

⛔ One container, three vocabularies — the opposite of ODF

In OpenDocument, office:text, office:spreadsheet, office:presentation and office:drawing are four bodies that share one vocabulary: text:p is a paragraph in all of them, so one walker reads all four. In OOXML the three formats share the container — the ZIP, the content types, the relationships, all of which live one repository down — and then speak three different languages:

the text is
Word w:p > w:r > w:t (WordprocessingML)
PowerPoint a:p > a:r > a:t (DrawingML, inside a p:sp shape)
Excel c > v, where v may be an index into xl/sharedStrings.xml

So there is one reader per format here. The thing they share is opc.

PowerPoint: the order is the list's

doc, err := ooxml.ParsePresentation(pptx, opc.Limits{})

Each slide comes out as a heading of its position, followed by the text in its shapes.

⛔ The order comes from p:sldIdLst, not from the filenames. slide1.xml, slide2.xml … are in order in a file straight out of a converter, and stop being in order the moment somebody moves a slide: the names stay where they were and the list changes. A reader that sorted by filename would silently reorder the deck, and it would pass every test written against a converted fixture — which is why there is a test whose list is the reverse of its filenames.

The heading is the slide's position and not a title taken from a shape: a .pptx carries no name for a slide, and the placeholder that would say which shape is the title is absent from a converted deck — the same thing go-odf found for .odp, where presentation:class is stripped.

⛔⛔ DrawingML carries its formatting as ATTRIBUTES

This is the trap that makes a shared implementation wrong:

a bold run is
WordprocessingML <w:rPr><w:b/></w:rPr> — a child element
DrawingML <a:rPr b="1"/> — an attribute

A reader that reused Word's runProps here would find no formatting at all in a .pptx, because every child of a:rPr is a colour or a font and none of them is a toggle.

And two of those attributes are named values, not booleans: u="none" and strike="noStrike" are off. Measured: LibreOffice writes b="0" strike="noStrike" u="none" on every run of a converted deck, so a reader treating the attribute's presence as "on" would make the whole deck bold, underlined and struck through at once.

A field means the opposite of Word's

A slide's a:fld holds in its a:t the value a viewer showed — the page number, the date — so it is read. Word's w:instrText holds the instructions and is dropped. The two formats put opposite things in the same place, which is one more reason the readers are separate.

Bullets are what the file says

a:buChar is a bulleted item, a:buAutoNum a numbered one, and ⛔ a:buNone says explicitly that a paragraph is not one — which has to be honoured, because a converted deck puts marL and indent on every paragraph of a text box, so an indent alone is not a bullet.

⛔ A heading is a paragraph wearing a style

There is no heading element in WordprocessingML. A heading is a w:p whose w:pStyle names Heading1…Heading9 — which is the opposite of ODF's text:h with its outline-level attribute. w:outlineLvl is read as well, because a document may say it that way instead, and it is zero-based where richdoc is one-based: passing it through would make every top heading a level zero.

Excel: a cell's value is often not in the cell

book, err := ooxml.ParseWorkbook(xlsx, opc.Limits{})

Each sheet comes out as a heading of its own name — Q3 actuals from xl/workbook.xml, not sheet2.xml from the disk — followed by its cells as a table.

⛔ With t="s", which is what every piece of text in a real workbook uses, the <v> holds an INDEX into xl/sharedStrings.xml. A reader that took <v> as the text produces a table of 0, 1, 2 … in place of every word — and it looks like a working reader, because small integers are plausible cell contents in a spreadsheet. Measured on a file LibreOffice wrote from a three-line CSV: every one of its five text cells was an index.

An index the shared table does not hold yields nothing, not the number: a workbook whose strings are missing should come out with empty cells rather than with the indexes in place of its words.

⛔ A cell is placed by its reference, not by its position among its siblings. A row writes only the cells that hold something — <c r="A1"/> then <c r="C1"/> for a row whose B is empty — so a reader that appended in order would shift C into B's place and every column after it. That is the defect which shows up as "the numbers are in the wrong columns" on somebody's real spreadsheet and on no fixture.

t= the value is
s an index into the shared strings
inlineStr in <is><t> on the cell itself
str a formula's string result, in <v>
b 0 or 1 in <v>, shown as FALSE or TRUE
e an error like #DIV/0!, in <v>
n or absent a number in <v> — Excel omits t, LibreOffice writes it, so both must work

The <f> beside a value is the formula and is dropped: =SUM(A1:A9) is what the cell computes with, and the number is what it showed.

A phonetic hint (rPh) is stored beside a word and never shown in the cell; its <t> would otherwise be appended to the string.

⛔ Four things in the file that must not reach the document

w:instrText a field's instructions. PAGE \* MERGEFORMAT is what the file says and 7 is what a reader shows; emitting the instructions puts typesetting directives into the prose
w:delText text somebody deleted with track changes on. It is in the file so the change can be rejected, and emitting it puts removed sentences back into the document — a confidentiality problem before it is a correctness one
w:b w:val="0" a toggle property switched off. Reading the element's presence as "on" makes bold every run whose style switched bold off
an empty w:p Word is full of them; they are how a person makes vertical space. A paragraph with no inlines reaches a PDF as a line of nothing

w:ins is the other half of the third: a tracked insertion is part of the document, and its runs are read as any others.

What the fixtures proved, and what they could not

The .docx in testdata was written by LibreOffice. Two rules come straight from reading it:

  • a hyperlink arrives as an external relationship, so its Target is the URL itself. opc.Resolve refuses an external target — correctly — and a reader that called it here would lose every link in the document;
  • numbering.xml said numFmt="none" for a list whose style said nothing. So the fallback for a list whose format the file does not give is unordered: a list presented as numbered invents an order the document never had, while presented as bulleted it loses nothing that was there.

⛔ The hand-made fixtures are for the branches a converter will not produce on demand — a field code, a tracked deletion, a toggle switched off. Discovering a rule needs a real file; exercising a branch does not, and the two kinds of fixture are in separate files so the distinction cannot blur.

Underline is not emphasis

Underlined text has no richdoc inline of its own and is deliberately not mapped to Emph or Strong. In a legal document an underline is a defined term; in a web page it is a link that is not one. Borrowing another meaning for it would make a document that says something it does not.

The first row is not a header

Word marks a header row with w:tblHeader. Taking the first row regardless would turn a two-row table of data into one row of data under a heading that is data.

Checks

100 % statement coverage with zero uncovered blocks, race-clean, go vet and gofmt clean, ten cross-compile targets including js/wasm, linux/loong64 and linux/s390x.

⛔ Two defects were found by the coverage gaps rather than by a test, and both were real:

  • the depth guard, a token pass of its own, was also reporting syntax errors. That called a truncated document "nested too deep", and it made every error return in every walker unreachable — one pass that validates everything leaves nothing for the others to find. Coverage went from 87.9 % to 98.7 % when it was given back its one job;
  • loadNumbering asked the package for relationships that loadRels had already read, so its error branch could never run. It uses the ones in hand now, which is also one less part to parse twice.

Licence

BSD-3-Clause.

What the mutations said

Seven, five caught. ⛔ Two survived, and between them they say something true about the code rather than about the tests. Deleting the instrText/delText case changes no test, because default drops what the walker does not name; making default emit the text changes no test either, because that case catches those names first. Two independent mechanisms, each sufficient, so a mutation of one is masked by the other — which is what defence in depth looks like from a mutation report, and is not a weak test.

What no mutation of a single line can establish is the pair being wrong at once. That is what TestDeletedTextStaysDeletedAndInsertedTextArrives is for: it asserts the output rather than the mechanism, which is the only assertion that survives either one being rewritten.

Documentation

Overview

Package ooxml reads Office Open XML documents into the neutral github.com/go-richdoc/richdoc model: Word (.docx), and in time Excel (.xlsx) and PowerPoint (.pptx).

One container, three vocabularies

⛔ This is the opposite of ODF, and the difference decides the shape of the package. In OpenDocument, office:text, office:spreadsheet, office:presentation and office:drawing are four BODIES that share one vocabulary: text:p is a paragraph in all of them, so one walker reads all four. In OOXML the three formats share the CONTAINER — the ZIP, the content types, the relationships, all of which are github.com/go-ooxml/opc — and then speak three different languages:

Word         w:p > w:r > w:t                    (WordprocessingML)
PowerPoint   a:p > a:r > a:t                    (DrawingML, inside p:sp)
Excel        c > v, where v may be an INDEX      (SpreadsheetML)
             into xl/sharedStrings.xml

So there is one reader per format here, and the thing they share lives one repository down.

Index

Constants

View Source
const (
	RelHyperlink = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
	RelImage     = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
)

RelHyperlink and RelImage are the relationship types a paragraph's contents point through.

Variables

This section is empty.

Functions

func ParsePresentation added in v0.2.0

func ParsePresentation(src []byte, lim opc.Limits) (*richdoc.Document, error)

ParsePresentation reads a PresentationML package (.pptx) into a document: each slide as a heading of its position, followed by the text found in its shapes.

⛔ The slides come out in the order the PRESENTATION gives, which is p:sldIdLst — not the order of the relationships, and above all not the order of the filenames. slide1.xml, slide2.xml … happen to be in order in a file straight out of a converter, and stop being in order the moment somebody moves a slide: the names stay where they were and the list changes. A reader that sorted by filename would silently reorder the deck.

func ParseWord

func ParseWord(src []byte, lim opc.Limits) (*richdoc.Document, error)

ParseWord reads a WordprocessingML package (.docx) into a document.

func ParseWorkbook added in v0.2.0

func ParseWorkbook(src []byte, lim opc.Limits) (*richdoc.Document, error)

ParseWorkbook reads a SpreadsheetML package (.xlsx) into a document: each sheet as a heading of its own name, followed by its cells as a table.

⛔ A cell's value is OFTEN NOT IN THE CELL. With t="s" — which is what every piece of text in a real workbook uses — the <v> holds an INDEX into xl/sharedStrings.xml, so a reader that took <v> as the text produces a table of "0", "1", "2" … in place of every word. Measured on a file LibreOffice wrote from a three-line CSV: every one of its five text cells was an index.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL