From 44ff313740b37bcc98376c2bb4a412cca2a5260c Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 6 Jul 2026 20:10:31 +0300 Subject: [PATCH 1/8] 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-@ Date: Mon, 6 Jul 2026 23:13:27 +0300 Subject: [PATCH 2/8] chore: Replace product link with affiliate tracking link (#2401) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c938b6df..ddf70792 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f ![CrossPoint Reader running on Xteink device](./docs/images/cover.jpg) +> If you're planning to buy an Xteink device, consider purchasing an **X3/X4 Developer Edition** through https://crosspointreader.com. CrossPoint receives a small share of each sale, helping fund development costs. + ## What can CrossPoint do? - **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more. From 6f5c5a0900ec10e4fa7bbf69a18c4e63183cb9a0 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 6 Jul 2026 16:41:00 -0400 Subject: [PATCH 3/8] fix: Flatten TextBlock word storage into single allocation (#2547) --- docs/file-formats.md | 30 ++-- lib/Epub/Epub/Page.cpp | 18 +- lib/Epub/Epub/ParsedText.cpp | 19 ++- lib/Epub/Epub/Section.cpp | 17 +- lib/Epub/Epub/blocks/TextBlock.cpp | 253 +++++++++++++++++++++-------- lib/Epub/Epub/blocks/TextBlock.h | 97 +++++++---- 6 files changed, 314 insertions(+), 120 deletions(-) diff --git a/docs/file-formats.md b/docs/file-formats.md index eec9474f..4289f7e1 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -90,13 +90,13 @@ if (parsedSize != fileSize) { ## `section.bin` -### Version 28 +### Version 29 Each file in `sections/*.bin` stores one laid-out spine section. The header is also the cache-busting key: if any layout-affecting setting differs from the current reader settings, the section is discarded and rebuilt. -Version 28 includes: +Version 29 includes: - cache-busting fields for paragraph alignment, hyphenation, embedded CSS, image rendering mode, and Focus Reading @@ -107,6 +107,10 @@ Version 28 includes: - per-page footnote entries - serialized word style bits for underline, strikethrough, superscript, and subscript +- flat TextBlock word storage (v29): per-word arrays plus one shared + NUL-terminated text blob, replacing v28's length-prefixed word strings. The + on-disk order mirrors the in-RAM arena so the firmware reads a whole block + payload with a single allocation and a single SD read ImHex pattern: @@ -115,7 +119,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 28 +#define EXPECTED_VERSION 29 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 96 @@ -176,14 +180,20 @@ struct BlockStyle { struct TextBlock { u16 wordCount; - String words[wordCount]; - s16 wordXPos[wordCount]; - WordStyle wordStyle[wordCount]; - u8 hasFocus; - if (hasFocus != 0) { - u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]]; - u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]]; + u16 textBytes [[comment("Total size of text[], including one NUL per word")]]; + + if (wordCount > 0) { + u16 textOff[wordCount] [[comment("Byte offset of word i's text within text[]")]]; + s16 wordXPos[wordCount]; + if (hasFocus != 0) { + u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]]; + } + WordStyle wordStyle[wordCount]; + if (hasFocus != 0) { + u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]]; + } + char text[textBytes] [[comment("All words back to back, each NUL-terminated")]]; } BlockStyle blockStyle; diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 5032056c..25c1f512 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -39,7 +39,17 @@ std::unique_ptr PageLine::deserialize(HalFile& file) { serialization::readPod(file, yPos); auto tb = TextBlock::deserialize(file); - return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); + if (!tb) { + LOG_ERR("PGE", "Deserialization failed: null TextBlock"); + return nullptr; + } + + auto* line = new (std::nothrow) PageLine(std::move(tb), xPos, yPos); + if (!line) { + LOG_ERR("PGE", "Deserialization failed: could not allocate PageLine"); + return nullptr; + } + return std::unique_ptr(line); } void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { @@ -155,9 +165,15 @@ std::unique_ptr Page::deserialize(HalFile& file) { if (tag == TAG_PageLine) { auto pl = PageLine::deserialize(file); + if (!pl) { + return nullptr; + } page->elements.push_back(std::move(pl)); } else if (tag == TAG_PageImage) { auto pi = PageImage::deserialize(file); + if (!pi) { + return nullptr; + } page->elements.push_back(std::move(pi)); } else if (tag == TAG_PageHorizontalRule) { auto rule = PageHorizontalRule::deserialize(file); diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index ebe758db..2ae204a4 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -1133,8 +1134,14 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } if (!lineHasFocusSplit) { - processLine(std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), - std::vector{}, std::vector{}, blockStyle)); + // TextBlock flattens the vectors into its arena; they stay owned here and die at return. + auto block = std::make_shared(lineWords, lineXPos, lineWordStyles, std::vector{}, + std::vector{}, blockStyle); + if (!block->valid()) { + LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed"); + return; + } + processLine(std::move(block)); return; } @@ -1179,6 +1186,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } } - processLine(std::make_shared(std::move(outWords), std::move(outXPos), std::move(outStyles), - std::move(outBoundaries), std::move(outSuffixX), blockStyle)); + auto block = std::make_shared(outWords, outXPos, outStyles, outBoundaries, outSuffixX, blockStyle); + if (!block->valid()) { + LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed"); + return; + } + processLine(std::move(block)); } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 2de11f58..663d086b 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -11,8 +11,9 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -// v28: text decoration bits now include line-through in serialized wordStyles. -constexpr uint8_t SECTION_FILE_VERSION = 28; +// v29: TextBlock word data stored as one flat arena (offset table + NUL-terminated +// text blob) instead of length-prefixed strings and per-field arrays. +constexpr uint8_t SECTION_FILE_VERSION = 29; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / // crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects @@ -25,7 +26,11 @@ constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0; // rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION, // so finalized files are untouched by this feature; older firmware treats the sentinel // as an unknown version and rebuilds, which is a safe downgrade. -constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE; +// MUST change in lockstep with SECTION_FILE_VERSION: the sentinel IS the partial's +// format version, so a stale-format partial otherwise passes the header check and +// only fails (noisily, via the block-decode error path) when a page is loaded. +// Derived so the pairing can't be forgotten: 0xFE for v28, 0xFD for v29, ... +constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE - (SECTION_FILE_VERSION - 28); constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + @@ -733,10 +738,10 @@ std::string Section::getTextFromSectionFile() { if (el->getTag() == TAG_PageLine) { const auto& line = static_cast(*el); if (line.getBlock()) { - const auto& words = line.getBlock()->getWords(); - for (const auto& w : words) { + const auto& block = *line.getBlock(); + for (uint16_t i = 0; i < block.wordCount(); i++) { if (!fullText.empty()) fullText += " "; - fullText += w; + fullText += block.wordText(i); } } } diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index 3d5f920b..c90467d6 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -3,19 +3,114 @@ #include #include #include +#include #include #include -void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { +size_t TextBlock::arenaSize(const uint16_t wordCount, const bool hasFocus, const uint16_t textBytes) { + // Layout documented in TextBlock.h: 16-bit arrays first, then 8-bit arrays, then text. + size_t size = static_cast(wordCount) * (sizeof(uint16_t) + sizeof(int16_t) + sizeof(uint8_t)); + if (hasFocus) { + size += static_cast(wordCount) * (sizeof(uint16_t) + sizeof(uint8_t)); + } + return size + textBytes; +} + +void TextBlock::bindArenaPointers() { + uint8_t* base = arena.get(); + const size_t wc = numWords; + textOffArr = reinterpret_cast(base); + xposArr = reinterpret_cast(base + wc * 2); + size_t off = wc * 4; + if (focusPresent) { + focusSuffixXArr = reinterpret_cast(base + off); + off += wc * 2; + } + stylesArr = base + off; + off += wc; + if (focusPresent) { + focusBoundaryArr = base + off; + off += wc; + } + textArr = reinterpret_cast(base + off); +} + +TextBlock::TextBlock(const std::vector& words, const std::vector& wordXpos, + const std::vector& wordStyles, const std::vector& focusBoundary, + const std::vector& focusSuffixX, const BlockStyle& blockStyle) + : blockStyle(blockStyle) { // Focus annotations are optional: empty vectors mean no word in this block has a split. // When present, they must be sized in lockstep with words[]. - const bool hasFocus = !wordFocusBoundary.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) { - LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n", - (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(), - (uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size()); + const bool hasFocus = !focusBoundary.empty(); + if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || words.size() > 10000 || + (hasFocus && (words.size() != focusBoundary.size() || words.size() != focusSuffixX.size()))) { + LOG_ERR("TXB", "Construction failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)", + static_cast(words.size()), static_cast(wordXpos.size()), + static_cast(wordStyles.size()), static_cast(focusBoundary.size()), + static_cast(focusSuffixX.size())); + isValid = false; + return; + } + + numWords = static_cast(words.size()); + focusPresent = hasFocus; + if (numWords == 0) { + return; // valid empty block, no arena + } + + // Pass 1: total text size, one NUL per word. A line is at most a physical + // row of the page, so uint16_t offsets are ample; reject anything larger. + size_t totalText = 0; + for (const auto& w : words) totalText += w.size() + 1; + if (totalText > UINT16_MAX) { + LOG_ERR("TXB", "Construction failed: text size %u exceeds arena limit", static_cast(totalText)); + numWords = 0; + focusPresent = false; + isValid = false; + return; + } + textBytes = static_cast(totalText); + + const size_t size = arenaSize(numWords, focusPresent, textBytes); + arena = makeUniqueNoThrow(size); + if (!arena) { + LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); + numWords = 0; + textBytes = 0; + focusPresent = false; + isValid = false; + return; + } + bindArenaPointers(); + + // Pass 2: fill. Mutable aliases of the const views bound above. + auto* textOff = const_cast(textOffArr); + auto* xpos = const_cast(xposArr); + auto* styles = const_cast(stylesArr); + auto* text = const_cast(textArr); + uint16_t off = 0; + for (uint16_t i = 0; i < numWords; i++) { + textOff[i] = off; + xpos[i] = wordXpos[i]; + styles[i] = static_cast(wordStyles[i]); + memcpy(text + off, words[i].data(), words[i].size()); + off += static_cast(words[i].size()); + text[off++] = '\0'; + } + if (focusPresent) { + auto* suffixX = const_cast(focusSuffixXArr); + auto* boundary = const_cast(focusBoundaryArr); + for (uint16_t i = 0; i < numWords; i++) { + suffixX[i] = focusSuffixX[i]; + boundary[i] = focusBoundary[i]; + } + } +} + +void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { + if (!isValid) { + LOG_ERR("TXB", "Render skipped: invalid block"); return; } @@ -54,12 +149,13 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } }; - for (size_t i = 0; i < words.size(); i++) { - const int wordX = wordXpos[i] + x; - const EpdFontFamily::Style currentStyle = wordStyles[i]; - const auto baseDir = static_cast( - BidiUtils::detectParagraphLevel(words[i].c_str(), blockStyle.isRtl ? 1 : 0)); - const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0; + for (uint16_t i = 0; i < numWords; i++) { + const char* word = wordText(i); + const int wordX = xposArr[i] + x; + const EpdFontFamily::Style currentStyle = wordStyle(i); + const auto baseDir = + static_cast(BidiUtils::detectParagraphLevel(word, blockStyle.isRtl ? 1 : 0)); + const uint8_t boundary = focusBoundary(i); // SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside // drawText, so these offsets are chosen relative to the full-size ascender: @@ -82,14 +178,15 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES, "boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)"); const auto boldStyle = static_cast(currentStyle | EpdFontFamily::BOLD); - const size_t boldLen = std::min({static_cast(boundary), words[i].size(), sizeof(boldBuf) - 1}); - memcpy(boldBuf, words[i].c_str(), boldLen); + const size_t boldLen = + std::min({static_cast(boundary), static_cast(wordTextLen(i)), sizeof(boldBuf) - 1}); + memcpy(boldBuf, word, boldLen); boldBuf[boldLen] = '\0'; renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir); - const int suffixX = wordX + wordFocusSuffixX[i]; - renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle, baseDir); + const int suffixX = wordX + focusSuffixXArr[i]; + renderer.drawText(fontId, suffixX, wordY, word + boldLen, true, currentStyle, baseDir); } else { - renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir); + renderer.drawText(fontId, wordX, wordY, word, true, currentStyle, baseDir); } if (scanning) { @@ -97,18 +194,17 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } if (EpdFontFamily::hasTextDecoration(currentStyle)) { - const std::string& w = words[i]; int lineStartX = wordX; - int lineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir); + int lineWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir); if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { lineWidth = (lineWidth + 1) / 2; } // Do not decorate the synthetic em-space used for paragraph indentation. - if (w.size() >= 3 && static_cast(w[0]) == 0xE2 && static_cast(w[1]) == 0x80 && - static_cast(w[2]) == 0x83) { - const char* visibleText = w.c_str() + 3; + if (wordTextLen(i) >= 3 && static_cast(word[0]) == 0xE2 && static_cast(word[1]) == 0x80 && + static_cast(word[2]) == 0x83) { + const char* visibleText = word + 3; lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir); if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { @@ -140,29 +236,23 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } bool TextBlock::serialize(HalFile& file) const { - // Focus annotations are optional; vectors are either empty (no splits in this block) - // or sized in lockstep with words[]. - const bool hasFocus = !wordFocusBoundary.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) { - LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n", - static_cast(words.size()), static_cast(wordXpos.size()), - static_cast(wordStyles.size()), static_cast(wordFocusBoundary.size()), - static_cast(wordFocusSuffixX.size())); + if (!isValid) { + LOG_ERR("TXB", "Serialization failed: invalid block"); return false; } - // Word data - serialization::writePod(file, static_cast(words.size())); - for (const auto& w : words) serialization::writeString(file, w); - for (auto x : wordXpos) serialization::writePod(file, x); - for (auto s : wordStyles) serialization::writePod(file, s); - // Focus block: 1-byte presence flag, followed by per-word vectors only when present. - // Saves 3 bytes/word when focus reading is disabled or no word on this line was split. - serialization::writePod(file, static_cast(hasFocus ? 1 : 0)); - if (hasFocus) { - for (auto b : wordFocusBoundary) serialization::writePod(file, b); - for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx); + // Word data: scalars, then the arena verbatim -- its in-memory layout is + // exactly the on-disk layout (see TextBlock.h), so one write covers all + // per-word arrays and the text blob. + serialization::writePod(file, numWords); + serialization::writePod(file, static_cast(focusPresent ? 1 : 0)); + serialization::writePod(file, textBytes); + if (numWords > 0) { + const size_t size = arenaSize(numWords, focusPresent, textBytes); + if (file.write(arena.get(), size) != size) { + LOG_ERR("TXB", "Serialization failed: arena write (%u bytes)", static_cast(size)); + return false; + } } // Style (alignment + margins/padding/indent) @@ -186,41 +276,64 @@ bool TextBlock::serialize(HalFile& file) const { std::unique_ptr TextBlock::deserialize(HalFile& file) { uint16_t wc; - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - std::vector wordFocusBoundary; - std::vector wordFocusSuffixX; - BlockStyle blockStyle; - - // Word count + uint8_t hasFocus; + uint16_t textBytes; serialization::readPod(file, wc); + serialization::readPod(file, hasFocus); + serialization::readPod(file, textBytes); - // Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block) + // Sanity checks: cap the arena allocation and reject impossible geometry + // (every word carries at least its NUL terminator). if (wc > 10000) { LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc); return nullptr; } + if ((wc == 0 && textBytes != 0) || (wc > 0 && textBytes < wc)) { + LOG_ERR("TXB", "Deserialization failed: bad text size %u for %u words", textBytes, wc); + return nullptr; + } - // Word data - words.resize(wc); - wordXpos.resize(wc); - wordStyles.resize(wc); - for (auto& w : words) serialization::readString(file, w); - for (auto& x : wordXpos) serialization::readPod(file, x); - for (auto& s : wordStyles) serialization::readPod(file, s); - // Focus block: presence flag, then vectors only if present. Empty vectors when absent - // signal "no splits in this block" to render() (zero per-word RAM cost). - uint8_t hasFocus; - serialization::readPod(file, hasFocus); - if (hasFocus) { - wordFocusBoundary.resize(wc); - wordFocusSuffixX.resize(wc); - for (auto& b : wordFocusBoundary) serialization::readPod(file, b); - for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx); + std::unique_ptr block(new (std::nothrow) TextBlock()); + if (!block) { + LOG_ERR("TXB", "OOM: TextBlock"); + return nullptr; + } + block->numWords = wc; + block->textBytes = textBytes; + block->focusPresent = hasFocus != 0; + + if (wc > 0) { + const size_t size = arenaSize(wc, block->focusPresent, textBytes); + block->arena = makeUniqueNoThrow(size); + if (!block->arena) { + LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); + return nullptr; + } + if (file.read(block->arena.get(), size) != size) { + LOG_ERR("TXB", "Deserialization failed: arena read (%u bytes)", static_cast(size)); + return nullptr; + } + block->bindArenaPointers(); + + // Validate offsets before anything dereferences wordText(): offset 0 first, + // strictly increasing, in bounds, and every word NUL-terminated (word i ends + // at the byte before offset i+1; the last word at the last text byte). + const uint16_t* textOff = block->textOffArr; + const char* text = block->textArr; + if (textOff[0] != 0 || text[textBytes - 1] != '\0') { + LOG_ERR("TXB", "Deserialization failed: corrupt text layout"); + return nullptr; + } + for (uint16_t i = 1; i < wc; i++) { + if (textOff[i] <= textOff[i - 1] || textOff[i] >= textBytes || text[textOff[i] - 1] != '\0') { + LOG_ERR("TXB", "Deserialization failed: corrupt word offset %u", i); + return nullptr; + } + } } // Style (alignment + margins/padding/indent) + BlockStyle& blockStyle = block->blockStyle; serialization::readPod(file, blockStyle.alignment); serialization::readPod(file, blockStyle.textAlignDefined); serialization::readPod(file, blockStyle.marginTop); @@ -236,7 +349,5 @@ std::unique_ptr TextBlock::deserialize(HalFile& file) { serialization::readPod(file, blockStyle.isRtl); serialization::readPod(file, blockStyle.directionDefined); - return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), - std::move(wordFocusBoundary), std::move(wordFocusSuffixX), - blockStyle)); + return block; } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 5f4bf80e..38f24f53 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -9,42 +9,83 @@ #include "Block.h" #include "BlockStyle.h" -// Represents a line of text on a page +// Represents a line of text on a page. +// +// All per-word data lives in ONE flat heap allocation (the arena) instead of +// six parallel vectors: a resident page holds ~25-30 of these blocks, and the +// vector-of-string layout cost ~250 throwing allocations per page load, which +// was the primary driver of heap fragmentation on the ESP32-C3. +// +// Arena layout, in order (2-byte alignment holds by construction: all 16-bit +// arrays come first and the arena base is allocator-aligned; RISC-V faults on +// unaligned multi-byte access): +// uint16_t textOff[wordCount] byte offset of word i's text in text[] +// int16_t xpos[wordCount] +// uint16_t focusSuffixX[wordCount] present only when focusPresent +// uint8_t styles[wordCount] +// uint8_t focusBoundary[wordCount] present only when focusPresent +// char text[textBytes] all words back to back, NUL-terminated +// +// Each word is stored NUL-terminated so render() can hand `text + textOff[i]` +// straight to C APIs (drawText) with no std::string materialization. +// +// Focus split semantics (unchanged from the vector layout): boundary N > 0 +// means the first N bytes of word i render bold, the remainder in the base +// style. N is bounded to 9 codepoints (<= 36 UTF-8 bytes) by the clamp in +// ParsedText::addWord. focusSuffixX is the pre-computed pixel offset from the +// word start to the regular suffix. Both arrays are omitted from the arena +// entirely when no word on the line has a split (zero per-word RAM cost when +// focus reading is disabled). class TextBlock final : public Block { private: - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - // Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold, - // the remainder in the base style. 0 means no split (whole word uses wordStyles[i]). - // N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by - // FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t. - // Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost - // when focus reading is disabled, or on lines that happen to contain no splittable words). - std::vector wordFocusBoundary; - // Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0. - // Eliminates getTextAdvanceX from the render path. 0 when boundary == 0. - // Empty in lockstep with wordFocusBoundary. - std::vector wordFocusSuffixX; BlockStyle blockStyle; + uint16_t numWords = 0; + uint16_t textBytes = 0; // total size of the text region, including NULs + bool focusPresent = false; + bool isValid = true; + // The ONLY allocation: makeUniqueNoThrow, so OOM yields an invalid block + // instead of abort() (bare new is not nothrow with -fno-exceptions). + std::unique_ptr arena; + // Typed views into the arena, bound once after the arena is filled. All + // 16-bit bases sit at even offsets, so direct dereference is alignment-safe. + const uint16_t* textOffArr = nullptr; + const int16_t* xposArr = nullptr; + const uint16_t* focusSuffixXArr = nullptr; // null when !focusPresent + const uint8_t* stylesArr = nullptr; + const uint8_t* focusBoundaryArr = nullptr; // null when !focusPresent + const char* textArr = nullptr; + + TextBlock() = default; // deserialize() fills the fields directly + static size_t arenaSize(uint16_t wordCount, bool hasFocus, uint16_t textBytes); + void bindArenaPointers(); public: - explicit TextBlock(std::vector words, std::vector word_xpos, - std::vector word_styles, std::vector focus_boundary, - std::vector focus_suffix_x, const BlockStyle& blockStyle = BlockStyle()) - : words(std::move(words)), - wordXpos(std::move(word_xpos)), - wordStyles(std::move(word_styles)), - wordFocusBoundary(std::move(focus_boundary)), - wordFocusSuffixX(std::move(focus_suffix_x)), - blockStyle(blockStyle) {} + // Flatten-on-construct: copies the layout-time vectors into the arena; the + // vectors die with the caller. On arena OOM the block is empty and valid() + // is false -- callers must check and fail the line instead of using it. + explicit TextBlock(const std::vector& words, const std::vector& wordXpos, + const std::vector& wordStyles, const std::vector& focusBoundary, + const std::vector& focusSuffixX, const BlockStyle& blockStyle = BlockStyle()); ~TextBlock() override = default; + TextBlock(const TextBlock&) = delete; + TextBlock& operator=(const TextBlock&) = delete; + void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } const BlockStyle& getBlockStyle() const { return blockStyle; } - const std::vector& getWords() const { return words; } - bool isEmpty() override { return words.empty(); } - size_t wordCount() const { return words.size(); } - // given a renderer works out where to break the words into lines + bool isEmpty() override { return numWords == 0; } + bool valid() const { return isValid; } + uint16_t wordCount() const { return numWords; } + // NUL-terminated by construction; safe to pass to C APIs directly. + const char* wordText(const uint16_t i) const { return textArr + textOffArr[i]; } + uint16_t wordTextLen(const uint16_t i) const { + const uint16_t end = (i + 1 < numWords) ? textOffArr[i + 1] : textBytes; + return end - textOffArr[i] - 1; // exclude the NUL + } + int16_t wordXpos(const uint16_t i) const { return xposArr[i]; } + EpdFontFamily::Style wordStyle(const uint16_t i) const { return static_cast(stylesArr[i]); } + uint8_t focusBoundary(const uint16_t i) const { return focusPresent ? focusBoundaryArr[i] : 0; } + uint16_t focusSuffixX(const uint16_t i) const { return focusPresent ? focusSuffixXArr[i] : 0; } + void render(const GfxRenderer& renderer, int fontId, int x, int y) const; BlockType getType() override { return TEXT_BLOCK; } bool serialize(HalFile& file) const; From 3c5c5e8aa7b2d81991d1f89b90fc32e9f1e2a04c Mon Sep 17 00:00:00 2001 From: Pietro Campagnano Date: Mon, 6 Jul 2026 22:45:22 +0200 Subject: [PATCH 4/8] feat: preview image files inline in web file browser (#2429) --- src/network/html/FilesPage.html | 78 ++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 8572b3e1..23881a7e 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -169,6 +169,27 @@ .modal.picker-mode { max-width: 920px; } + .modal.image-preview-mode { + max-width: 640px; + } + .image-preview-stage { + display: flex; + justify-content: center; + align-items: center; + overflow: auto; + margin-bottom: 15px; + border-radius: 6px; + } + .image-preview-stage img { + max-width: 100%; + max-height: 65vh; + object-fit: contain; + } + #imagePreviewDownload { + display: inline-block; + width: auto; + text-decoration: none; + } .picker-columns.picker-active { display: flex; flex-direction: row; @@ -1779,6 +1800,21 @@ + + +