Trim a nav label like its siblings, restore the lower bound a neighbour already guarded, and decode percents in the last resolver that did not

This commit is contained in:
heaven 2026-09-06 02:42:37 +03:00
parent 5f4c6d8754
commit fde85307d4
8 changed files with 473 additions and 28 deletions

View file

@ -12,8 +12,12 @@
| | команда | EXIT | пакетов | FAIL | линтер | скипы |
|---|---|---|---|---|---|---|
| до правок | `make battery` | 0 | 19 | 0 | 0 issues | **3** |
| после | `make battery` | 0 | 19 | 0 | 0 issues | **3** |
| до правок | `make battery` (одной командой) | 0 | 19 | 0 | 0 issues | **3** |
| после | ⚠ ПО СТАДИЯМ — см. §11 | 0 | 19 | 0 | 0 issues | **3** |
**Строка «после» — НЕ `make battery`.** Одной командой она не прошла: система дважды убила её по нехватке
памяти. Прогон сделан по стадиям, команды перечислены в §11. Первая редакция этой таблицы писала `make
battery` в обеих строках и противоречила §11 того же отчёта.
Скипы поимённо и в обоих прогонах те же: `TestMinerFullBookParity` (корпусный, снимается `battery-stand`),
`TestHelperEventsRun`, `TestHelperKillLoop` (хелперы подпроцессов). **Новых скипов не появилось** — это
@ -21,7 +25,7 @@
### Каталог мутаций
201 → **205 записей**. Шесть прогнаны исполнением на КОПИИ дерева (`~/tm-mut-0509`, `GOFLAGS=-buildvcs=false`),
201 → **211 записей** (первая редакция говорила 205 — это было состояние ПЕРВОГО круга, до шести записей дофикса). Шесть прогнаны исполнением на КОПИИ дерева (`~/tm-mut-0509`, `GOFLAGS=-buildvcs=false`),
**6 RED, 0 неожиданных исходов**:
| запись | покрасневший тест |
@ -52,7 +56,8 @@
### Счета формата
`form feeds ignored`, `documents attached`, `documents excluded`, `toc unresolved`, `targets collapsed` живут в `chunk.Document`,
`form feeds ignored`, `documents attached`, `documents excluded`, `toc unresolved`, `targets collapsed`,
`service roles inside documents` — ШЕСТЬ счётов — живут в `chunk.Document`,
уезжают в WARN (`events.go`, `ingestSource`) и **НЕ на провод** — потребителя у них сегодня нет.
---
@ -197,13 +202,13 @@ unit-id главы 1 `c3aa4cbbb53de134:d4fb75e2:0` → `c3aa4cbbb53de134:1b30b1d
## 6. ЧТО НЕ ПРОВЕРЕНО И ПОЧЕМУ
- **Полный каталог (205 записей) не гонялся.** Прогнаны 6 записей, целящих в изменённое поведение. Причина —
- **Полный каталог (211 записей) не гонялся.** Прогнаны 14 записей, целящих в изменённое поведение. Причина —
цена: 124 записи гоняют `./internal/pipeline/`, чей набор идёт ~145 с, то есть полный прогон — часы при
загруженном хосте. Механически проверено другое и дешёвое: **у всех 205 записей цель ЖИВА** (греп `find`
загруженном хосте. Механически проверено другое и дешёвое: **у всех 211 записей цель ЖИВА** (греп `find`
по дереву), то есть протухших целей после пака не осталось.
- **EPUB владельца (Кристофф/en, isekai/ja) не прогонялись через новый путь.** §3 выводит их из гейта, а
§2.1 п.3 требует ОРАКУЛА, а не замера; оракул построен кодом (`TestEPUBNavOracleReproducesTheTxtCut`).
Замер их нового разреза числом — не сделан.
- **Эта строка УСТАРЕЛА и оставлена как след:** на момент первой редакции EPUB владельца через новый путь
не прогонялись. Они прогнаны позже — §8 и §10 несут их числа. Строку не удаляю, потому что она показывает,
в каком порядке появлялось знание.
- **Платная книга платформы** не трогалась — чужая зона.
- **Сквозного прогона не-CJK нет** — промтов пары для en/ja не существует (§5), проверка ограничена
уровнем ingest/manifest, $0.
@ -434,3 +439,88 @@ go test ./... -count=1 -v → EXIT 0, o
**Это второй раз за смену, когда правку ловил пере-замер, а не тест.** Первый — Д-2 (не пере-мерил книгу
владельца после починки). Норма работает ровно потому, что она про ЭКЗЕМПЛЯР, а не про мнение.
---
## 12. ВЫЧИТКА ПОСЛЕ ЛЕНДИНГА — ЧТО НАШЛОСЬ В СВОЁМ ЖЕ КОДЕ
Пак был уже заланден (`2f65d1d`), когда владелец спросил, всё ли проверено. Простое чтение собственного
дифа глазами — то, чего я за весь пак ни разу не сделал подряд, — дало находки за минуты и без единого агента.
### Дефекты кода (починены)
| # | что | почему это дефект |
|---|---|---|
| В-1 | **метка `nav` не триммилась**, а её сёстры триммят | один заголовок давал `"\n Chapter One\n "` через nav и `"Chapter One"` через NCX — два ответа на один вопрос, решаемые тем, какую таблицу собрал издатель. Едет на провод в `title_raw`. Фикстуры пака не ловили: они пишут метку без отступов |
| В-2 | **поиск заголовка в манифесте без нижней границы** | сосед шестью строками выше пишет `k >= 0 && k < len(ids)` и деградирует с WARN, «если плотная нумерация однажды перестанет держаться». Моя строка в том же случае даёт панику индекса на платном прогоне |
| В-3 | `ncxPath` — единственный резолвер href, не раскодирующий проценты | ровно та асимметрия, которую пак свёл в `hrefTarget`; я свёл два резолвера и не заметил третий |
Пин на В-1 — `TestNavLabelIsTrimmedLikeItsSiblings`, предъявлен посадкой (снять trim → красный).
### Протухшие носители (девять, починены)
`resolveTOC` обещал счёт нелинейных целей, которого я сам же лишил его при M-5 · `epubTOC.unresolved` ссылался
на `DocumentsExcluded`, переименованный в `Excluded` · `isChapterHeader` нёс имя `isCJKChapterHeader` ·
`Chunk.Heading` — «detected via the pair's lang.HeadingRule» · `stripHeading` в `SplitChunksWithChapters`
«A nil rule» · шапка `ingest.go` и `chapterSep` — «form feed семантически граница главы» и «epub несёт
настоящую структуру глав в spine», то есть ровно те два убеждения, которые пак опровергает · `detectChapterUnit`
— четыре юнита и не тот источник · `buildManifest` — параметр `chapterTexts`, которого нет · `bookfile/model.go`
— «титульный документ был бы прочитан как отдельная глава» · комментарий сторожа в `ingestnotes_test.go`
обещал «любой тип», а `switch` покрывает два.
**Класс один и тот же: утверждение, верное на момент написания и не пере-прочитанное после правки.** Он же
дал шесть находок приёмки и четыре моих. Дешевле всего он ловится не тестом, а чтением подряд.
### Ошибки в ЭТОМ отчёте (исправлены)
Каталог 205 → **211** (число было от первого круга) · таблица §1 писала `make battery` в обеих строках и
противоречила §11 того же отчёта · «Счета формата» перечисляли пять, их шесть · строка §6 «EPUB владельца не
прогонялись» осталась после того, как §8 и §10 их прогнали.
### Перепись гейта была неверной — и я поймал её собственной нормой дня
Я считал конфиги **по ИМЕНИ файла** (`book.yaml`, `*.book.yaml`) и получил 31 конфиг / 8 исходников.
Счёт **по СОДЕРЖИМОМУ** (файл несёт `book_id` и `source_file`) даёт **39 конфигов и 12 различных
исходников**. Четыре источника семьи 蛊真人 под `books/gu-zhenren/exp15/` гейт не покрывал: их конфиги
называются `exp15-*.yaml`, и фильтр по имени их не видел.
**Это четвёртый за смену случай класса «имя против содержимого» — и первый, где на него наступил я сам,
уже объявив его нормой.**
Четыре пропущенных источника пинованы «до» и «после» отдельно:
| исходник | глав | structure до → после | разрез |
|---|---|---|---|
| `S2prime.txt` | 1 | none → none | байт-в-байт |
| `S2prime_big_flat.txt` | 1 | none → none | байт-в-байт |
| `S2prime_big_blanklines.txt` | 1 | none → none | байт-в-байт |
| `smoke.txt` | 1 | none → none | байт-в-байт |
Пробел последствий не имел, но он БЫЛ, и закрыт замером, а не доводом.
### Два заказа §2.1/§2.5, которых я не сделал и не назвал
**Полная 蛊真人 (2283 главы) через новый путь не прогонялась.** §2.1 велит: «Полную книгу гоняешь
ОТДЕЛЬНО, СВОИМ конфигом в своём каталоге; её результат идёт числом в отчёт». Я записал в §6 «у неё нет
конфига» — но отсутствие конфига и есть то, что заказ приказывал устранить. **Это не «не проверено», это
не сделано.**
**Машинная карта сдвигов не была построена и не была названа среди непроверенного.** §2.5: «Прозой
нельзя: на прозе не построить мигратор потом». Отчёт давал прозаическую таблицу восьми строк.
### ОБА ЗАКАЗА ИСПОЛНЕНЫ ПОСЛЕ ВЫЧИТКИ
**Полная 蛊真人 прогнана своим конфигом** (`~/tm-pins-0509/full/`, `tmctl manifest`, EXIT 0, 10 с, $0):
**2283 главы**, `structure = detected`, 4276 юнитов, 5071 чанк, `title_raw` заполнен у **2283 из 2283**.
Счёт глав сошёлся с независимым замером шапок шага 0 (2283 строки `第N节`) — два прибора, один ответ.
**Машинная карта сдвигов построена** — `backend/docs/CHAPTER_STRUCTURE_SHIFTMAP.json`, генератор рядом
(`chapter_structure_shiftmap.py`). Set-diff по id и номеру, `same/moved/gone/new`, по **тринадцати**
источникам: восемь пинов гейта, четыре пропущенных переписью, и полная книга.
| | глав | same | moved | gone | new |
|---|---|---|---|---|---|
| **все 13 источников** | **2355** | **2355** | **0** | **0** | **0** |
⇒ ветка (а) гейта §3 предъявлена не таблицей глазами, а машинным набором, и на нём строится мигратор,
если он понадобится. Это и было содержанием заказа.

View file

@ -0,0 +1,275 @@
[
{
"source": "ch1",
"before": {
"chapters": 1,
"structure": "none",
"key": "1199f7ef6aff4863dbd6bcdacf7f9313ea928d79f5f216ab5687d5f2c9022bf4"
},
"after": {
"chapters": 1,
"structure": "none",
"key": "37db0ea49c3dcae65c43a8ab22e5c054c8f252c6cac509cf66e69e63169bcf41"
},
"same": 1,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "ch1-2",
"before": {
"chapters": 2,
"structure": "detected",
"key": "f368bcf0e25bdff52e6f14a751ccc13e151b2bc475d4931cec30c0cd7f255be2"
},
"after": {
"chapters": 2,
"structure": "detected",
"key": "cdb5056e9a7440b2ba1fa6bcead16b4370d72a6ce0dc231e76e02b441c1e19f1"
},
"same": 2,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "ch1-5",
"before": {
"chapters": 5,
"structure": "detected",
"key": "1f2be0a61722c80cbf15e7539e5cd4826ee409aefb95ff9795d2a16137c7f2de"
},
"after": {
"chapters": 5,
"structure": "detected",
"key": "748f069774e72cbdeb3e3e344a05e392e60205161db819c6149d52d948445fa4"
},
"same": 5,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "ch4-5",
"before": {
"chapters": 2,
"structure": "detected",
"key": "be3010c163605ac444d5486e49599e4f0af19760858d9787c333d23ecfb9e360"
},
"after": {
"chapters": 2,
"structure": "detected",
"key": "b15aa9fa7837e373ff0893774978f9e423d863aa7119425ca8fbb1116a1568f0"
},
"same": 2,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "ch1-10",
"before": {
"chapters": 10,
"structure": "detected",
"key": "1e62a68d93f73534c27a1c67b1e98b813d5a5916986159259f307bd635e9bb03"
},
"after": {
"chapters": 10,
"structure": "detected",
"key": "01a00e3669302bc14accd9d05a8f6a0155398f817ac1d1419d6dbc92aa9419a1"
},
"same": 10,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "s11-16",
"before": {
"chapters": 6,
"structure": "detected",
"key": "b806ba144cecc402e4d130a7b42d4f04c9d63ca4d114e9772ef95ab7d8d4b577"
},
"after": {
"chapters": 6,
"structure": "detected",
"key": "c1148887ca4c977339969a6fc99f740d7b5a8674f8a602b34572dc1dbc71d4a8"
},
"same": 6,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "slice25",
"before": {
"chapters": 25,
"structure": "detected",
"key": "084b129293ba410f7245a57ee99b84a2bb4f3c4bb32aa6a24ff0a52c4e3fc246"
},
"after": {
"chapters": 25,
"structure": "detected",
"key": "6dbbbe3490343526781f9577c852426802b2b1b1962526e71bc07c28d8f4643b"
},
"same": 25,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "ch26-42",
"before": {
"chapters": 17,
"structure": "detected",
"key": "8f7aab0de5e785f0e25b89ebb2730c55c5fdc435cbcfb234a91c54608a42107d"
},
"after": {
"chapters": 17,
"structure": "detected",
"key": "8a936b25aa719179557a3c4728e1f1cfa5d4ffa0888a03d5407de9fdb74ef837"
},
"same": 17,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "S2prime",
"before": {
"chapters": 1,
"structure": "none",
"key": "511c1f6e9abe2c0a1818c0bfc5ba14d970ae18869195b4f3d0acea4fd32fe7a9"
},
"after": {
"chapters": 1,
"structure": "none",
"key": "b3ce41a67e170d3cf1c912a197bf279a1ae22515fc9665b335d6a2c97596ee24"
},
"same": 1,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "S2prime-flat",
"before": {
"chapters": 1,
"structure": "none",
"key": "0e7c8df7be6edbf0c0c7cd4d511d9ff0bb6a8f996a051cd214dc2378bbfc34e1"
},
"after": {
"chapters": 1,
"structure": "none",
"key": "084466f455aff0cbd32533d7f4d192e1a7e1243706dc3a0469190b0c4516d703"
},
"same": 1,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "S2prime-blank",
"before": {
"chapters": 1,
"structure": "none",
"key": "c2f95f265b7772fe2834230e8e581119f03a00c8b02a460afc54e05b970f8b04"
},
"after": {
"chapters": 1,
"structure": "none",
"key": "6c972cea05026d68a65d2b527f04bbbea8afcfe1d28bea933fe5a4b5b9f96626"
},
"same": 1,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "smoke",
"before": {
"chapters": 1,
"structure": "none",
"key": "a071430a17b96f7995bd54855c76fcf55d97ab082915351ec7ede748342d798b"
},
"after": {
"chapters": 1,
"structure": "none",
"key": "69131330d13d6d5f024eaca800c748466ed6a02e5240c0aeee081f274d601af2"
},
"same": 1,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
},
{
"source": "guzhenren-full-2283",
"before": {
"chapters": 2283,
"structure": "detected",
"key": "28fa8b38eda95e94f68c640901152350c66041dea7fd898e1710f02857f0c4c5"
},
"after": {
"chapters": 2283,
"structure": "detected",
"key": "d1a249ec0b8a3dc7ab59d070409659bc520b77b496e1cb73b2cd5e295dd7a68c"
},
"same": 2283,
"moved": 0,
"gone": 0,
"new": 0,
"moved_detail": [],
"gone_ids": [],
"new_ids": [],
"cut_unchanged": true
}
]

View file

@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Машинная карта сдвигов, заказанная §2.5: set-diff по id и номеру, same/moved/gone/new."""
import json, sys
def load(p):
m = json.load(open(p, encoding="utf-8"))
return {c["id"]: c["number"] for c in m["chapters"]}, m
def diff(before_path, after_path, source):
a, ma = load(before_path)
b, mb = load(after_path)
same = sorted(i for i in a if i in b and a[i] == b[i])
moved = sorted(i for i in a if i in b and a[i] != b[i])
gone = sorted(i for i in a if i not in b)
new = sorted(i for i in b if i not in a)
return {
"source": source,
"before": {"chapters": len(a), "structure": ma.get("structure"), "key": ma.get("key")},
"after": {"chapters": len(b), "structure": mb.get("structure"), "key": mb.get("key")},
"same": len(same), "moved": len(moved), "gone": len(gone), "new": len(new),
"moved_detail": [{"id": i, "from": a[i], "to": b[i]} for i in moved][:200],
"gone_ids": gone[:200], "new_ids": new[:200],
"cut_unchanged": not moved and not gone and not new,
}
if __name__ == "__main__":
out = [diff(*t) for t in (sys.argv[1:][i:i+3] for i in range(0, len(sys.argv)-1, 3))]
json.dump(out, sys.stdout, ensure_ascii=False, indent=1)
print()

View file

@ -60,8 +60,8 @@ type Chunk struct {
// ("degradation is not silent") rather than silently oversized.
OversizedSentence bool
// Heading is the deterministic chapter title, non-empty ONLY on a chapter's FIRST chunk (ChunkIdx 0)
// when the chapter opened with a source structural header (detected via the pair's lang.HeadingRule)
// AND a rule is configured. The chunker STRIPS the source marker from ch.Text (so the model never
// when the chapter opened with a source structural header (RECOGNISED by the source language's
// lang.SourceStructure) AND the pair supplies a template. The chunker STRIPS the source marker from ch.Text (so the model never
// renders its own «Раздел 2»/«Первая глава»/an orphaned « :») and renders this from the rule's template
// («Глава N»); the read-models PREPEND it to the chapter's first output unit. "" for every non-first
// chunk, every chapter without a header, and every book without a heading rule (in which case the source
@ -128,7 +128,7 @@ func SplitChunksWithChapters(chapters []string, seg SegBudget, rule *lang.Chapte
editUnitID := 0
for idx, ingested := range chapters {
// Title policy (pack-13): detect a leading structural header, render it deterministically and
// STRIP its marker from the text the model sees. A nil rule (no pack / no heading.txt) or a chapter
// STRIP its marker from the text the model sees. An absent TEMPLATE (no pack / no heading.txt) or a chapter
// with no header is a NO-OP — headingText is "" and chapText is unchanged, so a book that does not
// opt in produces byte-identical chunks. Runs BEFORE splitParagraphs so the stripped subtitle is
// re-paragraphed normally.
@ -202,7 +202,7 @@ func ApplyHeading(heading, finalText string) string {
// matchHeaderLine reports whether a single line is a structural header under the rule and returns the
// parsed section number + the trimmed subtitle after the marker. Shape: <marker><numeral-run><unit-rune>,
// then EITHER end-of-line OR a SEPARATOR (not a content glyph) — mirroring ingest.isCJKChapterHeader's
// then EITHER end-of-line OR a SEPARATOR (not a content glyph) — mirroring ingest.isChapterHeader's
// precision guard so «第一回见面» (回 a measure word glued to the content 见) is NOT a header, while
// «第一节:…» / «第1章 …» is. Leading whitespace is tolerated; the subtitle is the remainder with any
// leading separators (: 、,.。- —  ·) trimmed. Deterministic.

View file

@ -56,7 +56,7 @@ type epubTOC struct {
// ⚠ 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.
// nobody reads. Their exclusion, when they are service pages, is counted once in Document.Excluded.
unresolved int
// collapsed counts targets that landed in a document another target had already claimed.
//
@ -172,6 +172,13 @@ func parseNavDoc(data []byte, baseDir string) (toc []string, labels map[string]s
case html.EndTagToken:
switch name, _ := z.TagName(); string(name) {
case "a":
// Trim on CLOSE, where the whole label is in hand. Real books indent their nav, so an
// untrimmed label ships "\n Chapter One\n " as the chapter's source title — while
// the NCX half (parseNCX) and the txt half (chapterTitleRaw) both trim. One title, two
// answers, decided by which table the book happens to carry.
if pending != "" {
labels[pending] = strings.TrimSpace(labels[pending])
}
pending = ""
case "nav":
navType, depth, pending = "", 0, ""
@ -325,16 +332,20 @@ func isServiceRole(role string) bool {
// when that attribute is missing, as it is in books built by tools that half-migrated to EPUB 3 — the first
// item carrying the NCX media type.
func ncxPath(opf opfPackage, opfDir string) string {
// Resolved through the SAME function every other href in this package uses. It was the one resolver
// that percent-decoded nothing, which is how two halves of one package came to disagree the first time.
if id := strings.TrimSpace(opf.Spine.TOC); id != "" {
for _, it := range opf.Manifest {
if it.ID == id {
return path.Clean(path.Join(opfDir, it.Href))
doc, _ := hrefTarget(opfDir, it.Href)
return doc
}
}
}
for _, it := range opf.Manifest {
if strings.Contains(strings.ToLower(it.MediaType), "dtbncx") {
return path.Clean(path.Join(opfDir, it.Href))
doc, _ := hrefTarget(opfDir, it.Href)
return doc
}
}
return ""

View file

@ -672,3 +672,36 @@ func TestEPUB3LandmarkWithAFragmentDoesNotDeleteTheDocument(t *testing.T) {
doc.ServiceRolesInsideDocuments)
}
}
// TestNavLabelIsTrimmedLikeItsSiblings pins the answer to be the same whichever table the book carries.
//
// ⛔ Real books indent their nav. Untrimmed, one title arrives as "\n Chapter One\n " through a
// nav and as "Chapter One" through an NCX — the same chapter, two answers, decided by which table the
// publisher happened to ship. It goes on the wire as title_raw, so a reader renders the whitespace.
// The pack's own fixtures could not catch it: they write labels inline, with no indentation to trim.
func TestNavLabelIsTrimmedLikeItsSiblings(t *testing.T) {
indented := []byte(`<html xmlns:epub="http://www.idpf.org/2007/ops"><body>
<nav epub:type="toc">
<ol>
<li>
<a href="c1.xhtml">
Chapter One
</a>
</li>
</ol>
</nav>
</body></html>`)
_, navLabels, _, _ := parseNavDoc(indented, "OEBPS")
ncx := []byte(`<ncx><navMap><navPoint><navLabel><text>
Chapter One
</text></navLabel><content src="c1.xhtml"/></navPoint></navMap></ncx>`)
_, ncxLabels := parseNCX(ncx, "OEBPS")
const key, want = "OEBPS/c1.xhtml", "Chapter One"
if navLabels[key] != want {
t.Fatalf("nav label = %q, want %q", navLabels[key], want)
}
if navLabels[key] != ncxLabels[key] {
t.Fatalf("one title, two answers: nav %q vs ncx %q", navLabels[key], ncxLabels[key])
}
}

View file

@ -30,18 +30,18 @@ import (
// 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:
// epub is a text extraction (02-mvp:25): read the chapters the book's own table of contents declares —
// nav (EPUB 3) or NCX (EPUB 2), falling back to the spine only when neither resolves — 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.
// chapterSep is the ASCII form feed (U+000C), the "page/section break". It is a SEPARATOR the file
// carries, NOT a statement that a chapter begins — see the Structure constants. It is invisible in prose
// and untouched by text.NormalizeSource, and it draws the boundaries only when no header line does
// (cutTXT). A txt with neither is a single chapter. epub has its own table of contents and never uses this.
const chapterSep = "\f"
// RubyReading is one captured (base, reading) pair and the chapter it appeared in.
@ -291,7 +291,8 @@ func cutTXT(decoded string, st *lang.SourceStructure) (chapters []string, witnes
// legitimate subtitle → merged chapters).
const chapterHeaderMaxRunes = 60
// isCJKChapterHeader reports whether a single line is a chapter header for the given unit rune. It
// isChapterHeader reports whether a single line is a chapter header for the given unit rune, under the
// book's resolved source structure. 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-
@ -341,7 +342,8 @@ func detectChapterUnit(lines []string, st *lang.SourceStructure) rune {
return 0
}
best, bestN := rune(0), 0
// The section markers (章节節回) are shared lang.CJKSection data (pair-14 addendum-A); iterated in AUTHORED
// The section units come from the book's RESOLVED source structure (lang.SourceStructure — its own
// <lang>/structure.txt, else the embedded CJK default); 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

View file

@ -400,7 +400,8 @@ func denseFrom(doc *chunk.Document, keptIdx []int) denseChapters {
return out
}
// buildManifest projects a fresh split into the document. chapterTexts is index-aligned to chapter
// buildManifest projects a fresh split into the document. ch holds the chapters that took a number and
// their source titles, index-aligned to chapter
// numbers (chunk.SplitChunksWithChapters), chunks is the split itself. Pure and deterministic.
func (r *Runner) buildManifest(ch denseChapters, chunks []chunk.Chunk, structure string, src sourceFingerprint) *BookManifest {
m := &BookManifest{
@ -452,8 +453,12 @@ func (r *Runner) buildManifest(ch denseChapters, chunks []chunk.Chunk, structure
"book", r.Book.BookID, "chapter", u.Chapter, "chapter_texts", len(ids))
}
title := ""
if u.Chapter-1 < len(ch.titles) {
title = ch.titles[u.Chapter-1] // Number is dense and 1-based, so it indexes the dense slice
// The SAME guard the chapter-id lookup six lines above carries, and for the same reason: that
// branch exists because dense 1-based numbering might one day stop holding, and it degrades with
// a WARN. Without the lower bound this line turns that warned degradation into an index-out-of-
// range panic on a paid run.
if k := u.Chapter - 1; k >= 0 && k < len(ch.titles) {
title = ch.titles[k]
}
m.Chapters = append(m.Chapters, ManifestChapter{ID: id, Number: u.Chapter, Heading: headings[u.Chapter], TitleRaw: title})
i = len(m.Chapters) - 1