// 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;
}
}
}