From 552b2683e6a69ace80a57180eed9bcb6abb9b545 Mon Sep 17 00:00:00 2001 From: Jan Steinke Date: Mon, 13 Jul 2026 11:06:43 +0200 Subject: [PATCH] fix: keep list item bullet inline with nested paragraph text (#2589) --- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 21 ++++++ lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 1 + scripts/generate_br_section_break_epub.py | 66 +++++++++++++++++- test/epubs/test_br_section_break.epub | Bin 4568 -> 6710 bytes 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index c6c5723a..76a6901d 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -225,6 +225,7 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() { currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues); partWordBufferIndex = 0; nextWordContinues = false; + listItemBulletOnly = false; } // start a new text block if needed @@ -254,6 +255,17 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { return; } + //
  • added a bullet as the first word, making the block non-empty. When a nested + // block-level child (

    ,

    , etc.) opens, reuse the block instead of flushing + // the bullet to its own line. The bullet stays inline with the child's text. + if (listItemBulletOnly) { + const auto style = currentTextBlock->getBlockStyle(); + currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical)); + listItemBulletOnly = false; + flushPendingAnchor(); + return; + } + makePages(); } // If the pending anchor is a TOC chapter boundary, force a page break after the previous @@ -261,6 +273,7 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { flushPendingAnchor(); currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle)); wordsExtractedInBlock = 0; + listItemBulletOnly = false; } void ChapterHtmlSlimParser::emitHorizontalRule(const BlockStyle& blockStyle) { @@ -880,6 +893,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (strcmp(name, "li") == 0) { self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); + self->listItemBulletOnly = true; } } } else if (matches(name, UNDERLINE_TAGS, std::size(UNDERLINE_TAGS))) { @@ -1286,6 +1300,13 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n } self->blockStyleStack.pop_back(); } + + //
  • closes: if the bullet never got inline text (empty
  • or
  • with only + // block children that were flushed), clear the flag so the next sibling doesn't + // merge into this block. + if (strcmp(name, "li") == 0) { + self->listItemBulletOnly = false; + } } } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 0571a7a4..9cc01e69 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -80,6 +80,7 @@ class ChapterHtmlSlimParser { int tableDepth = 0; int tableRowIndex = 0; int tableColIndex = 0; + bool listItemBulletOnly = false; // true when currentTextBlock has only the
  • bullet // Anchor-to-page mapping: tracks which page each HTML id attribute lands on int completedPageCount = 0; diff --git a/scripts/generate_br_section_break_epub.py b/scripts/generate_br_section_break_epub.py index a22d4462..e9428515 100644 --- a/scripts/generate_br_section_break_epub.py +++ b/scripts/generate_br_section_break_epub.py @@ -6,6 +6,9 @@ Tests that a bare
    element between paragraphs produces a visible blank-line gap (section separator), while a
    inside a paragraph only produces a line break with no extra spacing. +Also tests that
  • containing

    renders the bullet inline with the paragraph +text, not on a separate line (GitHub issue #956). + Cases covered: 1. Standalone
    between paragraphs (section break — must show gap). 2.
    with a CSS class (calibre-style section break). @@ -13,6 +16,9 @@ Cases covered: 4. Inline
    inside a

    (line break only — no extra gap). 5.
    at start of chapter (no gap before first paragraph). 6.
    following a heading. + 7.

  • with bold+italic text (bullet must be inline with text). + 8.

  • with nested