View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0017896 | Scribus | Text Frames / Story Editor | public | 2026-07-28 14:07 | 2026-07-28 14:07 |
| Reporter | hugowett | Assigned To | |||
| Priority | normal | Severity | major | Reproducibility | always |
| Status | new | Resolution | open | ||
| Product Version | 1.7.4.svn | ||||
| Summary | 0017896: Fix widow/orphan handling as per 10779 | ||||
| Description | The keep settings were only enforced at frame boundaries (adjustParagraphEndings() / moveLinesFromPreviousFrame()), never at column breaks inside a frame — which is where users hit this (see also 0016514, 0015729). Changes: 1. PageItem_TextFrame::layout(): apply keepLinesStart/keepTogether/keepWithNext when advancing to the next column, and keepLinesEnd once a paragraph spanning a column boundary completes. Affected lines are removed and re-laid at the top of the next column. Unsatisfiable keeps (would empty a column) are left alone; explicit column/frame breaks win; consecutive keepWithNext paragraphs cascade. 2. A frame/column ending exactly on a forced line break (Shift+Return) no longer counts as end-of-paragraph, so keeps apply there too. 3. Off-by-one removed: "don't separate first/last N lines" now enforces N, not N+1. 4. GroupBox::update(): recompute first/last char instead of only expanding — stale ranges after TextLayout::removeLastLine() otherwise corrupt endOfFrame(). No change for text without keep settings. Tested: frame chains, 2/3-column frames, forced line breaks, COLBREAK, drop-cap exclusion, keepWithNext cascades, unsatisfiable keepTogether, paragraph ending the story mid-column; verified against rendered output. | ||||
| Tags | No tags attached. | ||||
| Attached Files | bug10779_keep_lines_at_column_breaks.patch (12,581 bytes)
Index: scribus/pageitem_textframe.cpp
===================================================================
--- scribus/pageitem_textframe.cpp (revision 27720)
+++ scribus/pageitem_textframe.cpp (working copy)
@@ -1092,13 +1092,15 @@
int pos = textLayout.endOfFrame() - 1;
QChar lastChar = itemText.text (pos);
// qDebug()<<"pos is"<<pos<<", length is"<<itemText.length()<<", incomplete is "<<prev->incompleteLines;
- if ((pos != itemText.length() - 1) && (!SpecialChars::isBreak (lastChar, true)))
+ // note: a LINEBREAK does not end the paragraph, so it must not stop the wait either
+ if ((pos != itemText.length() - 1)
+ && (lastChar == SpecialChars::LINEBREAK || !SpecialChars::isBreak (lastChar, true)))
return false; // the paragraph isn't ending yet
int lines = textLayout.lines(); // lines added to the current frame
ParagraphStyle style = itemText.paragraphStyle (pos);
- int need = style.keepLinesEnd () + 1;
- int prevneed = style.keepLinesStart () + 1;
+ int need = style.keepLinesEnd ();
+ int prevneed = style.keepLinesStart ();
if (lines >= need)
{
prev->incompleteLines = 0; // so that further paragraphs don't pull anything
@@ -1136,7 +1138,9 @@
int paragraphStart = itemText.prevParagraph (end) + 1;
QChar lastChar = itemText.text (end);
bool keepWithNext = style.keepWithNext() && (lastChar == SpecialChars::PARSEP);
- if (keepWithNext || (!SpecialChars::isBreak (lastChar, true)))
+ // a LINEBREAK does not end the paragraph: the frame ending on one still
+ // means the paragraph continues in the next frame
+ if (keepWithNext || lastChar == SpecialChars::LINEBREAK || (!SpecialChars::isBreak (lastChar, true)))
{
// paragraph continues in the next frame, or needs to be kept with the next one
// check how many lines are in this frame
@@ -1149,7 +1153,7 @@
incompleteLines++;
incompletePositions.prepend (lineStart);
}
- int need = style.keepLinesStart () + 1;
+ int need = style.keepLinesStart ();
if (style.keepTogether())
need = incompleteLines;
int pull = 0;
@@ -1393,8 +1397,160 @@
int regionMinY = 0, regionMaxY= 0;
double autoLeftIndent = 0.0;
- for (int i = 0; shapedText.haveMoreText(i, glyphClusters); ++i)
+
+ // #10779: paragraph keep settings (orphans/widows, keep together,
+ // keep with next) at column breaks. adjustParagraphEndings() and
+ // moveLinesFromPreviousFrame() only cover breaks between frames;
+ // the helpers below do the equivalent when text advances to the
+ // next column of the same frame, by removing the affected lines
+ // again and restarting the shaping loop at the top of the column.
+ QVector<int> colStartChar(m_columns, -1); // first char laid out in each column
+ int keepPulls = 0; // brake against pathological pull cycles
+ const int maxKeepPulls = m_columns * 4 + 8;
+
+ // Remove every laid-out line from position pos onward and restart
+ // the shaping loop with pos as the first char at the top of the
+ // current column. Returns the new cluster loop index.
+ auto restartColumnAt = [&](int pos, int clusterIndex, int removeLines) -> int
{
+ for (int r = 0; r < removeLines && textLayout.lines() > 0; ++r)
+ textLayout.removeLastLine();
+ int j = qMin(clusterIndex, static_cast<int>(glyphClusters.count()) - 1);
+ while (j > 0 && glyphClusters[j - 1].firstChar() >= pos)
+ --j;
+ ++keepPulls;
+ colStartChar[current.column] = pos;
+ for (int col = current.column + 1; col < m_columns; ++col)
+ colStartChar[col] = -1;
+ current.yPos = m_textDistanceMargins.top() + lineCorr;
+ current.startOfCol = true;
+ current.addLeftIndent = true;
+ current.lastInRowLine = false;
+ current.rowDesc = 0;
+ current.hyphenCount = 0;
+ current.restartX = current.xPos = current.colLeft;
+ current.restartIndex = current.restartRowIndex = j;
+ lastLineY = m_textDistanceMargins.top();
+ return current.restartLine(true, false);
+ };
+
+ // The column that just ended (current.column - 1) may not end where
+ // it did: it broke inside a paragraph whose keep settings forbid it,
+ // or right after a keep-with-next paragraph. Returns the position
+ // the new column must start at instead, or -1 if the break is fine.
+ // removeLines receives the number of trailing lines to remove.
+ auto columnKeepPull = [&](int &removeLines) -> int
+ {
+ if (current.column <= 0 || current.hasDropCap || keepPulls >= maxKeepPulls)
+ return -1;
+ int colStart = colStartChar[current.column - 1];
+ int end = textLayout.endOfFrame() - 1;
+ if (colStart < 0 || end < colStart || end >= itemText.length() - 1)
+ return -1;
+ const ParagraphStyle& endStyle = itemText.paragraphStyle(end);
+ QChar lastChar = itemText.text(end);
+ // LINEBREAK does not end a paragraph; PARSEP does, and
+ // FRAMEBREAK/COLBREAK are explicit user breaks
+ bool continues = (lastChar == SpecialChars::LINEBREAK)
+ || !SpecialChars::isBreak(lastChar, true);
+ bool keepWithNext = endStyle.keepWithNext() && (lastChar == SpecialChars::PARSEP);
+ if (!continues && !keepWithNext)
+ return -1;
+ int paragraphStart = itemText.prevParagraph(end) + 1;
+ // start positions of the paragraph's lines within the ended column
+ QList<int> lineStarts;
+ int lineStart = textLayout.startOfLine(end);
+ lineStarts.prepend(lineStart);
+ while (lineStart > paragraphStart && lineStart > colStart)
+ {
+ lineStart = textLayout.startOfLine(lineStart - 1);
+ lineStarts.prepend(lineStart);
+ }
+ // when the paragraph already spans a break, its start-side keep
+ // settings were decided at that earlier break
+ bool paraStartsHere = (lineStarts.first() == paragraphStart);
+ int pull = 0;
+ if (continues && endStyle.keepTogether())
+ pull = paraStartsHere ? lineStarts.count() : 0;
+ else if (continues && paraStartsHere && lineStarts.count() < endStyle.keepLinesStart())
+ pull = lineStarts.count();
+ // keep with next pulls one line; if that proves insufficient the
+ // next break's handling will pull more
+ if (keepWithNext && pull == 0)
+ pull = 1;
+ if (pull == 0)
+ return -1;
+ int pos = lineStarts.at(lineStarts.count() - pull);
+ if (pos <= colStart)
+ return -1; // would empty the column: the keep is unsatisfiable
+ removeLines = pull;
+ return pos;
+ };
+
+ // Called after appending a line that completes a paragraph: when the
+ // paragraph broke at the preceding column boundary and too few of
+ // its lines ended up in this column, more lines must move over from
+ // the previous column. This is the column counterpart of
+ // moveLinesFromPreviousFrame(), which can only run once the
+ // paragraph's total line count is known.
+ // removeLines receives the number of trailing lines to remove.
+ auto columnWidowPull = [&](int &removeLines) -> int
+ {
+ if (current.column <= 0 || current.hasDropCap || keepPulls >= maxKeepPulls)
+ return -1;
+ int colStart = colStartChar[current.column];
+ int prevColStart = colStartChar[current.column - 1];
+ if (colStart < 0 || prevColStart < 0)
+ return -1;
+ int end = textLayout.endOfFrame() - 1;
+ QChar lastChar = itemText.text(end);
+ if ((end != itemText.length() - 1) && (lastChar != SpecialChars::PARSEP))
+ return -1; // the paragraph isn't ending yet
+ int paragraphStart = itemText.prevParagraph(end) + 1;
+ if (paragraphStart >= colStart)
+ return -1; // the paragraph doesn't span this column boundary
+ const ParagraphStyle& endStyle = itemText.paragraphStyle(end);
+ // lines of the paragraph in this column
+ int have = 1;
+ int lineStart = textLayout.startOfLine(end);
+ while (lineStart > colStart)
+ {
+ lineStart = textLayout.startOfLine(lineStart - 1);
+ ++have;
+ }
+ if (have >= endStyle.keepLinesEnd())
+ return -1;
+ int pull = endStyle.keepLinesEnd() - have;
+ // start positions of the paragraph's lines in the previous column
+ QList<int> lineStarts;
+ lineStart = textLayout.startOfLine(colStart - 1);
+ lineStarts.prepend(lineStart);
+ while (lineStart > paragraphStart && lineStart > prevColStart)
+ {
+ lineStart = textLayout.startOfLine(lineStart - 1);
+ lineStarts.prepend(lineStart);
+ }
+ // if pulling would leave a stub that violates the orphan
+ // setting, move the paragraph's whole start portion instead
+ if (lineStarts.count() - pull < endStyle.keepLinesStart())
+ pull = lineStarts.count();
+ if (pull > lineStarts.count())
+ pull = lineStarts.count();
+ int pos = lineStarts.at(lineStarts.count() - pull);
+ if (pos <= prevColStart)
+ return -1; // would empty the previous column
+ removeLines = have + pull;
+ return pos;
+ };
+
+ // re-entry point for a keep-lines pull triggered after the shaping
+ // loop has ended (see the last-line handling below); keepPullRestartAt
+ // follows the restartColumnAt() contract of returning the cluster
+ // index before the one to lay out next
+ int keepPullRestartAt = -1;
+ keepPullRestart:
+ for (int i = keepPullRestartAt + 1; shapedText.haveMoreText(i, glyphClusters); ++i)
+ {
int currentIndex = i - current.lineData.firstCluster;
GlyphCluster newRun = glyphClusters[i];
if (currentIndex >= current.glyphs.count())
@@ -1998,7 +2154,18 @@
current.restartX = current.xPos;
lastLineY = current.yPos;
current.rowDesc = 0;
- i--;
+ // re-check after every pull: the pulled lines may expose a
+ // new column ending that violates a keep setting itself
+ // (e.g. consecutive keep-with-next paragraphs)
+ bool keepPulled = false;
+ int keepPullLines = 0;
+ for (int keepPos = columnKeepPull(keepPullLines); keepPos >= 0; keepPos = columnKeepPull(keepPullLines))
+ {
+ i = restartColumnAt(keepPos, i, keepPullLines);
+ keepPulled = true;
+ }
+ if (!keepPulled)
+ i--;
current.recalculateY = true;
current.addLeftIndent = true;
continue;
@@ -2751,8 +2918,21 @@
}
current.fillInTabLeaders();
//if right margin is set we temporally save line, not append it
+ if (colStartChar[current.column] < 0)
+ colStartChar[current.column] = glyphClusters[current.lineData.firstCluster].firstChar();
textLayout.appendLine(current.createLineBox());
setMaxY(maxYDesc);
+ int widowPullLines = 0;
+ int widowPos = columnWidowPull(widowPullLines);
+ if (widowPos >= 0)
+ {
+ goNoRoom = false;
+ goNextColumn = false;
+ outs = false;
+ inOverflow = false;
+ i = restartColumnAt(widowPos, i, widowPullLines);
+ continue;
+ }
current.restartIndex = current.lineData.lastCluster + 1;
i = current.lineData.lastCluster;
currentIndex = i - current.lineData.firstCluster;
@@ -2855,6 +3035,10 @@
lastLineY = m_textDistanceMargins.top();
current.rowDesc = 0;
current.recalculateY = true;
+ // re-check after every pull, see the other call site
+ int keepPullLines = 0;
+ for (int keepPos = columnKeepPull(keepPullLines); keepPos >= 0; keepPos = columnKeepPull(keepPullLines))
+ i = restartColumnAt(keepPos, i, keepPullLines);
}
else
{
@@ -3017,10 +3201,22 @@
current.startOfCol = false;
goNextColumn = false;
+ if (colStartChar[current.column] < 0)
+ colStartChar[current.column] = glyphClusters[current.lineData.firstCluster].firstChar();
textLayout.appendLine(current.createLineBox());
setMaxY(maxYDesc);
current.startOfCol = false;
+ int widowPullLines = 0;
+ int widowPos = columnWidowPull(widowPullLines);
+ if (widowPos >= 0)
+ {
+ goNoRoom = false;
+ outs = false;
+ keepPullRestartAt = restartColumnAt(widowPos, static_cast<int>(glyphClusters.count()) - 1, widowPullLines);
+ goto keepPullRestart;
+ }
+
if (moveLinesFromPreviousFrame ()) {
layout (); // line moving ensures that this won't be an endless loop
itemText.blockSignals(false);
Index: scribus/text/boxes.cpp
===================================================================
--- scribus/text/boxes.cpp (revision 27720)
+++ scribus/text/boxes.cpp (working copy)
@@ -124,6 +124,10 @@
void GroupBox::update()
{
m_naturalHeight = m_naturalWidth = 0;
+ // recompute the char range from scratch: boxes can also be removed
+ // (TextLayout::removeLastLine()), so the old range must not stick
+ m_firstChar = INT_MAX;
+ m_lastChar = INT_MIN;
for (const Box* box : boxes())
{
m_firstChar = qMin(m_firstChar, box->firstChar());
| ||||
| Patch | Yes | ||||