View Issue Details

IDProjectCategoryView StatusLast Update
0017896ScribusText Frames / Story Editorpublic2026-07-28 14:07
Reporterhugowett Assigned To 
PrioritynormalSeveritymajorReproducibilityalways
Status newResolutionopen 
Product Version1.7.4.svn 
Summary0017896: Fix widow/orphan handling as per 10779
DescriptionThe 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.
TagsNo 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());
PatchYes

Activities

Issue History

Date Modified Username Field Change
2026-07-28 14:07 hugowett New Issue
2026-07-28 14:07 hugowett File Added: bug10779_keep_lines_at_column_breaks.patch