From 7b2347c9df351c17c0f8e4e29f9bb26f0d7112c5 Mon Sep 17 00:00:00 2001 From: csmartdalton Date: Fri, 26 Jul 2024 23:10:22 +0000 Subject: [PATCH] Simple procedural text rendering API This adds `rive::RawText` can be used to append text runs with different styles and set layout rules for the text. It's pretty full featured. Simple example (trimmed for brevity): ``` auto roboto = loadFont("RobotoFlex.ttf"); // setup text object rive::RawText text(RiveFactory()); text.append("Hello world! ", roboto); // later during rendering text.render(renderer); ``` CleanShot 2024-07-25 at 21 59 43@2x A few more complex examples: ``` auto roboto = loadFont("RobotoFlex.ttf"); auto montserrat = loadFont("Montserrat.ttf"); rive::RawText text(RiveFactory()); text.append("Hello world! ", roboto, 72.0f); text.append("Moon's cool too. ", montserrat, 64.0f); ``` CleanShot 2024-07-25 at 22 01 28@2x Because `RawText` represents one contiguous styled block of text, you can apply rules like overflow, sizing, alignment, different paint, etc: ``` rive::RawText text(RiveFactory()); text.maxWidth(450.0f); text.maxHeight(330.0f); text.sizing(rive::TextSizing::fixed); text.overflow(rive::TextOverflow::ellipsis); text.append("Hello world! ", roboto, 72.0f); text.append("Moon's cool too. ", montserrat, 64.0f); auto paint = RiveFactory()->makeRenderPaint(); paint->color(0x88ff0000); text.append("Mars is red.", roboto, 72.0f, paint); ``` CleanShot 2024-07-25 at 22 03 01@2x You can also supply an override paint during rendering to force paint the whole thing with one color: ``` auto paint = RiveFactory()->makeRenderPaint(); paint->color(0xff00ff00); text.render(renderer, paint); ``` CleanShot 2024-07-25 at 22 04 44@2x Diffs= a56419984 Simple procedural text rendering API (#7701) Co-authored-by: Chris Dalton Co-authored-by: Luigi Rosso --- .rive_head | 2 +- include/rive/text/raw_text.hpp | 96 +++++++++ include/rive/text/text.hpp | 3 + src/factory.cpp | 1 + src/text/raw_text.cpp | 344 +++++++++++++++++++++++++++++++++ src/text/text.cpp | 15 +- 6 files changed, 452 insertions(+), 9 deletions(-) create mode 100644 include/rive/text/raw_text.hpp create mode 100644 src/text/raw_text.cpp diff --git a/.rive_head b/.rive_head index 69a431e2..fdd6112f 100644 --- a/.rive_head +++ b/.rive_head @@ -1 +1 @@ -d6d79132b5c6aa46c1919918a14de28f1cbf93a4 +a56419984ccdd39989f7c059a8af2ebbceb379a0 diff --git a/include/rive/text/raw_text.hpp b/include/rive/text/raw_text.hpp new file mode 100644 index 00000000..2d76fb8c --- /dev/null +++ b/include/rive/text/raw_text.hpp @@ -0,0 +1,96 @@ +#ifndef _RIVE_RENDER_TEXT_HPP_ +#define _RIVE_RENDER_TEXT_HPP_ + +#ifdef WITH_RIVE_TEXT + +#include "rive/text/text.hpp" + +namespace rive +{ +class Factory; + +class RawText +{ +public: + RawText(Factory* factory); + + /// Returns true if the text object contains no text. + bool empty() const; + + /// Appends a run to the text object. + void append(const std::string& text, + rcp paint, + rcp font, + float size = 16.0f, + float lineHeight = -1.0f, + float letterSpacing = 0.0f); + + /// Resets the text object to empty state (no text). + void clear(); + + /// Draw the text using renderer. Second argument is optional to override + /// all paints provided with run styles + void render(Renderer* renderer, rcp paint = nullptr); + + TextSizing sizing() const; + TextOverflow overflow() const; + TextAlign align() const; + float maxWidth() const; + float maxHeight() const; + float paragraphSpacing() const; + + void sizing(TextSizing value); + + /// How text that overflows when TextSizing::fixed is used. + void overflow(TextOverflow value); + + /// How text aligns within the bounds. + void align(TextAlign value); + + /// The width at which the text will wrap when using any sizing but TextSizing::auto. + void maxWidth(float value); + + /// The height at which the text will overflow when using TextSizing::fixed. + void maxHeight(float value); + + /// The vertical space between paragraphs delineated by a return character. + void paragraphSpacing(float value); + + /// Returns the bounds of the text object (helpful for aligning multiple + /// text objects/procredurally drawn shapes). + AABB bounds(); + +private: + void update(); + struct RenderStyle + { + rcp paint; + rcp path; + bool isEmpty; + }; + SimpleArray m_shape; + SimpleArray> m_lines; + + StyledText m_styled; + Factory* m_factory; + std::vector m_styles; + std::vector m_renderStyles; + bool m_dirty = false; + float m_paragraphSpacing = 0.0f; + + TextOrigin m_origin = TextOrigin::top; + TextSizing m_sizing = TextSizing::autoWidth; + TextOverflow m_overflow = TextOverflow::visible; + TextAlign m_align = TextAlign::left; + float m_maxWidth = 0.0f; + float m_maxHeight = 0.0f; + std::vector m_orderedLines; + GlyphRun m_ellipsisRun; + AABB m_bounds; + rcp m_clipRenderPath; +}; +} // namespace rive + +#endif // WITH_RIVE_TEXT + +#endif diff --git a/include/rive/text/text.hpp b/include/rive/text/text.hpp index 7d0ae9ac..b0267aba 100644 --- a/include/rive/text/text.hpp +++ b/include/rive/text/text.hpp @@ -200,6 +200,9 @@ class Text : public TextBase float effectiveHeight() { return std::isnan(m_layoutHeight) ? height() : m_layoutHeight; } #ifdef WITH_RIVE_TEXT const std::vector& runs() const { return m_runs; } + static SimpleArray> BreakLines(const SimpleArray& paragraphs, + float width, + TextAlign align); #endif bool haveModifiers() const diff --git a/src/factory.cpp b/src/factory.cpp index 07e352ac..09c670a9 100644 --- a/src/factory.cpp +++ b/src/factory.cpp @@ -5,6 +5,7 @@ #include "rive/factory.hpp" #include "rive/math/aabb.hpp" #include "rive/math/raw_path.hpp" +#include "rive/text/raw_text.hpp" #ifdef WITH_RIVE_TEXT #include "rive/text/font_hb.hpp" #endif diff --git a/src/text/raw_text.cpp b/src/text/raw_text.cpp new file mode 100644 index 00000000..abc7bba5 --- /dev/null +++ b/src/text/raw_text.cpp @@ -0,0 +1,344 @@ +#ifdef WITH_RIVE_TEXT +#include "rive/text/raw_text.hpp" +#include "rive/text_engine.hpp" +#include "rive/factory.hpp" + +using namespace rive; + +RawText::RawText(Factory* factory) : m_factory(factory) {} +bool RawText::empty() const { return m_styled.empty(); } + +void RawText::append(const std::string& text, + rcp paint, + rcp font, + float size, + float lineHeight, + float letterSpacing) +{ + int styleIndex = 0; + for (RenderStyle& style : m_styles) + { + if (style.paint == paint) + { + break; + } + styleIndex++; + } + if (styleIndex == m_styles.size()) + { + m_styles.push_back({paint, m_factory->makeEmptyRenderPath(), true}); + } + m_styled.append(font, size, lineHeight, letterSpacing, text, styleIndex); + m_dirty = true; +} + +void RawText::clear() +{ + m_styled.clear(); + m_dirty = true; +} + +TextSizing RawText::sizing() const { return m_sizing; } + +TextOverflow RawText::overflow() const { return m_overflow; } + +TextAlign RawText::align() const { return m_align; } + +float RawText::maxWidth() const { return m_maxWidth; } + +float RawText::maxHeight() const { return m_maxHeight; } + +float RawText::paragraphSpacing() const { return m_paragraphSpacing; } + +void RawText::sizing(TextSizing value) +{ + if (m_sizing != value) + { + m_sizing = value; + m_dirty = true; + } +} + +void RawText::overflow(TextOverflow value) +{ + if (m_overflow != value) + { + m_overflow = value; + m_dirty = true; + } +} + +void RawText::align(TextAlign value) +{ + if (m_align != value) + { + m_align = value; + m_dirty = true; + } +} + +void RawText::paragraphSpacing(float value) +{ + if (m_paragraphSpacing != value) + { + m_paragraphSpacing = value; + m_dirty = true; + } +} + +void RawText::maxWidth(float value) +{ + if (m_maxWidth != value) + { + m_maxWidth = value; + m_dirty = true; + } +} + +void RawText::maxHeight(float value) +{ + if (m_maxHeight != value) + { + m_maxHeight = value; + m_dirty = true; + } +} + +void RawText::update() +{ + for (RenderStyle& style : m_styles) + { + style.path->rewind(); + style.isEmpty = true; + } + m_renderStyles.clear(); + if (m_styled.empty()) + { + return; + } + auto runs = m_styled.runs(); + m_shape = runs[0].font->shapeText(m_styled.unichars(), runs); + m_lines = + Text::BreakLines(m_shape, m_sizing == TextSizing::autoWidth ? -1.0f : m_maxWidth, m_align); + + m_orderedLines.clear(); + m_ellipsisRun = {}; + + // build render styles. + if (m_shape.empty()) + { + m_bounds = AABB(0.0f, 0.0f, 0.0f, 0.0f); + return; + } + + // Build up ordered runs as we go. + int paragraphIndex = 0; + float y = 0.0f; + float minY = 0.0f; + float measuredWidth = 0.0f; + if (m_origin == TextOrigin::baseline && !m_lines.empty() && !m_lines[0].empty()) + { + y -= m_lines[0][0].baseline; + minY = y; + } + + int ellipsisLine = -1; + bool isEllipsisLineLast = false; + // Find the line to put the ellipsis on (line before the one that + // overflows). + bool wantEllipsis = m_overflow == TextOverflow::ellipsis && m_sizing == TextSizing::fixed; + + int lastLineIndex = -1; + for (const SimpleArray& paragraphLines : m_lines) + { + const Paragraph& paragraph = m_shape[paragraphIndex++]; + for (const GlyphLine& line : paragraphLines) + { + const GlyphRun& endRun = paragraph.runs[line.endRunIndex]; + const GlyphRun& startRun = paragraph.runs[line.startRunIndex]; + float width = endRun.xpos[line.endGlyphIndex] - startRun.xpos[line.startGlyphIndex] - + endRun.letterSpacing; + if (width > measuredWidth) + { + measuredWidth = width; + } + lastLineIndex++; + if (wantEllipsis && y + line.bottom <= m_maxHeight) + { + ellipsisLine++; + } + } + + if (!paragraphLines.empty()) + { + y += paragraphLines.back().bottom; + } + y += m_paragraphSpacing; + } + if (wantEllipsis && ellipsisLine == -1) + { + // Nothing fits, just show the first line and ellipse it. + ellipsisLine = 0; + } + isEllipsisLineLast = lastLineIndex == ellipsisLine; + + int lineIndex = 0; + paragraphIndex = 0; + switch (m_sizing) + { + case TextSizing::autoWidth: + m_bounds = AABB(0.0f, minY, measuredWidth, std::max(minY, y - m_paragraphSpacing)); + break; + case TextSizing::autoHeight: + m_bounds = AABB(0.0f, minY, m_maxWidth, std::max(minY, y - m_paragraphSpacing)); + break; + case TextSizing::fixed: + m_bounds = AABB(0.0f, minY, m_maxWidth, minY + m_maxHeight); + break; + } + + // Build the clip path if we want it. + if (m_overflow == TextOverflow::clipped) + { + if (m_clipRenderPath == nullptr) + { + m_clipRenderPath = m_factory->makeEmptyRenderPath(); + } + else + { + m_clipRenderPath->rewind(); + } + + m_clipRenderPath->addRect(m_bounds.minX, + m_bounds.minY, + m_bounds.width(), + m_bounds.height()); + } + else + { + m_clipRenderPath = nullptr; + } + + y = 0; + if (m_origin == TextOrigin::baseline && !m_lines.empty() && !m_lines[0].empty()) + { + y -= m_lines[0][0].baseline; + } + paragraphIndex = 0; + + for (const SimpleArray& paragraphLines : m_lines) + { + const Paragraph& paragraph = m_shape[paragraphIndex++]; + for (const GlyphLine& line : paragraphLines) + { + switch (m_overflow) + { + case TextOverflow::hidden: + if (m_sizing == TextSizing::fixed && y + line.bottom > m_maxHeight) + { + return; + } + break; + case TextOverflow::clipped: + if (m_sizing == TextSizing::fixed && y + line.top > m_maxHeight) + { + return; + } + break; + default: + break; + } + + if (lineIndex >= m_orderedLines.size()) + { + // We need to still compute this line's ordered runs. + m_orderedLines.emplace_back(OrderedLine(paragraph, + line, + m_maxWidth, + ellipsisLine == lineIndex, + isEllipsisLineLast, + &m_ellipsisRun)); + } + + const OrderedLine& orderedLine = m_orderedLines[lineIndex]; + float x = line.startX; + float renderY = y + line.baseline; + for (auto glyphItr : orderedLine) + { + const GlyphRun* run = std::get<0>(glyphItr); + size_t glyphIndex = std::get<1>(glyphItr); + + const Font* font = run->font.get(); + const Vec2D& offset = run->offsets[glyphIndex]; + + GlyphID glyphId = run->glyphs[glyphIndex]; + float advance = run->advances[glyphIndex]; + + RawPath path = font->getPath(glyphId); + + path.transformInPlace( + Mat2D(run->size, 0.0f, 0.0f, run->size, x + offset.x, renderY + offset.y)); + + x += advance; + + assert(run->styleId < m_styles.size()); + RenderStyle* style = &m_styles[run->styleId]; + assert(style != nullptr); + path.addTo(style->path.get()); + + if (style->isEmpty) + { + // This was the first path added to the style, so let's mark + // it in our draw list. + style->isEmpty = false; + + m_renderStyles.push_back(style); + } + } + if (lineIndex == ellipsisLine) + { + return; + } + lineIndex++; + } + if (!paragraphLines.empty()) + { + y += paragraphLines.back().bottom; + } + y += m_paragraphSpacing; + } +} + +AABB RawText::bounds() +{ + if (m_dirty) + { + update(); + m_dirty = false; + } + return m_bounds; +} + +void RawText::render(Renderer* renderer, rcp paint) +{ + if (m_dirty) + { + update(); + m_dirty = false; + } + + if (m_overflow == TextOverflow::clipped && m_clipRenderPath) + { + renderer->save(); + renderer->clipPath(m_clipRenderPath.get()); + } + for (auto style : m_renderStyles) + { + renderer->drawPath(style->path.get(), paint ? paint.get() : style->paint.get()); + } + if (m_overflow == TextOverflow::clipped && m_clipRenderPath) + { + renderer->restore(); + } +} +#endif diff --git a/src/text/text.cpp b/src/text/text.cpp index 59403c7a..03a47efd 100644 --- a/src/text/text.cpp +++ b/src/text/text.cpp @@ -269,7 +269,7 @@ void Text::buildRenderStyles() style->rewindPath(); } m_renderStyles.clear(); - if (m_shape.size() == 0) + if (m_shape.empty()) { m_bounds = AABB(0.0f, 0.0f, 0.0f, 0.0f); return; @@ -508,7 +508,6 @@ const TextStyle* Text::styleFromShaperId(uint16_t id) const void Text::draw(Renderer* renderer) { - ClipResult clipResult = applyClip(renderer); if (clipResult == ClipResult::noClip) { @@ -646,9 +645,9 @@ bool Text::makeStyled(StyledText& styledText, bool withModifiers) const return !styledText.empty(); } -static SimpleArray> breakLines(const SimpleArray& paragraphs, - float width, - TextAlign align) +SimpleArray> Text::BreakLines(const SimpleArray& paragraphs, + float width, + TextAlign align) { bool autoWidth = width == -1.0f; float paragraphWidth = width; @@ -706,7 +705,7 @@ void Text::update(ComponentDirt value) auto runs = m_modifierStyledText.runs(); m_modifierShape = runs[0].font->shapeText(m_modifierStyledText.unichars(), runs); m_modifierLines = - breakLines(m_modifierShape, + BreakLines(m_modifierShape, effectiveSizing() == TextSizing::autoWidth ? -1.0f : effectiveWidth(), (TextAlign)alignValue()); m_glyphLookup.compute(m_modifierStyledText.unichars(), m_modifierShape); @@ -725,7 +724,7 @@ void Text::update(ComponentDirt value) auto runs = m_styledText.runs(); m_shape = runs[0].font->shapeText(m_styledText.unichars(), runs); m_lines = - breakLines(m_shape, + BreakLines(m_shape, effectiveSizing() == TextSizing::autoWidth ? -1.0f : effectiveWidth(), (TextAlign)alignValue()); if (!precomputeModifierCoverage && haveModifiers()) @@ -776,7 +775,7 @@ Vec2D Text::measure(Vec2D maxSize) const float paragraphSpace = paragraphSpacing(); auto runs = m_styledText.runs(); auto shape = runs[0].font->shapeText(m_styledText.unichars(), runs); - auto lines = breakLines(shape, + auto lines = BreakLines(shape, std::min(maxSize.x, sizing() == TextSizing::autoWidth ? std::numeric_limits::max()