diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index a5a616bf..db56f6b7 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -22,6 +22,10 @@ struct BlockStyle { int16_t textIndent = 0; bool textIndentDefined = false; // true if text-indent was explicitly set in CSS bool textAlignDefined = false; // true if text-align was explicitly set in CSS + // Set when this block was created by a
element. Used by startNewTextBlock to inject + // a full line-height gap when the
block stays empty (section-break use case). + // NOT propagated through getCombinedBlockStyle so it can't leak into sibling blocks. + bool fromBrElement = false; // Combined horizontal insets (margin + padding) [[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; } @@ -58,6 +62,9 @@ struct BlockStyle { combinedBlockStyle.alignment = alignment; combinedBlockStyle.textAlignDefined = textAlignDefined; } + // fromBrElement is never propagated — it is consumed by startNewTextBlock + // when the empty
block is merged with the following paragraph. + combinedBlockStyle.fromBrElement = false; return combinedBlockStyle; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b0721607..e9e46fc4 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -133,7 +133,17 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { // Merge with existing block style to accumulate CSS styling from parent block elements. // This handles cases like

text

where the // div's margin should be preserved, even though it has no direct text content. - currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle)); + + BlockStyle incoming = blockStyle; + if (currentTextBlock->getBlockStyle().fromBrElement) { + // The empty block was created by a
section separator. Inject a full line of + // blank space before the following paragraph so the scene/section break is visible. + // This only fires when the
block stayed empty (i.e. no inline text was added). + const int16_t lineHeight = static_cast(renderer.getLineHeight(fontId) * lineCompression + 0.5f); + incoming.marginTop = static_cast(incoming.marginTop + lineHeight); + } + + currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(incoming)); if (!pendingAnchorId.empty()) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); @@ -587,7 +597,13 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // flush word preceding
to currentTextBlock before calling startNewTextBlock self->flushPartWordBuffer(); } - self->startNewTextBlock(self->currentTextBlock->getBlockStyle()); + // Tag the new block so startNewTextBlock can inject a full line-height gap if + // the block remains empty (i.e.
is a section separator between paragraphs). + // If the block gets text added before the next block opens it becomes non-empty, + // goes through makePages() normally, and the flag has no effect (inline
case). + BlockStyle brStyle = self->currentTextBlock->getBlockStyle(); + brStyle.fromBrElement = true; + self->startNewTextBlock(brStyle); } else { self->currentCssStyle = cssStyle; self->startNewTextBlock(userAlignmentBlockStyle); diff --git a/scripts/generate_br_section_break_epub.py b/scripts/generate_br_section_break_epub.py new file mode 100644 index 00000000..a22d4462 --- /dev/null +++ b/scripts/generate_br_section_break_epub.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Generate a test EPUB for
section-break rendering. + +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. + +Cases covered: + 1. Standalone
between paragraphs (section break — must show gap). + 2.
with a CSS class (calibre-style section break). + 3. Multiple consecutive
elements (each adds one line of spacing). + 4. Inline
inside a

(line break only — no extra gap). + 5.
at start of chapter (no gap before first paragraph). + 6.
following a heading. + +Visual verification instructions are embedded as the first paragraph of each +chapter so a human tester can confirm the expected result on device. +""" + +import os +import zipfile +from pathlib import Path + +OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs" +OUTPUT_PATH = OUTPUT_DIR / "test_br_section_break.epub" + +FILLER = ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod " + "tempor incididunt ut labore et dolore magna aliqua." +) + +CSS = """\ +body { margin: 0; padding: 0; } +p { margin-top: 1pt; margin-bottom: 0; text-indent: 1em; text-align: justify; } +h1 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; } +h2 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; } +.section-br { display: block; } +""" + +def xhtml(title, body): + return f"""\ + + + + + {title} + + + +{body} + +""" + + +# --------------------------------------------------------------------------- +# Chapter 1 — standalone
between paragraphs +# --------------------------------------------------------------------------- +ch1 = xhtml("Ch1: Standalone br", f""" +

Ch 1: Standalone <br> Section Break

+

PASS: A visible blank-line gap should appear between the two sections below.

+

{FILLER}

+
+

{FILLER}

+

PASS: The gap above should be roughly one line tall (same as a blank line).

+""") + +# --------------------------------------------------------------------------- +# Chapter 2 —
CSS-classed section break (calibre style) +# --------------------------------------------------------------------------- +ch2 = xhtml("Ch2: Classed br", f""" +

Ch 2: <br class="section-br"/>

+

PASS: A blank-line gap should appear between the two sections below, identical +to Ch 1, even though the <br> carries a CSS class.

+

{FILLER}

+
+

{FILLER}

+""") + +# --------------------------------------------------------------------------- +# Chapter 3 — multiple consecutive
elements +# --------------------------------------------------------------------------- +ch3 = xhtml("Ch3: Multiple br", f""" +

Ch 3: Multiple Consecutive <br> Elements

+

PASS: Two blank lines should appear between the sections (one per <br>).

+

{FILLER}

+
+
+

{FILLER}

+

PASS: Three blank lines should appear below.

+

{FILLER}

+
+
+
+

{FILLER}

+""") + +# --------------------------------------------------------------------------- +# Chapter 4 — inline
inside a paragraph (line break, NOT a gap) +# --------------------------------------------------------------------------- +ch4 = xhtml("Ch4: Inline br", """ +

Ch 4: Inline <br> Inside a Paragraph

+

PASS: The two lines below should be adjacent with NO extra gap between them. +The <br> is inside the paragraph and must only break the line.

+

First line of the paragraph.
Second line of the paragraph — directly below, no gap.

+

PASS: Above should look like two closely-spaced lines, not like two paragraphs +separated by a blank line.

+""") + +# --------------------------------------------------------------------------- +# Chapter 5 —
following a heading +# --------------------------------------------------------------------------- +ch5 = xhtml("Ch5: br after heading", f""" +

Ch 5: <br> After a Heading

+
+

PASS: There should be a blank-line gap between the heading above and this paragraph.

+

{FILLER}

+

Section heading

+
+

PASS: There should be a blank-line gap between the section heading and this paragraph.

+""") + +# --------------------------------------------------------------------------- +# Chapter 6 —
at very start of chapter (no spurious leading gap) +# --------------------------------------------------------------------------- +ch6 = xhtml("Ch6: br at chapter start", f"""
+

Ch 6: <br> at Chapter Start

+

PASS: This heading should appear near the top of the page with no large blank +area above it despite the <br> being the very first element.

+

{FILLER}

+""") + +CHAPTERS = [ + ("ch1", "chapter1.xhtml", "Chapter 1: Standalone br", ch1), + ("ch2", "chapter2.xhtml", "Chapter 2: Classed br", ch2), + ("ch3", "chapter3.xhtml", "Chapter 3: Multiple br", ch3), + ("ch4", "chapter4.xhtml", "Chapter 4: Inline br", ch4), + ("ch5", "chapter5.xhtml", "Chapter 5: br after heading", ch5), + ("ch6", "chapter6.xhtml", "Chapter 6: br at start", ch6), +] + +def build_epub(path): + os.makedirs(os.path.dirname(path), exist_ok=True) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as epub: + # mimetype must be first and uncompressed + epub.writestr("mimetype", "application/epub+zip", + compress_type=zipfile.ZIP_STORED) + + epub.writestr("META-INF/container.xml", """\ + + + + + +""") + + epub.writestr("OEBPS/styles/test.css", CSS) + + manifest_items = [] + spine_items = [] + nav_items = [] + + for (chid, chfile, chtitle, chcontent) in CHAPTERS: + epub.writestr(f"OEBPS/{chfile}", chcontent) + manifest_items.append( + f' ') + spine_items.append(f' ') + nav_items.append(f'
  • {chtitle}
  • ') + + manifest_items.append( + ' ') + + content_opf = f"""\ + + + + test-epub-br-section-break + Test: br Section Break + en + + +{chr(10).join(manifest_items)} + + +{chr(10).join(spine_items)} + +""" + epub.writestr("OEBPS/content.opf", content_opf) + + nav_xhtml = f"""\ + + +Table of Contents + + + +""" + epub.writestr("OEBPS/nav.xhtml", nav_xhtml) + + print(f"Generated: {path}") + + +if __name__ == "__main__": + build_epub(OUTPUT_PATH) diff --git a/test/epubs/test_br_section_break.epub b/test/epubs/test_br_section_break.epub new file mode 100644 index 00000000..8d4a9301 Binary files /dev/null and b/test/epubs/test_br_section_break.epub differ