// RichTextKit // Copyright © 2019-2020 Topten Software. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this product except in compliance with the License. You may obtain // a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations // under the License. using HarfBuzzSharp; using SkiaSharp; using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Topten.RichTextKit.Utils; namespace Topten.RichTextKit { /// /// Helper class for shaping text /// internal class TextShaper : IDisposable { /// /// Cache of shapers for typefaces /// static Dictionary _shapers = new Dictionary(); /// /// Get the text shaper for a particular type face /// /// The typeface being queried for /// A TextShaper public static TextShaper ForTypeface(SKTypeface typeface) { lock (_shapers) { if (!_shapers.TryGetValue(typeface, out var shaper)) { shaper = new TextShaper(typeface); _shapers.Add(typeface, shaper); } return shaper; } } /// /// Constructs a new TextShaper /// /// The typeface of this shaper private TextShaper(SKTypeface typeface) { // Store typeface _typeface = typeface; // Load the typeface stream to a HarfBuzz font int index; using (var blob = GetHarfBuzzBlob(typeface.OpenStream(out index))) using (var face = new Face(blob, (uint)index)) { face.UnitsPerEm = typeface.UnitsPerEm; _font = new HarfBuzzSharp.Font(face); _font.SetScale(overScale, overScale); _font.SetFunctionsOpenType(); } // Get font metrics for this typeface using (var paint = new SKPaint()) { paint.Typeface = typeface; paint.TextSize = overScale; _fontMetrics = paint.FontMetrics; // This is a temporary hack until SkiaSharp exposes // a way to check if a font is fixed pitch. For now // we just measure and `i` and a `w` and see if they're // the same width. float[] widths = paint.GetGlyphWidths("iw", out var rects); _isFixedPitch = widths != null && widths.Length > 1 && widths[0] == widths[1]; if (_isFixedPitch) _fixedCharacterWidth = widths[0]; } } /// /// Dispose this text shaper /// public void Dispose() { if (_font != null) { _font.Dispose(); _font = null; } } /// /// The HarfBuzz font for this shaper /// HarfBuzzSharp.Font _font; /// /// The typeface for this shaper /// SKTypeface _typeface; /// /// Font metrics for the font /// SKFontMetrics _fontMetrics; /// /// True if this font face is fixed pitch /// bool _isFixedPitch; /// /// Fixed pitch character width /// float _fixedCharacterWidth; /// /// A set of re-usable result buffers to store the result of text shaping operation /// public class ResultBufferSet { public void Clear() { GlyphIndicies.Clear(); GlyphPositions.Clear(); Clusters.Clear(); CodePointXCoords.Clear(); } public Buffer GlyphIndicies = new Buffer(); public Buffer GlyphPositions = new Buffer(); public Buffer Clusters = new Buffer(); public Buffer CodePointXCoords = new Buffer(); } /// /// Returned as the result of a text shaping operation /// public struct Result { /// /// The glyph indicies of all glyphs required to render the shaped text /// public Slice GlyphIndicies; /// /// The position of each glyph /// public Slice GlyphPositions; /// /// One entry for each glyph, showing the code point index /// of the characters it was derived from /// public Slice Clusters; /// /// The end position of the rendered text /// public SKPoint EndXCoord; /// /// The X-Position of each passed code point /// public Slice CodePointXCoords; /// /// The ascent of the font /// public float Ascent; /// /// The descent of the font /// public float Descent; /// /// The leading of the font /// public float Leading; /// /// The XMin for the font /// public float XMin; } /// /// Over scale used for all font operations /// const int overScale = 512; /// /// Shape an array of utf-32 code points replacing each grapheme cluster with a replacement character /// /// A re-usable text shaping buffer set that results will be allocated from /// The utf-32 code points to be shaped /// The user style for the text /// A value to add to all reported cluster numbers /// A TextShaper.Result representing the shaped text public Result ShapeReplacement(ResultBufferSet bufferSet, Slice codePoints, IStyle style, int clusterAdjustment) { var clusters = GraphemeClusterAlgorithm.GetBoundaries(codePoints).ToArray(); var glyph = _typeface.GetGlyph(style.ReplacementCharacter); var font = new SKFont(_typeface, overScale); float glyphScale = style.FontSize / overScale; float[] widths = new float[1]; SKRect[] bounds = new SKRect[1]; font.GetGlyphWidths((new ushort[] { glyph }).AsSpan(), widths.AsSpan(), bounds.AsSpan()); var r = new Result(); r.GlyphIndicies = bufferSet.GlyphIndicies.Add((int)clusters.Length-1, false); r.GlyphPositions = bufferSet.GlyphPositions.Add((int)clusters.Length-1, false); r.Clusters = bufferSet.Clusters.Add((int)clusters.Length-1, false); r.CodePointXCoords = bufferSet.CodePointXCoords.Add(codePoints.Length, false); r.CodePointXCoords.Fill(0); float xCoord = 0; for (int i = 0; i < clusters.Length-1; i++) { r.GlyphPositions[i].X = xCoord * glyphScale; r.GlyphPositions[i].Y = 0; r.GlyphIndicies[i] = codePoints[clusters[i]] == 0x2029 ? (ushort)0 : glyph; r.Clusters[i] = clusters[i] + clusterAdjustment; for (int j = clusters[i]; j < clusters[i + 1]; j++) { r.CodePointXCoords[j] = r.GlyphPositions[i].X; } xCoord += widths[0] + style.LetterSpacing / glyphScale; } // Also return the end cursor position r.EndXCoord = new SKPoint(xCoord * glyphScale, 0); ApplyFontMetrics(ref r, style.FontSize); return r; } /// /// Shape an array of utf-32 code points /// /// A re-usable text shaping buffer set that results will be allocated from /// The utf-32 code points to be shaped /// The user style for the text /// LTR or RTL direction /// A value to add to all reported cluster numbers /// The type face this font is a fallback for /// The text alignment of the paragraph, used to control placement of glyphs within character cell when letter spacing used /// A TextShaper.Result representing the shaped text public Result Shape(ResultBufferSet bufferSet, Slice codePoints, IStyle style, TextDirection direction, int clusterAdjustment, SKTypeface asFallbackFor, TextAlignment textAlignment) { // Work out if we need to force this to a fixed pitch and if // so the unscale character width we need to use float forceFixedPitchWidth = 0; // ATZ: keep original shaper to set metrics on exit TextShaper originalTypefaceShaper = null; if (asFallbackFor != _typeface && asFallbackFor != null) { originalTypefaceShaper = ForTypeface(asFallbackFor); if (originalTypefaceShaper._isFixedPitch) { forceFixedPitchWidth = originalTypefaceShaper._fixedCharacterWidth; } } // Work out how much to shift glyphs in the character cell when using letter spacing // The idea here is to align the glyphs within the character cell the same way as the // text block alignment so that left/right aligned text still aligns with the margin // and centered text is still centered (and not shifted slightly due to the extra // space that would be at the right with normal letter spacing). float glyphLetterSpacingAdjustment = 0; switch (textAlignment) { case TextAlignment.Right: glyphLetterSpacingAdjustment = style.LetterSpacing; break; case TextAlignment.Center: glyphLetterSpacingAdjustment = style.LetterSpacing / 2; break; } using (var buffer = new HarfBuzzSharp.Buffer()) { // Setup buffer buffer.AddUtf32(codePoints.AsSpan(), 0, -1); // Setup directionality (if supplied) switch (direction) { case TextDirection.LTR: buffer.Direction = Direction.LeftToRight; break; case TextDirection.RTL: buffer.Direction = Direction.RightToLeft; break; default: throw new ArgumentException(nameof(direction)); } // Guess other attributes buffer.GuessSegmentProperties(); // Shape it _font.Shape(buffer); // RTL? bool rtl = buffer.Direction == Direction.RightToLeft; // Work out glyph scaling and offsetting for super/subscript float glyphScale = style.FontSize / overScale; float glyphVOffset = 0; if (style.FontVariant == FontVariant.SuperScript) { glyphScale *= 0.65f; glyphVOffset -= style.FontSize * 0.35f; } if (style.FontVariant == FontVariant.SubScript) { glyphScale *= 0.65f; glyphVOffset += style.FontSize * 0.1f; } // Create results and get buffes var r = new Result(); r.GlyphIndicies = bufferSet.GlyphIndicies.Add((int)buffer.Length, false); r.GlyphPositions = bufferSet.GlyphPositions.Add((int)buffer.Length, false); r.Clusters = bufferSet.Clusters.Add((int)buffer.Length, false); r.CodePointXCoords = bufferSet.CodePointXCoords.Add(codePoints.Length, false); r.CodePointXCoords.Fill(0); // Convert points var gp = buffer.GlyphPositions; var gi = buffer.GlyphInfos; float cursorX = 0; float cursorY = 0; float cursorXCluster = 0; for (int i = 0; i < buffer.Length; i++) { r.GlyphIndicies[i] = (ushort)gi[i].Codepoint; r.Clusters[i] = (int)gi[i].Cluster + clusterAdjustment; // Update code point positions if (!rtl) { // First cluster, different cluster, or same cluster with lower x-coord if ( i == 0 || (r.Clusters[i] != r.Clusters[i - 1]) || (cursorX < r.CodePointXCoords[r.Clusters[i] - clusterAdjustment])) { r.CodePointXCoords[r.Clusters[i] - clusterAdjustment] = cursorX; } } // Get the position var pos = gp[i]; // Update glyph position r.GlyphPositions[i] = new SKPoint( cursorX + pos.XOffset * glyphScale + glyphLetterSpacingAdjustment, cursorY - pos.YOffset * glyphScale + glyphVOffset ); // Update cursor position cursorX += pos.XAdvance * glyphScale; cursorY += pos.YAdvance * glyphScale; // Ensure paragraph separator character (0x2029) has some // width so it can be seen as part of the selection in the editor. if (pos.XAdvance == 0 && codePoints[(int)gi[i].Cluster] == 0x2029) { cursorX += style.FontSize * 2 / 3; } if (i+1 == gi.Length || gi[i].Cluster != gi[i+1].Cluster) { cursorX += style.LetterSpacing; } // Are we falling back for a fixed pitch font and is the next character a // new cluster? If so advance by the width of the original font, not this // fallback font if (forceFixedPitchWidth != 0) { // New cluster? if (i + 1 >= buffer.Length || gi[i].Cluster != gi[i + 1].Cluster) { // Work out fixed pitch position of next cluster cursorXCluster += forceFixedPitchWidth * glyphScale; if (cursorXCluster > cursorX) { // Nudge characters to center them in the fixed pitch width if (i == 0 || gi[i - 1].Cluster != gi[i].Cluster) { r.GlyphPositions[i].X += (cursorXCluster - cursorX)/ 2; } // Use fixed width character position cursorX = cursorXCluster; } else { // Character is wider (probably an emoji) so we // allow it to exceed the fixed pitch character width cursorXCluster = cursorX; } } } // Store RTL cursor position if (rtl) { // First cluster, different cluster, or same cluster with lower x-coord if (i == 0 || (r.Clusters[i] != r.Clusters[i - 1]) || (cursorX > r.CodePointXCoords[r.Clusters[i] - clusterAdjustment])) { r.CodePointXCoords[r.Clusters[i] - clusterAdjustment] = cursorX; } } } // Finalize cursor positions by filling in any that weren't // referenced by a cluster if (rtl) { r.CodePointXCoords[0] = cursorX; for (int i = codePoints.Length - 2; i >= 0; i--) { if (r.CodePointXCoords[i] == 0) r.CodePointXCoords[i] = r.CodePointXCoords[i + 1]; } } else { for (int i = 1; i < codePoints.Length; i++) { if (r.CodePointXCoords[i] == 0) r.CodePointXCoords[i] = r.CodePointXCoords[i - 1]; } } // Also return the end cursor position r.EndXCoord = new SKPoint(cursorX, cursorY); // And some other useful metrics // ATZ: we have to match original typeface metrics in case of font fallback (mimic gdi+ behavior) (originalTypefaceShaper ?? this).ApplyFontMetrics(ref r, style.FontSize); // Done return r; } } private void ApplyFontMetrics(ref Result result, float fontSize) { // And some other useful metrics result.Ascent = _fontMetrics.Ascent * fontSize / overScale; result.Descent = _fontMetrics.Descent * fontSize / overScale; result.Leading = _fontMetrics.Leading * fontSize / overScale; result.XMin = _fontMetrics.XMin * fontSize / overScale; } private static Blob GetHarfBuzzBlob(SKStreamAsset asset) { if (asset == null) throw new ArgumentNullException(nameof(asset)); Blob blob; var size = asset.Length; var memoryBase = asset.GetMemoryBase(); if (memoryBase != IntPtr.Zero) { // the underlying stream is really a mamory block // so save on copying and just use that directly blob = new Blob(memoryBase, size, MemoryMode.ReadOnly, () => asset.Dispose()); } else { // this could be a forward-only stream, so we must copy var ptr = Marshal.AllocCoTaskMem(size); asset.Read(ptr, size); blob = new Blob(ptr, size, MemoryMode.ReadOnly, () => Marshal.FreeCoTaskMem(ptr)); } // make immutable for performance? blob.MakeImmutable(); return blob; } } }