The EPUB that passed every check and opened blank
A reader opens a book this tool made. There is no cover. There is no table of contents. Chapter one starts immediately, and there is no way to get anywhere else. Nothing on the screen says why, and nothing ever will.
The file is a valid EPUB 3. The official epubcheck passes it with zero errors and zero warnings. The text renders perfectly. Every formal instrument I own says the book is fine.
It took about forty books, sent to a real device one at a time, to find out what was wrong. Twice.
Valid is an answer to a different question
epubcheck is not broken here, and that is the whole problem. It answers *does this file conform to the EPUB specification*, and the answer was honestly yes, every time.
What a reader gets is not the file. Send a book to a Kindle and Amazon converts it to KFX first, and that converter has opinions no specification records. Two of those opinions delete the cover and the table of contents of the entire book — not the chapter at fault, the whole book — while leaving the reading text untouched.
So the book is simultaneously valid and maimed, and nothing in the toolchain is lying to you. The validator and the device are answering different questions, and I had only been asking one of them.
The one where the correct value is the wrong one
The first case was a five-volume set, vertically typeset and read right to left.
An EPUB declares that with `page-progression-direction="rtl"`. Correct, sufficient by the spec, and enough for every other reader. The Kindle converter also wants `<meta name="primary-writing-mode">`, which is Amazon's and not part of EPUB — and here is the part I did not believe until the device had said it twice:
primary-writing-mode: vertical-rl → cover still gone
primary-writing-mode: horizontal-rl → cover restored, text still vertical
`vertical-rl` is the value that honestly describes a vertically typeset book. It does not work. `horizontal-rl` — the one that reads like a contradiction — does, because the name describes how *pages* progress, not how text flows. Text direction comes from the stylesheet and is unaffected either way.
There was also a fix that looked like a fix: drop `rtl` entirely and the cover comes back. It does, and the book then opens left to right, so the reader turns pages backwards through the whole thing. Curing the symptom by removing the feature is very attractive at hour four, and it is not a fix.
The one where my instrument lied to me
The second case was a different book, and rule one did not apply to it. This is where it got expensive.
I removed the translator metadata. Dead. The images. Dead. The stylesheet. Dead. All but two chapters. Dead. All of those at once. Dead. I swapped its cover for a known-good cover, and put its cover onto a known-good book — that pair is what finally proved the image was innocent and the *book* was guilty.
Seven consecutive single-variable tests, every one properly controlled, every one useless. They were useless for a reason worth stating plainly: **each of them removed something that was not the cause, from a book that still contained the cause.**
What worked was going the other way — strip the book down to the exact shape of one that works, then add back. That version lived. Its only difference from the last dead one was whether the chapter text was real or a stub. So: the text. Bisect the text. Twenty-five blocks per chapter, alive. Thirty, dead. Look at blocks twenty-six through thirty.
A pictograph at the head of a heading. Another beside a pull-quote. Emoji.
Now the part that had already cost a dozen rounds. Early on I had scanned that book for unusual characters and the scan reported none. It was wrong, and it was wrong because of something I had done myself: at that point the build was writing those pictographs as numeric entities — `🧠` and friends, which are **plain ASCII at the byte level**. My scanner read bytes. The emoji were invisible to the only instrument pointed at them, and its clean report is what steered me away for a dozen rounds.
Then I did it again. The test meant to settle the question — the whole book with every emoji removed — applied its stripping pass *after* serialisation, by which point the pictographs were entities again. The regex matched nothing. The verification counter, reading the same bytes, agreed that zero emoji remained, and I nearly reported that as the decisive result.
The rule that fell out of it is four words: **decode before you count**. Count from the parsed document, never from the file.
Why it survived so long
Three things had to be true at once, and they were:
- **epubcheck does not care.** Formally the file is perfect.
- **Both control books happened to contain zero emoji.** The one comparison that would have exposed it instantly was never run, because nothing suggested running it.
- **The scan that should have found it reported clean**, for the reason above.
Any two of those and it falls out in an hour. All three, and a real defect hides behind a green check for most of a day.
The rule that is not "remove emoji"
The obvious fix is too big. A book must never lose a character of its own language to a compatibility rule, and "astral plane" would do exactly that — CJK Extension B lives up there, and a book in the test corpus carries such a character a dozen times and displays perfectly.
So the range stops at pictographs. Arrows, bullets and ticks stay; another book carries 241 bullets and is fine. A diagram made of boxes and arrows still reads as a diagram once the decorative icon in its heading is gone: a heading like `🧠 Concept map` becomes `Concept map`, and the label was always the text. The icon was always decoration.
A compatibility rule that quietly widens is worse than the defect it fixes, because it takes content the reader cannot see is missing.
What changed, and what I still do not know
Both rules are enforced now, asserted separately on both sides of the Node/Swift border, and — this matters more than the fixes — promised, so that a later refactor is not free to drop them. A fix nothing promises is a fix with a shelf life.
The full ledger of what was *ruled out* is in the repo, because roughly thirty-five of those forty books existed only to kill a hypothesis, and every one of those deaths is a question the next person does not have to re-ask on their own device. The three tests that turned out to be measuring nothing are in there too, including the one where renaming a file did not produce a new book, because a Kindle matches on metadata and served the cached conversion instead.
What I still do not know is in there as well: why the converter behaves this way, whether every storefront's converter behaves identically — I tested one — whether other delivery paths differ, and where the harmful range actually ends. The upper bound I chose stops below CJK Extension B, which is a design decision, not a measurement.
The thing I would tell myself
A validator tells you the file is well-formed. A device tells you the book is readable. Those are not the same claim, and only one of them is the one your reader experiences.
There is a sibling lesson about building rulers that can actually fire, and another about judging the artifact instead of the exit code. This is the third face of the same thing, and the most uncomfortable one: sometimes the exit code is correct, the ruler is correct, and the artifact is correct by every definition anyone has written down — and the reader still gets a broken book.
The only instrument that could see it was a person with the device in their hands, reporting two words at a time, forty times.
---
*reepub is a 100% offline Mac tool that turns scanned paper into clean, reflowable EPUBs. Source, MIT.*
Keep reading
-
My selftest said 3/3. One of the three had never fired.
Four measurements lied to me in a single day of shipping, and every one of them lied in the shape of a result. The worst was a gate that proved itself — by counting an unrelated failure as a catch.
-
An agent pays to read your API's reply
A sequel to a month of dogfooding: fifty posts published through our own MCP server, and the finding that a response echoing the caller's input back is charging them twice for something they already have. Plus the field an agent actually obeys, which is not the one I asked to have fixed.
-
Four ways past your own rate limiter
A token bucket, a retry decorator, a circuit breaker and a wrapper class — all present, all correct-looking, and the quota was being blown through every day. The most instructive of the four holes is a two-line __getattr__.