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);
```
<img width="538" alt="CleanShot 2024-07-25 at 21 59 43@2x" src="https://github.com/user-attachments/assets/7c421cca-ec91-4978-b358-7ba5d457746a">
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);
```
<img width="491" alt="CleanShot 2024-07-25 at 22 01 28@2x" src="https://github.com/user-attachments/assets/06272869-11dc-43f3-a7a3-6bde7a226238">
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);
```
<img width="401" alt="CleanShot 2024-07-25 at 22 03 01@2x" src="https://github.com/user-attachments/assets/bf03d1e6-c966-4834-92ab-20d9918186ad">
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);
```
<img width="321" alt="CleanShot 2024-07-25 at 22 04 44@2x" src="https://github.com/user-attachments/assets/eaaf2983-4cd5-45e0-96fd-5edc72da211a">
Diffs=
a56419984 Simple procedural text rendering API (#7701)
Co-authored-by: Chris Dalton <chris@rive.app>
Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index 69a431e..fdd6112 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 0000000..2d76fb8
--- /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<RenderPaint> paint,
+ rcp<Font> 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<RenderPaint> 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<RenderPaint> paint;
+ rcp<RenderPath> path;
+ bool isEmpty;
+ };
+ SimpleArray<Paragraph> m_shape;
+ SimpleArray<SimpleArray<GlyphLine>> m_lines;
+
+ StyledText m_styled;
+ Factory* m_factory;
+ std::vector<RenderStyle> m_styles;
+ std::vector<RenderStyle*> 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<OrderedLine> m_orderedLines;
+ GlyphRun m_ellipsisRun;
+ AABB m_bounds;
+ rcp<RenderPath> m_clipRenderPath;
+};
+} // namespace rive
+
+#endif // WITH_RIVE_TEXT
+
+#endif
diff --git a/include/rive/text/text.hpp b/include/rive/text/text.hpp
index 7d0ae9a..b0267ab 100644
--- a/include/rive/text/text.hpp
+++ b/include/rive/text/text.hpp
@@ -200,6 +200,9 @@
float effectiveHeight() { return std::isnan(m_layoutHeight) ? height() : m_layoutHeight; }
#ifdef WITH_RIVE_TEXT
const std::vector<TextValueRun*>& runs() const { return m_runs; }
+ static SimpleArray<SimpleArray<GlyphLine>> BreakLines(const SimpleArray<Paragraph>& paragraphs,
+ float width,
+ TextAlign align);
#endif
bool haveModifiers() const
diff --git a/src/factory.cpp b/src/factory.cpp
index 07e352a..09c670a 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 0000000..abc7bba
--- /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<RenderPaint> paint,
+ rcp<Font> 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<GlyphLine>& 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<GlyphLine>& 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<RenderPaint> 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 59403c7..03a47ef 100644
--- a/src/text/text.cpp
+++ b/src/text/text.cpp
@@ -269,7 +269,7 @@
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 @@
void Text::draw(Renderer* renderer)
{
-
ClipResult clipResult = applyClip(renderer);
if (clipResult == ClipResult::noClip)
{
@@ -646,9 +645,9 @@
return !styledText.empty();
}
-static SimpleArray<SimpleArray<GlyphLine>> breakLines(const SimpleArray<Paragraph>& paragraphs,
- float width,
- TextAlign align)
+SimpleArray<SimpleArray<GlyphLine>> Text::BreakLines(const SimpleArray<Paragraph>& paragraphs,
+ float width,
+ TextAlign align)
{
bool autoWidth = width == -1.0f;
float paragraphWidth = width;
@@ -706,7 +705,7 @@
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 @@
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 @@
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<float>::max()