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()