Skip to content

Commit 7b19f39

Browse files
Merge pull request #271 from SixLabors/js/vertical-mixed
Support Vertical Mixed Text
2 parents f72bd7a + dbb1ef6 commit 7b19f39

22 files changed

+253
-62
lines changed

src/ImageSharp.Drawing/ImageSharp.Drawing.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<None Include="..\..\shared-infrastructure\branding\icons\imagesharp.drawing\sixlabors.imagesharp.drawing.128.png" Pack="true" PackagePath="" />
1919
</ItemGroup>
2020
<ItemGroup>
21-
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta19.10" />
21+
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta19.13" />
2222
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
2323
</ItemGroup>
2424
<Import Project="..\..\shared-infrastructure\src\SharedInfrastructure\SharedInfrastructure.projitems" Label="Shared" />

src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.cs

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ internal sealed class RichTextGlyphRenderer : BaseGlyphBuilder, IColorGlyphRende
2424
private const byte RenderOrderOutline = 1;
2525
private const byte RenderOrderDecoration = 2;
2626

27-
private readonly RichTextOptions textOptions;
2827
private readonly DrawingOptions drawingOptions;
2928
private readonly MemoryAllocator memoryAllocator;
3029
private readonly Pen defaultPen;
@@ -40,6 +39,7 @@ internal sealed class RichTextGlyphRenderer : BaseGlyphBuilder, IColorGlyphRende
4039
private TextDecorationDetails? currentUnderline;
4140
private TextDecorationDetails? currentStrikout;
4241
private TextDecorationDetails? currentOverline;
42+
private bool currentDecorationRotated;
4343

4444
// Just enough accuracy to allow for 1/8 px differences which later are accumulated while rendering,
4545
// but do not grow into full px offsets.
@@ -60,7 +60,6 @@ public RichTextGlyphRenderer(
6060
Brush brush)
6161
: base(drawingOptions.Transform)
6262
{
63-
this.textOptions = textOptions;
6463
this.drawingOptions = drawingOptions;
6564
this.memoryAllocator = memoryAllocator;
6665
this.defaultPen = pen;
@@ -101,7 +100,7 @@ protected override void BeginText(in FontRectangle bounds)
101100
protected override void BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
102101
{
103102
this.currentColor = null;
104-
103+
this.currentDecorationRotated = parameters.LayoutMode.IsVertical() || parameters.LayoutMode.IsVerticalMixed();
105104
this.currentTextRun = parameters.TextRun;
106105
if (parameters.TextRun is RichTextRun drawingRun)
107106
{
@@ -219,41 +218,40 @@ public override void SetDecoration(TextDecorations textDecorations, Vector2 star
219218
}
220219
}
221220

222-
// Clamp the line to whole pixels
223-
Vector2 pad = new(0, thickness * .5F);
224-
Vector2 tl = start - pad;
225-
Vector2 bl = start + pad;
226-
Vector2 tr = end - pad;
227-
228-
tl = ClampToPixel(tl);
229-
bl = ClampToPixel(bl);
230-
tr = ClampToPixel(tr);
231-
232221
// Always respect the pen stroke width if explicitly set.
233-
if (pen is null)
222+
if (pen is not null)
234223
{
235-
thickness = bl.Y - tl.Y;
236-
pen = new SolidPen(this.currentBrush ?? this.defaultBrush, thickness);
224+
thickness = pen.StrokeWidth;
237225
}
238226
else
239227
{
240-
thickness = pen.StrokeWidth;
228+
// Clamp the thickness to whole pixels.
229+
thickness = MathF.Max(1F, MathF.Round(thickness));
230+
pen = new SolidPen(this.currentBrush ?? this.defaultBrush, thickness);
241231
}
242232

243233
// Drawing is always centered around the point so we need to offset by half.
244234
Vector2 offset = Vector2.Zero;
235+
bool rotated = this.currentDecorationRotated;
245236
if (textDecorations == TextDecorations.Overline)
246237
{
247238
// CSS overline is drawn above the position, so we need to move it up.
248-
offset = new Vector2(0, -(thickness * .5F));
239+
offset = rotated ? new(thickness * .5F, 0) : new(0, -(thickness * .5F));
249240
}
250241
else if (textDecorations == TextDecorations.Underline)
251242
{
252243
// CSS underline is drawn below the position, so we need to move it down.
253-
offset = new Vector2(0, thickness * .5F);
244+
offset = rotated ? new(-(thickness * .5F), 0) : new(0, thickness * .5F);
254245
}
255246

256-
this.AppendDecoration(ref targetDecoration, tl + offset, tr + offset, pen, thickness);
247+
// We clamp the start and end points to the pixel grid to avoid anti-aliasing.
248+
this.AppendDecoration(
249+
ref targetDecoration,
250+
ClampToPixel(start + offset, (int)thickness, rotated),
251+
ClampToPixel(end + offset, (int)thickness, rotated),
252+
pen,
253+
thickness,
254+
rotated);
257255
}
258256

259257
protected override void EndGlyph()
@@ -395,6 +393,24 @@ protected override void EndText()
395393
[MethodImpl(MethodImplOptions.AggressiveInlining)]
396394
private static Point ClampToPixel(PointF point) => Point.Truncate(point);
397395

396+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
397+
private static PointF ClampToPixel(PointF point, int thickness, bool rotated)
398+
{
399+
// Even. Clamp to whole pixels.
400+
if ((thickness & 1) == 0)
401+
{
402+
return Point.Truncate(point);
403+
}
404+
405+
// Odd. Clamp to half pixels.
406+
if (rotated)
407+
{
408+
return Point.Truncate(point) + new Vector2(.5F, 0);
409+
}
410+
411+
return Point.Truncate(point) + new Vector2(0, .5F);
412+
}
413+
398414
// Point.Truncate(point);
399415
private void FinalizeDecoration(ref TextDecorationDetails? decoration)
400416
{
@@ -428,24 +444,47 @@ private void FinalizeDecoration(ref TextDecorationDetails? decoration)
428444
}
429445
}
430446

431-
private void AppendDecoration(ref TextDecorationDetails? decoration, Vector2 start, Vector2 end, Pen pen, float thickness)
447+
private void AppendDecoration(
448+
ref TextDecorationDetails? decoration,
449+
Vector2 start,
450+
Vector2 end,
451+
Pen pen,
452+
float thickness,
453+
bool rotated)
432454
{
433455
if (decoration != null)
434456
{
435457
// TODO: This only works well if we are not trying to follow a path.
436458
if (this.path is null)
437459
{
438460
// Let's try and expand it first.
439-
if (thickness == decoration.Value.Thickness
440-
&& decoration.Value.End.Y == start.Y
441-
&& (decoration.Value.End.X + 1) >= start.X
461+
if (rotated)
462+
{
463+
if (thickness == decoration.Value.Thickness
464+
&& decoration.Value.End.Y + 1 >= start.Y
465+
&& decoration.Value.End.X == start.X
442466
&& decoration.Value.Pen.Equals(pen))
467+
{
468+
// Expand the line
469+
start = decoration.Value.Start;
470+
471+
// If this is null finalize does nothing.
472+
decoration = null;
473+
}
474+
}
475+
else
443476
{
444-
// Expand the line
445-
start = decoration.Value.Start;
477+
if (thickness == decoration.Value.Thickness
478+
&& decoration.Value.End.Y == start.Y
479+
&& decoration.Value.End.X + 1 >= start.X
480+
&& decoration.Value.Pen.Equals(pen))
481+
{
482+
// Expand the line
483+
start = decoration.Value.Start;
446484

447-
// If this is null finalize does nothing.
448-
decoration = null;
485+
// If this is null finalize does nothing.
486+
decoration = null;
487+
}
449488
}
450489
}
451490
}
@@ -456,7 +495,7 @@ private void AppendDecoration(ref TextDecorationDetails? decoration, Vector2 sta
456495
Start = start,
457496
End = end,
458497
Pen = pen,
459-
Thickness = MathF.Abs(thickness)
498+
Thickness = thickness
460499
};
461500
}
462501

src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Copyright (c) Six Labors.
22
// Licensed under the Apache License, Version 2.0.
33

4+
using System;
45
using System.Collections.Generic;
56
using System.Numerics;
7+
using System.Runtime.CompilerServices;
68
using SixLabors.Fonts;
79

810
namespace SixLabors.ImageSharp.Drawing.Text
@@ -145,33 +147,65 @@ public virtual void SetDecoration(TextDecorations textDecorations, Vector2 start
145147
return;
146148
}
147149

150+
thickness = MathF.Max(1F, (float)Math.Round(thickness));
148151
var renderer = (IGlyphRenderer)this;
149152

150-
Vector2 height = new(0, thickness);
151-
Vector2 tl = start;
152-
Vector2 tr = end;
153-
Vector2 bl = start + height;
154-
Vector2 br = end + height;
153+
// Expand the points to create a rectangle centered around the line.
154+
bool rotated = this.parameters.LayoutMode.IsVertical() || this.parameters.LayoutMode.IsVerticalMixed();
155+
Vector2 pad = rotated ? new(thickness * .5F, 0) : new(0, thickness * .5F);
156+
157+
// Clamp the line to the pixel grid.
158+
start = ClampToPixel(start, (int)thickness, rotated);
159+
end = ClampToPixel(end, (int)thickness, rotated);
160+
161+
// Offset to create the rectangle.
162+
Vector2 a = start - pad;
163+
Vector2 b = start + pad;
164+
Vector2 c = end + pad;
165+
Vector2 d = end - pad;
155166

156167
// Drawing is always centered around the point so we need to offset by half.
157168
Vector2 offset = Vector2.Zero;
158169
if (textDecorations == TextDecorations.Overline)
159170
{
160171
// CSS overline is drawn above the position, so we need to move it up.
161-
offset = new(0, -(thickness * .5F));
172+
offset = rotated ? new(thickness * .5F, 0) : new(0, -(thickness * .5F));
162173
}
163174
else if (textDecorations == TextDecorations.Underline)
164175
{
165176
// CSS underline is drawn below the position, so we need to move it down.
166-
offset = new Vector2(0, thickness * .5F);
177+
offset = rotated ? new(-(thickness * .5F), 0) : new(0, thickness * .5F);
167178
}
168179

169-
// MoveTo calls StartFigure();
170-
renderer.MoveTo(tl + offset);
171-
renderer.LineTo(bl + offset);
172-
renderer.LineTo(br + offset);
173-
renderer.LineTo(tr + offset);
180+
renderer.BeginFigure();
181+
182+
// Now draw the rectangle clamped to the pixel grid.
183+
renderer.MoveTo(ClampToPixel(a + offset));
184+
renderer.LineTo(ClampToPixel(b + offset));
185+
renderer.LineTo(ClampToPixel(c + offset));
186+
renderer.LineTo(ClampToPixel(d + offset));
174187
renderer.EndFigure();
175188
}
189+
190+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
191+
private static Point ClampToPixel(PointF point) => Point.Truncate(point);
192+
193+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
194+
private static PointF ClampToPixel(PointF point, int thickness, bool rotated)
195+
{
196+
// Even. Clamp to whole pixels.
197+
if ((thickness & 1) == 0)
198+
{
199+
return Point.Truncate(point);
200+
}
201+
202+
// Odd. Clamp to half pixels.
203+
if (rotated)
204+
{
205+
return Point.Truncate(point) + new Vector2(.5F, 0);
206+
}
207+
208+
return Point.Truncate(point) + new Vector2(0, .5F);
209+
}
176210
}
177211
}

tests/ImageSharp.Drawing.Tests/Drawing/Text/DrawTextOnImageTests.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,105 @@ public void PathAndTextDrawingMatch<TPixel>(TestImageProvider<TPixel> provider)
792792
});
793793
}
794794

795+
[Theory]
796+
[WithBlankImage(500, 400, PixelTypes.Rgba32)]
797+
public void CanFillTextVertical<TPixel>(TestImageProvider<TPixel> provider)
798+
where TPixel : unmanaged, IPixel<TPixel>
799+
{
800+
Font font = CreateFont(TestFonts.OpenSans, 36);
801+
Font fallback = CreateFont(TestFonts.NotoSansKRRegular, 36);
802+
803+
const string text = "한국어 hangugeo 한국어 hangugeo 한국어 hangugeo 한국어 hangugeo 한국어 hangugeo";
804+
RichTextOptions textOptions = new(font)
805+
{
806+
Origin = new(0, 0),
807+
FallbackFontFamilies = new[] { fallback.Family },
808+
WrappingLength = 300,
809+
LayoutMode = LayoutMode.VerticalLeftRight,
810+
TextRuns = new[] { new RichTextRun() { Start = 0, End = text.GetGraphemeCount(), TextDecorations = TextDecorations.Underline | TextDecorations.Strikeout | TextDecorations.Overline } }
811+
};
812+
813+
IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, textOptions);
814+
815+
// TODO: This still leaves some holes when overlaying the text (CFF NotoSansKRRegular only). We need to fix this.
816+
DrawingOptions options = new() { ShapeOptions = new() { IntersectionRule = IntersectionRule.NonZero } };
817+
818+
provider.RunValidatingProcessorTest(
819+
c => c.Fill(Color.White).Fill(options, Color.Black, glyphs),
820+
comparer: ImageComparer.TolerantPercentage(0.002f));
821+
}
822+
823+
[Theory]
824+
[WithBlankImage(500, 400, PixelTypes.Rgba32)]
825+
public void CanFillTextVerticalMixed<TPixel>(TestImageProvider<TPixel> provider)
826+
where TPixel : unmanaged, IPixel<TPixel>
827+
{
828+
Font font = CreateFont(TestFonts.OpenSans, 36);
829+
Font fallback = CreateFont(TestFonts.NotoSansKRRegular, 36);
830+
831+
const string text = "한국어 hangugeo 한국어 hangugeo 한국어 hangugeo 한국어 hangugeo 한국어 hangugeo";
832+
RichTextOptions textOptions = new(font)
833+
{
834+
FallbackFontFamilies = new[] { fallback.Family },
835+
WrappingLength = 400,
836+
LayoutMode = LayoutMode.VerticalMixedLeftRight,
837+
TextRuns = new[] { new RichTextRun() { Start = 0, End = text.GetGraphemeCount(), TextDecorations = TextDecorations.Underline | TextDecorations.Strikeout | TextDecorations.Overline } }
838+
};
839+
840+
IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, textOptions);
841+
842+
// TODO: This still leaves some holes when overlaying the text (CFF NotoSansKRRegular only). We need to fix this.
843+
DrawingOptions options = new() { ShapeOptions = new() { IntersectionRule = IntersectionRule.NonZero } };
844+
845+
provider.RunValidatingProcessorTest(
846+
c => c.Fill(Color.White).Fill(options, Color.Black, glyphs),
847+
comparer: ImageComparer.TolerantPercentage(0.002f));
848+
}
849+
850+
[Theory]
851+
[WithBlankImage(500, 400, PixelTypes.Rgba32)]
852+
public void CanDrawTextVertical<TPixel>(TestImageProvider<TPixel> provider)
853+
where TPixel : unmanaged, IPixel<TPixel>
854+
{
855+
Font font = CreateFont(TestFonts.OpenSans, 36);
856+
Font fallback = CreateFont(TestFonts.NotoSansKRRegular, 36);
857+
858+
const string text = "한국어 hangugeo 한국어 hangugeo 한국어 hangugeo 한국어 hangugeo 한국어 hangugeo";
859+
RichTextOptions textOptions = new(font)
860+
{
861+
FallbackFontFamilies = new[] { fallback.Family },
862+
WrappingLength = 400,
863+
LayoutMode = LayoutMode.VerticalLeftRight,
864+
TextRuns = new[] { new RichTextRun() { Start = 0, End = text.GetGraphemeCount(), TextDecorations = TextDecorations.Underline | TextDecorations.Strikeout | TextDecorations.Overline } }
865+
};
866+
867+
provider.RunValidatingProcessorTest(
868+
c => c.Fill(Color.White).DrawText(textOptions, text, Brushes.Solid(Color.Black)),
869+
comparer: ImageComparer.TolerantPercentage(0.002f));
870+
}
871+
872+
[Theory]
873+
[WithBlankImage(500, 400, PixelTypes.Rgba32)]
874+
public void CanDrawTextVerticalMixed<TPixel>(TestImageProvider<TPixel> provider)
875+
where TPixel : unmanaged, IPixel<TPixel>
876+
{
877+
Font font = CreateFont(TestFonts.OpenSans, 36);
878+
Font fallback = CreateFont(TestFonts.NotoSansKRRegular, 36);
879+
880+
const string text = "한국어 hangugeo 한국어 hangugeo 한국어 hangugeo 한국어 hangugeo 한국어 hangugeo";
881+
RichTextOptions textOptions = new(font)
882+
{
883+
FallbackFontFamilies = new[] { fallback.Family },
884+
WrappingLength = 400,
885+
LayoutMode = LayoutMode.VerticalMixedLeftRight,
886+
TextRuns = new[] { new RichTextRun() { Start = 0, End = text.GetGraphemeCount(), TextDecorations = TextDecorations.Underline | TextDecorations.Strikeout | TextDecorations.Overline } }
887+
};
888+
889+
provider.RunValidatingProcessorTest(
890+
c => c.Fill(Color.White).DrawText(textOptions, text, Brushes.Solid(Color.Black)),
891+
comparer: ImageComparer.TolerantPercentage(0.002f));
892+
}
893+
795894
private static string Repeat(string str, int times) => string.Concat(Enumerable.Repeat(str, times));
796895

797896
private static string ToTestOutputDisplayText(string text)

tests/ImageSharp.Drawing.Tests/ImageSharp.Drawing.Tests.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
<None Update="TestFonts\*.woff">
2727
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
2828
</None>
29+
<None Update="TestFonts\*.otf">
30+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
31+
</None>
2932
<None Update="xunit.runner.json">
3033
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
3134
</None>

0 commit comments

Comments
 (0)