Avoid hypehnation at page breaks

This commit is contained in:
jpirnay
2026-04-05 10:47:05 +02:00
parent 1398454ad8
commit 1509af8729
4 changed files with 132 additions and 29 deletions
+88 -16
View File
@@ -1,6 +1,7 @@
#include "ParsedText.h" #include "ParsedText.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <Utf8.h> #include <Utf8.h>
#include <algorithm> #include <algorithm>
@@ -90,9 +91,10 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
} }
// Consumes data to minimize memory usage // Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth, void ParsedText::layoutAndExtractLines(
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
const bool includeLastLine) { const std::function<LineProcessResult(std::shared_ptr<TextBlock>, bool, bool)>& processLine,
const bool includeLastLine) {
if (words.empty()) { if (words.empty()) {
return; return;
} }
@@ -104,16 +106,57 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
auto wordWidths = calculateWordWidths(renderer, fontId); auto wordWidths = calculateWordWidths(renderer, fontId);
std::vector<size_t> lineBreakIndices; std::vector<size_t> lineBreakIndices;
std::vector<bool> lineEndsWithHyphenatedWord;
std::vector<int> splitPrefixWordIndexes;
std::vector<bool> splitInsertedHyphen;
if (hyphenationEnabled) { if (hyphenationEnabled) {
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits. // 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 { } else {
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, wordWidths, wordContinues); 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) { 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<unsigned>(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<size_t>(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<unsigned>(i));
extractLine(i, pageWidth, wordWidths, wordContinues, lineBreakIndices, processLine, renderer, fontId, false,
true);
}
}
} }
// Remove consumed words so size() reflects only remaining words // 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. // Builds break indices while opportunistically splitting the word that would overflow the current line.
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId, std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
const int pageWidth, std::vector<uint16_t>& wordWidths, const int pageWidth, std::vector<uint16_t>& wordWidths,
std::vector<bool>& continuesVec) { std::vector<bool>& continuesVec,
std::vector<bool>& lineEndsWithHyphenatedWord,
std::vector<int>& splitPrefixWordIndexes,
std::vector<bool>& splitInsertedHyphen) {
// Calculate first line indent (only for left/justified text). // Calculate first line indent (only for left/justified text).
// Positive text-indent (paragraph indent) is suppressed when extraParagraphSpacing is on. // 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 — // Negative text-indent (hanging indent, e.g. margin-left:3em; text-indent:-1em) always applies —
@@ -306,12 +352,18 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
} }
std::vector<size_t> lineBreakIndices; std::vector<size_t> lineBreakIndices;
lineEndsWithHyphenatedWord.clear();
splitPrefixWordIndexes.clear();
splitInsertedHyphen.clear();
size_t currentIndex = 0; size_t currentIndex = 0;
bool isFirstLine = true; bool isFirstLine = true;
while (currentIndex < wordWidths.size()) { while (currentIndex < wordWidths.size()) {
const size_t lineStart = currentIndex; const size_t lineStart = currentIndex;
int lineWidth = 0; int lineWidth = 0;
bool lineEndedWithHyphenation = false;
int splitPrefixIndex = -1;
bool splitNeedsInsertedHyphen = false;
// First line has reduced width due to text-indent // First line has reduced width due to text-indent
const int effectivePageWidth = isFirstLine ? pageWidth - firstLineIndent : pageWidth; const int effectivePageWidth = isFirstLine ? pageWidth - firstLineIndent : pageWidth;
@@ -333,11 +385,15 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
const int availableWidth = effectivePageWidth - lineWidth - spacing; const int availableWidth = effectivePageWidth - lineWidth - spacing;
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
if (availableWidth > 0 && bool insertedHyphen = false;
hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) { if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths,
allowFallbackBreaks, &insertedHyphen)) {
// Keep interWordGaps in sync: insert placeholder for the new remainder word. // 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. // The remainder is always the first word on the next line so this slot is never read.
interWordGaps.insert(interWordGaps.begin() + currentIndex + 1, 0); interWordGaps.insert(interWordGaps.begin() + currentIndex + 1, 0);
lineEndedWithHyphenation = true;
splitPrefixIndex = static_cast<int>(currentIndex);
splitNeedsInsertedHyphen = insertedHyphen;
// Prefix now fits; append it to this line and move to next line // Prefix now fits; append it to this line and move to next line
lineWidth += spacing + wordWidths[currentIndex]; lineWidth += spacing + wordWidths[currentIndex];
++currentIndex; ++currentIndex;
@@ -358,7 +414,17 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
--currentIndex; --currentIndex;
} }
if (lineEndedWithHyphenation &&
(splitPrefixIndex < static_cast<int>(lineStart) || splitPrefixIndex >= static_cast<int>(currentIndex))) {
lineEndedWithHyphenation = false;
splitPrefixIndex = -1;
splitNeedsInsertedHyphen = false;
}
lineBreakIndices.push_back(currentIndex); lineBreakIndices.push_back(currentIndex);
lineEndsWithHyphenatedWord.push_back(lineEndedWithHyphenation);
splitPrefixWordIndexes.push_back(splitPrefixIndex);
splitInsertedHyphen.push_back(splitNeedsInsertedHyphen);
isFirstLine = false; isFirstLine = false;
} }
@@ -369,7 +435,7 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
// available width. // available width.
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer, bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
const int fontId, std::vector<uint16_t>& wordWidths, const int fontId, std::vector<uint16_t>& wordWidths,
const bool allowFallbackBreaks) { const bool allowFallbackBreaks, bool* outInsertedHyphen) {
// Guard against invalid indices or zero available width before attempting to split. // Guard against invalid indices or zero available width before attempting to split.
if (availableWidth <= 0 || wordIndex >= words.size()) { if (availableWidth <= 0 || wordIndex >= words.size()) {
return false; return false;
@@ -448,13 +514,18 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth); wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style); const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style);
wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth); wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth);
if (outInsertedHyphen) {
*outInsertedHyphen = chosenNeedsHyphen;
}
return true; return true;
} }
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::vector<uint16_t>& wordWidths, ParsedText::LineProcessResult ParsedText::extractLine(
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices, const size_t breakIndex, const int pageWidth, const std::vector<uint16_t>& wordWidths,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
const GfxRenderer& renderer, const int fontId) { const std::function<LineProcessResult(std::shared_ptr<TextBlock>, bool, bool)>& processLine,
const GfxRenderer& renderer, const int fontId, const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
const size_t lineBreak = lineBreakIndices[breakIndex]; const size_t lineBreak = lineBreakIndices[breakIndex];
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0; const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
const size_t lineWordCount = lineBreak - lastBreakAt; const size_t lineWordCount = lineBreak - lastBreakAt;
@@ -552,6 +623,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
} }
} }
processLine( return processLine(
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle)); std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle),
lineEndsWithHyphenatedWord, suppressHyphenationRetry);
} }
+22 -9
View File
@@ -13,6 +13,13 @@
class GfxRenderer; class GfxRenderer;
class ParsedText { class ParsedText {
public:
enum class LineProcessResult {
Accepted,
RetryWithoutHyphenation,
};
private:
std::vector<std::string> words; std::vector<std::string> words;
std::vector<EpdFontFamily::Style> wordStyles; std::vector<EpdFontFamily::Style> wordStyles;
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it) std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
@@ -24,13 +31,18 @@ class ParsedText {
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec); std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec); std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec,
std::vector<bool>& lineEndsWithHyphenatedWord,
std::vector<int>& splitPrefixWordIndexes,
std::vector<bool>& splitInsertedHyphen);
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId, bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks); std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks,
void extractLine(size_t breakIndex, int pageWidth, const std::vector<uint16_t>& wordWidths, bool* outInsertedHyphen = nullptr);
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices, LineProcessResult extractLine(
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer, size_t breakIndex, int pageWidth, const std::vector<uint16_t>& wordWidths, const std::vector<bool>& continuesVec,
int fontId); const std::vector<size_t>& lineBreakIndices,
const std::function<LineProcessResult(std::shared_ptr<TextBlock>, bool, bool)>& processLine,
const GfxRenderer& renderer, int fontId, bool lineEndsWithHyphenatedWord, bool suppressHyphenationRetry);
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId); std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
public: public:
@@ -44,7 +56,8 @@ class ParsedText {
BlockStyle& getBlockStyle() { return blockStyle; } BlockStyle& getBlockStyle() { return blockStyle; }
size_t size() const { return words.size(); } size_t size() const { return words.size(); }
bool isEmpty() const { return words.empty(); } bool isEmpty() const { return words.empty(); }
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, void layoutAndExtractLines(
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
bool includeLastLine = true); const std::function<LineProcessResult(std::shared_ptr<TextBlock>, bool, bool)>& processLine,
bool includeLastLine = true);
}; };
@@ -1091,7 +1091,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
: self->viewportWidth; : self->viewportWidth;
self->currentTextBlock->layoutAndExtractLines( self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, effectiveWidth, self->renderer, self->fontId, effectiveWidth,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false); [self](const std::shared_ptr<TextBlock>& textBlock, const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
return self->addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry);
},
false);
} }
} }
@@ -1371,7 +1375,9 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return true; return true;
} }
void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) { ParsedText::LineProcessResult ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line,
const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
if (!currentPage) { if (!currentPage) {
@@ -1387,6 +1393,13 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
currentPageNextY = 0; 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 // Track cumulative words to assign footnotes to the page containing their anchor
wordsExtractedInBlock += line->wordCount(); wordsExtractedInBlock += line->wordCount();
auto footnoteIt = pendingFootnotes.begin(); auto footnoteIt = pendingFootnotes.begin();
@@ -1400,6 +1413,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
const int16_t xOffset = line->getBlockStyle().leftInset(); const int16_t xOffset = line->getBlockStyle().leftInset();
currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY)); currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY));
currentPageNextY += lineHeight; currentPageNextY += lineHeight;
return ParsedText::LineProcessResult::Accepted;
} }
void ChapterHtmlSlimParser::makePages() { void ChapterHtmlSlimParser::makePages() {
@@ -1431,7 +1445,10 @@ void ChapterHtmlSlimParser::makePages() {
currentTextBlock->layoutAndExtractLines( currentTextBlock->layoutAndExtractLines(
renderer, fontId, effectiveWidth, renderer, fontId, effectiveWidth,
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); }); [this](const std::shared_ptr<TextBlock>& textBlock, const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
return addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry);
});
// Fallback: transfer any remaining pending footnotes to current page. // Fallback: transfer any remaining pending footnotes to current page.
// Normally addLineToPage handles this via word-index tracking, but this catches // Normally addLineToPage handles this via word-index tracking, but this catches
@@ -151,7 +151,8 @@ class ChapterHtmlSlimParser {
~ChapterHtmlSlimParser() = default; ~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages(); bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line); ParsedText::LineProcessResult addLineToPage(std::shared_ptr<TextBlock> line, bool lineEndsWithHyphenatedWord,
bool suppressHyphenationRetry);
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; } const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
const std::vector<uint16_t>& getParagraphIndexPerPage() const { return paragraphIndexPerPage; } const std::vector<uint16_t>& getParagraphIndexPerPage() const { return paragraphIndexPerPage; }
}; };