From 1509af872916534a3e7f72a49c0eff0db7dcdb98 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 5 Apr 2026 10:47:05 +0200 Subject: [PATCH] Avoid hypehnation at page breaks --- lib/Epub/Epub/ParsedText.cpp | 104 +++++++++++++++--- lib/Epub/Epub/ParsedText.h | 31 ++++-- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 23 +++- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 3 +- 4 files changed, 132 insertions(+), 29 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 5219c57e..496d6908 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -1,6 +1,7 @@ #include "ParsedText.h" #include +#include #include #include @@ -90,9 +91,10 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, } // Consumes data to minimize memory usage -void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth, - const std::function)>& processLine, - const bool includeLastLine) { +void ParsedText::layoutAndExtractLines( + const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth, + const std::function, bool, bool)>& processLine, + const bool includeLastLine) { if (words.empty()) { return; } @@ -104,16 +106,57 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo auto wordWidths = calculateWordWidths(renderer, fontId); std::vector lineBreakIndices; + std::vector lineEndsWithHyphenatedWord; + std::vector splitPrefixWordIndexes; + std::vector splitInsertedHyphen; if (hyphenationEnabled) { // Use greedy layout that can split words mid-loop when a hyphenated prefix fits. - lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues); + lineBreakIndices = + computeHyphenatedLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues, lineEndsWithHyphenatedWord, + splitPrefixWordIndexes, splitInsertedHyphen); } else { lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues); + lineEndsWithHyphenatedWord.assign(lineBreakIndices.size(), false); + splitPrefixWordIndexes.assign(lineBreakIndices.size(), -1); + splitInsertedHyphen.assign(lineBreakIndices.size(), false); } - const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; + size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; for (size_t i = 0; i < lineCount; ++i) { - extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId); + const bool lineEndedWithHyphenation = i < lineEndsWithHyphenatedWord.size() ? lineEndsWithHyphenatedWord[i] : false; + const auto result = extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, + fontId, lineEndedWithHyphenation, false); + + if (result == LineProcessResult::RetryWithoutHyphenation && lineEndedWithHyphenation) { + LOG_DBG("PTX", "Line %u requested rerender without hyphenation", static_cast(i)); + // Undo the split used to end this line so it can be relaid without hyphenation. + const int splitPrefixIndex = i < splitPrefixWordIndexes.size() ? splitPrefixWordIndexes[i] : -1; + if (splitPrefixIndex >= 0 && static_cast(splitPrefixIndex + 1) < words.size()) { + std::string merged = words[splitPrefixIndex]; + if (i < splitInsertedHyphen.size() && splitInsertedHyphen[i] && !merged.empty() && merged.back() == '-') { + merged.pop_back(); + } + merged += words[splitPrefixIndex + 1]; + words[splitPrefixIndex] = std::move(merged); + words.erase(words.begin() + splitPrefixIndex + 1); + wordStyles.erase(wordStyles.begin() + splitPrefixIndex + 1); + wordContinues.erase(wordContinues.begin() + splitPrefixIndex + 1); + } + + // Re-layout remaining output without hyphenation for this pass. + wordWidths = calculateWordWidths(renderer, fontId); + lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues); + lineEndsWithHyphenatedWord.assign(lineBreakIndices.size(), false); + splitPrefixWordIndexes.assign(lineBreakIndices.size(), -1); + splitInsertedHyphen.assign(lineBreakIndices.size(), false); + lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; + + if (i < lineCount) { + LOG_DBG("PTX", "Rerendering line %u with hyphenation suppressed", static_cast(i)); + extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId, false, + true); + } + } } // Remove consumed words so size() reflects only remaining words @@ -279,7 +322,10 @@ void ParsedText::applyParagraphIndent() { // Builds break indices while opportunistically splitting the word that would overflow the current line. std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth, std::vector& wordWidths, - std::vector& continuesVec) { + std::vector& continuesVec, + std::vector& lineEndsWithHyphenatedWord, + std::vector& splitPrefixWordIndexes, + std::vector& splitInsertedHyphen) { // Calculate first line indent (only for left/justified text). // Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on. // Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies — @@ -306,12 +352,18 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r } std::vector lineBreakIndices; + lineEndsWithHyphenatedWord.clear(); + splitPrefixWordIndexes.clear(); + splitInsertedHyphen.clear(); size_t currentIndex = 0; bool isFirstLine = true; while (currentIndex < wordWidths.size()) { const size_t lineStart = currentIndex; int lineWidth = 0; + bool lineEndedWithHyphenation = false; + int splitPrefixIndex = -1; + bool splitNeedsInsertedHyphen = false; // First line has reduced width due to text-indent const int effectivePageWidth = isFirstLine ? pageWidth - firstLineIndent : pageWidth; @@ -333,11 +385,15 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r const int availableWidth = effectivePageWidth - lineWidth - spacing; const bool allowFallbackBreaks = isFirstWord; // Only for first word on line - if (availableWidth > 0 && - hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) { + bool insertedHyphen = false; + if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, + allowFallbackBreaks, &insertedHyphen)) { // Keep interWordGaps in sync: insert placeholder for the new remainder word. // The remainder is always the first word on the next line so this slot is never read. interWordGaps.insert(interWordGaps.begin() + currentIndex + 1, 0); + lineEndedWithHyphenation = true; + splitPrefixIndex = static_cast(currentIndex); + splitNeedsInsertedHyphen = insertedHyphen; // Prefix now fits; append it to this line and move to next line lineWidth += spacing + wordWidths[currentIndex]; ++currentIndex; @@ -358,7 +414,17 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r --currentIndex; } + if (lineEndedWithHyphenation && + (splitPrefixIndex < static_cast(lineStart) || splitPrefixIndex >= static_cast(currentIndex))) { + lineEndedWithHyphenation = false; + splitPrefixIndex = -1; + splitNeedsInsertedHyphen = false; + } + lineBreakIndices.push_back(currentIndex); + lineEndsWithHyphenatedWord.push_back(lineEndedWithHyphenation); + splitPrefixWordIndexes.push_back(splitPrefixIndex); + splitInsertedHyphen.push_back(splitNeedsInsertedHyphen); isFirstLine = false; } @@ -369,7 +435,7 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r // available width. bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer, const int fontId, std::vector& wordWidths, - const bool allowFallbackBreaks) { + const bool allowFallbackBreaks, bool* outInsertedHyphen) { // Guard against invalid indices or zero available width before attempting to split. if (availableWidth <= 0 || wordIndex >= words.size()) { return false; @@ -448,13 +514,18 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl wordWidths[wordIndex] = static_cast(chosenWidth); const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style); wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth); + if (outInsertedHyphen) { + *outInsertedHyphen = chosenNeedsHyphen; + } return true; } -void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::vector& wordWidths, - const std::vector& continuesVec, const std::vector& lineBreakIndices, - const std::function)>& processLine, - const GfxRenderer& renderer, const int fontId) { +ParsedText::LineProcessResult ParsedText::extractLine( + const size_t breakIndex, const int pageWidth, const std::vector& wordWidths, + const std::vector& continuesVec, const std::vector& lineBreakIndices, + const std::function, bool, bool)>& processLine, + const GfxRenderer& renderer, const int fontId, const bool lineEndsWithHyphenatedWord, + const bool suppressHyphenationRetry) { const size_t lineBreak = lineBreakIndices[breakIndex]; const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0; const size_t lineWordCount = lineBreak - lastBreakAt; @@ -552,6 +623,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } } - processLine( - std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle)); + return processLine( + std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle), + lineEndsWithHyphenatedWord, suppressHyphenationRetry); } diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 9d43400e..6546e2be 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -13,6 +13,13 @@ class GfxRenderer; class ParsedText { + public: + enum class LineProcessResult { + Accepted, + RetryWithoutHyphenation, + }; + + private: std::vector words; std::vector wordStyles; std::vector wordContinues; // true = word attaches to previous (no space before it) @@ -24,13 +31,18 @@ class ParsedText { std::vector computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, std::vector& wordWidths, std::vector& continuesVec); std::vector computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, - std::vector& wordWidths, std::vector& continuesVec); + std::vector& wordWidths, std::vector& continuesVec, + std::vector& lineEndsWithHyphenatedWord, + std::vector& splitPrefixWordIndexes, + std::vector& splitInsertedHyphen); bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, - std::vector& wordWidths, bool allowFallbackBreaks); - void extractLine(size_t breakIndex, int pageWidth, const std::vector& wordWidths, - const std::vector& continuesVec, const std::vector& lineBreakIndices, - const std::function)>& processLine, const GfxRenderer& renderer, - int fontId); + std::vector& wordWidths, bool allowFallbackBreaks, + bool* outInsertedHyphen = nullptr); + LineProcessResult extractLine( + size_t breakIndex, int pageWidth, const std::vector& wordWidths, const std::vector& continuesVec, + const std::vector& lineBreakIndices, + const std::function, bool, bool)>& processLine, + const GfxRenderer& renderer, int fontId, bool lineEndsWithHyphenatedWord, bool suppressHyphenationRetry); std::vector calculateWordWidths(const GfxRenderer& renderer, int fontId); public: @@ -44,7 +56,8 @@ class ParsedText { BlockStyle& getBlockStyle() { return blockStyle; } size_t size() const { return words.size(); } bool isEmpty() const { return words.empty(); } - void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, - const std::function)>& processLine, - bool includeLastLine = true); + void layoutAndExtractLines( + const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, + const std::function, bool, bool)>& processLine, + bool includeLastLine = true); }; \ No newline at end of file diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 4284746c..90850d8d 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1091,7 +1091,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char : self->viewportWidth; self->currentTextBlock->layoutAndExtractLines( self->renderer, self->fontId, effectiveWidth, - [self](const std::shared_ptr& textBlock) { self->addLineToPage(textBlock); }, false); + [self](const std::shared_ptr& textBlock, const bool lineEndsWithHyphenatedWord, + const bool suppressHyphenationRetry) { + return self->addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry); + }, + false); } } @@ -1371,7 +1375,9 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return true; } -void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line) { +ParsedText::LineProcessResult ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line, + const bool lineEndsWithHyphenatedWord, + const bool suppressHyphenationRetry) { const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; if (!currentPage) { @@ -1387,6 +1393,13 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line) { currentPageNextY = 0; } + const bool noRoomForAnotherLine = + currentPageNextY + lineHeight <= viewportHeight && currentPageNextY + (lineHeight * 2) > viewportHeight; + if (lineEndsWithHyphenatedWord && !suppressHyphenationRetry && noRoomForAnotherLine) { + LOG_DBG("EHP", "Requesting line rerender without hyphenation to avoid page-break split word"); + return ParsedText::LineProcessResult::RetryWithoutHyphenation; + } + // Track cumulative words to assign footnotes to the page containing their anchor wordsExtractedInBlock += line->wordCount(); auto footnoteIt = pendingFootnotes.begin(); @@ -1400,6 +1413,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line) { const int16_t xOffset = line->getBlockStyle().leftInset(); currentPage->elements.push_back(std::make_shared(line, xOffset, currentPageNextY)); currentPageNextY += lineHeight; + return ParsedText::LineProcessResult::Accepted; } void ChapterHtmlSlimParser::makePages() { @@ -1431,7 +1445,10 @@ void ChapterHtmlSlimParser::makePages() { currentTextBlock->layoutAndExtractLines( renderer, fontId, effectiveWidth, - [this](const std::shared_ptr& textBlock) { addLineToPage(textBlock); }); + [this](const std::shared_ptr& textBlock, const bool lineEndsWithHyphenatedWord, + const bool suppressHyphenationRetry) { + return addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry); + }); // Fallback: transfer any remaining pending footnotes to current page. // Normally addLineToPage handles this via word-index tracking, but this catches diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 092f5737..ecda8da1 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -151,7 +151,8 @@ class ChapterHtmlSlimParser { ~ChapterHtmlSlimParser() = default; bool parseAndBuildPages(); - void addLineToPage(std::shared_ptr line); + ParsedText::LineProcessResult addLineToPage(std::shared_ptr line, bool lineEndsWithHyphenatedWord, + bool suppressHyphenationRetry); const std::vector>& getAnchors() const { return anchorData; } const std::vector& getParagraphIndexPerPage() const { return paragraphIndexPerPage; } };