From 1509af872916534a3e7f72a49c0eff0db7dcdb98 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 5 Apr 2026 10:47:05 +0200 Subject: [PATCH 1/3] 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; } }; From 01503f9c8495e8fd083c4f9b47f0cfe9938be8f5 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 5 Apr 2026 11:01:40 +0200 Subject: [PATCH 2/3] Fix reflow logic --- lib/Epub/Epub/ParsedText.cpp | 151 +++++++++++++++++- lib/Epub/Epub/ParsedText.h | 6 + .../Epub/parsers/ChapterHtmlSlimParser.cpp | 25 ++- 3 files changed, 176 insertions(+), 6 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 496d6908..2d155848 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -75,6 +75,23 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style); } +std::string buildLinePreview(const std::vector& words, const std::vector& continuesVec, + const size_t start, const size_t endExclusive, const size_t maxLen = 120) { + std::string preview; + for (size_t idx = start; idx < endExclusive; ++idx) { + if (idx > start && idx < continuesVec.size() && !continuesVec[idx]) { + preview.push_back(' '); + } + preview += words[idx]; + if (preview.size() >= maxLen) { + preview.resize(maxLen); + preview += "..."; + break; + } + } + return preview; +} + } // namespace void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, @@ -128,7 +145,11 @@ void ParsedText::layoutAndExtractLines( fontId, lineEndedWithHyphenation, false); if (result == LineProcessResult::RetryWithoutHyphenation && lineEndedWithHyphenation) { - LOG_DBG("PTX", "Line %u requested rerender without hyphenation", static_cast(i)); + const size_t lineStart = i > 0 ? lineBreakIndices[i - 1] : 0; + const size_t lineEnd = i < lineBreakIndices.size() ? lineBreakIndices[i] : lineStart; + const std::string firstAttemptPreview = buildLinePreview(words, wordContinues, lineStart, lineEnd); + LOG_DBG("PTX", "Line %u requested rerender without hyphenation, first attempt: %s", static_cast(i), + firstAttemptPreview.c_str()); // 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()) { @@ -152,9 +173,42 @@ void ParsedText::layoutAndExtractLines( lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; if (i < lineCount) { - LOG_DBG("PTX", "Rerendering line %u with hyphenation suppressed", static_cast(i)); + const size_t retryLineStart = i > 0 ? lineBreakIndices[i - 1] : 0; + const size_t retryLineEnd = i < lineBreakIndices.size() ? lineBreakIndices[i] : retryLineStart; + const std::string retryPreview = buildLinePreview(words, wordContinues, retryLineStart, retryLineEnd); + LOG_DBG("PTX", "Rerendering line %u with hyphenation suppressed, retry attempt: %s", static_cast(i), + retryPreview.c_str()); extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId, false, true); + + // Continue with regular hyphenation for subsequent lines only. + const size_t resumeIndex = lineBreakIndices[i]; + std::vector suffixLineEndsWithHyphenatedWord; + std::vector suffixSplitPrefixWordIndexes; + std::vector suffixSplitInsertedHyphen; + const auto hyphenatedSuffixBreaks = computeHyphenatedLineBreaksFromIndex( + renderer, fontId, pageWidth, wordWidths, wordContinues, resumeIndex, suffixLineEndsWithHyphenatedWord, + suffixSplitPrefixWordIndexes, suffixSplitInsertedHyphen); + + lineBreakIndices.resize(i + 1); + lineEndsWithHyphenatedWord.resize(i + 1); + splitPrefixWordIndexes.resize(i + 1); + splitInsertedHyphen.resize(i + 1); + + lineEndsWithHyphenatedWord[i] = false; + splitPrefixWordIndexes[i] = -1; + splitInsertedHyphen[i] = false; + + lineBreakIndices.insert(lineBreakIndices.end(), hyphenatedSuffixBreaks.begin(), hyphenatedSuffixBreaks.end()); + lineEndsWithHyphenatedWord.insert(lineEndsWithHyphenatedWord.end(), suffixLineEndsWithHyphenatedWord.begin(), + suffixLineEndsWithHyphenatedWord.end()); + splitPrefixWordIndexes.insert(splitPrefixWordIndexes.end(), suffixSplitPrefixWordIndexes.begin(), + suffixSplitPrefixWordIndexes.end()); + splitInsertedHyphen.insert(splitInsertedHyphen.end(), suffixSplitInsertedHyphen.begin(), + suffixSplitInsertedHyphen.end()); + + lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; + LOG_DBG("PTX", "Resumed regular hyphenation after rerendered line %u", static_cast(i)); } } } @@ -431,6 +485,94 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r return lineBreakIndices; } +std::vector ParsedText::computeHyphenatedLineBreaksFromIndex( + const GfxRenderer& renderer, const int fontId, const int pageWidth, std::vector& wordWidths, + std::vector& continuesVec, const size_t startIndex, std::vector& lineEndsWithHyphenatedWord, + std::vector& splitPrefixWordIndexes, std::vector& splitInsertedHyphen) { + if (startIndex >= wordWidths.size()) { + lineEndsWithHyphenatedWord.clear(); + splitPrefixWordIndexes.clear(); + splitInsertedHyphen.clear(); + return {}; + } + + std::vector interWordGaps(wordWidths.size(), 0); + for (size_t j = 1; j < wordWidths.size(); ++j) { + if (!continuesVec[j]) { + interWordGaps[j] = + renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); + } else { + interWordGaps[j] = + renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); + } + } + + std::vector lineBreakIndices; + lineEndsWithHyphenatedWord.clear(); + splitPrefixWordIndexes.clear(); + splitInsertedHyphen.clear(); + + size_t currentIndex = startIndex; + while (currentIndex < wordWidths.size()) { + const size_t lineStart = currentIndex; + int lineWidth = 0; + bool lineEndedWithHyphenation = false; + int splitPrefixIndex = -1; + bool splitNeedsInsertedHyphen = false; + + while (currentIndex < wordWidths.size()) { + const bool isFirstWord = currentIndex == lineStart; + const int spacing = isFirstWord ? 0 : interWordGaps[currentIndex]; + const int candidateWidth = spacing + wordWidths[currentIndex]; + + if (lineWidth + candidateWidth <= pageWidth) { + lineWidth += candidateWidth; + ++currentIndex; + continue; + } + + const int availableWidth = pageWidth - lineWidth - spacing; + const bool allowFallbackBreaks = isFirstWord; + + bool insertedHyphen = false; + if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, + allowFallbackBreaks, &insertedHyphen)) { + interWordGaps.insert(interWordGaps.begin() + currentIndex + 1, 0); + lineEndedWithHyphenation = true; + splitPrefixIndex = static_cast(currentIndex); + splitNeedsInsertedHyphen = insertedHyphen; + lineWidth += spacing + wordWidths[currentIndex]; + ++currentIndex; + break; + } + + if (currentIndex == lineStart) { + lineWidth += candidateWidth; + ++currentIndex; + } + break; + } + + while (currentIndex > lineStart + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) { + --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); + } + + return lineBreakIndices; +} + // Splits words[wordIndex] into prefix (adding a hyphen only when needed) and remainder when a legal breakpoint fits the // available width. bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer, @@ -612,9 +754,8 @@ ParsedText::LineProcessResult ParsedText::extractLine( } } - // Build line data by moving from the original vectors using index range - std::vector lineWords(std::make_move_iterator(words.begin() + lastBreakAt), - std::make_move_iterator(words.begin() + lineBreak)); + // Copy line words; keep source intact so retry paths can safely inspect/merge tokens. + std::vector lineWords(words.begin() + lastBreakAt, words.begin() + lineBreak); std::vector lineWordStyles(wordStyles.begin() + lastBreakAt, wordStyles.begin() + lineBreak); for (auto& word : lineWords) { diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 6546e2be..96410752 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -35,6 +35,12 @@ class ParsedText { std::vector& lineEndsWithHyphenatedWord, std::vector& splitPrefixWordIndexes, std::vector& splitInsertedHyphen); + std::vector computeHyphenatedLineBreaksFromIndex(const GfxRenderer& renderer, int fontId, int pageWidth, + std::vector& wordWidths, + std::vector& continuesVec, size_t startIndex, + 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, bool* outInsertedHyphen = nullptr); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 90850d8d..a7982a53 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -79,6 +79,27 @@ bool isTableStructuralTag(const char* name) { return strcmp(name, "table") == 0 || strcmp(name, "tr") == 0 || strcmp(name, "td") == 0 || strcmp(name, "th") == 0; } +std::string buildTextBlockPreview(const std::shared_ptr& line, const size_t maxLen = 120) { + if (!line) { + return {}; + } + + std::string preview; + const auto& words = line->getWords(); + for (size_t i = 0; i < words.size(); ++i) { + if (i > 0) { + preview.push_back(' '); + } + preview += words[i]; + if (preview.size() >= maxLen) { + preview.resize(maxLen); + preview += "..."; + break; + } + } + return preview; +} + // Calibre sometimes injects empty

...

// spacers inside running prose. Keep them as paragraph boundaries, but ignore // their inner text payload (usually NBSP) to avoid no-break-space glue artifacts. @@ -1396,7 +1417,9 @@ ParsedText::LineProcessResult ChapterHtmlSlimParser::addLineToPage(std::shared_p 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"); + const std::string linePreview = buildTextBlockPreview(line); + LOG_DBG("EHP", "Requesting line rerender without hyphenation to avoid page-break split word: %s", + linePreview.c_str()); return ParsedText::LineProcessResult::RetryWithoutHyphenation; } From 65474d40662e2e01ad689839e8f179e5ad97c819 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Sun, 5 Apr 2026 11:18:19 +0200 Subject: [PATCH 3/3] Fix alignment issues --- lib/Epub/Epub/ParsedText.cpp | 131 ++++++++++++++++++++++++++++------- lib/Epub/Epub/ParsedText.h | 7 ++ 2 files changed, 112 insertions(+), 26 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 2d155848..3c2964ca 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "hyphenation/Hyphenator.h" @@ -77,6 +78,8 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s std::string buildLinePreview(const std::vector& words, const std::vector& continuesVec, const size_t start, const size_t endExclusive, const size_t maxLen = 120) { + // Build a readable line preview while preserving continuation semantics + // (no synthetic spaces before attached tokens). std::string preview; for (size_t idx = start; idx < endExclusive; ++idx) { if (idx > start && idx < continuesVec.size() && !continuesVec[idx]) { @@ -150,26 +153,55 @@ void ParsedText::layoutAndExtractLines( const std::string firstAttemptPreview = buildLinePreview(words, wordContinues, lineStart, lineEnd); LOG_DBG("PTX", "Line %u requested rerender without hyphenation, first attempt: %s", static_cast(i), firstAttemptPreview.c_str()); - // 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() == '-') { + // Undo precomputed splits from this line onward so the retry starts from + // clean, unsplit tokens and cannot inherit future hyphenation artifacts. + std::set> splitIndexesToUndo; + for (size_t lineIdx = i; lineIdx < splitPrefixWordIndexes.size(); ++lineIdx) { + const int splitIndex = splitPrefixWordIndexes[lineIdx]; + if (splitIndex >= 0) { + splitIndexesToUndo.insert(splitIndex); + } + } + for (const int splitIndex : splitIndexesToUndo) { + if (splitIndex < 0 || static_cast(splitIndex + 1) >= words.size()) { + continue; + } + bool removeInsertedHyphen = false; + for (size_t lineIdx = i; lineIdx < splitPrefixWordIndexes.size(); ++lineIdx) { + if (splitPrefixWordIndexes[lineIdx] == splitIndex && lineIdx < splitInsertedHyphen.size()) { + removeInsertedHyphen = splitInsertedHyphen[lineIdx]; + break; + } + } + + std::string merged = words[splitIndex]; + if (removeInsertedHyphen && !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); + merged += words[splitIndex + 1]; + words[splitIndex] = std::move(merged); + words.erase(words.begin() + splitIndex + 1); + wordStyles.erase(wordStyles.begin() + splitIndex + 1); + wordContinues.erase(wordContinues.begin() + splitIndex + 1); } - // Re-layout remaining output without hyphenation for this pass. + // Recompute widths after restoring unsplit words. 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); + + // Keep previous lines fixed; recompute only this specific line without hyphenation. + // Suppression is intentionally line-local. + const size_t retryBreak = + computeSingleLineBreakNoHyphen(renderer, fontId, pageWidth, wordWidths, wordContinues, lineStart); + + lineBreakIndices.resize(i + 1); + lineEndsWithHyphenatedWord.resize(i + 1); + splitPrefixWordIndexes.resize(i + 1); + splitInsertedHyphen.resize(i + 1); + + lineBreakIndices[i] = retryBreak; + lineEndsWithHyphenatedWord[i] = false; + splitPrefixWordIndexes[i] = -1; + splitInsertedHyphen[i] = false; lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; if (i < lineCount) { @@ -181,7 +213,7 @@ void ParsedText::layoutAndExtractLines( extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId, false, true); - // Continue with regular hyphenation for subsequent lines only. + // Resume regular hyphenation from the first word after the retried line. const size_t resumeIndex = lineBreakIndices[i]; std::vector suffixLineEndsWithHyphenatedWord; std::vector suffixSplitPrefixWordIndexes; @@ -190,15 +222,6 @@ void ParsedText::layoutAndExtractLines( renderer, fontId, pageWidth, wordWidths, wordContinues, resumeIndex, suffixLineEndsWithHyphenatedWord, suffixSplitPrefixWordIndexes, suffixSplitInsertedHyphen); - lineBreakIndices.resize(i + 1); - lineEndsWithHyphenatedWord.resize(i + 1); - splitPrefixWordIndexes.resize(i + 1); - splitInsertedHyphen.resize(i + 1); - - lineEndsWithHyphenatedWord[i] = false; - splitPrefixWordIndexes[i] = -1; - splitInsertedHyphen[i] = false; - lineBreakIndices.insert(lineBreakIndices.end(), hyphenatedSuffixBreaks.begin(), hyphenatedSuffixBreaks.end()); lineEndsWithHyphenatedWord.insert(lineEndsWithHyphenatedWord.end(), suffixLineEndsWithHyphenatedWord.begin(), suffixLineEndsWithHyphenatedWord.end()); @@ -359,6 +382,58 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c return lineBreakIndices; } +size_t ParsedText::computeSingleLineBreakNoHyphen(const GfxRenderer& renderer, const int fontId, const int pageWidth, + const std::vector& wordWidths, + const std::vector& continuesVec, + const size_t lineStartIndex) const { + // One-line non-hyphenating breaker used by the page-boundary retry path. + if (lineStartIndex >= wordWidths.size()) { + return lineStartIndex; + } + + const int firstLineIndent = + lineStartIndex == 0 && blockStyle.textIndentDefined && (blockStyle.textIndent < 0 || !extraParagraphSpacing) && + (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) + ? blockStyle.textIndent + : 0; + const int effectivePageWidth = pageWidth - firstLineIndent; + + size_t currentIndex = lineStartIndex; + int lineWidth = 0; + + while (currentIndex < wordWidths.size()) { + const bool isFirstWord = currentIndex == lineStartIndex; + int spacing = 0; + if (!isFirstWord) { + if (!continuesVec[currentIndex]) { + spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]), + firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]); + } else { + spacing = renderer.getKerning(fontId, lastCodepoint(words[currentIndex - 1]), + firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]); + } + } + + const int candidateWidth = spacing + wordWidths[currentIndex]; + if (lineWidth + candidateWidth <= effectivePageWidth) { + lineWidth += candidateWidth; + ++currentIndex; + continue; + } + + if (currentIndex == lineStartIndex) { + ++currentIndex; + } + break; + } + + while (currentIndex > lineStartIndex + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) { + --currentIndex; + } + + return currentIndex; +} + void ParsedText::applyParagraphIndent() { if (extraParagraphSpacing || words.empty()) { return; @@ -489,6 +564,7 @@ std::vector ParsedText::computeHyphenatedLineBreaksFromIndex( const GfxRenderer& renderer, const int fontId, const int pageWidth, std::vector& wordWidths, std::vector& continuesVec, const size_t startIndex, std::vector& lineEndsWithHyphenatedWord, std::vector& splitPrefixWordIndexes, std::vector& splitInsertedHyphen) { + // Same greedy hyphenating breaker as the full pass, but scoped to a suffix. if (startIndex >= wordWidths.size()) { lineEndsWithHyphenatedWord.clear(); splitPrefixWordIndexes.clear(); @@ -707,7 +783,10 @@ ParsedText::LineProcessResult ParsedText::extractLine( // Calculate spacing (account for indent reducing effective page width on first line) const int effectivePageWidth = pageWidth - firstLineIndent; - const bool isLastLine = breakIndex == lineBreakIndices.size() - 1; + // A line is only truly last when it consumes all paragraph words. + // During single-line retry we may temporarily pass a truncated break vector, + // so relying only on breakIndex would incorrectly disable justification. + const bool isLastLine = lineBreak == words.size(); // For justified text, compute per-gap extra to distribute remaining space evenly const int spareSpace = effectivePageWidth - lineWordWidthSum - totalNaturalGaps; diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 96410752..6aa7e3cc 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -35,12 +35,19 @@ class ParsedText { std::vector& lineEndsWithHyphenatedWord, std::vector& splitPrefixWordIndexes, std::vector& splitInsertedHyphen); + // Recompute hyphenated breaks for a suffix that starts at startIndex. + // Used after a single-line retry so later lines keep normal hyphenation. std::vector computeHyphenatedLineBreaksFromIndex(const GfxRenderer& renderer, int fontId, int pageWidth, std::vector& wordWidths, std::vector& continuesVec, size_t startIndex, std::vector& lineEndsWithHyphenatedWord, std::vector& splitPrefixWordIndexes, std::vector& splitInsertedHyphen); + // Compute exactly one line break without hyphenating words. + // Used only for the page-boundary retry line. + size_t computeSingleLineBreakNoHyphen(const GfxRenderer& renderer, int fontId, int pageWidth, + const std::vector& wordWidths, const std::vector& continuesVec, + size_t lineStartIndex) const; bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, std::vector& wordWidths, bool allowFallbackBreaks, bool* outInsertedHyphen = nullptr);