Chapter 9: The last four, and a harness that had stopped measuring
2026-08-08. Chapter 8 left four disagreements untriaged. All four were my bugs.
Chapter 8 ended by sorting the fourteen remaining corpus disagreements into piles: six elapsed-time cells, three strict-ISO timezone cases, one whitespace argument I believe I am right about, and four I had not looked at. "Four are untriaged" is a fine place to stop for a day. It is not a fine place to stop.
So I went back for them. All four were real defects in this reader, in three distinct classes, and none of them were the kind of thing I would have found by thinking harder about the code.
First, the harness had rotted
Before any of that. The corpus checkout is not vendored, so re-running the
comparison means cloning ExcelDataReader again and rebuilding both dumpers.
I did, pointed it at the committed baseline, and got two files that had been
recorded IDENTICAL coming back DIFFER.
That is the alarming direction. Either the reader had regressed without a test noticing, or the baseline was recorded against something I could no longer reproduce.
It was the second, and the cause is embarrassing in a specific way. Chapter 8
describes fixing the C# dumper so that DateTimeOffset renders as UTC instead
of falling through a default: branch into Convert.ToString. That fix is
real; it is why those two files matched. It was never committed. The
journey/edrdump.cs in the repository is the version from before the fix.
So the committed harness could not reproduce the committed baseline, and had not been able to since the day both were written. Nothing failed, because the test that runs without .NET checks hashes of our output, and our output had not changed. The parity number was still true. The thing that proved it was not in the repository.
Committing the dumper fix makes HEAD reproduce the baseline exactly: 229, 33, 27, 14, file for file. That took an hour and produced no improvement to the library whatsoever, which is the correct amount of progress for discovering that your instrument was not in the box.
The lesson is the same one this project keeps teaching, one level further out. I had checked that the reader was correct. I had not checked that the thing checking the reader was the thing I had checked with.
The four
With the harness honest again, the four files.
Two of them were one bug. Issue467_SstEmptyContinue.xls and
Issue467_EmptyContinueLeftoverbytes.xls are named, helpfully, after their own
cause. Both produce one corrupted string in a 627-line dump: the reference reads
However theCCsion, and this read However the䌁sion. It then dropped the
final character of the string, 8,000 characters later.
A BIFF8 string split across a CONTINUE record restates its compression flag in
the first byte of the continuation. Chapter 5 already got that right, and there
is a test for it. What neither covered:
- A
CONTINUEmay carry its flags byte and no characters at all. The next byte then begins anotherCONTINUEwith another flags byte. Checking for a boundary once per character consumes the first flag and reads the second as character data.01 43becomes U+4301 instead ofC. - A
CONTINUEmay end with fewer bytes than a character needs. A UTF-16 unit never straddles a boundary, so that trailing byte belongs to no character and must be discarded rather than paired with the next segment's flags byte.
Both collapse into one rule. Before every character, settle the boundary, and
keep settling it until the position is somewhere a character can actually start.
The if became a for, which is the entire fix:
for {
if c.atContinuation() {
flags, ok := c.u8()
...
high = flags&0x01 != 0
continue
}
width := 1
if high {
width = 2
}
if end := c.segmentEnd(); end < len(c.data) && end-c.pos < width {
c.pos = end
continue
}
break
}
I want to be honest about how this was found, because it was not by reading. It
was by dumping the byte offsets of every continuation boundary in the record and
staring at 01 01 43 00 43 00 01 73 until the two adjacent boundaries at 8210
and 8211 stopped looking like a mistake in my probe.
The third was a chart. Issue321.xls produced thirty-three numeric cells
that appear nowhere in the reference's output, and lost two strings that do.
The invented values were an 11×3 block at the top-left of the sheet.
A worksheet that carries a chart stores the chart as its own BOF..EOF
substream in the middle of the worksheet's cell records, and that substream
contains NUMBER records for the chart's cached series values. My reader
skipped BOF as "not a cell record" and carried on, so the chart's cached data
was merged into the sheet as real cells, overwriting the two strings that
happened to share their addresses. Then the chart's EOF ended the sheet early,
twenty-three records before the real one.
The fix is a depth counter. A nested BOF opens a substream that is not ours;
its records are skipped and its EOF decrements rather than closing the sheet.
This is the third time this format has punished me for assuming a record stream is flat. Chapter 8 was cells arriving out of row order; this is records arriving from a different document entirely.
The fourth was the same bug I had already fixed, in the other reader.
Issue324.xlsx came out as 289 rows of one cell each, arranged on a diagonal.
The sheet is written like this, once per cell:
<row r="1" spans="1:13" ...><c r="A1" s="1" t="str"><v>London Metal Exchange</v></c></row>
<row r="1" spans="1:13" ...><c r="B1" s="1" t="str"><v></v></c></row>
<row r="1" spans="1:13" ...><c r="C1" s="1" t="str"><v></v></c></row>
Every <row> element carries r="1". They are one row. The scanner read the
r attribute correctly and then emitted one row per element, so a 13-column
sheet became 13 rows that all claimed to be row 1.
Chapter 8's whole subject was placing cells by absolute index instead of by
arrival order, in the .xls reader. I fixed it there, wrote a page about why it
mattered, and did not go and look at whether the .xlsx reader made the same
assumption. It did. The two readers were written days apart and share nothing
but a mental model, and the mental model was the bug.
After merging rows by index, one difference remained: 117 cells where the file
writes <c t="str"><v></v></c> and I returned "" where the reference returns
nothing. D15 already settled the shape of this question for inline strings:
empty text written into the cell means absent, empty text interned in the shared
table means a value. A formula's cached empty result belongs on the absent side.
Applying it fixed the file and moved nothing else in the corpus, which is the
test D15 used and the only reason to trust it.
Result
| Chapter 8 | Now | |
|---|---|---|
| Byte-identical | 229 | 233 |
| Both read it, output differs | 14 | 10 |
| Both refuse | 33 | 33 |
| Only ExcelDataReader reads | 27 | 27 |
| Only this library reads | 0 | 0 |
96% of the files both readers accept. Four fixed, nothing regressed. I checked
that by re-running the sweep against a build of HEAD as well as of the fix, so
that "no regressions" is a measurement and not an assumption. The two files that
had looked like regressions differ at HEAD too; they were the harness.
The remaining ten, now with none untriaged:
- Five are elapsed-time cells, where the reference returns a
TimeSpanand this returns a time. A duration and an instant are different things and the interface specifiestime.Time. - Three are OOXML strict cells holding a time with no date. The reference resolves those against today's date, so its output for them changes overnight. That is the one finding here worth sending upstream. Our own output was worse, and the first afterword below is what it turned out to be.
- One is
Issue425.xlsx, whitespace, and I had this one backwards for two days. See the second afterword. - One is
Issue158.xls, a single extra empty cell, attempted in chapter 8, broken worse by the guess, reverted, and still open.
Two of those ten are things I would change about this library. Neither is a mystery any more, which is the whole point of spending a day on four files that nobody had complained about.
Afterword: the bug I described wrongly
Above I wrote that this library "renders 10:59:59.9999999999984025 where it
means 11:00:00", and called it a float-precision bug on our side. Going to fix
it, I opened the file:
<c r="B2" s="4" t="d"><v>01:34:00.00000000000154875</v></c>
We were not rendering anything. That is the text in the file, and we were handing
it back verbatim as a string, because parseISODate had layouts for a date and
for a date and time but none for a time on its own. Every layout failed and the
value fell through to the branch that returns the raw text.
So the diagnosis was wrong in a way worth recording. I had read our own output, seen a number that looked like floating point error, and concluded we had done the arithmetic badly. We had not done any arithmetic at all. The noise belongs to whatever produced the file, which converted an Excel serial into ISO text and wrote the serial's error into the string.
The fix is to parse the time forms, and to resolve everything a t="d" cell
holds to the millisecond. That second half is not new policy. SerialToTime has
rounded to the millisecond since chapter 1 and says why in a comment: the values
that arrive are the result of Excel's own decimal rounding, and an exact
conversion produces times like 09:59:59.9999997. A serial that took the scenic
route through text is the same value with the same problem.
A time with no date is placed on the workbook's epoch day, which is where a serial holding only a fraction already lands, so the two spellings of "a time and no date" now decode to the same instant.
This costs a file. strict/BigFormatted.xlsx contains
2014-01-16T15:59:59.99999979045242400, and ExcelDataReader keeps that noise;
its dump only looks clean because .NET's .fff truncates it. Rounding gives
16:00:00.000, which is the value the file means and the one Excel displays, and
byte-identical parity says that is a difference. So the count goes 233 to 232 and
the trade is written into the table of intentional differences.
I would rather be right than matching, but only when I can say which one I am choosing. That is the whole reason the table exists.
Second afterword: two things I said about the reference that were not true
Drafting an upstream issue meant writing a reproduction that did not go through my own harness, and two claims did not survive contact with it.
The timezone finding does not exist. Chapter 8 has a memorable little table
showing strict/Open.xlsx decoding to 16:00, 11:00 and 02:00 under New York, UTC
and Tokyo, and concludes that ExcelDataReader's output depends on the host. A
twenty-line program that calls the library directly and prints the CLR type
returns 2009-01-01 11:00:00 under all three.
The spread was mine. Release 3.9.0 returns a DateTimeOffset for those cells and
attaches the host's offset to a file that carries none, and my dumper renders a
DateTimeOffset through .UtcDateTime. Converting a fixed wall clock with a
varying offset into UTC is what moved the number. Release 4.0.0 returns a plain
DateTime and there is nothing left to see.
Something real is underneath: 3.9.0 hands you an instant that depends on where
you are standing, which will bite anyone who calls .ToUniversalTime(). But that
is a much smaller claim than the one I published, and it is fixed in the
development branch already.
The whitespace finding is the wrong way round. Chapter 8 says the reference
trims shared strings carrying xml:space="preserve", and that I think it is
wrong to. Issue425.xlsx holds three strings, and the reference returns:
" text " <- the one with xml:space="preserve", preserved
"text" <- no xml:space, trimmed
"text text" <- no xml:space, trimmed
Which is correct. xml:space="preserve" is exactly the marker that says
whitespace is significant, and absent it an application may normalise. This
library preserves unconditionally, so on that file we are the ones out of step.
It is now listed as a defect here rather than a difference of opinion.
Both mistakes have one cause, and it is the same one as the harness that was
never committed, one turn further in. I read the reference implementation's
behaviour off my own rendering of it. The dump is a lossy projection built to
make two languages comparable, and I treated it as though it were the thing
itself. It collapses DateTimeOffset into UTC, it collapses int and double
into the same bits, and every one of those collapses is deliberate and
documented. None of that makes it safe to read a conclusion about the other
implementation off it.
The rule I did not have and now do: a claim about the code is proved by the harness; a claim about the other implementation has to be proved against the other implementation. Those are different tools and I own only one of them.
Writing is AI assisted. Thoughts and publishing are human-gated.
Rendered from jloor/go-excel-reader at 18e4904. The markdown in that repository is the source of truth; if this page disagrees with it, this page is stale.