Skip to main content

Opening an EPUB Starts With Addresses

Updated 8 min readMarkdown

Opening an EPUB sounds simple. You unzip it, read a small file that tells you where the package file is, read that, and you have the book: its title, its files, its reading order, its table of contents.

The walk from the zip to the first page is short, but almost everything on it is an address, and almost every address can be written two ways. When entry gets an address wrong, nothing downstream complains. It just looks things up under the wrong key and quietly returns the wrong thing: a missing chapter, a file path where a title should be, a table of contents that answers with confidence and gets it wrong.

The directory that differed only in case

A book wouldn't open. Its container file said the package file was at ops/fb.opf. Inside the zip, the directory was OPS/. On a system that ignores case, that's the same path. Mine doesn't, so the read failed.

The retry uppercases just the directory part and tries again, filename unchanged. If that works, the corrected directory stays in place for everything that follows — the file list, the table of contents, every relative read. If it fails too, the book doesn't open, and the error says the package file couldn't be found.

I didn't build a general case-insensitive search. The fault I saw was a directory in the wrong case, so the fix covers that fault and nothing else. A wider search would also have opened this book, but it would have changed what every package read does, and it would have hidden the next fault I haven't seen yet.

Two smaller things sit in the same code. The container scan takes the last matching rootfile when several declare the package type; there is no check that stops at the first. And nothing verifies the zip's mimetype file at all — the container's media-type attribute is the only thing consulted.

The file list was inside metadata

Another book came in with its package file arranged wrong. The spec puts the manifest — the list of every file in the book — as a direct child of the package element. This file had it nested inside the metadata element instead. A lookup that only checks direct children finds nothing, and then the book looks empty.

The lookup now searches all descendants instead of just children. That's it. The files still have to exist and be well-formed; the search only stopped assuming they sit exactly where the spec puts them. The same change went in for the spine, which can be nested the same way. Reading order is numbered in steps of ten, leaving gaps, and each entry's address comes from the manifest through its idref — the spine carries no address of its own.

One more thing about that file list: when a single entry fails to read, the failure is logged and the entry's content stays empty. The book still opens.

The table of contents used a different base

This one took the longest to see, because nothing failed on the surface. The table of contents showed up, but clicking an entry sometimes landed nowhere, and chapter titles sometimes showed up as file paths instead of titles.

The cause was two address systems. A table-of-contents entry writes its address relative to the table-of-contents file itself, so it might say ../Content/x.html. The reading order writes its addresses relative to the package directory, so it says Content/x.html. Same file, two addresses, and a lookup that compares strings will never match them.

Every table-of-contents address now gets converted to the package directory's base before anything tries to match it: join it with the table-of-contents file's directory, clean up the .. parts, then make it relative to the package directory. The part after # stays exactly as it is, because the reader needs it to jump to a spot inside the chapter. An empty address stays empty — a grouping entry with no file behind it should not come back as a path to something that isn't there. An address that climbs out above the package directory stays as written, for the same reason: converting it would produce a path that points nowhere real.

Checked against an Arabic book, ten checks, all passing: every entry matched its chapter, and titles came back instead of file paths. The reason a missed title shows up on screen as a file path is plain: the title lookup falls back to the address itself when nothing matches.

Converting the addresses broke two neighboring reads in the same change. They were still reading descriptions relative to the table-of-contents file, and once the addresses stopped being relative to it, those reads had to move to the package directory too. And the conversion only went into the main EPUB reader. The older reader and the BS4 one still use their own bases, and the old cached parses were left to expire on their own rather than being migrated.

The anchor: part of the address, or part of the key?

Table-of-contents entries often point inside a file, in the form file#anchor. The anchor's job is to locate a heading inside the file. Fetching works on files. One code path used the full string, anchor included, as the key for fetching and caching a chapter. The same chapter, fetched once per anchor, got stored again as if it were a new chapter. In the paragraph table, 308 extra rows turned up that way, all from one batch of books.

Fixing it turned into a bigger cleanup than expected. The idea of stripping the anchor before you look anything up was written out 18 times across the EPUB, MOBI, PDF, and TXT readers plus the API and chat code, in three different spellings. Two of them turned a missing value into the string 'None' — a key that looks perfectly fine and matches nothing, so the lookup fails silently. All 18 went into one small module: one function that splits an address into its file part and its anchor, and one wrapper that returns just the file part. Empty or missing values come back as an empty string.

The rollout went in two passes. The first pass changed nothing observable — fifteen replacements that behaved exactly as before. The two real fixes went in second. And the check at the end had to use a pattern that covered every spelling, because the first search for the 18 copies had missed half of them: it looked for split('#') and the copies written split('#', 1) didn't show up. The same blind spot showed up again when a deleted function name was left referenced in another file and eight tests went red. Deleting a function takes two searches, not one: the old spellings, and the old name.

One more place where anchors leak: looking up a manifest entry by address matches on substrings, and when nothing matches, it reads the file out of the zip and appends a freshly made manifest entry, id and all. A lookup can grow the list it is searching. And a "relative" read inside the reader does not mean relative to the package directory — it means relative to the table-of-contents file's directory. An old commented-out line in the code shows that base was changed on purpose at some point, and anyone reading the function fresh would still guess wrong.

The cleanup also turned up something left open on purpose. Chapter-title lookup strips the anchor in two of the five readers and not in the other three. Nothing triggers it right now, because callers pass file-level addresses, but the difference is still there.

There is a sharper version of the same story from the question-answering side. A tool that answered from the table of contents was fed file-level addresses for its citation checks, so an address that still carried its anchor failed every check, and every citation got dropped. The more correctly the anchor was passed through, the cleaner the citations disappeared. The fix was the same one: the anchor means something in exactly one place — finding a passage inside a chapter. Everywhere else, only the file part counts.

After the whole walk, the reader throws the scaffolding away: the zip handle, the parsed package tree, the root element. Line numbers go into the chapters, titles come out of the table of contents, and what remains is the set of addresses fixed along the way. That is why entry mistakes don't get corrected later. There is no later pass that re-checks them. The addresses are what the rest of the book runs on.

19 views

About the author

  • Writing about ebook technology.

No comments yet. Be the first to comment.