diff --git a/Client/UI/CairoFont.cs b/Client/UI/CairoFont.cs index 1cf69379..ef53b4bd 100644 --- a/Client/UI/CairoFont.cs +++ b/Client/UI/CairoFont.cs @@ -24,7 +24,8 @@ public class CairoFont : FontConfig, IDisposable public double LineHeightMultiplier = 1f; - FontOptions CairoFontOptions; + FontOptions CairoFontOptions; + const string ArabicFallbackFontName = "Tahoma"; public FontSlant Slant = FontSlant.Normal; @@ -213,11 +214,16 @@ public CairoFont WithFont(string fontname) /// Sets up the context. Must be executed in the main thread, as it is not thread safe. /// /// The context to set up the CairoFont with. - public void SetupContext(Context ctx) - { - ctx.SetFontSize(GuiElement.scaled(UnscaledFontsize)); - ctx.SelectFontFace(Fontname, Slant, FontWeight); - CairoFontOptions = new FontOptions(); + public void SetupContext(Context ctx) + { + SetupContext(ctx, null); + } + + public void SetupContext(Context ctx, string text) + { + ctx.SetFontSize(GuiElement.scaled(UnscaledFontsize)); + ctx.SelectFontFace(GetFontNameForText(text), Slant, GetFontWeightForText(text)); + CairoFontOptions = new FontOptions(); // Antialias.Best does not work on Linux it completely borks the font CairoFontOptions.Antialias = Antialias.Subpixel; @@ -233,29 +239,61 @@ public void SetupContext(Context ctx) { ctx.SetSourceRGBA(Color[0], Color[1], Color[2], Color[3]); } - } - } + } + } + + string GetFontNameForText(string text) + { + if (!ComplexTextLayout.RequiresRightToLeftLayout(text)) + { + return Fontname; + } + + return ArabicFallbackFontName; + } + + FontWeight GetFontWeightForText(string text) + { + if (!ComplexTextLayout.RequiresRightToLeftLayout(text)) + { + return FontWeight; + } + + return Cairo.FontWeight.Normal; + } /// /// Gets the font's extents. /// /// The FontExtents for this particular font. - public FontExtents GetFontExtents() - { - SetupContext(FontMeasuringContext); - return FontMeasuringContext.FontExtents; - } + public FontExtents GetFontExtents() + { + SetupContext(FontMeasuringContext); + return FontMeasuringContext.FontExtents; + } + + public FontExtents GetFontExtents(string text) + { + SetupContext(FontMeasuringContext, text); + return FontMeasuringContext.FontExtents; + } /// /// Gets the extents of the text. /// /// The text to extend. /// The Text extends for this font with this text. - public TextExtents GetTextExtents(string text) - { - SetupContext(FontMeasuringContext); - return FontMeasuringContext.TextExtents(text); - } + public TextExtents GetTextExtents(string text) + { + SetupContext(FontMeasuringContext, text); + + if (CairoGlyphLayout.TryCreateGlyphs(FontMeasuringContext, text, 0, 0, out Glyph[] glyphs)) + { + return FontMeasuringContext.GlyphExtents(glyphs); + } + + return FontMeasuringContext.TextExtents(ComplexTextLayout.PrepareForRendering(text)); + } /// /// Clone function. Creates a duplicate of this Cairofont. diff --git a/Client/UI/CairoGlyphLayout.cs b/Client/UI/CairoGlyphLayout.cs new file mode 100644 index 00000000..619c0688 --- /dev/null +++ b/Client/UI/CairoGlyphLayout.cs @@ -0,0 +1,142 @@ +using System; +using System.Runtime.InteropServices; +using Cairo; + +#nullable disable + +namespace Vintagestory.API.Client +{ + internal static class CairoGlyphLayout + { + // The native cairo glyph conversion used by the shipped runtime resolves glyph indices, + // but it does not perform Arabic shaping. Enabling it leaves right-to-left text disconnected. + const bool NativeGlyphLayoutEnabled = false; + + [StructLayout(LayoutKind.Sequential)] + struct NativeGlyph32 + { + public uint Index; + public double X; + public double Y; + } + + [StructLayout(LayoutKind.Sequential)] + struct NativeGlyph64 + { + public ulong Index; + public double X; + public double Y; + } + + [DllImport("libcairo-2.dll", CallingConvention = CallingConvention.Cdecl)] + static extern int cairo_scaled_font_text_to_glyphs( + IntPtr scaledFont, + double x, + double y, + [MarshalAs(UnmanagedType.LPUTF8Str)] string utf8, + int utf8Len, + out IntPtr glyphs, + out int numGlyphs, + IntPtr clusters, + IntPtr numClusters, + IntPtr clusterFlags); + + [DllImport("libcairo-2.dll", CallingConvention = CallingConvention.Cdecl)] + static extern void cairo_glyph_free(IntPtr glyphs); + + public static bool TryCreateGlyphs(Context ctx, string text, double originX, double originY, out Glyph[] glyphs) + { + glyphs = null; + + if (!NativeGlyphLayoutEnabled) + { + return false; + } + + if (ctx == null || string.IsNullOrEmpty(text) || !ComplexTextLayout.RequiresRightToLeftLayout(text)) + { + return false; + } + + string preparedText = ComplexTextLayout.PrepareForRendering(text); + if (string.IsNullOrEmpty(preparedText)) + { + return false; + } + + IntPtr nativeGlyphs = IntPtr.Zero; + + try + { + int status = cairo_scaled_font_text_to_glyphs( + ctx.GetScaledFont().Handle, + originX, + originY, + preparedText, + -1, + out nativeGlyphs, + out int numGlyphs, + IntPtr.Zero, + IntPtr.Zero, + IntPtr.Zero + ); + + if (status != 0 || nativeGlyphs == IntPtr.Zero || numGlyphs <= 0) + { + return false; + } + + glyphs = CopyGlyphs(nativeGlyphs, numGlyphs); + return glyphs.Length > 0; + } + catch + { + glyphs = null; + return false; + } + finally + { + if (nativeGlyphs != IntPtr.Zero) + { + cairo_glyph_free(nativeGlyphs); + } + } + } + + static Glyph[] CopyGlyphs(IntPtr nativeGlyphs, int numGlyphs) + { + Glyph[] glyphs = new Glyph[numGlyphs]; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + int glyphSize = Marshal.SizeOf(); + for (int i = 0; i < numGlyphs; i++) + { + NativeGlyph32 nativeGlyph = Marshal.PtrToStructure(nativeGlyphs + i * glyphSize); + glyphs[i] = new Glyph + { + Index = nativeGlyph.Index, + X = nativeGlyph.X, + Y = nativeGlyph.Y + }; + } + } + else + { + int glyphSize = Marshal.SizeOf(); + for (int i = 0; i < numGlyphs; i++) + { + NativeGlyph64 nativeGlyph = Marshal.PtrToStructure(nativeGlyphs + i * glyphSize); + glyphs[i] = new Glyph + { + Index = (long)nativeGlyph.Index, + X = nativeGlyph.X, + Y = nativeGlyph.Y + }; + } + } + + return glyphs; + } + } +} diff --git a/Client/UI/ComplexTextLayout.cs b/Client/UI/ComplexTextLayout.cs new file mode 100644 index 00000000..53b93884 --- /dev/null +++ b/Client/UI/ComplexTextLayout.cs @@ -0,0 +1,639 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +#nullable disable + +namespace Vintagestory.API.Client +{ + internal static class ComplexTextLayout + { + enum TextDirection + { + Neutral, + LeftToRight, + RightToLeft + } + + readonly struct ArabicForms + { + public readonly char Isolated; + public readonly char Final; + public readonly char Initial; + public readonly char Medial; + + public ArabicForms(char isolated, char final, char initial = '\0', char medial = '\0') + { + Isolated = isolated; + Final = final; + Initial = initial; + Medial = medial; + } + + public bool ConnectsToPrevious => Final != '\0' || Medial != '\0'; + public bool ConnectsToNext => Initial != '\0' || Medial != '\0'; + + public char GetShapedChar(bool connectsToPrevious, bool connectsToNext) + { + if (connectsToPrevious && connectsToNext && Medial != '\0') return Medial; + if (connectsToPrevious && Final != '\0') return Final; + if (connectsToNext && Initial != '\0') return Initial; + return Isolated; + } + } + + readonly struct DirectionalRun + { + public readonly TextDirection Direction; + public readonly List Clusters; + + public DirectionalRun(TextDirection direction, List clusters) + { + Direction = direction; + Clusters = clusters; + } + } + + static readonly Dictionary ArabicPresentationForms = new Dictionary + { + ['\u0621'] = new ArabicForms('\uFE80', '\0'), + ['\u0622'] = new ArabicForms('\uFE81', '\uFE82'), + ['\u0623'] = new ArabicForms('\uFE83', '\uFE84'), + ['\u0624'] = new ArabicForms('\uFE85', '\uFE86'), + ['\u0625'] = new ArabicForms('\uFE87', '\uFE88'), + ['\u0626'] = new ArabicForms('\uFE89', '\uFE8A', '\uFE8B', '\uFE8C'), + ['\u0627'] = new ArabicForms('\uFE8D', '\uFE8E'), + ['\u0628'] = new ArabicForms('\uFE8F', '\uFE90', '\uFE91', '\uFE92'), + ['\u0629'] = new ArabicForms('\uFE93', '\uFE94'), + ['\u062A'] = new ArabicForms('\uFE95', '\uFE96', '\uFE97', '\uFE98'), + ['\u062B'] = new ArabicForms('\uFE99', '\uFE9A', '\uFE9B', '\uFE9C'), + ['\u062C'] = new ArabicForms('\uFE9D', '\uFE9E', '\uFE9F', '\uFEA0'), + ['\u062D'] = new ArabicForms('\uFEA1', '\uFEA2', '\uFEA3', '\uFEA4'), + ['\u062E'] = new ArabicForms('\uFEA5', '\uFEA6', '\uFEA7', '\uFEA8'), + ['\u062F'] = new ArabicForms('\uFEA9', '\uFEAA'), + ['\u0630'] = new ArabicForms('\uFEAB', '\uFEAC'), + ['\u0631'] = new ArabicForms('\uFEAD', '\uFEAE'), + ['\u0632'] = new ArabicForms('\uFEAF', '\uFEB0'), + ['\u0633'] = new ArabicForms('\uFEB1', '\uFEB2', '\uFEB3', '\uFEB4'), + ['\u0634'] = new ArabicForms('\uFEB5', '\uFEB6', '\uFEB7', '\uFEB8'), + ['\u0635'] = new ArabicForms('\uFEB9', '\uFEBA', '\uFEBB', '\uFEBC'), + ['\u0636'] = new ArabicForms('\uFEBD', '\uFEBE', '\uFEBF', '\uFEC0'), + ['\u0637'] = new ArabicForms('\uFEC1', '\uFEC2', '\uFEC3', '\uFEC4'), + ['\u0638'] = new ArabicForms('\uFEC5', '\uFEC6', '\uFEC7', '\uFEC8'), + ['\u0639'] = new ArabicForms('\uFEC9', '\uFECA', '\uFECB', '\uFECC'), + ['\u063A'] = new ArabicForms('\uFECD', '\uFECE', '\uFECF', '\uFED0'), + ['\u0640'] = new ArabicForms('\u0640', '\u0640', '\u0640', '\u0640'), + ['\u0641'] = new ArabicForms('\uFED1', '\uFED2', '\uFED3', '\uFED4'), + ['\u0642'] = new ArabicForms('\uFED5', '\uFED6', '\uFED7', '\uFED8'), + ['\u0643'] = new ArabicForms('\uFED9', '\uFEDA', '\uFEDB', '\uFEDC'), + ['\u0644'] = new ArabicForms('\uFEDD', '\uFEDE', '\uFEDF', '\uFEE0'), + ['\u0645'] = new ArabicForms('\uFEE1', '\uFEE2', '\uFEE3', '\uFEE4'), + ['\u0646'] = new ArabicForms('\uFEE5', '\uFEE6', '\uFEE7', '\uFEE8'), + ['\u0647'] = new ArabicForms('\uFEE9', '\uFEEA', '\uFEEB', '\uFEEC'), + ['\u0648'] = new ArabicForms('\uFEED', '\uFEEE'), + ['\u0649'] = new ArabicForms('\uFEEF', '\uFEF0'), + ['\u064A'] = new ArabicForms('\uFEF1', '\uFEF2', '\uFEF3', '\uFEF4'), + ['\u0671'] = new ArabicForms('\uFB50', '\uFB51'), + ['\u067E'] = new ArabicForms('\uFB56', '\uFB57', '\uFB58', '\uFB59'), + ['\u0686'] = new ArabicForms('\uFB7A', '\uFB7B', '\uFB7C', '\uFB7D'), + ['\u0688'] = new ArabicForms('\uFB88', '\uFB89'), + ['\u0691'] = new ArabicForms('\uFB8C', '\uFB8D'), + ['\u0698'] = new ArabicForms('\uFB8A', '\uFB8B'), + ['\u06A4'] = new ArabicForms('\uFB6A', '\uFB6B', '\uFB6C', '\uFB6D'), + ['\u06A9'] = new ArabicForms('\uFB8E', '\uFB8F', '\uFB90', '\uFB91'), + ['\u06AF'] = new ArabicForms('\uFB92', '\uFB93', '\uFB94', '\uFB95'), + ['\u06BA'] = new ArabicForms('\uFB9E', '\uFB9F'), + ['\u06BE'] = new ArabicForms('\uFBAA', '\uFBAB', '\uFBAC', '\uFBAD'), + ['\u06C1'] = new ArabicForms('\uFBA6', '\uFBA7', '\uFBA8', '\uFBA9'), + ['\u06CC'] = new ArabicForms('\uFBFC', '\uFBFD', '\uFBFE', '\uFBFF'), + ['\u06D2'] = new ArabicForms('\uFBAE', '\uFBAF') + }; + + static readonly Dictionary MirroredChars = new Dictionary + { + ['('] = ')', + [')'] = '(', + ['['] = ']', + [']'] = '[', + ['{'] = '}', + ['}'] = '{', + ['<'] = '>', + ['>'] = '<' + }; + + static readonly HashSet LtrFriendlyNeutrals = new HashSet + { + '.', ',', ':', ';', '/', '\\', '-', '_', '+', '@', '#', '&', '=', '%', '*' + }; + + public static string PrepareForRendering(string text) + { + if (string.IsNullOrEmpty(text) || !ContainsRightToLeftText(text)) + { + return text; + } + + if (ContainsPresentationForms(text) || ContainsDirectionalControlCharacters(text)) + { + return text; + } + + StringBuilder builder = new StringBuilder(text.Length); + int lineStart = 0; + + while (lineStart < text.Length) + { + int lineEnd = lineStart; + while (lineEnd < text.Length && text[lineEnd] != '\r' && text[lineEnd] != '\n') + { + lineEnd++; + } + + builder.Append(PrepareLine(text.Substring(lineStart, lineEnd - lineStart), true)); + + if (lineEnd < text.Length) + { + if (text[lineEnd] == '\r' && lineEnd + 1 < text.Length && text[lineEnd + 1] == '\n') + { + builder.Append("\r\n"); + lineStart = lineEnd + 2; + } + else + { + builder.Append(text[lineEnd]); + lineStart = lineEnd + 1; + } + } + else + { + lineStart = lineEnd; + } + } + + return builder.ToString(); + } + + public static string PrepareForGlyphRendering(string text) + { + if (string.IsNullOrEmpty(text) || !ContainsRightToLeftText(text)) + { + return text; + } + + if (ContainsDirectionalControlCharacters(text)) + { + return text; + } + + StringBuilder builder = new StringBuilder(text.Length); + int lineStart = 0; + + while (lineStart < text.Length) + { + int lineEnd = lineStart; + while (lineEnd < text.Length && text[lineEnd] != '\r' && text[lineEnd] != '\n') + { + lineEnd++; + } + + builder.Append(PrepareLine(text.Substring(lineStart, lineEnd - lineStart), false)); + + if (lineEnd < text.Length) + { + if (text[lineEnd] == '\r' && lineEnd + 1 < text.Length && text[lineEnd + 1] == '\n') + { + builder.Append("\r\n"); + lineStart = lineEnd + 2; + } + else + { + builder.Append(text[lineEnd]); + lineStart = lineEnd + 1; + } + } + else + { + lineStart = lineEnd; + } + } + + return builder.ToString(); + } + + public static bool RequiresRightToLeftLayout(string text) + { + return !string.IsNullOrEmpty(text) && ContainsRightToLeftText(text); + } + + static string PrepareLine(string line, bool shapeArabic) + { + if (string.IsNullOrEmpty(line) || !ContainsRightToLeftText(line)) + { + return line; + } + + string shapedLine = shapeArabic ? ShapeArabic(line) : line; + List clusters = CreateClusters(shapedLine); + if (clusters.Count == 0) + { + return shapedLine; + } + + TextDirection baseDirection = GetBaseDirection(line); + List runs = CreateRuns(clusters, baseDirection); + + StringBuilder builder = new StringBuilder(shapedLine.Length); + + if (baseDirection == TextDirection.RightToLeft) + { + for (int i = runs.Count - 1; i >= 0; i--) + { + AppendRun(builder, runs[i]); + } + } + else + { + for (int i = 0; i < runs.Count; i++) + { + AppendRun(builder, runs[i]); + } + } + + return builder.ToString(); + } + + static void AppendRun(StringBuilder builder, DirectionalRun run) + { + if (run.Direction == TextDirection.RightToLeft) + { + for (int i = run.Clusters.Count - 1; i >= 0; i--) + { + builder.Append(MirrorCluster(run.Clusters[i])); + } + + return; + } + + for (int i = 0; i < run.Clusters.Count; i++) + { + builder.Append(run.Clusters[i]); + } + } + + static List CreateRuns(List clusters, TextDirection baseDirection) + { + TextDirection[] directions = new TextDirection[clusters.Count]; + + for (int i = 0; i < clusters.Count; i++) + { + directions[i] = ClassifyCluster(clusters[i]); + } + + for (int i = 0; i < directions.Length; i++) + { + if (directions[i] == TextDirection.Neutral) + { + directions[i] = ResolveNeutralDirection(directions, i, baseDirection); + } + } + + List runs = new List(); + List currentClusters = new List(); + TextDirection currentDirection = directions[0]; + + for (int i = 0; i < clusters.Count; i++) + { + if (currentClusters.Count > 0 && directions[i] != currentDirection) + { + runs.Add(new DirectionalRun(currentDirection, currentClusters)); + currentClusters = new List(); + currentDirection = directions[i]; + } + + currentClusters.Add(clusters[i]); + } + + if (currentClusters.Count > 0) + { + runs.Add(new DirectionalRun(currentDirection, currentClusters)); + } + + return runs; + } + + static TextDirection ClassifyCluster(string cluster) + { + if (string.IsNullOrEmpty(cluster)) + { + return TextDirection.Neutral; + } + + for (int i = 0; i < cluster.Length; i++) + { + char chr = cluster[i]; + + if (IsRightToLeftChar(chr)) + { + return TextDirection.RightToLeft; + } + + if (char.IsDigit(chr) || IsLeftToRightLetter(chr)) + { + return TextDirection.LeftToRight; + } + } + + return TextDirection.Neutral; + } + + static TextDirection ResolveNeutralDirection(TextDirection[] directions, int index, TextDirection baseDirection) + { + TextDirection previousDirection = FindStrongDirection(directions, index - 1, -1); + TextDirection nextDirection = FindStrongDirection(directions, index + 1, 1); + + if (previousDirection != TextDirection.Neutral && previousDirection == nextDirection) + { + return previousDirection; + } + + if (previousDirection != TextDirection.Neutral && nextDirection == TextDirection.Neutral) + { + return previousDirection; + } + + if (nextDirection != TextDirection.Neutral && previousDirection == TextDirection.Neutral) + { + return nextDirection; + } + + return baseDirection == TextDirection.Neutral ? TextDirection.LeftToRight : baseDirection; + } + + static TextDirection FindStrongDirection(TextDirection[] directions, int index, int step) + { + while (index >= 0 && index < directions.Length) + { + if (directions[index] != TextDirection.Neutral) + { + return directions[index]; + } + + index += step; + } + + return TextDirection.Neutral; + } + + static List CreateClusters(string text) + { + List clusters = new List(); + StringBuilder cluster = new StringBuilder(); + + for (int i = 0; i < text.Length; i++) + { + char chr = text[i]; + + if (cluster.Length > 0 && IsTransparentChar(chr)) + { + cluster.Append(chr); + continue; + } + + if (cluster.Length > 0) + { + clusters.Add(cluster.ToString()); + cluster.Clear(); + } + + cluster.Append(chr); + } + + if (cluster.Length > 0) + { + clusters.Add(cluster.ToString()); + } + + return clusters; + } + + static string MirrorCluster(string cluster) + { + if (string.IsNullOrEmpty(cluster)) + { + return cluster; + } + + char chr = cluster[0]; + if (!MirroredChars.TryGetValue(chr, out char mirrored)) + { + return cluster; + } + + if (cluster.Length == 1) + { + return mirrored.ToString(); + } + + char[] chars = cluster.ToCharArray(); + chars[0] = mirrored; + return new string(chars); + } + + static string ShapeArabic(string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + char[] sourceChars = text.ToCharArray(); + StringBuilder builder = new StringBuilder(text.Length); + + for (int i = 0; i < sourceChars.Length; i++) + { + char currentChar = sourceChars[i]; + if (!ArabicPresentationForms.TryGetValue(currentChar, out ArabicForms currentForms)) + { + builder.Append(currentChar); + continue; + } + + int previousIndex = FindPreviousJoiningIndex(sourceChars, i - 1); + int nextIndex = FindNextJoiningIndex(sourceChars, i + 1); + + bool connectsToPrevious = + previousIndex >= 0 && + ArabicPresentationForms.TryGetValue(sourceChars[previousIndex], out ArabicForms previousForms) && + previousForms.ConnectsToNext && + currentForms.ConnectsToPrevious; + + if (currentChar == '\u0644' && TryGetLamAlefLigature(sourceChars, i, nextIndex, connectsToPrevious, out char ligature)) + { + builder.Append(ligature); + i = nextIndex; + continue; + } + + bool connectsToNext = + nextIndex >= 0 && + ArabicPresentationForms.TryGetValue(sourceChars[nextIndex], out ArabicForms nextForms) && + currentForms.ConnectsToNext && + nextForms.ConnectsToPrevious; + + builder.Append(currentForms.GetShapedChar(connectsToPrevious, connectsToNext)); + } + + return builder.ToString(); + } + + static bool TryGetLamAlefLigature(char[] chars, int lamIndex, int nextIndex, bool connectsToPrevious, out char ligature) + { + ligature = '\0'; + + if (nextIndex != lamIndex + 1) + { + return false; + } + + switch (chars[nextIndex]) + { + case '\u0622': + ligature = connectsToPrevious ? '\uFEF6' : '\uFEF5'; + return true; + case '\u0623': + ligature = connectsToPrevious ? '\uFEF8' : '\uFEF7'; + return true; + case '\u0625': + ligature = connectsToPrevious ? '\uFEFA' : '\uFEF9'; + return true; + case '\u0627': + ligature = connectsToPrevious ? '\uFEFC' : '\uFEFB'; + return true; + } + + return false; + } + + static int FindPreviousJoiningIndex(char[] chars, int index) + { + while (index >= 0) + { + if (!IsTransparentChar(chars[index])) + { + return index; + } + + index--; + } + + return -1; + } + + static int FindNextJoiningIndex(char[] chars, int index) + { + while (index < chars.Length) + { + if (!IsTransparentChar(chars[index])) + { + return index; + } + + index++; + } + + return -1; + } + + static TextDirection GetBaseDirection(string text) + { + for (int i = 0; i < text.Length; i++) + { + char chr = text[i]; + + if (IsRightToLeftChar(chr)) + { + return TextDirection.RightToLeft; + } + + if (IsLeftToRightLetter(chr)) + { + return TextDirection.LeftToRight; + } + } + + return TextDirection.Neutral; + } + + static bool ContainsRightToLeftText(string text) + { + for (int i = 0; i < text.Length; i++) + { + if (IsRightToLeftChar(text[i])) + { + return true; + } + } + + return false; + } + + static bool IsTransparentChar(char chr) + { + UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory(chr); + return category == UnicodeCategory.NonSpacingMark + || category == UnicodeCategory.SpacingCombiningMark + || category == UnicodeCategory.EnclosingMark; + } + + static bool IsLeftToRightLetter(char chr) + { + return char.IsLetter(chr) && !IsRightToLeftChar(chr); + } + + static bool ContainsPresentationForms(string text) + { + for (int i = 0; i < text.Length; i++) + { + char chr = text[i]; + if ((chr >= '\uFB50' && chr <= '\uFDFF') || (chr >= '\uFE70' && chr <= '\uFEFF')) + { + return true; + } + } + + return false; + } + + static bool ContainsDirectionalControlCharacters(string text) + { + for (int i = 0; i < text.Length; i++) + { + switch (text[i]) + { + case '\u200E': + case '\u200F': + case '\u202A': + case '\u202B': + case '\u202C': + case '\u202D': + case '\u202E': + case '\u2066': + case '\u2067': + case '\u2068': + case '\u2069': + return true; + } + } + + return false; + } + + static bool IsRightToLeftChar(char chr) + { + return + (chr >= '\u0590' && chr <= '\u08FF') || + (chr >= '\uFB1D' && chr <= '\uFDFF') || + (chr >= '\uFE70' && chr <= '\uFEFF'); + } + } +} diff --git a/Client/UI/Elements/Impl/Interactive/Controls/GuiElementHorizontalTabs.cs b/Client/UI/Elements/Impl/Interactive/Controls/GuiElementHorizontalTabs.cs index b5e0e713..f81680c8 100644 --- a/Client/UI/Elements/Impl/Interactive/Controls/GuiElementHorizontalTabs.cs +++ b/Client/UI/Elements/Impl/Interactive/Controls/GuiElementHorizontalTabs.cs @@ -98,21 +98,19 @@ public override void ComposeTextElements(Context ctxStatic, ImageSurface surface ImageSurface surface = new ImageSurface(Format.Argb32, (int)Bounds.InnerWidth + 1, (int)Bounds.InnerHeight + 1); Context ctx = new Context(surface); - Font.SetupContext(ctx); - - fontHeight = (float)Font.GetFontExtents().Height; + fontHeight = (float)Font.GetFontExtents(tabs.Length > 0 ? tabs[0].Name : null).Height; double radius = scaled(1); double spacing = scaled(unscaledTabSpacing); double padding = scaled(unscaledTabPadding); - totalWidth = 0; - - for (int i = 0; i < tabs.Length; i++) - { - tabWidths[i] = (int)(ctx.TextExtents(tabs[i].Name).Width + 2 * padding + 1); - totalWidth += spacing + tabWidths[i]; - } + totalWidth = 0; + + for (int i = 0; i < tabs.Length; i++) + { + tabWidths[i] = (int)(Font.GetTextExtents(tabs[i].Name).Width + 2 * padding + 1); + totalWidth += spacing + tabWidths[i]; + } ctx.Dispose(); surface.Dispose(); @@ -140,12 +138,12 @@ public override void ComposeTextElements(Context ctxStatic, ImageSurface surface ShadePath(ctx, 2); - if (AlarmTabs) - { - notifyFont.SetupContext(ctx); - } else { - Font.SetupContext(ctx); - } + if (AlarmTabs) + { + notifyFont.SetupContext(ctx, tabs[i].Name); + } else { + Font.SetupContext(ctx, tabs[i].Name); + } DrawTextLineAt(ctx, tabs[i].Name, xpos + padding, (surface.Height - fontHeight) / 2); @@ -212,13 +210,13 @@ private void ComposeOverlays(bool isNotifyTabs = false) ShadePath(ctx, 2); - if (isNotifyTabs) - { - notifyFont.SetupContext(ctx); - } else - { - selectedFont.SetupContext(ctx); - } + if (isNotifyTabs) + { + notifyFont.SetupContext(ctx, tabs[i].Name); + } else + { + selectedFont.SetupContext(ctx, tabs[i].Name); + } ctx.Operator = Operator.Clear; ctx.Rectangle(0, surface.Height - 1, surface.Width, 1); diff --git a/Client/UI/Elements/Impl/Interactive/Controls/GuiElementVerticalTabs.cs b/Client/UI/Elements/Impl/Interactive/Controls/GuiElementVerticalTabs.cs index dc21d6a8..ac1e3f7d 100644 --- a/Client/UI/Elements/Impl/Interactive/Controls/GuiElementVerticalTabs.cs +++ b/Client/UI/Elements/Impl/Interactive/Controls/GuiElementVerticalTabs.cs @@ -70,17 +70,16 @@ public override void ComposeTextElements(Context ctxStatic, ImageSurface surface double ypos = 0; - Font.Color[3] = 0.85; - Font.SetupContext(ctx); - textOffsetY = (tabHeight + 1 - Font.GetFontExtents().Height) / 2; + Font.Color[3] = 0.85; + textOffsetY = (tabHeight + 1 - Font.GetFontExtents(tabs.Length > 0 ? tabs[0].Name : null).Height) / 2; - double maxWidth = 0; - for (int i = 0; i < tabs.Length; i++) - { - double w = (int)(ctx.TextExtents(tabs[i].Name).Width + 1 + 2 * padding); - - maxWidth = Math.Max(w, maxWidth); - } + double maxWidth = 0; + for (int i = 0; i < tabs.Length; i++) + { + double w = (int)(Font.GetTextExtents(tabs[i].Name).Width + 1 + 2 * padding); + + maxWidth = Math.Max(w, maxWidth); + } for (int i = 0; i < tabs.Length; i++) @@ -121,7 +120,7 @@ public override void ComposeTextElements(Context ctxStatic, ImageSurface surface ShadePath(ctx, 2); - Font.SetupContext(ctx); + Font.SetupContext(ctx, tabs[i].Name); DrawTextLineAt(ctx, tabs[i].Name, xpos - (Right ? 0 : tabWidths[i]) + padding, ypos + textOffsetY); @@ -195,7 +194,7 @@ private void ComposeOverlays() ctx.Stroke(); - selectedFont.SetupContext(ctx); + selectedFont.SetupContext(ctx, tabs[i].Name); DrawTextLineAt(ctx, tabs[i].Name, padding+2, textOffsetY); @@ -383,4 +382,4 @@ public static GuiElementVerticalTabs GetVerticalTab(this GuiComposer composer, s return (GuiElementVerticalTabs)composer.GetElement(key); } } -} \ No newline at end of file +} diff --git a/Client/UI/TextDrawUtil.cs b/Client/UI/TextDrawUtil.cs index c1f57c6a..6cc4a4b1 100644 --- a/Client/UI/TextDrawUtil.cs +++ b/Client/UI/TextDrawUtil.cs @@ -95,11 +95,21 @@ public LineRectangled() : base() - public class TextDrawUtil - { - int caretPos = 0; - bool gotLinebreak = false; - bool gotSpace = false; + public class TextDrawUtil + { + int caretPos = 0; + bool gotLinebreak = false; + bool gotSpace = false; + + static double GetRenderedTextWidth(Context ctx, string text) + { + if (CairoGlyphLayout.TryCreateGlyphs(ctx, text, 0, 0, out Glyph[] glyphs)) + { + return ctx.GlyphExtents(glyphs).Width; + } + + return ctx.TextExtents(ComplexTextLayout.PrepareForRendering(text)).Width; + } #region Shorthand methods for simple box constrained multiline text @@ -198,13 +208,13 @@ public double GetLineHeight(CairoFont font) /// The path for the text. /// The height of the line /// The number of lines. - public int GetQuantityTextLines(CairoFont font, string text, EnumLinebreakBehavior linebreak, TextFlowPath[] flowPath, double lineY = 0) - { - if (text == null || text.Length == 0) return 0; - - ImageSurface surface = new ImageSurface(Format.Argb32, 1, 1); - Context ctx = new Context(surface); - font.SetupContext(ctx); + public int GetQuantityTextLines(CairoFont font, string text, EnumLinebreakBehavior linebreak, TextFlowPath[] flowPath, double lineY = 0) + { + if (text == null || text.Length == 0) return 0; + + ImageSurface surface = new ImageSurface(Format.Argb32, 1, 1); + Context ctx = new Context(surface); + font.SetupContext(ctx, text); int quantityLines = Lineize(ctx, text, linebreak, flowPath, 0, lineY, font.LineHeightMultiplier).Length; @@ -240,13 +250,13 @@ public double GetMultilineTextHeight(CairoFont font, string text, EnumLinebreakB /// The offset start position for Y /// /// The text broken up into lines. - public TextLine[] Lineize(CairoFont font, string fulltext, EnumLinebreakBehavior linebreak, TextFlowPath[] flowPath, double startOffsetX = 0, double startY = 0, bool keepLinebreakChar = false) - { - if (fulltext == null || fulltext.Length == 0) return Array.Empty(); - - ImageSurface surface = new ImageSurface(Format.Argb32, 1, 1); - Context ctx = new Context(surface); - font.SetupContext(ctx); + public TextLine[] Lineize(CairoFont font, string fulltext, EnumLinebreakBehavior linebreak, TextFlowPath[] flowPath, double startOffsetX = 0, double startY = 0, bool keepLinebreakChar = false) + { + if (fulltext == null || fulltext.Length == 0) return Array.Empty(); + + ImageSurface surface = new ImageSurface(Format.Argb32, 1, 1); + Context ctx = new Context(surface); + font.SetupContext(ctx, fulltext); TextLine[] textlines = Lineize(ctx, fulltext, linebreak, flowPath, startOffsetX, startY, font.LineHeightMultiplier, keepLinebreakChar); @@ -296,7 +306,7 @@ public TextLine[] Lineize(Context ctx, string text, EnumLinebreakBehavior linebr { string spc = (gotLinebreak || caretPos >= text.Length || !gotSpace ? "" : " "); - double nextWidth = ctx.TextExtents(lineTextBldr + word + spc).Width; + double nextWidth = GetRenderedTextWidth(ctx, lineTextBldr + word + spc); currentSection = GetCurrentFlowPathSection(flowPath, curY); @@ -321,7 +331,7 @@ public TextLine[] Lineize(Context ctx, string text, EnumLinebreakBehavior linebr while (word.Length > 0 && nextWidth >= usableWidth && tries-- > 0) { word = word.Substring(0, word.Length - 1); - nextWidth = ctx.TextExtents(lineTextBldr + word + spc).Width; + nextWidth = GetRenderedTextWidth(ctx, lineTextBldr + word + spc); caretPos--; } @@ -331,7 +341,7 @@ public TextLine[] Lineize(Context ctx, string text, EnumLinebreakBehavior linebr } string linetext = lineTextBldr.ToString(); - double withoutWidth = ctx.TextExtents(linetext).Width; + double withoutWidth = GetRenderedTextWidth(ctx, linetext); lines.Add(new TextLine() { @@ -361,7 +371,7 @@ public TextLine[] Lineize(Context ctx, string text, EnumLinebreakBehavior linebr { if (keepLinebreakChar) lineTextBldr.Append("\n"); string linetext = lineTextBldr.ToString(); - double withoutWidth = ctx.TextExtents(linetext).Width; + double withoutWidth = GetRenderedTextWidth(ctx, linetext); lines.Add(new TextLine() { @@ -384,7 +394,7 @@ public TextLine[] Lineize(Context ctx, string text, EnumLinebreakBehavior linebr if (currentSection == null) currentSection = new TextFlowPath(); usableWidth = currentSection.X2 - currentSection.X1 - curX; string lineTextStr = lineTextBldr.ToString(); - double endWidth = ctx.TextExtents(lineTextStr).Width; + double endWidth = GetRenderedTextWidth(ctx, lineTextStr); lines.Add(new TextLine() { @@ -486,11 +496,9 @@ public double AutobreakAndDrawMultilineText(Context ctx, CairoFont font, string /// The preformatted lines of the text. /// The font of the text /// The orientation of text (Default: Left) - public void DrawMultilineText(Context ctx, CairoFont font, TextLine[] lines, EnumTextOrientation orientation = EnumTextOrientation.Left) - { - font.SetupContext(ctx); - - double offsetX = 0; + public void DrawMultilineText(Context ctx, CairoFont font, TextLine[] lines, EnumTextOrientation orientation = EnumTextOrientation.Left) + { + double offsetX = 0; for (int i = 0; i < lines.Length; i++) { @@ -520,38 +528,73 @@ public void DrawMultilineText(Context ctx, CairoFont font, TextLine[] lines, Enu /// The X offset for the text start position. (Default: 0) /// The Y offset for the text start position. (Default: 0) /// Whether or not to use TextPathMode. - public void DrawTextLine(Context ctx, CairoFont font, string text, double offsetX = 0, double offsetY = 0, bool textPathMode = false) - { - if (text == null || text.Length == 0) return; - - ctx.MoveTo((int)(offsetX), (int)(offsetY + ctx.FontExtents.Ascent)); - - if (textPathMode) - { - ctx.TextPath(text); - } - else - { - if (font.StrokeWidth > 0) - { - ctx.TextPath(text); - ctx.LineWidth = font.StrokeWidth; - ctx.SetSourceRGBA(font.StrokeColor); - ctx.StrokePreserve(); + public void DrawTextLine(Context ctx, CairoFont font, string text, double offsetX = 0, double offsetY = 0, bool textPathMode = false) + { + font.SetupContext(ctx, text); + string renderedText = ComplexTextLayout.PrepareForRendering(text); + if (renderedText == null || renderedText.Length == 0) return; + + double baselineY = offsetY + ctx.FontExtents.Ascent; + + if (CairoGlyphLayout.TryCreateGlyphs(ctx, text, offsetX, baselineY, out Glyph[] glyphs)) + { + if (textPathMode) + { + ctx.GlyphPath(glyphs); + return; + } + + if (font.StrokeWidth > 0) + { + ctx.GlyphPath(glyphs); + ctx.LineWidth = font.StrokeWidth; + ctx.SetSourceRGBA(font.StrokeColor); + ctx.StrokePreserve(); + + ctx.SetSourceRGBA(font.Color); + ctx.Fill(); + } + else + { + ctx.ShowGlyphs(glyphs); + + if (font.RenderTwice) + { + ctx.ShowGlyphs(glyphs); + } + } + + return; + } + + ctx.MoveTo((int)(offsetX), (int)baselineY); + + if (textPathMode) + { + ctx.TextPath(renderedText); + } + else + { + if (font.StrokeWidth > 0) + { + ctx.TextPath(renderedText); + ctx.LineWidth = font.StrokeWidth; + ctx.SetSourceRGBA(font.StrokeColor); + ctx.StrokePreserve(); ctx.SetSourceRGBA(font.Color); ctx.Fill(); - } - else - { - ctx.ShowText(text); - - if (font.RenderTwice) - { - ctx.ShowText(text); - } - - } + } + else + { + ctx.ShowText(renderedText); + + if (font.RenderTwice) + { + ctx.ShowText(renderedText); + } + + } } } #endregion diff --git a/Client/UI/TextTextureUtil.cs b/Client/UI/TextTextureUtil.cs index 75c45d44..01c79c49 100644 --- a/Client/UI/TextTextureUtil.cs +++ b/Client/UI/TextTextureUtil.cs @@ -132,41 +132,68 @@ public LoadedTexture GenTextTexture(string text, CairoFont font, int width, int ctx.Stroke(); } - - - font.SetupContext(ctx); - - double fontHeight = font.GetFontExtents().Height; - - string[] lines = text.Split('\n'); - for (int i = 0; i < lines.Length; i++) - { - lines[i] = lines[i].TrimEnd(); - - ctx.MoveTo(background.HorPadding, background.VerPadding + ctx.FontExtents.Ascent + i * fontHeight); - - if (font.StrokeWidth > 0) - { - ctx.TextPath(lines[i]); - - ctx.LineWidth = font.StrokeWidth; - ctx.SetSourceRGBA(font.StrokeColor); + font.SetupContext(ctx, text); + + double fontHeight = font.GetFontExtents(text).Height; + + string[] lines = text.Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + lines[i] = lines[i].TrimEnd(); + font.SetupContext(ctx, lines[i]); + string renderedLine = ComplexTextLayout.PrepareForRendering(lines[i]); + double baselineY = background.VerPadding + ctx.FontExtents.Ascent + i * fontHeight; + + if (CairoGlyphLayout.TryCreateGlyphs(ctx, lines[i], background.HorPadding, baselineY, out Glyph[] glyphs)) + { + if (font.StrokeWidth > 0) + { + ctx.GlyphPath(glyphs); + + ctx.LineWidth = font.StrokeWidth; + ctx.SetSourceRGBA(font.StrokeColor); + ctx.StrokePreserve(); + + ctx.SetSourceRGBA(font.Color); + ctx.Fill(); + } + else + { + ctx.ShowGlyphs(glyphs); + + if (font.RenderTwice) + { + ctx.ShowGlyphs(glyphs); + } + } + + continue; + } + + ctx.MoveTo(background.HorPadding, baselineY); + + if (font.StrokeWidth > 0) + { + ctx.TextPath(renderedLine); + + ctx.LineWidth = font.StrokeWidth; + ctx.SetSourceRGBA(font.StrokeColor); ctx.StrokePreserve(); ctx.SetSourceRGBA(font.Color); ctx.Fill(); - } - else - { - - ctx.ShowText(lines[i]); - - if (font.RenderTwice) - { - ctx.ShowText(lines[i]); - } - - } + } + else + { + + ctx.ShowText(renderedLine); + + if (font.RenderTwice) + { + ctx.ShowText(renderedLine); + } + + } } int textureId = capi.Gui.LoadCairoTexture(surface, true);