package chunk import ( "bytes" "encoding/xml" "net/url" "path" "sort" "strings" "golang.org/x/net/html" ) // The EPUB table of contents, and why the spine is not one. // // A spine states READING ORDER: the sequence a reader is walked through. A nav (EPUB 3) or an NCX (EPUB 2) // states the CHAPTER STRUCTURE the book itself claims. Reading the spine as a chapter list is an assumption // — right for the common one-document-per-chapter build, wrong for the many that split a chapter across // documents or pack several into one, and wrong for every service page that rides the spine. // // Precedence is nav → NCX → spine, and a nav whose targets all fail to resolve counts as ABSENT rather than // as an empty table of contents: a broken file must fall through to the next witness, not produce zero // chapters. // tocKind names which witness drew the boundaries, so the caller can report provenance without re-deriving // it. type tocKind int const ( tocNone tocKind = iota tocNav tocNCX ) // epubSpineDoc is one entry of the spine, after the manifest has been resolved. type epubSpineDoc struct { idref string entry string // resolved zip path linear bool // service marks a document that is not book text: the nav document itself, or one the book declares as // its toc or cover. Excluded from the reading WITH a count. service bool // readable is false for a spine item that is not an (x)html content document (an image, a stylesheet). readable bool } // epubTOC is a resolved table of contents: the spine indices that START a chapter, already filtered, // de-duplicated and ordered. type epubTOC struct { kind tocKind // starts are spine indices, unique, kept ascending. Read as a SET — see resolveTOC. starts []int // unresolved counts targets that named NOTHING IN THE SPINE — a broken table of contents, which is a real // property of scraped EPUBs and worth an alarm. // // ⚠ It deliberately does NOT count a target that resolved fine but landed on a service page or a // non-linear one. Those are ordinary and expected — an EPUB 2 whose toc lists its own cover would report // a "broken" table of contents on every normal book, and an alarm that fires on the normal case is one // nobody reads. Their exclusion is already counted, once, as DocumentsExcluded. unresolved int // collapsed counts targets that landed in a document another target had already claimed. // // ⛔ IT DOWNGRADES THE PROVENANCE, not just the report. A book packing three chapters into one document // with anchors declares THREE; this package hands back ONE, because it groups whole documents. Calling // that `declared` would put the engine's own coarser answer out under the format's name — the exact lie // the spine used to tell. The file did delimit its documents, so `delimited` is the true word for it. collapsed int } // hasProperty reports whether an OPF `properties` attribute carries a token. Token-wise, not substring-wise: // the attribute is a space-separated list, and `cover-image` must not read as `cover`. func hasProperty(properties, want string) bool { for _, tok := range strings.Fields(properties) { if tok == want { return true } } return false } // hrefTarget resolves an OPF or TOC href against the directory of the file that carried it, returning the // DOCUMENT it names and whether it pointed at a place INSIDE that document rather than at the whole of it. // // ⛔ THE ORDER IS NOT INTERCHANGEABLE, and the two halves of this package used to disagree about it. The '#' // that opens a fragment exists in the ENCODED form; a literal '#' in a file name arrives as %23. Unescape // first and that literal becomes a delimiter, truncating the name — which is what the spine resolver did // while the TOC resolver did the reverse. They are compared against each other, so they parted company on // exactly the files nobody writes a test for, and the disagreement is silent. // // insideDoc is not a detail: a `` says the table of contents // BEGINS there, inside a document that may hold prose before and after it. Reading that as "the whole // document is the table of contents" deletes book text. func hrefTarget(baseDir, href string) (doc string, insideDoc bool) { h := strings.TrimSpace(href) if i := strings.IndexByte(h, '#'); i >= 0 { h, insideDoc = h[:i], true } if h == "" { return "", insideDoc } if dec, err := url.PathUnescape(h); err == nil { h = dec } return path.Clean(path.Join(baseDir, h)), insideDoc } // parseNavDoc reads an EPUB 3 navigation document, returning the toc targets in document order and the // landmark roles it declares (resolved zip path → epub:type). // // Nested
    are FLATTENED by construction: every inside the toc nav is a boundary, whatever its // depth. A book's volume/part hierarchy is a rendering question, and treating a nested entry as "not really a // chapter" would drop text into whichever ancestor happened to be shallower. func parseNavDoc(data []byte, baseDir string) (toc []string, labels map[string]string, landmarks map[string]string, rolesInsideDoc int) { labels = map[string]string{} landmarks = map[string]string{} z := html.NewTokenizer(bytes.NewReader(data)) navType := "" // epub:type of the