From fadfb477d980fe77a6a8ae0f187a0b4e248bd89c Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Sun, 28 Nov 2021 16:43:13 +1000 Subject: [PATCH] [api] Move text wrapping handling logic from layout table code to QgsTextRenderer Allows other users of QgsTextRenderer to take advantage of the automatic line wrapping behaviour --- python/core/auto_additions/qgis.py | 7 + python/core/auto_generated/qgis.sip.in | 8 ++ .../textrenderer/qgstextrenderer.sip.in | 26 +++- src/core/layout/qgslayouttable.cpp | 135 ++++-------------- src/core/layout/qgslayouttable.h | 4 - src/core/qgis.h | 13 ++ src/core/textrenderer/qgstextrenderer.cpp | 108 +++++++++++++- src/core/textrenderer/qgstextrenderer.h | 24 +++- tests/src/core/testqgslayouttable.cpp | 6 +- ...omposerattributetable_columnwidth_mask.png | Bin 0 -> 5767 bytes .../expected_manualtable_columnwidth_mask.png | Bin 7181 -> 7182 bytes .../expected_manualtable_textformat_mask.png | Bin 11214 -> 11231 bytes 12 files changed, 204 insertions(+), 127 deletions(-) create mode 100644 tests/testdata/control_images/composer_table/expected_composerattributetable_columnwidth/expected_composerattributetable_columnwidth_mask.png diff --git a/python/core/auto_additions/qgis.py b/python/core/auto_additions/qgis.py index 8847a264d03..c1eef81ccd0 100644 --- a/python/core/auto_additions/qgis.py +++ b/python/core/auto_additions/qgis.py @@ -1169,6 +1169,13 @@ QgsStringUtils.AllSmallCaps.__doc__ = "Force all characters to small caps (since Qgis.Capitalization.__doc__ = 'String capitalization options.\n\n.. note::\n\n Prior to QGIS 3.24 this was available as :py:class:`QgsStringUtils`.Capitalization\n\n.. versionadded:: 3.24\n\n' + '* ``MixedCase``: ' + Qgis.Capitalization.MixedCase.__doc__ + '\n' + '* ``AllUppercase``: ' + Qgis.Capitalization.AllUppercase.__doc__ + '\n' + '* ``AllLowercase``: ' + Qgis.Capitalization.AllLowercase.__doc__ + '\n' + '* ``ForceFirstLetterToCapital``: ' + Qgis.Capitalization.ForceFirstLetterToCapital.__doc__ + '\n' + '* ``SmallCaps``: ' + Qgis.Capitalization.SmallCaps.__doc__ + '\n' + '* ``TitleCase``: ' + Qgis.Capitalization.TitleCase.__doc__ + '\n' + '* ``UpperCamelCase``: ' + Qgis.Capitalization.UpperCamelCase.__doc__ + '\n' + '* ``AllSmallCaps``: ' + Qgis.Capitalization.AllSmallCaps.__doc__ # -- Qgis.Capitalization.baseClass = Qgis +# monkey patching scoped based enum +Qgis.TextRendererFlag.WrapLines.__doc__ = "Automatically wrap long lines of text" +Qgis.TextRendererFlag.__doc__ = 'Flags which control the behavior of rendering text.\n\n.. versionadded:: 3.24\n\n' + '* ``WrapLines``: ' + Qgis.TextRendererFlag.WrapLines.__doc__ +# -- +Qgis.TextRendererFlag.baseClass = Qgis +Qgis.TextRendererFlags.baseClass = Qgis +TextRendererFlags = Qgis # dirty hack since SIP seems to introduce the flags in module QgsCurve.Orientation = Qgis.AngularDirection # monkey patching scoped based enum QgsCurve.Clockwise = Qgis.AngularDirection.Clockwise diff --git a/python/core/auto_generated/qgis.sip.in b/python/core/auto_generated/qgis.sip.in index c1be32f8d52..a4f7dc5f2e8 100644 --- a/python/core/auto_generated/qgis.sip.in +++ b/python/core/auto_generated/qgis.sip.in @@ -750,6 +750,12 @@ The development version AllSmallCaps, }; + enum class TextRendererFlag + { + WrapLines, + }; + typedef QFlags TextRendererFlags; + enum class AngularDirection { @@ -858,6 +864,8 @@ QFlags operator|(Qgis::VectorLayerTypeFlag f1, QFlags QFlags operator|(Qgis::MarkerLinePlacement f1, QFlags f2); +QFlags operator|(Qgis::TextRendererFlag f1, QFlags f2); + diff --git a/python/core/auto_generated/textrenderer/qgstextrenderer.sip.in b/python/core/auto_generated/textrenderer/qgstextrenderer.sip.in index 8fd0e49436d..4821ca2aad0 100644 --- a/python/core/auto_generated/textrenderer/qgstextrenderer.sip.in +++ b/python/core/auto_generated/textrenderer/qgstextrenderer.sip.in @@ -87,7 +87,8 @@ Calculates pixel size (considering output size should be in pixel or map units, static void drawText( const QRectF &rect, double rotation, HAlignment alignment, const QStringList &textLines, QgsRenderContext &context, const QgsTextFormat &format, - bool drawAsOutlines = true, VAlignment vAlignment = AlignTop ); + bool drawAsOutlines = true, VAlignment vAlignment = AlignTop, + Qgis::TextRendererFlags flags = Qgis::TextRendererFlags() ); %Docstring Draws text within a rectangle using the specified settings. @@ -102,6 +103,7 @@ Draws text within a rectangle using the specified settings. rendering and may result in side effects like misaligned text buffers. This setting is deprecated and has no effect as of QGIS 3.4.3 and the text format should be set using :py:func:`QgsRenderContext.setTextRenderFormat()` instead. :param vAlignment: vertical alignment (since QGIS 3.16) +:param flags: text rendering flags (since QGIS 3.24) %End static void drawText( QPointF point, double rotation, HAlignment alignment, const QStringList &textLines, @@ -196,7 +198,7 @@ Returns the width of a text based on a given format. %End static double textHeight( const QgsRenderContext &context, const QgsTextFormat &format, const QStringList &textLines, DrawMode mode = Point, - QFontMetricsF *fontMetrics = 0 ); + QFontMetricsF *fontMetrics = 0, Qgis::TextRendererFlags flags = Qgis::TextRendererFlags(), double maxLineWidth = 0 ); %Docstring Returns the height of a text based on a given format. @@ -205,6 +207,8 @@ Returns the height of a text based on a given format. :param textLines: list of lines of text to calculate width from :param mode: draw mode :param fontMetrics: font metrics +:param flags: text renderer flags (since QGIS 3.24) +:param maxLineWidth: maximum line width, in painter units. Used when the Qgis.TextRendererFlag.WrapLines flag is used (since QGIS 3.24) %End static double textHeight( const QgsRenderContext &context, const QgsTextFormat &format, QChar character, bool includeEffects = false ); @@ -218,6 +222,24 @@ Returns the height of a character when rendered with the specified text ``format returned height. If ``False``, then the returned size considers the character only. .. versionadded:: 3.16 +%End + + static bool textRequiresWrapping( const QgsRenderContext &context, const QString &text, double width, const QgsTextFormat &format ); +%Docstring +Returns ``True`` if the specified ``text`` requires line wrapping in order to fit within the specified ``width`` (in painter units). + +.. seealso:: :py:func:`wrappedText` + +.. versionadded:: 3.24 +%End + + static QStringList wrappedText( const QgsRenderContext &context, const QString &text, double width, const QgsTextFormat &format ); +%Docstring +Wraps a ``text`` string to multiple lines, such that each individual line will fit within the specified ``width`` (in painter units). + +.. seealso:: :py:func:`textRequiresWrapping` + +.. versionadded:: 3.24 %End static const double FONT_WORKAROUND_SCALE; diff --git a/src/core/layout/qgslayouttable.cpp b/src/core/layout/qgslayouttable.cpp index 9244e4c19b1..3c73083ce08 100644 --- a/src/core/layout/qgslayouttable.cpp +++ b/src/core/layout/qgslayouttable.cpp @@ -470,13 +470,7 @@ void QgsLayoutTable::render( QgsLayoutItemRenderContext &context, const QRectF & const QRectF textCell = QRectF( currentX, currentY + mCellMargin, mMaxColumnWidthMap[col], cellHeaderHeight - 2 * mCellMargin ); - // disable text clipping to target text rectangle, because we manually clip to the full cell bounds below - // and it's ok if text overlaps into the margin (e.g. extenders or italicized text) - QStringList str = column.heading().split( '\n' ); - if ( ( mWrapBehavior != TruncateText || column.width() > 0 ) && textRequiresWrapping( context.renderContext(), column.heading(), column.width(), headerFormat ) ) - { - str = wrappedText( context.renderContext(), column.heading(), column.width(), headerFormat ); - } + const QStringList str = column.heading().split( '\n' ); // scale to dots { @@ -485,7 +479,9 @@ void QgsLayoutTable::render( QgsLayoutItemRenderContext &context, const QRectF & textCell.top() * context.renderContext().scaleFactor(), textCell.width() * context.renderContext().scaleFactor(), textCell.height() * context.renderContext().scaleFactor() ), 0, - headerAlign, str, context.renderContext(), headerFormat, true, QgsTextRenderer::AlignVCenter ); + headerAlign, str, context.renderContext(), headerFormat, true, QgsTextRenderer::AlignVCenter, + mWrapBehavior == WrapText ? Qgis::TextRendererFlag::WrapLines : Qgis::TextRendererFlags() + ); } currentX += mMaxColumnWidthMap[ col ]; @@ -514,6 +510,7 @@ void QgsLayoutTable::render( QgsLayoutItemRenderContext &context, const QRectF & for ( const QgsLayoutTableColumn &column : std::as_const( mColumns ) ) { + ( void )column; const QRectF fullCell( currentX, currentY, mMaxColumnWidthMap[col] + 2 * mCellMargin, rowHeight ); //draw background p->save(); @@ -527,19 +524,12 @@ void QgsLayoutTable::render( QgsLayoutItemRenderContext &context, const QRectF & QVariant cellContents = mTableContents.at( row ).at( col ); const QString localizedString { QgsExpressionUtils::toLocalizedString( cellContents ) }; - QStringList str = localizedString.split( '\n' ); + const QStringList str = localizedString.split( '\n' ); QgsTextFormat cellFormat = textFormatForCell( row, col ); QgsExpressionContextScopePopper popper( context.renderContext().expressionContext(), scopeForCell( row, col ) ); cellFormat.updateDataDefinedProperties( context.renderContext() ); - // disable text clipping to target text rectangle, because we manually clip to the full cell bounds below - // and it's ok if text overlaps into the margin (e.g. extenders or italicized text) - if ( ( mWrapBehavior != TruncateText || column.width() > 0 ) && textRequiresWrapping( context.renderContext(), localizedString, column.width(), cellFormat ) ) - { - str = wrappedText( context.renderContext(), localizedString, column.width(), cellFormat ); - } - p->save(); p->setClipRect( fullCell ); const QRectF textCell = QRectF( currentX, currentY + mCellMargin, mMaxColumnWidthMap[col], rowHeight - 2 * mCellMargin ); @@ -559,7 +549,8 @@ void QgsLayoutTable::render( QgsLayoutItemRenderContext &context, const QRectF & textCell.width() * context.renderContext().scaleFactor(), textCell.height() * context.renderContext().scaleFactor() ), 0, QgsTextRenderer::convertQtHAlignment( horizontalAlignmentForCell( row, col ) ), str, context.renderContext(), cellFormat, true, - QgsTextRenderer::convertQtVAlignment( verticalAlignmentForCell( row, col ) ) ); + QgsTextRenderer::convertQtVAlignment( verticalAlignmentForCell( row, col ) ), + mWrapBehavior == WrapText ? Qgis::TextRendererFlag::WrapLines : Qgis::TextRendererFlags() ); } p->restore(); @@ -1189,16 +1180,16 @@ bool QgsLayoutTable::calculateMaxRowHeights() { heights[i] = 0; } - else if ( textRequiresWrapping( context, col.heading(), mColumns.at( i ).width(), cellFormat ) ) - { - //contents too wide for cell, need to wrap - heights[i] = QgsTextRenderer::textHeight( context, cellFormat, wrappedText( context, col.heading(), mColumns.at( i ).width(), cellFormat ), QgsTextRenderer::Rect ) - / context.convertToPainterUnits( 1, QgsUnitTypes::RenderMillimeters ) - - headerDescentMm; - } else { - heights[i] = QgsTextRenderer::textHeight( context, cellFormat, QStringList() << col.heading(), QgsTextRenderer::Rect ) / context.convertToPainterUnits( 1, QgsUnitTypes::RenderMillimeters ) + heights[i] = QgsTextRenderer::textHeight( context, + cellFormat, + QStringList() << col.heading(), QgsTextRenderer::Rect, + nullptr, + mWrapBehavior == WrapText ? Qgis::TextRendererFlag::WrapLines : Qgis::TextRendererFlags(), + context.convertToPainterUnits( mColumns.at( i ).width(), QgsUnitTypes::RenderMillimeters ) + ) + / context.convertToPainterUnits( 1, QgsUnitTypes::RenderMillimeters ) - headerDescentMm; } i++; @@ -1219,15 +1210,14 @@ bool QgsLayoutTable::calculateMaxRowHeights() const double contentDescentMm = QgsTextRenderer::fontMetrics( context, cellFormat, QgsTextRenderer::FONT_WORKAROUND_SCALE ).descent() / QgsTextRenderer::FONT_WORKAROUND_SCALE / context.convertToPainterUnits( 1, QgsUnitTypes::RenderMillimeters ); const QString localizedString { QgsExpressionUtils::toLocalizedString( *colIt ) }; - if ( textRequiresWrapping( context, localizedString, mColumns.at( i ).width(), cellFormat ) ) - { - //contents too wide for cell, need to wrap - heights[ row * cols + i ] = QgsTextRenderer::textHeight( context, cellFormat, wrappedText( context, localizedString, mColumns.at( i ).width(), cellFormat ), QgsTextRenderer::Rect ) / context.convertToPainterUnits( 1, QgsUnitTypes::RenderMillimeters ) - contentDescentMm; - } - else - { - heights[ row * cols + i ] = QgsTextRenderer::textHeight( context, cellFormat, QStringList() << localizedString.split( '\n' ), QgsTextRenderer::Rect ) / context.convertToPainterUnits( 1, QgsUnitTypes::RenderMillimeters ) - contentDescentMm; - } + heights[ row * cols + i ] = QgsTextRenderer::textHeight( context, + cellFormat, + QStringList() << localizedString.split( '\n' ), + QgsTextRenderer::Rect, + nullptr, + mWrapBehavior == WrapText ? Qgis::TextRendererFlag::WrapLines : Qgis::TextRendererFlags(), + context.convertToPainterUnits( mColumns.at( i ).width(), QgsUnitTypes::RenderMillimeters ) + ) / context.convertToPainterUnits( 1, QgsUnitTypes::RenderMillimeters ) - contentDescentMm; i++; } @@ -1375,83 +1365,6 @@ void QgsLayoutTable::drawHorizontalGridLines( QgsLayoutItemRenderContext &contex painter->drawLine( QPointF( halfGridStrokeWidth, currentY ), QPointF( mTableSize.width() - halfGridStrokeWidth, currentY ) ); } -bool QgsLayoutTable::textRequiresWrapping( QgsRenderContext &context, const QString &text, double columnWidth, const QgsTextFormat &format ) const -{ - if ( qgsDoubleNear( columnWidth, 0.0 ) || mWrapBehavior != WrapText ) - return false; - - const QStringList multiLineSplit = text.split( '\n' ); - const double currentTextWidth = QgsTextRenderer::textWidth( context, format, multiLineSplit ) / context.convertToPainterUnits( 1, QgsUnitTypes::RenderMillimeters ); - return currentTextWidth > columnWidth; -} - -QStringList QgsLayoutTable::wrappedText( QgsRenderContext &context, const QString &value, double columnWidth, const QgsTextFormat &format ) const -{ - QStringList lines = value.split( '\n' ); - QStringList outLines; - const auto constLines = lines; - for ( const QString &line : constLines ) - { - if ( textRequiresWrapping( context, line, columnWidth, format ) ) - { - //first step is to identify words which must be on their own line (too long to fit) - QStringList words = line.split( ' ' ); - QStringList linesToProcess; - QString wordsInCurrentLine; - const auto constWords = words; - for ( const QString &word : constWords ) - { - if ( textRequiresWrapping( context, word, columnWidth, format ) ) - { - //too long to fit - if ( !wordsInCurrentLine.isEmpty() ) - linesToProcess << wordsInCurrentLine; - wordsInCurrentLine.clear(); - linesToProcess << word; - } - else - { - if ( !wordsInCurrentLine.isEmpty() ) - wordsInCurrentLine.append( ' ' ); - wordsInCurrentLine.append( word ); - } - } - if ( !wordsInCurrentLine.isEmpty() ) - linesToProcess << wordsInCurrentLine; - - const auto constLinesToProcess = linesToProcess; - for ( const QString &line : constLinesToProcess ) - { - QString remainingText = line; - int lastPos = remainingText.lastIndexOf( ' ' ); - while ( lastPos > -1 ) - { - //check if remaining text is short enough to go in one line - if ( !textRequiresWrapping( context, remainingText, columnWidth, format ) ) - { - break; - } - - if ( !textRequiresWrapping( context, remainingText.left( lastPos ), columnWidth, format ) ) - { - outLines << remainingText.left( lastPos ); - remainingText = remainingText.mid( lastPos + 1 ); - lastPos = 0; - } - lastPos = remainingText.lastIndexOf( ' ', lastPos - 1 ); - } - outLines << remainingText; - } - } - else - { - outLines << line; - } - } - - return outLines; -} - QColor QgsLayoutTable::backgroundColor( int row, int column ) const { QColor color = mBackgroundColor; diff --git a/src/core/layout/qgslayouttable.h b/src/core/layout/qgslayouttable.h index a5f21d7d503..15cf9e76fd2 100644 --- a/src/core/layout/qgslayouttable.h +++ b/src/core/layout/qgslayouttable.h @@ -771,10 +771,6 @@ class CORE_EXPORT QgsLayoutTable: public QgsLayoutMultiFrame //! Initializes cell style map void initStyles(); - bool textRequiresWrapping( QgsRenderContext &context, const QString &text, double columnWidth, const QgsTextFormat &format ) const; - - QStringList wrappedText( QgsRenderContext &context, const QString &value, double columnWidth, const QgsTextFormat &format ) const; - /** * Returns the calculated background color for a row and column combination. * \param row row number, where -1 is the header row, and 0 is the first body row diff --git a/src/core/qgis.h b/src/core/qgis.h index 3471a8faf61..e7a30888690 100644 --- a/src/core/qgis.h +++ b/src/core/qgis.h @@ -1223,6 +1223,18 @@ class CORE_EXPORT Qgis }; Q_ENUM( Capitalization ) + /** + * Flags which control the behavior of rendering text. + * + * \since QGIS 3.24 + */ + enum class TextRendererFlag : int + { + WrapLines = 1 << 0, //!< Automatically wrap long lines of text + }; + Q_ENUM( TextRendererFlag ) + Q_DECLARE_FLAGS( TextRendererFlags, TextRendererFlag ) + Q_FLAG( TextRendererFlags ) /** * Angular directions. @@ -1367,6 +1379,7 @@ Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::MapSettingsFlags ) Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::RenderContextFlags ) Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::VectorLayerTypeFlags ) Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::MarkerLinePlacements ) +Q_DECLARE_OPERATORS_FOR_FLAGS( Qgis::TextRendererFlags ) // hack to workaround warnings when casting void pointers diff --git a/src/core/textrenderer/qgstextrenderer.cpp b/src/core/textrenderer/qgstextrenderer.cpp index e0cc9ea4d86..21731cfafc3 100644 --- a/src/core/textrenderer/qgstextrenderer.cpp +++ b/src/core/textrenderer/qgstextrenderer.cpp @@ -77,13 +77,26 @@ int QgsTextRenderer::sizeToPixel( double size, const QgsRenderContext &c, QgsUni return static_cast< int >( c.convertToPainterUnits( size, unit, mapUnitScale ) + 0.5 ); //NOLINT } -void QgsTextRenderer::drawText( const QRectF &rect, double rotation, QgsTextRenderer::HAlignment alignment, const QStringList &textLines, QgsRenderContext &context, const QgsTextFormat &format, bool, VAlignment vAlignment ) +void QgsTextRenderer::drawText( const QRectF &rect, double rotation, QgsTextRenderer::HAlignment alignment, const QStringList &text, QgsRenderContext &context, const QgsTextFormat &format, bool, VAlignment vAlignment, Qgis::TextRendererFlags flags ) { QgsTextFormat tmpFormat = format; if ( format.dataDefinedProperties().hasActiveProperties() ) // note, we use format instead of tmpFormat here, it's const and potentially avoids a detach tmpFormat.updateDataDefinedProperties( context ); tmpFormat = updateShadowPosition( tmpFormat ); + QStringList textLines; + for ( const QString &line : text ) + { + if ( flags & Qgis::TextRendererFlag::WrapLines && textRequiresWrapping( context, line, rect.width(), format ) ) + { + textLines.append( wrappedText( context, line, rect.width(), format ) ); + } + else + { + textLines.append( line ); + } + } + QgsTextDocument document = format.allowHtmlFormatting() ? QgsTextDocument::fromHtml( textLines ) : QgsTextDocument::fromPlainText( textLines ); document.applyCapitalization( format.capitalization() ); @@ -599,15 +612,28 @@ double QgsTextRenderer::textWidth( const QgsRenderContext &context, const QgsTex return width / scaleFactor; } -double QgsTextRenderer::textHeight( const QgsRenderContext &context, const QgsTextFormat &format, const QStringList &textLines, DrawMode mode, QFontMetricsF * ) +double QgsTextRenderer::textHeight( const QgsRenderContext &context, const QgsTextFormat &format, const QStringList &textLines, DrawMode mode, QFontMetricsF *, Qgis::TextRendererFlags flags, double maxLineWidth ) { + QStringList lines; + for ( const QString &line : textLines ) + { + if ( flags & Qgis::TextRendererFlag::WrapLines && maxLineWidth > 0 && textRequiresWrapping( context, line, maxLineWidth, format ) ) + { + lines.append( wrappedText( context, line, maxLineWidth, format ) ); + } + else + { + lines.append( line ); + } + } + if ( !format.allowHtmlFormatting() ) { - return textHeight( context, format, QgsTextDocument::fromPlainText( textLines ), mode ); + return textHeight( context, format, QgsTextDocument::fromPlainText( lines ), mode ); } else { - return textHeight( context, format, QgsTextDocument::fromHtml( textLines ), mode ); + return textHeight( context, format, QgsTextDocument::fromHtml( lines ), mode ); } } @@ -648,6 +674,80 @@ double QgsTextRenderer::textHeight( const QgsRenderContext &context, const QgsTe return height + maxExtension; } +bool QgsTextRenderer::textRequiresWrapping( const QgsRenderContext &context, const QString &text, double width, const QgsTextFormat &format ) +{ + if ( qgsDoubleNear( width, 0.0 ) ) + return false; + + const QStringList multiLineSplit = text.split( '\n' ); + const double currentTextWidth = QgsTextRenderer::textWidth( context, format, multiLineSplit ); + return currentTextWidth > width; +} + +QStringList QgsTextRenderer::wrappedText( const QgsRenderContext &context, const QString &text, double width, const QgsTextFormat &format ) +{ + const QStringList lines = text.split( '\n' ); + QStringList outLines; + for ( const QString &line : lines ) + { + if ( textRequiresWrapping( context, line, width, format ) ) + { + //first step is to identify words which must be on their own line (too long to fit) + const QStringList words = line.split( ' ' ); + QStringList linesToProcess; + QString wordsInCurrentLine; + for ( const QString &word : words ) + { + if ( textRequiresWrapping( context, word, width, format ) ) + { + //too long to fit + if ( !wordsInCurrentLine.isEmpty() ) + linesToProcess << wordsInCurrentLine; + wordsInCurrentLine.clear(); + linesToProcess << word; + } + else + { + if ( !wordsInCurrentLine.isEmpty() ) + wordsInCurrentLine.append( ' ' ); + wordsInCurrentLine.append( word ); + } + } + if ( !wordsInCurrentLine.isEmpty() ) + linesToProcess << wordsInCurrentLine; + + for ( const QString &line : std::as_const( linesToProcess ) ) + { + QString remainingText = line; + int lastPos = remainingText.lastIndexOf( ' ' ); + while ( lastPos > -1 ) + { + //check if remaining text is short enough to go in one line + if ( !textRequiresWrapping( context, remainingText, width, format ) ) + { + break; + } + + if ( !textRequiresWrapping( context, remainingText.left( lastPos ), width, format ) ) + { + outLines << remainingText.left( lastPos ); + remainingText = remainingText.mid( lastPos + 1 ); + lastPos = 0; + } + lastPos = remainingText.lastIndexOf( ' ', lastPos - 1 ); + } + outLines << remainingText; + } + } + else + { + outLines << line; + } + } + + return outLines; +} + double QgsTextRenderer::textHeight( const QgsRenderContext &context, const QgsTextFormat &format, const QgsTextDocument &doc, DrawMode mode ) { QgsTextDocument document = doc; diff --git a/src/core/textrenderer/qgstextrenderer.h b/src/core/textrenderer/qgstextrenderer.h index 5758c3a3035..134b3da2f3d 100644 --- a/src/core/textrenderer/qgstextrenderer.h +++ b/src/core/textrenderer/qgstextrenderer.h @@ -115,10 +115,12 @@ class CORE_EXPORT QgsTextRenderer * rendering and may result in side effects like misaligned text buffers. This setting is deprecated and has no effect * as of QGIS 3.4.3 and the text format should be set using QgsRenderContext::setTextRenderFormat() instead. * \param vAlignment vertical alignment (since QGIS 3.16) + * \param flags text rendering flags (since QGIS 3.24) */ static void drawText( const QRectF &rect, double rotation, HAlignment alignment, const QStringList &textLines, QgsRenderContext &context, const QgsTextFormat &format, - bool drawAsOutlines = true, VAlignment vAlignment = AlignTop ); + bool drawAsOutlines = true, VAlignment vAlignment = AlignTop, + Qgis::TextRendererFlags flags = Qgis::TextRendererFlags() ); /** * Draws text at a point origin using the specified settings. @@ -212,9 +214,11 @@ class CORE_EXPORT QgsTextRenderer * \param textLines list of lines of text to calculate width from * \param mode draw mode * \param fontMetrics font metrics + * \param flags text renderer flags (since QGIS 3.24) + * \param maxLineWidth maximum line width, in painter units. Used when the Qgis::TextRendererFlag::WrapLines flag is used (since QGIS 3.24) */ static double textHeight( const QgsRenderContext &context, const QgsTextFormat &format, const QStringList &textLines, DrawMode mode = Point, - QFontMetricsF *fontMetrics = nullptr ); + QFontMetricsF *fontMetrics = nullptr, Qgis::TextRendererFlags flags = Qgis::TextRendererFlags(), double maxLineWidth = 0 ); /** * Returns the height of a character when rendered with the specified text \a format. @@ -229,6 +233,22 @@ class CORE_EXPORT QgsTextRenderer */ static double textHeight( const QgsRenderContext &context, const QgsTextFormat &format, QChar character, bool includeEffects = false ); + /** + * Returns TRUE if the specified \a text requires line wrapping in order to fit within the specified \a width (in painter units). + * + * \see wrappedText() + * \since QGIS 3.24 + */ + static bool textRequiresWrapping( const QgsRenderContext &context, const QString &text, double width, const QgsTextFormat &format ); + + /** + * Wraps a \a text string to multiple lines, such that each individual line will fit within the specified \a width (in painter units). + * + * \see textRequiresWrapping() + * \since QGIS 3.24 + */ + static QStringList wrappedText( const QgsRenderContext &context, const QString &text, double width, const QgsTextFormat &format ); + /** * Scale factor for upscaling font sizes and downscaling destination painter devices. * diff --git a/tests/src/core/testqgslayouttable.cpp b/tests/src/core/testqgslayouttable.cpp index 738e915f39b..3508e45a632 100644 --- a/tests/src/core/testqgslayouttable.cpp +++ b/tests/src/core/testqgslayouttable.cpp @@ -37,6 +37,7 @@ #include "qgslayoutatlas.h" #include "qgslayoututils.h" #include "qgspallabeling.h" +#include "qgstextrenderer.h" #include #include "qgstest.h" @@ -1644,18 +1645,15 @@ void TestQgsLayoutTable::wrappedText() { QgsProject p; QgsLayout l( &p ); - QgsLayoutItemAttributeTable *t = new QgsLayoutItemAttributeTable( &l ); - t->setWrapBehavior( QgsLayoutTable::WrapText ); const QFont f; const QString sourceText( "Lorem ipsum dolor sit amet, consectetur adipisici elit, sed eiusmod tempor incidunt ut labore et dolore magna aliqua" ); QgsRenderContext context = QgsLayoutUtils::createRenderContextForLayout( &l, nullptr ); - const QString wrapText = t->wrappedText( context, sourceText, 101 /*columnWidth*/, QgsTextFormat::fromQFont( f ) ).join( '\n' ); + const QString wrapText = QgsTextRenderer::wrappedText( context, sourceText, context.convertToPainterUnits( 101, QgsUnitTypes::RenderMillimeters ) /*columnWidth*/, QgsTextFormat::fromQFont( f ) ).join( '\n' ); //there should be no line break before the last word (bug #20546) QVERIFY( !wrapText.endsWith( "\naliqua" ) ); } - void TestQgsLayoutTable::testBaseSort() { QgsLayout l( QgsProject::instance() ); diff --git a/tests/testdata/control_images/composer_table/expected_composerattributetable_columnwidth/expected_composerattributetable_columnwidth_mask.png b/tests/testdata/control_images/composer_table/expected_composerattributetable_columnwidth/expected_composerattributetable_columnwidth_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..5ebb8fb03ec0ab19559991dedf0e3b3089968496 GIT binary patch literal 5767 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 I4xah;|Eh3~4gdfE diff --git a/tests/testdata/control_images/layout_manual_table/expected_manualtable_textformat/expected_manualtable_textformat_mask.png b/tests/testdata/control_images/layout_manual_table/expected_manualtable_textformat/expected_manualtable_textformat_mask.png index 306e6f85a119fcc69b347d9d48cd28167558d9d9..b0738929509d101d9823d5851256eeaf519057d7 100644 GIT binary patch literal 11231 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 literal 11214 zcmeHNX;hQfy2jd5J17_hL_`s6MJqC>3?c-p3`J3jnnY%SGS4zY09zaai1bJW6(K#K zAOwhjB#c4JbZiKeLW~eY1O!ZCh!8?1hWmbY-5>X?yVgDHtaa}Fab+#|LbCJiz2E)3 z&-*-YlJ(tb#|@urf3BjUvcc(FyE7^(AN#4OtkwSX6L^L(^{O2%pGAM`7OSG7=8OEU zd1zY^qN1`>#mVlQb4mB!ay+6uf)Xx`H#R44jITEw$+C$w(Q^LbPU-5Zg?jtG-^U&Y zS8NHH_>!{AZPUOt&KJHSy%47YHTR!|Gi%n<_3a~E`~Jeka~0WyhabUQmr_}yi+_O! zD)@buwU8&iJhmP#M>pvqE83a0aV=bavLmd4%eVSn@K@za6f9Xq<>sfR$i?YjecSD@ z$L=_nldkF>v`yxXY~h}4{ZZi}#rR6qwYkA)P>YSQSdg z{aP-^(-tj!D!TnpD7_|Fn9ufoezmRz0d`Ai0qfOzXJ;eT?R_6~;c8g|d$)o?frOH_ z7gkEV^+$AWdWVRyvQ{!ivs6N#Izy0u zek))rC(>ef{A@JJ+w-b4*rIRrRpy>rr7sTr1~ILMsXtla!Xp0ohD8wuC|z_^dHNFL@3Hv_t=q+NYyH}fo`jo+MS3htry~+2vbo8o zrX~V`klvxYWy_YBix)RVacQ{(LVJ7rv_HB(_)s>1q+!yrq7c+{R5<4C=#I9$9!X`l z8R2~Vi%%Q4*I=(ZlmTq^ucx@zycijAOI=cg3HH0>Kl`$P9$=`M6HH~Zya9xD5Wbhj z`Vfn*i_PKQtH<3zKGKeXHq3&*bPfyRTIICO$0tVB4vH3cjW}|5N&0yK?PjVf~ zrj^ghN=1CVya72|6Pt$`rU7o|Q6@NCOIwuSj~OkR@+X|Sr?m!j!USL}29MY2SY1iAv+E0NkbcqtKZK^wFUMbSOY?e8X2G6vy7e8(T&>fWERBqf z{n126Dmyo!mD6{hCrJS;Ow3L8n%;4~dlcTe8DftNf^>t+ByXr9CME_oIcKS=stRB? z9}I-IlbG3&RKSkOv{*hm095p(bKw=XYZw(6|9~DLt*PSoliVW3hj&b-EiNvWLW+?N z@o(9P&XTWZ5gKcZQMfhtxgrbF75&n~8o}aJX-YesG(*bqw6E*v;8sWTAddVS3U{2Y z_|>Zm5tI|#bNJ*MiE{akB!w{vNoDt-h$iaqKP|QvibNe!d2>!@XXkC_-J;8d()J$M zEC4HM;ftlJVbdV$vMqdS!?gxt=4dg1qQ-%8wB&n>6S zVNZ<~^No-(2oVeh5BIIlYO+d@zkBznnVWCm;%8Od16z>e$LwmSE^c)xu@0G~LX7Z4 z#bF`dedx ziFl&jj!0i z3-qj)9UOc?v zAzNr}t4!pkZK?5gMTMQ<5}>b(As!Qom#I% z8$5+pql&P|qEuQ$OpJ;mk9eP5SHuu`tgFJ3Bu+n6kI~LXjVI5)c3N5z4}?jZ1t%T? zOYZ(Lw|5dy#1MqUqx*Th5bHF>p-(cOHXgqv5W14;LM0TdN04NehYx4r$lhjD;=Oxz zBxWeKtGf0V`LdKdDU(@_(bB>&P^F)&UEF!h*(OO^&NL~C>LKUsrcce6P5)e7OObz< znDsHqFzm{uwg&z9gY}`Y4PbIH$BPQn+m1c zCz-cTSDaD-1`k6$z>@nO;`TDfFIW{L0U3{O(mZ2++__>Xgkr=LyJ+Uv$96ou5FJ4n zVY59vP-2BdpqAo|ZBLO@Wkn3}4#5F8E*?ppo9I5`8+dlvnt43AkRGtPo-a2picIwy zCb@l*`Hi~$TtBvwX)-KKC>l9{!WkT58lWSt(x)ztc+!vGV}bs7atkU4Ht#3eEJYCB zbDOD5`+Hj<`|6sG0e*Ns)3=tSs-{-NG=TyU_P0uJQ`p#8t4+wz^tMHY9*tFF(h|ZP zh#yF87y$%at{> z@eZS|mlTY^sH1{O=5jb*9wV-;Mn6_NWVtW}lDk~TcKgbzsuvti?$FQ>5X;KA?us7d zRYS`gD|^?jp$eDD3Szpu(i6C2>Y}{=0zj*sd>iHF%$qlRAjc5W!K3qn21?R~B^m&MUwCYYrkz`X#Y$y{qi#zJNaRb_ zYF~GGN_$rPr@g96relj4L;16*Bgv-c<1@};{nD-My1S95Qj)nN;VcIw91pQ zM5;y9n4PQo98}HeyZp(zfq?-QC5oB(l0N?*-*D*~ z^rL&G8<_`xu2l{p$3*AXO+aX^4H3d7eT0sG1$v}?PBx}UFRC!$P{*TRtPd$ZT>)hk z@*Kp1C_F&15m*yZAmK4tP1@zHDdF>Sx!ipTNDGQ5qDdg!>h>rYC9+;DbIb#ZGS9rS zs}>9JD56Gj&Bq^Z7r*D?3`0xX<&=QsWm%LPR{hF^X5{id?PH<{_8qRd~6 zjg3K^yos}#19g=c*o8Mj5~uekVsWuq=MBM7wPHCPv(3ibEG#TE^f$Vhfno+t z=JwZvMU1;#T>GYS8n=qU`U;LjtvB;CQxXXi`Nf|2zrU~+21~@EBAfZW$A7>D)rQx; zIo~d988IXgTlZe7Mh7m>@zco47Da6Q`sT<7&-gR5E%6U%W6~PWX5ozc1`7+De!j%I zE5b|?7MhqSEgq^YL8oaC@fJpfUc?ERtm!j^R$Rd~3@|`OfTGaJ+JNYoTbum&8Yrrp zjn|$D4rU)J(Ka*%P2_6D%M|NB$wczI(AcLo;v@;jYK!VVv8<5EXNW~)D8)#LREj$C zKpEMt$ow~dB)}X4k?Y-&&gomKWOiQPRZDqLGv-aMs`Q~Fb$Dz{m@p~jX5=Aq*-`{4 zddo$!EMVG8GAss&wjGj>_dr}Ig0W-H5cR2x$zhi(cR8hT8A=vAFY@k8T}yUgJhKl3 zF(tM-9X&y});~h$hvqSE>8g#$<;jq-UD1~KAj$85&&>xSFG4AYoIunKEgke7R0t&b zp#bD0iKkB?%rP}Med4~mTLB^|JIAAian%G8%Qx+m1Y2JeNp=YU93Ym&yvU3!VKPnO zZiBoy@DxcBKIXTVf6{9E<=a-(b}#Yx`6{a$!%@M4RdI)v^mRFzjbH1znhMp5%JxbX zyZf-?xvApGs6qbkV}eg)=1@Ps z({!X(yQV1?x8sk7G`d@q6g*DhGMOrp6SrHd11q_0wIvQ8!>~siq?`P}AJ-TNt83$$ z=_Z_3RUTs8lm1?-OPkN4DQ8>5Y<-C4n^BCPynjGmkzrrbvu^`fP#L;LEmqeP#Yv&> zUHzXg=%&m5cGw{$zTl*dCGPTjTWfD`_fT&eDA;blx`g!>YRADAf(dE&Htw06qZnUH z{X>}G4{S6z=o)+IFo2nLl(1$;HHceqX)hK7U{bufwBC$&5}d;pC`z-lvtZ~qtS-2$ zstXg2tjrg7ge%W9IodA0JC1OSn(fk~|Mc6p!-jjqSQ+Bz$84PVfnbO#vtl;fUn*In za*3S#-?bq^qLd%e&H>$nFFwcY)wRN4FyN3tz!2wtgQZ-$c7h=P6p|3^7%1%Yd4COi z6aXr0wFE3$5d$r+gI!;`W)GtzUl%}*1UI7bi7futcdM#E8uj|s1*2KKIN%cl&73Pc zkxqyYN^g4C`(6ZaGcmx!^-qzz`*+}S5KV)jjipd+@aWb1`@e(XPdayYS*r~3;`YD! zIbdtMH+iIV7;ad1{?M9F;bqx@&rH>R_p2j7E29Rx59G&4xMqM`q_Q4azRPPFe_eR9 zrvnD#s%{Tnbx*b*yAS~b{v*BVFTPuMJAwVtZsayMlY=q_fRbyyo*jU_TJPZUOtVXa z^_|@c!VC&*uSI8EzP*2Mwm#HryRLPNH1LGdcqe2=o0HtT!=a56;6>z8 zC=%n*x6`)r!0AGzB{XTj+J*woE9HuM!_8YIVS)%}R2`)~t7yBfE@ChtSYR8$$6xRD z-dso9&JQ@Q#z=@QSym(N!--<-! zwAvkiq^6`O#>gUYID4G}+}sCa5RWLE)LqqDT@kJBK;g!x5WWW8k5uwI&d`y0-$}a= zcIG&o4d8N@K8-J{=byzsLOMH=l$iAN9WYJNi(e&sSLDRi!z)uhD_`mBue`fv+x7J6 zH%qLHW!yQ8hP|sDnI+bN7TzOFWoHV#K76_i1cGo8v(DY9@J4_)AO>;c5b}FOqGOjL zwzoB1HhxV;T$K|EGNtDI)un;eaO_45Mgxv}Ws%wuAwB?`mGwkS4d2I-G{o|@EMxd{ z_!|N#*aRx@ekat`1=_&eT+GrUI|_xGE9_YMYGu-P#h;EtKq)%GzWcaEqw>AiVmKXb zBaQv>C)m3H;iY@}2+dDJAM>GUbjO*Jg!Idz+iMDbV%{OfPLTei5p-F5hIEMO{72Zbr(de@zw^P%(Akwz@u|j3OQn3 z!keE#x%%~(t2(yF)Blh*;-mp58A>)6s?e6X$voRX)9b>i69njBg&jN%U5JA;VW!C) zXTL+*oTOA}%0*Jixn?X*V%_TaA6{4=LYamaN=OBFdKY|kOf9MrTGnm;sqJB-iQ`)iw>)t?zZ&hdx6CT zAE>i^dpESFkkCRW6S5L9>%iS*gL_sKtPbthUqHG%f0;_C#KjEjf=^4l;p=T<$_vX~ z4;{AA{5(Ffoh2|^n-X;<%B6lkFC}WMEei?}LVr4{MS8uY%+%F6-PM>J95Bxlsu6?~ z@MP!7_@TL}DQRXA8jc{%^=kvm@h2V424=)Z)b`ejf;I{n(2@XX!MT8}jsCm zkHJ9iNf(T9V8aN-SPcfrK0emxc;2S*P5I`(&dzHAeFE1P1jOq95p5Yx(UrXKS^?be zvfr=1aHAN-zqOYru)B|egN5anLBH{I1t1bFm_#jXrnqdGZ5%otmIfJ}!tCeR@ft1Kiu)-Ayru zHN0w&hwt+Pge^(P8Auxw^y~X6-n}z{`VQfPuI9_dsYg%^96^>my|6L=+4tXP}s46O7#3A1WKpfe>^W*t{nEAtO|MC|dK1A{ZXMW(!|1-|~z+4}g z3o!7(0zwe}XHT%wvHgf9j8xHj9c7c_bp>UDdjB=Q%_xMzKK%0`2LCT{kgsBV=g9!3 SB!_|IhLiniyV_$He*6#NuYO