diff --git a/modules/juce_graphics/fonts/juce_GlyphArrangement.cpp b/modules/juce_graphics/fonts/juce_GlyphArrangement.cpp index 74f72feb13..a597bda0e8 100644 --- a/modules/juce_graphics/fonts/juce_GlyphArrangement.cpp +++ b/modules/juce_graphics/fonts/juce_GlyphArrangement.cpp @@ -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); } //============================================================================== diff --git a/modules/juce_graphics/fonts/juce_SimpleShapedText.cpp b/modules/juce_graphics/fonts/juce_SimpleShapedText.cpp index e0bb709edf..a6ea8e1f23 100644 --- a/modules/juce_graphics/fonts/juce_SimpleShapedText.cpp +++ b/modules/juce_graphics/fonts/juce_SimpleShapedText.cpp @@ -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::max(); String ellipsis; @@ -392,7 +399,7 @@ static std::vector 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 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::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 glyphs; + Range 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 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 + { + 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);