1209 lines
51 KiB
Go
1209 lines
51 KiB
Go
package chunk
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"encoding/xml"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path"
|
||
"regexp"
|
||
"strings"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"textmachine/backend/internal/lang"
|
||
"textmachine/backend/internal/text"
|
||
|
||
"golang.org/x/net/html"
|
||
"golang.org/x/text/encoding/simplifiedchinese"
|
||
xunicode "golang.org/x/text/encoding/unicode"
|
||
"golang.org/x/text/transform"
|
||
)
|
||
|
||
// ingest.go: the step-3a import layer. Ingest turns a source file (txt or epub)
|
||
// into an ordered list of per-chapter NORMALIZED text plus the ruby/furigana
|
||
// readings captured on the way (04-unhappy §4 / D9). It is the ONLY place that
|
||
// reads the source; the runner then feeds doc.Chapters to SplitChunks and persists
|
||
// doc.Ruby (seeding.go). It is offline and deterministic — no LLM, no time/rand — so
|
||
// the whole path is $0 and reproducible.
|
||
//
|
||
// epub v1 is a text extraction (02-mvp:25): read the chapters in spine order,
|
||
// strip tags, drop inline markup/styling. The ONE thing we do NOT drop is ruby:
|
||
// <ruby>base<rt>reading</rt></ruby> carries an author reading of a name/term that a
|
||
// plain text export would silently lose (the documented hole, 04-unhappy §4). We
|
||
// keep the BASE in the body and hand the READING to the caller for a glossary lock
|
||
// (memory v2, step 4) — never injecting it into a prompt here (§7d).
|
||
|
||
// chapterSep splits a TXT source into chapters. Form feed (U+000C) is the ASCII
|
||
// "page/section break": semantically a chapter boundary, invisible in prose, and
|
||
// untouched by text.NormalizeSource. A txt with no \f is a single chapter (Phase-0 backward
|
||
// compat: the one-file example stays one chapter). epub carries real chapter
|
||
// structure in its spine, so it does not use this.
|
||
const chapterSep = "\f"
|
||
|
||
// RubyReading is one captured (base, reading) pair and the chapter it appeared in.
|
||
// The pipeline aggregates these (min chapter, count) before persisting (seeding.go).
|
||
type RubyReading struct {
|
||
Base string // the ruby body: the kanji/base surface form
|
||
Reading string // the <rt> reading (furigana)
|
||
Chapter int // 1-based chapter where this occurrence was found
|
||
}
|
||
|
||
// Document is the ingested source: per-chapter normalized text (in reading order)
|
||
// plus every ruby occurrence found. Chapters is what SplitChunks consumes.
|
||
type Document struct {
|
||
Chapters []string
|
||
Ruby []RubyReading
|
||
// FormFeedsIgnored counts page breaks the winning cut turned into paragraph breaks. Reported in a WARN,
|
||
// deliberately NOT on the wire: there is no consumer for it, and a field without one is a mechanism kept
|
||
// for its own sake.
|
||
FormFeedsIgnored int
|
||
// Titles are the chapter titles AS THE SOURCE SPELLS THEM, index-aligned to Chapters: the header line
|
||
// for a txt, the navLabel for an EPUB. Additive — the engine's own rendered heading is a different
|
||
// string in a different language and neither replaces the other. "" where the source names nothing.
|
||
Titles []string
|
||
// Structure says WHERE the chapter boundaries came from, which is a different question from how many
|
||
// there are. A caller offering an order «up to chapter N» has to know whether N names something the
|
||
// source itself declared or something this package inferred from prose, because the two fail
|
||
// differently: a declared cut is wrong only if the file is wrong, an inferred one is wrong whenever a
|
||
// sentence happens to open like a header. See the four constants.
|
||
Structure string
|
||
// The counts below are the ALARM this work ships INSTEAD of a validator: a degenerate table of contents
|
||
// shows up as a large DocumentsAttached, not as a wrong label. They are deliberately NOT on the wire —
|
||
// no consumer exists for them — and they reach an operator through exactly one place, IngestNotes, so
|
||
// that adding a count and forgetting to report it is not possible one field at a time.
|
||
DocumentsAttached int // spine documents folded into a chapter rather than opening one
|
||
// Excluded NAMES every service page that was not read, with its role and its size.
|
||
//
|
||
// ⚠ A NUMBER HERE WOULD HIDE THE ONE THING WORTH SEEING. The conservation invariant cannot redden on a
|
||
// WRONG exclusion — the document is accounted for, so the sum closes — which means a mistaken exclusion
|
||
// is exactly the defect no gate can catch. What is left is a human reading the warning, and "excluded: 1"
|
||
// gives them nothing to doubt. «excluded OEBPS/index_split_124.html as `toc`, 2019 runes» does.
|
||
Excluded []ExcludedDocument
|
||
TOCUnresolved int // table-of-contents targets that named nothing this book reads
|
||
TargetsCollapsed int // targets that shared a document with an earlier one, so the cut is coarser
|
||
ServiceRolesInsideDocuments int // toc/cover roles declared at a POINT inside a document, so NOT excluded
|
||
}
|
||
|
||
// ExcludedDocument is one spine document this reader declined to read as book text, and why.
|
||
type ExcludedDocument struct {
|
||
Entry string // the document, as the zip spells it
|
||
Role string // what made it service: "nav", or the declared role ("toc" / "cover")
|
||
Runes int // how much text was NOT read — the size of the doubt, if the exclusion was wrong
|
||
}
|
||
|
||
// IngestNotes returns the alarm counts as log key/value pairs, or nil when the reading was unremarkable.
|
||
//
|
||
// ONE reporter for every count is the point. The first version logged two of five, and the three silent ones
|
||
// included the number that makes a degenerate cut visible — 126 documents folded into 6 chapters printed
|
||
// nothing at all. A single accessor cannot be half-wired.
|
||
func (d *Document) IngestNotes() []any {
|
||
var kv []any
|
||
add := func(k string, v int) {
|
||
if v > 0 {
|
||
kv = append(kv, k, v)
|
||
}
|
||
}
|
||
|
||
add("form_feeds_ignored", d.FormFeedsIgnored)
|
||
add("documents_attached", d.DocumentsAttached)
|
||
if n := len(d.Excluded); n > 0 {
|
||
kv = append(kv, "documents_excluded", n, "excluded", d.Excluded)
|
||
}
|
||
add("toc_unresolved", d.TOCUnresolved)
|
||
add("targets_collapsed", d.TargetsCollapsed)
|
||
add("service_roles_inside_documents", d.ServiceRolesInsideDocuments)
|
||
if kv == nil {
|
||
return nil
|
||
}
|
||
return append(kv, "chapters", len(d.Chapters), "structure", d.Structure)
|
||
}
|
||
|
||
// The four values of Document.Structure — WHICH PATH drew the chapter boundaries. Not a confidence score:
|
||
// the question is provenance, with one exception that is also provenance (a single-chapter document is
|
||
// `none` whichever path produced it, because a boundary nothing crossed was never tested).
|
||
//
|
||
// - DECLARED — the format states the chapter STRUCTURE: an EPUB nav or NCX, a table of contents the file
|
||
// itself carries. Getting these wrong requires the file to be wrong.
|
||
// - DELIMITED — the file carried a SEPARATOR, but "this separator is a chapter" is the engine's
|
||
// assumption. An EPUB spine declares reading ORDER; a form feed is the ASCII PAGE break, and in practice
|
||
// comes from pdftotext rather than from an author. Both are real marks in the file and neither is a
|
||
// statement about chapters.
|
||
// - DETECTED — the engine inferred the boundary from CONTENT, by matching header-shaped lines. Right on
|
||
// the corpus it was built for, and a guess: a paragraph opening «第三节…» within the length bound is
|
||
// indistinguishable from a header.
|
||
// - NONE — one chapter, or no grammar was declared for the source at all.
|
||
//
|
||
// ⚠ The header match runs on the SOURCE language's structure data, so a source with no such data finds no
|
||
// headers and reports `none` however clearly its chapters are marked. That asymmetry is not introduced here
|
||
// — the cut has been CJK-only since it was written (row 283) — but it now has a NAME on the wire instead of
|
||
// hiding inside a chapter count, and a consumer can decline to offer an order in chapters rather than offer
|
||
// one against a cut nobody made.
|
||
const (
|
||
StructureDeclared = "declared"
|
||
StructureDelimited = "delimited"
|
||
StructureDetected = "detected"
|
||
StructureNone = "none"
|
||
)
|
||
|
||
// structureOf reports the provenance of a cut: `none` below two chapters, otherwise the witness of the path
|
||
// that WON. There is nothing to aggregate — the paths compete, so exactly one of them drew every boundary.
|
||
func structureOf(chapters []string, witness string) string {
|
||
if len(chapters) < 2 {
|
||
return StructureNone
|
||
}
|
||
return witness
|
||
}
|
||
|
||
// ingest reads a source file with automatic encoding detection (the txt path), source language
|
||
// unspecified. Dispatch is by extension: .epub → the epub reader; anything else → plain text. Kept
|
||
// as the terse form used by tests and the epub path.
|
||
func ingest(p string) (*Document, error) {
|
||
return IngestEncoded(p, "", "", lang.DefaultCJKStructure())
|
||
}
|
||
|
||
// IngestEncoded reads a source file and returns its Document, decoding a txt source per the book's
|
||
// declared encoding (auto|utf8|gb18030; "" == auto) and source language (zh|ja|en; "" == unknown).
|
||
// sourceLang scopes the GB18030 auto-probe to Chinese sources — a Shift-JIS/EUC-JP ja file would
|
||
// otherwise decode to Han-shaped mojibake that passes the plausibility guard (self-review critical).
|
||
// Dispatch is by extension: .epub → the epub reader (its own per-document charset handling);
|
||
// anything else → plain text.
|
||
func IngestEncoded(p, encoding, sourceLang string, st *lang.SourceStructure) (*Document, error) {
|
||
if strings.EqualFold(extOf(p), ".epub") {
|
||
return ingestEPUB(p)
|
||
}
|
||
return ingestTXT(p, encoding, sourceLang, st)
|
||
}
|
||
|
||
// extOf returns the lower-cased file extension incl. the dot ("" if none).
|
||
func extOf(p string) string {
|
||
i := strings.LastIndexByte(p, '.')
|
||
if i < 0 || strings.ContainsAny(p[i:], "/\\") {
|
||
return ""
|
||
}
|
||
return strings.ToLower(p[i:])
|
||
}
|
||
|
||
// ingestTXT reads a plain-text source: decode the raw bytes to UTF-8 per `encoding`, split on \f
|
||
// into chapters, text.NormalizeSource each (BOM/CRLF/NFC/outer-trim). No ruby in txt. Encoding decode
|
||
// runs BEFORE text.NormalizeSource so the whole downstream path (chunker fuzz-invariant, memory keys)
|
||
// sees valid UTF-8; a decode failure is a LOUD error, never silent U+FFFD corruption.
|
||
func ingestTXT(p, encoding, sourceLang string, st *lang.SourceStructure) (*Document, error) {
|
||
raw, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chunk: ingest txt: %w", err)
|
||
}
|
||
decoded, err := decodeSourceBytes(raw, encoding, sourceLang)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chunk: ingest txt %s: %w", p, err)
|
||
}
|
||
chapters, witness, formFeeds := cutTXT(decoded, st)
|
||
titles := make([]string, len(chapters))
|
||
// WHERE the header may be found depends on which path drew the boundaries, and the difference is not
|
||
// cosmetic.
|
||
//
|
||
// On a cut the HEADERS won, a header may sit deep inside chapter one: the frozen preamble rule folds
|
||
// everything before the first header into it, so the whole chapter is scanned.
|
||
//
|
||
// On any other cut the boundaries came from page breaks, and a header-shaped line buried in the prose is
|
||
// a single occurrence that never met the ≥2 floor — calling it this chapter's title would be a confident
|
||
// wrong answer. Only the OPENING line counts there, which still gives a one-chapter document its own real
|
||
// title without inventing one for the rest.
|
||
scanAll := witness == StructureDetected
|
||
for i, c := range chapters {
|
||
if scanAll {
|
||
titles[i] = chapterTitleRaw(c, st)
|
||
continue
|
||
}
|
||
titles[i] = chapterTitleRaw(firstLine(c), st)
|
||
}
|
||
return &Document{
|
||
Chapters: chapters,
|
||
Titles: titles,
|
||
Structure: structureOf(chapters, witness),
|
||
FormFeedsIgnored: formFeeds,
|
||
}, nil
|
||
}
|
||
|
||
// chapterTitleRaw returns the chapter's header line verbatim, or "" when it has none.
|
||
//
|
||
// It scans the WHOLE chapter rather than only its first line: the preamble rule folds everything before the
|
||
// first header into chapter one, so chapter one's header is not at its start. Scanning finds it there and
|
||
// nowhere else, which is why chapter one gets a title at all.
|
||
func chapterTitleRaw(chapter string, st *lang.SourceStructure) string {
|
||
if st == nil {
|
||
return ""
|
||
}
|
||
for _, ln := range strings.Split(chapter, "\n") {
|
||
for _, u := range st.UnitOrdered {
|
||
if isChapterHeader(ln, u, st) {
|
||
return strings.TrimSpace(ln)
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// firstLine returns everything before the first newline.
|
||
func firstLine(s string) string {
|
||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||
return s[:i]
|
||
}
|
||
return s
|
||
}
|
||
|
||
// cutTXT runs the two candidate cuts against each other and returns the winner's chapters, its provenance
|
||
// witness, and how many form feeds the winner ignored.
|
||
//
|
||
// ⛔ THE PATHS COMPETE, THEY DO NOT ADD UP. They used to: \f split first, then each part was split on header
|
||
// lines, and a single stray \f among fifty matched headers made the whole cut `declared` — forty-nine
|
||
// guesses reported as the file's own word. Competing, there is no mixed case to aggregate: headers found ⇒
|
||
// headers drew every boundary; none found ⇒ the form feeds did.
|
||
//
|
||
// ⛔ THE ORDER BELOW IS LOAD-BEARING, and the naive one loses a chapter SILENTLY. There is no \n before a
|
||
// \f, so a header sitting right after one shares a LINE with the previous chapter's prose, where both the
|
||
// length bound and the ^ anchor reject it — detect first and that chapter is never seen, while the label
|
||
// stays correct and the loss goes unreported. Hence: neutralise on a COPY, detect on the copy, and fall back
|
||
// to the ORIGINAL if headers lose, because neutralising in place would destroy the separator the fallback
|
||
// needs.
|
||
func cutTXT(decoded string, st *lang.SourceStructure) (chapters []string, witness string, formFeedsIgnored int) {
|
||
working := strings.ReplaceAll(decoded, chapterSep, "\n\n")
|
||
if headerCut := splitTextChapters(working, st); len(headerCut) > 1 {
|
||
for _, chap := range headerCut {
|
||
chapters = append(chapters, text.NormalizeSource(chap))
|
||
}
|
||
return chapters, StructureDetected, strings.Count(decoded, chapterSep)
|
||
}
|
||
for _, part := range strings.Split(decoded, chapterSep) {
|
||
chapters = append(chapters, text.NormalizeSource(part))
|
||
}
|
||
return chapters, StructureDelimited, 0
|
||
}
|
||
|
||
// --- CJK chapter-header splitting (D18: real zh/ja txt mark chapters as «第N章/节/回») ---------
|
||
|
||
// chapterHeaderMaxRunes bounds a header line so a prose sentence that merely opens with «第三节…»
|
||
// (a longer line) is not mistaken for a header. The 蛊真人 headers are ≤23 runes; 60 leaves room for
|
||
// a long subtitle while still excluding a full prose sentence (self-review: 40 could miss a long
|
||
// legitimate subtitle → merged chapters).
|
||
const chapterHeaderMaxRunes = 60
|
||
|
||
// isCJKChapterHeader reports whether a single line is a chapter header for the given unit rune. It
|
||
// requires: a short trimmed line (≤60 runes); a leading 第<numerals>; the unit rune immediately
|
||
// after the numerals; and — critically — the char AFTER the unit is a SEPARATOR (whitespace / ::、,.
|
||
// / dash) or end-of-line, NOT a content glyph. The separator guard is the precision fix (self-
|
||
// review): 回 is a common measure word, so prose like «第一回见面…» glues a content char (见) right
|
||
// after the unit and must NOT split; a real header writes «第一节:…» or «第1章 …» with a separator.
|
||
// (A glued-title header «第一章天空…» without a separator is not detected — rare; a recall trade for
|
||
// no false splits. Deterministic and pure.)
|
||
func isChapterHeader(line string, unit rune, st *lang.SourceStructure) bool {
|
||
if st == nil {
|
||
return false
|
||
}
|
||
t := strings.TrimSpace(line)
|
||
if utf8.RuneCountInString(t) == 0 || utf8.RuneCountInString(t) > chapterHeaderMaxRunes {
|
||
return false
|
||
}
|
||
loc := st.HeaderNumeralRE().FindStringIndex(t)
|
||
if loc == nil {
|
||
return false
|
||
}
|
||
rest := t[loc[1]:] // the runes right after the numerals
|
||
r, sz := utf8.DecodeRuneInString(rest)
|
||
if r != unit {
|
||
return false
|
||
}
|
||
after := rest[sz:]
|
||
if after == "" {
|
||
return true // the header is exactly 第N章
|
||
}
|
||
nr, _ := utf8.DecodeRuneInString(after)
|
||
return isHeaderSeparator(nr)
|
||
}
|
||
|
||
// isHeaderSeparator reports whether a rune separates a chapter number from its title (so the line is
|
||
// a header, not a prose sentence that continues with a content glyph after 第N章). Anything that is
|
||
// NOT content (letter / ideograph / kana / digit) counts as a separator. It is the exact complement of
|
||
// chunker.go's isHeaderContentRune — one shared rune-class list, so the two cannot byte-drift apart (row
|
||
// 89: previously the same classification was spelled out in both files, synced only by hand). NOT to be
|
||
// confused with chunker.go's isHeadingSeparator, a narrower punctuation whitelist for trimming subtitles.
|
||
func isHeaderSeparator(r rune) bool {
|
||
return !isHeaderContentRune(r)
|
||
}
|
||
|
||
// detectChapterUnit picks the section marker that appears most as a header line (≥2 to avoid a
|
||
// single stray match). Returns 0 when no unit qualifies (→ the part stays a single chapter).
|
||
func detectChapterUnit(lines []string, st *lang.SourceStructure) rune {
|
||
if st == nil {
|
||
return 0
|
||
}
|
||
best, bestN := rune(0), 0
|
||
// The section markers (章节節回) are shared lang.CJKSection data (pair-14 addendum-A); iterated in AUTHORED
|
||
// order so a tie between two units breaks deterministically (first-seen), as the fixed slice once did.
|
||
for _, unit := range st.UnitOrdered {
|
||
n := 0
|
||
for _, ln := range lines {
|
||
if isChapterHeader(ln, unit, st) {
|
||
n++
|
||
}
|
||
}
|
||
if n >= 2 && n > bestN {
|
||
best, bestN = unit, n
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// splitTextChapters splits one text block on its dominant CJK chapter-header lines. The header line
|
||
// STARTS its chapter (the title is kept). Any preamble before the FIRST header is merged into
|
||
// chapter 1 so «第一节» == chapter 1 (dense numbering aligns with the section numbers). A block with
|
||
// no detectable headers is returned unchanged as a single chapter.
|
||
func splitTextChapters(text string, st *lang.SourceStructure) []string {
|
||
norm := strings.ReplaceAll(text, "\r\n", "\n")
|
||
norm = strings.ReplaceAll(norm, "\r", "\n") // bare-CR (old Mac) lines too, so a CR-only header is seen
|
||
lines := strings.Split(norm, "\n")
|
||
unit := detectChapterUnit(lines, st)
|
||
if unit == 0 {
|
||
return []string{text}
|
||
}
|
||
var chapters []string
|
||
var cur []string
|
||
seenHeader := false
|
||
for _, ln := range lines {
|
||
if isChapterHeader(ln, unit, st) {
|
||
if seenHeader && hasNonBlank(cur) {
|
||
chapters = append(chapters, strings.Join(cur, "\n"))
|
||
cur = nil
|
||
}
|
||
seenHeader = true
|
||
}
|
||
cur = append(cur, ln)
|
||
}
|
||
if hasNonBlank(cur) {
|
||
chapters = append(chapters, strings.Join(cur, "\n"))
|
||
}
|
||
if len(chapters) == 0 {
|
||
return []string{text}
|
||
}
|
||
return chapters
|
||
}
|
||
|
||
// hasNonBlank reports whether any line in the slice has non-whitespace content.
|
||
func hasNonBlank(lines []string) bool {
|
||
for _, ln := range lines {
|
||
if strings.TrimSpace(ln) != "" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// --- source encoding detection (Task 2 / D18) ----------------------------------
|
||
//
|
||
// Real zh .txt are frequently GB18030 (a superset of GBK/GB2312), not UTF-8 — the 蛊真人 acceptance
|
||
// book is exactly this case. decodeSourceBytes converts raw source bytes to a UTF-8 string per the
|
||
// book's declared encoding, defaulting to a conservative auto-detect. The invariant is fail-LOUD:
|
||
// any doubt is an error, never silent mojibake — a corrupt/foreign byte must never reach a U+FFFD
|
||
// replacement in ch.Text (which would defeat the lossless-chunker fuzz invariant and mis-key the
|
||
// glossary). Dependency: golang.org/x/text/encoding (already in go.mod for NFC) — first use of its
|
||
// simplifiedchinese + unicode subpackages (noted in the session journal).
|
||
func decodeSourceBytes(raw []byte, encoding, sourceLang string) (string, error) {
|
||
switch enc := strings.ToLower(strings.TrimSpace(encoding)); enc {
|
||
case "", "auto":
|
||
return autoDecodeSource(raw, sourceLang)
|
||
case "utf8", "utf-8":
|
||
b := bytes.TrimPrefix(raw, utf8BOM)
|
||
if !utf8.Valid(b) {
|
||
return "", fmt.Errorf("declared encoding utf8 but the bytes are not valid UTF-8 (declare gb18030, or convert the file)")
|
||
}
|
||
return checkNoNUL(string(b))
|
||
case "gb18030", "gbk", "gb2312":
|
||
// Operator asserted the encoding: decode strictly (fail on undecodable bytes / U+FFFD) but
|
||
// skip the Chinese-plausibility heuristic — they told us it is GB18030.
|
||
return decodeGB18030(raw, false)
|
||
default:
|
||
return "", fmt.Errorf("unknown encoding %q in book.yaml (use auto|utf8|gb18030)", encoding)
|
||
}
|
||
}
|
||
|
||
// checkNoNUL rejects a decoded string that contains a NUL (U+0000). Real prose never contains NUL;
|
||
// its presence means a mis-detected / mis-declared encoding: a UTF-16 stream WITHOUT a BOM whose
|
||
// ASCII half is valid UTF-8 with interleaved NULs, OR a UTF-16/binary file wrongly fed to the
|
||
// GB18030/UTF-16 decoder (0x00 decodes to a VALID U+0000, not U+FFFD, so the replacement-char check
|
||
// cannot catch it — self-review major, D20.4). Applied on EVERY decode path (utf8/gb18030/utf16),
|
||
// not just UTF-8, so the guard is symmetric across encodings.
|
||
func checkNoNUL(s string) (string, error) {
|
||
if strings.IndexByte(s, 0) >= 0 {
|
||
return "", fmt.Errorf("decoded text contains NUL (U+0000) — the source is likely UTF-16 without a BOM, or a binary/mis-declared file; convert it to UTF-8 or declare its encoding correctly")
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
var (
|
||
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||
utf16LEBOM = []byte{0xFF, 0xFE}
|
||
utf16BEBOM = []byte{0xFE, 0xFF}
|
||
)
|
||
|
||
// autoDecodeSource is the conservative detection ladder: BOM → valid UTF-8 (+NUL guard) → GB18030
|
||
// probe (ZH sources only) → fail-loud. Order matters: a BOM is definitive; valid UTF-8 is
|
||
// unambiguous (but rejected if it carries interleaved NUL — UTF-16 without a BOM); GB18030 is tried
|
||
// only when the bytes are NOT valid UTF-8 AND the source is Chinese (or unspecified). GB18030 is a
|
||
// Chinese encoding that maps almost every byte sequence to Han-shaped codepoints, so a ja Shift-JIS
|
||
// / EUC-JP or a European Latin-1 file would decode to CJK-shaped MOJIBAKE that the plausibility
|
||
// guard cannot distinguish from real Chinese (self-review CRITICAL). So we auto-probe GB18030 only
|
||
// for zh; for ja/en a non-UTF-8 file fails loud and the operator must convert or declare it.
|
||
func autoDecodeSource(raw []byte, sourceLang string) (string, error) {
|
||
switch {
|
||
case bytes.HasPrefix(raw, utf8BOM):
|
||
b := raw[len(utf8BOM):]
|
||
if !utf8.Valid(b) {
|
||
return "", fmt.Errorf("file has a UTF-8 BOM but the body is not valid UTF-8 (corrupt file)")
|
||
}
|
||
return checkNoNUL(string(b))
|
||
case bytes.HasPrefix(raw, utf16LEBOM):
|
||
return decodeUTF16(raw, xunicode.LittleEndian)
|
||
case bytes.HasPrefix(raw, utf16BEBOM):
|
||
return decodeUTF16(raw, xunicode.BigEndian)
|
||
}
|
||
if utf8.Valid(raw) {
|
||
return checkNoNUL(string(raw)) // no BOM, already UTF-8 (covers en/ja/UTF-8 zh); NUL ⇒ UTF-16-no-BOM
|
||
}
|
||
// Not valid UTF-8. GB18030 auto-probe is scoped to Chinese sources (see doc above).
|
||
zhScoped := sourceLang == "" || strings.EqualFold(sourceLang, "zh")
|
||
if zhScoped {
|
||
if s, err := decodeGB18030(raw, true); err == nil {
|
||
return s, nil
|
||
}
|
||
}
|
||
probe := "was tried and failed (truncated/corrupt or not GB18030)"
|
||
if !zhScoped {
|
||
probe = fmt.Sprintf("is restricted to zh sources (this source is %q) — a Shift-JIS/EUC-JP/Latin-1 file would decode to Han-shaped mojibake and is NOT auto-detected", sourceLang)
|
||
}
|
||
return "", fmt.Errorf("could not auto-detect the source encoding: the bytes are not valid UTF-8, and the GB18030 probe %s; convert the file to UTF-8 or declare `encoding:` explicitly in book.yaml", probe)
|
||
}
|
||
|
||
// decodeGB18030 decodes raw as GB18030 (superset of GBK/GB2312). It is strict: a decode error, or a
|
||
// U+FFFD replacement char in the output (undecodable/truncated bytes), is a failure — broken bytes
|
||
// must never reach ch.Text. When probe is true (auto-detect) it additionally requires the decoded
|
||
// text to look like Chinese (plausibleCJK), because GB18030 maps almost every byte sequence to
|
||
// SOMETHING, so a Latin-1/other file decodes without error into mojibake unless we sanity-check it.
|
||
func decodeGB18030(raw []byte, probe bool) (string, error) {
|
||
out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw)
|
||
if err != nil {
|
||
return "", fmt.Errorf("GB18030 decode error (not valid GB18030): %w", err)
|
||
}
|
||
s := string(out)
|
||
if strings.ContainsRune(s, utf8.RuneError) {
|
||
return "", fmt.Errorf("GB18030 decode produced replacement chars (U+FFFD) — bytes are truncated/corrupt or not GB18030")
|
||
}
|
||
if probe {
|
||
if err := plausibleCJK(s); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
return checkNoNUL(s) // 0x00 bytes decode to a valid U+0000, not U+FFFD — a mis-declared UTF-16/binary file slips the replacement-char check (D20.4)
|
||
}
|
||
|
||
// decodeUTF16 decodes a BOM-prefixed UTF-16 stream (ExpectBOM consumes the BOM). Strict: a decode
|
||
// error or a U+FFFD in the output is a failure.
|
||
func decodeUTF16(raw []byte, endian xunicode.Endianness) (string, error) {
|
||
dec := xunicode.UTF16(endian, xunicode.ExpectBOM).NewDecoder()
|
||
out, _, err := transform.Bytes(dec, raw)
|
||
if err != nil {
|
||
return "", fmt.Errorf("UTF-16 decode error: %w", err)
|
||
}
|
||
s := string(out)
|
||
if strings.ContainsRune(s, utf8.RuneError) {
|
||
return "", fmt.Errorf("UTF-16 decode produced replacement chars (U+FFFD) — corrupt")
|
||
}
|
||
return checkNoNUL(s) // a genuine UTF-16 stream carrying embedded NULs (or a mis-detected binary) is caught here (D20.4)
|
||
}
|
||
|
||
// plausibleCJK is the GB18030 auto-accept guard: a real GB18030 source is Chinese, so the decoded
|
||
// text must be (a) almost entirely CJK-plausible runes (Han/kana/CJK-punct/ASCII/whitespace) and
|
||
// (b) genuinely Han-dense. Latin-1/other mojibake fails (b) — it decodes to sparse Han among ASCII
|
||
// (café → "caf" + 轳), so every rune is "plausible" in isolation yet Han density is ~0. The Han
|
||
// floor is intentionally low (5%) so a heavily Latin-mixed but real Chinese source still passes,
|
||
// while mojibake (Han≈0) is rejected loudly. Thresholds bias toward fail-loud (D18 invariant); a
|
||
// false reject is recoverable (declare `encoding: gb18030`), a false accept is silent corruption.
|
||
func plausibleCJK(s string) error {
|
||
var total, han, plausible int
|
||
for _, r := range s {
|
||
total++
|
||
switch {
|
||
case unicode.Is(unicode.Han, r):
|
||
han++
|
||
plausible++
|
||
case isPlausibleSourceRune(r):
|
||
plausible++
|
||
}
|
||
}
|
||
if total == 0 {
|
||
return fmt.Errorf("GB18030 probe: empty decode")
|
||
}
|
||
if pf := float64(plausible) / float64(total); pf < 0.90 {
|
||
return fmt.Errorf("GB18030 probe: only %.1f%% of decoded runes are CJK-plausible — likely mojibake, not Chinese", 100*pf)
|
||
}
|
||
if hf := float64(han) / float64(total); hf < 0.05 {
|
||
return fmt.Errorf("GB18030 probe: Han density %.2f%% is too low for a Chinese source — likely a mis-detected encoding; declare `encoding` explicitly if this really is GB18030", 100*hf)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// isPlausibleSourceRune reports whether a rune belongs to the expected repertoire of a zh/ja source
|
||
// (excluding Han, which the caller counts separately): ASCII text, kana, CJK/general punctuation,
|
||
// and fullwidth/halfwidth forms. Deliberately tight — it must NOT whitelist the Latin-1 supplement
|
||
// or arbitrary symbols, or Latin-1 mojibake would pass the plausibility fraction.
|
||
func isPlausibleSourceRune(r rune) bool {
|
||
switch {
|
||
case r == '\n', r == '\r', r == '\t', r == ' ':
|
||
return true
|
||
case r >= 0x20 && r <= 0x7E: // printable ASCII
|
||
return true
|
||
case unicode.Is(unicode.Hiragana, r), unicode.Is(unicode.Katakana, r):
|
||
return true
|
||
case r >= 0x3000 && r <= 0x303F: // CJK symbols & punctuation (。、「」〜…)
|
||
return true
|
||
case r >= 0x2010 && r <= 0x206F: // general punctuation (— – ‘’ “” … ‰ etc.)
|
||
return true
|
||
case r >= 0xFF00 && r <= 0xFFEF: // halfwidth & fullwidth forms (!?()ア fullwidth digits/letters)
|
||
return true
|
||
case r == 0x30FC || r == 0x30FB: // prolonged-sound mark ー, middle dot ・ (also in Katakana block)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// --- epub ----------------------------------------------------------------------
|
||
|
||
// containerXML is META-INF/container.xml: it points at the OPF package file. Tags
|
||
// are matched by LOCAL name (no namespace in the struct tags), so a namespaced or
|
||
// prefixed container still binds.
|
||
type containerXML struct {
|
||
Rootfiles []struct {
|
||
FullPath string `xml:"full-path,attr"`
|
||
MediaType string `xml:"media-type,attr"`
|
||
} `xml:"rootfiles>rootfile"`
|
||
}
|
||
|
||
// opfPackage is the OPF: the manifest (id→href→media-type→properties), the spine (reading order, with each
|
||
// itemref's `linear`), and the EPUB 2 `<guide>`.
|
||
//
|
||
// `properties` and `linear` are read because the pack that introduced nav/NCX needs them and nothing else
|
||
// supplies them: `properties="nav"` is the ONLY way to find the navigation document, and `linear="no"` marks
|
||
// a document that is text but is not part of the reading sequence.
|
||
//
|
||
// `<guide>` is not legacy trivia. Landmarks — the EPUB 3 way a book declares which document is its toc or
|
||
// cover — live INSIDE the navigation document, and an EPUB 2 has no navigation document at all. Reading only
|
||
// landmarks would leave every EPUB 2 with its toc page glued to chapter one, which is precisely the mass
|
||
// case the exclusion rule exists for.
|
||
type opfPackage struct {
|
||
Manifest []struct {
|
||
ID string `xml:"id,attr"`
|
||
Href string `xml:"href,attr"`
|
||
MediaType string `xml:"media-type,attr"`
|
||
Properties string `xml:"properties,attr"`
|
||
} `xml:"manifest>item"`
|
||
Spine struct {
|
||
TOC string `xml:"toc,attr"` // EPUB 2: the manifest id of the NCX
|
||
ItemRefs []struct {
|
||
IDRef string `xml:"idref,attr"`
|
||
Linear string `xml:"linear,attr"`
|
||
} `xml:"itemref"`
|
||
} `xml:"spine"`
|
||
Guide []struct {
|
||
Type string `xml:"type,attr"`
|
||
Href string `xml:"href,attr"`
|
||
} `xml:"guide>reference"`
|
||
}
|
||
|
||
// ingestEPUB reads chapters in spine order and captures ruby. Every structural
|
||
// problem (bad zip, missing container/opf, empty spine, no readable chapter) is a
|
||
// loud error, never a panic (a truncated/foreign epub must not crash a run).
|
||
func ingestEPUB(p string) (*Document, error) {
|
||
zr, err := zip.OpenReader(p)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chunk: ingest epub: open zip %s: %w", p, err)
|
||
}
|
||
defer zr.Close()
|
||
|
||
files := map[string]*zip.File{}
|
||
for _, f := range zr.File {
|
||
files[path.Clean(f.Name)] = f
|
||
}
|
||
|
||
// 1) container.xml → OPF path.
|
||
cdata, err := readZipEntry(files, "META-INF/container.xml")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chunk: ingest epub: %w", err)
|
||
}
|
||
var container containerXML
|
||
if err := xml.Unmarshal(cdata, &container); err != nil {
|
||
return nil, fmt.Errorf("chunk: ingest epub: parse container.xml: %w", err)
|
||
}
|
||
opfPath := ""
|
||
for _, rf := range container.Rootfiles {
|
||
if strings.TrimSpace(rf.FullPath) != "" {
|
||
opfPath = path.Clean(rf.FullPath)
|
||
break
|
||
}
|
||
}
|
||
if opfPath == "" {
|
||
return nil, fmt.Errorf("chunk: ingest epub: container.xml has no rootfile full-path")
|
||
}
|
||
|
||
// 2) OPF → manifest + spine.
|
||
odata, err := readZipEntry(files, opfPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chunk: ingest epub: %w", err)
|
||
}
|
||
var opf opfPackage
|
||
if err := xml.Unmarshal(odata, &opf); err != nil {
|
||
return nil, fmt.Errorf("chunk: ingest epub: parse opf %s: %w", opfPath, err)
|
||
}
|
||
if len(opf.Spine.ItemRefs) == 0 {
|
||
return nil, fmt.Errorf("chunk: ingest epub: opf %s has an empty spine", opfPath)
|
||
}
|
||
type manifestItem struct{ href, mediaType, properties string }
|
||
byID := map[string]manifestItem{}
|
||
for _, it := range opf.Manifest {
|
||
byID[it.ID] = manifestItem{it.Href, it.MediaType, it.Properties}
|
||
}
|
||
opfDir := path.Dir(opfPath)
|
||
|
||
// 3) Spine → documents. A dangling idref is a LOST chapter, not a skip: continuing silently would drop
|
||
// its content AND shift every later chapter's dense number. Collected and failed loud after the loop.
|
||
docs := make([]epubSpineDoc, 0, len(opf.Spine.ItemRefs))
|
||
var dangling []string
|
||
for _, ref := range opf.Spine.ItemRefs {
|
||
item, ok := byID[ref.IDRef]
|
||
if !ok {
|
||
dangling = append(dangling, ref.IDRef)
|
||
continue
|
||
}
|
||
docs = append(docs, epubSpineDoc{
|
||
idref: ref.IDRef,
|
||
entry: resolveHref(opfDir, item.href),
|
||
linear: !strings.EqualFold(strings.TrimSpace(ref.Linear), "no"),
|
||
readable: isXHTML(item.mediaType, item.href),
|
||
service: hasProperty(item.properties, "nav"),
|
||
})
|
||
}
|
||
if len(dangling) > 0 {
|
||
return nil, fmt.Errorf("chunk: ingest epub: spine references %d manifest id(s) with no manifest item %v — a chapter would be silently lost", len(dangling), dangling)
|
||
}
|
||
|
||
// 4) Which spine documents are SERVICE pages rather than book text. The list is exact and short: the
|
||
// navigation document itself, and whatever the book declares as its toc or cover. Nothing else is
|
||
// excluded — everything else in the spine is text, whatever it looks like.
|
||
service := map[string]string{} // entry → the role that made it service
|
||
// serviceInsideDoc counts role declarations this package REFUSED to act on: a guide reference or landmark
|
||
// naming a POINT inside a document ("toc.xhtml#pos") says the table of contents begins there, not that
|
||
// the whole file is one — and since chapters are grouped by document, excluding it would delete whatever
|
||
// prose shares the file. Refusing is the safe half; the count is what keeps the refusal visible.
|
||
serviceInsideDoc := 0
|
||
for _, g := range opf.Guide { // EPUB 2, which has no navigation document to hold landmarks
|
||
if !isServiceRole(g.Type) {
|
||
continue
|
||
}
|
||
h, insideDoc := hrefTarget(opfDir, g.Href)
|
||
switch {
|
||
case h == "":
|
||
case insideDoc:
|
||
serviceInsideDoc++
|
||
default:
|
||
service[h] = strings.ToLower(strings.TrimSpace(g.Type))
|
||
}
|
||
}
|
||
navEntry := ""
|
||
for _, it := range opf.Manifest {
|
||
if hasProperty(it.Properties, "nav") {
|
||
navEntry = resolveHref(opfDir, it.Href)
|
||
break
|
||
}
|
||
}
|
||
var navTargets []string
|
||
labels := map[string]string{}
|
||
if navEntry != "" {
|
||
if data, rerr := readZipEntry(files, navEntry); rerr == nil {
|
||
targets, navLabels, landmarks, insideDoc := parseNavDoc(data, path.Dir(navEntry))
|
||
navTargets, labels = targets, navLabels
|
||
serviceInsideDoc += insideDoc
|
||
for h, role := range landmarks { // EPUB 3
|
||
if isServiceRole(role) {
|
||
service[h] = strings.ToLower(strings.TrimSpace(role))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
var excluded []ExcludedDocument
|
||
for i := range docs {
|
||
role := service[docs[i].entry]
|
||
if role != "" {
|
||
docs[i].service = true
|
||
} else if docs[i].service {
|
||
role = "nav" // set from properties="nav" when the spine was read
|
||
}
|
||
if !docs[i].service || !docs[i].readable {
|
||
continue
|
||
}
|
||
// Read it — only to SIZE it. An exclusion no gate can second-guess should at least say how much text
|
||
// it took with it.
|
||
runes := 0
|
||
if data, rerr := readZipEntry(files, docs[i].entry); rerr == nil {
|
||
if body, _, xerr := extractXHTML(data); xerr == nil {
|
||
runes = utf8.RuneCountInString(text.NormalizeSource(body))
|
||
}
|
||
}
|
||
excluded = append(excluded, ExcludedDocument{Entry: docs[i].entry, Role: role, Runes: runes})
|
||
}
|
||
|
||
// 5) The table of contents, nav → NCX → spine. Each witness is tried only if the previous resolved to
|
||
// nothing: a nav whose targets all dangle is a broken nav, not a book with no chapters.
|
||
toc := resolveTOC(tocNav, navTargets, docs)
|
||
if toc.kind == tocNone {
|
||
// A nav that resolved to nothing still SAID something, and how much of it dangled is a fact about the
|
||
// book. Falling through to the NCX must not erase that count along with the failed table.
|
||
navUnresolved := toc.unresolved
|
||
if ncxEntry := ncxPath(opf, opfDir); ncxEntry != "" {
|
||
if data, rerr := readZipEntry(files, ncxEntry); rerr == nil {
|
||
targets, ncxLabels := parseNCX(data, path.Dir(ncxEntry))
|
||
if ncx := resolveTOC(tocNCX, targets, docs); ncx.kind == tocNCX {
|
||
// Switching tables: carry the nav's dangling count across, or it vanishes with the table.
|
||
toc, labels = ncx, ncxLabels
|
||
toc.unresolved += navUnresolved
|
||
} else {
|
||
toc.unresolved += ncx.unresolved
|
||
}
|
||
}
|
||
}
|
||
}
|
||
starts := toc.starts
|
||
witness := StructureDeclared
|
||
if toc.collapsed > 0 {
|
||
// The format named more chapters than this package can hand back, so the cut is no longer the
|
||
// format's statement — see epubTOC.collapsed.
|
||
witness = StructureDelimited
|
||
}
|
||
if toc.kind == tocNone {
|
||
// No table of contents resolved: fall back to the spine, where every readable document opens its own
|
||
// chapter. That is the assumption this package has always made, and it is now NAMED as one.
|
||
starts = starts[:0]
|
||
for i, d := range docs {
|
||
if d.readable && !d.service && d.linear {
|
||
starts = append(starts, i)
|
||
}
|
||
}
|
||
witness = StructureDelimited
|
||
}
|
||
|
||
// 6) Group, then read. Reading happens per GROUP so a chapter split across documents arrives whole.
|
||
groups, attached := groupChapters(docs, starts)
|
||
// ⛔ CONSERVATION, checked before a single paid call can be reached. The counts this reader keeps are
|
||
// OBSERVABILITY — they say what happened once you go looking. This is the GUARANTEE: every readable spine
|
||
// document is either excluded ON PURPOSE or lands in exactly one chapter, and nothing else is possible.
|
||
//
|
||
// It exists because the two defects that mattered most in this reader were both invisible to counts: a
|
||
// service role declared at a point inside a document deleted the whole file, and the chapter total stayed
|
||
// plausible while the provenance stayed a confident `declared`. An invariant catches that class by ANY
|
||
// route — the fragment of yesterday and whatever tomorrow's is — and it costs nothing to check.
|
||
placed := map[int]bool{}
|
||
for _, g := range groups {
|
||
for _, i := range g.docs {
|
||
if placed[i] {
|
||
return nil, fmt.Errorf("chunk: ingest epub: spine document %q landed in two chapters — the cut is not a partition", docs[i].entry)
|
||
}
|
||
placed[i] = true
|
||
}
|
||
}
|
||
for i, d := range docs {
|
||
if !d.readable {
|
||
continue
|
||
}
|
||
if d.service == placed[i] {
|
||
// Either a readable document is BOTH excluded and placed, or it is neither — text left the reading
|
||
// without anybody deciding to drop it.
|
||
return nil, fmt.Errorf("chunk: ingest epub: spine document %q is neither excluded nor part of a chapter (excluded=%v placed=%v) — book text would be lost silently",
|
||
docs[i].entry, d.service, placed[i])
|
||
}
|
||
}
|
||
doc := &Document{
|
||
DocumentsAttached: attached, Excluded: excluded,
|
||
TOCUnresolved: toc.unresolved, TargetsCollapsed: toc.collapsed,
|
||
ServiceRolesInsideDocuments: serviceInsideDoc,
|
||
}
|
||
denseNo := 0 // matches SplitChunks: only a NON-empty chapter takes a number
|
||
read := 0
|
||
for _, g := range groups {
|
||
// The chapter's source title is the label of the document that OPENED it.
|
||
title := ""
|
||
if g.start >= 0 {
|
||
title = labels[docs[g.start].entry]
|
||
}
|
||
doc.Titles = append(doc.Titles, title)
|
||
var parts []string
|
||
var ruby []RubyReading
|
||
for _, i := range g.docs {
|
||
data, rerr := readZipEntry(files, docs[i].entry)
|
||
if rerr != nil {
|
||
return nil, fmt.Errorf("chunk: ingest epub: spine item %q → %s: %w", docs[i].idref, docs[i].entry, rerr)
|
||
}
|
||
read++
|
||
body, rb, xerr := extractXHTML(data)
|
||
if xerr != nil {
|
||
return nil, fmt.Errorf("chunk: ingest epub: extract %s: %w", docs[i].entry, xerr)
|
||
}
|
||
if b := text.NormalizeSource(body); b != "" {
|
||
parts = append(parts, b)
|
||
}
|
||
ruby = append(ruby, rb...)
|
||
}
|
||
norm := strings.Join(parts, "\n\n")
|
||
// The rune half of the same invariant: a chapter is EXACTLY its documents plus the separators this
|
||
// function inserted between them. A body silently dropped inside the join would keep every count
|
||
// right and every document "placed".
|
||
want := 0
|
||
for _, b := range parts {
|
||
want += utf8.RuneCountInString(b)
|
||
}
|
||
if sep := 2 * (len(parts) - 1); len(parts) > 0 && utf8.RuneCountInString(norm) != want+sep {
|
||
return nil, fmt.Errorf("chunk: ingest epub: chapter %d holds %d runes but its %d document(s) carry %d + %d separator(s) — text was lost in the join",
|
||
len(doc.Chapters)+1, utf8.RuneCountInString(norm), len(parts), want, sep)
|
||
}
|
||
doc.Chapters = append(doc.Chapters, norm)
|
||
// The chapter number ruby carries MUST equal the number SplitChunks assigns (dense — empty chapters
|
||
// are skipped), otherwise memory v2's since_ch is off by the count of empty chapters before it.
|
||
if len(splitParagraphs(norm)) == 0 {
|
||
continue
|
||
}
|
||
denseNo++
|
||
for i := range ruby {
|
||
ruby[i].Chapter = denseNo
|
||
}
|
||
doc.Ruby = append(doc.Ruby, ruby...)
|
||
}
|
||
if read == 0 {
|
||
return nil, fmt.Errorf("chunk: ingest epub: spine resolved to no readable (x)html chapters")
|
||
}
|
||
doc.Structure = structureOf(doc.Chapters, witness)
|
||
return doc, nil
|
||
}
|
||
|
||
// isXHTML reports whether a manifest item is an (x)html content document. It
|
||
// checks the media-type (parameters like "; charset=utf-8" stripped), accepting
|
||
// the (x)html types AND the generic XML types real epubs mislabel xhtml chapters
|
||
// with (application/xml, text/xml), then falls back to a content-document href
|
||
// extension for ANY unrecognized/absent type. A genuine non-content asset keeps its
|
||
// own extension (.jpg/.css/.ncx/.svg), so the fallback does not misclassify it.
|
||
// (External-review #2: a chapter typed application/xml with a .xml href used to slip
|
||
// past BOTH the type switch and a .xhtml-only extension fallback and vanish silently.)
|
||
func isXHTML(mediaType, href string) bool {
|
||
mt := strings.ToLower(strings.TrimSpace(mediaType))
|
||
if i := strings.IndexByte(mt, ';'); i >= 0 {
|
||
mt = strings.TrimSpace(mt[:i])
|
||
}
|
||
switch mt {
|
||
case "application/xhtml+xml", "text/html", "application/html",
|
||
"application/xml", "text/xml":
|
||
return true
|
||
}
|
||
switch extOf(href) {
|
||
case ".xhtml", ".html", ".htm", ".xml":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// resolveHref joins an OPF-relative href to the OPF directory (percent-decoded,
|
||
// slash-cleaned) into a zip entry path.
|
||
func resolveHref(opfDir, href string) string {
|
||
doc, _ := hrefTarget(opfDir, href)
|
||
return doc
|
||
}
|
||
|
||
// readZipEntry reads a zip entry by cleaned path.
|
||
func readZipEntry(files map[string]*zip.File, name string) ([]byte, error) {
|
||
f, ok := files[path.Clean(name)]
|
||
if !ok {
|
||
return nil, fmt.Errorf("missing zip entry %q", name)
|
||
}
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("open zip entry %q: %w", name, err)
|
||
}
|
||
defer rc.Close()
|
||
return io.ReadAll(rc)
|
||
}
|
||
|
||
// --- xhtml text + ruby extraction ----------------------------------------------
|
||
|
||
// blockTags emit a paragraph break (blank line) so the chunker's paragraph packing
|
||
// survives extraction; without them a whole chapter collapses into one paragraph.
|
||
var blockTags = map[string]bool{
|
||
"p": true, "div": true, "h1": true, "h2": true, "h3": true, "h4": true,
|
||
"h5": true, "h6": true, "li": true, "blockquote": true, "section": true,
|
||
"article": true, "tr": true, "table": true, "ul": true, "ol": true, "dl": true,
|
||
"dd": true, "dt": true, "pre": true, "figure": true, "figcaption": true,
|
||
"header": true, "footer": true, "aside": true, "nav": true, "hr": true,
|
||
"main": true, "td": true, "th": true, "caption": true,
|
||
}
|
||
|
||
// skipRoots are subtrees whose text is not prose (CSS/JS/metadata).
|
||
var skipRoots = map[string]bool{"script": true, "style": true, "head": true}
|
||
|
||
// rawTextTags are the elements whose content the tokenizer hands back verbatim instead of lexing
|
||
// it. That is exactly what we want for a <script>/<style> subtree we drop anyway — it is why
|
||
// markup-shaped JS no longer breaks the parse. Everywhere else raw mode is wrong: it would put
|
||
// literal tags into the prose, and after a SELF-CLOSED raw-text tag (<script src=".."/>, a common
|
||
// xhtml spelling) no closing tag ever arrives, so the whole rest of the chapter would be swallowed
|
||
// onto the wire as markup. Those cases are switched back to normal lexing.
|
||
var rawTextTags = map[string]bool{
|
||
"script": true, "style": true, "title": true, "textarea": true, "iframe": true,
|
||
"noscript": true, "noembed": true, "noframes": true, "xmp": true, "plaintext": true,
|
||
}
|
||
|
||
// voidTags never have content, so the tokenizer emits ONE token for them and no end tag. The
|
||
// extractor closes them itself, which is also what encoding/xml did via HTMLAutoClose — that
|
||
// parity is what keeps <hr/> and <br/> extracting byte-for-byte as before.
|
||
var voidTags = map[string]bool{
|
||
"area": true, "base": true, "br": true, "col": true, "embed": true, "hr": true,
|
||
"img": true, "input": true, "link": true, "meta": true, "param": true,
|
||
"source": true, "track": true, "wbr": true,
|
||
}
|
||
|
||
// xmlDeclCharset picks the encoding out of a leading <?xml … encoding="…"?> declaration.
|
||
var xmlDeclCharset = regexp.MustCompile(`(?is)^\s*<\?xml\s[^>]*?encoding\s*=\s*["']([^"']+)["']`)
|
||
|
||
// checkXHTMLCharset keeps the epub-v1 = UTF-8 contract that the xml decoder's CharsetReader used
|
||
// to enforce: a document DECLARING another encoding is a loud error, never silently mis-decoded
|
||
// prose. Only the declaration is inspected, exactly as before — a <meta charset> is not consulted.
|
||
func checkXHTMLCharset(data []byte) error {
|
||
head := bytes.TrimPrefix(data, utf8BOM)
|
||
if len(head) > 512 {
|
||
head = head[:512]
|
||
}
|
||
m := xmlDeclCharset.FindSubmatch(head)
|
||
if m == nil {
|
||
return nil
|
||
}
|
||
switch cs := strings.ToLower(strings.TrimSpace(string(m[1]))); cs {
|
||
case "", "utf-8", "utf8", "us-ascii", "ascii":
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("unsupported xhtml charset %q (epub v1 expects UTF-8)", cs)
|
||
}
|
||
}
|
||
|
||
var cdataOpen, cdataClose = []byte("<![CDATA["), []byte("]]>")
|
||
|
||
// unwrapCDATA turns XML CDATA sections into their escaped text before tokenizing. The HTML
|
||
// tokenizer has no CDATA: it lexes «<![CDATA[…]]>» as a bogus comment, dropping part of the
|
||
// content and leaking «]]>» into the prose. Unwrapping keeps what the xml decoder gave — CDATA
|
||
// content is literal text — so a chapter that used to import cleanly still extracts byte-for-byte.
|
||
func unwrapCDATA(data []byte) []byte {
|
||
if !bytes.Contains(data, cdataOpen) {
|
||
return data
|
||
}
|
||
var out bytes.Buffer
|
||
rest := data
|
||
for {
|
||
i := bytes.Index(rest, cdataOpen)
|
||
if i < 0 {
|
||
out.Write(rest)
|
||
return out.Bytes()
|
||
}
|
||
out.Write(rest[:i])
|
||
rest = rest[i+len(cdataOpen):]
|
||
j := bytes.Index(rest, cdataClose)
|
||
if j < 0 { // unterminated section: the remainder is literal text, as XML would have it
|
||
out.WriteString(html.EscapeString(string(rest)))
|
||
return out.Bytes()
|
||
}
|
||
out.WriteString(html.EscapeString(string(rest[:j])))
|
||
rest = rest[j+len(cdataClose):]
|
||
}
|
||
}
|
||
|
||
// tokenTagName is the token's element name: already lower-cased by the tokenizer, with any
|
||
// namespace prefix dropped (<epub:switch> → "switch") so matching stays on the LOCAL name the
|
||
// xml decoder used to report.
|
||
func tokenTagName(z *html.Tokenizer) string {
|
||
raw, _ := z.TagName()
|
||
name := string(raw)
|
||
if i := strings.IndexByte(name, ':'); i >= 0 {
|
||
name = name[i+1:]
|
||
}
|
||
return name
|
||
}
|
||
|
||
// extractXHTML strips tags to text and captures ruby. Rules:
|
||
// - block tag → blank line; <br> → newline (paragraph structure for the chunker);
|
||
// - <ruby>base<rt>reading</rt></ruby>: BASE goes to the body, READING is captured
|
||
// as a (base,reading) pair, <rp> parenthesis fallbacks are dropped;
|
||
// - <script>/<style>/<head> subtrees are dropped.
|
||
//
|
||
// It runs on the x/net/html TOKENIZER (a lexer, not the tree parser): real epubs ship xhtml that
|
||
// is not well-formed XML, and encoding/xml aborted the whole chapter on any of it — a bare «<» in
|
||
// prose, mis-nested tags, markup-looking <script> content, or «--» inside a comment. The tokenizer
|
||
// never fails on those, so a dirty chapter imports instead of killing the run. A non-UTF-8 declared
|
||
// charset stays a loud error (epub v1 = UTF-8).
|
||
// Ruby is returned with Chapter unset (0); the caller stamps the dense chapter no.
|
||
func extractXHTML(data []byte) (string, []RubyReading, error) {
|
||
if err := checkXHTMLCharset(data); err != nil {
|
||
return "", nil, err
|
||
}
|
||
z := html.NewTokenizer(bytes.NewReader(unwrapCDATA(data)))
|
||
|
||
var body strings.Builder
|
||
var ruby []RubyReading
|
||
var base, reading strings.Builder
|
||
// Ruby sub-state is a MODE, not a nesting depth. HTML5 makes </rt>, </rp> and </rb> OPTIONAL —
|
||
// a sibling or the closing </ruby> ends them — and the tokenizer does not synthesise those
|
||
// implied end tags the way non-strict encoding/xml did. With counters, one omitted </rt> leaks
|
||
// forever and every LATER base in the chapter is routed into the reading buffer, i.e. deleted
|
||
// from the prose. A mode cannot leak: every sibling and every </ruby> resets it.
|
||
const (
|
||
rubyBase = iota
|
||
rubyRT
|
||
rubyRP
|
||
)
|
||
rubyDepth, rubyPart := 0, rubyBase
|
||
// A skipped subtree is tracked BY NAME, not by raw nesting depth: the tokenizer emits no end
|
||
// tag for void/self-closing children (<meta/>, <link/> in <head>), so a blind depth counter
|
||
// would never return to zero and would swallow the rest of the chapter.
|
||
skip, skipDepth := "", 0
|
||
|
||
// flushRuby closes the current ruby, emitting its (base, reading) if both are non-empty, and
|
||
// clears ALL ruby state. Called on </ruby> and at a block boundary — ruby is inline, so an
|
||
// unclosed <ruby> must not outlive its paragraph (left open it suppresses every later
|
||
// paragraph break and swallows the rest of the chapter into one run-on block).
|
||
flushRuby := func() {
|
||
b := strings.TrimSpace(base.String())
|
||
r := strings.TrimSpace(reading.String())
|
||
if b != "" && r != "" {
|
||
ruby = append(ruby, RubyReading{Base: b, Reading: r})
|
||
}
|
||
base.Reset()
|
||
reading.Reset()
|
||
rubyDepth, rubyPart = 0, rubyBase
|
||
}
|
||
|
||
start := func(name string) {
|
||
if skip != "" {
|
||
switch {
|
||
case skip == "head" && name == "body":
|
||
skip, skipDepth = "", 0 // an unclosed <head>: <body> ends it rather than eating the chapter
|
||
case name == skip:
|
||
skipDepth++
|
||
}
|
||
return
|
||
}
|
||
switch {
|
||
case skipRoots[name]:
|
||
skip, skipDepth = name, 1
|
||
case name == "ruby":
|
||
rubyDepth++
|
||
if rubyDepth == 1 {
|
||
base.Reset()
|
||
reading.Reset()
|
||
}
|
||
rubyPart = rubyBase
|
||
case name == "rt":
|
||
rubyPart = rubyRT
|
||
case name == "rp":
|
||
rubyPart = rubyRP
|
||
case name == "rb":
|
||
rubyPart = rubyBase
|
||
case name == "br" && rubyDepth == 0:
|
||
body.WriteByte('\n')
|
||
case blockTags[name]:
|
||
if rubyDepth > 0 {
|
||
flushRuby()
|
||
}
|
||
body.WriteString("\n\n")
|
||
}
|
||
}
|
||
|
||
end := func(name string) {
|
||
if skip != "" {
|
||
if name == skip {
|
||
if skipDepth--; skipDepth <= 0 {
|
||
skip, skipDepth = "", 0
|
||
}
|
||
}
|
||
return
|
||
}
|
||
switch {
|
||
case name == "ruby":
|
||
if rubyDepth > 0 {
|
||
rubyDepth--
|
||
rubyPart = rubyBase
|
||
if rubyDepth == 0 {
|
||
flushRuby()
|
||
}
|
||
}
|
||
case name == "rt", name == "rp", name == "rb":
|
||
rubyPart = rubyBase
|
||
case blockTags[name]:
|
||
if rubyDepth > 0 {
|
||
flushRuby()
|
||
}
|
||
body.WriteString("\n\n")
|
||
}
|
||
}
|
||
|
||
for {
|
||
tt := z.Next()
|
||
if tt == html.ErrorToken {
|
||
if err := z.Err(); !errors.Is(err, io.EOF) {
|
||
return "", nil, fmt.Errorf("tokenize xhtml: %w", err)
|
||
}
|
||
break
|
||
}
|
||
switch tt {
|
||
case html.StartTagToken, html.SelfClosingTagToken:
|
||
name := tokenTagName(z)
|
||
// Keep raw text ONLY for a script/style subtree we are about to drop whole.
|
||
if rawTextTags[name] && (tt == html.SelfClosingTagToken || !skipRoots[name]) {
|
||
z.NextIsNotRawText()
|
||
}
|
||
start(name)
|
||
// A void or self-closing element has no content and no end tag of its own.
|
||
if tt == html.SelfClosingTagToken || voidTags[name] {
|
||
end(name)
|
||
}
|
||
case html.EndTagToken:
|
||
end(tokenTagName(z))
|
||
case html.TextToken:
|
||
if skip != "" {
|
||
continue // <script>/<style>/<head> subtree — not prose
|
||
}
|
||
text := string(z.Text())
|
||
if rubyDepth > 0 {
|
||
switch {
|
||
case rubyPart == rubyRP:
|
||
// ruby parenthesis fallback — drop.
|
||
case rubyPart == rubyRT:
|
||
reading.WriteString(text)
|
||
case strings.TrimSpace(text) == "":
|
||
// Formatting whitespace BETWEEN ruby base segments (pretty-printed
|
||
// jukugo <rb>旧</rb> <rb>字</rb> …) must not enter the captured base
|
||
// (it would mis-key the glossary reading) nor the body (it would
|
||
// inject stray whitespace between CJK glyphs into ch.Text) —
|
||
// self-review finding. Drop it; real base glyphs are non-whitespace.
|
||
default:
|
||
base.WriteString(text)
|
||
body.WriteString(text)
|
||
}
|
||
continue
|
||
}
|
||
body.WriteString(text)
|
||
}
|
||
// CommentToken / DoctypeToken carry no prose — dropped.
|
||
}
|
||
return body.String(), ruby, nil
|
||
}
|