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:
parent
7cbdd14da9
commit
5e4016b4fb
2 changed files with 217 additions and 66 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue