1
0
Fork 0
mirror of https://github.com/juce-framework/JUCE.git synced 2026-01-10 23:44:24 +00:00

GlyphArrangement::addFittedText: Break words across multiple lines if necessary

This behaviour, previously available in JUCE 7, was missing since the
JUCE 8 changes related to Unicode text drawing.

With this commit, words that are too long to fit in a line are again
broken up, with the caveat, that we can expect this approach to produce
quirks with bidirectional text. We don't expect that such a feature
could be satisfactorily provided for bidirectional text, so this is a
stopgap measure for legacy applications.
This commit is contained in:
attila 2024-09-03 17:14:10 +02:00
parent 7cbdd14da9
commit 5e4016b4fb
2 changed files with 217 additions and 66 deletions

View file

@ -43,6 +43,24 @@ static constexpr bool isNonBreakingSpace (const juce_wchar c)
|| c == 0x2060;
}
static bool areAllRequiredWidthsSmallerThanMax (const ShapedText& shapedText, float width)
{
const auto lineWidths = shapedText.getMinimumRequiredWidthForLines();
return std::all_of (lineWidths.begin(), lineWidths.end(), [width] (auto& w) { return w <= width; });
}
// ShapedText truncates the last line by default, even if it requires larger width than the maximum
// allowed.
static bool areAllRequiredWidthsExceptTheLastSmallerThanMax (const ShapedText& shapedText, float width)
{
const auto lineWidths = shapedText.getMinimumRequiredWidthForLines();
if (lineWidths.empty())
return true;
return std::all_of (lineWidths.begin(), std::prev (lineWidths.end()), [width] (auto& w) { return w <= width; });
}
PositionedGlyph::PositionedGlyph() noexcept
: character (0), glyph (0), x (0), y (0), w (0), whitespace (false)
{
@ -210,15 +228,14 @@ void GlyphArrangement::addJustifiedText (const Font& font, const String& text,
addGlyphsFromShapedText (*this, st, x, y);
}
void GlyphArrangement::addFittedText (const Font& f,
const String& text,
float x,
float y,
float width,
float height,
Justification layout,
int maximumLines,
float minimumHorizontalScale)
static auto createFittedText (const Font& f,
const String& text,
float width,
float height,
Justification layout,
int maximumLines,
float minimumHorizontalScale,
ShapedText::Options baseOptions = {})
{
if (! layout.testFlags (Justification::bottom | Justification::top))
layout = layout.getOnlyHorizontalFlags() | Justification::verticallyCentred;
@ -232,16 +249,14 @@ void GlyphArrangement::addFittedText (const Font& f,
if (text.containsAnyOf ("\r\n"))
{
ShapedText st { text,
ShapedText::Options{}
baseOptions
.withMaxWidth (width)
.withHeight (height)
.withJustification (layout)
.withFont (f)
.withTrailingWhitespacesShouldFit (false) };
addGlyphsFromShapedText (*this, st, x, y);
return;
return st;
}
const auto trimmed = text.trim();
@ -250,41 +265,37 @@ void GlyphArrangement::addFittedText (const Font& f,
// First attempt: try to squash the entire text on a single line
{
ShapedText st { trimmed, ShapedText::Options{}.withFont (f)
.withMaxWidth (width)
.withHeight (height)
.withMaxNumLines (1)
.withJustification (layout)
.withTrailingWhitespacesShouldFit (false) };
ShapedText st { trimmed, baseOptions.withFont (f)
.withMaxWidth (width)
.withHeight (height)
.withMaxNumLines (1)
.withJustification (layout)
.withTrailingWhitespacesShouldFit (false) };
const auto requiredWidths = st.getMinimumRequiredWidthForLines();
if (requiredWidths.empty() || requiredWidths.front() <= width)
{
addGlyphsFromShapedText (*this, st, x, y);
return;
}
return st;
// If we can fit the entire line, squash by just enough and insert
if (requiredWidths.front() * minimumHorizontalScale < width)
{
ShapedText squashed { trimmed,
ShapedText::Options{}
baseOptions
.withFont (f.withHorizontalScale (width / (requiredWidths.front() + widthFittingTolerance)))
.withMaxWidth (width)
.withHeight (height)
.withJustification (layout)
.withTrailingWhitespacesShouldFit (false)};
addGlyphsFromShapedText (*this, squashed, x, y);
return;
return squashed;
}
}
if (maximumLines <= 1)
{
ShapedText squashed { trimmed,
ShapedText::Options{}
baseOptions
.withFont (f.withHorizontalScale (minimumHorizontalScale))
.withMaxWidth (width)
.withHeight (height)
@ -292,8 +303,7 @@ void GlyphArrangement::addFittedText (const Font& f,
.withMaxNumLines (1)
.withEllipsis() };
addGlyphsFromShapedText (*this, squashed, x, y);
return;
return squashed;
}
// Keep reshaping the text constantly decreasing the fontsize and increasing the number of lines
@ -310,12 +320,6 @@ void GlyphArrangement::addFittedText (const Font& f,
auto font = f;
auto cumulativeLineLengths = font.getHeight() * 1.4f;
const auto isFittingAllText = [width] (auto& shapedText)
{
const auto lineWidths = shapedText.getMinimumRequiredWidthForLines();
return std::all_of (lineWidths.begin(), lineWidths.end(), [width] (auto& w) { return w <= width; });
};
while (numLines < maximumLines)
{
++numLines;
@ -325,7 +329,7 @@ void GlyphArrangement::addFittedText (const Font& f,
font.setHeight (jmax (8.0f, newFontHeight));
ShapedText squashed { trimmed,
ShapedText::Options{}
baseOptions
.withFont (font)
.withMaxWidth (width)
.withHeight (height)
@ -333,11 +337,8 @@ void GlyphArrangement::addFittedText (const Font& f,
.withJustification (layout)
.withTrailingWhitespacesShouldFit (false) };
if (isFittingAllText (squashed))
{
addGlyphsFromShapedText (*this, squashed, x, y);
return;
}
if (areAllRequiredWidthsSmallerThanMax (squashed, width))
return squashed;
const auto lineWidths = squashed.getMinimumRequiredWidthForLines();
@ -351,18 +352,13 @@ void GlyphArrangement::addFittedText (const Font& f,
break;
}
// At this point we failed to fit the text by just increasing the number of lines and decreasing
// the font size. Horizontal squashing is also necessary, for which horizontal justification is
// enabled.
layout = layout.getOnlyVerticalFlags() | Justification::horizontallyJustified;
//==============================================================================
// We run an iterative interval halving algorithm to find the largest scale that can fit all
// text
auto makeShapedText = [&] (float horizontalScale)
{
return ShapedText { trimmed,
ShapedText::Options{}
baseOptions
.withFont (font.withHorizontalScale (horizontalScale))
.withMaxWidth (width)
.withHeight (height)
@ -378,11 +374,10 @@ void GlyphArrangement::addFittedText (const Font& f,
(float) numLines * width / cumulativeLineLengths);
if (auto st = makeShapedText (upperScaleBound);
isFittingAllText (st)
areAllRequiredWidthsSmallerThanMax (st, width)
|| approximatelyEqual (upperScaleBound, minimumHorizontalScale))
{
addGlyphsFromShapedText (*this, st, x, y);
return;
return st;
}
struct Candidate
@ -398,7 +393,7 @@ void GlyphArrangement::addFittedText (const Font& f,
auto scale = jmap (0.5f, lowerScaleBound, upperScaleBound);
if (auto st = makeShapedText (scale);
isFittingAllText (st))
areAllRequiredWidthsSmallerThanMax (st, width))
{
lowerScaleBound = std::max (lowerScaleBound, scale);
@ -414,14 +409,68 @@ void GlyphArrangement::addFittedText (const Font& f,
}
}
if (approximatelyEqual (candidate.scale, minimumHorizontalScale)
|| candidate.shapedText.getMinimumRequiredWidthForLines().back() <= width)
const auto scalePerfectlyFittingTheLongestLine = [&]
{
addGlyphsFromShapedText (*this, candidate.shapedText, x, y);
const auto lineWidths = candidate.shapedText.getMinimumRequiredWidthForLines();
const auto greatestLineWidth = std::accumulate (lineWidths.begin(),
lineWidths.end(),
0.0f,
[] (auto acc, auto w) { return std::max (acc, w); });
if (exactlyEqual (greatestLineWidth, 0.0f))
return candidate.scale;
return jlimit (candidate.scale,
1.0f,
candidate.scale * width / (greatestLineWidth + widthFittingTolerance));
}();
if (candidate.scale < scalePerfectlyFittingTheLongestLine)
{
if (auto st = makeShapedText (scalePerfectlyFittingTheLongestLine);
areAllRequiredWidthsSmallerThanMax (st, width))
{
candidate.scale = scalePerfectlyFittingTheLongestLine;
candidate.shapedText = std::move (st);
}
}
return candidate.shapedText;
}
void GlyphArrangement::addFittedText (const Font& f,
const String& text,
float x,
float y,
float width,
float height,
Justification layout,
int maximumLines,
float minimumHorizontalScale)
{
const auto st = createFittedText (f, text, width, height, layout, maximumLines, minimumHorizontalScale);
// ShapedText has the feature for visually truncating the last line, and createFittedText() uses
// it. Hence if it's only the last line that requires a larger width, ShapedText will take care
// of it. If lines other than the last one require more width than the minimum, it means they
// contain a single unbreakable word, and the shaping needs to be redone with breaks inside
// words allowed.
if (areAllRequiredWidthsExceptTheLastSmallerThanMax (st, width))
{
addGlyphsFromShapedText (*this, st, x, y);
return;
}
addGlyphsFromShapedText (*this, candidate.shapedText, x, y);
const auto stWithWordBreaks = createFittedText (f,
text,
width,
height,
layout,
maximumLines,
minimumHorizontalScale,
ShapedText::Options{}.withAllowBreakingInsideWord());
addGlyphsFromShapedText (*this, stWithWordBreaks, x, y);
}
//==============================================================================

View file

@ -119,6 +119,11 @@ public:
return withMember (*this, &ShapedTextOptions::readingDir, x);
}
[[nodiscard]] ShapedTextOptions withAllowBreakingInsideWord (bool x = true) const
{
return withMember (*this, &ShapedTextOptions::allowBreakingInsideWord, x);
}
const auto& getReadingDirection() const { return readingDir; }
const auto& getJustification() const { return justification; }
const auto& getMaxWidth() const { return maxWidth; }
@ -132,6 +137,7 @@ public:
const auto& getTrailingWhitespacesShouldFit() const { return trailingWhitespacesShouldFit; }
const auto& getMaxNumLines() const { return maxNumLines; }
const auto& getEllipsis() const { return ellipsis; }
const auto& getAllowBreakingInsideWord() const { return allowBreakingInsideWord; }
private:
Justification justification { Justification::topLeft };
@ -145,6 +151,7 @@ private:
float leading = 1.0f;
float additiveLineSpacing = 0.0f;
bool baselineAtZero = false;
bool allowBreakingInsideWord = false;
bool trailingWhitespacesShouldFit;
int64 maxNumLines = std::numeric_limits<int64>::max();
String ellipsis;
@ -392,7 +399,7 @@ static std::vector<ShapedGlyph> lowLevelShape (const String& string,
HbBuffer buffer { hb_buffer_create() };
hb_buffer_clear_contents (buffer.get());
hb_buffer_set_cluster_level (buffer.get(), HB_BUFFER_CLUSTER_LEVEL_MONOTONE_CHARACTERS);
hb_buffer_set_cluster_level (buffer.get(), HB_BUFFER_CLUSTER_LEVEL_MONOTONE_GRAPHEMES);
hb_buffer_set_script (buffer.get(), getScriptTag (script));
hb_buffer_set_language (buffer.get(), hb_language_from_string (language.toRawUTF8(), -1));
@ -1009,12 +1016,21 @@ void SimpleShapedText::shape (const String& data,
remainingWidth = options.getMaxWidth();
};
enum class CanAddGlyphsBeyondLineLimits
{
no,
yes
};
const auto append = [&] (const BidiParagraph& bidiParagraph, Range<int64> range, const ShapingParams& shapingParams)
{
jassert (! range.isEmpty());
ConsumableGlyphs glyphsToConsume { data, range, shapingParams };
const auto appendingToFirstLine = [&] { return lineNumbers.isEmpty(); };
const auto appendingToLastLine = [&] { return (int64) lineNumbers.size() == options.getMaxNumLines() - 1; };
while (! glyphsToConsume.isEmpty())
{
const auto remainingCodepointsToConsume = glyphsToConsume.getCodepointRange();
@ -1038,8 +1054,7 @@ void SimpleShapedText::shape (const String& data,
static constexpr auto floatMax = std::numeric_limits<float>::max();
for (auto breakBefore = softBreakIterator.next();
breakBefore.has_value() && (lineNumbers.isEmpty()
|| (int64) lineNumbers.size() < options.getMaxNumLines() - 1);
breakBefore.has_value() && (appendingToFirstLine() || ! appendingToLastLine());
breakBefore = softBreakIterator.next())
{
if (auto safeAdvance = glyphsToConsume.getAdvanceXUpToBreakPointIfSafe (*breakBefore,
@ -1098,7 +1113,13 @@ void SimpleShapedText::shape (const String& data,
jassert (bestMatch.has_value());
const auto consumeBestMatch = [&]
struct ConsumedGlyphs
{
std::vector<ShapedGlyph> glyphs;
Range<int64> textRange;
};
const auto consumeGlyphs = [&]() -> ConsumedGlyphs
{
auto glyphs = [&]
{
@ -1110,30 +1131,111 @@ void SimpleShapedText::shape (const String& data,
const auto textRange = glyphsToConsume.getCodepointRange().withEnd (bestMatch->breakBefore);
std::vector<ShapedGlyph> copiedGlyphs { glyphs.begin(), glyphs.end() };
glyphsToConsume.breakBeforeAndConsume (bestMatch->breakBefore);
return { copiedGlyphs, textRange };
};
const auto addGlyphsToLine = [&] (const ConsumedGlyphs& toAdd,
CanAddGlyphsBeyondLineLimits evenIfFull) -> ConsumedGlyphs
{
const auto glyphsEnd = [&]
{
if (evenIfFull == CanAddGlyphsBeyondLineLimits::yes || ! remainingWidth.has_value())
return toAdd.glyphs.end();
auto it = toAdd.glyphs.begin();
for (float advance = 0.0f; it != toAdd.glyphs.end();)
{
const auto clusterEnd = std::find_if (it,
toAdd.glyphs.end(),
[cluster = it->cluster] (const auto& g)
{
return g.cluster != cluster;
});
advance = std::accumulate (it,
clusterEnd,
advance,
[] (auto acc, const auto& g)
{
return acc + g.advance.getX();
});
// Consume at least one glyph in each line, even if the line is too short.
if (advance > *remainingWidth
&& (numGlyphsInLine == 0 && it != toAdd.glyphs.begin()))
{
break;
}
it = clusterEnd;
}
if (options.getTrailingWhitespacesShouldFit() || (numGlyphsInLine == 0 && it == toAdd.glyphs.begin()))
return it;
return std::find_if (it, toAdd.glyphs.end(), [] (const auto& x) { return ! x.whitespace; });
}();
const auto numGlyphsAdded = (int64) std::distance (toAdd.glyphs.begin(), glyphsEnd);
const auto textRange = [&]() -> Range<int64>
{
if (glyphsEnd == toAdd.glyphs.end())
return toAdd.textRange;
return { toAdd.textRange.getStart(), glyphsEnd->cluster };
}();
lineChunks.push_back ({ textRange,
{ glyphs.begin(), glyphs.end() },
{ toAdd.glyphs.begin(), glyphsEnd },
shapingParams.resolvedFont,
shapingParams.embeddingLevel });
numGlyphsInLine += (int64) glyphs.size();
numGlyphsInLine += numGlyphsAdded;
if (remainingWidth.has_value())
remainingWidth = *remainingWidth - bestMatch->advance.includingTrailingWhitespace;
{
*remainingWidth -= std::accumulate (toAdd.glyphs.begin(),
glyphsEnd,
0.0f,
[] (auto acc, auto& g) { return acc + g.advance.getX(); });
}
glyphsToConsume.breakBeforeAndConsume (bestMatch->breakBefore);
return { { glyphsEnd, toAdd.glyphs.end() }, toAdd.textRange.withStart (textRange.getEnd()) };
};
if (bestMatch->advance.maybeIgnoringWhitespace >= remainingWidth.value_or (floatMax))
{
// Even an empty line is too short to fit any of the text
if (numGlyphsInLine == 0 && exactlyEqual (remainingWidth, options.getMaxWidth()))
consumeBestMatch();
{
auto glyphsToAdd = consumeGlyphs();
commitLine (bidiParagraph);
while (! glyphsToAdd.glyphs.empty())
{
glyphsToAdd = addGlyphsToLine (glyphsToAdd,
(appendingToLastLine() || ! options.getAllowBreakingInsideWord()) ? CanAddGlyphsBeyondLineLimits::yes
: CanAddGlyphsBeyondLineLimits::no);
if (! glyphsToAdd.glyphs.empty())
commitLine (bidiParagraph);
}
}
else
{
commitLine (bidiParagraph);
}
}
else
{
consumeBestMatch();
[[maybe_unused]] const auto remainder = addGlyphsToLine (consumeGlyphs(),
CanAddGlyphsBeyondLineLimits::yes);
jassert (remainder.glyphs.empty());
if (! glyphsToConsume.isEmpty())
commitLine (bidiParagraph);