From 44ff313740b37bcc98376c2bb4a412cca2a5260c Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 6 Jul 2026 20:10:31 +0300 Subject: [PATCH] fix: render
between paragraphs as a visible section break (#2548) Co-authored-by: Brooks Ilg --- lib/Epub/Epub/blocks/BlockStyle.h | 8 + .../Epub/parsers/ChapterHtmlSlimParser.cpp | 20 +- scripts/generate_br_section_break_epub.py | 210 ++++++++++++++++++ test/epubs/test_br_section_break.epub | Bin 0 -> 4568 bytes 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 scripts/generate_br_section_break_epub.py create mode 100644 test/epubs/test_br_section_break.epub diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index fbc18d42..08bcfd62 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -32,6 +32,11 @@ struct BlockStyle { bool isRtl = false; // true if resolved direction is RTL bool directionDefined = false; // true if direction was explicitly set in CSS/HTML + // 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 insets (margin + padding) [[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; } [[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; } @@ -92,6 +97,9 @@ struct BlockStyle { result.directionDefined = true; } + // fromBrElement is consumed by startNewTextBlock when an empty
block + // is merged with the following paragraph; never propagate it further. + result.fromBrElement = false; return result; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b04159e7..7263de4a 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -239,7 +239,16 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { // open. Merge those into the new style so the first child in a container inherits // the container's vertical spacing. const auto style = currentTextBlock->getBlockStyle(); - currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical)); + BlockStyle incoming = blockStyle; + if (style.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(style.getCombinedBlockStyle(incoming, BlockStyle::CombineAxis::Vertical)); flushPendingAnchor(); return; @@ -855,7 +864,14 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // flush word preceding
to currentTextBlock before calling startNewTextBlock self->flushPartWordBuffer(); } - self->startNewTextBlock(self->blockStyleStack.back().withoutBottom()); + // 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 ? self->currentTextBlock->getBlockStyle() : self->blockStyleStack.back(); + brStyle.fromBrElement = true; + self->startNewTextBlock(brStyle); } else { self->currentCssStyle = cssStyle; const auto accumulated = self->blockStyleStack.back().getCombinedBlockStyle(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 0000000000000000000000000000000000000000..5d7711662c2e76424a0d7be2f274cb83e223e8e7 GIT binary patch literal 4568 zcmZ{oc{EgiAIE3x3?XDovZRDD_FZ-*Cj0WUFO%I^Mn5AvgRI#yRMTP?vKLCol6~JZ zWGS*0F?P@N)RSXQ&z*D6o%_ewK2?)xAkf!U*_z^*lpbz-?a${(Sp+Q{ZoR zXLlF^=?Sy-^mKE!w?#O^J%nMNzIFos&YrrzBPsqHDbXfXQHGD81Ofo(@J}6eLuH{G zPz_;wxCg@4*#qVc_H%bLO6a6&g3zhsg4EH)mHm$+a>JgQlWq~)$rd*v_{0kqr`hYi ziPV7{*J_6M`tN$Ramn)0bb14c+P&xU2R#*6W0;DT&0rbHUJ>CR0oR3qZCOWd${C@84Y=qz0vkhdvE<7>FG@ZoW5+{!*7lgzey(i z^Om}bu7R)*0_g_x5k|m#5MX;BpW^tNXPO|?q_{^-%ud7HWjCNH;#@2n2KKz%%~M`P zX(19;X7ClHYzEc86fV9jq}pf8Mai<3_wCa|9p{L11Z{eFUnyOF&bTKu&Q8M;AKxq` z`G#SkzicN!D57m5HLphC+EUZ4J89MugMLoQ)6Y#h9vog1v@ZkM+Lf1g*|b|30Vnbn z26h-uul-HBN?0s@-6KT5@cvr6y_2mc0_H6O_H#nGyLId9Bo0F8-r`K;y-h=#Th%O_ zq}pA~W+Ixkgvb-1^1Q|za$_mEhdAxb)zIJue(%qLannA#eizO|8B=KLJ7Nx0$u`^} zBmWw0Z+=@nGT1hScSu%NtLz^5)d(XVLndcQy^t$70q?z`^M*>uemOPvo!C)!sngyH#kui6uw2)nC*QI zjAwbfB-9u&!MP5TP|4HGR@ClMO5|CJj`ZW(n87soDE)IOh1vqWsUL6Bz0NfNkjr%r zp#-b(cJ>i}zgtPfeC|8rvv>}7mrKF-D@3&&A^_zEY+=Ab2-W!B2%9TkFPa4^#Z5bF zlnGhvadmP$*530)@f9B1Cz;nr1;ra&xm=5yptD2QVl7JzeY+o3`S?9_P+uf@JL1Lh z*8Eb=b`{*&6Zs?JiPY5!4v%rFIm|K4-d7z%S6lJuO&#toIJ6HAbNy; zCdN;mP+m2b*^!(y;=8eK*0#_}JCmKX{q_)3d6g3!-X@T7;fvHTnvhenbA4FLpT^-w<-IMFXBnc* zon_~et@L0Ft5sMEq_8-}TF+)+N*~y;Ouy56$}%OjetE|#;I3Ev*5KT3Kr;^;yr~m9 zKqo84jq}<#cl&%huXwYV9soyz;lb9z_;;k?p9D$o6n|$=pgRZx`M+J78`_eZWOG!` zlHix=fJi^?b!~V)G$+*a3hflazA4_I!O2i=j}G;VHI63Y!<>!t&FU}vn4Vrm zKc6eF2FkT%tN;~P^gu&JMBX@OD@2cuL^L8A8~kv0Md9>EhYrW;lC0aZFjnlwrDMQp zYPJI&NPj%F3F5z`oY-$_28{`W5XLt+0WwbGi^|$n%o950DlnN)Q|HdmEQV`rpf<_v z&fLShR@J2w3}39=c9GaFrvr=W3z~_V9W5<=`Mz6Lp(>okpeT*N>EIxo@>KPUz2Q7~ z-C^Rs9JRc^j4*q)M5+c&m-CA)`QG6G%a(w?Ai69LjQ_*TmpoHA^&|2iOP(2i5 zxe);#-LtT!!=kJ|%(ENq%okAOx#g+F+sT%%-PB_dJv@l&(?-3>DN@&ZQuP%)VNiE= zsp`Y094t6e<*<1z$#Y>hx6_2YzVUHh5)rel>CIr5^=pdqZe>q+WZ})J(ph1m&F$vs z)DI4bpX6vsxM256XMCw6)OJbt9~O=*-P^j^CEk=H8eiJq7B|asn>Q%-sGXJJ7Hezm zyq0hxU;_#UnK=V$3gY_1oV5p5RMv|s3~aQi;j+)jzh`7ZGF2fE>>;Mh8e}=ryG%NU z>@mZn9zCo~+=S)&@tbvgdy;3R;kTO=Ou(w?Skfp7¬B87BUVm3wld$0US0ay^I& z8ig@_(RjyHe^R-xqyg+2k{!hXdst;Ckjz!)8!)N5LW$$evddl#!B8h(+x?zrQItpy z5@vb+y@WCLKnqaY`l{78$8T)fGKgXMs(|7Yc6ue){atu&_=0p`h`FU8ijU1$KVc4? zXzk4@TVo1N-$09VJL^>$k?G#&iXDzWstx1pVT;m@Qt25t{9;H8bm!;RIhUou3lyg@ zrE217=hFbn&dix{QSvPC3$gM#>g(;_DHJ6WW+8)Df^LrbzFu6+6zi{~psK!_56)Ni z!G+z8a;Y0%H7*^c5cj_xPw!G06p?>e;VuIhRxBXnXmq;%zW46TYFHSQ#v+HlALuMU zZ^pKH#+4hL(FJ4aanmh9z4zh$)&mV-GCaJIw20)28npYDuXJ|4`P$L7i^5+ozTl9= zkO$_6ynA=7ZBzSQ0(65=7P_j>7W3 zY(T4P1ItP=I|EflCBa{!r(J`t)CcSp91#@BI~b8Get3kze?uto3t>*O@~w|Qi{w5& z-=x#f?nd`4lP{_%D;N?vrxR;lzF_w=-y&9y?zXy_7|}kGR?k4L2a073nbh?+vgf*m z!<)-xnM3MnC-$DvCr;K?fV_smUsJ4cQj>X6<`8k&AYodY7rpTiH7}tBe7YTEQ90l5 z9Co?%uR2f6xik-EfP|U%7#97+b@6PzV-rbEd(l4^GSxp91&2nI&}VF)sZizxRwj;7 zG;y>mIs*AVZR&ARiW%hyW?ZIis0H~?gVFCC_#i)q`eEr%^4f@H+Sg&zn)0z@Nio-| zX-6M+vsIHZs7Ts+v&lO6en}0MncJl!W2(^*drmIu_mS9;8Eg7g!o)o?Kbb8#CbtlF z@ZpHzY=VtFMx(D0MKXRipMHt`N2B8krSt4~WxOQr62s$#+Zx?uN^%P#p7IGv-6J?- za|LABy#^7CqlT;+Pn^p)s?>^?DSL8ZyI2pFCfpJ3d(C#v>gI9ileOM984Fa>f~G(D z>B4wL`t`S9JSpYBk&^sHN=LsFpKNq*_64ft=Z9h>h)Akg8 zjZ00`D8pQ1Fpg=-%?}k4P)fIk$2= zY9zZet3q4g=cjPHx3l+M?2c_`OVKt%g*tWVZSQSCn)V<=Z~}46y_CVH`YLH$LnvyZ z7LxA>1wIMxt_Rc+6i`@+e_qqF7R>PJ-cEuIrT7B}%9lLH$fdHvA-;EQq) z6FzKwE7XR&`lFb_MWG=ItN74(xqZok79z)e`ocFssNmDUZmDJpy5fP|CIJBG{|vrA zM|+qD0u1-Oos+8L5suF*oD6Ego%GE8Y$hUZ-+qv|LWn)B4QZUKb~3j9^JU&=DuvA+ zMN;bzr5-Fe1q*=?WR|g7tz$l;ETIle%%ZX1M?bwszTbCX;_rZp6FbzwIOaaGFrD?T zv9M?~`f4o8bL4-hbv12P4(#L=&p1sZ9TqOm&Ggj%BSk_AlZqZ?q_>MxFO3X)e__9R z)IyvRFh^pv*+*f3wK%QfTY_2`ts!xhmy(3$m;A?U=rUzD${&nLmpeJ^Bls`MnA%4t zumx`O{ZP!@i!6KhRMd3^#(Pl?DgTN+X;SMj$S`IojJyJ(dq>Y~%{JBb`egD|;t8LZ zHT{&zj0{1^OtP%6L`T-4-kZU$FC0#Ao5>qzwO*MptL_Nya)&Qo7egS3m5# z19D2nz{SmvI3rR^7@c^Yu5mf|U8jX#E=;@s4bq=brW&<~H9%i(f07=CbQTkK|B^E$ zv`Z(+{r+Xr0nuqPfg>U}F8uCWw13EW*xvb-Me#{G9`7&WxhS?o2kJa}H$stu%F(h! z4F3Q~w~CwvI`DaU@6VRTFsJ=X`?*_+IFWOWnOI7KWYtYQCO}vy!@P1rH|avZm{yxtSDVgSq?k}gc{Z+HHTfQYu)Ecx+O4U;AKk_5FZ^^~TK7MBNEqtJQK>Ec~R_Qb^i*b zcvKGX{R2qENc{ipFaA9LdxhW+_{0pKME%|R{)zzrLV&XPzkfHvgc^j-@