fix(epub): preserve <hr> scene separators in Plain Text Mode (#254) - #256
Merged
Conversation
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>
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.VOID_BLOCK_TAGS = ("hr",)constant, kept separate fromBLOCK_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._collect_blocks, the recursive function, so an<hr>nested in a<div>/<section>works too.***,---) is inserted — the element is restored as a real<hr/>and readers already render it.Zero extra LLM calls.
build_plain_segmentsalready skips whitespace-only paragraphs and_reassemblealready 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 existingbilingual and source_textguard is already false. No second guard was added.Only
src/core/epub/plain_extractor.pychanges: 19 insertions, 3 deletions.plain_text_pipeline.py,plain_text_checkpoint.pyand the DOCX extractor are untouched.Known consequence
Adding a slot per
<hr>changesparagraph_count, so a checkpoint written by an older version for a partially translated file is rejected byresume_plain_segmentsand that single file restarts. This is the designed behavior of that guard, it logsplain_text_resume_ignored, and it costs at most one file of re-translation.A chapter containing only
<hr/>elements now yieldscount > 0where it previously yielded0, 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 producesp, hr, p, thehrbaretest_hr_is_never_sent_to_the_llm— no segment covers the void indextest_hr_survives_bilingual_rebuild— onehr, noplain-text-sourcetwin for ittest_hr_attributes_are_dropped—<hr class="scene"/>comes out baretest_hr_survives_the_full_plain_text_epub_pipeline— real in-memory EPUB throughtranslate_epub_filewith 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 requestThe 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.mdordocs/to extend, and inventing a new documentation section was out of scope.Out of scope
src/core/docx/plain_extractor.pyhas 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