Skip to content

fix(epub): preserve <hr> scene separators in Plain Text Mode (#254) - #256

Merged
hydropix merged 1 commit into
mainfrom
fix/issue-254-plain-text-hr-separators
Aug 9, 2026
Merged

fix(epub): preserve <hr> scene separators in Plain Text Mode (#254)#256
hydropix merged 1 commit into
mainfrom
fix/issue-254-plain-text-hr-separators

Conversation

@hydropix

@hydropix hydropix commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Fixes #254.

Problem

In Plain Text Mode an EPUB body is destructured into a flat list of plain-text blocks and rebuilt exclusively from that list. <hr> was in none of the tag sets driving collection (BLOCK_TAGS, CONTAINER_TAGS, DROP_TAGS), so it fell through to the generic tail branch of _collect_blocks, which only keeps a child that has text or images — <hr> has neither. It was never collected, so the rebuild could not re-emit it.

The reporter measured 416 <hr> in the source and 0 in the output, with no textual substitute.

Fix

<hr> becomes a void block: collected with an empty string, it keeps its own slot in the paragraph list and is re-emitted as a bare <hr/> at its original position.

  • New VOID_BLOCK_TAGS = ("hr",) constant, kept separate from BLOCK_TAGS — the latter's entries carry translatable text and can anchor images, a void block carries neither. Keeping the sets distinct keeps each branch's invariant readable.
  • The collection branch lives in _collect_blocks, the recursive function, so an <hr> nested in a <div>/<section> works too.
  • Attributes are dropped, as they are everywhere else in Plain Text Mode. No textual substitute (***, ---) is inserted — the element is restored as a real <hr/> and readers already render it.

Zero extra LLM calls. build_plain_segments already skips whitespace-only paragraphs and _reassemble already restores those slots from source, so a void block is never sent to the model and cannot shift alignment. This is why the design is a void block rather than a sentinel string. The invariant is asserted by a test rather than assumed.

Bilingual mode emits exactly one <hr/> and no source twin: a void block has no source text, so the existing bilingual and source_text guard is already false. No second guard was added.

Only src/core/epub/plain_extractor.py changes: 19 insertions, 3 deletions. plain_text_pipeline.py, plain_text_checkpoint.py and the DOCX extractor are untouched.

Known consequence

Adding a slot per <hr> changes paragraph_count, so a checkpoint written by an older version for a partially translated file is rejected by resume_plain_segments and that single file restarts. This is the designed behavior of that guard, it logs plain_text_resume_ignored, and it costs at most one file of re-translation.

A chapter containing only <hr/> elements now yields count > 0 where it previously yielded 0, so it takes the rebuild path instead of the keep-source-verbatim path. The output is a body of bare <hr/> elements, which is exactly the source content. No regression.

Tests

New tests/unit/epub/test_plain_text_hr_separators.py:

  • test_hr_is_collected_as_void_block<p>A</p><hr/><p>B</p> extracts to (["A", "", "B"], ["p", "hr", "p"], {})
  • test_hr_nested_in_div_is_collected — same triple when wrapped in a <div>
  • test_hr_is_reemitted_at_its_position — rebuild produces p, hr, p, the hr bare
  • test_hr_is_never_sent_to_the_llm — no segment covers the void index
  • test_hr_survives_bilingual_rebuild — one hr, no plain-text-source twin for it
  • test_hr_attributes_are_dropped<hr class="scene"/> comes out bare
  • test_hr_survives_the_full_plain_text_epub_pipeline — real in-memory EPUB through translate_epub_file with a recording stub LLM; asserts the output <hr> count equals the input's (2, one nested in a <div>) and that the stub received no empty or whitespace-only request

The end-to-end test was run both ways: it fails before the fix (expected 2 <hr> in the translated chapter, got 0) and passes after it.

Full suite: 2046 passed, 1 skipped, 23 deselected, 1 xfailed. The xfail is a pre-existing unrelated one.

Documentation

Not updated. There is no Plain Text Mode limitation list in README.md or docs/ to extend, and inventing a new documentation section was out of scope.

Out of scope

src/core/docx/plain_extractor.py has its own paragraph model and is not touched. No other silently dropped void block-level element surfaced during the audit — <br> is already folded to a space in _extract_text_keep_inline, <img> is already anchored to its enclosing paragraph.

🤖 Generated with Claude Code

Plain Text Mode destructures an EPUB body into a flat list of plain-text
blocks and rebuilds the body exclusively from that list. `<hr>` was in
none of the tag sets driving collection, so it was never collected and
the rebuild could not re-emit it: the reporter measured 416 `<hr>` in the
source and 0 in the output, with no textual substitute.

`<hr>` becomes a void block: collected with an empty string, it keeps its
own slot in the paragraph list and is re-emitted as a bare `<hr/>` at its
original position. Because `build_plain_segments` already skips
whitespace-only paragraphs and `_reassemble` already restores those slots
from source, this costs zero extra LLM calls and cannot shift alignment.

The new `VOID_BLOCK_TAGS` constant is kept separate from `BLOCK_TAGS`,
whose entries carry translatable text and can anchor images; a void block
carries neither. The collection branch lives in `_collect_blocks` so an
`<hr>` nested in a `<div>`/`<section>` works too. Attributes are dropped,
as they are everywhere else in Plain Text Mode.

Adding a slot per `<hr>` changes `paragraph_count`, so a checkpoint
written by an older version for a partially translated file is rejected
by `resume_plain_segments` and that single file restarts. This is the
designed behavior of that guard and costs at most one file.

Tests cover collection (body-level and nested), re-emission at position,
the invariant that the void index never reaches the LLM, bilingual mode
emitting one `<hr/>` and no source twin, attribute dropping, and a full
`translate_epub_file` round trip asserting the output `<hr>` count equals
the input's. The end-to-end test was verified to fail before the fix.

Documentation was left untouched: there is no Plain Text Mode limitation
list in README.md or docs/ to extend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hydropix
hydropix merged commit aa1383d into main Aug 9, 2026
1 check passed
@hydropix
hydropix deleted the fix/issue-254-plain-text-hr-separators branch August 9, 2026 12:12
@hydropix
hydropix restored the fix/issue-254-plain-text-hr-separators branch August 9, 2026 12:12
@hydropix
hydropix deleted the fix/issue-254-plain-text-hr-separators branch August 9, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plain Text Mode silently drops <hr> scene separators (EPUB)

1 participant