diff --git a/modules/juce_graphics/fonts/juce_GlyphArrangement.cpp b/modules/juce_graphics/fonts/juce_GlyphArrangement.cpp index 9029f807a4..b16e3ca36a 100644 --- a/modules/juce_graphics/fonts/juce_GlyphArrangement.cpp +++ b/modules/juce_graphics/fonts/juce_GlyphArrangement.cpp @@ -153,83 +153,46 @@ void GlyphArrangement::addLineOfText (const Font& font, const String& text, floa addCurtailedLineOfText (font, text, xOffset, yOffset, 1.0e10f, false); } +static void addGlyphsFromShapedText (GlyphArrangement& ga, const ShapedText& st, float x, float y) +{ + st.access ([&] (auto shapedGlyphs, auto positions, auto font, auto glyphRange, auto) + { + for (size_t i = 0; i < shapedGlyphs.size(); ++i) + { + const auto glyphIndex = (int64) i + glyphRange.getStart(); + + auto& glyph = shapedGlyphs[i]; + auto& position = positions[i]; + + PositionedGlyph pg { font, + st.getText()[(int) st.getTextRange (glyphIndex).getStart()], + (int) glyph.glyphId, + position.getX() + x, + position.getY() + y, + glyph.advance.getX(), + glyph.whitespace }; + + ga.addGlyph (std::move (pg)); + } + }); +} + void GlyphArrangement::addCurtailedLineOfText (const Font& font, const String& text, float xOffset, float yOffset, float maxWidthPixels, bool useEllipsis) { - if (text.isNotEmpty()) - { - Array newGlyphs; - Array xOffsets; - font.getGlyphPositions (text, newGlyphs, xOffsets); - auto textLen = newGlyphs.size(); - glyphs.ensureStorageAllocated (glyphs.size() + textLen); + auto options = ShapedText::Options{}.withMaxNumLines (1) + .withMaxWidth (maxWidthPixels) + .withFont (font) + .withBaselineAtZero() + .withTrailingWhitespacesShouldFit (false); - auto t = text.getCharPointer(); + if (useEllipsis) + options = options.withEllipsis(); - for (int i = 0; i < textLen; ++i) - { - auto nextX = xOffsets.getUnchecked (i + 1); + ShapedText st { text, options }; - if (nextX > maxWidthPixels + 1.0f) - { - // curtail the string if it's too wide.. - if (useEllipsis && textLen > 3 && glyphs.size() >= 3) - insertEllipsis (font, xOffset + maxWidthPixels, 0, glyphs.size()); - - break; - } - - auto thisX = xOffsets.getUnchecked (i); - auto isWhitespace = isNonBreakingSpace (*t) || t.isWhitespace(); - - glyphs.add (PositionedGlyph (font, t.getAndAdvance(), - newGlyphs.getUnchecked (i), - xOffset + thisX, yOffset, - nextX - thisX, isWhitespace)); - } - } -} - -int GlyphArrangement::insertEllipsis (const Font& font, float maxXPos, int startIndex, int endIndex) -{ - int numDeleted = 0; - - if (! glyphs.isEmpty()) - { - Array dotGlyphs; - Array dotXs; - font.getGlyphPositions ("..", dotGlyphs, dotXs); - - auto dx = dotXs[1]; - float xOffset = 0.0f, yOffset = 0.0f; - - while (endIndex > startIndex) - { - auto& pg = glyphs.getReference (--endIndex); - xOffset = pg.x; - yOffset = pg.y; - - glyphs.remove (endIndex); - ++numDeleted; - - if (xOffset + dx * 3 <= maxXPos) - break; - } - - for (int i = 3; --i >= 0;) - { - glyphs.insert (endIndex++, PositionedGlyph (font, '.', dotGlyphs.getFirst(), - xOffset, yOffset, dx, false)); - --numDeleted; - xOffset += dx; - - if (xOffset > maxXPos) - break; - } - } - - return numDeleted; + addGlyphsFromShapedText (*this, st, xOffset, yOffset); } void GlyphArrangement::addJustifiedText (const Font& font, const String& text, @@ -237,88 +200,29 @@ void GlyphArrangement::addJustifiedText (const Font& font, const String& text, Justification horizontalLayout, float leading) { - auto lineStartIndex = glyphs.size(); - addLineOfText (font, text, x, y); + ShapedText st { text, ShapedText::Options{}.withMaxWidth (maxLineWidth) + .withJustification (horizontalLayout) + .withFont (font) + .withLeading (1.0f + leading / font.getHeight()) + .withBaselineAtZero () + .withTrailingWhitespacesShouldFit (false) }; - auto originalY = y; - - while (lineStartIndex < glyphs.size()) - { - int i = lineStartIndex; - - if (glyphs.getReference (i).getCharacter() != '\n' - && glyphs.getReference (i).getCharacter() != '\r') - ++i; - - auto lineMaxX = glyphs.getReference (lineStartIndex).getLeft() + maxLineWidth; - int lastWordBreakIndex = -1; - - while (i < glyphs.size()) - { - auto& pg = glyphs.getReference (i); - auto c = pg.getCharacter(); - - if (c == '\r' || c == '\n') - { - ++i; - - if (c == '\r' && i < glyphs.size() - && glyphs.getReference (i).getCharacter() == '\n') - ++i; - - break; - } - - if (pg.isWhitespace()) - { - lastWordBreakIndex = i + 1; - } - else if (pg.getRight() - 0.0001f >= lineMaxX) - { - if (lastWordBreakIndex >= 0) - i = lastWordBreakIndex; - - break; - } - - ++i; - } - - auto currentLineStartX = glyphs.getReference (lineStartIndex).getLeft(); - auto currentLineEndX = currentLineStartX; - - for (int j = i; --j >= lineStartIndex;) - { - if (! glyphs.getReference (j).isWhitespace()) - { - currentLineEndX = glyphs.getReference (j).getRight(); - break; - } - } - - float deltaX = 0.0f; - - if (horizontalLayout.testFlags (Justification::horizontallyJustified)) - spreadOutLine (lineStartIndex, i - lineStartIndex, maxLineWidth); - else if (horizontalLayout.testFlags (Justification::horizontallyCentred)) - deltaX = (maxLineWidth - (currentLineEndX - currentLineStartX)) * 0.5f; - else if (horizontalLayout.testFlags (Justification::right)) - deltaX = maxLineWidth - (currentLineEndX - currentLineStartX); - - moveRangeOfGlyphs (lineStartIndex, i - lineStartIndex, - x + deltaX - currentLineStartX, y - originalY); - - lineStartIndex = i; - - y += font.getHeight() + leading; - } + 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, +void GlyphArrangement::addFittedText (const Font& f, + const String& text, + float x, + float y, + float width, + float height, + Justification layout, + int maximumLines, float minimumHorizontalScale) { + if (! layout.testFlags (Justification::bottom)) + layout = layout.getOnlyHorizontalFlags() | Justification::verticallyCentred; + if (approximatelyEqual (minimumHorizontalScale, 0.0f)) minimumHorizontalScale = Font::getDefaultMinimumHorizontalScaleFactor(); @@ -327,42 +231,194 @@ void GlyphArrangement::addFittedText (const Font& f, const String& text, if (text.containsAnyOf ("\r\n")) { - addLinesWithLineBreaks (text, f, x, y, width, height, layout); + ShapedText st { text, + ShapedText::Options{} + .withMaxWidth (width) + .withHeight (height) + .withJustification (layout) + .withFont (f) + .withTrailingWhitespacesShouldFit (false) }; + + addGlyphsFromShapedText (*this, st, x, y); + + return; } - else + + const auto trimmed = text.trim(); + + // First attempt: try to squash the entire text on a single line { - auto startIndex = glyphs.size(); - auto trimmed = text.trim(); - addLineOfText (f, trimmed, x, y); - auto numGlyphs = glyphs.size() - startIndex; + ShapedText st { trimmed, ShapedText::Options{}.withFont (f) + .withMaxWidth (width) + .withHeight (height) + .withMaxNumLines (1) + .withJustification (layout) + .withTrailingWhitespacesShouldFit (false) }; - if (numGlyphs > 0) + const auto requiredWidths = st.getMinimumRequiredWidthForLines(); + + if (requiredWidths.empty() || requiredWidths.front() <= width) { - auto lineWidth = glyphs.getReference (glyphs.size() - 1).getRight() - - glyphs.getReference (startIndex).getLeft(); + addGlyphsFromShapedText (*this, st, x, y); + return; + } - if (lineWidth > 0) - { - if (lineWidth * minimumHorizontalScale < width) - { - if (lineWidth > width) - stretchRangeOfGlyphs (startIndex, numGlyphs, width / lineWidth); + // If we can fit the entire line, squash by just enough and insert + if (requiredWidths.front() * minimumHorizontalScale < width) + { + ShapedText squashed { trimmed, + ShapedText::Options{} + .withFont (f.withHorizontalScale (width / (requiredWidths.front() + 0.01f))) + .withMaxWidth (width) + .withHeight (height) + .withJustification (layout) + .withTrailingWhitespacesShouldFit (false)}; - justifyGlyphs (startIndex, numGlyphs, x, y, width, height, layout); - } - else if (maximumLines <= 1) - { - fitLineIntoSpace (startIndex, numGlyphs, x, y, width, height, - f, layout, minimumHorizontalScale); - } - else - { - splitLines (trimmed, f, startIndex, x, y, width, height, - maximumLines, lineWidth, layout, minimumHorizontalScale); - } - } + addGlyphsFromShapedText (*this, squashed, x, y); + return; } } + + if (maximumLines <= 1) + { + ShapedText squashed { trimmed, + ShapedText::Options{} + .withFont (f.withHorizontalScale (minimumHorizontalScale)) + .withMaxWidth (width) + .withHeight (height) + .withJustification (layout) + .withMaxNumLines (1) + .withEllipsis() }; + + addGlyphsFromShapedText (*this, squashed, x, y); + return; + } + + // Keep reshaping the text constantly decreasing the fontsize and increasing the number of lines + // until all text fits. + + auto length = trimmed.length(); + int numLines = 1; + + if (length <= 12 && ! trimmed.containsAnyOf (" -\t\r\n")) + maximumLines = 1; + + maximumLines = jmin (maximumLines, length); + + auto font = f; + float cumulativeLineLengths{}; + + while (numLines < maximumLines) + { + ++numLines; + auto newFontHeight = height / (float) numLines; + + if (newFontHeight < font.getHeight()) + font.setHeight (jmax (8.0f, newFontHeight)); + + ShapedText squashed { trimmed, + ShapedText::Options{} + .withFont (font) + .withMaxWidth (width) + .withHeight (height) + .withMaxNumLines (numLines) + .withJustification (layout) + .withTrailingWhitespacesShouldFit (false) }; + + const auto lineWidths = squashed.getMinimumRequiredWidthForLines(); + + if (lineWidths.empty() || lineWidths.back() <= width) + { + addGlyphsFromShapedText (*this, squashed, x, y); + return; + } + + // We're trying to guess how much horizontal space the text would need to fit, and we + // need to have an allowance for line end whitespaces which take up additional room + // when not falling at the end of lines. + cumulativeLineLengths = std::accumulate (lineWidths.begin(), lineWidths.end(), 0.0f) + + font.getHeight() * (float) numLines * 1.4f; + + if (newFontHeight < 8.0f) + 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{} + .withFont (font.withHorizontalScale (horizontalScale)) + .withMaxWidth (width) + .withHeight (height) + .withMaxNumLines (numLines) + .withJustification (layout) + .withTrailingWhitespacesShouldFit (false) + .withEllipsis() }; + }; + + auto lowerScaleBound = minimumHorizontalScale; + auto upperScaleBound = std::max (minimumHorizontalScale, ((float) numLines * width) / cumulativeLineLengths); + + const auto isFittingAllText = [width] (auto& shapedText) + { + // ShapedText guarantees that all lines maybe except the last - when there is a maximum line + // limit - will fit the requested width + return shapedText.getMinimumRequiredWidthForLines().back() <= width; + }; + + if (auto st = makeShapedText (upperScaleBound); + isFittingAllText (st) + || approximatelyEqual (upperScaleBound, minimumHorizontalScale)) + { + addGlyphsFromShapedText (*this, st, x, y); + return; + } + + struct Candidate + { + float scale{}; + ShapedText shapedText; + }; + + Candidate candidate { minimumHorizontalScale, makeShapedText (minimumHorizontalScale) }; + + for (int i = 0, numApproximatingIterations = 3; i < numApproximatingIterations; ++i) + { + auto scale = (upperScaleBound - lowerScaleBound) / 2.0f; + + if (auto st = makeShapedText (scale); + isFittingAllText (st)) + { + lowerScaleBound = std::max (lowerScaleBound, scale); + + if (candidate.scale < scale) + { + candidate.scale = scale; + candidate.shapedText = std::move (st); + } + } + else + { + upperScaleBound = std::min (upperScaleBound, scale); + } + } + + if (approximatelyEqual (candidate.scale, minimumHorizontalScale) + || candidate.shapedText.getMinimumRequiredWidthForLines().back() <= width) + { + addGlyphsFromShapedText (*this, candidate.shapedText, x, y); + return; + } + + addGlyphsFromShapedText (*this, candidate.shapedText, x, y); } //============================================================================== @@ -380,49 +436,6 @@ void GlyphArrangement::moveRangeOfGlyphs (int startIndex, int num, const float d } } -void GlyphArrangement::addLinesWithLineBreaks (const String& text, const Font& f, - float x, float y, float width, float height, Justification layout) -{ - GlyphArrangement ga; - ga.addJustifiedText (f, text, x, y, width, layout); - - auto bb = ga.getBoundingBox (0, -1, false); - auto dy = y - bb.getY(); - - if (layout.testFlags (Justification::verticallyCentred)) dy += (height - bb.getHeight()) * 0.5f; - else if (layout.testFlags (Justification::bottom)) dy += (height - bb.getHeight()); - - ga.moveRangeOfGlyphs (0, -1, 0.0f, dy); - - glyphs.addArray (ga.glyphs); -} - -int GlyphArrangement::fitLineIntoSpace (int start, int numGlyphs, float x, float y, float w, float h, const Font& font, - Justification justification, float minimumHorizontalScale) -{ - int numDeleted = 0; - auto lineStartX = glyphs.getReference (start).getLeft(); - auto lineWidth = glyphs.getReference (start + numGlyphs - 1).getRight() - lineStartX; - - if (lineWidth > w) - { - if (minimumHorizontalScale < 1.0f) - { - stretchRangeOfGlyphs (start, numGlyphs, jmax (minimumHorizontalScale, w / lineWidth)); - lineWidth = glyphs.getReference (start + numGlyphs - 1).getRight() - lineStartX - 0.5f; - } - - if (lineWidth > w) - { - numDeleted = insertEllipsis (font, lineStartX + w, start, start + numGlyphs); - numGlyphs -= numDeleted; - } - } - - justifyGlyphs (start, numGlyphs, x, y, w, h, justification); - return numDeleted; -} - void GlyphArrangement::stretchRangeOfGlyphs (int startIndex, int num, float horizontalScaleFactor) { jassert (startIndex >= 0); @@ -556,145 +569,6 @@ void GlyphArrangement::spreadOutLine (int start, int num, float targetWidth) } } -static bool isBreakableGlyph (const PositionedGlyph& g) noexcept -{ - return ! isNonBreakingSpace (g.getCharacter()) && (g.isWhitespace() || g.getCharacter() == '-'); -} - -void GlyphArrangement::splitLines (const String& text, Font font, int startIndex, - float x, float y, float width, float height, int maximumLines, - float lineWidth, Justification layout, float minimumHorizontalScale) -{ - auto length = text.length(); - auto originalStartIndex = startIndex; - int numLines = 1; - - if (length <= 12 && ! text.containsAnyOf (" -\t\r\n")) - maximumLines = 1; - - maximumLines = jmin (maximumLines, length); - - while (numLines < maximumLines) - { - ++numLines; - auto newFontHeight = height / (float) numLines; - - if (newFontHeight < font.getHeight()) - { - font.setHeight (jmax (8.0f, newFontHeight)); - - removeRangeOfGlyphs (startIndex, -1); - addLineOfText (font, text, x, y); - - lineWidth = glyphs.getReference (glyphs.size() - 1).getRight() - - glyphs.getReference (startIndex).getLeft(); - } - - // Try to estimate the point at which there are enough lines to fit the text, - // allowing for unevenness in the lengths due to differently sized words. - const float lineLengthUnevennessAllowance = 80.0f; - - if ((float) numLines > (lineWidth + lineLengthUnevennessAllowance) / width || newFontHeight < 8.0f) - break; - } - - if (numLines < 1) - numLines = 1; - - int lineIndex = 0; - auto lineY = y; - auto widthPerLine = jmin (width / minimumHorizontalScale, - lineWidth / (float) numLines); - - while (lineY < y + height) - { - auto endIndex = startIndex; - auto lineStartX = glyphs.getReference (startIndex).getLeft(); - auto lineBottomY = lineY + font.getHeight(); - - if (lineIndex++ >= numLines - 1 - || lineBottomY >= y + height) - { - widthPerLine = width; - endIndex = glyphs.size(); - } - else - { - while (endIndex < glyphs.size()) - { - if (glyphs.getReference (endIndex).getRight() - lineStartX > widthPerLine) - { - // got to a point where the line's too long, so skip forward to find a - // good place to break it.. - auto searchStartIndex = endIndex; - - while (endIndex < glyphs.size()) - { - auto& g = glyphs.getReference (endIndex); - - if ((g.getRight() - lineStartX) * minimumHorizontalScale < width) - { - if (isBreakableGlyph (g)) - { - ++endIndex; - break; - } - } - else - { - // can't find a suitable break, so try looking backwards.. - endIndex = searchStartIndex; - - for (int back = 1; back < jmin (7, endIndex - startIndex - 1); ++back) - { - if (isBreakableGlyph (glyphs.getReference (endIndex - back))) - { - endIndex -= back - 1; - break; - } - } - - break; - } - - ++endIndex; - } - - break; - } - - ++endIndex; - } - - auto wsStart = endIndex; - auto wsEnd = endIndex; - - while (wsStart > 0 && glyphs.getReference (wsStart - 1).isWhitespace()) - --wsStart; - - while (wsEnd < glyphs.size() && glyphs.getReference (wsEnd).isWhitespace()) - ++wsEnd; - - removeRangeOfGlyphs (wsStart, wsEnd - wsStart); - endIndex = jmax (wsStart, startIndex + 1); - } - - endIndex -= fitLineIntoSpace (startIndex, endIndex - startIndex, - x, lineY, width, font.getHeight(), font, - layout.getOnlyHorizontalFlags() | Justification::verticallyCentred, - minimumHorizontalScale); - - startIndex = endIndex; - lineY = lineBottomY; - - if (startIndex >= glyphs.size()) - break; - } - - justifyGlyphs (originalStartIndex, glyphs.size() - originalStartIndex, - x, y, width, height, layout.getFlags() & ~Justification::horizontallyJustified); -} - //============================================================================== void GlyphArrangement::drawGlyphUnderline (const Graphics& g, int i, @@ -730,6 +604,10 @@ void GlyphArrangement::draw (const Graphics& g, AffineTransform transform) const glyphNumbers.reserve (static_cast (glyphs.size())); positions.reserve (static_cast (glyphs.size())); + auto& ctx = g.getInternalContext(); + ctx.saveState(); + const ScopeGuard guard { [&ctx] { ctx.restoreState(); } }; + for (auto it = glyphs.begin(), end = glyphs.end(); it != end;) { const auto adjacent = std::adjacent_find (it, end, [] (const auto& a, const auto& b) @@ -751,7 +629,6 @@ void GlyphArrangement::draw (const Graphics& g, AffineTransform transform) const return Point { x.x, x.y }; }); - auto& ctx = g.getInternalContext(); ctx.setFont (it->font); ctx.drawGlyphs (glyphNumbers, positions, transform); diff --git a/modules/juce_graphics/fonts/juce_GlyphArrangement.h b/modules/juce_graphics/fonts/juce_GlyphArrangement.h index f7e3b1d938..2bc5dd302c 100644 --- a/modules/juce_graphics/fonts/juce_GlyphArrangement.h +++ b/modules/juce_graphics/fonts/juce_GlyphArrangement.h @@ -315,13 +315,7 @@ private: //============================================================================== Array glyphs; - int insertEllipsis (const Font&, float maxXPos, int startIndex, int endIndex); - int fitLineIntoSpace (int start, int numGlyphs, float x, float y, float w, float h, const Font&, - Justification, float minimumHorizontalScale); void spreadOutLine (int start, int numGlyphs, float targetWidth); - void splitLines (const String&, Font, int start, float x, float y, float w, float h, int maxLines, - float lineWidth, Justification, float minimumHorizontalScale); - void addLinesWithLineBreaks (const String&, const Font&, float x, float y, float width, float height, Justification); void drawGlyphUnderline (const Graphics&, int, AffineTransform) const; JUCE_LEAK_DETECTOR (GlyphArrangement)