Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bf8e5d0
Replace grapheme splitter with GraphemeSplitterNET submodule and add …
tpbeldie Oct 18, 2026
1c8198e
Add high-performance selection, GDI renderer, QuickTextView, and Quic…
tpbeldie Oct 18, 2026
028ed19
Fix GDI+ character measurement drift and selection text alignment
tpbeldie Oct 18, 2026
b323ed4
Optimize GDI+ text measurement and rendering performance
tpbeldie Oct 18, 2026
c8430ca
Add high-speed Chinese and CJK character support with accurate measur…
tpbeldie Oct 18, 2026
6aeacdf
Fix GDI+ character rearrangement during selection using dual-pass cli…
tpbeldie Oct 18, 2026
e74cc33
Fix cache invalidation and layout recalculation on renderer switch
tpbeldie Oct 18, 2026
4d4fa89
Optimize NoWrapTextView selection speed, hit-testing, and rendering
tpbeldie Oct 18, 2026
d95d9f7
Add wrapped view and alignment support to demo and STTextBox
tpbeldie Oct 18, 2026
142cbb1
feat: implement STTextBox core control and related quick selection te…
tpbeldie Oct 18, 2026
ab385eb
Improve toolbar sizing and status display for font selection demo
tpbeldie Oct 18, 2026
fabdf1d
feat: add WrapTextView implementation and QuickSelectionTests for tex…
tpbeldie Oct 18, 2026
1b945f3
fix: eliminate ghost selection offsets and GDI+ float advance drift i…
tpbeldie Oct 18, 2026
9b4bde3
fix(gdip): fix GDI+ floating-point character offset drift and SingleT…
tpbeldie Oct 18, 2026
2fa2f45
fix: eliminate glyph jitter and rearrangement during selection of sty…
tpbeldie Oct 18, 2026
fd8bee8
fix(wrap): fix selection drag invalidation and rendering for wrapped …
tpbeldie Oct 18, 2026
8dca7a2
fix(svg): use invariant culture for svg float parsing and number regexes
tpbeldie Oct 18, 2026
933422f
fix(emoji): support single-character and non-surrogate BMP emojis in …
tpbeldie Oct 18, 2026
2e88141
perf(memory): optimize memory footprint across EmojiRender, TextViews…
tpbeldie Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "External/GraphemeSplitterNET"]
path = External/GraphemeSplitterNET
url = https://github.com/tpbeldie/GraphemeSplitterNET
1 change: 1 addition & 0 deletions External/GraphemeSplitterNET
Submodule GraphemeSplitterNET added at 5fe4e1
173 changes: 132 additions & 41 deletions ST.Library.Drawing/EmojiRender.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
Expand All @@ -16,8 +16,24 @@ namespace ST.Library.Drawing
{
public class EmojiRender : IEmojiRender, IDisposable
{
private Dictionary<string, string> m_dic_xml = new Dictionary<string, string>();
private struct SvgSpan
{
public readonly int Offset;
public readonly int Length;
public SvgSpan(int offset, int length) {
this.Offset = offset;
this.Length = length;
}
}

private const string FALLBACK_NONE_XML = "<svg viewBox='0,0,20,20'><rect stroke='red' fill='yellow' x='4' y='2' width='12' height='16'/><path stroke='red' fill='none' d='M8,8 v-2 h4 v4 h-2 v2'/><line stroke='red' x1='8' y1='14' x2='12' y2='14'/></svg>";
private const int MAX_CACHE_SIZE = 128;

private FileStream m_file_stream;
private readonly object m_stream_lock = new object();
private Dictionary<string, SvgSpan> m_dic_spans = new Dictionary<string, SvgSpan>();
private Dictionary<string, CacheInfo> m_dic_cache = new Dictionary<string, CacheInfo>();
private LinkedList<string> m_cache_lru = new LinkedList<string>();

private struct CacheInfo
{
Expand Down Expand Up @@ -50,18 +66,23 @@ public bool IsEmoji(string strChar) {
}

private string GetEmojiString(string strText) {
if (m_dic_xml.ContainsKey(strText)) {
if (string.IsNullOrEmpty(strText)) return null;
if (strText == "none") return "none";
if (m_dic_spans.ContainsKey(strText)) {
return strText;
}
// http://www.unicode.org/charts/PDF/UFE00.pdf
// FE00 - FE0F is the Variation Selectors
// FE0E -> text variation selector
// FE0F -> emoji variation selector
if (strText.IndexOf('\uFE0F') == -1) {
if (m_dic_spans.ContainsKey(strText + "\uFE0F")) {
return strText + "\uFE0F";
}
return null;
}
strText = strText.Replace("\uFE0F", "");// strText.Substring(0, strText.Length - 1);
if (m_dic_xml.ContainsKey(strText)) {
strText = strText.Replace("\uFE0F", "");
if (m_dic_spans.ContainsKey(strText)) {
return strText;
}
return null;
Expand All @@ -73,49 +94,60 @@ public void DrawEmoji(ISTTextBoxRender render, string strChar, int nX, int nY, i
if (string.IsNullOrEmpty(strChar)) {
strChar = "none";
}
if (m_dic_cache.ContainsKey(strChar)) {
ci = m_dic_cache[strChar];
if (m_dic_cache.TryGetValue(strChar, out ci)) {
if (ci.Size != nWidth) {
ci.Image.Dispose();
if (ci.Image != null) ci.Image.Dispose();
if (ci.SelectedImage != null) ci.SelectedImage.Dispose();
ci.Size = nWidth;
ci.Image = this.GetEmojiImage(strChar, nWidth);
ci.SelectedImage = this.SetAlpha((Bitmap)ci.Image.Clone());
m_dic_cache[strChar] = ci;
}
} else {
if (m_dic_cache.Count >= MAX_CACHE_SIZE && m_cache_lru.First != null) {
string oldest = m_cache_lru.First.Value;
m_cache_lru.RemoveFirst();
if (m_dic_cache.TryGetValue(oldest, out CacheInfo oldCi)) {
if (oldCi.Image != null) oldCi.Image.Dispose();
if (oldCi.SelectedImage != null) oldCi.SelectedImage.Dispose();
m_dic_cache.Remove(oldest);
}
}
ci.Size = nWidth;
ci.Image = this.GetEmojiImage(strChar, nWidth);
ci.SelectedImage = this.SetAlpha((Bitmap)ci.Image.Clone());
m_dic_cache.Add(strChar, ci);
m_dic_cache[strChar] = ci;
m_cache_lru.AddLast(strChar);
}
Rectangle rect = new Rectangle(nX, nY, nWidth, nWidth);
render.DrawImage(bSelected ? ci.SelectedImage : ci.Image, rect);
//if (!bSelected) {
// render.DrawImage(ci.Image, rect);
//} else {
// using (Bitmap bmp = (Bitmap)ci.Image.Clone()) {
// var bmpData = bmp.LockBits(new Rectangle(0, 0, nWidth, nWidth), ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
// byte[] byClr = new byte[bmpData.Stride * bmpData.Height];
// Marshal.Copy(bmpData.Scan0, byClr, 0, byClr.Length);
// for (int x = 0; x < bmp.Width; x++) {
// for (int y = 0; y < bmp.Height; y++) {
// int nIndex = y * bmpData.Stride + x * 4 + 3;
// byClr[nIndex] = (byte)(byClr[nIndex] >> 1);
// }
// }
// Marshal.Copy(byClr, 0, bmpData.Scan0, byClr.Length);
// bmp.UnlockBits(bmpData);
// render.DrawImage(bmp, rect);
// }
//}
}

public Image GetEmojiImage(string strChar, int nSize) {
strChar = this.GetEmojiString(strChar);
if (strChar == null) {
if (strChar == null && strChar != "none") {
return null;
}
string xml = null;
if (strChar == "none") {
xml = FALLBACK_NONE_XML;
} else if (m_dic_spans.TryGetValue(strChar, out SvgSpan span)) {
lock (m_stream_lock) {
if (m_file_stream != null) {
m_file_stream.Seek(span.Offset, SeekOrigin.Begin);
byte[] buf = new byte[span.Length];
int read = m_file_stream.Read(buf, 0, span.Length);
xml = Encoding.UTF8.GetString(buf, 0, read);
}
}
}
if (string.IsNullOrEmpty(xml)) {
xml = FALLBACK_NONE_XML;
}

Bitmap bmp = new Bitmap(nSize, nSize);
using (Graphics g = Graphics.FromImage(bmp)) {
using (SvgDocument svg = SvgDocument.FromXml(m_dic_xml[strChar])) {
using (SvgDocument svg = SvgDocument.FromXml(xml)) {
svg.Draw(g, new RectangleF(0, 0, nSize, nSize));
}
}
Expand All @@ -138,21 +170,61 @@ public Image SetAlpha(Bitmap bmp) {
}

private int InitSvgFromPackage(string strFile) {
int nCounter = 0;
Dictionary<string, string> dic = new Dictionary<string, string>();
using (StreamReader reader = new StreamReader(strFile, Encoding.UTF8)) {
string strLine = string.Empty;
while ((strLine = reader.ReadLine()) != null) {
int nIndex = strLine.IndexOf(',');
string strXml = strLine.Substring(nIndex + 1);
dic.Add(this.GetString(strLine.Substring(0, nIndex)), strXml);
if (m_file_stream != null) {
m_file_stream.Dispose();
m_file_stream = null;
}
m_file_stream = new FileStream(strFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
m_dic_spans.Clear();

byte[] buffer = new byte[65536];
long filePos = 0;

int bomRead = m_file_stream.Read(buffer, 0, 3);
if (bomRead == 3 && buffer[0] == 0xEF && buffer[1] == 0xBB && buffer[2] == 0xBF) {
filePos = 3;
} else {
m_file_stream.Seek(0, SeekOrigin.Begin);
filePos = 0;
}

List<byte> lineBytes = new List<byte>(4096);
long lineStartPos = filePos;
int commaIndexInLine = -1;

int bytesRead;
while ((bytesRead = m_file_stream.Read(buffer, 0, buffer.Length)) > 0) {
for (int i = 0; i < bytesRead; i++) {
byte b = buffer[i];
long currentBytePos = filePos + i;
if (b == (byte)'\r' || b == (byte)'\n') {
if (lineBytes.Count > 0 && commaIndexInLine >= 0) {
string hex = Encoding.ASCII.GetString(lineBytes.ToArray(), 0, commaIndexInLine);
int xmlOffset = (int)(lineStartPos + commaIndexInLine + 1);
int xmlLen = lineBytes.Count - (commaIndexInLine + 1);
string key = this.GetString(hex);
m_dic_spans[key] = new SvgSpan(xmlOffset, xmlLen);
}
lineBytes.Clear();
commaIndexInLine = -1;
lineStartPos = currentBytePos + 1;
} else {
if (b == (byte)',' && commaIndexInLine == -1) {
commaIndexInLine = lineBytes.Count;
}
lineBytes.Add(b);
}
}
filePos += bytesRead;
}
if (!dic.ContainsKey("none")) {
dic.Add("none", "<svg viewBox='0,0,20,20'><rect stroke='red' fill='yellow' x='4' y='2' width='12' height='16'/><path stroke='red' fill='none' d='M8,8 v-2 h4 v4 h-2 v2'/><line stroke='red' x1='8' y1='14' x2='12' y2='14'/></svg>");
if (lineBytes.Count > 0 && commaIndexInLine >= 0) {
string hex = Encoding.ASCII.GetString(lineBytes.ToArray(), 0, commaIndexInLine);
int xmlOffset = (int)(lineStartPos + commaIndexInLine + 1);
int xmlLen = lineBytes.Count - (commaIndexInLine + 1);
string key = this.GetString(hex);
m_dic_spans[key] = new SvgSpan(xmlOffset, xmlLen);
}
m_dic_xml = dic;
return nCounter;
return m_dic_spans.Count;
}

public string GetString(string strText) {
Expand Down Expand Up @@ -207,11 +279,30 @@ private static string FileNameToUnicode(string strName) {
}

public void Dispose() {
if (m_file_stream != null) {
m_file_stream.Dispose();
m_file_stream = null;
}
if (m_dic_cache != null) {
foreach (var ci in m_dic_cache.Values) {
if (ci.Image != null) ci.Image.Dispose();
if (ci.SelectedImage != null) ci.SelectedImage.Dispose();
}
m_dic_cache.Clear();
}
if (m_cache_lru != null) {
m_cache_lru.Clear();
}
if (m_dic_spans != null) {
m_dic_spans.Clear();
}
if (m_img_attr_normal != null) {
m_img_attr_normal.Dispose();
m_img_attr_normal = null;
}
if (m_img_attr_selected != null) {
m_img_attr_selected.Dispose();
m_img_attr_selected = null;
}
}
}
Expand Down
51 changes: 33 additions & 18 deletions ST.Library.Drawing/SvgRender/SvgAttributes.static.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
Expand All @@ -12,9 +12,9 @@ namespace ST.Library.Drawing.SvgRender

public partial class SvgAttributes
{
private static Regex m_reg_number = new Regex(@"([+-]?(?:\.\d+|\d+(?:\.\d+)?)(?:[eE]-\d+)?)");
private static Regex m_reg_angle = new Regex(@"([+-]?(?:\.\d+|\d+(?:\.\d+)?)(?:[eE]-\d+)?)(deg|grad|rad)?");
private static Regex m_reg_size = new Regex(@"([+-]?(?:\.\d+|\d+(?:\.\d+)?)(?:[eE]-\d+)?)(em|ex|px|in|cm|mm|pt|pc|%)?");
private static Regex m_reg_number = new Regex(@"([+-]?(?:\.\d+|\d+(?:\.\d+)?)(?:[eE][+-]?\d+)?)");
private static Regex m_reg_angle = new Regex(@"([+-]?(?:\.\d+|\d+(?:\.\d+)?)(?:[eE][+-]?\d+)?)(deg|grad|rad)?");
private static Regex m_reg_size = new Regex(@"([+-]?(?:\.\d+|\d+(?:\.\d+)?)(?:[eE][+-]?\d+)?)(em|ex|px|in|cm|mm|pt|pc|%)?");
private static Regex m_reg_transform = new Regex(@"(rotate|scale|scaleX|scaleY|skew|skewX|skewY|translate|translateX|translateY|matrix)\((.*?)\)");
private static Dictionary<string, Color> m_dic_color = new Dictionary<string, Color>();
private static Dictionary<string, SvgAttributeValueCheckCallBack> m_dic_value_check = new Dictionary<string, SvgAttributeValueCheckCallBack>();
Expand Down Expand Up @@ -598,17 +598,18 @@ public static bool CheckIsFloat(string strText) {
if (string.IsNullOrEmpty(strText)) {
return false;
}
return Regex.IsMatch(strText, @"^[+-]?(\.\d+|\d+(\.\d+)?)([eE]-\d+)?$");
float f;
return float.TryParse(strText, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f);
}

public static bool CheckIsSize(string strText) {
if (string.IsNullOrEmpty(strText)) return false;
return Regex.IsMatch(strText, @"^[+-]?(\.\d+|\d+(?:\.\d+)?)(em|ex|px|in|cm|mm|pt|pc|%)?$");
return Regex.IsMatch(strText, @"^[+-]?(\.\d+|\d+(?:\.\d+)?)([eE][+-]?\d+)?(em|ex|px|in|cm|mm|pt|pc|%)?$");
}

public static bool CheckIsAngle(string strText) {
if (string.IsNullOrEmpty(strText)) return false;
return Regex.IsMatch(strText, @"^[+-]?(\.\d+|\d+(?:\.\d+)?)([eE]-\d+)?(deg|grad|rad)?$");
return Regex.IsMatch(strText, @"^[+-]?(\.\d+|\d+(?:\.\d+)?)([eE][+-]?\d+)?(deg|grad|rad)?$");
}

public static bool CheckIsColor(string strText) {
Expand Down Expand Up @@ -723,7 +724,11 @@ public static float GetFloat(SvgElement ele, string strName, bool inherit, float
if (!inherit) return defaultValue;
return SvgAttributes.GetFloat(ele.CurrentParent, strName, true, defaultValue);
}
return float.Parse(str);
float val;
if (float.TryParse(str, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out val)) {
return val;
}
return defaultValue;
}

public static Color GetColor(SvgElement ele, string strName) {
Expand Down Expand Up @@ -849,7 +854,8 @@ public static float GetAngleFromText(string strText) {
return 0;
}
// 90deg = 100grad = 0.25turn ≈ 1.570796326794897rad
float f = float.Parse(m.Groups[1].Value);
float f;
float.TryParse(m.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f);
switch (m.Groups[1].Value) {
case "grad":
return f * 90 / 100;
Expand Down Expand Up @@ -878,7 +884,8 @@ public static float GetSize(SvgElement ele, string strName, bool inherit, float
if (!m.Success) {
return defaultValue;
}
float fSize = float.Parse(m.Groups[1].Value);
float fSize;
float.TryParse(m.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out fSize);
switch (m.Groups[2].Value) {
case "%":
return fSize / 100 * SvgAttributes.GetSize(ele.CurrentParent, strName, inherit, defaultValue);
Expand Down Expand Up @@ -913,7 +920,8 @@ public static float GetSizeFromText(SvgElement ele, string strText, out bool bEr
bError = true;
return 0;
}
float fSize = float.Parse(m.Groups[1].Value);
float fSize;
float.TryParse(m.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out fSize);
switch (m.Groups[2].Value) {
case "%":
return fSize / 100 * SvgAttributes.GetSize(ele, "width", true, 0);
Expand Down Expand Up @@ -947,7 +955,8 @@ public static float[] GetSizeArray(SvgElement ele, string strName, bool inherit)
float[] arr = new float[ms.Count];
for (int i = 0; i < arr.Length; i++) {
var m = ms[i];
float fSize = float.Parse(m.Groups[1].Value);
float fSize;
float.TryParse(m.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out fSize);
switch (m.Groups[2].Value) {
case "%":
arr[i] = fSize / 100 * SvgAttributes.GetFontSize(ele.CurrentParent);
Expand Down Expand Up @@ -992,7 +1001,8 @@ public static float GetFontSize(SvgElement ele) {
if (!m.Success) { // The error value, search from parent to inherit
return SvgAttributes.GetFontSize(ele.CurrentParent);
}
float fSize = float.Parse(m.Groups[1].Value);
float fSize;
float.TryParse(m.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out fSize);
switch (m.Groups[2].Value) {
case "%":
return fSize / 100 * SvgAttributes.GetFontSize(ele.CurrentParent);
Expand Down Expand Up @@ -1145,7 +1155,9 @@ private static float GetColorChannel(string strNum) {
if (!SvgAttributes.CheckIsFloat(strNum)) {
return 0;
}
return float.Parse(strNum);
float f;
float.TryParse(strNum, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f);
return f;
}
if (strNum.Length < 2) {
return 0;
Expand All @@ -1154,7 +1166,9 @@ private static float GetColorChannel(string strNum) {
if (!SvgAttributes.CheckIsFloat(strNum)) {
return 0;
}
return 255 * (SvgAttributes.CheckRange(float.Parse(strNum), 0, 100) % 100);
float fPercent;
float.TryParse(strNum, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out fPercent);
return 255 * (SvgAttributes.CheckRange(fPercent, 0, 100) % 100);
}

private static float HueToRgb(float p, float q, float t) {
Expand Down Expand Up @@ -1189,10 +1203,11 @@ private static float CheckRange(float fNum, float fMin, float fMax) {
}

private static float _F(string strText) {
if (!SvgAttributes.CheckIsFloat(strText)) {
return 0;
float val;
if (float.TryParse(strText, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out val)) {
return val;
}
return float.Parse(strText);
return 0;
}

private static float _F(string strText, float fMin, float fMax) {
Expand Down
Loading