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.
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.
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.