freetype-commit
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[freetype2-demos] master e9aa456 22/41: # This is a combination of 2 com


From: Werner Lemberg
Subject: [freetype2-demos] master e9aa456 22/41: # This is a combination of 2 commits.
Date: Mon, 3 Oct 2022 11:27:02 -0400 (EDT)

branch: master
commit e9aa456e6e40f6c7b85cbcb44d4a8a6a9a0aa278
Author: Charlie Jiang <w@chariri.moe>
Commit: Werner Lemberg <wl@gnu.org>

    # This is a combination of 2 commits.
    # This is the 1st commit message:
    
    [ftinspect] Add "Continuous View".
    
    Most new features in the continuous view are included in the commit, except
    the mouse left (details pane)/right (go to singular) click behaviour.
    
    * src/ftinspect/panels/continuous.cpp, src/ftinspect/panels/continuous.hpp:
      New files, the main continuous tab.
    
    * src/ftinspect/glyphcomponents/glyphcontinuous.cpp,
      src/ftinspect/glyphcomponents/glyphcontinuous.hpp:
      New files, adding the `GlyphContinuous` as the actual canvas for
      continuous rendering.
    
    * src/ftinspect/engine/stringrenderer.cpp,
      src/ftinspect/engine/stringrenderer.hpp:
      New files, adding `StringRenderer` to layout the strings and produce
      glyphs for the canvas to draw.
    
    * src/ftinspect/widgets/charmapcombobox.cpp,
      src/ftinspect/widgets/charmapcombobox.hpp:
      New files, add the `CharMapComboBox` widget.
    
    * src/ftinspect/engine/charmap.cpp,src/ftinspect/engine/charmap.hpp:
      New files, adding `CharMapInfo` class.
    
    * src/ftinspect/engine/engine.cpp, src/ftinspect/engine/engine.hpp:
      Add necessary fields and getters for string rendering.
      Retrieve charmap when loading fonts.
    
    * src/ftinspect/maingui.cpp, src/ftinspect/maingui.hpp:
      Add the continuous view to the main window.
      Call `ContinuousTab::highlightGlyph` when switching from singular to
      continuous view.
    
    * src/ftinspect/panels/settingpanel.cpp: Uncomment functional code.
    
    * src/ftinspect/CMakeLists.txt, src/ftinspect/meson.build: Updated.
    
    # This is the commit message #2:
    
    * src/ftinspect/engine/stringrenderer.cpp: Fix infinite loop.
    
    Don't limit the glyph position and exit too early, or the loop in `render`
    function won't stop forever. Also, since the string can be moved by the
    mouse, there's no point to limit the line position in the canvas viewport.
---
 src/ftinspect/CMakeLists.txt                      |   5 +
 src/ftinspect/engine/charmap.cpp                  | 158 +++++
 src/ftinspect/engine/charmap.hpp                  |  61 ++
 src/ftinspect/engine/engine.cpp                   |  77 +++
 src/ftinspect/engine/engine.hpp                   |  21 +-
 src/ftinspect/engine/stringrenderer.cpp           | 721 ++++++++++++++++++++++
 src/ftinspect/engine/stringrenderer.hpp           | 241 ++++++++
 src/ftinspect/glyphcomponents/glyphcontinuous.cpp | 602 ++++++++++++++++++
 src/ftinspect/glyphcomponents/glyphcontinuous.hpp | 204 ++++++
 src/ftinspect/maingui.cpp                         |  14 +-
 src/ftinspect/maingui.hpp                         |   2 +
 src/ftinspect/meson.build                         |   8 +
 src/ftinspect/panels/continuous.cpp               | 699 +++++++++++++++++++++
 src/ftinspect/panels/continuous.hpp               | 153 +++++
 src/ftinspect/panels/settingpanel.cpp             |   6 +-
 src/ftinspect/widgets/charmapcombobox.cpp         | 118 ++++
 src/ftinspect/widgets/charmapcombobox.hpp         |  40 ++
 17 files changed, 3124 insertions(+), 6 deletions(-)

diff --git a/src/ftinspect/CMakeLists.txt b/src/ftinspect/CMakeLists.txt
index 19c9591..098f331 100644
--- a/src/ftinspect/CMakeLists.txt
+++ b/src/ftinspect/CMakeLists.txt
@@ -26,8 +26,11 @@ add_executable(ftinspect
   "engine/paletteinfo.cpp"
   "engine/mmgx.cpp"
   "engine/fontinfo.cpp"
+  "engine/stringrenderer.cpp"
+  "engine/charmap.cpp"
 
   "glyphcomponents/glyphbitmap.cpp"
+  "glyphcomponents/glyphcontinuous.cpp"
   "glyphcomponents/glyphoutline.cpp"
   "glyphcomponents/glyphpointnumbers.cpp"
   "glyphcomponents/glyphpoints.cpp"
@@ -38,12 +41,14 @@ add_executable(ftinspect
   "widgets/tripletselector.cpp"
   "widgets/glyphindexselector.cpp"
   "widgets/fontsizeselector.cpp"
+  "widgets/charmapcombobox.cpp"
 
   "models/customcomboboxmodels.cpp"
 
   "panels/settingpanel.cpp"
   "panels/settingpanelmmgx.cpp"
   "panels/singular.cpp"
+  "panels/continuous.cpp"
 )
 target_link_libraries(ftinspect
   Qt5::Core Qt5::Widgets
diff --git a/src/ftinspect/engine/charmap.cpp b/src/ftinspect/engine/charmap.cpp
new file mode 100644
index 0000000..680b6cd
--- /dev/null
+++ b/src/ftinspect/engine/charmap.cpp
@@ -0,0 +1,158 @@
+// charmap.cpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#include "charmap.hpp"
+
+#include <QHash>
+#include <freetype/freetype.h>
+#include <freetype/tttables.h>
+
+namespace
+{
+QHash<FT_Encoding, QString>& encodingNames();
+}
+
+CharMapInfo::CharMapInfo(int index, FT_CharMap cmap)
+: index(index), ptr(cmap),
+  encoding(cmap->encoding),
+  platformID(cmap->platform_id),
+  encodingID(cmap->encoding_id),
+  formatID(FT_Get_CMap_Format(cmap)),
+  languageID(FT_Get_CMap_Language_ID(cmap)),
+  maxIndex(-1)
+{
+  auto& names = encodingNames();
+  auto it = names.find(encoding);
+  if (it == names.end())
+    encodingName = &names[static_cast<FT_Encoding>(FT_ENCODING_OTHER)];
+  else
+    encodingName = &it.value();
+
+  if (encoding != FT_ENCODING_OTHER)
+    maxIndex = computeMaxIndex();
+}
+
+
+QString
+CharMapInfo::stringifyIndex(int code, int idx)
+{
+  return QString("CharCode: %1 (glyph idx %2)")
+           .arg(stringifyIndexShort(code))
+           .arg(idx);
+}
+
+
+QString
+CharMapInfo::stringifyIndexShort(int code)
+{
+  return (encoding == FT_ENCODING_UNICODE ? "U+" : "0x")
+         + QString::number(code, 16).rightJustified(4, '0').toUpper();
+}
+
+
+int
+CharMapInfo::computeMaxIndex()
+{
+  int result;
+  switch (encoding)
+  {
+  case FT_ENCODING_UNICODE:
+    result = maxIndexForFaceAndCharMap(ptr, 0x110000) + 1;
+    break;
+
+  case FT_ENCODING_ADOBE_LATIN_1:
+  case FT_ENCODING_ADOBE_STANDARD:
+  case FT_ENCODING_ADOBE_EXPERT:
+  case FT_ENCODING_ADOBE_CUSTOM:
+  case FT_ENCODING_APPLE_ROMAN:
+    result = 0x100;
+    break;
+
+  /* some fonts use range 0x00-0x100, others have 0xF000-0xF0FF */
+  case FT_ENCODING_MS_SYMBOL:
+    result = maxIndexForFaceAndCharMap(ptr, 0x10000) + 1;
+    break;
+
+  default:
+    // Some encodings can reach > 0x10000, e.g. GB 18030.
+    result = maxIndexForFaceAndCharMap(ptr, 0x110000) + 1;
+  }
+  return result;
+}
+
+
+int
+CharMapInfo::maxIndexForFaceAndCharMap(FT_CharMap charMap,
+                                       unsigned maxIn)
+{
+  // code adopted from `ftcommon.c`
+  // This never overflows since no format here exceeds INT_MAX...
+  FT_ULong min = 0, max = maxIn;
+  FT_UInt glyphIndex;
+  FT_Face face = charMap->face;
+
+  if (FT_Set_Charmap(face, charMap))
+    return -1;
+
+  do
+  {
+    FT_ULong mid = (min + max) >> 1;
+    FT_ULong res = FT_Get_Next_Char(face, mid, &glyphIndex);
+
+    if (glyphIndex)
+      min = res;
+    else
+    {
+      max = mid;
+
+      // once moved, it helps to advance min through sparse regions
+      if (min)
+      {
+        res = FT_Get_Next_Char(face, min, &glyphIndex);
+
+        if (glyphIndex)
+          min = res;
+        else
+          max = min; // found it
+      }
+    }
+  } while (max > min);
+
+  return static_cast<int>(max);
+}
+
+
+namespace
+{
+// Mapping for `FT_Encoding` is placed here since it's only for the charmap.
+QHash<FT_Encoding, QString> encodingNamesCache;
+QHash<FT_Encoding, QString>&
+encodingNames()
+{
+  if (encodingNamesCache.empty())
+  {
+    encodingNamesCache[static_cast<FT_Encoding>(FT_ENCODING_OTHER)]
+     = "Unknown Encoding";
+    encodingNamesCache[FT_ENCODING_NONE] = "No Encoding";
+    encodingNamesCache[FT_ENCODING_MS_SYMBOL] = "MS Symbol (symb)";
+    encodingNamesCache[FT_ENCODING_UNICODE] = "Unicode (unic)";
+    encodingNamesCache[FT_ENCODING_SJIS] = "Shift JIS (sjis)";
+    encodingNamesCache[FT_ENCODING_PRC] = "PRC/GB 18030 (gb)";
+    encodingNamesCache[FT_ENCODING_BIG5] = "Big5 (big5)";
+    encodingNamesCache[FT_ENCODING_WANSUNG] = "Wansung (wans)";
+    encodingNamesCache[FT_ENCODING_JOHAB] = "Johab (joha)";
+    encodingNamesCache[FT_ENCODING_ADOBE_STANDARD] = "Adobe Standard (ADOB)";
+    encodingNamesCache[FT_ENCODING_ADOBE_EXPERT] = "Adobe Expert (ADBE)";
+    encodingNamesCache[FT_ENCODING_ADOBE_CUSTOM] = "Adobe Custom (ADBC)";
+    encodingNamesCache[FT_ENCODING_ADOBE_LATIN_1] = "Latin 1 (lat1)";
+    encodingNamesCache[FT_ENCODING_OLD_LATIN_2] = "Latin 2 (lat2)";
+    encodingNamesCache[FT_ENCODING_APPLE_ROMAN] = "Apple Roman (armn)";
+  }
+
+  return encodingNamesCache;
+}
+}
+
+
+// end of charmap.cpp
diff --git a/src/ftinspect/engine/charmap.hpp b/src/ftinspect/engine/charmap.hpp
new file mode 100644
index 0000000..8b73470
--- /dev/null
+++ b/src/ftinspect/engine/charmap.hpp
@@ -0,0 +1,61 @@
+// charmap.hpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#pragma once
+
+#include <QString>
+
+#include <ft2build.h>
+#include <freetype/freetype.h>
+
+class Engine;
+
+#define FT_ENCODING_OTHER 0xFFFE
+struct CharMapInfo
+{
+  int index;
+  FT_CharMap ptr;
+  FT_Encoding encoding;
+  unsigned short platformID;
+  unsigned short encodingID;
+  long formatID;
+  unsigned long languageID;
+  QString* encodingName;
+
+  // Actually this shouldn't go here, but for convenience...
+  int maxIndex;
+
+  CharMapInfo(int index, FT_CharMap cmap);
+
+  QString stringifyIndex(int code, int idx);
+  QString stringifyIndexShort(int code);
+
+
+  friend bool
+  operator==(const CharMapInfo& lhs, const CharMapInfo& rhs)
+  {
+    // omitting `ptr` by design!
+    return lhs.index == rhs.index
+           && lhs.encoding == rhs.encoding
+           && lhs.platformID == rhs.platformID
+           && lhs.encodingID == rhs.encodingID
+           && lhs.formatID == rhs.formatID
+           && lhs.languageID == rhs.languageID;
+  }
+
+
+  friend bool
+  operator!=(const CharMapInfo& lhs, const CharMapInfo& rhs)
+  {
+    return !(lhs == rhs);
+  }
+
+
+private:
+  int computeMaxIndex();
+  static int maxIndexForFaceAndCharMap(FT_CharMap charMap, unsigned max);
+};
+
+
+// end of charmap.hpp
diff --git a/src/ftinspect/engine/engine.cpp b/src/ftinspect/engine/engine.cpp
index 459f815..87ee808 100644
--- a/src/ftinspect/engine/engine.cpp
+++ b/src/ftinspect/engine/engine.cpp
@@ -152,6 +152,12 @@ Engine::Engine()
     // XXX error handling
   }
 
+  error = FTC_CMapCache_New(cacheManager_, &cmapCache_);
+  if (error)
+  {
+    // XXX error handling
+  }
+  
   queryEngine();
   renderingEngine_
     = std::unique_ptr<RenderingEngine>(new RenderingEngine(this));
@@ -329,6 +335,7 @@ Engine::loadFont(int fontIndex,
     curFamilyName_ = QString();
     curStyleName_ = QString();
 
+    curCharMaps_.clear();
     curPaletteInfos_.clear();
     curSFNTNames_.clear();
   }
@@ -347,6 +354,11 @@ Engine::loadFont(int fontIndex,
     else
       fontType_ = FontType_Other;
 
+    curCharMaps_.clear();
+    curCharMaps_.reserve(ftFallbackFace_->num_charmaps);
+    for (int i = 0; i < ftFallbackFace_->num_charmaps; i++)
+      curCharMaps_.emplace_back(i, ftFallbackFace_->charmaps[i]);
+
     SFNTName::get(this, curSFNTNames_);
     loadPaletteInfos();
     curMMGXState_ = MMGXAxisInfo::get(this, curMMGXAxes_);
@@ -465,6 +477,58 @@ Engine::currentFontFixedSizes()
 }
 
 
+int
+Engine::currentFontFirstUnicodeCharMap()
+{
+  auto& charmaps = currentFontCharMaps();
+  for (auto& cmap : charmaps)
+    if (cmap.encoding == FT_ENCODING_UNICODE)
+      return cmap.index;
+  return -1;
+}
+
+
+unsigned
+Engine::glyphIndexFromCharCode(int code, int charMapIndex)
+{
+  if (charMapIndex < 0)
+    return code;
+  return FTC_CMapCache_Lookup(cmapCache_, scaler_.face_id, charMapIndex, code);
+}
+
+
+FT_Pos
+Engine::currentFontTrackingKerning(int degree)
+{
+  if (!ftSize_)
+    return 0;
+
+  FT_Pos result;
+  // this function needs and returns points, not pixels
+  if (!FT_Get_Track_Kerning(ftSize_->face,
+                            static_cast<FT_Fixed>(scaler_.width) << 10, 
+                            -degree,
+                            &result))
+  {
+    result = static_cast<FT_Pos>((result / 1024.0 * scaler_.x_res) / 72.0);
+    return result;
+  }
+  return 0;
+}
+
+
+FT_Vector
+Engine::currentFontKerning(int glyphIndex,
+                           int prevIndex)
+{
+  FT_Vector kern = {0, 0};
+  FT_Get_Kerning(ftSize_->face, 
+                 prevIndex, glyphIndex, 
+                 FT_KERNING_UNFITTED, &kern);
+  return kern;
+}
+
+
 std::pair<int, int>
 Engine::currentSizeAscDescPx()
 {
@@ -571,6 +635,19 @@ Engine::loadGlyphWithoutUpdate(int glyphIndex,
 }
 
 
+FT_Size_Metrics const&
+Engine::currentFontMetrics()
+{
+  return ftSize_->metrics;
+}
+
+FT_GlyphSlot
+Engine::currentFaceSlot()
+{
+  return ftSize_->face->glyph;
+}
+
+
 bool
 Engine::renderReady()
 {
diff --git a/src/ftinspect/engine/engine.hpp b/src/ftinspect/engine/engine.hpp
index cba152e..d9ea177 100644
--- a/src/ftinspect/engine/engine.hpp
+++ b/src/ftinspect/engine/engine.hpp
@@ -6,12 +6,13 @@
 #pragma once
 
 #include "fontfilemanager.hpp"
-
 #include "paletteinfo.hpp"
 #include "fontinfo.hpp"
 #include "mmgx.hpp"
 #include "rendering.hpp"
+#include "charmap.hpp"
 
+#include <vector>
 #include <memory>
 #include <utility>
 #include <QString>
@@ -109,6 +110,9 @@ public:
   // (for current fonts)
   FT_Face currentFallbackFtFace() { return ftFallbackFace_; }
   FT_Size currentFtSize() { return ftSize_; }
+  FT_Size_Metrics const& currentFontMetrics();
+  FT_GlyphSlot currentFaceSlot();
+
   bool renderReady(); // Can we render bitmaps? (implys `fontValid`)
   bool fontValid(); // Is the current font valid (valid font may be unavailable
                     // to render, such as non-scalable font with invalid sizes)
@@ -124,6 +128,7 @@ public:
   MMGXState currentFontMMGXState() { return curMMGXState_; }
   std::vector<MMGXAxisInfo>& currentFontMMGXAxes() { return curMMGXAxes_; }
   std::vector<SFNTName>& currentFontSFNTNames() { return curSFNTNames_; }
+  std::vector<CharMapInfo>& currentFontCharMaps() { return curCharMaps_; }
 
   QString glyphName(int glyphIndex);
   long numberOfFaces(int fontIndex);
@@ -137,13 +142,21 @@ public:
   bool currentFontHasColorLayers();
   std::vector<int> currentFontFixedSizes();
 
+  int currentFontFirstUnicodeCharMap();
+  // Note: the current font face must be properly set
+  unsigned glyphIndexFromCharCode(int code, int charMapIndex);
+  FT_Pos currentFontTrackingKerning(int degree);
+  FT_Vector currentFontKerning(int glyphIndex, int prevIndex);
   std::pair<int, int> currentSizeAscDescPx();
 
   // (settings)
   int dpi() { return dpi_; }
+  double pointSize() { return pointSize_; }
   FTC_ImageType imageType() { return &imageType_; }
   bool antiAliasingEnabled() { return antiAliasingEnabled_; }
+  bool doHinting() { return doHinting_; }
   bool embeddedBitmapEnabled() { return embeddedBitmap_; }
+  bool lcdUsingSubPixelPositioning() { return lcdSubPixelPositioning_; }
   bool useColorLayer() { return useColorLayer_; }
   int paletteIndex() { return paletteIndex_; }
   FT_Render_Mode
@@ -179,8 +192,9 @@ public:
   void setEmbeddedBitmapEnabled(bool enabled) { embeddedBitmap_ = enabled; }
   void setUseColorLayer(bool colorLayer) { useColorLayer_ = colorLayer; }
   void setPaletteIndex(int index) { paletteIndex_ = index; }
+  void setLCDSubPixelPositioning(bool sp) { lcdSubPixelPositioning_ = sp; }
+  
   // (settings without backing fields)
-
   // Note: These 3 functions now takes actual mode/version from FreeType,
   // instead of values from enum in MainGUI!
   void setLcdFilter(FT_LcdFilter filter);
@@ -210,6 +224,7 @@ private:
   QString curFamilyName_;
   QString curStyleName_;
   int curNumGlyphs_ = -1;
+  std::vector<CharMapInfo> curCharMaps_;
   std::vector<PaletteInfo> curPaletteInfos_;
   MMGXState curMMGXState_ = MMGXState::NoMMGX;
   std::vector<MMGXAxisInfo> curMMGXAxes_;
@@ -220,6 +235,7 @@ private:
   FTC_Manager cacheManager_;
   FTC_ImageCache imageCache_;
   FTC_SBitCache sbitsCache_;
+  FTC_CMapCache cmapCache_;
   EngineDefaultValues engineDefaults_;
 
   // settings
@@ -249,6 +265,7 @@ private:
   bool useColorLayer_;
   int paletteIndex_ = -1;
   int antiAliasingTarget_;
+  bool lcdSubPixelPositioning_;
   int renderMode_;
 
   unsigned long loadFlags_;
diff --git a/src/ftinspect/engine/stringrenderer.cpp 
b/src/ftinspect/engine/stringrenderer.cpp
new file mode 100644
index 0000000..bd9eaf1
--- /dev/null
+++ b/src/ftinspect/engine/stringrenderer.cpp
@@ -0,0 +1,721 @@
+// stringrenderer.cpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#include "stringrenderer.hpp"
+
+#include "engine.hpp"
+
+#include <cmath>
+#include <QTextCodec>
+
+
+StringRenderer::StringRenderer(Engine* engine)
+: engine_(engine)
+{
+}
+
+
+StringRenderer::~StringRenderer()
+{
+  clearActive();
+}
+
+
+void
+StringRenderer::setCharMapIndex(int charMapIndex,
+                                int limitIndex)
+{
+  auto& charMaps = engine_->currentFontCharMaps();
+  if (charMapIndex < 0
+      || static_cast<unsigned>(charMapIndex) >= charMaps.size())
+    charMapIndex = -1;
+
+  charMapIndex_ = charMapIndex;
+  limitIndex_ = limitIndex;
+}
+
+
+void
+StringRenderer::setRotation(double rotation)
+{
+  rotation_ = rotation;
+
+  if (rotation <= -180)
+    rotation += 360;
+  if (rotation > 180)
+    rotation -= 360;
+
+  if (rotation == 0)
+  {
+    matrixEnabled_ = false;
+    return;
+  }
+
+  matrixEnabled_ = true;
+  double radian = rotation * 3.14159265 / 180.0;
+  auto cosinus = static_cast<FT_Fixed>(cos(radian) * 65536.0);
+  auto sinus = static_cast<FT_Fixed>(sin(radian) * 65536.0);
+
+  matrix_.xx = cosinus;
+  matrix_.yx = sinus;
+  matrix_.xy = -sinus;
+  matrix_.yy = cosinus;
+}
+
+
+void
+StringRenderer::setKerning(bool kerning)
+{
+  if (kerning)
+  {
+    kerningMode_ = KM_Smart;
+    kerningDegree_ = KD_Medium;
+  }
+  else
+  {
+    kerningMode_ = KM_None;
+    kerningDegree_ = KD_None;
+  }
+}
+
+
+void
+StringRenderer::reloadAll()
+{
+  clearActive(usingString_); // if "All Glyphs", then do a complete wipe
+  if (usingString_)
+    reloadGlyphIndices();
+}
+
+
+void
+StringRenderer::reloadGlyphs()
+{
+  clearActive(true);
+}
+
+
+void
+StringRenderer::setUseString(QString const& string)
+{
+  clearActive(); // clear existing
+  usingString_ = true;
+
+  long long totalCount = 0;
+  for (uint ch : string.toUcs4())
+  {
+    activeGlyphs_.emplace_back();
+    auto& it = activeGlyphs_.back();
+    it.charCodeUcs4 = it.charCode = static_cast<int>(ch);
+    it.glyphIndex = 0;
+    ++totalCount;
+    if (totalCount >= INT_MAX) // Prevent overflow
+      break;
+  }
+  reloadGlyphIndices();
+}
+
+
+void
+StringRenderer::setUseAllGlyphs()
+{
+  if (usingString_)
+    clearActive();
+  usingString_ = false;
+}
+
+
+void
+StringRenderer::reloadGlyphIndices()
+{
+  if (!usingString_)
+    return;
+  int charMapIndex = charMapIndex_;
+  auto& charMaps = engine_->currentFontCharMaps();
+  if (charMaps.empty())
+    return;
+  if (charMapIndex < 0
+      || static_cast<unsigned>(charMapIndex) >= charMaps.size())
+    charMapIndex = engine_->currentFontFirstUnicodeCharMap();
+  if (charMapIndex < 0
+      || static_cast<unsigned>(charMapIndex) >= charMaps.size())
+    charMapIndex = 0;
+  auto encoding = charMaps[charMapIndex].encoding;
+
+  if (charMapIndex < 0)
+    return;
+  for (auto& ctx : activeGlyphs_)
+  {
+    if (encoding != FT_ENCODING_UNICODE)
+      ctx.charCode = convertCharEncoding(ctx.charCodeUcs4, encoding);
+
+    auto index = engine_->glyphIndexFromCharCode(ctx.charCode, charMapIndex);
+    ctx.glyphIndex = static_cast<int>(index);
+  }
+}
+
+
+void
+StringRenderer::prepareRendering()
+{
+  engine_->reloadFont();
+  if (!engine_->renderReady())
+    return;
+  engine_->loadPalette();
+  if (kerningDegree_ != KD_None)
+    trackingKerning_ = engine_->currentFontTrackingKerning(kerningDegree_);
+  else
+    trackingKerning_ = 0;
+}
+
+
+void
+StringRenderer::loadSingleContext(GlyphContext* ctx,
+                                  GlyphContext* prev)
+{
+  if (ctx->cacheNode)
+  {
+    FTC_Node_Unref(ctx->cacheNode, engine_->cacheManager());
+    ctx->cacheNode = NULL;
+  }
+  else if (ctx->glyph)
+    FT_Done_Glyph(ctx->glyph); // when caching isn't used
+
+  // TODO use FTC?
+
+  // After `prepareRendering`, current size/face is properly set
+  FT_GlyphSlot slot = engine_->currentFaceSlot();
+  if (engine_->loadGlyphIntoSlotWithoutCache(ctx->glyphIndex) != 0)
+  {
+    ctx->glyph = NULL;
+    return;
+  }
+  if (FT_Get_Glyph(slot, &ctx->glyph) != 0)
+  {
+    ctx->glyph = NULL;
+    return;
+  }
+  auto& metrics = slot->metrics;
+  //ctx->glyph = engine_->loadGlyphWithoutUpdate(ctx->glyphIndex, 
+  //                                            &ctx->cacheNode);
+
+  if (!ctx->glyph)
+    return;
+
+  ctx->vvector.x = metrics.vertBearingX - metrics.horiBearingX;
+  ctx->vvector.y = -metrics.vertBearingY - metrics.horiBearingY;
+
+  ctx->vadvance.x = 0;
+  ctx->vadvance.y = -metrics.vertAdvance;
+
+  ctx->lsbDelta = slot->lsb_delta;
+  ctx->rsbDelta = slot->rsb_delta;
+
+  ctx->hadvance.x = metrics.horiAdvance;
+  ctx->hadvance.y = 0;
+
+  if (lsbRsbDeltaEnabled_ && engine_->lcdUsingSubPixelPositioning())
+    ctx->hadvance.x += ctx->lsbDelta - ctx->rsbDelta;
+  prev->hadvance.x += trackingKerning_;
+
+  if (kerningMode_ != KM_None)
+  {
+    FT_Vector kern = engine_->currentFontKerning(ctx->glyphIndex, 
+                                        prev->glyphIndex);
+
+    prev->hadvance.x += kern.x;
+    prev->hadvance.y += kern.y;
+  }
+
+  if (!engine_->lcdUsingSubPixelPositioning()
+      && lsbRsbDeltaEnabled_)
+  {
+    if (prev->rsbDelta - ctx->lsbDelta > 32)
+      prev->hadvance.x -= 64;
+    else if (prev->rsbDelta - ctx->lsbDelta < -31)
+      prev->hadvance.x += 64;
+  }
+
+  if (!engine_->lcdUsingSubPixelPositioning() && engine_->doHinting())
+  {
+    prev->hadvance.x = (prev->hadvance.x + 32) & -64;
+    prev->hadvance.y = (prev->hadvance.y + 32) & -64;
+  }
+}
+
+
+void
+StringRenderer::loadStringGlyphs()
+{
+  if (!usingString_)
+    return;
+  GlyphContext* prev = &tempGlyphContext_; // = empty
+  tempGlyphContext_ = {};
+
+  for (auto& ctx : activeGlyphs_)
+  {
+    loadSingleContext(&ctx, prev);
+    prev = &ctx;
+  }
+
+  glyphCacheValid_ = true;
+}
+
+
+int
+StringRenderer::prepareLine(int offset,
+                            int lineWidth,
+                            FT_Vector& outActualLineWidth,
+                            int nonSpacingPlaceholder,
+                            bool handleMultiLine)
+{
+  int totalCount = 0;
+  outActualLineWidth = {0, 0};
+  if (!usingString_) // All glyphs
+  {
+    // The thing gets a little complicated when we're using "All Glyphs" mode
+    // The input sequence is actually infinite
+    // so we have to combine loading glyph into rendering, and can't preload
+    // all glyphs
+
+    // TODO: Low performance when the begin index is large.
+    // TODO: Optimize: use a sparse vector...!
+    // The problem is that when doing a `list::resize`, the ctor is called
+    // for unnecessarily many times.
+    tempGlyphContext_ = {};
+    for (unsigned n = offset; n < static_cast<unsigned>(limitIndex_);)
+    {
+      if (activeGlyphs_.capacity() <= n)
+        activeGlyphs_.reserve(static_cast<size_t>(n) * 2);
+      if (activeGlyphs_.size() <= n)
+        activeGlyphs_.resize(n + 1);
+
+      auto& ctx = activeGlyphs_[n];
+      ctx.charCode = static_cast<int>(n);
+      ctx.glyphIndex = static_cast<int>(
+          engine_->glyphIndexFromCharCode(static_cast<int>(n), charMapIndex_));
+
+      auto prev = n == 0 ? &tempGlyphContext_ : &activeGlyphs_[n - 1];
+      if (!ctx.glyph)
+        loadSingleContext(&ctx, prev);
+
+      // In All Glyphs mode, a red placeholder should be drawn for non-spacing
+      // glyphs (e.g. the stress mark)
+      auto actualAdvanceX = ctx.hadvance.x ? ctx.hadvance.x
+                                           : nonSpacingPlaceholder << 6;
+      if (outActualLineWidth.x + actualAdvanceX > lineWidth)
+        break;
+      outActualLineWidth.x += actualAdvanceX;
+      outActualLineWidth.y += ctx.hadvance.y;
+      ++n;
+      ++totalCount;
+    }
+  }
+  else // strings
+  {
+    if (!glyphCacheValid_)
+    {
+      clearActive(true);
+      loadStringGlyphs();
+    }
+
+    for (unsigned n = offset; n < activeGlyphs_.size();)
+    {
+      auto& ctx = activeGlyphs_[n];
+
+      if (handleMultiLine && ctx.charCode == '\n')
+      {
+        totalCount += 1; // Break here.
+        break;
+      }
+
+      if (repeated_) // if repeated, we must stop when we touch the end of line
+      {
+        if (outActualLineWidth.x + ctx.hadvance.x > lineWidth)
+          break;
+        outActualLineWidth.x += ctx.hadvance.x;
+        outActualLineWidth.y += ctx.hadvance.y;
+        ++n;
+        n %= static_cast<int>(activeGlyphs_.size()); // safe
+      }
+      else if (vertical_)
+      {
+        outActualLineWidth.x += ctx.vadvance.x;
+        outActualLineWidth.y += ctx.vadvance.y;
+        ++n;
+      }
+      else
+      {
+        outActualLineWidth.x += ctx.hadvance.x;
+        outActualLineWidth.y += ctx.hadvance.y;
+        ++n;
+      }
+      ++totalCount;
+    }
+  }
+  return totalCount;
+}
+
+
+int
+StringRenderer::render(int width,
+                       int height,
+                       int offset)
+{
+  engine_->reloadFont();
+
+  if (usingString_)
+    offset = 0;
+  if (!usingString_ && limitIndex_ <= 0)
+    return 0;
+  if (!engine_->fontValid())
+    return 0;
+
+  auto initialOffset = offset;
+
+  // Separated into 3 modes:
+  // Waterfall, fill the whole canvas and only single string.
+  if (waterfall_)
+  {
+    // Waterfall
+
+    vertical_ = false;
+    // They're only effective for non-bitmap-only (scalable) fonts!
+    auto originalSize = static_cast<int>(engine_->pointSize() * 64);
+    auto ptSize = originalSize;
+    auto ptHeight = 64 * 72 * height / engine_->dpi();
+    int step = 0;
+
+    auto bitmapOnly = engine_->currentFontBitmapOnly();
+    auto fixedSizes = engine_->currentFontFixedSizes();
+    std::sort(fixedSizes.begin(), fixedSizes.end());
+    auto fixedSizesIter = fixedSizes.begin();
+
+    if (waterfallStart_ <= 0)
+    {
+      // auto
+      step = (originalSize * originalSize / ptHeight + 64) & ~63;
+      ptSize = ptSize - step * (ptSize / step); // modulo
+      ptSize += step;
+    }
+    else if (!bitmapOnly)
+    {
+      ptSize = static_cast<int>(waterfallStart_ * 64.0) & ~31;
+      // we first get a ratio since height & ppem are near proportional...
+      // 64.0 is somewhat a magic reference number
+      engine_->setSizeByPoint(64.0);
+      engine_->reloadFont();
+      if (!engine_->renderReady())
+        return -1;
+      auto pixelActual = engine_->currentFontMetrics().height >> 6;
+
+      auto heightPt = height * 64.0 / pixelActual;
+
+      if (waterfallEnd_ < waterfallStart_)
+        waterfallEnd_ = waterfallStart_ + 1;
+
+      auto n = heightPt * 2 / (waterfallStart_ + waterfallEnd_);
+      auto stepTemp = (waterfallEnd_ - waterfallStart_) / (n + 1);
+      // rounding to 0.25
+      step = static_cast<int>(std::round(stepTemp * 4)) * 16 & ~15;
+      if (step == 0)
+        step = 16; // 0.25 pt
+    }
+
+    int y = 0;
+    // no position param in "All Glyphs" or repeated mode
+    int x = static_cast<int>((usingString_ && !repeated_) ? (width * position_)
+                                                          : 0);
+    int count = 0;
+
+    while (true)
+    {
+      if (!bitmapOnly)
+        engine_->setSizeByPoint(ptSize / 64.0);
+      else
+      {
+        if (fixedSizesIter == fixedSizes.end())
+          break;
+        engine_->setSizeByPixel(*fixedSizesIter);
+      }
+      clearActive(true);
+      prepareRendering(); // set size/face for engine, so metrics are valid
+      auto& metrics = engine_->currentFontMetrics();
+      
+      y += static_cast<int>(metrics.height >> 6) + 1;
+      if (y >= height && !bitmapOnly)
+        break;
+
+      loadStringGlyphs();
+      auto lcount = renderLine(x, y + static_cast<int>(metrics.descender >> 6),
+                               width, height,
+                               offset);
+      count = std::max(count, lcount);
+
+      if (!bitmapOnly)
+      {
+        if (step == 0)
+          break;
+        ptSize += step;
+      }
+      else
+        ++fixedSizesIter;
+    }
+    engine_->setSizeByPoint(originalSize / 64.0);
+
+    return count;
+  }
+  // end of waterfall
+
+  if (repeated_ || !usingString_)
+  {
+    // Fill the whole canvas (string repeated or all glyphs)
+
+    prepareRendering();
+    if (!engine_->renderReady())
+      return 0;
+    auto& metrics = engine_->currentFontMetrics();
+    auto y = 4 + static_cast<int>(metrics.ascender >> 6);
+    auto stepY = static_cast<int>(metrics.height >> 6) + 1;
+    auto limitY = height + static_cast<int>(metrics.descender >> 6);
+
+    // Only care about multiline when in string mode
+    for (; y < limitY; y += stepY)
+    {
+      offset = renderLine(0, y, width, height, offset, usingString_);
+      // For repeating
+      if (usingString_ && repeated_ && !activeGlyphs_.empty())
+        offset %= static_cast<int>(activeGlyphs_.size());
+    }
+    if (!usingString_) // only return count for All Glyphs mode.
+      return offset - initialOffset;
+    return 0;
+  }
+
+  // Single string
+  prepareRendering();
+  if (!engine_->renderReady())
+    return 0;
+
+  auto& metrics = engine_->currentFontMetrics();
+  auto x = static_cast<int>(width * position_);
+  // Anchor at top-left in vertical mode, at the center in horizontal mode
+  auto y = vertical_ ? 0 : (height / 2);
+  auto stepY = static_cast<int>(metrics.height >> 6) + 1;
+  y += 4 + static_cast<int>(metrics.ascender >> 6);
+
+  while (offset < static_cast<int>(activeGlyphs_.size()))
+  {
+    offset = renderLine(x, y, width, height, offset, true);
+    y += stepY;
+  }
+  return offset - initialOffset;
+}
+
+
+int
+StringRenderer::renderLine(int x,
+                           int y,
+                           int width,
+                           int height,
+                           int offset,
+                           bool handleMultiLine)
+{
+  y = height - y; // change to Cartesian coordinates
+
+  FT_Vector pen = { 0, 0 };
+  FT_Vector advance;
+  auto nonSpacingPlaceholder = engine_->currentFontMetrics().y_ppem / 2 + 2;
+
+  // When in "All Glyphs"  mode, no vertical support.
+  if (repeated_ || !usingString_)
+    vertical_ = false; // TODO: Support vertical + repeated
+
+  int lineLength = 64 * (vertical_ ? height : width);
+
+  // first prepare the line & determine the line length
+  int totalCount = prepareLine(offset, lineLength, pen, 
+                               nonSpacingPlaceholder, handleMultiLine);
+
+  // round to control initial pen position and preserve hinting...
+  // pen.x, y is the actual length now, and we multiple it by pos
+  auto centerFixed = static_cast<int>(0x10000 * position_);
+  if (!usingString_ || repeated_)
+    centerFixed = 0;
+  pen.x = FT_MulFix(pen.x, centerFixed) & ~63;
+  pen.y = FT_MulFix(pen.y, centerFixed) & ~63;
+
+  // ... unless rotating; XXX sbits
+  if (matrixEnabled_)
+    FT_Vector_Transform(&pen, &matrix_);
+
+  // get pen position: penPos = center - pos * width
+  pen.x = (x << 6) - pen.x;
+  pen.y = (y << 6) - pen.y;
+
+  // Need to transform the coord back to normal coord system
+  lineBeginCallback_({ (pen.x >> 6), 
+                       height - (pen.y >> 6) },
+                     engine_->pointSize());
+
+  for (int i = offset; i < totalCount + offset; i++)
+  {
+    auto& ctx = activeGlyphs_[i % activeGlyphs_.size()];
+    if (handleMultiLine && ctx.charCode == '\n')
+      continue; // skip \n
+    FT_Glyph image = NULL; // Remember to clean up
+    FT_BBox bbox;
+
+    if (!ctx.glyph)
+      continue;
+
+    advance = vertical_ ? ctx.vadvance : ctx.hadvance;
+
+    QRect rect;
+    QImage* colorLayerImage
+      = engine_->renderingEngine()->tryDirectRenderColorLayers(ctx.glyphIndex,
+                                                               &rect, true);
+
+    if (colorLayerImage)
+    {
+      FT_Vector penPos = { (pen.x >> 6), height - (pen.y >> 6) };
+      renderImageCallback_(colorLayerImage, rect, penPos, advance, ctx);
+    }
+    else
+    {
+      // copy the glyph because we're doing manipulation
+      auto error = FT_Glyph_Copy(ctx.glyph, &image);
+      if (error)
+        continue;
+
+      glyphPreprocessCallback_(&image);
+
+      if (image->format != FT_GLYPH_FORMAT_BITMAP)
+      {
+        if (vertical_)
+          error = FT_Glyph_Transform(image, NULL, &ctx.vvector);
+
+        if (!error)
+        {
+          if (matrixEnabled_)
+            error = FT_Glyph_Transform(image, &matrix_, NULL);
+        }
+
+        if (error)
+        {
+          FT_Done_Glyph(image);
+          continue;
+        }
+      }
+      else
+      {
+        auto bitmap = reinterpret_cast<FT_BitmapGlyph>(image);
+
+         if (vertical_)
+        {
+           bitmap->left += static_cast<int>(ctx.vvector.x) >> 6;
+           bitmap->top += static_cast<int>(ctx.vvector.y) >> 6;
+        }
+      }
+
+      if (matrixEnabled_)
+        FT_Vector_Transform(&advance, &matrix_);
+
+      FT_Glyph_Get_CBox(image, FT_GLYPH_BBOX_PIXELS, &bbox);
+
+      // Don't check for bounding box here.
+      FT_Vector penPos = { (pen.x >> 6), height - (pen.y >> 6) };
+      renderCallback_(image, penPos, ctx);
+
+      FT_Done_Glyph(image);
+    }
+    
+    pen.x += advance.x;
+    pen.y += advance.y;
+
+    if (!advance.x && !usingString_) // add placeholder
+      pen.x += nonSpacingPlaceholder << 6;
+  }
+
+  return offset + totalCount;
+}
+
+
+void
+StringRenderer::clearActive(bool glyphOnly)
+{
+  for (auto& ctx : activeGlyphs_)
+  {
+    if (ctx.cacheNode)
+      FTC_Node_Unref(ctx.cacheNode, engine_->cacheManager());
+    else if (ctx.glyph)
+      FT_Done_Glyph(ctx.glyph); // when caching isn't used
+    ctx.cacheNode = NULL;
+    ctx.glyph = NULL;
+  }
+  if (!glyphOnly)
+    activeGlyphs_.clear();
+
+  glyphCacheValid_ = false;
+}
+
+
+int
+StringRenderer::convertCharEncoding(int charUcs4, FT_Encoding encoding)
+{
+  switch (encoding)
+  {
+  case FT_ENCODING_MS_SYMBOL:
+  case FT_ENCODING_UNICODE:
+  case FT_ENCODING_ADOBE_STANDARD: // These may be problematic...
+  case FT_ENCODING_ADOBE_EXPERT:
+  case FT_ENCODING_ADOBE_CUSTOM:
+  case FT_ENCODING_ADOBE_LATIN_1:
+    return charUcs4;
+  default:; // proceed
+  }
+
+  auto mib = -1;
+  switch (encoding)
+  {
+  case FT_ENCODING_SJIS:
+    mib = 17; // Shift_JIS
+    break;
+  case FT_ENCODING_PRC:
+    mib = 114; // GB 18030
+    break;
+  case FT_ENCODING_BIG5:
+    mib = 2026; // Big5
+    break;
+  case FT_ENCODING_WANSUNG:
+    mib = -949; // KS C 5601:1987, this is a fake mib value
+    break;
+  case FT_ENCODING_JOHAB:
+    mib = 38; //  KS C 5601:1992 / EUC-KR
+    break;
+  case FT_ENCODING_APPLE_ROMAN:
+    mib = 2027;
+    break;
+  default:
+    return charUcs4; // Failed
+  }
+
+  if (mib == -1)
+    return charUcs4; // unsupported charmap
+  auto codec = QTextCodec::codecForMib(mib);
+  if (!codec)
+    return charUcs4; // unsupported
+
+  auto res = codec->fromUnicode(
+      QString::fromUcs4(reinterpret_cast<uint*>(&charUcs4), 1));
+  if (res.size() == 0)
+    return charUcs4;
+  if (res.size() == 1)
+    return res[0];
+  return ((static_cast<int>(res[0]) & 0xFF) << 8)
+         | (static_cast<int>(res[1]) & 0xFF);
+}
+
+
+// end of stringrenderer.cpp
diff --git a/src/ftinspect/engine/stringrenderer.hpp 
b/src/ftinspect/engine/stringrenderer.hpp
new file mode 100644
index 0000000..d8aa323
--- /dev/null
+++ b/src/ftinspect/engine/stringrenderer.hpp
@@ -0,0 +1,241 @@
+// stringrenderer.hpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#pragma once
+
+#include <vector>
+#include <functional>
+
+#include <QString>
+
+#include <ft2build.h>
+#include <qslider.h>
+#include <freetype/freetype.h>
+#include <freetype/ftcache.h>
+#include <freetype/ftglyph.h>
+
+// adopted from `ftcommon.h`
+
+class Engine;
+struct GlyphContext
+{
+  int charCode = 0;
+  int charCodeUcs4 = 0;
+  int glyphIndex = 0;
+  FT_Glyph glyph = NULL;
+  FTC_Node cacheNode = NULL;
+
+  FT_Pos lsbDelta = 0;    // delta caused by hinting
+  FT_Pos rsbDelta = 0;   // delta caused by hinting
+  FT_Vector hadvance = { 0, 0 }; // kerned horizontal advance
+
+  FT_Vector vvector = { 0, 0 };  // vert. origin => hori. origin
+  FT_Vector vadvance = { 0, 0 }; // vertical advance
+};
+
+// Class to populate chars to render, to load and properly position glyphs.
+// Use callbacks to receive characters and lines. You should save the result
+// from the callbacks to a cache.
+class StringRenderer
+{
+public:
+  StringRenderer(Engine* engine);
+  ~StringRenderer();
+
+  enum KerningDegree // XXX: Not honored actually
+  {
+    KD_None = 0,
+    KD_Light,
+    KD_Medium,
+    KD_Tight
+  };
+
+  enum KerningMode
+  {
+    KM_None = 0,
+    KM_Normal,
+    KM_Smart
+  };
+
+  /*
+   * Called when outputting a glyph. The receiver is reponsible for rendering
+   * the glyph to bitmap.
+   *
+   * Need to pass the pen position because sometimes the outline vector
+   * contains no points, and thus can't be translated to the desired pen
+   * position.
+   */
+  using RenderCallback = std::function<void(FT_Glyph,        // glyph
+                                            FT_Vector,       // penPos
+                                            GlyphContext&)>;
+  /*
+   * Called when outputtng a glyph with bitmap pre-rendered.
+   * The receiver can simply use the bitmap, mainly for color layered fonts.
+   * 
+   * TODO: Remove `RenderCallback` and do QImage creation in this class?
+   * The receiver is responsible for deleteing the `QImage`
+   * (ownership transfered).
+   */
+  using RenderImageCallback = std::function<void(QImage*,   // bitmap
+                                                 QRect,     // bbox
+                                                 FT_Vector, // penPos
+                                                 FT_Vector, // advance
+                                                 GlyphContext&)>;
+  /*
+   * Called right after the glyph is obtained from the font, before any other
+   * operation is done. The receiver can do pre-processing like slanting and
+   * emboldening in this function.
+   *
+   * The glyph pointer may be replaced. In that case, ownership is transfered
+   * to the renderer, and the new glyph will be eventually freed by
+   * the renderer. The callback is responsible to free the old glyph.
+   * This allows you to do the following:
+   * void callback(FT_Glyph* ptr) {
+   *     ....
+   *     auto oldPtr = *ptr;
+   *     *ptr = ....;
+   *     FT_Done_Glyph(olPtr);
+   * }
+   */
+  using PreprocessCallback = std::function<void(FT_Glyph*)>;
+  /*
+   * Called when a new line begins.
+   */
+  using LineBeginCallback = std::function<void(FT_Vector, // initial penPos
+                                               double)>;  // size (points)
+
+  //////// Getters
+  bool isWaterfall() { return waterfall_; }
+  double position(){ return position_; }
+  int charMapIndex() { return charMapIndex_; }
+
+  //////// Callbacks
+  void
+  setCallback(RenderCallback cb)
+  {
+    renderCallback_ = std::move(cb);
+  }
+  void
+  setImageCallback(RenderImageCallback cb)
+  {
+    renderImageCallback_ = std::move(cb);
+  }
+  void
+  setPreprocessCallback(PreprocessCallback cb)
+  {
+    glyphPreprocessCallback_ = std::move(cb);
+  }
+  void
+  setLineBeginCallback(LineBeginCallback cb)
+  {
+    lineBeginCallback_ = std::move(cb);
+  }
+
+  //////// Setters for options
+  void setCharMapIndex(int charMapIndex, int limitIndex);
+  void setRepeated(bool repeated) { repeated_ = repeated; }
+  void setVertical(bool vertical) { vertical_ = vertical; }
+  void setRotation(double rotation);
+  void setWaterfall(bool waterfall) { waterfall_ = waterfall; }
+  void setWaterfallParameters(double start, double end)
+  {
+    waterfallStart_ = start;
+    waterfallEnd_ = end;
+  }
+  void setPosition(double pos) { position_ = pos; }
+  void setLsbRsbDelta(bool enabled) { lsbRsbDeltaEnabled_ = enabled; }
+  void setKerning(bool kerning);
+
+  // Need to be called when font or charMap changes
+  void setUseString(QString const& string);
+  void setUseAllGlyphs();
+
+  //////// Actions
+  int render(int width,
+             int height,
+             int offset);
+  int renderLine(int x, int y,
+                 int width, int height,
+                 int offset,
+                 bool handleMultiLine = false);
+
+  void reloadAll();      // text/font/charmap changes, will call
+                         // `reloadGlyphs`
+  void reloadGlyphs();   // any other parameter changes
+
+private:
+  Engine* engine_;
+
+  // Generally, rendering has those steps:
+  // 1. If in string mode, the string is load into `activeGlyphs_`
+  //    (in `updateString`)
+  // 2. The char codes in contexts are converted to glyph indices
+  //    (in `reloadGlyphIndices`)
+  // 3. If in string mode, glyphs are loaded into contexts.
+  //    (in `loadStringGlyphs`)
+  // 4. In `render` function, according to mode, `renderLine` is called line
+  //    by line (as well as `prepareRendering`).
+  // 5. In `renderLine`, if in all glyphs mode, glyphs from the begin index
+  //    are loaded until the line is full (if the glyph already exists, it will
+  //    be reused). If in string mode, it will directly use the prepared 
glyphs.
+  //    Preprocessing is done within this step, such as emboldening or 
stroking.
+  //    Eventually the `FT_Glyph` pointer is passed to the callback.
+  
+  GlyphContext tempGlyphContext_;
+  // This vector stores all active glyphs for rendering. When rendering 
strings,
+  // this is the container for chars, so DO NOT directly clear it to flush
+  // cache, you should clean glyph objects only. However when rendering all
+  // glyphs, it's generally to directly wipe the vector because it's 
dynamically
+  // generated in `render` function (see above).
+  //
+  // Note: Because of kerning, this list must be ordered and allow duplicate
+  //       characters.
+  //
+  // Actually this means 3 parts of storage: string charcode, glyph indices and
+  // glyph (+ all related info). Different parameter changes will trigger
+  // different levels of flushing.
+  std::vector<GlyphContext> activeGlyphs_;
+  bool glyphCacheValid_ = false;
+
+  int charMapIndex_ = 0;
+  int limitIndex_ = 0;
+  bool usingString_ = false;
+  bool repeated_ = false;
+  bool vertical_ = false;
+  double position_ = 0.5;
+  double rotation_ = 0;
+  int kerningDegree_ = KD_None;
+  KerningMode kerningMode_ = KM_None;
+  FT_Pos trackingKerning_ = 0;
+  FT_Matrix matrix_ = {};
+  bool matrixEnabled_ = false;
+  bool lsbRsbDeltaEnabled_ = true;
+
+  bool waterfall_ = false;
+  double waterfallStart_ = -1;
+  double waterfallEnd_ = -1; // -1 = Auto
+
+  RenderCallback renderCallback_;
+  RenderImageCallback renderImageCallback_;
+  PreprocessCallback glyphPreprocessCallback_;
+  LineBeginCallback lineBeginCallback_;
+
+  void reloadGlyphIndices(); // for string rendering
+  void prepareRendering();
+  void loadSingleContext(GlyphContext* ctx, GlyphContext* prev);
+  // Need to be called when font, charMap or size changes;
+  void loadStringGlyphs();
+  // Returns total line count
+  int prepareLine(int offset, 
+                  int lineWidth,
+                  FT_Vector& outActualLineWidth,
+                  int nonSpacingPlaceholder,
+                  bool handleMultiLine = false);
+  void clearActive(bool glyphOnly = false);
+
+  int convertCharEncoding(int charUcs4, FT_Encoding encoding);
+};
+
+
+// end of stringrenderer.hpp
diff --git a/src/ftinspect/glyphcomponents/glyphcontinuous.cpp 
b/src/ftinspect/glyphcomponents/glyphcontinuous.cpp
new file mode 100644
index 0000000..64e46ce
--- /dev/null
+++ b/src/ftinspect/glyphcomponents/glyphcontinuous.cpp
@@ -0,0 +1,602 @@
+// glyphcontinuous.cpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#include "glyphcontinuous.hpp"
+
+#include "../engine/engine.hpp"
+
+#include <QPainter>
+#include <QWheelEvent>
+
+#include <freetype/ftbitmap.h>
+
+
+GlyphCacheEntry::~GlyphCacheEntry()
+{
+  delete image;
+}
+
+
+GlyphCacheEntry::GlyphCacheEntry(GlyphCacheEntry&& other) noexcept
+{
+  *this = std::move(other);
+}
+
+
+GlyphCacheEntry&
+GlyphCacheEntry::operator=(GlyphCacheEntry&& other) noexcept
+{
+  if (this == &other)
+    return *this;
+
+  auto oldImage = image;
+  image = other.image;
+  basePosition = other.basePosition;
+  penPos = other.penPos;
+  charCode = other.charCode;
+  glyphIndex = other.glyphIndex;
+  nonSpacingPlaceholder = other.nonSpacingPlaceholder;
+  advance = other.advance;
+  other.image = oldImage;
+  return *this;
+}
+
+
+GlyphContinuous::GlyphContinuous(QWidget* parent, Engine* engine)
+: QWidget(parent),
+  engine_(engine),
+  stringRenderer_(engine)
+{
+  setAcceptDrops(false);
+  setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+
+  flashTimer_ = new QTimer(this);
+  flashTimer_->setInterval(FlashIntervalMs);
+  connect(flashTimer_, &QTimer::timeout,
+          this, &GlyphContinuous::flashTimerFired);
+
+  FT_Stroker_New(engine_->ftLibrary(), &stroker_);
+}
+
+
+GlyphContinuous::~GlyphContinuous()
+{
+  FT_Stroker_Done(stroker_);
+}
+
+
+void
+GlyphContinuous::setSource(Source source)
+{
+  source_ = source;
+  switch (source)
+  {
+  case SRC_AllGlyphs:
+    stringRenderer_.setUseAllGlyphs();
+    positionDelta_ = {};
+    break;
+
+  case SRC_TextStringRepeated:
+    positionDelta_ = {};
+    /* fall through */
+  case SRC_TextString:
+    updateRendererText();
+    break;
+  }
+}
+
+
+void
+GlyphContinuous::setSourceText(QString text)
+{
+  text_ = std::move(text);
+  updateRendererText();
+}
+
+
+void
+GlyphContinuous::flashOnGlyph(int glyphIndex)
+{
+  flashTimer_->stop();
+
+  flashGlyphIndex_ = glyphIndex;
+  flashRemainingCount_ = FlashDurationMs / FlashIntervalMs;
+  flashTimer_->start();
+}
+
+
+void
+GlyphContinuous::stopFlashing()
+{
+  flashGlyphIndex_ = -1;
+  flashTimer_->stop();
+}
+
+
+void
+GlyphContinuous::purgeCache()
+{
+  glyphCache_.clear();
+  backgroundColorCache_ = engine_->renderingEngine()->background();
+  currentWritingLine_ = NULL;
+}
+
+
+void
+GlyphContinuous::resetPositionDelta()
+{
+  positionDelta_ = {};
+  repaint();
+}
+
+
+void
+GlyphContinuous::paintEvent(QPaintEvent* event)
+{
+  QPainter painter(this);
+  painter.fillRect(rect(), backgroundColorCache_);
+  painter.scale(scale_, scale_);
+
+  if (glyphCache_.empty())
+    fillCache();
+  paintCache(&painter);
+}
+
+
+void
+GlyphContinuous::wheelEvent(QWheelEvent* event)
+{
+  int numSteps = event->angleDelta().y() / 120;
+  if (event->modifiers() & Qt::ShiftModifier)
+    emit wheelResize(numSteps);
+  else if (event->modifiers() & Qt::ControlModifier)
+    emit wheelZoom(numSteps);
+  else if (event->modifiers() == 0)
+    emit wheelNavigate(-numSteps);
+}
+
+
+void
+GlyphContinuous::resizeEvent(QResizeEvent* event)
+{
+  purgeCache();
+  QWidget::resizeEvent(event);
+}
+
+
+void
+GlyphContinuous::mousePressEvent(QMouseEvent* event)
+{
+  if (!mouseOperationEnabled_)
+    return;
+  if (event->button() == Qt::LeftButton)
+  {
+    prevPositionDelta_ = positionDelta_;
+    mouseDownPostition_ = event->pos();
+    prevHoriPosition_ = stringRenderer_.position();
+    prevIndex_ = beginIndex_;
+    // We need to precalculate this value because after the first change of
+    // the begin index, the average line count would change. If we don't use 
the
+    // old value, then moving up/down for the same distance would not return
+    // to the original index which is confusing.
+    averageLineCount_ = calculateAverageLineCount();
+  }
+}
+
+
+void
+GlyphContinuous::mouseMoveEvent(QMouseEvent* event)
+{
+  if (!mouseOperationEnabled_)
+    return;
+  if (event->buttons() != Qt::LeftButton)
+    return;
+  auto delta = event->pos() - mouseDownPostition_;
+  delta /= scale_;
+  if (source_ == SRC_AllGlyphs)
+  {
+    auto deltaIndex = -delta.x() / HorizontalUnitLength
+                          - delta.y() / VerticalUnitLength * averageLineCount_;
+    if (prevIndex_ + deltaIndex != beginIndex_)
+      emit beginIndexChangeRequest(beginIndex_ + deltaIndex);
+  }
+  else if (source_ == SRC_TextString)
+  {
+    positionDelta_ = prevPositionDelta_ + delta;
+    positionDelta_.setX(0); // Don't move horizontally
+
+    // but use the renderer
+    // purpose for two scale_: one for undoing the `delta /= scale_`
+    // the other for effectively dividing width by the scale
+    auto horiPos = delta.x() * scale_ * scale_ / static_cast<double>(width());
+    horiPos += prevHoriPosition_;
+    horiPos = qBound(0.0, horiPos, 1.0);
+    stringRenderer_.setPosition(horiPos);
+
+    purgeCache();
+    repaint();
+  }
+}
+
+
+void
+GlyphContinuous::paintByRenderer()
+{
+  purgeCache();
+
+  stringRenderer_.setRepeated(source_ == SRC_TextStringRepeated);
+  stringRenderer_.setCallback(
+    [&](FT_Glyph glyph, FT_Vector penPos, GlyphContext& ctx)
+    {
+      saveSingleGlyph(glyph, penPos, ctx);
+    });
+  stringRenderer_.setImageCallback(
+    [&](QImage* image,
+        QRect pos, 
+        FT_Vector penPos, FT_Vector advance,
+        GlyphContext& ctx)
+    {
+      saveSingleGlyphImage(image, pos, penPos, advance, ctx);
+    });
+  stringRenderer_.setPreprocessCallback(
+    [&](FT_Glyph* ptr)
+    {
+      preprocessGlyph(ptr);
+    });
+  stringRenderer_.setLineBeginCallback(
+    [&](FT_Vector pos, double size)
+    {
+      beginSaveLine(pos, size);
+    });
+  auto count = stringRenderer_.render(static_cast<int>(width() / scale_), 
+                                      static_cast<int>(height() / scale_),
+                                      beginIndex_);
+  if (source_ == SRC_AllGlyphs)
+    displayingCount_ = count;
+  else
+    displayingCount_ = 0;
+}
+
+
+void
+GlyphContinuous::transformGlyphFancy(FT_Glyph glyph)
+{
+  auto& metrics = engine_->currentFontMetrics();
+  auto emboldeningX = (FT_Pos)(metrics.y_ppem * 64 * boldX_);
+  auto emboldeningY = (FT_Pos)(metrics.y_ppem * 64 * boldY_);
+  // adopted from ftview.c:289
+  if (glyph->format == FT_GLYPH_FORMAT_OUTLINE)
+  {
+    auto outline = reinterpret_cast<FT_OutlineGlyph>(glyph)->outline;
+    FT_Glyph_Transform(glyph, &shearMatrix_, NULL);
+    if (FT_Outline_EmboldenXY(&outline, emboldeningX, emboldeningY))
+    {
+      // XXX error handling?
+      return;
+    }
+
+    if (glyph->advance.x)
+      glyph->advance.x += emboldeningX;
+
+    if (glyph->advance.y)
+      glyph->advance.y += emboldeningY;
+  }
+  else if (glyph->format == FT_GLYPH_FORMAT_BITMAP)
+  {
+    auto xstr = emboldeningX & ~63;
+    auto ystr = emboldeningY & ~63;
+
+    auto bitmap = &reinterpret_cast<FT_BitmapGlyph>(glyph)->bitmap;
+    // No shearing support for bitmap
+    FT_Bitmap_Embolden(engine_->ftLibrary(), bitmap, 
+                       xstr, ystr);
+  }
+  else
+    return; // XXX no support for SVG
+}
+
+
+FT_Glyph
+GlyphContinuous::transformGlyphStroked(FT_Glyph glyph)
+{
+  // Well, here only outline glyph is supported.
+  if (glyph->format != FT_GLYPH_FORMAT_OUTLINE)
+    return NULL;
+  auto error = FT_Glyph_Stroke(&glyph, stroker_, 0);
+  if (error)
+    return NULL;
+  return glyph;
+}
+
+
+void
+GlyphContinuous::paintCache(QPainter* painter)
+{
+  bool flashFlipFlop = false;
+  if (flashRemainingCount_ >= 0)
+  {
+    if (flashGlyphIndex_ >= 0) // only flash when the glyph index valid
+      flashFlipFlop = flashRemainingCount_ % 2 == 1;
+    else
+    {
+      flashTimer_->stop();
+      flashRemainingCount_ = 0;
+    }
+    flashRemainingCount_--;
+  }
+  else if (flashGlyphIndex_ >= 0)
+  {
+    flashGlyphIndex_ = -1;
+    flashTimer_->stop();
+  }
+
+  if (stringRenderer_.isWaterfall())
+    positionDelta_.setY(0);
+  for (auto& line : glyphCache_)
+  {
+    beginDrawCacheLine(painter, line);
+    for (auto& glyph : line.entries)
+    {
+      if (glyph.glyphIndex == flashGlyphIndex_ && flashFlipFlop)
+        drawCacheGlyph(painter, glyph, true);
+      else
+        drawCacheGlyph(painter, glyph);
+    }
+  }
+}
+
+
+void
+GlyphContinuous::fillCache()
+{
+  prePaint();
+  paintByRenderer();
+  emit displayingCountUpdated(displayingCount_);
+}
+
+
+void
+GlyphContinuous::prePaint()
+{
+  displayingCount_ = 0;
+
+  // Used by fancy:
+  // adopted from ftview.c:289
+  /***************************************************************/
+  /*                                                             */
+  /*  2*2 affine transformation matrix, 16.16 fixed float format */
+  /*                                                             */
+  /*  Shear matrix:                                              */
+  /*                                                             */
+  /*         | x' |     | 1  k |   | x |          x' = x + ky    */
+  /*         |    |  =  |      | * |   |   <==>                  */
+  /*         | y' |     | 0  1 |   | y |          y' = y         */
+  /*                                                             */
+  /*        outline'     shear    outline                        */
+  /*                                                             */
+  /***************************************************************/
+  
+
+  shearMatrix_.xx = 1 << 16;
+  shearMatrix_.xy = static_cast<FT_Fixed>(slant_ * (1 << 16));
+  shearMatrix_.yx = 0;
+  shearMatrix_.yy = 1 << 16;
+}
+
+
+void
+GlyphContinuous::updateStroke()
+{
+  if (mode_ != M_Stroked || !engine_->renderReady())
+    return;
+
+  auto& metrics = engine_->currentFontMetrics();
+  auto radius = static_cast<FT_Fixed>(metrics.y_ppem * 64 * strokeRadius_);
+  strokeRadiusForSize_ = radius;
+  FT_Stroker_Set(stroker_, radius,
+                 FT_STROKER_LINECAP_ROUND,
+                 FT_STROKER_LINEJOIN_ROUND,
+                 0);
+}
+
+
+void
+GlyphContinuous::updateRendererText()
+{
+  stringRenderer_.setUseString(text_); // TODO this need to be called when 
font,
+                                       // size or charmap change
+}
+
+
+void
+GlyphContinuous::preprocessGlyph(FT_Glyph* glyphPtr)
+{
+  auto glyph = *glyphPtr;
+  switch (mode_)
+  {
+  case M_Fancy:
+    transformGlyphFancy(glyph);
+    break;
+  case M_Stroked:
+  {
+    auto stroked = transformGlyphStroked(glyph);
+    if (stroked)
+    {
+      FT_Done_Glyph(glyph);
+      *glyphPtr = stroked;
+    }
+  }
+  break;
+  default:; // Nothing for M_NORMAL.
+  }
+}
+
+
+void
+GlyphContinuous::beginSaveLine(FT_Vector pos,
+                               double sizePoint)
+{
+  glyphCache_.emplace_back();
+  currentWritingLine_ = &glyphCache_.back();
+  currentWritingLine_->nonSpacingPlaceholder
+    = engine_->currentFontMetrics().y_ppem / 2;
+  currentWritingLine_->sizePoint = sizePoint;
+  currentWritingLine_->basePosition = { static_cast<int>(pos.x),
+                                        static_cast<int>(pos.y) };
+}
+
+
+void
+GlyphContinuous::saveSingleGlyph(FT_Glyph glyph,
+                                 FT_Vector penPos,
+                                 GlyphContext gctx)
+{
+  if (!currentWritingLine_)
+    return;
+
+  QRect rect;
+  QImage* image = engine_->renderingEngine()->convertGlyphToQImage(glyph, 
+                                                                   &rect, 
+                                                                   true);
+  saveSingleGlyphImage(image, rect, penPos, glyph->advance, gctx);
+}
+
+
+void
+GlyphContinuous::saveSingleGlyphImage(QImage* image,
+                                      QRect rect,
+                                      FT_Vector penPos,
+                                      FT_Vector advance,
+                                      GlyphContext gctx)
+{
+  if (!currentWritingLine_)
+    return;
+
+  currentWritingLine_->entries.emplace_back();
+  auto& entry = currentWritingLine_->entries.back();
+
+  QPoint penPosPoint = { static_cast<int>(penPos.x),
+                         static_cast<int>(penPos.y) };
+
+  rect.translate(penPosPoint);
+
+  entry.image = image;
+  entry.basePosition = rect;
+  entry.charCode = gctx.charCode;
+  entry.glyphIndex = gctx.glyphIndex;
+  entry.advance = advance;
+  entry.penPos = penPosPoint;
+  entry.nonSpacingPlaceholder = currentWritingLine_->nonSpacingPlaceholder;
+}
+
+
+void
+GlyphContinuous::beginDrawCacheLine(QPainter* painter,
+                                    GlyphCacheLine& line)
+{
+  // Now only used by waterfall mode to draw a size indicator.
+  if (!stringRenderer_.isWaterfall())
+  {
+    sizeIndicatorOffset_ = 0;
+    return;
+  }
+
+  auto oldFont = painter->font();
+  oldFont.setPointSizeF(line.sizePoint);
+  painter->setFont(oldFont);
+  auto metrics = painter->fontMetrics();
+
+  auto printSize = line.sizePoint;
+  if (engine_->currentFontBitmapOnly())
+    printSize = printSize * engine_->dpi() / 72.0; // convert back
+  auto sizePrefix = QString("%1: ").arg(printSize);
+  painter->drawText(line.basePosition, sizePrefix);
+
+  sizeIndicatorOffset_ = metrics.horizontalAdvance(sizePrefix);
+  line.sizeIndicatorOffset = sizeIndicatorOffset_;
+}
+
+
+void
+GlyphContinuous::drawCacheGlyph(QPainter* painter,
+                                const GlyphCacheEntry& entry,
+                                bool colorInverted)
+{
+  // ftview.c:557
+  // Well, metrics is also part of the cache...
+  int width = entry.advance.x ? entry.advance.x >> 16
+                              : entry.nonSpacingPlaceholder;
+
+  if (entry.advance.x == 0 
+      && !stringRenderer_.isWaterfall()
+      && source_ == SRC_AllGlyphs)
+  {
+    // Draw a red square to indicate non-spacing glyphs
+    auto squarePoint = entry.penPos;
+    squarePoint.setY(squarePoint.y() - width);
+    auto rect = QRect(squarePoint, QSize(width, width));
+    painter->fillRect(rect, Qt::red);
+  }
+
+  QRect rect = entry.basePosition;
+  rect.moveLeft(rect.x() + sizeIndicatorOffset_);
+  rect.translate(positionDelta_);
+
+  if (colorInverted)
+  {
+    auto inverted = entry.image->copy();
+    inverted.invertPixels();
+    painter->drawImage(rect.topLeft(), inverted);
+  }
+  else
+    painter->drawImage(rect.topLeft(), *entry.image);
+}
+
+
+GlyphCacheEntry*
+GlyphContinuous::findGlyphByMouse(QPoint position,
+                                  double* outSizePoint)
+{
+  position -= positionDelta_;
+  position /= scale_;
+  for (auto& line : glyphCache_)
+    for (auto& entry : line.entries)
+    {
+      auto rect = entry.basePosition;
+      rect.moveLeft(rect.x() + line.sizeIndicatorOffset);
+      if (rect.contains(position))
+      {
+        if (outSizePoint)
+          *outSizePoint = line.sizePoint;
+        return &entry;
+      }
+    }
+  return NULL;
+}
+
+
+int
+GlyphContinuous::calculateAverageLineCount()
+{
+  int averageLineCount = 0;
+  for (auto& line : glyphCache_)
+  {
+    // line.entries.size must < INT_MAX because the total glyph count in
+    // the renderer is below that
+    averageLineCount += static_cast<int>(line.entries.size());
+  }
+  if (!glyphCache_.empty())
+    averageLineCount /= static_cast<int>(glyphCache_.size());
+  return averageLineCount;
+}
+
+
+void
+GlyphContinuous::flashTimerFired()
+{
+  repaint();
+}
+
+
+// end of glyphcontinuous.cpp
diff --git a/src/ftinspect/glyphcomponents/glyphcontinuous.hpp 
b/src/ftinspect/glyphcomponents/glyphcontinuous.hpp
new file mode 100644
index 0000000..7366de9
--- /dev/null
+++ b/src/ftinspect/glyphcomponents/glyphcontinuous.hpp
@@ -0,0 +1,204 @@
+// glyphcontinuous.hpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#pragma once
+
+#include "graphicsdefault.hpp"
+#include "../engine/stringrenderer.hpp"
+
+#include <utility>
+#include <vector>
+
+#include <QWidget>
+#include <QImage>
+#include <QTimer>
+
+#include <freetype/freetype.h>
+#include <freetype/ftglyph.h>
+#include <freetype/ftoutln.h>
+#include <freetype/ftstroke.h>
+
+
+// We store images in the cache so we don't need to render all glyphs every 
time
+// when repainting the widget.
+struct GlyphCacheEntry
+{
+  QImage* image = NULL;
+  QRect basePosition = {};
+  QPoint penPos = {};
+  int charCode = -1;
+  int glyphIndex = -1;
+  unsigned nonSpacingPlaceholder = 0;
+
+  FT_Vector advance = {};
+
+  GlyphCacheEntry() {}
+  ~GlyphCacheEntry();
+  GlyphCacheEntry(const GlyphCacheEntry& other) = delete;
+  GlyphCacheEntry& operator=(const GlyphCacheEntry& other) = delete;
+  GlyphCacheEntry(GlyphCacheEntry&& other) noexcept;
+  GlyphCacheEntry& operator=(GlyphCacheEntry&& other) noexcept;
+};
+
+
+struct GlyphCacheLine
+{
+  QPoint basePosition = {};
+  double sizePoint = 0.0;
+  int sizeIndicatorOffset;
+  unsigned short nonSpacingPlaceholder;
+  std::vector<GlyphCacheEntry> entries;
+};
+
+
+class Engine;
+class GlyphContinuous
+: public QWidget
+{
+  Q_OBJECT
+public:
+  GlyphContinuous(QWidget* parent, Engine* engine);
+  ~GlyphContinuous() override;
+
+  enum Source : int
+  {
+    SRC_AllGlyphs,
+    SRC_TextString,
+    SRC_TextStringRepeated
+  };
+
+  enum Mode : int
+  {
+    M_Normal,
+    M_Fancy,
+    M_Stroked
+  };
+
+  int displayingCount() { return displayingCount_; }
+  StringRenderer& stringRenderer() { return stringRenderer_; }
+
+  // all those setters don't trigger repaint.
+  void setBeginIndex(int index) { beginIndex_ = index; }
+  void setSource(Source source);
+  void setMode(Mode mode) { mode_ = mode; }
+  void setScale(double scale) { scale_ = scale; }
+  void setFancyParams(double boldX, double boldY, double slant)
+  {
+    boldX_ = boldX;
+    boldY_ = boldY;
+    slant_ = slant;
+  }
+  void setStrokeRadius(double radius) { strokeRadius_ = radius; }
+  void setSourceText(QString text);
+  void setMouseOperationEnabled(bool enabled)
+  {
+    mouseOperationEnabled_ = enabled;
+  }
+  
+  void flashOnGlyph(int glyphIndex);
+  void stopFlashing();
+  void purgeCache();
+  void resetPositionDelta();
+
+signals:
+  void wheelNavigate(int steps);
+  void wheelResize(int steps);
+  void wheelZoom(int steps);
+  void beginIndexChangeRequest(int newIndex);
+  void displayingCountUpdated(int newCount);
+
+protected:
+  void paintEvent(QPaintEvent* event) override;
+  void wheelEvent(QWheelEvent* event) override;
+  void resizeEvent(QResizeEvent* event) override;
+  void mousePressEvent(QMouseEvent* event) override;
+  void mouseMoveEvent(QMouseEvent* event) override;
+
+private:
+  Engine* engine_;
+  StringRenderer stringRenderer_;
+
+  QTimer* flashTimer_;
+  int flashRemainingCount_ = 0;
+  int flashGlyphIndex_ = -1;
+
+  Source source_ = SRC_AllGlyphs;
+  Mode mode_ = M_Normal;
+  int beginIndex_;
+  double boldX_, boldY_, slant_;
+  double strokeRadius_;
+  QString text_;
+  int sizeIndicatorOffset_ = 0; // For Waterfall Rendering...
+
+  bool mouseOperationEnabled_ = true;
+  int displayingCount_ = 0;
+  FT_Fixed strokeRadiusForSize_ = 0;
+  double scale_ = 1.0;
+  FT_Matrix shearMatrix_;
+
+  FT_Stroker stroker_;
+
+  std::vector<GlyphCacheLine> glyphCache_;
+  QColor backgroundColorCache_;
+  GlyphCacheLine* currentWritingLine_ = NULL;
+
+  // Mouse operation related fields
+  QPoint positionDelta_; // For dragging on the text to move
+  double prevHoriPosition_;
+  QPoint prevPositionDelta_ = { 0, 0 };
+  QPoint mouseDownPostition_ = { 0, 0 };
+  int prevIndex_ = -1;
+  int averageLineCount_ = 0;
+
+  void paintByRenderer();
+
+  // These two assume ownership of glyphs, but don't free them.
+  // However, remember to free the glyph returned from `transformGlyphStroked`
+  void transformGlyphFancy(FT_Glyph glyph);
+  FT_Glyph transformGlyphStroked(FT_Glyph glyph);
+
+  void paintCache(QPainter* painter);
+  void fillCache();
+  void prePaint();
+  void updateStroke();
+  void updateRendererText();
+  void preprocessGlyph(FT_Glyph* glyphPtr);
+  // Callbacks
+  void beginSaveLine(FT_Vector pos,
+                     double sizePoint);
+  void saveSingleGlyph(FT_Glyph glyph,
+                       FT_Vector penPos,
+                       GlyphContext gctx);
+  void saveSingleGlyphImage(QImage* image,
+                            QRect rect,
+                            FT_Vector penPos,
+                            FT_Vector advance,
+                            GlyphContext gctx);
+
+  // Funcs drawing from the cache
+  void beginDrawCacheLine(QPainter* painter,
+                          GlyphCacheLine& line);
+  void drawCacheGlyph(QPainter* painter,
+                      const GlyphCacheEntry& entry,
+                      bool colorInverted = false);
+
+  // Mouse operations
+  GlyphCacheEntry* findGlyphByMouse(QPoint position,
+                                    double* outSizePoint);
+  int calculateAverageLineCount();
+
+  void flashTimerFired();
+
+  // Mouse constants
+  constexpr static int ClickDragThreshold = 10;
+  constexpr static int HorizontalUnitLength = 100;
+  constexpr static int VerticalUnitLength = 150;
+
+  // Flash Timer constants
+  constexpr static int FlashIntervalMs = 250;
+  constexpr static int FlashDurationMs = 3000;
+};
+
+
+// end of glyphcontinuous.hpp
diff --git a/src/ftinspect/maingui.cpp b/src/ftinspect/maingui.cpp
index 9fc4c01..b4a38dc 100644
--- a/src/ftinspect/maingui.cpp
+++ b/src/ftinspect/maingui.cpp
@@ -164,8 +164,14 @@ MainGUI::onTripletChanged()
 void
 MainGUI::switchTab()
 {
+  auto current = tabWidget_->currentWidget();
   reloadCurrentTabFont();
-  lastTab_ = tabWidget_->currentWidget();
+
+  if (current == continuousTab_ && lastTab_ == singularTab_
+      && singularTab_->currentGlyph() >= 0)
+    continuousTab_->highlightGlyph(singularTab_->currentGlyph());
+
+  lastTab_ = current;
 }
 
 
@@ -215,6 +221,7 @@ MainGUI::createLayout()
 
   // right side
   singularTab_ = new SingularTab(this, engine_);
+  continuousTab_ = new ContinuousTab(this, engine_);
 
   tabWidget_ = new QTabWidget(this);
   tabWidget_->setObjectName("mainTab"); // for stylesheet
@@ -222,10 +229,15 @@ MainGUI::createLayout()
   // Note those two list must be in sync
   tabs_.push_back(singularTab_);
   tabWidget_->addTab(singularTab_, tr("Singular Grid View"));
+  tabs_.push_back(continuousTab_);
+  tabWidget_->addTab(continuousTab_, tr("Continuous View"));
   lastTab_ = singularTab_;
   
   tabWidget_->setTabToolTip(0, tr("View single glyph in grid view.\n"
                                   "For pixelwise inspection of the glyphs."));
+  tabWidget_->setTabToolTip(1, tr("View a string of glyphs continuously.\n"
+                                  "Show all glyphs in the font or render "
+                                  "strings."));
   tripletSelector_ = new TripletSelector(this, engine_);
 
   rightLayout_ = new QVBoxLayout;
diff --git a/src/ftinspect/maingui.hpp b/src/ftinspect/maingui.hpp
index 18e3c98..44c6014 100644
--- a/src/ftinspect/maingui.hpp
+++ b/src/ftinspect/maingui.hpp
@@ -10,6 +10,7 @@
 #include "panels/settingpanel.hpp"
 #include "panels/abstracttab.hpp"
 #include "panels/singular.hpp"
+#include "panels/continuous.hpp"
 
 #include <vector>
 #include <QAction>
@@ -88,6 +89,7 @@ private:
   QTabWidget* tabWidget_;
   std::vector<AbstractTab*> tabs_;
   SingularTab* singularTab_;
+  ContinuousTab* continuousTab_;
   QWidget* lastTab_ = NULL;
 
   void openFonts(QStringList const& fileNames);
diff --git a/src/ftinspect/meson.build b/src/ftinspect/meson.build
index 405cff8..392ec8a 100644
--- a/src/ftinspect/meson.build
+++ b/src/ftinspect/meson.build
@@ -27,8 +27,11 @@ if qt5_dep.found()
     'engine/paletteinfo.cpp',
     'engine/mmgx.cpp',
     'engine/fontinfo.cpp',
+    'engine/stringrenderer.cpp',
+    'engine/charmap.cpp',
 
     'glyphcomponents/glyphbitmap.cpp',
+    'glyphcomponents/glyphcontinuous.cpp',
     'glyphcomponents/glyphoutline.cpp',
     'glyphcomponents/glyphpointnumbers.cpp',
     'glyphcomponents/glyphpoints.cpp',
@@ -39,12 +42,14 @@ if qt5_dep.found()
     'widgets/tripletselector.cpp',
     'widgets/glyphindexselector.cpp',
     'widgets/fontsizeselector.cpp',
+    'widgets/charmapcombobox.cpp',
 
     'models/customcomboboxmodels.cpp',
 
     'panels/settingpanel.cpp',
     'panels/settingpanelmmgx.cpp',
     'panels/singular.cpp',
+    'panels/continuous.cpp',
 
     'ftinspect.cpp',
     'maingui.cpp',
@@ -54,15 +59,18 @@ if qt5_dep.found()
   moc_files = qt5.preprocess(
     moc_headers: [
       'engine/fontfilemanager.hpp',
+      'glyphcomponents/glyphcontinuous.hpp',
       'widgets/customwidgets.hpp',
       'widgets/tripletselector.hpp',
       'widgets/glyphindexselector.hpp',
       'widgets/fontsizeselector.hpp',
+      'widgets/charmapcombobox.hpp',
       'maingui.hpp',
       'models/customcomboboxmodels.hpp',
       'panels/settingpanel.hpp',
       'panels/settingpanelmmgx.hpp',
       'panels/singular.hpp',
+      'panels/continuous.hpp',
     ],
     dependencies: qt5_dep)
 
diff --git a/src/ftinspect/panels/continuous.cpp 
b/src/ftinspect/panels/continuous.cpp
new file mode 100644
index 0000000..c10a7ef
--- /dev/null
+++ b/src/ftinspect/panels/continuous.cpp
@@ -0,0 +1,699 @@
+// continuous.cpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#include "continuous.hpp"
+
+#include "../uihelper.hpp"
+
+#include <climits>
+#include <QToolTip>
+#include <QVariant>
+
+
+ContinuousTab::ContinuousTab(QWidget* parent,
+                             Engine* engine)
+: QWidget(parent),
+  engine_(engine)
+{
+  createLayout();
+
+  std::vector<CharMapInfo> tempCharMaps;
+  charMapSelector_->repopulate(tempCharMaps); // pass in an empty one
+
+  checkModeSource();
+  setDefaults();
+
+  createConnections();
+}
+
+
+void
+ContinuousTab::repaintGlyph()
+{
+  sizeSelector_->applyToEngine(engine_);
+  
+  applySettings();
+  canvas_->stopFlashing();
+  canvas_->purgeCache();
+  canvas_->repaint();
+}
+
+
+void
+ContinuousTab::reloadFont()
+{
+  currentGlyphCount_ = engine_->currentFontNumberOfGlyphs();
+  {
+    QSignalBlocker blocker(sizeSelector_);
+    sizeSelector_->reloadFromFont(engine_);
+  }
+  setGlyphCount(qBound(0, currentGlyphCount_, INT_MAX));
+  checkModeSource();
+
+  charMapSelector_->repopulate();
+  canvas_->stopFlashing();
+  canvas_->stringRenderer().reloadAll();
+  canvas_->purgeCache();
+  repaintGlyph();
+}
+
+
+void
+ContinuousTab::applySettings()
+{
+  auto mode = 
static_cast<GlyphContinuous::Mode>(modeSelector_->currentIndex());
+  auto src
+    = static_cast<GlyphContinuous::Source>(sourceSelector_->currentIndex());
+  canvas_->setMode(mode);
+  canvas_->setSource(src);
+  canvas_->setBeginIndex(indexSelector_->currentIndex());
+  canvas_->setScale(sizeSelector_->zoomFactor());
+  auto& sr = canvas_->stringRenderer();
+  sr.setWaterfall(waterfallCheckBox_->isChecked());
+  sr.setVertical(verticalCheckBox_->isChecked());
+  sr.setKerning(kerningCheckBox_->isChecked());
+  sr.setRotation(rotationSpinBox_->value());
+
+  // -1: Glyph order, otherwise the char map index in the original list
+  sr.setCharMapIndex(charMapSelector_->currentCharMapIndex(), 
glyphLimitIndex_);
+
+  if (sr.isWaterfall())
+    sr.setWaterfallParameters(wfConfigDialog_->startSize(),
+                              wfConfigDialog_->endSize());
+
+  canvas_->setFancyParams(xEmboldeningSpinBox_->value(),
+                          yEmboldeningSpinBox_->value(),
+                          slantSpinBox_->value());
+  canvas_->setStrokeRadius(strokeRadiusSpinBox_->value());
+}
+
+
+void
+ContinuousTab::highlightGlyph(int index)
+{
+  canvas_->flashOnGlyph(index);
+}
+
+
+void
+ContinuousTab::setGlyphCount(int count)
+{
+  currentGlyphCount_ = count;
+  updateLimitIndex();
+}
+
+
+void
+ContinuousTab::setGlyphBeginindex(int index)
+{
+  indexSelector_->setCurrentIndex(index);
+}
+
+
+void
+ContinuousTab::updateLimitIndex()
+{
+  auto cMap = charMapSelector_->currentCharMapIndex();
+  if (cMap < 0)
+    glyphLimitIndex_ = currentGlyphCount_;
+  else
+    glyphLimitIndex_ = charMapSelector_->charMaps()[cMap].maxIndex + 1;
+  indexSelector_->setMinMax(0, glyphLimitIndex_ - 1);
+}
+
+
+void
+ContinuousTab::checkModeSource()
+{
+  auto isFancy = modeSelector_->currentIndex() == GlyphContinuous::M_Fancy;
+  auto isStroked = modeSelector_->currentIndex() == GlyphContinuous::M_Stroked;
+  xEmboldeningSpinBox_->setEnabled(isFancy);
+  yEmboldeningSpinBox_->setEnabled(isFancy);
+  slantSpinBox_->setEnabled(isFancy);
+  strokeRadiusSpinBox_->setEnabled(isStroked);
+
+  auto src
+      = static_cast<GlyphContinuous::Source>(sourceSelector_->currentIndex());
+  auto isTextStrict = src == GlyphContinuous::SRC_TextString;
+  auto isText = src == GlyphContinuous::SRC_TextString
+                || src == GlyphContinuous::SRC_TextStringRepeated;
+  indexSelector_->setEnabled(src == GlyphContinuous::SRC_AllGlyphs);
+  sourceTextEdit_->setEnabled(isText);
+  sampleStringSelector_->setEnabled(isText);
+
+  {
+    QSignalBlocker blocker(kerningCheckBox_);
+    kerningCheckBox_->setEnabled(isText);
+    if (!isText)
+      kerningCheckBox_->setChecked(false);
+  }
+  
+  canvas_->setSource(src);
+
+  {
+    auto wf = waterfallCheckBox_->isChecked();
+    QSignalBlocker blocker(verticalCheckBox_);
+    if (wf || !isTextStrict)
+      verticalCheckBox_->setChecked(false);
+    verticalCheckBox_->setEnabled(!wf && isTextStrict);
+  }
+
+  {
+    auto vert = verticalCheckBox_->isChecked();
+    QSignalBlocker blocker(waterfallCheckBox_);
+    if (vert)
+      waterfallCheckBox_->setChecked(false);
+    waterfallCheckBox_->setEnabled(!vert);
+  }
+
+  waterfallConfigButton_->setEnabled(waterfallCheckBox_->isChecked()
+                                     && !engine_->currentFontBitmapOnly());
+}
+
+
+void
+ContinuousTab::checkModeSourceAndRepaint()
+{
+  checkModeSource();
+  repaintGlyph();
+}
+
+
+void
+ContinuousTab::charMapChanged()
+{
+  int newIndex = charMapSelector_->currentCharMapIndex();
+  if (newIndex != lastCharMapIndex_)
+    setGlyphBeginindex(charMapSelector_->defaultFirstGlyphIndex());
+  updateLimitIndex();
+
+  applySettings();
+  canvas_->stringRenderer().reloadAll();
+  repaintGlyph();
+  lastCharMapIndex_ = newIndex;
+}
+
+
+void
+ContinuousTab::sourceTextChanged()
+{
+  canvas_->setSourceText(sourceTextEdit_->toPlainText());
+  repaintGlyph();
+}
+
+
+void
+ContinuousTab::presetStringSelected()
+{
+  auto index = sampleStringSelector_->currentIndex();
+  if (index < 0)
+    return;
+
+  auto var = sampleStringSelector_->currentData();
+  if (var.isValid() && var.canConvert<QString>())
+  {
+    auto str = var.toString();
+    if (!str.isEmpty())
+      sourceTextEdit_->setPlainText(str);
+  }
+}
+
+
+void
+ContinuousTab::reloadGlyphsAndRepaint()
+{
+  canvas_->stringRenderer().reloadGlyphs();
+  repaintGlyph();
+}
+
+
+void
+ContinuousTab::openWaterfallConfig()
+{
+  wfConfigDialog_->setVisible(true); // no `exec`: modalless
+}
+
+
+void
+ContinuousTab::showToolTip()
+{
+  QToolTip::showText(mapToGlobal(helpButton_->pos()),
+                     tr(
+R"(Shift + Scroll: Adjust Font Size
+Ctrl + Scroll: Adjust Zoom Factor
+Shift + Plus/Minus: Adjust Font Size
+Shift + 0: Reset Font Size to Default
+Left Click: Show Glyph Details Info
+Right Click: Inspect Glyph in Singular Grid View
+
+<All Glyphs Source>
+  Drag: Adjust Begin Index
+<Text String Source>
+  Drag: Move String Position)"),
+                     helpButton_);
+}
+
+
+bool
+ContinuousTab::eventFilter(QObject* watched,
+                           QEvent* event)
+{
+  if (event->type() == QEvent::KeyPress)
+  {
+    auto keyEvent = dynamic_cast<QKeyEvent*>(event);
+    if (sizeSelector_->handleKeyEvent(keyEvent))
+      return true;
+  }
+  return false;
+}
+
+
+void
+ContinuousTab::wheelNavigate(int steps)
+{
+  if (sourceSelector_->currentIndex() == GlyphContinuous::SRC_AllGlyphs)
+    setGlyphBeginindex(indexSelector_->currentIndex() + steps);
+}
+
+
+void
+ContinuousTab::wheelZoom(int steps)
+{
+  sizeSelector_->handleWheelZoomBySteps(steps);
+}
+
+
+void
+ContinuousTab::wheelResize(int steps)
+{
+  sizeSelector_->handleWheelResizeBySteps(steps);
+}
+
+
+void
+ContinuousTab::createLayout()
+{
+  canvasFrame_ = new QFrame(this);
+  canvasFrame_->setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
+
+  canvas_ = new GlyphContinuous(canvasFrame_, engine_);
+  sizeSelector_ = new FontSizeSelector(this, false, true);
+
+  indexSelector_ = new GlyphIndexSelector(this);
+  indexSelector_->setSingleMode(false);
+  indexSelector_->setNumberRenderer([this](int index)
+                                    { return formatIndex(index); });
+  sourceTextEdit_ = new QPlainTextEdit(
+      tr("The quick brown fox jumps over the lazy dog."), this);
+
+  modeSelector_ = new QComboBox(this);
+  charMapSelector_ = new CharMapComboBox(this, engine_);
+  sourceSelector_ = new QComboBox(this);
+  sampleStringSelector_ = new QComboBox(this);
+
+  charMapSelector_->setSizeAdjustPolicy(QComboBox::AdjustToContents);
+
+  // Note: in sync with the enum!!
+  modeSelector_->insertItem(GlyphContinuous::M_Normal, tr("Normal"));
+  modeSelector_->insertItem(GlyphContinuous::M_Fancy, tr("Fancy"));
+  modeSelector_->insertItem(GlyphContinuous::M_Stroked, tr("Stroked"));
+  modeSelector_->setCurrentIndex(GlyphContinuous::M_Normal);
+
+  // Note: in sync with the enum!!
+  sourceSelector_->insertItem(GlyphContinuous::SRC_AllGlyphs,
+                              tr("All Glyphs"));
+  sourceSelector_->insertItem(GlyphContinuous::SRC_TextString, 
+                              tr("Text String"));
+  sourceSelector_->insertItem(GlyphContinuous::SRC_TextStringRepeated,
+                              tr("Text String (Repeated)"));
+
+  verticalCheckBox_ = new QCheckBox(tr("Vertical"), this);
+  waterfallCheckBox_ = new QCheckBox(tr("Waterfall"), this);
+  kerningCheckBox_ = new QCheckBox(tr("Kerning"), this);
+
+  modeLabel_ = new QLabel(tr("Mode:"), this);
+  sourceLabel_ = new QLabel(tr("Text Source:"), this);
+  charMapLabel_ = new QLabel(tr("Char Map:"), this);
+  xEmboldeningLabel_ = new QLabel(tr("Horz. Emb.:"), this);
+  yEmboldeningLabel_ = new QLabel(tr("Vert. Emb.:"), this);
+  slantLabel_ = new QLabel(tr("Slanting:"), this);
+  strokeRadiusLabel_ = new QLabel(tr("Stroke Radius:"), this);
+  rotationLabel_ = new QLabel(tr("Rotation:"), this);
+
+  resetPositionButton_ = new QPushButton(tr("Reset Pos"), this);
+  waterfallConfigButton_ = new QPushButton(tr("WF Config"), this);
+  helpButton_ = new QPushButton(this);
+  helpButton_->setText(tr("?"));
+
+  xEmboldeningSpinBox_ = new QDoubleSpinBox(this);
+  yEmboldeningSpinBox_ = new QDoubleSpinBox(this);
+  slantSpinBox_ = new QDoubleSpinBox(this);
+  strokeRadiusSpinBox_ = new QDoubleSpinBox(this);
+  rotationSpinBox_ = new QDoubleSpinBox(this);
+
+  xEmboldeningSpinBox_->setSingleStep(0.005);
+  xEmboldeningSpinBox_->setMinimum(-0.1);
+  xEmboldeningSpinBox_->setMaximum(0.1);
+  yEmboldeningSpinBox_->setSingleStep(0.005);
+  yEmboldeningSpinBox_->setMinimum(-0.1);
+  yEmboldeningSpinBox_->setMaximum(0.1);
+  slantSpinBox_->setSingleStep(0.02);
+  slantSpinBox_->setMinimum(-1);
+  slantSpinBox_->setMaximum(1);
+  strokeRadiusSpinBox_->setSingleStep(0.005);
+  strokeRadiusSpinBox_->setMinimum(0);
+  strokeRadiusSpinBox_->setMaximum(0.05);
+  rotationSpinBox_->setSingleStep(5);
+  rotationSpinBox_->setMinimum(-180);
+  rotationSpinBox_->setMaximum(180);
+
+  wfConfigDialog_ = new WaterfallConfigDialog(this);
+
+  // Tooltips
+  sourceSelector_->setToolTip(tr("Choose what to display as the text 
source."));
+  modeSelector_->setToolTip(
+    tr("Choose the special effect in which the text is displayed."));
+  strokeRadiusSpinBox_->setToolTip(
+    tr("Stroke corner radius (only available when mode set to Stroked)"));
+  rotationSpinBox_->setToolTip(tr("Rotation, in degrees"));
+  xEmboldeningSpinBox_->setToolTip(
+    tr("Horizontal Emboldening (only available when mode set to Fancy)"));
+  yEmboldeningSpinBox_->setToolTip(
+    tr("Vertical Emboldening (only available when mode set to Fancy)"));
+  slantSpinBox_->setToolTip(
+    tr("Slanting (only available when mode set to Fancy)"));
+  sourceTextEdit_->setToolTip(
+    tr("Source string (only available when source set to Text String)"));
+  waterfallConfigButton_->setToolTip(tr(
+    "Set waterfall start and end size. Not available when the font is not\n"
+    "scalable because in such case all available sizes would be displayed."));
+  sampleStringSelector_->setToolTip(
+    tr("Select preset sample strings (only available when source set to\nText "
+       "String)"));
+  resetPositionButton_->setToolTip(tr("Reset the position to the center (only "
+                                      "available when source set to\nText "
+                                      "String)"));
+  waterfallCheckBox_->setToolTip(tr(
+    "Enable waterfall mode: show the font output in different sizes.\nWill "
+    "show all available sizes when the font is not scalable."));
+  verticalCheckBox_->setToolTip(tr("Enable vertical rendering (only 
available\n"
+                                "when source set to Text String)"));
+  kerningCheckBox_->setToolTip(tr("Enable kerning (GPOS table unsupported)"));
+  helpButton_->setToolTip(tr("Get mouse helps"));
+
+  // Layouting
+  canvasFrameLayout_ = new QHBoxLayout;
+  canvasFrameLayout_->addWidget(canvas_);
+  canvasFrame_->setLayout(canvasFrameLayout_);
+  canvasFrameLayout_->setContentsMargins(2, 2, 2, 2);
+  canvasFrame_->setContentsMargins(2, 2, 2, 2);
+
+  sizeHelpLayout_ = new QHBoxLayout;
+  sizeHelpLayout_->addWidget(sizeSelector_, 1, Qt::AlignVCenter);
+  sizeHelpLayout_->addWidget(helpButton_, 0);
+
+  bottomLayout_ = new QGridLayout;
+  bottomLayout_->addWidget(sourceLabel_, 0, 0);
+  bottomLayout_->addWidget(modeLabel_, 1, 0);
+  bottomLayout_->addWidget(charMapLabel_, 2, 0);
+  bottomLayout_->addWidget(sourceSelector_, 0, 1);
+  bottomLayout_->addWidget(modeSelector_, 1, 1);
+  bottomLayout_->addWidget(charMapSelector_, 2, 1);
+
+  bottomLayout_->addWidget(xEmboldeningLabel_, 1, 2);
+  bottomLayout_->addWidget(yEmboldeningLabel_, 2, 2);
+  bottomLayout_->addWidget(slantLabel_, 3, 2);
+  bottomLayout_->addWidget(strokeRadiusLabel_, 3, 0);
+  bottomLayout_->addWidget(rotationLabel_, 0, 2);
+
+  bottomLayout_->addWidget(xEmboldeningSpinBox_, 1, 3);
+  bottomLayout_->addWidget(yEmboldeningSpinBox_, 2, 3);
+  bottomLayout_->addWidget(slantSpinBox_, 3, 3);
+  bottomLayout_->addWidget(strokeRadiusSpinBox_, 3, 1);
+  bottomLayout_->addWidget(rotationSpinBox_, 0, 3);
+
+  bottomLayout_->addWidget(indexSelector_, 0, 4, 1, 2);
+  bottomLayout_->addWidget(sourceTextEdit_, 1, 4, 3, 1);
+  bottomLayout_->addWidget(resetPositionButton_, 0, 6);
+  bottomLayout_->addWidget(waterfallCheckBox_, 1, 6);
+  bottomLayout_->addWidget(verticalCheckBox_, 2, 6);
+  bottomLayout_->addWidget(kerningCheckBox_, 3, 6);
+  bottomLayout_->addWidget(waterfallConfigButton_, 1, 5);
+  bottomLayout_->addWidget(sampleStringSelector_, 2, 5);
+
+  bottomLayout_->setColumnStretch(4, 1);
+
+  mainLayout_ = new QVBoxLayout;
+  mainLayout_->addWidget(canvasFrame_);
+  mainLayout_->addLayout(sizeHelpLayout_);
+  mainLayout_->addLayout(bottomLayout_);
+
+  setLayout(mainLayout_);
+}
+
+
+void
+ContinuousTab::createConnections()
+{
+  connect(sizeSelector_, &FontSizeSelector::valueChanged,
+          this, &ContinuousTab::reloadGlyphsAndRepaint);
+
+  connect(canvas_, &GlyphContinuous::wheelResize, 
+          this, &ContinuousTab::wheelResize);
+  connect(canvas_, &GlyphContinuous::wheelNavigate, 
+          this, &ContinuousTab::wheelNavigate);
+  connect(canvas_, &GlyphContinuous::wheelZoom, 
+          this, &ContinuousTab::wheelZoom);
+  connect(canvas_, &GlyphContinuous::displayingCountUpdated, 
+          indexSelector_, &GlyphIndexSelector::setShowingCount);
+  connect(canvas_, &GlyphContinuous::beginIndexChangeRequest, 
+          this, &ContinuousTab::setGlyphBeginindex);
+
+  connect(indexSelector_, &GlyphIndexSelector::currentIndexChanged,
+          this, &ContinuousTab::repaintGlyph);
+  connect(modeSelector_, QOverload<int>::of(&QComboBox::currentIndexChanged),
+          this, &ContinuousTab::checkModeSourceAndRepaint);
+  connect(charMapSelector_,
+          QOverload<int>::of(&CharMapComboBox::currentIndexChanged),
+          this, &ContinuousTab::charMapChanged);
+  connect(charMapSelector_, &CharMapComboBox::forceUpdateLimitIndex,
+          this, &ContinuousTab::updateLimitIndex);
+  connect(sourceSelector_, QOverload<int>::of(&QComboBox::currentIndexChanged),
+          this, &ContinuousTab::checkModeSourceAndRepaint);
+
+  connect(resetPositionButton_, &QPushButton::clicked,
+          canvas_, &GlyphContinuous::resetPositionDelta);
+  connect(waterfallConfigButton_, &QPushButton::clicked,
+          this, &ContinuousTab::openWaterfallConfig);
+  connect(helpButton_, &QPushButton::clicked,
+          this, &ContinuousTab::showToolTip);
+  connect(wfConfigDialog_, &WaterfallConfigDialog::sizeUpdated,
+          this, &ContinuousTab::repaintGlyph);
+
+  connect(xEmboldeningSpinBox_, 
+          QOverload<double>::of(&QDoubleSpinBox::valueChanged),
+          this, &ContinuousTab::repaintGlyph);
+  connect(yEmboldeningSpinBox_, 
+          QOverload<double>::of(&QDoubleSpinBox::valueChanged),
+          this, &ContinuousTab::repaintGlyph);
+  connect(slantSpinBox_, 
+          QOverload<double>::of(&QDoubleSpinBox::valueChanged),
+          this, &ContinuousTab::repaintGlyph);
+  connect(strokeRadiusSpinBox_, 
+          QOverload<double>::of(&QDoubleSpinBox::valueChanged),
+          this, &ContinuousTab::repaintGlyph);
+  connect(rotationSpinBox_, 
+          QOverload<double>::of(&QDoubleSpinBox::valueChanged),
+          this, &ContinuousTab::repaintGlyph);
+
+  connect(waterfallCheckBox_, &QCheckBox::clicked,
+          this, &ContinuousTab::checkModeSourceAndRepaint);
+  connect(verticalCheckBox_, &QCheckBox::clicked,
+          this, &ContinuousTab::checkModeSourceAndRepaint);
+  connect(kerningCheckBox_, &QCheckBox::clicked,
+          this, &ContinuousTab::reloadGlyphsAndRepaint);
+  connect(sourceTextEdit_, &QPlainTextEdit::textChanged,
+          this, &ContinuousTab::sourceTextChanged);
+  connect(sampleStringSelector_, 
+          QOverload<int>::of(&QComboBox::currentIndexChanged),
+          this, &ContinuousTab::presetStringSelected);
+
+  sizeSelector_->installEventFilterForWidget(canvas_);
+  sizeSelector_->installEventFilterForWidget(this);
+}
+
+
+extern const char* StringSamples[];
+
+void
+ContinuousTab::setDefaults()
+{
+  xEmboldeningSpinBox_->setValue(0.04);
+  yEmboldeningSpinBox_->setValue(0.04);
+  slantSpinBox_->setValue(0.22);
+  strokeRadiusSpinBox_->setValue(0.02);
+  rotationSpinBox_->setValue(0);
+
+  canvas_->setSourceText(sourceTextEdit_->toPlainText());
+  canvas_->setSource(GlyphContinuous::SRC_AllGlyphs);
+
+  sampleStringSelector_->addItem(tr("<Sample>"));
+  sampleStringSelector_->addItem(tr("English"),  QString(StringSamples[0]));
+  sampleStringSelector_->addItem(tr("Latin"),    QString(StringSamples[1]));
+  sampleStringSelector_->addItem(tr("Greek"),    QString(StringSamples[2]));
+  sampleStringSelector_->addItem(tr("Cyrillic"), QString(StringSamples[3]));
+  sampleStringSelector_->addItem(tr("Chinese"),  QString(StringSamples[4]));
+  sampleStringSelector_->addItem(tr("Japanese"), QString(StringSamples[5]));
+  sampleStringSelector_->addItem(tr("Korean"),   QString(StringSamples[6]));
+}
+
+
+QString
+ContinuousTab::formatIndex(int index)
+{
+  auto idx = charMapSelector_->currentCharMapIndex();
+  if (idx < 0) // glyph order
+    return QString::number(index);
+  return charMapSelector_->charMaps()[idx].stringifyIndexShort(index);
+}
+
+
+WaterfallConfigDialog::WaterfallConfigDialog(QWidget* parent)
+: QDialog(parent)
+{
+  setModal(false);
+  setWindowTitle(tr("Waterfall Config"));
+  setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+  createLayout();
+  checkAutoStatus();
+  createConnections();
+}
+
+
+double
+WaterfallConfigDialog::startSize()
+{
+  if (autoBox_->isChecked())
+    return -1.0;
+  return startSpinBox_->value();
+}
+
+
+double
+WaterfallConfigDialog::endSize()
+{
+  if (autoBox_->isChecked())
+    return -1.0;
+  return endSpinBox_->value();
+}
+
+
+void
+WaterfallConfigDialog::createLayout()
+{
+  startLabel_ = new QLabel(tr("Start Size (pt):"), this);
+  endLabel_ = new QLabel(tr("End Size (pt):"), this);
+
+  startSpinBox_ = new QDoubleSpinBox(this);
+  endSpinBox_ = new QDoubleSpinBox(this);
+
+  startSpinBox_->setSingleStep(0.5);
+  startSpinBox_->setMinimum(0.5);
+  startSpinBox_->setValue(1);
+
+  endSpinBox_->setSingleStep(0.5);
+  endSpinBox_->setMinimum(0.5);
+  endSpinBox_->setValue(1);
+
+  autoBox_ = new QCheckBox(tr("Auto"), this);
+  autoBox_->setChecked(true);
+
+  // Tooltips
+  autoBox_->setToolTip(tr(
+    "Use the default value which will try to start from near zero and place "
+    "in the middle of the screen the size selected in the selector."));
+  startSpinBox_->setToolTip(tr("Start size, will be always guaranteed."));
+  endSpinBox_->setToolTip(tr(
+    "End size, may not be guaranteed due to rounding and precision issues."));
+
+  // Layouting
+  layout_ = new QGridLayout;
+  gridLayout2ColAddWidget(layout_, autoBox_);
+  gridLayout2ColAddWidget(layout_, startLabel_, startSpinBox_);
+  gridLayout2ColAddWidget(layout_, endLabel_, endSpinBox_);
+
+  setLayout(layout_);
+}
+
+
+void
+WaterfallConfigDialog::createConnections()
+{
+  connect(autoBox_, &QCheckBox::clicked,
+          this, &WaterfallConfigDialog::checkAutoStatus);
+  connect(startSpinBox_, 
+          QOverload<double>::of(&QDoubleSpinBox::valueChanged),
+          this, &WaterfallConfigDialog::sizeUpdated);
+  connect(endSpinBox_, 
+          QOverload<double>::of(&QDoubleSpinBox::valueChanged),
+          this, &WaterfallConfigDialog::sizeUpdated);
+}
+
+
+void
+WaterfallConfigDialog::checkAutoStatus()
+{
+  startSpinBox_->setEnabled(!autoBox_->isChecked());
+  endSpinBox_->setEnabled(!autoBox_->isChecked());
+
+  emit sizeUpdated();
+}
+
+
+const char* StringSamples[] = {
+  "The quick brown fox jumps over the lazy dog",
+
+  /* Luís argüia à Júlia que «brações, fé, chá, óxido, pôr, zângão» */
+  /* eram palavras do português */
+  "Lu\u00EDs arg\u00FCia \u00E0 J\u00FAlia que \u00ABbra\u00E7\u00F5es, "
+  "f\u00E9, ch\u00E1, \u00F3xido, p\u00F4r, z\u00E2ng\u00E3o\u00BB eram "
+  "palavras do portugu\u00EAs",
+
+  /* Ο καλύμνιος σφουγγαράς ψιθύρισε πως θα βουτήξει χωρίς να διστάζει */
+  "\u039F \u03BA\u03B1\u03BB\u03CD\u03BC\u03BD\u03B9\u03BF\u03C2 \u03C3"
+  "\u03C6\u03BF\u03C5\u03B3\u03B3\u03B1\u03C1\u03AC\u03C2 \u03C8\u03B9"
+  "\u03B8\u03CD\u03C1\u03B9\u03C3\u03B5 \u03C0\u03C9\u03C2 \u03B8\u03B1 "
+  "\u03B2\u03BF\u03C5\u03C4\u03AE\u03BE\u03B5\u03B9 \u03C7\u03C9\u03C1"
+  "\u03AF\u03C2 \u03BD\u03B1 \u03B4\u03B9\u03C3\u03C4\u03AC\u03B6\u03B5"
+  "\u03B9",
+
+  /* Съешь ещё этих мягких французских булок да выпей же чаю */
+  "\u0421\u044A\u0435\u0448\u044C \u0435\u0449\u0451 \u044D\u0442\u0438"
+  "\u0445 \u043C\u044F\u0433\u043A\u0438\u0445 \u0444\u0440\u0430\u043D"
+  "\u0446\u0443\u0437\u0441\u043A\u0438\u0445 \u0431\u0443\u043B\u043E"
+  "\u043A \u0434\u0430 \u0432\u044B\u043F\u0435\u0439 \u0436\u0435 "
+  "\u0447\u0430\u044E",
+
+  /* 天地玄黃,宇宙洪荒。日月盈昃,辰宿列張。寒來暑往,秋收冬藏。*/
+  "\u5929\u5730\u7384\u9EC3\uFF0C\u5B87\u5B99\u6D2A\u8352\u3002\u65E5"
+  "\u6708\u76C8\u6603\uFF0C\u8FB0\u5BBF\u5217\u5F35\u3002\u5BD2\u4F86"
+  "\u6691\u5F80\uFF0C\u79CB\u6536\u51AC\u85CF\u3002",
+
+  /* いろはにほへと ちりぬるを わかよたれそ つねならむ */
+  /* うゐのおくやま けふこえて あさきゆめみし ゑひもせす */
+  "\u3044\u308D\u306F\u306B\u307B\u3078\u3068 \u3061\u308A\u306C\u308B"
+  "\u3092 \u308F\u304B\u3088\u305F\u308C\u305D \u3064\u306D\u306A\u3089"
+  "\u3080 \u3046\u3090\u306E\u304A\u304F\u3084\u307E \u3051\u3075\u3053"
+  "\u3048\u3066 \u3042\u3055\u304D\u3086\u3081\u307F\u3057 \u3091\u3072"
+  "\u3082\u305B\u3059",
+
+  /* 키스의 고유조건은 입술끼리 만나야 하고 특별한 기술은 필요치 않다 */
+  "\uD0A4\uC2A4\uC758 \uACE0\uC720\uC870\uAC74\uC740 \uC785\uC220\uB07C"
+  "\uB9AC \uB9CC\uB098\uC57C \uD558\uACE0 \uD2B9\uBCC4\uD55C \uAE30"
+  "\uC220\uC740 \uD544\uC694\uCE58 \uC54A\uB2E4"
+};
+
+
+// end of continuous.cpp
diff --git a/src/ftinspect/panels/continuous.hpp 
b/src/ftinspect/panels/continuous.hpp
new file mode 100644
index 0000000..deec32a
--- /dev/null
+++ b/src/ftinspect/panels/continuous.hpp
@@ -0,0 +1,153 @@
+// continuous.hpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#pragma once
+
+#include "abstracttab.hpp"
+#include "../widgets/customwidgets.hpp"
+#include "../widgets/glyphindexselector.hpp"
+#include "../widgets/fontsizeselector.hpp"
+#include "../widgets/charmapcombobox.hpp"
+#include "../glyphcomponents/graphicsdefault.hpp"
+#include "../glyphcomponents/glyphcontinuous.hpp"
+#include "../engine/engine.hpp"
+
+#include <vector>
+#include <QWidget>
+#include <QDialog>
+#include <QFrame>
+#include <QLabel>
+#include <QComboBox>
+#include <QGridLayout>
+#include <QBoxLayout>
+#include <QPlainTextEdit>
+#include <QCheckBox>
+
+class WaterfallConfigDialog;
+class ContinuousTab
+: public QWidget, public AbstractTab
+{
+  Q_OBJECT
+public:
+  ContinuousTab(QWidget* parent, Engine* engine);
+  ~ContinuousTab() override = default;
+
+  void repaintGlyph() override;
+  void reloadFont() override;
+  void highlightGlyph(int index);
+  void applySettings();
+
+protected:
+  bool eventFilter(QObject* watched, QEvent* event) override;
+  
+private:
+  Engine* engine_;
+
+  int currentGlyphCount_;
+  int lastCharMapIndex_ = 0;
+  int glyphLimitIndex_ = 0;
+
+  GlyphContinuous* canvas_;
+  QFrame* canvasFrame_;
+  FontSizeSelector* sizeSelector_;
+
+  QComboBox* modeSelector_;
+  QComboBox* sourceSelector_;
+  CharMapComboBox* charMapSelector_ = NULL;
+  QComboBox* sampleStringSelector_;
+
+  QPushButton* resetPositionButton_;
+  QPushButton* waterfallConfigButton_;
+  QPushButton* helpButton_;
+
+  QLabel* modeLabel_;
+  QLabel* sourceLabel_;
+  QLabel* charMapLabel_;
+  QLabel* xEmboldeningLabel_;
+  QLabel* yEmboldeningLabel_;
+  QLabel* slantLabel_;
+  QLabel* strokeRadiusLabel_;
+  QLabel* rotationLabel_;
+
+  QDoubleSpinBox* xEmboldeningSpinBox_;
+  QDoubleSpinBox* yEmboldeningSpinBox_;
+  QDoubleSpinBox* slantSpinBox_;
+  QDoubleSpinBox* strokeRadiusSpinBox_;
+  QDoubleSpinBox* rotationSpinBox_;
+
+  QCheckBox* verticalCheckBox_;
+  QCheckBox* waterfallCheckBox_;
+  QCheckBox* kerningCheckBox_;
+
+  GlyphIndexSelector* indexSelector_;
+  QPlainTextEdit* sourceTextEdit_;
+  
+  QHBoxLayout* canvasFrameLayout_;
+  QHBoxLayout* sizeHelpLayout_;
+  QGridLayout* bottomLayout_;
+  QVBoxLayout* mainLayout_;
+
+  WaterfallConfigDialog* wfConfigDialog_;
+
+  void createLayout();
+  void createConnections();
+
+  void updateLimitIndex();
+  void checkModeSource();
+
+  // This doesn't trigger immediate repaint
+  void setGlyphCount(int count);
+
+  // But they do
+  void setGlyphBeginindex(int index);
+  void checkModeSourceAndRepaint();
+  void charMapChanged();
+  void sourceTextChanged();
+  void presetStringSelected();
+  void reloadGlyphsAndRepaint();
+  void openWaterfallConfig();
+  void showToolTip();
+
+  void wheelNavigate(int steps);
+  void wheelZoom(int steps);
+  void wheelResize(int steps);
+
+  void setDefaults();
+  QString formatIndex(int index);
+};
+
+
+class WaterfallConfigDialog
+: public QDialog
+{
+  Q_OBJECT
+
+public:
+  WaterfallConfigDialog(QWidget* parent);
+
+  double startSize();
+  double endSize();
+
+signals:
+  void sizeUpdated();
+
+private:
+  QLabel* startLabel_;
+  QLabel* endLabel_;
+
+  QDoubleSpinBox* startSpinBox_;
+  QDoubleSpinBox* endSpinBox_;
+
+  QCheckBox* autoBox_;
+
+  QGridLayout* layout_;
+
+  void createLayout();
+  void createConnections();
+
+  void checkAutoStatus();
+};
+
+
+// end of continuous.hpp
diff --git a/src/ftinspect/panels/settingpanel.cpp 
b/src/ftinspect/panels/settingpanel.cpp
index 33345f7..403be29 100644
--- a/src/ftinspect/panels/settingpanel.cpp
+++ b/src/ftinspect/panels/settingpanel.cpp
@@ -363,9 +363,9 @@ SettingPanel::applySettings()
 
   engine_->setUseColorLayer(colorLayerCheckBox_->isChecked());
   engine_->renderingEngine()->setLCDUsesBGR(aaSettings.isBGR);
-  //engine_->renderingEngine()->setLCDSubPixelPositioning(
-  //  antiAliasingComboBox_->currentIndex()
-  //    == AntiAliasingComboBoxModel::AntiAliasing_Light_SubPixel);
+  engine_->setLCDSubPixelPositioning(
+    antiAliasingComboBox_->currentIndex()
+      == AntiAliasingComboBoxModel::AntiAliasing_Light_SubPixel);
 
   engine_->renderingEngine()->setForeground(foregroundColor_.rgba());
   engine_->renderingEngine()->setBackground(backgroundColor_.rgba());
diff --git a/src/ftinspect/widgets/charmapcombobox.cpp 
b/src/ftinspect/widgets/charmapcombobox.cpp
new file mode 100644
index 0000000..b9caa91
--- /dev/null
+++ b/src/ftinspect/widgets/charmapcombobox.cpp
@@ -0,0 +1,118 @@
+// charmapcombobox.cpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#include "charmapcombobox.hpp"
+
+#include "../engine/engine.hpp"
+
+
+CharMapComboBox::CharMapComboBox(QWidget* parent,
+                                 Engine* engine,
+                                 bool haveGlyphOrder)
+: QComboBox(parent),
+  haveGlyphOrder_(haveGlyphOrder),
+  engine_(engine)
+{
+  setToolTip("Set current charmap.");
+  connect(this, QOverload<int>::of(&CharMapComboBox::currentIndexChanged),
+          this, &CharMapComboBox::updateToolTip);
+}
+
+
+int
+CharMapComboBox::currentCharMapIndex()
+{
+  auto index = haveGlyphOrder_ ? currentIndex() - 1 : currentIndex();
+  if (index < 0 || charMaps_.size() <= static_cast<unsigned>(index))
+    return -1;
+  return index;
+}
+
+
+int
+CharMapComboBox::defaultFirstGlyphIndex()
+{
+  auto newIndex = currentCharMapIndex();
+  if (newIndex < 0)
+    return 0;
+  if (charMaps_[newIndex].maxIndex <= 20)
+    return charMaps_[newIndex].maxIndex - 1;
+  return 0x20;
+}
+
+
+void
+CharMapComboBox::repopulate()
+{
+  repopulate(engine_->currentFontCharMaps());
+}
+
+
+#define EncodingRole (Qt::UserRole + 10)
+void
+CharMapComboBox::repopulate(std::vector<CharMapInfo>& charMaps)
+{
+  if (charMaps_ == charMaps)
+  {
+    charMaps_ = charMaps; // Still need to substitute because ptr may differ
+    return;
+  }
+  charMaps_ = charMaps;
+  int oldIndex = currentIndex();
+  unsigned oldEncoding = 0u;
+
+  // Using additional UserRole to store encoding id
+  auto oldEncodingV = itemData(oldIndex, EncodingRole);
+  if (oldEncodingV.isValid() && oldEncodingV.canConvert<unsigned>())
+    oldEncoding = oldEncodingV.value<unsigned>();
+
+  { // This brace isn't for the `if` statement!
+    // suppress events during updating
+    QSignalBlocker selectorBlocker(this);
+
+    clear();
+    if (haveGlyphOrder_)
+    {
+      addItem(tr("Glyph Order"));
+      setItemData(0, 0u, EncodingRole);
+    }
+
+    int i = 0;
+    int newIndex = 0;
+    for (auto& map : charMaps)
+    {
+      addItem(tr("%1: %2 (platform %3, encoding %4)")
+                .arg(i)
+                .arg(*map.encodingName)
+                .arg(map.platformID)
+                .arg(map.encodingID));
+      auto encoding = static_cast<unsigned>(map.encoding);
+      setItemData(haveGlyphOrder_ ? i + 1 : i, encoding, EncodingRole);
+
+      if (encoding == oldEncoding && i == oldIndex)
+        newIndex = i;
+    
+      i++;
+    }
+
+    // this shouldn't emit any event either, because force repainting
+    // will happen later, so embrace it into blocker block
+    setCurrentIndex(newIndex);
+    updateToolTip();
+  }
+
+  emit forceUpdateLimitIndex();
+}
+
+
+void
+CharMapComboBox::updateToolTip()
+{
+  auto index = currentIndex();
+  if (index >= 0 && index < count())
+    setToolTip(this->currentText());
+}
+
+
+// end of charmapcombobox.cpp
diff --git a/src/ftinspect/widgets/charmapcombobox.hpp 
b/src/ftinspect/widgets/charmapcombobox.hpp
new file mode 100644
index 0000000..bebdbf8
--- /dev/null
+++ b/src/ftinspect/widgets/charmapcombobox.hpp
@@ -0,0 +1,40 @@
+// charmapcombobox.hpp
+
+// Copyright (C) 2022 by Charlie Jiang.
+
+#pragma once
+
+#include "../engine/charmap.hpp"
+
+#include <vector>
+#include <QComboBox>
+
+class Engine;
+class CharMapComboBox
+: public QComboBox
+{
+  Q_OBJECT
+public:
+  CharMapComboBox(QWidget* parent, Engine* engine, bool haveGlyphOrder = true);
+  ~CharMapComboBox() override = default;
+
+  bool haveGlyphOrder_;
+
+  std::vector<CharMapInfo>& charMaps() { return charMaps_; }
+  int currentCharMapIndex();
+  int defaultFirstGlyphIndex();
+  void repopulate();
+  void repopulate(std::vector<CharMapInfo>& charMaps);
+
+signals:
+  void forceUpdateLimitIndex();
+
+private:
+  Engine* engine_;
+  std::vector<CharMapInfo> charMaps_;
+
+  void updateToolTip();
+};
+
+
+// charmapcombobox.hpp



reply via email to

[Prev in Thread] Current Thread [Next in Thread]