Skip to content

Commit

Permalink
Merge pull request #197 from SixLabors/js/word-break
Browse files Browse the repository at this point in the history
Support Word Breaking.
  • Loading branch information
JimBobSquarePants authored Aug 30, 2021
2 parents 5bb6324 + 91c8068 commit 998a0da
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 56 deletions.
2 changes: 1 addition & 1 deletion samples/DrawWithImageSharp/DrawWithImageSharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta13.3" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta13.7" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>

Expand Down
18 changes: 18 additions & 0 deletions src/SixLabors.Fonts/GlyphLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ internal GlyphLayout(
/// <returns>The <see cref="bool"/>.</returns>
public bool IsWhiteSpace() => CodePoint.IsWhiteSpace(this.CodePoint);

/// <summary>
/// Create a new <see cref="GlyphLayout"/> from the given layout offset by the specifid amount.
/// </summary>
/// <param name="glyphLayout">The glyph layout.</param>
/// <param name="offset">The vector to offset the layout by.</param>
/// <param name="startOfLine">Whether the glyph should be considered to fall at the start of a line.</param>
/// <returns>The <see cref="GlyphLayout"/>.</returns>
public static GlyphLayout Offset(GlyphLayout glyphLayout, Vector2 offset, bool startOfLine)
=> new GlyphLayout(
glyphLayout.GraphemeIndex,
glyphLayout.CodePoint,
glyphLayout.Glyph,
glyphLayout.Location + offset,
glyphLayout.Width,
glyphLayout.Height,
glyphLayout.LineHeight,
startOfLine);

internal FontRectangle BoundingBox(Vector2 dpi)
{
FontRectangle box = this.Glyph.BoundingBox(this.Location * dpi, dpi);
Expand Down
4 changes: 2 additions & 2 deletions src/SixLabors.Fonts/GlyphMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ internal GlyphMetrics(
GlyphColor? glyphColor = null)
{
this.FontMetrics = font;
this.Codepoint = codePoint;
this.CodePoint = codePoint;
this.UnitsPerEm = unitsPerEM;
this.vector = vector;

Expand All @@ -56,7 +56,7 @@ internal GlyphMetrics(
/// <summary>
/// Gets the Unicode codepoint of the glyph.
/// </summary>
public CodePoint Codepoint { get; }
public CodePoint CodePoint { get; }

/// <summary>
/// Gets the advance width for horizontal layout, expressed in font units.
Expand Down
11 changes: 8 additions & 3 deletions src/SixLabors.Fonts/RendererOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,16 @@ public RendererOptions(Font font, float dpiX, float dpiY, Vector2 origin)
/// <summary>
/// Gets or sets the width relative to the current DPI at which text will automatically wrap onto a newline
/// </summary>
/// <value>
/// if value is -1 then wrapping is disabled.
/// </value>
/// <remarks>
/// If value is -1 then wrapping is disabled.
/// </remarks>
public float WrappingWidth { get; set; } = -1;

/// <summary>
/// Gets or sets the word breaking mode to use when wrapping text.
/// </summary>
public WordBreaking WordBreaking { get; set; }

/// <summary>
/// Gets or sets the line spacing. Applied as a multiple of the line height.
/// </summary>
Expand Down
87 changes: 43 additions & 44 deletions src/SixLabors.Fonts/TextLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
float lineMaxAscender = 0f;
float lineMaxDescender = 0f;
Vector2 location = Vector2.Zero;
float lineHeightOfFirstLine = 0;

// Remember where the top of the layouted text is for accurate vertical alignment.
// This is important because there is considerable space between the lineHeight at the glyph's ascender.
Expand All @@ -79,6 +78,9 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
int lastWrappableLocation = -1;
int nextWrappableLocation = codePointCount;
bool nextWrappableRequired = false;
bool shouldWrap = options.WrappingWidth > 0;
bool breakAll = options.WordBreaking == WordBreaking.BreakAll;
bool keepAll = options.WordBreaking == WordBreaking.KeepAll;
bool startOfLine = true;
float totalHeight = 0;

Expand All @@ -95,11 +97,11 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
int codePointIndex = 0;

// Enumerate through each grapheme in the text.
// TODO: In the future we can use word-break settings to split graphemes when required.
var graphemeEnumerator = new SpanGraphemeEnumerator(text);
for (graphemeIndex = 0; graphemeEnumerator.MoveNext(); graphemeIndex++)
{
// Now enumerate through each codepoint in the grapheme.
int graphemeCodePointIndex = 0;
var codePointEnumerator = new SpanCodePointEnumerator(graphemeEnumerator.Current);
while (codePointEnumerator.MoveNext())
{
Expand Down Expand Up @@ -149,13 +151,7 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render

if (firstLine)
{
// Reset the line height for the first line to prevent initial lead.
float unspacedLineHeight = lineHeight / options.LineSpacing;
if (unspacedLineHeight > lineHeightOfFirstLine)
{
lineHeightOfFirstLine = unspacedLineHeight;
}

// Set the position for the first line.
switch (options.VerticalAlignment)
{
case VerticalAlignment.Top:
Expand All @@ -170,19 +166,29 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
}
}

if ((options.WrappingWidth > 0 && nextWrappableLocation == codePointIndex) || nextWrappableRequired)
// Keep a record of where to wrap text and ensure that no line starts with white space
if ((shouldWrap && (breakAll || nextWrappableLocation == codePointIndex))
|| nextWrappableRequired)
{
// Keep a record of where to wrap text and ensure that no line starts with white space
for (int j = layout.Count - 1; j >= 0; j--)
if (!(keepAll && UnicodeUtility.IsCJKCodePoint((uint)glyph.CodePoint.Value)))
{
if (!layout[j].IsWhiteSpace())
// We don't want to ever break between codepoints within a grapheme.
if (graphemeCodePointIndex == 0)
{
lastWrappableLocation = j + 1;
break;
for (int j = layout.Count - 1; j >= 0; j--)
{
GlyphLayout item = layout[j];
if (!item.IsWhiteSpace())
{
lastWrappableLocation = j + 1;
break;
}
}
}
}
}

// Find the next line break.
if (nextWrappableLocation == codePointIndex && lineBreakEnumerator.MoveNext())
{
LineBreak b = lineBreakEnumerator.Current;
Expand All @@ -198,20 +204,27 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
Vector2 glyphLocation = location;
if (spanStyle.ApplyKerning && previousGlyph != null)
{
// if there is special instructions for this glyph pair use that width
// If there is special instructions for this glyph pair use that width
Vector2 scaledOffset = spanStyle.GetOffset(glyph, previousGlyph) * spanStyle.PointSize / scale;

glyphLocation += scaledOffset;

// only fix the 'X' of the current tracked location but use the actual 'X'/'Y' of the offset
// Only fix the 'X' of the current tracked location but use the actual 'X'/'Y' of the offset
location.X = glyphLocation.X;
}

foreach (GlyphMetrics? g in glyphs)
{
float w = g.AdvanceWidth * spanStyle.PointSize / scale;
float h = g.AdvanceHeight * spanStyle.PointSize / scale;
layout.Add(new GlyphLayout(graphemeIndex, codePoint, new Glyph(g, spanStyle.PointSize), glyphLocation, w, h, lineHeight, startOfLine));
layout.Add(new GlyphLayout(
graphemeIndex,
codePoint,
new Glyph(g, spanStyle.PointSize),
glyphLocation,
w,
h,
lineHeight,
startOfLine));

if (w > glyphWidth)
{
Expand All @@ -224,8 +237,11 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
// Move forward the actual width of the glyph, we are retaining the baseline
location.X += glyphWidth;

// If the word extended pass the end of the box, wrap it
if (location.X >= maxWidth && lastWrappableLocation > 0
// If the word extended pass the end of the box, wrap it.
// We don't want to ever break between codepoints within a grapheme.
if (graphemeCodePointIndex == 0
&& location.X >= maxWidth
&& lastWrappableLocation > 0
&& lastWrappableLocation < layout.Count)
{
float wrappingOffset = layout[lastWrappableLocation].Location.X;
Expand All @@ -243,18 +259,8 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
}

GlyphLayout current = layout[j];
var wrapped = new GlyphLayout(
current.GraphemeIndex,
current.CodePoint,
current.Glyph,
new Vector2(current.Location.X - wrappingOffset, current.Location.Y + lineHeight),
current.Width,
current.Height,
current.LineHeight,
startOfLine);

var wrapped = GlyphLayout.Offset(current, new Vector2(-wrappingOffset, lineHeight), startOfLine);
startOfLine = false;

location.X = wrapped.Location.X + wrapped.Width;
layout[j] = wrapped;
}
Expand Down Expand Up @@ -360,6 +366,7 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
}

codePointIndex++;
graphemeCodePointIndex++;
}
}

Expand Down Expand Up @@ -390,14 +397,15 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
int currentGraphemeIndex = current.GraphemeIndex;
if (current.StartOfLine && (currentGraphemeIndex != graphemeIndex))
{
// Leading graphemes are made up of multiple glyphs all marked as 'StartOfLine so we only
// Leading graphemes can be made up of multiple glyphs all marked as 'StartOfLine so we only
// break when we are sure we have entered a new cluster or previously defined break.
break;
}

width = current.Location.X + current.Width;
width = Math.Max(width, current.Location.X + current.Width);
}

// Calculate an offset from the 'origin' based on TextAlignment for each line
switch (options.HorizontalAlignment)
{
case HorizontalAlignment.Left:
Expand All @@ -412,16 +420,7 @@ public IReadOnlyList<GlyphLayout> GenerateLayout(ReadOnlySpan<char> text, Render
}
}

// TODO: calculate an offset from the 'origin' based on TextAlignment for each line
layout[i] = new GlyphLayout(
glyphLayout.GraphemeIndex,
glyphLayout.CodePoint,
glyphLayout.Glyph,
glyphLayout.Location + offsetX + origin,
glyphLayout.Width,
glyphLayout.Height,
glyphLayout.LineHeight,
glyphLayout.StartOfLine);
layout[i] = GlyphLayout.Offset(glyphLayout, offsetX + origin, glyphLayout.StartOfLine);
}

return layout;
Expand Down
4 changes: 2 additions & 2 deletions src/SixLabors.Fonts/TextMeasurer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ internal static FontRectangle GetSize(IReadOnlyList<GlyphLayout> glyphLayouts, V
float left = glyphLayouts.Min(x => x.Location.X);
float right = glyphLayouts.Max(x => x.Location.X + x.Width);

// location is bottom left of the line
// Location is bottom left of the line. We offset the bottom by the top to handle the ascender
float top = glyphLayouts.Min(x => x.Location.Y - x.LineHeight);
float bottom = glyphLayouts.Max(x => x.Location.Y - x.LineHeight + x.Height);
float bottom = glyphLayouts.Max(x => x.Location.Y - x.LineHeight + x.Height) - top;

Vector2 topLeft = new Vector2(left, top) * dpi;
Vector2 bottomRight = new Vector2(right, bottom) * dpi;
Expand Down
95 changes: 95 additions & 0 deletions src/SixLabors.Fonts/Unicode/UnicodeUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,101 @@ internal static class UnicodeUtility
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsBmpCodePoint(uint value) => value <= 0xFFFFu;

/// <summary>
/// Returns <see langword="true"/> if <paramref name="value"/> is a
/// Chinese/Japanese/Korean (CJK) character.
/// </summary>
/// <remarks>
/// <see href="https://blog.ceshine.net/post/cjk-unicode/"/>
/// <see href="https://en.wikipedia.org/wiki/Hiragana_%28Unicode_block%29"/>
/// <see href="https://en.wikipedia.org/wiki/Katakana_(Unicode_block)"/>
/// <see href="https://en.wikipedia.org/wiki/Hangul_Syllables"/>
/// <see href="https://en.wikipedia.org/wiki/CJK_Unified_Ideographs"/>
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsCJKCodePoint(uint value)
{
// Hiragana
if (IsInRangeInclusive(value, 0x3040u, 0x309Fu))
{
return true;
}

// Katakana
if (IsInRangeInclusive(value, 0x30A0u, 0x30FFu))
{
return true;
}

// Hangul Syllables
if (IsInRangeInclusive(value, 0xAC00u, 0xD7A3u))
{
return true;
}

// CJK Unified Ideographs
if (IsInRangeInclusive(value, 0x4E00u, 0x9FFFu))
{
return true;
}

// CJK Unified Ideographs Extension A
if (IsInRangeInclusive(value, 0x3400u, 0x4DBFu))
{
return true;
}

// CJK Unified Ideographs Extension B
if (IsInRangeInclusive(value, 0x20000u, 0x2A6DFu))
{
return true;
}

// CJK Unified Ideographs Extension C
if (IsInRangeInclusive(value, 0x2A700u, 0x2B73Fu))
{
return true;
}

// CJK Unified Ideographs Extension D
if (IsInRangeInclusive(value, 0x2B740u, 0x2B81Fu))
{
return true;
}

// CJK Unified Ideographs Extension E
if (IsInRangeInclusive(value, 0x2B820u, 0x2CEAFu))
{
return true;
}

// CJK Unified Ideographs Extension F
if (IsInRangeInclusive(value, 0x2CEB0u, 0x2EBEFu))
{
return true;
}

// CJK Unified Ideographs Extension G
if (IsInRangeInclusive(value, 0x30000u, 0x3134Fu))
{
return true;
}

// CJK Compatibility Ideographs
if (IsInRangeInclusive(value, 0xF900u, 0xFAFFu))
{
return true;
}

// CJK Compatibility Ideographs Supplement
if (IsInRangeInclusive(value, 0x2F800u, 0x2FA1Fu))
{
return true;
}

return false;
}

/// <summary>
/// Returns the Unicode plane (0 through 16, inclusive) which contains this code point.
/// </summary>
Expand Down
Loading

0 comments on commit 998a0da

Please sign in to comment.