RenderText work

Introduce a new header : render_text.hpp

This has new types, including a new virtual class RenderFont that clients need to subclass and provide.

This PR includes two experimental implementations:
- skia based (but trivial shaper : no shaping, no kerning, no intl support)
- harfbuzz based (good shaper, but no support for color glyphs)
Neither try to perform font substitution yet.

This PR has **no impact** on Runtimes yet -- it is just play code that Viewer can test.

Diffs=
5da5fa606 RenderText work
diff --git a/.rive_head b/.rive_head
index b487f8a..fc25c85 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-a798ea7de2d9189a24fa9101813c1b42498f69df
+5da5fa606df8b3e05ab15e9a1297e4c941d35c60
diff --git a/include/rive/render_text.hpp b/include/rive/render_text.hpp
index 20d7206..f610b74 100644
--- a/include/rive/render_text.hpp
+++ b/include/rive/render_text.hpp
@@ -14,8 +14,18 @@
 using Unichar = int32_t;
 using GlyphID = uint16_t;
 
+struct RenderTextRun;
+struct RenderGlyphRun;
+
 class RenderFont : public RefCnt {
 public:
+    struct LineMetrics {
+        float ascent,
+              descent;
+    };
+
+    const LineMetrics& lineMetrics() const { return m_LineMetrics; }
+
     // This is experimental
     // -- may only be needed by Editor
     // -- so it may be removed from here later
@@ -55,6 +65,18 @@
     // relative to (0,0) with the typographic baseline at y = 0.
     //
     virtual RawPath getPath(GlyphID) const = 0;
+
+    std::vector<RenderGlyphRun> shapeText(rive::Span<const rive::Unichar> text,
+                                          rive::Span<const rive::RenderTextRun> runs) const;
+
+protected:
+    RenderFont(const LineMetrics& lm) : m_LineMetrics(lm) {}
+
+    virtual std::vector<RenderGlyphRun> onShapeText(rive::Span<const rive::Unichar> text,
+                                                   rive::Span<const rive::RenderTextRun> runs) const = 0;
+
+private:
+    const LineMetrics m_LineMetrics;
 };
 
 struct RenderTextRun {
@@ -66,10 +88,10 @@
 struct RenderGlyphRun {
     rcp<RenderFont>         font;
     float                   size;
-    uint32_t                startTextIndex;
 
-    std::vector<GlyphID>    glyphs;
-    std::vector<float>      xpos;   // xpos.size() == glyphs.size() + 1
+    std::vector<GlyphID>    glyphs;         // [#glyphs]
+    std::vector<uint32_t>   textOffsets;    // [#glyphs]
+    std::vector<float>      xpos;           // [#glyphs + 1]
 };
 
 } // namespace rive
diff --git a/skia/renderer/build/premake5.lua b/skia/renderer/build/premake5.lua
index c1f7051..2726453 100644
--- a/skia/renderer/build/premake5.lua
+++ b/skia/renderer/build/premake5.lua
@@ -10,7 +10,11 @@
     toolset "clang"
     targetdir "%{cfg.system}/bin/%{cfg.buildcfg}"
     objdir "%{cfg.system}/obj/%{cfg.buildcfg}"
-    includedirs {"../include", "../../../include"}
+    includedirs {
+        "../../../../../third_party/externals/harfbuzz/src",
+        "../include",
+        "../../../include"
+    }
 
     if os.host() == "macosx" then
         links {"Cocoa.framework", "rive", "skia"}
@@ -30,7 +34,9 @@
 
     libdirs {"../../../build/%{cfg.system}/bin/%{cfg.buildcfg}"}
 
-    files {"../src/**.cpp"}
+    files {
+        "../src/skia_factory.cpp",
+    }
 
     buildoptions {"-Wall", "-fno-exceptions", "-fno-rtti", "-Werror=format"}
 
@@ -84,6 +90,7 @@
             libdirs {"../../dependencies/" .. SKIA_DIR.. "/out/arm64"}
             
     filter "configurations:debug"
+        buildoptions {"-g"}
         defines {"DEBUG"}
         symbols "On"
 
diff --git a/skia/renderer/include/line_breaker.hpp b/skia/renderer/include/line_breaker.hpp
new file mode 100644
index 0000000..aa45448
--- /dev/null
+++ b/skia/renderer/include/line_breaker.hpp
@@ -0,0 +1,44 @@
+#ifndef _RIVE_RENDER_GLYPH_LINE_H_
+#define _RIVE_RENDER_GLYPH_LINE_H_
+
+#include "rive/render_text.hpp"
+
+namespace rive {
+
+struct RenderGlyphLine {
+  int startRun;
+  int startIndex;
+  int endRun;
+  int endIndex;
+  int wsRun;
+  int wsIndex;
+  float startX;
+  float top = 0, baseline = 0, bottom = 0;
+
+  RenderGlyphLine(int startRun,
+                  int startIndex,
+                  int endRun,
+                  int endIndex,
+                  int wsRun,
+                  int wsIndex,
+                  float startX) :
+      startRun(startRun),
+      startIndex(startIndex),
+      endRun(endRun),
+      endIndex(endIndex),
+      wsRun(wsRun),
+      wsIndex(wsIndex),
+      startX(startX)
+  {}
+
+    static std::vector<RenderGlyphLine> BreakLines(Span<const RenderGlyphRun> runs,
+                                                   Span<const int> breaks,
+                                                   float width);
+    // Compute vaues for top/baseline/bottom per line
+    static void ComputeLineSpacing(rive::Span<RenderGlyphLine>,
+                                   rive::Span<const RenderGlyphRun>);
+};
+
+} // namespace
+
+#endif
diff --git a/skia/renderer/include/renderfont_hb.hpp b/skia/renderer/include/renderfont_hb.hpp
new file mode 100644
index 0000000..fce1980
--- /dev/null
+++ b/skia/renderer/include/renderfont_hb.hpp
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+#ifndef _RIVE_RENDERFONT_HB_HPP_
+#define _RIVE_RENDERFONT_HB_HPP_
+
+#include "rive/factory.hpp"
+#include "rive/render_text.hpp"
+
+struct hb_font_t;
+struct hb_draw_funcs_t;
+
+class HBRenderFont : public rive::RenderFont {
+    hb_draw_funcs_t* m_DrawFuncs;
+
+public:
+    hb_font_t* m_Font;
+
+    // We assume ownership of font!
+    HBRenderFont(hb_font_t* font);
+    ~HBRenderFont() override;
+
+    std::vector<Axis> getAxes() const override;
+    std::vector<Coord> getCoords() const override;
+    rive::rcp<rive::RenderFont> makeAtCoords(rive::Span<const Coord>) const override;
+    rive::RawPath getPath(rive::GlyphID) const override;
+    std::vector<rive::RenderGlyphRun> onShapeText(rive::Span<const rive::Unichar>,
+                                                  rive::Span<const rive::RenderTextRun>) const override;
+
+    static rive::rcp<rive::RenderFont> Decode(rive::Span<const uint8_t>);
+};
+
+#endif
diff --git a/skia/renderer/include/renderfont_skia.hpp b/skia/renderer/include/renderfont_skia.hpp
new file mode 100644
index 0000000..c7593ba
--- /dev/null
+++ b/skia/renderer/include/renderfont_skia.hpp
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+#ifndef _RIVE_RENDERFONT_SKIA_HPP_
+#define _RIVE_RENDERFONT_SKIA_HPP_
+
+#include "rive/render_text.hpp"
+#include "include/core/SkTypeface.h"
+
+class SkiaRenderFont : public rive::RenderFont {
+public:
+    sk_sp<SkTypeface> m_Typeface;
+
+    SkiaRenderFont(sk_sp<SkTypeface>);
+
+    std::vector<Axis> getAxes() const override;
+    std::vector<Coord> getCoords() const override;
+    rive::rcp<rive::RenderFont> makeAtCoords(rive::Span<const Coord>) const override;
+    rive::RawPath getPath(rive::GlyphID) const override;
+    std::vector<rive::RenderGlyphRun> onShapeText(rive::Span<const rive::Unichar>,
+                                                  rive::Span<const rive::RenderTextRun>) const override;
+
+    static rive::rcp<rive::RenderFont> Decode(rive::Span<const uint8_t>);
+};
+
+#endif
diff --git a/skia/renderer/src/line_breaker.cpp b/skia/renderer/src/line_breaker.cpp
new file mode 100644
index 0000000..53f8db5
--- /dev/null
+++ b/skia/renderer/src/line_breaker.cpp
@@ -0,0 +1,144 @@
+#include "line_breaker.hpp"
+
+using namespace rive;
+
+// Return the index for the run that contains the char at textOffset
+static int _offsetToRunIndex(Span<const RenderGlyphRun> runs, size_t textOffset) {
+    assert(textOffset >= 0);
+    for (int i = 0; i < (int)runs.size() - 1; ++i) {
+        if (textOffset <= runs[i].textOffsets.back()) {
+            return i;
+        }
+    }
+    return (int)runs.size() - 1;
+}
+
+static int textOffsetToGlyphIndex(const RenderGlyphRun& run, size_t textOffset) {
+    assert(textOffset >= run.textOffsets.front());
+//    assert(textOffset <= run.textOffsets.back()); // not true for last run
+
+    // todo: bsearch?
+    auto begin = run.textOffsets.begin();
+    auto end = run.textOffsets.end();
+    auto iter = std::find(begin, end, textOffset);
+    if (iter == end) {  // end of run
+      return run.glyphs.size() - 1;
+    }
+    return iter - begin;
+}
+
+std::vector<RenderGlyphLine> RenderGlyphLine::BreakLines(Span<const RenderGlyphRun> runs,
+                                                         Span<const int> breaks,
+                                                         float width) {
+    assert(breaks.size() >= 2);
+
+    std::vector<RenderGlyphLine> lines;
+    int startRun = 0;
+    int startIndex = 0;
+    double xlimit = width;
+
+    int prevRun = 0;
+    int prevIndex = 0;
+
+    int wordStart = breaks[0];
+    int wordEnd = breaks[1];
+    int nextBreakIndex = 2;
+    int lineStartTextOffset = wordStart;
+
+    for (;;) {
+      assert(wordStart <= wordEnd); // == means trailing spaces?
+
+      int endRun = _offsetToRunIndex(runs, wordEnd);
+      int endIndex = textOffsetToGlyphIndex(runs[endRun], wordEnd);
+      float pos = runs[endRun].xpos[endIndex];
+      bool bumpBreakIndex = true;
+      if (pos > xlimit) {
+        int wsRun = _offsetToRunIndex(runs, wordStart);
+        int wsIndex = textOffsetToGlyphIndex(runs[wsRun], wordStart);
+
+        bumpBreakIndex = false;
+        // does just one word not fit?
+        if (lineStartTextOffset == wordStart) {
+          // walk backwards a letter at a time until we fit, stopping at
+          // 1 letter.
+          int wend = wordEnd;
+          while (pos > xlimit && wend - 1 > wordStart) {
+            wend -= 1;
+            prevRun = _offsetToRunIndex(runs, wend);
+            prevIndex = textOffsetToGlyphIndex(runs[prevRun], wend);
+            pos = runs[prevRun].xpos[prevIndex];
+          }
+          assert(wend < wordEnd || wend == wordEnd && wordStart + 1 == wordEnd);
+          if (wend == wordEnd) {
+            bumpBreakIndex = true;
+          }
+
+          // now reset our "whitespace" marker to just be prev, since
+          // by defintion we have no extra whitespace on this line
+          wsRun = prevRun;
+          wsIndex = prevIndex;
+          wordStart = wend;
+        }
+
+        // bulid the line
+        const auto lineStartX = runs[startRun].xpos[startIndex];
+        lines.push_back(RenderGlyphLine(
+          startRun, startIndex, prevRun, prevIndex, wsRun, wsIndex, lineStartX
+        ));
+
+        // update for the next line
+        xlimit = runs[wsRun].xpos[wsIndex] + width;
+        startRun = prevRun = wsRun;
+        startIndex = prevIndex = wsIndex;
+        lineStartTextOffset = wordStart;
+      } else {
+        // we didn't go too far, so remember this word-end boundary
+        prevRun = endRun;
+        prevIndex = endIndex;
+      }
+
+      if (bumpBreakIndex) {
+        if (nextBreakIndex < breaks.size()) {
+          wordStart = breaks[nextBreakIndex++];
+          wordEnd = breaks[nextBreakIndex++];
+        } else {
+          break; // bust out of the loop
+        }
+      }
+    }
+    // scoop up the last line (if present)
+    const int tailRun = runs.size() - 1;
+    const int tailIndex = runs[tailRun].glyphs.size();
+    if (startRun != tailRun || startIndex != tailIndex) {
+      const auto startX = runs[startRun].xpos[startIndex];
+      lines.push_back(RenderGlyphLine(
+        startRun, startIndex, tailRun, tailIndex, tailRun, tailIndex, startX
+      ));
+    }
+
+    ComputeLineSpacing(toSpan(lines), runs);
+
+    return lines;
+  }
+
+void RenderGlyphLine::ComputeLineSpacing(Span<RenderGlyphLine> lines,
+                                         Span<const RenderGlyphRun> runs) {
+    float Y = 0;  // top of our frame
+    for (auto& line : lines) {
+      float asc = 0;
+      float des = 0;
+      for (int i = line.startRun; i <= line.wsRun; ++i) {
+        const auto& run = runs[i];
+
+        asc = std::min(asc, run.font->lineMetrics().ascent * run.size);
+        des = std::max(des, run.font->lineMetrics().descent * run.size);
+      }
+      line.top = Y;
+      Y -= asc;
+      line.baseline = Y;
+      Y += des;
+      line.bottom = Y;
+    }
+    // TODO: good place to perform left/center/right alignment
+
+}
diff --git a/skia/renderer/src/renderfont_hb.cpp b/skia/renderer/src/renderfont_hb.cpp
new file mode 100644
index 0000000..7d9c441
--- /dev/null
+++ b/skia/renderer/src/renderfont_hb.cpp
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+#include "renderfont_hb.hpp"
+
+#include "rive/factory.hpp"
+#include "rive/render_text.hpp"
+
+#include "hb.h"
+#include "hb-ot.h"
+//#include "harfbuzz/hb.h"
+//#include "harfbuzz/hb-ot.h"
+
+rive::rcp<rive::RenderFont> HBRenderFont::Decode(rive::Span<const uint8_t> span) {
+    auto blob = hb_blob_create_or_fail((const char*)span.data(), (unsigned)span.size(),
+                                             HB_MEMORY_MODE_DUPLICATE, nullptr, nullptr);
+    if (blob) {
+       auto face = hb_face_create(blob, 0);
+        hb_blob_destroy(blob);
+        if (face) {
+            auto font = hb_font_create(face);
+            hb_face_destroy(face);
+            if (font) {
+                return rive::rcp<rive::RenderFont>(new HBRenderFont(font));
+            }
+        }
+    }
+    return nullptr;
+}
+
+//////////////
+
+constexpr int kStdScale = 2048;
+constexpr float gInvScale = 1.0f / kStdScale;
+
+extern "C" {
+void rpath_move_to(hb_draw_funcs_t*, void* rpath, hb_draw_state_t*, float x, float y, void*) {
+    ((rive::RawPath*)rpath)->moveTo(x * gInvScale, -y * gInvScale);
+}
+void rpath_line_to(hb_draw_funcs_t*, void* rpath, hb_draw_state_t*, float x1, float y1, void*) {
+    ((rive::RawPath*)rpath)->lineTo(x1 * gInvScale, -y1 * gInvScale);
+}
+void rpath_quad_to(hb_draw_funcs_t*, void* rpath, hb_draw_state_t*, float x1, float y1, float x2, float y2, void*) {
+    ((rive::RawPath*)rpath)->quadTo(x1 * gInvScale, -y1 * gInvScale,
+                                    x2 * gInvScale, -y2 * gInvScale);
+}
+void rpath_cubic_to(hb_draw_funcs_t*, void* rpath, hb_draw_state_t*,
+                    float x1, float y1, float x2, float y2, float x3, float y3, void*) {
+    ((rive::RawPath*)rpath)->cubicTo(x1 * gInvScale, -y1 * gInvScale,
+                                     x2 * gInvScale, -y2 * gInvScale,
+                                     x3 * gInvScale, -y3 * gInvScale);
+}
+void rpath_close(hb_draw_funcs_t*, void* rpath, hb_draw_state_t*, void*) {
+    ((rive::RawPath*)rpath)->close();
+}
+}
+
+static rive::RenderFont::LineMetrics make_lmx(hb_font_t* font) {
+    // premable on font...
+    hb_ot_font_set_funcs(font);
+    hb_font_set_scale(font, kStdScale, kStdScale);
+
+    hb_font_extents_t extents;
+    hb_font_get_h_extents(font, &extents);
+    return {-extents.ascender * gInvScale, -extents.descender * gInvScale};
+}
+
+HBRenderFont::HBRenderFont(hb_font_t* font) :
+    RenderFont(make_lmx(font)),
+    m_Font(font)    // we just take ownership, no need to call reference()
+{
+    m_DrawFuncs = hb_draw_funcs_create();
+    hb_draw_funcs_set_move_to_func(m_DrawFuncs, rpath_move_to, nullptr, nullptr);
+    hb_draw_funcs_set_line_to_func(m_DrawFuncs, rpath_line_to, nullptr, nullptr);
+    hb_draw_funcs_set_quadratic_to_func(m_DrawFuncs, rpath_quad_to, nullptr, nullptr);
+    hb_draw_funcs_set_cubic_to_func(m_DrawFuncs, rpath_cubic_to, nullptr, nullptr);
+    hb_draw_funcs_set_close_path_func(m_DrawFuncs, rpath_close, nullptr, nullptr);
+    hb_draw_funcs_make_immutable(m_DrawFuncs);
+}
+
+HBRenderFont::~HBRenderFont() {
+    hb_draw_funcs_destroy(m_DrawFuncs);
+    hb_font_destroy(m_Font);
+}
+
+std::vector<rive::RenderFont::Axis> HBRenderFont::getAxes() const {
+    auto face = hb_font_get_face(m_Font);
+    std::vector<rive::RenderFont::Axis> axes;
+
+    const int count = hb_ot_var_get_axis_count(face);
+    if (count > 0) {
+        axes.resize(count);
+
+        hb_ot_var_axis_info_t info;
+        for (int i = 0; i < count; ++i) {
+            unsigned n = 1;
+            hb_ot_var_get_axis_infos(face, i, &n, &info);
+            assert(n == 1);
+            axes[i] = { info.tag, info.min_value, info.default_value, info.max_value };
+     //       printf("[%d] %08X %g %g %g\n", i, info.tag, info.min_value, info.default_value, info.max_value);
+        }
+    }
+    return axes;
+}
+
+std::vector<rive::RenderFont::Coord> HBRenderFont::getCoords() const {
+    auto axes = this->getAxes();
+  //  const int count = (int)axes.size();
+
+    unsigned length;
+    const float* values = hb_font_get_var_coords_design(m_Font, &length);
+
+    std::vector<rive::RenderFont::Coord> coords(length);
+    for (unsigned i = 0; i < length; ++i) {
+        coords[i] = { axes[i].tag, values[i] };
+    }
+    return coords;
+}
+
+rive::rcp<rive::RenderFont> HBRenderFont::makeAtCoords(rive::Span<const Coord> coords) const {
+    const int count = (int)coords.size();
+    std::vector<hb_variation_t> vars(count);
+    for (int i = 0; i < count; ++i) {
+        vars[i].tag = coords[i].axis;
+        vars[i].value = coords[i].value;
+    }
+
+    auto font = hb_font_create_sub_font(m_Font);
+    hb_font_set_variations(font, vars.data(), count);
+    return rive::rcp<rive::RenderFont>(new HBRenderFont(font));
+}
+
+rive::RawPath HBRenderFont::getPath(rive::GlyphID glyph) const {
+    rive::RawPath rpath;
+    hb_font_get_glyph_shape(m_Font, glyph, m_DrawFuncs, &rpath);
+    return rpath;
+}
+
+///////////////////////////////////////////////////////////
+
+std::vector<rive::RenderGlyphRun>
+HBRenderFont::onShapeText(rive::Span<const rive::Unichar> text,
+                          rive::Span<const rive::RenderTextRun> truns) const {
+    std::vector<rive::RenderGlyphRun> gruns;
+    gruns.reserve(truns.size());
+
+    /////////////////
+
+    const hb_feature_t features[] = {
+        { 'liga', 1, HB_FEATURE_GLOBAL_START, HB_FEATURE_GLOBAL_END },
+        { 'dlig', 1, HB_FEATURE_GLOBAL_START, HB_FEATURE_GLOBAL_END },
+        { 'kern', 1, HB_FEATURE_GLOBAL_START, HB_FEATURE_GLOBAL_END },
+    };
+    constexpr int numFeatures = sizeof(features) / sizeof(features[0]);
+
+    uint32_t unicharIndex = 0;
+    rive::Vec2D origin = {0, 0};
+    for (const auto& tr : truns) {
+        hb_buffer_t *buf = hb_buffer_create();
+        hb_buffer_add_utf32(buf, (const uint32_t*)&text[unicharIndex], tr.unicharCount, 0, tr.unicharCount);
+
+        hb_buffer_set_direction(buf, HB_DIRECTION_LTR);
+        hb_buffer_set_script(buf, HB_SCRIPT_LATIN);
+        hb_buffer_set_language(buf, hb_language_from_string("en", -1));
+    
+        auto hbfont = (HBRenderFont*)tr.font.get();
+        hb_shape(hbfont->m_Font, buf, features, numFeatures);
+    
+        unsigned int glyph_count;
+        hb_glyph_info_t *glyph_info    = hb_buffer_get_glyph_infos(buf, &glyph_count);
+        hb_glyph_position_t *glyph_pos = hb_buffer_get_glyph_positions(buf, &glyph_count);
+
+        // todo: check for missing glyphs, and perform font-substitution
+
+        rive::RenderGlyphRun gr;
+        gr.font = tr.font;
+        gr.size = tr.size;
+        gr.glyphs.resize(glyph_count);
+        gr.textOffsets.resize(glyph_count);
+        gr.xpos.resize(glyph_count + 1);
+
+        const float scale = tr.size / kStdScale;
+
+        for (unsigned int i = 0; i < glyph_count; i++) {
+//            hb_position_t x_offset  = glyph_pos[i].x_offset;
+//            hb_position_t y_offset  = glyph_pos[i].y_offset;
+
+            gr.glyphs[i] = (uint16_t)glyph_info[i].codepoint;
+            gr.textOffsets[i] = unicharIndex + glyph_info[i].cluster;
+            gr.xpos[i] = origin.x;
+
+            origin.x += glyph_pos[i].x_advance * scale;
+        }
+        gr.xpos[glyph_count] = origin.x;
+        gruns.push_back(std::move(gr));
+
+        unicharIndex += tr.unicharCount;
+        hb_buffer_destroy(buf);
+    }
+
+    return gruns;
+}
diff --git a/skia/viewer/src/fontmgr.cpp b/skia/renderer/src/renderfont_skia.cpp
similarity index 69%
rename from skia/viewer/src/fontmgr.cpp
rename to skia/renderer/src/renderfont_skia.cpp
index 4298c3c..825fa5d 100644
--- a/skia/viewer/src/fontmgr.cpp
+++ b/skia/renderer/src/renderfont_skia.cpp
@@ -4,23 +4,38 @@
 
 #include "rive/factory.hpp"
 #include "rive/render_text.hpp"
-#include "skia_rive_fontmgr.hpp"
+#include "renderfont_skia.hpp"
 
-#include "include/core/SkTypeface.h"
+#include "include/core/SkData.h"
 #include "include/core/SkFont.h"
+#include "include/core/SkFontMetrics.h"
 #include "include/core/SkPath.h"
+#include "include/core/SkTypeface.h"
 
-class SkiaRenderFont : public rive::RenderFont {
-public:
-    sk_sp<SkTypeface> m_Typeface;
+static void setupFont(SkFont* font) {
+    font->setLinearMetrics(true);
+    font->setBaselineSnap(false);
+    font->setHinting(SkFontHinting::kNone);
+}
 
-    SkiaRenderFont(sk_sp<SkTypeface> tf) : m_Typeface(std::move(tf)) {}
+static rive::RenderFont::LineMetrics make_lmx(sk_sp<SkTypeface> tf) {
+    SkFont font(tf, 1.0f);
+    setupFont(&font);
+    SkFontMetrics metrics;
+    (void)font.getMetrics(&metrics);
+    return {metrics.fAscent, metrics.fDescent};
+}
 
-    std::vector<Axis> getAxes() const override;
-    std::vector<Coord> getCoords() const override;
-    rive::rcp<rive::RenderFont> makeAtCoords(rive::Span<const Coord>) const override;
-    rive::RawPath getPath(rive::GlyphID) const override;
-};
+SkiaRenderFont::SkiaRenderFont(sk_sp<SkTypeface> tf) :
+    RenderFont(make_lmx(tf)),
+    m_Typeface(std::move(tf))
+{}
+
+rive::rcp<rive::RenderFont> SkiaRenderFont::Decode(rive::Span<const uint8_t> span) {
+    auto tf = SkTypeface::MakeFromData(SkData::MakeWithCopy(span.data(),
+                                                            span.size()));
+    return tf ? rive::rcp<rive::RenderFont>(new SkiaRenderFont(std::move(tf))) : nullptr;
+}
 
 std::vector<rive::RenderFont::Axis> SkiaRenderFont::getAxes() const {
     std::vector<rive::RenderFont::Axis> axes;
@@ -70,12 +85,6 @@
 
 static inline rive::Vec2D rv(SkPoint p) { return rive::Vec2D(p.fX, p.fY); }
 
-static void setupFont(SkFont* font) {
-    font->setLinearMetrics(true);
-    font->setBaselineSnap(false);
-    font->setHinting(SkFontHinting::kNone);
-}
-
 rive::RawPath SkiaRenderFont::getPath(rive::GlyphID glyph) const {
     SkFont font(m_Typeface, 1.0f);
     setupFont(&font);
@@ -103,62 +112,45 @@
 
 ///////////////////////////////////////////////////////////
 
-rive::rcp<rive::RenderFont> SkiaRiveFontMgr::decodeFont(rive::Span<const uint8_t> data) {
-    auto tf = SkTypeface::MakeDefault(); // ignoring data for now
-    return rive::rcp<rive::RenderFont>(new SkiaRenderFont(std::move(tf)));
-}
-
-rive::rcp<rive::RenderFont> SkiaRiveFontMgr::findFont(const char name[]) {
-    auto tf = SkTypeface::MakeFromName(name, SkFontStyle());
-    if (!tf) {
-        tf = SkTypeface::MakeDefault();
-    }
-    return rive::rcp<rive::RenderFont>(new SkiaRenderFont(std::move(tf)));
-}
-
 static float shapeRun(rive::RenderGlyphRun* grun, const rive::RenderTextRun& trun,
                       const rive::Unichar text[], size_t textOffset, float origin) {
+    const int glyphCount = SkToInt(trun.unicharCount);   // simple shaper, no ligatures
+
     grun->font = trun.font;
     grun->size = trun.size;
-    grun->startTextIndex = textOffset;
-    grun->glyphs.resize(trun.unicharCount);
-    grun->xpos.resize(trun.unicharCount + 1);
 
-    const int count = SkToInt(trun.unicharCount);
+    grun->glyphs.resize(glyphCount);
+    grun->textOffsets.resize(glyphCount);
+    grun->xpos.resize(glyphCount + 1);
 
     SkiaRenderFont* rfont = static_cast<SkiaRenderFont*>(trun.font.get());
-    rfont->m_Typeface->unicharsToGlyphs(text + textOffset, count, grun->glyphs.data());
+    rfont->m_Typeface->unicharsToGlyphs(text + textOffset, glyphCount, grun->glyphs.data());
+
+    // simple shaper, assume one glyph per char
+    for (int i = 0; i < glyphCount; ++i) {
+        grun->textOffsets[i] = textOffset + i;
+    }
 
     SkFont font(rfont->m_Typeface, grun->size);
     setupFont(&font);
 
     // We get 'widths' from skia, but then turn them into xpos
     // this will write count values, but xpos has count+1 slots
-    font.getWidths(grun->glyphs.data(), count, grun->xpos.data());
+    font.getWidths(grun->glyphs.data(), glyphCount, grun->xpos.data());
     for (auto& xp : grun->xpos) {
         auto width = xp;
         xp = origin;
         origin += width;
     }
 
-    return grun->xpos.data()[count];
+    return grun->xpos.data()[glyphCount];
 }
 
 std::vector<rive::RenderGlyphRun>
-SkiaRiveFontMgr::shapeText(rive::Span<const rive::Unichar> text,
-                           rive::Span<const rive::RenderTextRun> truns) {
-    std::vector<rive::RenderGlyphRun> gruns;
+SkiaRenderFont::onShapeText(rive::Span<const rive::Unichar> text,
+                            rive::Span<const rive::RenderTextRun> truns) const {
+    std::vector<rive::RenderGlyphRun> gruns(truns.size());
 
-    // sanity check
-    size_t count = 0;
-    for (const auto& tr : truns) {
-        count += tr.unicharCount;
-    }
-    if (count > text.size()) {
-        return gruns;   // not enough text, so abort
-    }
-
-    gruns.resize(truns.size());
     int i = 0;
     size_t offset = 0;
     float origin = 0;
diff --git a/skia/viewer/build/premake5.lua b/skia/viewer/build/premake5.lua
index c2d5b58..8202faf 100644
--- a/skia/viewer/build/premake5.lua
+++ b/skia/viewer/build/premake5.lua
@@ -5,6 +5,10 @@
 location("./")
 dofile(path.join(BASE_DIR, "premake5.lua"))
 
+BASE_DIR = path.getabsolute("../../../../../third_party/harfbuzz/build")
+location("./")
+dofile(path.join(BASE_DIR, "premake5.lua"))
+
 BASE_DIR = path.getabsolute("../../renderer/build")
 location("./")
 dofile(path.join(BASE_DIR, "premake5.lua"))
@@ -27,7 +31,8 @@
         "../../dependencies/skia/include/config",
         "../../dependencies/imgui",
         "../../dependencies",
-        "../../dependencies/gl3w/build/include"
+        "../../dependencies/gl3w/build/include",
+        "../../../../../third_party/externals/harfbuzz/src",
     }
 
     links {
@@ -35,12 +40,14 @@
         "IOKit.framework",
         "CoreVideo.framework",
         "rive",
+        "rive_harfbuzz",
         "skia",
         "rive_skia_renderer",
         "glfw3"
     }
 
     libdirs {
+        "../../../../../third_party/harfbuzz/build/%{cfg.buildcfg}/bin",
         "../../../build/%{cfg.system}/bin/%{cfg.buildcfg}",
         "../../dependencies/glfw_build/src",
         "../../dependencies/skia/out/static",
@@ -49,6 +56,11 @@
 
     files {
         "../src/**.cpp",
+
+        "../../renderer/src/line_breaker.cpp",
+        "../../renderer/src/renderfont_hb.cpp",
+        "../../renderer/src/renderfont_skia.cpp",
+
         "../../dependencies/gl3w/build/src/gl3w.c",
         "../../dependencies/imgui/backends/imgui_impl_glfw.cpp",
         "../../dependencies/imgui/backends/imgui_impl_opengl3.cpp",
diff --git a/skia/viewer/src/drawtext.cpp b/skia/viewer/src/drawtext.cpp
deleted file mode 100644
index 6a5af71..0000000
--- a/skia/viewer/src/drawtext.cpp
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright 2022 Rive
- */
-
-#include "skia_factory.hpp"
-#include "skia_renderer.hpp"
-#include "skia_rive_fontmgr.hpp"
-
-#include <string>
-
-static void drawrun(rive::Factory* factory, rive::Renderer* renderer,
-                    const rive::RenderGlyphRun& run, rive::Vec2D origin) {
-    auto font = run.font.get();
-    const auto scale = rive::Mat2D::fromScale(run.size, run.size);
-    auto paint = factory->makeRenderPaint();
-    paint->color(0xFFFFFFFF);
-
-    for (size_t i = 0; i < run.glyphs.size(); ++i) {
-        auto trans = rive::Mat2D::fromTranslate(origin.x + run.xpos[i], origin.y);
-        auto rawpath = font->getPath(run.glyphs[i]);
-        rawpath.transformInPlace(trans * scale);
-        auto path = factory->makeRenderPath(rawpath.points(), rawpath.verbsU8(), rive::FillRule::nonZero);
-        renderer->drawPath(path.get(), paint.get());
-    }
-}
-
-struct UniString {
-    std::vector<rive::Unichar> array;
-
-    UniString(const char text[]) {
-        while (*text) {
-            array.push_back(*text++);
-        }
-    }
-};
-
-static std::string tagstr(uint32_t tag) {
-    std::string s("abcd");
-    s[0] = (tag >> 24) & 0xFF;
-    s[1] = (tag >> 16) & 0xFF;
-    s[2] = (tag >>  8) & 0xFF;
-    s[3] = (tag >>  0) & 0xFF;
-    return s;
-}
-
-void drawtext(rive::Factory* factory, rive::Renderer* renderer) {
-    static std::vector<rive::RenderGlyphRun> gruns;
-
-    if (gruns.size() == 0) {
-        SkiaRiveFontMgr fmgr;
-
-        auto font0 = fmgr.findFont("Times New Roman");
-        auto font1 = fmgr.findFont("Skia");
-
-        if (false) {
-            auto axes = font1->getAxes();
-            for (auto a : axes) {
-                printf("%s %g %g %g\n", tagstr(a.tag).c_str(), a.min, a.def, a.max);
-            }
-            auto coords = font1->getCoords();
-            for (auto c : coords) {
-                printf("%s %g\n", tagstr(c.axis).c_str(), c.value);
-            }
-        }
-
-        rive::RenderFont::Coord c0 = {'wght', 0.5f},
-                                c1 = {'wght', 2.0f};
-
-        UniString str("Uneasy lies the head that wears a crown.");
-        rive::RenderTextRun truns[] = {
-            { font0, 60, 1 },
-            { font0, 30, 6 },
-            { font1->makeAtCoord(c0), 30, 4 },
-            { font1, 30, 4 },
-            { font1->makeAtCoord(c1), 30, 5 },
-            { font0, 30, 20 },
-        };
-
-        gruns = fmgr.shapeText(rive::toSpan(str.array), rive::Span(truns, 6));
-    }
-
-    renderer->save();
-    renderer->scale(3, 3);
-    for (const auto& g : gruns) {
-        drawrun(factory, renderer, g, {10, 50});
-    }
-    renderer->restore();
-}
-
diff --git a/skia/viewer/src/image_content.cpp b/skia/viewer/src/image_content.cpp
index 91c7c4c..276ae40 100644
--- a/skia/viewer/src/image_content.cpp
+++ b/skia/viewer/src/image_content.cpp
@@ -20,8 +20,8 @@
     void handleImgui() override {}
 };
 
-std::unique_ptr<ViewerContent> ViewerContent::Image(const char filename[],
-                                                    rive::Span<const uint8_t> bytes) {
+std::unique_ptr<ViewerContent> ViewerContent::Image(const char filename[]) {
+    auto bytes = LoadFile(filename);
     auto data = SkData::MakeWithCopy(bytes.data(), bytes.size());
     if (auto image = SkImage::MakeFromEncoded(data)) {
         return std::make_unique<ImageContent>(std::move(image));
diff --git a/skia/viewer/src/main.cpp b/skia/viewer/src/main.cpp
index 39814bd..386a359 100644
--- a/skia/viewer/src/main.cpp
+++ b/skia/viewer/src/main.cpp
@@ -29,6 +29,30 @@
 
 std::unique_ptr<ViewerContent> gContent;
 
+std::vector<uint8_t> ViewerContent::LoadFile(const char filename[]) {
+    std::vector<uint8_t> bytes;
+
+    FILE* fp = fopen(filename, "rb");
+    if (!fp) {
+        fprintf(stderr, "Can't find file: %s\n", filename);
+        return bytes;
+    }
+
+    fseek(fp, 0, SEEK_END);
+    size_t size = ftell(fp);
+    fseek(fp, 0, SEEK_SET);
+
+    bytes.resize(size);
+    size_t bytesRead = fread(bytes.data(), 1, size, fp);
+    fclose(fp);
+
+    if (bytesRead != size) {
+        fprintf(stderr, "Failed to read all of %s\n", filename);
+        bytes.resize(0);
+    }
+    return bytes;
+}
+
 static void glfwCursorPosCallback(GLFWwindow* window, double x, double y) {
     if (gContent) {
         float xscale, yscale;
@@ -56,21 +80,7 @@
     // Just get the last dropped file for now...
     const char* filename = paths[count - 1];
 
-    FILE* fp = fopen(filename, "rb");
-    fseek(fp, 0, SEEK_END);
-    size_t size = ftell(fp);
-    fseek(fp, 0, SEEK_SET);
-
-    std::vector<uint8_t> bytes(size);
-    size_t bytesRead = fread(bytes.data(), 1, size, fp);
-    fclose(fp);
-
-    if (bytesRead != size) {
-        fprintf(stderr, "failed to read all of %s\n", filename);
-        return;
-    }
-
-    auto newContent = ViewerContent::FindHandler(filename, rive::toSpan(bytes));
+    auto newContent = ViewerContent::FindHandler(filename);
     if (newContent) {
         gContent = std::move(newContent);
         gContent->handleResize(lastScreenWidth, lastScreenHeight);
diff --git a/skia/viewer/src/scene_content.cpp b/skia/viewer/src/scene_content.cpp
index 364f98d..bb443d2 100644
--- a/skia/viewer/src/scene_content.cpp
+++ b/skia/viewer/src/scene_content.cpp
@@ -282,9 +282,9 @@
 
 rive::CGSkiaFactory skiaFactory;
 
-std::unique_ptr<ViewerContent> ViewerContent::Scene(const char filename[],
-                                                    rive::Span<const uint8_t> data) {
-    if (auto file = rive::File::import(data, &skiaFactory)) {
+std::unique_ptr<ViewerContent> ViewerContent::Scene(const char filename[]) {
+    auto bytes = LoadFile(filename);
+    if (auto file = rive::File::import(rive::toSpan(bytes), &skiaFactory)) {
         return std::make_unique<SceneContent>(filename, std::move(file));
     }
     return nullptr;
diff --git a/skia/viewer/src/skia_rive_fontmgr.hpp b/skia/viewer/src/skia_rive_fontmgr.hpp
deleted file mode 100644
index 9777b11..0000000
--- a/skia/viewer/src/skia_rive_fontmgr.hpp
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright 2022 Rive
- */
-
-#ifndef _RIVE_SKIA_FONTMGR_HPP_
-#define _RIVE_SKIA_FONTMGR_HPP_
-
-#include "rive/refcnt.hpp"
-#include "rive/span.hpp"
-#include "rive/render_text.hpp"
-
-// This is a working model for how we might extend Factory.hpp
-class SkiaRiveFontMgr {
-public:
-    rive::rcp<rive::RenderFont> decodeFont(rive::Span<const uint8_t>);
-    rive::rcp<rive::RenderFont> findFont(const char name[]);
-    std::vector<rive::RenderGlyphRun> shapeText(rive::Span<const rive::Unichar> text,
-                                                rive::Span<const rive::RenderTextRun> runs);
-
-};
-
-#endif
diff --git a/skia/viewer/src/text_content.cpp b/skia/viewer/src/text_content.cpp
new file mode 100644
index 0000000..8536cbb
--- /dev/null
+++ b/skia/viewer/src/text_content.cpp
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2022 Rive
+ */
+
+#include "viewer_content.hpp"
+
+#include "rive/refcnt.hpp"
+#include "rive/render_text.hpp"
+
+#include "skia_factory.hpp"
+#include "skia_renderer.hpp"
+#include "line_breaker.hpp"
+
+static bool ws(rive::Unichar c) {
+    return c <= ' ';
+}
+
+std::vector<int> compute_word_breaks(rive::Span<rive::Unichar> chars) {
+    std::vector<int> breaks;
+
+    const unsigned len = chars.size();
+    for (unsigned i = 0; i < len;) {
+      // skip ws
+      while (i < len && ws(chars[i])) {
+        ++i;
+      }
+      breaks.push_back(i); // word start
+      // skip non-ws
+      while (i < len && !ws(chars[i])) {
+        ++i;
+      }
+      breaks.push_back(i); // word end
+    }
+    assert(breaks[breaks.size()-1] == len);
+    return breaks;
+}
+
+static void drawrun(rive::Factory* factory, rive::Renderer* renderer,
+                    const rive::RenderGlyphRun& run, unsigned startIndex, unsigned endIndex, rive::Vec2D origin) {
+    auto font = run.font.get();
+    const auto scale = rive::Mat2D::fromScale(run.size, run.size);
+    auto paint = factory->makeRenderPaint();
+    paint->color(0xFFFFFFFF);
+
+    assert(startIndex >= 0 && endIndex <= run.glyphs.size());
+    for (size_t i = startIndex; i < endIndex; ++i) {
+        auto trans = rive::Mat2D::fromTranslate(origin.x + run.xpos[i], origin.y);
+        auto rawpath = font->getPath(run.glyphs[i]);
+        rawpath.transformInPlace(trans * scale);
+        auto path = factory->makeRenderPath(rawpath.points(), rawpath.verbsU8(), rive::FillRule::nonZero);
+        renderer->drawPath(path.get(), paint.get());
+    }
+}
+
+static void drawpara(rive::Factory* factory, rive::Renderer* renderer,
+                     rive::Span<const rive::RenderGlyphLine> lines,
+                     rive::Span<const rive::RenderGlyphRun> runs,
+                     rive::Vec2D origin) {
+    for (const auto& line : lines) {
+        const float x0 = runs[line.startRun].xpos[line.startIndex];
+        int startGIndex = line.startIndex;
+        for (int runIndex = line.startRun; runIndex <= line.endRun; ++runIndex) {
+            const auto& run = runs[runIndex];
+            int endGIndex = runIndex == line.endRun ? line.endIndex : run.glyphs.size();
+            drawrun(factory, renderer, run, startGIndex, endGIndex,
+                    {origin.x - x0, origin.y + line.baseline});
+            startGIndex = 0;
+        }
+    }
+}
+
+////////////////////////////////////////////////////////////////////////////////////
+
+typedef rive::rcp<rive::RenderFont> (*RenderFontFactory)(rive::Span<const uint8_t>);
+
+#include "renderfont_skia.hpp"
+#include "renderfont_hb.hpp"
+#include "include/core/SkData.h"
+
+static void draw_line(rive::Factory* factory, rive::Renderer* renderer, float x) {
+    auto paint = factory->makeRenderPaint();
+    paint->style(rive::RenderPaintStyle::stroke);
+    paint->thickness(1);
+    paint->color(0xFFFFFFFF);
+    auto path = factory->makeEmptyRenderPath();
+    path->move({x, 0});
+    path->line({x, 1000});
+    renderer->drawPath(path.get(), paint.get());
+}
+
+static rive::SkiaFactory skiaFactory;
+
+static rive::RenderTextRun append(std::vector<rive::Unichar>* unichars,
+                                  rive::rcp<rive::RenderFont> font,
+                                  float size, const char text[]) {
+    uint32_t n = 0;
+    while (text[n]) {
+        unichars->push_back(text[n]);   // todo: utf8 -> unichar
+        n += 1;
+    }
+    return { std::move(font), size, n };
+}
+
+class TextContent : public ViewerContent {
+    std::vector<rive::Unichar> m_unichars;
+    std::vector<int> m_breaks;
+    std::vector<rive::RenderTextRun> m_truns;
+    std::vector<rive::RenderGlyphRun> m_gruns;
+
+    void make_truns() {
+        auto loader = [](const char filename[]) -> rive::rcp<rive::RenderFont> {
+            auto bytes = ViewerContent::LoadFile(filename);
+            if (bytes.size() == 0) {
+                return nullptr;
+            }
+            return HBRenderFont::Decode(rive::toSpan(bytes));
+        };
+
+        const char* fontFiles[] = {
+            "../../test/assets/RobotoFlex.ttf",
+            "../../test/assets/LibreBodoni-Italic-VariableFont_wght.ttf",
+        };
+
+        auto font0 = loader(fontFiles[0]);
+        auto font1 = loader(fontFiles[1]);
+        assert(font0);
+        assert(font1);
+
+        rive::RenderFont::Coord c1 = {'wght', 100.f},
+                                c2 = {'wght', 700.f};
+
+        m_truns.push_back(append(&m_unichars, font0->makeAtCoord(c2), 60, "U"));
+        m_truns.push_back(append(&m_unichars, font0->makeAtCoord(c1), 30, "neasy"));
+        m_truns.push_back(append(&m_unichars, font1, 30, " fits the crown"));
+        m_truns.push_back(append(&m_unichars, font1->makeAtCoord(c1), 30, " that often"));
+        m_truns.push_back(append(&m_unichars, font0, 30, " lies the head."));
+
+        m_breaks = compute_word_breaks(rive::toSpan(m_unichars));
+    }
+
+    void make_gruns() {
+        m_gruns = m_truns[0].font->shapeText(rive::toSpan(m_unichars), rive::toSpan(m_truns));
+    }
+
+public:
+    TextContent() {
+        this->make_truns();
+    }
+
+    void handleDraw(SkCanvas* canvas, double) override {
+        rive::SkiaRenderer renderer(canvas);
+
+        this->make_gruns();
+
+        renderer.save();
+        renderer.translate(10, 0);
+
+        renderer.save();
+        renderer.scale(3, 3);
+
+        static float width = 300;
+        static float dw = 1;
+        width += dw; if (width > 600) { dw = -dw; } if (width < 50) { dw = -dw; }
+        auto lines = rive::RenderGlyphLine::BreakLines(rive::toSpan(m_gruns), rive::toSpan(m_breaks), width);
+
+        drawpara(&skiaFactory, &renderer, rive::toSpan(lines), rive::toSpan(m_gruns), {0, 0});
+
+        draw_line(&skiaFactory, &renderer, width);
+
+        renderer.restore();
+
+        renderer.restore();
+    }
+
+    void handleResize(int width, int height) override {}
+    void handleImgui() override {}
+};
+
+std::unique_ptr<ViewerContent> ViewerContent::Text(const char filename[]) {
+    return std::make_unique<TextContent>();
+}
diff --git a/skia/viewer/src/viewer_content.hpp b/skia/viewer/src/viewer_content.hpp
index 650b375..3fd2697 100644
--- a/skia/viewer/src/viewer_content.hpp
+++ b/skia/viewer/src/viewer_content.hpp
@@ -23,15 +23,13 @@
     virtual void handlePointerDown() {}
     virtual void handlePointerUp() {}
 
-    using Factory = std::unique_ptr<ViewerContent> (*)(const char filename[],
-                                                       rive::Span<const uint8_t>);
+    using Factory = std::unique_ptr<ViewerContent> (*)(const char filename[]);
 
     // Searches all handlers and returns a content if it is found.
-    static std::unique_ptr<ViewerContent> FindHandler(const char filename[],
-                                                      rive::Span<const uint8_t> data) {
-        Factory factories[] = { Scene, Image };
+    static std::unique_ptr<ViewerContent> FindHandler(const char filename[]) {
+        Factory factories[] = { Scene, Image, Text };
         for (auto f : factories) {
-            if (auto content = f(filename, data)) {
+            if (auto content = f(filename)) {
                 return content;
             }
         }
@@ -39,8 +37,11 @@
     }
 
     // Private factories...
-    static std::unique_ptr<ViewerContent> Scene(const char[], rive::Span<const uint8_t>);
-    static std::unique_ptr<ViewerContent> Image(const char[], rive::Span<const uint8_t>);
+    static std::unique_ptr<ViewerContent> Scene(const char[]);
+    static std::unique_ptr<ViewerContent> Image(const char[]);
+    static std::unique_ptr<ViewerContent> Text(const char[]);
+
+    static std::vector<uint8_t> LoadFile(const char path[]);
 };
 
 #endif
diff --git a/src/renderer.cpp b/src/renderer.cpp
index 87111ee..9780a2a 100644
--- a/src/renderer.cpp
+++ b/src/renderer.cpp
@@ -110,3 +110,26 @@
 
 RenderPath::RenderPath() { gCounter.update(kPath, 1); }
 RenderPath::~RenderPath() { gCounter.update(kPath, -1); }
+
+#include "rive/render_text.hpp"
+
+std::vector<RenderGlyphRun> RenderFont::shapeText(rive::Span<const rive::Unichar> text,
+                                                  rive::Span<const rive::RenderTextRun> runs) const {
+#ifdef DEBUG
+    size_t count = 0;
+    for (const auto& tr : runs) {
+        assert(tr.unicharCount > 0);
+        count += tr.unicharCount;
+    }
+    assert(count <= text.size());
+#endif
+    auto gruns = this->onShapeText(text, runs);
+#ifdef DEBUG
+    for (const auto& gr : gruns) {
+        assert(gr.glyphs.size() > 0);
+        assert(gr.glyphs.size() == gr.textOffsets.size());
+        assert(gr.glyphs.size() + 1 == gr.xpos.size());
+    }
+#endif
+    return gruns;
+}
diff --git a/test/assets/LibreBodoni-Italic-VariableFont_wght.ttf b/test/assets/LibreBodoni-Italic-VariableFont_wght.ttf
new file mode 100644
index 0000000..2c0891e
--- /dev/null
+++ b/test/assets/LibreBodoni-Italic-VariableFont_wght.ttf
Binary files differ
diff --git a/test/assets/RobotoFlex.ttf b/test/assets/RobotoFlex.ttf
new file mode 100644
index 0000000..4cf1ecb
--- /dev/null
+++ b/test/assets/RobotoFlex.ttf
Binary files differ