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:
parent
5f4c6d8754
commit
fde85307d4
8 changed files with 473 additions and 28 deletions
|
|
@ -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 предъявлена не таблицей глазами, а машинным набором, и на нём строится мигратор,
|
||||
если он понадобится. Это и было содержанием заказа.
|
||||
|
||||
|
|
|
|||
275
backend/docs/CHAPTER_STRUCTURE_SHIFTMAP.json
Normal file
275
backend/docs/CHAPTER_STRUCTURE_SHIFTMAP.json
Normal 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
|
||||
}
|
||||
]
|
||||
29
backend/docs/chapter_structure_shiftmap.py
Normal file
29
backend/docs/chapter_structure_shiftmap.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue