<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[jbook]]></title><description><![CDATA[jbook]]></description><link>https://jbook.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 29 Oct 2026 10:55:17 GMT</lastBuildDate><atom:link href="https://jbook.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Opening an EPUB Starts With Addresses]]></title><description><![CDATA[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…]]></description><link>https://jbook.hashnode.dev/opening-an-epub-starts-with-addresses</link><guid isPermaLink="true">https://jbook.hashnode.dev/opening-an-epub-starts-with-addresses</guid><category><![CDATA[epub]]></category><category><![CDATA[ebooks]]></category><dc:creator><![CDATA[jbook]]></dc:creator><pubDate>Thu, 29 Oct 2026 09:46:57 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<h2>The directory that differed only in case</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>The file list was inside metadata</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>The table of contents used a different base</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>The anchor: part of the address, or part of the key?</h2>
<p>Table-of-contents entries often point inside a file, in the form <code>file#anchor</code>. 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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
]]></content:encoded></item><item><title><![CDATA[Two Tables of Contents, and One Impersonating the Other]]></title><description><![CDATA[I was testing the question-answering side of my reader when I asked it about an essay I knew was in the book. It said the entry was not in the table of…]]></description><link>https://jbook.hashnode.dev/two-tables-of-contents-and-one-impersonating-the-other</link><guid isPermaLink="true">https://jbook.hashnode.dev/two-tables-of-contents-and-one-impersonating-the-other</guid><category><![CDATA[epub]]></category><category><![CDATA[ebooks]]></category><dc:creator><![CDATA[jbook]]></dc:creator><pubDate>Sun, 25 Oct 2026 04:22:00 GMT</pubDate><content:encoded><![CDATA[<h1>The Table of Contents Was Wrong, and Nothing Complained</h1>
<p>I was testing the question-answering side of my reader when I asked it about an essay I knew was in the book. It said the entry was not in the table of contents, and that the book's table of contents had 7 entries in total. I could see the essay in the book. No error, no crash. The answer was just wrong.</p>
<p>That was the first of three times the table of contents turned out to be the wrong list, or the right list read as the wrong kind of thing. None of them raised an error. That was what made them hard to find: the reader kept working, and the answers were just wrong.</p>
<h2>The file the guess landed on</h2>
<p>EPUB has two table-of-contents formats. EPUB 2 uses NCX, usually toc.ncx. EPUB 3 uses a navigation document, usually nav.xhtml, declared in the manifest with properties="nav". The spec says the navigation document replaces NCX. Books did not get the memo: plenty of EPUB 3 files carry both, and the two do not always agree. Plenty carry no NCX at all.</p>
<p>My parser only read NCX. And the code path for "no NCX" was an empty else branch — a comment saying to look for nav.xhtml, and a pass underneath it, plus a TODO comment somewhere else in the file. Navigation support was not broken. It had never been built.</p>
<p>There was also a trap in front of it. When the spine does not declare a table-of-contents id, the parser guesses from a short list: ncx, toc.ncx, toc. A lot of EPUB 3 navigation documents have the id "toc". So the guess landed on the navigation document, the NCX parse ran on it, found no navMap, and reported no table of contents — for a book that had one. Every step succeeded.</p>
<p>The order of the fix mattered: skip any manifest item whose properties include nav before guessing, or the new navigation code would never run. One detail made that check easy to get wrong. Properties comes back as a list, so the check is whether nav is in it. Comparing it to a string never matches. The same list-versus-string mistake had also been quietly breaking EPUB 3 cover detection in the same file, along with a field name written with a hyphen where the code used an underscore, so the cover fix went in during the same round.</p>
<h2>The fallback that only runs when NCX is missing</h2>
<p>Navigation parsing only runs when there is no NCX at all. That was deliberate. A lot of books carry both documents with contents that disagree, and switching to navigation-first would have changed the table of contents of every one of them overnight. Books that were already working would have changed for no reason.</p>
<p>The new code is one method. It reuses what NCX parsing already had: the same address conversion, pointed at the navigation document's own directory, and the same two outputs — a tree for display and a flat list for counting and searching — so nothing downstream had to change.</p>
<p>Real files break the spec in small ways, so the code retreats in steps. If the XML parse fails, it tries HTML. If no navigation block is marked as the table of contents, it takes the first navigation block that contains a list. If the list is wrapped in an extra div, it takes the first list inside it rather than insisting on a direct child. Label text is joined from the link's text, with whitespace collapsed using a pattern that deliberately does not touch full-width spaces, and if the label comes out empty it falls back to the title attribute, then to an alt attribute further down. A link that matches no chapter gets a spine order of -1 instead of breaking the run. If anything in the whole attempt throws, it logs the error and returns a single entry saying no table of contents was found, and the book still opens.</p>
<p>Two limits stayed as they were. A navigation document with a broken NCX in the same book does not fall back — the fallback only runs when NCX is absent entirely. And if a book somehow declares two navigation documents, only the first is read. The other parser in the tree was left alone, and two cache cleanups from that round are still on the list: old parsed results and old cover results do not recompute on their own.</p>
<h2>Seven files, thirty entries</h2>
<p>The second bug was the one from my opening paragraph. The question-answering tool was counting the spine and calling it the table of contents. The spine lists files in reading order. For that book it had 7 files: a cover page, a contents page, front matter, and four parts. The table of contents, as a flat list, had 30 entries, including 22 essays.</p>
<p>One file held six of those entries: a part plus five essays. The essay I had asked about sat inside the second split file, at an anchor on a heading carrying line number 77. Counting files could never find it, because entries point inside files, not just at files.</p>
<p>There was a second counting trap in the same book. The table of contents as a tree has 7 top-level entries: cover, illustrations, a preface, and the four parts. So even the tree's top level is not the whole table of contents. Counting and searching have to use the flat list. That is also why the parser builds both shapes in two separate passes over the same document instead of deriving one from the other.</p>
<p>The fix added a table-of-contents read next to the spine read, after checking what else used the spine. Indexing walks files in order, citation checks test whether a cited file belongs to the book, and a spoiler guard works off reading order — all of those still need the spine, so the spine read stayed exactly as it was. Only the tool that answers "what is in the table of contents" switched sources, and its matching logic did not change by a line.</p>
<p>One result was not expected. The assumption had been that PDF and TXT books had no table of contents worth switching to. Their table-of-contents reads turned out to be fuller than their spines — 72 entries became 90 for one PDF, 112 became 128 for one TXT — and before the switch, their entry titles had been file names standing in for titles. The switch did not just fix EPUB. It fixed those too.</p>
<h2>The anchor that went where it should not</h2>
<p>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 that same book it duplicated 308 paragraphs, and nothing errored, so it sat there unnoticed.</p>
<p>Anchors do have one legitimate use: turning an entry into a readable range. The rule that came out of measuring real entries is that the range runs from an entry's anchor to the next same-level anchor in the same file, and a same-level entry without an anchor does not count as an endpoint — a part entry points at a whole file, and it must not cut the previous entry short at the file's head. If the anchor cannot be located at all, the whole chapter is read instead. Those rules live in the chapter-reading code, and every other use of an entry's address is supposed to strip the anchor first.</p>
<p>That "supposed to" is where the next failure came from. The tool description was updated to tell the model to pass entry addresses along unchanged, so the chapter reader would get its anchors. It worked. And in the same test run, citations dropped to zero. The citation registry only accepts file-level addresses: an anchored address failed its membership check, the marker was stripped, and the footnote markers went with it. The more correctly the model behaved, the more completely the citations vanished.</p>
<p>The fix was to strip the anchor at the registry's entrance, and then to notice that the same stripping rule now existed in three places with slightly different behavior. Those three were converged into one implementation, with a test that fails outright if an anchored address ever reaches the registry unstripped. The 308 polluted rows were left where they were on purpose: reads now use file-level keys, so the old rows are never reached, and cleaning them is a separate job.</p>
<h2>Left as it is</h2>
<p>A broken NCX still does not fall back to the navigation document. The other parser still has no navigation support. The old cached results still need clearing by hand. And the 308 rows are still in the table. None of that blocks reading a book, which is why it is still there. The three failures above all had one thing in common, and it was not the file format: the reader kept working, and the answer was just wrong.</p>
]]></content:encoded></item></channel></rss>