diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf73a9ab..ca9ad6974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ All notable changes to this project will be documented in this file. - Multi-Line Text support to SkiaRenderContext (#1538) - Added title clipping to PlotModel (#1510) - Added LabelStep and LabelSpacing to contour series (#1511) +- SvgExporter for OxyPlot.ImageSharp +- Consistent support for DrawText's maxSize parameter (#1559) ### Changed - Legends model (#644) @@ -48,6 +50,7 @@ All notable changes to this project will be documented in this file. - SkiaRenderContext does not apply pixel snapping when rendering to vector graphic (#1539) - Mark OxyPlot.PdfExporter and OxyPlot.Pdf.PdfExporter as obsolete (#1527) - Replace Axis.DesiredSize by Axis.DesiredMargin, change signature of Axis.Measure(...) (#453) +- OxyPlot.Wpf to use common text arranging and trimming ### Removed - Remove PlotModel.Legends (#644) @@ -56,6 +59,7 @@ All notable changes to this project will be documented in this file. - Remove exporter Background properties (#1409) - Remove OxyThickness Width and Height properties (#1429) - RenderingExtensions.DrawRectangleAsPolygon(...) extension methods. IRenderContext.DrawRectangle(...) with an appropriate EdgeRenderingMode can be used instead. +- UseVerticalAlignmentWorkaround property of OxyPlot.SvgRenderContext ### Fixed - Legend font size is not affected by DefaultFontSize (#1396) @@ -69,6 +73,9 @@ All notable changes to this project will be documented in this file. - Text measurement and rendering in OxyPlot.ImageSharp - ExampleLibrary reporting annotation-only PlotModels as transposable (#1544) - Auto plot margin not taking width of labels into account (#453) +- Vertical text alignment in OxyPlot.SvgRenderContext (#1531) +- Consistent horizontal text alignment for mutliline text (#1554) +- Consistent implementation of MeasureText (#1551) ## [2.0.0] - 2019-10-19 ### Added diff --git a/Source/Examples/ExampleLibrary/Examples/RenderingCapabilities.cs b/Source/Examples/ExampleLibrary/Examples/RenderingCapabilities.cs index 3f7418a35..f2b4e91ab 100644 --- a/Source/Examples/ExampleLibrary/Examples/RenderingCapabilities.cs +++ b/Source/Examples/ExampleLibrary/Examples/RenderingCapabilities.cs @@ -288,6 +288,60 @@ public static PlotModel DrawMultilineTextAlignmentRotation() return model; } + /// + /// Shows max size capabilities for the DrawText method. + /// + /// A plot model. + [Example("DrawText - Bounded Multi-line Alignment/Rotation")] + public static PlotModel DrawBoundedMultilineTextAlignmentRotationWith() + { + var model = new PlotModel(); + model.Annotations.Add(new DelegateAnnotation(rc => + { + for (var ha = HorizontalAlignment.Left; ha <= HorizontalAlignment.Right; ha++) + { + for (var va = VerticalAlignment.Top; va <= VerticalAlignment.Bottom; va++) + { + var origin = new ScreenPoint(((int)ha + 2) * 170, ((int)va + 2) * 170); + rc.FillCircle(origin, 3, OxyColors.Blue, EdgeRenderingMode.Adaptive); + for (var rotation = 0; rotation < 360; rotation += 90) + { + rc.DrawText(origin, $"R{rotation:000}\n{ha}\n{va}", OxyColors.Black, fontSize: 20d, rotation: rotation, horizontalAlignment: ha, verticalAlignment: va, maxSize: new OxySize(50, 70)); + } + } + } + })); + return model; + } + + /// + /// Shows max size capabilities for the DrawText method. + /// + /// A plot model. + [Example("DrawText - Horizontal Text Trimming")] + public static PlotModel DrawHorizontalTextTrimmed() + { + var text = "This is a long piece of text with many words that barely qualifies as a sentence."; + var multiLineText = "This is a long piece of multiline text\nwith many words that\nbarely qualifies as a sentence."; + + var model = new PlotModel(); + model.Annotations.Add(new DelegateAnnotation(rc => + { + for (int i = 0; i < 20; i++) + { + var p = new ScreenPoint(10, 10 + i * 20); + rc.DrawText(p, text, OxyColors.Black, maxSize: new OxySize(i * 20, double.MaxValue)); + } + + for (int i = 0; i < 5; i++) + { + var p = new ScreenPoint(10, 400 + i * 50); + rc.DrawText(p, multiLineText, OxyColors.Black, maxSize: new OxySize(i * 25, double.MaxValue)); + } + })); + return model; + } + /// /// Shows color capabilities for the DrawText method. /// diff --git a/Source/OxyPlot.ImageSharp.Tests/PngExporterTests.cs b/Source/OxyPlot.ImageSharp.Tests/PngExporterTests.cs index d30d4fd3d..c2c050ff5 100644 --- a/Source/OxyPlot.ImageSharp.Tests/PngExporterTests.cs +++ b/Source/OxyPlot.ImageSharp.Tests/PngExporterTests.cs @@ -183,7 +183,17 @@ public void LargeImageTest(bool interpolate) public void TestMultilineAlignment() { var plotModel = ExampleLibrary.RenderingCapabilities.DrawMultilineTextAlignmentRotation(); - var fileName = Path.Combine(this.outputDirectory, "Text.png"); + var fileName = Path.Combine(this.outputDirectory, "Multiline-Alignment.png"); + PngExporter.Export(plotModel, fileName, 700, 700); + + Assert.IsTrue(File.Exists(fileName)); + } + + [Test] + public void TestBoundedMultilineAlignment() + { + var plotModel = ExampleLibrary.RenderingCapabilities.DrawBoundedMultilineTextAlignmentRotationWith(); + var fileName = Path.Combine(this.outputDirectory, "Bounded-Multiline-Alignment.png"); PngExporter.Export(plotModel, fileName, 700, 700); Assert.IsTrue(File.Exists(fileName)); diff --git a/Source/OxyPlot.ImageSharp.Tests/SvgExporterTests.cs b/Source/OxyPlot.ImageSharp.Tests/SvgExporterTests.cs new file mode 100644 index 000000000..662a96bc0 --- /dev/null +++ b/Source/OxyPlot.ImageSharp.Tests/SvgExporterTests.cs @@ -0,0 +1,209 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2020 OxyPlot contributors +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace OxyPlot.ImageSharp.Tests +{ + using System; + using System.IO; + using NUnit.Framework; + + using OxyPlot.Series; + using OxyPlot.ImageSharp; + using OxyPlot.Annotations; + + [TestFixture] + public class SvgExporterTests + { + private const string SVG_FOLDER = "SVG"; + private string outputDirectory; + + [OneTimeSetUp] + public void Setup() + { + this.outputDirectory = Path.Combine(TestContext.CurrentContext.WorkDirectory, SVG_FOLDER); + Directory.CreateDirectory(this.outputDirectory); + } + + [Test] + public void Export_SomeExamplesInExampleLibrary_CheckThatAllFilesExist() + { + var exporter = new SvgExporter(1000, 750); + var directory = Path.Combine(this.outputDirectory, "ExampleLibrary"); + ExportTest.Export_FirstExampleOfEachExampleGroup_CheckThatAllFilesExist(exporter, directory, ".svg"); + } + + [Test] + public void ExportToStream() + { + var plotModel = CreateTestModel1(); + var exporter = new SvgExporter(1000, 750); + var stream = new MemoryStream(); + exporter.Export(plotModel, stream); + + Assert.IsTrue(stream.Length > 0); + } + + [Test] + public void ExportToFile() + { + var plotModel = CreateTestModel1(); + var fileName = Path.Combine(this.outputDirectory, "Plot1.svg"); + SvgExporter.Export(plotModel, fileName, 1000, 750); + + Assert.IsTrue(File.Exists(fileName)); + } + + [Test] + public void ExportWithDifferentBackground() + { + var plotModel = CreateTestModel1(); + plotModel.Background = OxyColors.Yellow; + var fileName = Path.Combine(this.outputDirectory, "Background_Yellow.svg"); + var exporter = new SvgExporter(1000, 750); + using (var stream = File.OpenWrite(fileName)) + { + exporter.Export(plotModel, stream); + } + + Assert.IsTrue(File.Exists(fileName)); + } + + [Test] + [TestCase(0.75)] + [TestCase(1)] + [TestCase(1.2)] + [TestCase(2)] + [TestCase(3.1415)] + public void ExportWithResolution(double factor) + { + var resolution = (int)(96 * factor); + var plotModel = CreateTestModel1(); + var directory = Path.Combine(this.outputDirectory, "Resolution"); + Directory.CreateDirectory(directory); + + var fileName = Path.Combine(directory, $"Resolution{resolution}.svg"); + var exporter = new SvgExporter((int)(400 * factor), (int)(300 * factor), resolution); + + using (var stream = File.OpenWrite(fileName)) + { + exporter.Export(plotModel, stream); + } + + Assert.IsTrue(File.Exists(fileName)); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void PlotBackgroundImageTest(bool interpolate) + { + // this is a test of the DrawImage function; don't add pointless backgrounds to your plots + + var plotModel = CreateTestModel1(); + + var pixelData = new OxyColor[5, 5]; + for (int i = 0; i < pixelData.GetLength(0); i++) + { + for (int j = 0; j < pixelData.GetLength(1); j++) + { + pixelData[i, j] = OxyColor.FromArgb(255, 128, (byte)((i * 255) / pixelData.GetLength(0)), (byte)((j * 255) / pixelData.GetLength(1))); + } + } + + var oxyImage = OxyImage.Create(pixelData, ImageFormat.Png); + var imageAnnotation = new ImageAnnotation() + { + ImageSource = oxyImage, + X = new PlotLength(-0.0, PlotLengthUnit.RelativeToPlotArea), + Y = new PlotLength(-0.0, PlotLengthUnit.RelativeToPlotArea), + Width = new PlotLength(1.0, PlotLengthUnit.RelativeToPlotArea), + Height = new PlotLength(1.0, PlotLengthUnit.RelativeToPlotArea), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Interpolate = interpolate + }; + plotModel.Annotations.Add(imageAnnotation); + + var fileName = Path.Combine(this.outputDirectory, $"PlotBackground{(interpolate ? "Interpolated" : "Pixelated")}.svg"); + var exporter = new SvgExporter(1000, 750); + using (var stream = File.OpenWrite(fileName)) + { + exporter.Export(plotModel, stream); + } + + Assert.IsTrue(File.Exists(fileName)); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void LargeImageTest(bool interpolate) + { + // this is a test of the DrawImage function; don't add pointless backgrounds to your plots + + var plotModel = CreateTestModel1(); + + var pixelData = new OxyColor[5, 5]; + for (int i = 0; i < pixelData.GetLength(0); i++) + { + for (int j = 0; j < pixelData.GetLength(1); j++) + { + pixelData[i, j] = OxyColor.FromArgb(255, 128, (byte)((i * 255) / pixelData.GetLength(0)), (byte)((j * 255) / pixelData.GetLength(1))); + } + } + + var oxyImage = OxyImage.Create(pixelData, ImageFormat.Png); + var imageAnnotation = new ImageAnnotation() + { + ImageSource = oxyImage, + X = new PlotLength(-1, PlotLengthUnit.RelativeToViewport), + Y = new PlotLength(-1, PlotLengthUnit.RelativeToViewport), + Width = new PlotLength(3, PlotLengthUnit.RelativeToViewport), + Height = new PlotLength(3, PlotLengthUnit.RelativeToViewport), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Interpolate = interpolate + }; + plotModel.Annotations.Add(imageAnnotation); + + var fileName = Path.Combine(this.outputDirectory, $"LargeImage{(interpolate ? "Interpolated" : "Pixelated")}.svg"); + var exporter = new SvgExporter(1000, 750); + using (var stream = File.OpenWrite(fileName)) + { + exporter.Export(plotModel, stream); + } + + Assert.IsTrue(File.Exists(fileName)); + } + + [Test] + public void TestMultilineAlignment() + { + var plotModel = ExampleLibrary.RenderingCapabilities.DrawMultilineTextAlignmentRotation(); + var fileName = Path.Combine(this.outputDirectory, "Multiline-Alignment.svg"); + SvgExporter.Export(plotModel, fileName, 700, 700); + + Assert.IsTrue(File.Exists(fileName)); + } + + [Test] + public void TestBoundedMultilineAlignment() + { + var plotModel = ExampleLibrary.RenderingCapabilities.DrawBoundedMultilineTextAlignmentRotationWith(); + var fileName = Path.Combine(this.outputDirectory, "Bounded-Multiline-Alignment.svg"); + SvgExporter.Export(plotModel, fileName, 700, 700); + + Assert.IsTrue(File.Exists(fileName)); + } + + private static PlotModel CreateTestModel1() + { + var model = new PlotModel { Title = "Test 1" }; + model.Series.Add(new FunctionSeries(Math.Sin, 0, Math.PI * 8, 200, "sin(x)")); + return model; + } + } +} diff --git a/Source/OxyPlot.ImageSharp/ImageRenderContext.cs b/Source/OxyPlot.ImageSharp/ImageRenderContext.cs index 5f3c01f26..bac5728aa 100644 --- a/Source/OxyPlot.ImageSharp/ImageRenderContext.cs +++ b/Source/OxyPlot.ImageSharp/ImageRenderContext.cs @@ -13,6 +13,7 @@ namespace OxyPlot.ImageSharp using System.Collections.Generic; using System.IO; using System.Linq; + using OxyPlot.Rendering; using SixLabors.Fonts; using SixLabors.Fonts.Exceptions; using SixLabors.ImageSharp; @@ -24,7 +25,7 @@ namespace OxyPlot.ImageSharp /// /// Provides an implementation of IRenderContext which draws to a . /// - public class ImageRenderContext : RenderContextBase, IDisposable + public class ImageRenderContext : RenderContextBase, IDisposable, ITextMeasurer { /// /// The default font to use when a request font cannot be found. @@ -81,8 +82,15 @@ public ImageRenderContext(int width, int height, OxyColor background, double dpi this.RendersToScreen = false; this.clipping = false; + + this.TextArranger = new TextArranger(this, new SimpleTextTrimmer()); } + /// + /// Gets or sets the used by this instance. + /// + public TextArranger TextArranger { get; set; } + /// /// Gets the DPI scaling factor. A value of 1 corresponds to 96 DPI (dots per inch). /// @@ -167,79 +175,29 @@ public override void DrawText(ScreenPoint p, string text, OxyColor fill, string var font = this.GetFontOrThrow(fontFamily, fontSize, this.ToFontStyle(fontWeight)); var actualFontSize = this.NominalFontSizeToPoints(fontSize); - var outputX = this.Convert(p.X); - var outputY = this.Convert(p.Y); - var outputPosition = new PointF(outputX, outputY); - - var cos = (float)Math.Cos(rotation * Math.PI / 180.0); - var sin = (float)Math.Sin(rotation * Math.PI / 180.0); - - // measure bounds of the whole text (we only need the height) - var bounds = this.MeasureTextLoose(text, fontFamily, fontSize, fontWeight); - var boundsHeight = this.Convert(bounds.Height); - var offsetHeight = new PointF(boundsHeight * -sin, boundsHeight * cos); - - // determine the font metrids for this font size at 96 DPI - var actualDescent = this.Convert(actualFontSize * this.MilliPointsToNominalResolution(font.Descender)); - var offsetDescent = new PointF(actualDescent * -sin, actualDescent * cos); - - var actualLineHeight = this.Convert(actualFontSize * this.MilliPointsToNominalResolution(font.LineHeight)); - var offsetLineHeight = new PointF(actualLineHeight * -sin, actualLineHeight * cos); + this.TextArranger.ArrangeText(p, text, fontFamily, fontSize, fontWeight, rotation, horizontalAlignment, verticalAlignment, maxSize, OxyPlot.HorizontalAlignment.Left, TextVerticalAlignment.Baseline, out var lines, out var linePositions); - var actualLineGap = this.Convert(actualFontSize * this.MilliPointsToNominalResolution(font.LineGap)); - var offsetLineGap = new PointF(actualLineGap * -sin, actualLineGap * cos); - - // find top of the whole text - var deltaY = verticalAlignment switch + for (int i = 0; i < lines.Length; i++) { - OxyPlot.VerticalAlignment.Top => 1.0f, - OxyPlot.VerticalAlignment.Middle => 0.5f, - OxyPlot.VerticalAlignment.Bottom => 0.0f, - _ => throw new ArgumentOutOfRangeException(nameof(verticalAlignment)), - }; - - // this is the top of the top line - var topPosition = outputPosition + (offsetHeight * deltaY) - offsetHeight; - - // need this later - var deltaX = horizontalAlignment switch - { - OxyPlot.HorizontalAlignment.Left => -0.0f, - OxyPlot.HorizontalAlignment.Center => -0.5f, - OxyPlot.HorizontalAlignment.Right => -1.0f, - _ => throw new ArgumentOutOfRangeException(nameof(horizontalAlignment)), - }; - - var lines = StringHelper.SplitLines(text); - for (int li = 0; li < lines.Length; li++) - { - var line = lines[li]; + var line = lines[i]; + var linePosition = this.Convert(linePositions[i]); if (string.IsNullOrWhiteSpace(line)) { continue; } - - // measure bounds of just the line (we only need the width) - var lineBounds = this.MeasureTextLoose(line, fontFamily, fontSize, fontWeight); - var lineBoundsWidth = this.Convert(lineBounds.Width); - var offsetLineWidth = new PointF(lineBoundsWidth * cos, lineBoundsWidth * sin); - - // find the left baseline position - var lineTop = topPosition + (offsetLineGap * li) + (offsetLineHeight * li); - var lineBaseLineLeft = lineTop + offsetLineWidth * deltaX + offsetLineHeight + offsetDescent; // this seems to produce consistent and correct results, but we have to rotate it manually, so render it at the origin for simplicity var glyphsAtOrigin = TextBuilder.GenerateGlyphs(line, new PointF(0f, 0f), new RendererOptions(font, this.Dpi, this.Dpi) { HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Bottom, // sit on the line (baseline) + VerticalAlignment = VerticalAlignment.Bottom, // baseline ApplyKerning = true, }); // translate and rotate into possition var transform = Matrix3x2Extensions.CreateRotationDegrees((float)rotation); - transform.Translation = lineBaseLineLeft; + transform.Translation = linePosition; var glyphs = glyphsAtOrigin.Transform(transform); // draw the glyphs @@ -253,7 +211,7 @@ public override void DrawText(ScreenPoint p, string text, OxyColor fill, string /// public override OxySize MeasureText(string text, string fontFamily = null, double fontSize = 10, double fontWeight = 500) { - return this.MeasureTextLoose(text, fontFamily, fontSize, fontWeight); + return this.TextArranger.MeasureText(text, fontFamily, fontSize, fontWeight); } /// @@ -441,6 +399,31 @@ public override void ResetClip() this.clipping = false; } + /// + public FontMetrics GetFontMetrics(string fontFamily, double fontSize, double fontWeight) + { + var font = this.GetFontOrThrow(fontFamily, fontSize, this.ToFontStyle(fontWeight)); + var actualFontSize = this.NominalFontSizeToPoints(fontSize); + + var ascender = actualFontSize * this.MilliPointsToNominalResolution(Math.Abs(font.Ascender)); + var descender = actualFontSize * this.MilliPointsToNominalResolution(Math.Abs(font.Descender)); + var leading = actualFontSize * this.MilliPointsToNominalResolution(Math.Abs(font.LineGap)); + + return new FontMetrics(ascender, descender, leading); + } + + /// + public double MeasureTextWidth(string text, string fontFamily, double fontSize, double fontWeight) + { + text = text ?? string.Empty; + + var font = this.GetFontOrThrow(fontFamily, fontSize, this.ToFontStyle(fontWeight)); + var actualFontSize = this.NominalFontSizeToPoints(fontSize); + + var result = TextMeasurer.Measure(text, new RendererOptions(font, this.Dpi)); + return this.ConvertBack(result.Width); + } + /// public override bool SetClip(OxyRect clippingRectangle) { @@ -605,52 +588,6 @@ private FontFamily GetFamilyOrFallbackOrThrow(string fontFamily = null, bool all return family; } - /// - /// Measures the text as it will be arranged out by OxyPlot. - /// - /// The text to render. - /// The font family. - /// The font size in points. - /// The font weight. - /// An . - private OxySize MeasureTextLoose(string text, string fontFamily, double fontSize, double fontWeight) - { - text = text ?? string.Empty; - - var font = this.GetFontOrThrow(fontFamily, fontSize, this.ToFontStyle(fontWeight)); - var actualFontSize = this.NominalFontSizeToPoints(fontSize); - - var tight = this.MeasureTextTight(text, fontFamily, fontSize, fontWeight); - var width = tight.Width; - - var lineHeight = actualFontSize * this.MilliPointsToNominalResolution(font.LineHeight); - var lineGap = actualFontSize * this.MilliPointsToNominalResolution(font.LineGap); - var lineCount = CountLines(text); - - var height = (lineHeight * lineCount) + (lineGap * (lineCount - 1)); - - return new OxySize(width, height); - } - - /// - /// Measures the text as it will be rendered by ImageSharp. - /// - /// The text to render. - /// The font family. - /// The font size in points. - /// The font weight. - /// An . - private OxySize MeasureTextTight(string text, string fontFamily, double fontSize, double fontWeight) - { - text = text ?? string.Empty; - - var font = this.GetFontOrThrow(fontFamily, fontSize, this.ToFontStyle(fontWeight)); - var actualFontSize = this.NominalFontSizeToPoints(fontSize); - - var result = TextMeasurer.Measure(text, new RendererOptions(font, this.Dpi)); - return new OxySize(this.ConvertBack(result.Width), this.ConvertBack(result.Height)); - } - /// /// Gets the snapping offset for the specified stroke thickness. /// diff --git a/Source/OxyPlot.ImageSharp/SvgExporter.cs b/Source/OxyPlot.ImageSharp/SvgExporter.cs new file mode 100644 index 000000000..b7a559ec7 --- /dev/null +++ b/Source/OxyPlot.ImageSharp/SvgExporter.cs @@ -0,0 +1,77 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2014 OxyPlot contributors +// +// +// Provides functionality to export plots to scalable vector graphics using for text measuring. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace OxyPlot.ImageSharp +{ + using System; + using System.IO; + + /// + /// Provides functionality to export plots to scalable vector graphics using ImageSharp for text measuring. + /// + public class SvgExporter : OxyPlot.SvgExporter, IDisposable + { + /// + /// The render context. + /// + private ImageRenderContext irc; + + /// + /// Initializes a new instance of the class. + /// + /// The width. + /// The height. + /// The resolution in dots per inch. + public SvgExporter(double width, double height, double resolution = 96) + { + this.Width = width; + this.Height = height; + this.irc = new ImageRenderContext(1, 1, OxyColors.Undefined, resolution); + this.TextMeasurer = this.irc; + } + + /// + /// Exports the specified to the specified file. + /// + /// The model. + /// The file name. + /// The width. + /// The height. + /// The resolution in dpi (defaults to 96dpi). + public static void Export(IPlotModel model, string fileName, int width, int height, double resolution = 96) + { + var exporter = new SvgExporter(width, height, resolution); + using (var stream = File.Create(fileName)) + { + exporter.Export(model, stream); + } + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes the . + /// + /// Whether we are disposing. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + this.irc.Dispose(); + } + } + } +} diff --git a/Source/OxyPlot.SkiaSharp.Tests/SvgExporterTests.cs b/Source/OxyPlot.SkiaSharp.Tests/SvgExporterTests.cs index 77713ca8d..2dac372e1 100644 --- a/Source/OxyPlot.SkiaSharp.Tests/SvgExporterTests.cs +++ b/Source/OxyPlot.SkiaSharp.Tests/SvgExporterTests.cs @@ -34,6 +34,15 @@ public void TestMultilineAlignment() exporter.Export(model, stream); } + [Test] + public void TestBoundedMultilineAlignment() + { + var exporter = new SvgExporter { Width = 1000, Height = 750 }; + var model = RenderingCapabilities.DrawBoundedMultilineTextAlignmentRotationWith(); + using var stream = File.Create(Path.Combine(this.outputDirectory, "Bounded-Multiline-Alignment.svg")); + exporter.Export(model, stream); + } + [OneTimeSetUp] public void Setup() { diff --git a/Source/OxyPlot.SkiaSharp/SkiaRenderContext.cs b/Source/OxyPlot.SkiaSharp/SkiaRenderContext.cs index 55f6dc0ce..90ab959b2 100644 --- a/Source/OxyPlot.SkiaSharp/SkiaRenderContext.cs +++ b/Source/OxyPlot.SkiaSharp/SkiaRenderContext.cs @@ -6,22 +6,36 @@ namespace OxyPlot.SkiaSharp { - using global::SkiaSharp; - using global::SkiaSharp.HarfBuzz; using System; using System.Collections.Generic; using System.Linq; + using global::SkiaSharp; + using global::SkiaSharp.HarfBuzz; + using OxyPlot.Rendering; /// /// Implements based on SkiaSharp. /// - public class SkiaRenderContext : IRenderContext, IDisposable + public class SkiaRenderContext : IRenderContext, IDisposable, ITextMeasurer { private readonly Dictionary shaperCache = new Dictionary(); private readonly Dictionary typefaceCache = new Dictionary(); private SKPaint paint = new SKPaint(); private SKPath path = new SKPath(); + /// + /// Initializes a new instance of the class. + /// + public SkiaRenderContext() + { + this.TextArranger = new TextArranger(this, new SimpleTextTrimmer()); + } + + /// + /// Gets or sets the used by this instance. + /// + public TextArranger TextArranger { get; set; } + /// /// Gets or sets the DPI scaling factor. A value of 1 corresponds to 96 DPI (dots per inch). /// @@ -227,7 +241,7 @@ public void DrawEllipses(IList extents, OxyColor fill, OxyColor stroke, double[] dashArray = null, LineJoin lineJoin = LineJoin.Miter) { - if (!fill.IsVisible() && !(stroke.IsVisible() || thickness <= 0) || points.Count < 2) + if ((!fill.IsVisible() && !(stroke.IsVisible() || thickness <= 0)) || points.Count < 2) { return; } @@ -260,7 +274,7 @@ public void DrawEllipses(IList extents, OxyColor fill, OxyColor stroke, double[] dashArray = null, LineJoin lineJoin = LineJoin.Miter) { - if (!fill.IsVisible() && !(stroke.IsVisible() || thickness <= 0) || polygons.Count == 0) + if ((!fill.IsVisible() && !(stroke.IsVisible() || thickness <= 0)) || polygons.Count == 0) { return; } @@ -317,7 +331,7 @@ public void DrawRectangle(OxyRect rectangle, OxyColor fill, OxyColor stroke, dou /// public void DrawRectangles(IList rectangles, OxyColor fill, OxyColor stroke, double thickness, EdgeRenderingMode edgeRenderingMode) { - if (!fill.IsVisible() && !(stroke.IsVisible() || thickness <= 0) || rectangles.Count == 0) + if ((!fill.IsVisible() && !(stroke.IsVisible() || thickness <= 0)) || rectangles.Count == 0) { return; } @@ -365,68 +379,50 @@ public void DrawRectangles(IList rectangles, OxyColor fill, OxyColor st var x = this.Convert(p.X); var y = this.Convert(p.Y); - var lines = StringHelper.SplitLines(text); - var lineHeight = paint.GetFontMetrics(out var metrics); + using var canvasRestore = new SKAutoCanvasRestore(this.SkCanvas); + this.SkCanvas.Translate(x, y); + this.SkCanvas.RotateDegrees((float)rotation); - var deltaY = verticalAlignment switch + var targetHorizontalAlignment = horizontalAlignment; + if (this.UseTextShaping) { - VerticalAlignment.Top => -metrics.Ascent, - VerticalAlignment.Middle => -(metrics.Ascent + metrics.Descent + lineHeight * (lines.Length - 1)) / 2, - VerticalAlignment.Bottom => -metrics.Descent - lineHeight * (lines.Length - 1), - _ => throw new ArgumentOutOfRangeException(nameof(verticalAlignment)) - }; + paint.TextAlign = SKTextAlign.Left; + targetHorizontalAlignment = HorizontalAlignment.Left; + } + else + { + paint.TextAlign = horizontalAlignment switch + { + HorizontalAlignment.Left => SKTextAlign.Left, + HorizontalAlignment.Center => SKTextAlign.Center, + HorizontalAlignment.Right => SKTextAlign.Right, + _ => throw new ArgumentOutOfRangeException(nameof(horizontalAlignment)), + }; + } - using var _ = new SKAutoCanvasRestore(this.SkCanvas); - this.SkCanvas.Translate(x, y); - this.SkCanvas.RotateDegrees((float)rotation); + // arrange around the origin with no rotation, because SkCanvas does the rotation for us + this.TextArranger.ArrangeText(new ScreenPoint(0, 0), text, fontFamily, fontSize, fontWeight, 0.0, horizontalAlignment, verticalAlignment, maxSize, targetHorizontalAlignment, TextVerticalAlignment.Baseline, out var lines, out var linePositions); - foreach (var line in lines) + for (int i = 0; i < lines.Length; i++) { + var line = lines[i]; + var linePosition = this.Convert(linePositions[i]); + if (this.UseTextShaping) { - var width = this.MeasureText(line, shaper, paint); - var deltaX = horizontalAlignment switch - { - HorizontalAlignment.Left => 0, - HorizontalAlignment.Center => -width / 2, - HorizontalAlignment.Right => -width, - _ => throw new ArgumentOutOfRangeException(nameof(horizontalAlignment)) - }; - - this.paint.TextAlign = SKTextAlign.Left; - this.SkCanvas.DrawShapedText(shaper, line, deltaX, deltaY, paint); + this.SkCanvas.DrawShapedText(shaper, line, linePosition.X, linePosition.Y, paint); } else { - paint.TextAlign = horizontalAlignment switch - { - HorizontalAlignment.Left => SKTextAlign.Left, - HorizontalAlignment.Center => SKTextAlign.Center, - HorizontalAlignment.Right => SKTextAlign.Right, - _ => throw new ArgumentOutOfRangeException(nameof(horizontalAlignment)) - }; - - this.SkCanvas.DrawText(line, 0, deltaY, paint); + this.SkCanvas.DrawText(line, linePosition.X, linePosition.Y, paint); } - - deltaY += lineHeight; } } /// public OxySize MeasureText(string text, string fontFamily = null, double fontSize = 10, double fontWeight = 500) { - if (text == null) - { - return new OxySize(0, 0); - } - - var lines = StringHelper.SplitLines(text); - var paint = this.GetTextPaint(fontFamily, fontSize, fontWeight, out var shaper); - var height = paint.GetFontMetrics(out _) * lines.Length; - var width = lines.Max(line => this.MeasureText(line, shaper, paint)); - - return new OxySize(this.ConvertBack(width), this.ConvertBack(height)); + return this.TextArranger.MeasureText(text, fontFamily, fontSize, fontWeight); } /// @@ -454,6 +450,26 @@ public void SetToolTip(string text) { } + /// + public FontMetrics GetFontMetrics(string fontFamily, double fontSize, double fontWeight) + { + var lineSpacing = this.paint.GetFontMetrics(out var metrics); + var ascender = this.ConvertBack(Math.Abs(metrics.Ascent)); + var descender = this.ConvertBack(Math.Abs(metrics.Descent)); + var leading = this.ConvertBack(Math.Abs(metrics.Leading)); + + return new FontMetrics(ascender, descender, leading); + } + + /// + public double MeasureTextWidth(string text, string fontFamily, double fontSize, double fontWeight) + { + var paint = this.GetTextPaint(fontFamily, fontSize, fontWeight, out var shaper); + var width = this.MeasureText(text, shaper, paint); + + return this.ConvertBack(width); + } + /// /// Disposes managed resources. /// @@ -773,7 +789,7 @@ private SKPaint GetLinePaint(OxyColor strokeColor, double strokeThickness, EdgeR LineJoin.Miter => SKStrokeJoin.Miter, LineJoin.Round => SKStrokeJoin.Round, LineJoin.Bevel => SKStrokeJoin.Bevel, - _ => throw new ArgumentOutOfRangeException(nameof(lineJoin)) + _ => throw new ArgumentOutOfRangeException(nameof(lineJoin)), }; return paint; @@ -934,12 +950,12 @@ public FontDescriptor(string fontFamily, double fontWeight) } /// - /// The font family. + /// Gets the font family. /// public string FontFamily { get; } /// - /// The font weight. + /// Gets the font weight. /// public double FontWeight { get; } @@ -953,8 +969,8 @@ public override bool Equals(object obj) public override int GetHashCode() { var hashCode = -1030903623; - hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(this.FontFamily); - hashCode = hashCode * -1521134295 + this.FontWeight.GetHashCode(); + hashCode = (hashCode * -1521134295) + EqualityComparer.Default.GetHashCode(this.FontFamily); + hashCode = (hashCode * -1521134295) + this.FontWeight.GetHashCode(); return hashCode; } } diff --git a/Source/OxyPlot.Tests/Rendering/MockTextMeasurer.cs b/Source/OxyPlot.Tests/Rendering/MockTextMeasurer.cs new file mode 100644 index 000000000..bab8d028d --- /dev/null +++ b/Source/OxyPlot.Tests/Rendering/MockTextMeasurer.cs @@ -0,0 +1,59 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2014 OxyPlot contributors +// +// +// Provides a predicatble implementation of ITextMeasurer. +// +// -------------------------------------------------------------------------------------------------------------------- + +using OxyPlot.Rendering; +using System.Collections.Generic; +using System.Linq; + +namespace OxyPlot.Tests +{ + public class MockTextMeasurer : ITextMeasurer + { + public MockTextMeasurer() + { + } + + public double Ascent { get; set; } = 0.6; + public double Descent { get; set; } = 0.3; + public double Leading { get; set; } = 0.1; + public double CharacterWidth { get; set; } = 0.8; + + public HashSet CharsWithWidth { get; } = new HashSet(); + + public FontMetrics GetFontMetrics(string fontFamily, double fontSize, double fontWeight) + { + return new FontMetrics(fontSize * this.Ascent, fontSize * this.Descent, fontSize * this.Leading); + } + + public double MeasureTextWidth(string text, string fontFamily, double fontSize, double fontWeight) + { + return fontSize * this.CharacterWidth * text.Count(CharsWithWidth.Contains); + } + + public void AddBasicAlphabet() + { + for (char c = 'a'; c <= 'z'; c++) + { + this.CharsWithWidth.Add(c); + this.CharsWithWidth.Add(char.ToUpperInvariant(c)); + } + } + + public void AddBasicWhitespace() + { + this.CharsWithWidth.Add(' '); + } + + public void AddEllipsisChars() + { + this.CharsWithWidth.Add(SimpleTextTrimmer.AsciiEllipsis[0]); + this.CharsWithWidth.Add(SimpleTextTrimmer.UnicodeEllipsis[0]); + } + } +} diff --git a/Source/OxyPlot.Tests/Rendering/TextTrimmerTests.cs b/Source/OxyPlot.Tests/Rendering/TextTrimmerTests.cs new file mode 100644 index 000000000..e6a56ba12 --- /dev/null +++ b/Source/OxyPlot.Tests/Rendering/TextTrimmerTests.cs @@ -0,0 +1,262 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2014 OxyPlot contributors +// +// +// Provides unit tests for the class. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace OxyPlot.Tests +{ + using System.Linq; + using NUnit.Framework; + using OxyPlot.Rendering; + + /// + /// Provides unit tests for the class. + /// + [TestFixture] + public class TextTrimmerTests + { + /// + /// Tests word boundaries with boring ascii text. + /// + [Test] + public void CharacterBoundariesAscii() + { + CollectionAssert.AreEqual(new int[0], SimpleTextTrimmer.GetCharacterBoundaries("")); + CollectionAssert.AreEqual(new[] { 1 }, SimpleTextTrimmer.GetCharacterBoundaries("A")); + CollectionAssert.AreEqual(Range(1, 7), SimpleTextTrimmer.GetCharacterBoundaries("OxyPlot")); + CollectionAssert.AreEqual(Range(2, 7), SimpleTextTrimmer.GetCharacterBoundaries(" OxyPlot ")); + CollectionAssert.AreEqual(new[] { 1, 3, 5, 7, 10 }, SimpleTextTrimmer.GetCharacterBoundaries("a b\tc\nd ?")); + } + + /// + /// Tests word boundaries with boring ascii punctuation. + /// + [Test] + public void CharacterBoundariesPunctuation() + { + CollectionAssert.AreEqual(new[] { 1 } , SimpleTextTrimmer.GetCharacterBoundaries(".")); + CollectionAssert.AreEqual(Range(1, 15), SimpleTextTrimmer.GetCharacterBoundaries(".!?#@£$%^&*()[]")); + CollectionAssert.AreEqual(Range(1, 8), SimpleTextTrimmer.GetCharacterBoundaries("OxyPlot!")); + } + + /// + /// Tests word boundaries with random western characters. + /// + [Test] + public void CharacterBoundariesWestern() + { + // non-combining + CollectionAssert.AreEqual(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, SimpleTextTrimmer.GetCharacterBoundaries("öüäßáéíóú")); + + // combining + CollectionAssert.AreEqual(new[] { 2, 4, 6, 8, 10, 12, 14, 16 }, SimpleTextTrimmer.GetCharacterBoundaries("äöüáéíóú")); + } + + /// + /// Tests word boundaries with Zalgo text. + /// + [Test] + public void CharacterBoundariesZalgo() + { + var zalgo = "O̴͙͇͔̺̓̐x̶̹̙͕͕͈͚̫͂̇̑̌̒́̄͐̽̇̕y̴̡͕͗̔̆̈́̓̈́̚͝͠P̸̼̜̯͕̜̟̞̥̦͓̦̙̦͕̜̹͌̅̐̉̑̕̚ḽ̷̢͈͉͙̬̫̔͋̋͆͐͝o̷̘̪̒̏̈͊̂̿̀̀̒͗̿̅̀̉̑t̵̨̧̙͖͈̲̬͚̤̦͎͈̣̀͊"; + + Assert.AreEqual(7, SimpleTextTrimmer.GetCharacterBoundaries(zalgo).Count); + } + + /// + /// Tests word boundaries with UTF-16 surrogate pairs. + /// + [Test] + public void CharacterBoundariesSurrogatePairs() + { + // TODO: more tests, ideally written by someone who knows about this stuff + CollectionAssert.AreEqual(new[] { 2 }, SimpleTextTrimmer.GetCharacterBoundaries("\uD852\uDF62")); + } + + /// + /// Tests word boundaries with only whitespace. + /// + [Test] + public void WordBoundariesNoWord() + { + var empty = new int[] { }; + CollectionAssert.AreEqual(empty, SimpleTextTrimmer.GetWordBoundaries("")); + CollectionAssert.AreEqual(empty, SimpleTextTrimmer.GetWordBoundaries("\t")); + CollectionAssert.AreEqual(empty, SimpleTextTrimmer.GetWordBoundaries(" ")); + CollectionAssert.AreEqual(empty, SimpleTextTrimmer.GetWordBoundaries("\r\n")); + } + + /// + /// Tests word boundaries with only one word. + /// + [Test] + public void WordBoundariesOneWord() + { + CollectionAssert.AreEqual(new[] { 1 }, SimpleTextTrimmer.GetWordBoundaries("A")); + CollectionAssert.AreEqual(new[] { 7 }, SimpleTextTrimmer.GetWordBoundaries("OxyPlot")); + CollectionAssert.AreEqual(new[] { 8 }, SimpleTextTrimmer.GetWordBoundaries(" OxyPlot ")); + CollectionAssert.AreEqual(new[] { 9 }, SimpleTextTrimmer.GetWordBoundaries(" OxyPlot ")); + } + + /// + /// Tests word boundaries with boring ascii text. + /// + [Test] + public void WordBoundariesAscii() + { + CollectionAssert.AreEqual(new[] { 7, 10, 12, 21, 30 }, SimpleTextTrimmer.GetWordBoundaries("OxyPlot is a plotting library.")); + CollectionAssert.AreEqual(new[] { 8, 12, 15, 25, 35 }, SimpleTextTrimmer.GetWordBoundaries(" OxyPlot is a plotting library. ")); + } + + /// + /// Tests word boundaries with western characters. + /// + [Test] + public void WordBoundariesWestern() + { + // non-combining + CollectionAssert.AreEqual(new[] { 3, 5, 11, 15 }, SimpleTextTrimmer.GetWordBoundaries("öäü ß áéíóú €20")); + + // combining + CollectionAssert.AreEqual(new[] { 6, 17 }, SimpleTextTrimmer.GetWordBoundaries("äöü áéíóú")); + } + + /// + /// Tests word boundaries with a mix of whitespace. + /// + [Test] + public void WordBoundariesWhiteSpace() + { + CollectionAssert.AreEqual(new[] { 7, 10, 12, 21, 31 }, SimpleTextTrimmer.GetWordBoundaries("OxyPlot\tis\na plotting library.\r\n")); + } + + /// + /// Comvenience method to return an array of sequential integers. + /// + /// The first value, if any, in the array. + /// The number of values in this array. + /// The array. + private static int[] Range(int start, int count) + { + return Enumerable.Range(start, count).ToArray(); + } + + /// + /// Tests the . + /// + [Test] + public void MockTextMeasurerMeasureTextWidth() + { + var textMeasurer = new MockTextMeasurer(); + + var simpleTestString = "OxyPlot"; // 7 basic chars + var trickyTestString = "áéíóú"; // 10 chars, 5 characters with width + var fontSize = 10.0; + + Assert.AreEqual(0.0, textMeasurer.MeasureTextWidth(simpleTestString, null, fontSize, 500)); + Assert.AreEqual(0.0, textMeasurer.MeasureTextWidth(trickyTestString, null, fontSize, 500)); + + textMeasurer.AddBasicAlphabet(); + + Assert.AreEqual(fontSize * textMeasurer.CharacterWidth * 7, textMeasurer.MeasureTextWidth(simpleTestString, null, fontSize, 10)); + Assert.AreEqual(fontSize * textMeasurer.CharacterWidth * 5, textMeasurer.MeasureTextWidth(trickyTestString, null, fontSize, 10)); + } + + /// + /// Basics tests of character trimming. + /// + [Test] + public void TrimmingByChar() + { + var textMeasurer = new MockTextMeasurer(); + textMeasurer.AddBasicAlphabet(); + textMeasurer.AddEllipsisChars(); + + var trimmer = new SimpleTextTrimmer(); + + trimmer.AppendEllipsis = false; + trimmer.TrimToWord = false; + + var simpleTestString = "OxyPlot"; // 7 basic chars + var trickyTestString = "áéíóúaa"; // 12 chars, 5 characters with width + var fontSize = 10.0; + + var widthZero = textMeasurer.MeasureTextWidth("", null, fontSize, 500); + var widthNearZero = textMeasurer.MeasureTextWidth("a", null, fontSize, 500) / 10.0; + + var widthSmall = textMeasurer.MeasureTextWidth("aaa", null, fontSize, 500); + var widthNearSmall = textMeasurer.MeasureTextWidth("aaa", null, fontSize, 500) + widthNearZero; + + var widthLarge = textMeasurer.MeasureTextWidth("aaaaaaa", null, fontSize, 500); + var widthVeryLarge = widthLarge * 2.0; + + Assert.AreEqual("", trimmer.Trim(textMeasurer, simpleTestString, widthZero, null, fontSize, 500)); + Assert.AreEqual("", trimmer.Trim(textMeasurer, trickyTestString, widthZero, null, fontSize, 500)); + + Assert.AreEqual("", trimmer.Trim(textMeasurer, simpleTestString, widthNearZero, null, fontSize, 500)); + Assert.AreEqual("", trimmer.Trim(textMeasurer, trickyTestString, widthNearZero, null, fontSize, 500)); + + Assert.AreEqual(simpleTestString.Substring(0, 3), trimmer.Trim(textMeasurer, simpleTestString, widthSmall, null, fontSize, 500)); + Assert.AreEqual(trickyTestString.Substring(0, 6), trimmer.Trim(textMeasurer, trickyTestString, widthSmall, null, fontSize, 500)); + + Assert.AreEqual(simpleTestString.Substring(0, 3), trimmer.Trim(textMeasurer, simpleTestString, widthNearSmall, null, fontSize, 500)); + Assert.AreEqual(trickyTestString.Substring(0, 6), trimmer.Trim(textMeasurer, trickyTestString, widthNearSmall, null, fontSize, 500)); + + Assert.AreEqual(simpleTestString, trimmer.Trim(textMeasurer, simpleTestString, widthLarge, null, fontSize, 500)); + Assert.AreEqual(trickyTestString, trimmer.Trim(textMeasurer, trickyTestString, widthLarge, null, fontSize, 500)); + + Assert.AreEqual(simpleTestString, trimmer.Trim(textMeasurer, simpleTestString, widthVeryLarge, null, fontSize, 500)); + Assert.AreEqual(trickyTestString, trimmer.Trim(textMeasurer, trickyTestString, widthVeryLarge, null, fontSize, 500)); + } + + /// + /// Basics tests of word trimming. + /// + [Test] + public void TrimmingByWord() + { + var textMeasurer = new MockTextMeasurer(); + textMeasurer.AddBasicAlphabet(); + textMeasurer.AddEllipsisChars(); + textMeasurer.AddBasicWhitespace(); + + var trimmer = new SimpleTextTrimmer(); + + trimmer.TrimToWord = true; + + var text = "OxyPlot is a multiplatform plotting library"; + var fontSize = 10.0; + var oneChar = textMeasurer.CharacterWidth * fontSize; + var tiny = textMeasurer.CharacterWidth * fontSize / 10; + + var previousTarget = ""; + int i = 0; + while (++i < text.Length && (i = text.IndexOf(' ', i)) > 0) + { + var target = text.Substring(0, i); + var width = textMeasurer.MeasureTextWidth(target, null, fontSize, 500); + var widthWithEllipsis = textMeasurer.MeasureTextWidth(target + trimmer.Ellipsis, null, fontSize, 500); + + trimmer.AppendEllipsis = false; + Assert.AreEqual(previousTarget, trimmer.Trim(textMeasurer, text, width - tiny - oneChar, null, fontSize, 500)); + Assert.AreEqual(previousTarget, trimmer.Trim(textMeasurer, text, width - tiny, null, fontSize, 500)); + Assert.AreEqual(target, trimmer.Trim(textMeasurer, text, width, null, fontSize, 500)); + Assert.AreEqual(target, trimmer.Trim(textMeasurer, text, width + tiny, null, fontSize, 500)); + Assert.AreEqual(target, trimmer.Trim(textMeasurer, text, width + tiny + oneChar, null, fontSize, 500)); + + trimmer.AppendEllipsis = true; + Assert.AreEqual(previousTarget + trimmer.Ellipsis, trimmer.Trim(textMeasurer, text, widthWithEllipsis - tiny - oneChar, null, fontSize, 500)); + Assert.AreEqual(previousTarget + trimmer.Ellipsis, trimmer.Trim(textMeasurer, text, widthWithEllipsis - tiny, null, fontSize, 500)); + Assert.AreEqual(target + trimmer.Ellipsis, trimmer.Trim(textMeasurer, text, widthWithEllipsis, null, fontSize, 500)); + Assert.AreEqual(target + trimmer.Ellipsis, trimmer.Trim(textMeasurer, text, widthWithEllipsis + tiny, null, fontSize, 500)); + Assert.AreEqual(target + trimmer.Ellipsis, trimmer.Trim(textMeasurer, text, widthWithEllipsis + tiny + oneChar, null, fontSize, 500)); + + previousTarget = target; + } + } + } +} diff --git a/Source/OxyPlot.Tests/Svg/SvgExporterTests.cs b/Source/OxyPlot.Tests/Svg/SvgExporterTests.cs index 757d36667..3c7f4a334 100644 --- a/Source/OxyPlot.Tests/Svg/SvgExporterTests.cs +++ b/Source/OxyPlot.Tests/Svg/SvgExporterTests.cs @@ -111,7 +111,7 @@ public void Export_BoundedTest() var textMeasurer = new PdfRenderContext(width, height, model.Background); using (var stream = new FileStream(path, FileMode.Create)) - using (var rc = new SvgRenderContext(stream, width, height, false, textMeasurer, model.Background, true)) + using (var rc = new SvgRenderContext(stream, width, height, false, textMeasurer, model.Background)) { ((IPlotModel)model).Update(true); ((IPlotModel)model).Render(rc, rect); diff --git a/Source/OxyPlot.WindowsForms/GraphicsRenderContext.cs b/Source/OxyPlot.WindowsForms/GraphicsRenderContext.cs index c670295b3..108e3b38f 100644 --- a/Source/OxyPlot.WindowsForms/GraphicsRenderContext.cs +++ b/Source/OxyPlot.WindowsForms/GraphicsRenderContext.cs @@ -23,11 +23,12 @@ namespace OxyPlot.WindowsForms using System.Linq; using OxyPlot; + using OxyPlot.Rendering; /// /// The graphics render context. /// - public class GraphicsRenderContext : RenderContextBase, IDisposable + public class GraphicsRenderContext : RenderContextBase, IDisposable, ITextMeasurer { /// /// The font size factor. @@ -77,8 +78,15 @@ public GraphicsRenderContext(Graphics graphics = null) } this.stringFormat = StringFormat.GenericTypographic; + + this.TextArranger = new TextArranger(this, new SimpleTextTrimmer()); } + /// + /// Gets or sets the for this instance. + /// + public TextArranger TextArranger { get; set; } + /// /// Sets the graphics target. /// @@ -105,7 +113,7 @@ public override void DrawEllipse(OxyRect rect, OxyColor fill, OxyColor stroke, d { return; } - + var pen = this.GetCachedPen(stroke, thickness); this.g.DrawEllipse(pen, (float)rect.Left, (float)rect.Top, (float)rect.Width, (float)rect.Height); } @@ -191,9 +199,9 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, /// The font family. /// Size of the font. /// The font weight. - /// The rotation angle. - /// The horizontal alignment. - /// The vertical alignment. + /// The rotation angle. + /// The horizontal alignment. + /// The vertical alignment. /// The maximum size of the text. public override void DrawText( ScreenPoint p, @@ -202,9 +210,9 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, string fontFamily, double fontSize, double fontWeight, - double rotate, - HorizontalAlignment halign, - VerticalAlignment valign, + double rotation, + HorizontalAlignment horizontalAlignment, + VerticalAlignment verticalAlignment, OxySize? maxSize) { if (text == null) @@ -218,59 +226,27 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, { this.stringFormat.Alignment = StringAlignment.Near; this.stringFormat.LineAlignment = StringAlignment.Near; - var size = Ceiling(this.g.MeasureString(text, font, int.MaxValue, this.stringFormat)); - if (maxSize != null) - { - if (size.Width > maxSize.Value.Width) - { - size.Width = (float)maxSize.Value.Width; - } - - if (size.Height > maxSize.Value.Height) - { - size.Height = (float)maxSize.Value.Height; - } - } - - float dx = 0; - if (halign == HorizontalAlignment.Center) - { - dx = -size.Width / 2; - } - if (halign == HorizontalAlignment.Right) - { - dx = -size.Width; - } + var graphicsState = this.g.Save(); - float dy = 0; - this.stringFormat.LineAlignment = StringAlignment.Near; - if (valign == VerticalAlignment.Middle) - { - dy = -size.Height / 2; - } + this.g.TranslateTransform((float)p.X, (float)p.Y); - if (valign == VerticalAlignment.Bottom) + if (Math.Abs(rotation) > double.Epsilon) { - dy = -size.Height; + this.g.RotateTransform((float)rotation); } - var graphicsState = this.g.Save(); - - this.g.TranslateTransform((float)p.X, (float)p.Y); + // arrange around the origin with no rotation, because Graphics does the rotation for us + this.TextArranger.ArrangeText(new ScreenPoint(0, 0), text, fontFamily, fontSize, fontWeight, 0.0, horizontalAlignment, verticalAlignment, maxSize, HorizontalAlignment.Left, TextVerticalAlignment.Top, out var lines, out var linePositions); - var layoutRectangle = new RectangleF(0, 0, size.Width, size.Height); - if (Math.Abs(rotate) > double.Epsilon) + for (int i = 0; i < lines.Length; i++) { - this.g.RotateTransform((float)rotate); + var line = lines[i]; + var linePosition = new PointF((float)linePositions[i].X, (float)linePositions[i].Y); - layoutRectangle.Height += (float)(fontSize / 18.0); + this.g.DrawString(line, font, this.GetCachedBrush(fill), linePosition, this.stringFormat); } - this.g.TranslateTransform(dx, dy); - - this.g.DrawString(text, font, this.GetCachedBrush(fill), layoutRectangle, this.stringFormat); - this.g.Restore(graphicsState); } } @@ -285,19 +261,7 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, /// The text size. public override OxySize MeasureText(string text, string fontFamily, double fontSize, double fontWeight) { - if (text == null) - { - return OxySize.Empty; - } - - var fontStyle = fontWeight < 700 ? FontStyle.Regular : FontStyle.Bold; - using (var font = CreateFont(fontFamily, fontSize, fontStyle)) - { - this.stringFormat.Alignment = StringAlignment.Near; - this.stringFormat.LineAlignment = StringAlignment.Near; - var size = this.g.MeasureString(text, font, int.MaxValue, this.stringFormat); - return new OxySize(size.Width, size.Height); - } + return this.TextArranger.MeasureText(text, fontFamily, fontSize, fontWeight); } /// @@ -381,10 +345,54 @@ public override void ResetClip() this.g.ResetClip(); } + /// + public FontMetrics GetFontMetrics(string fontFamily, double fontSize, double fontWeight) + { + // TODO: DPI support + var fontStyle = fontWeight < 700 ? FontStyle.Regular : FontStyle.Bold; + using (var font = CreateFont(fontFamily, fontSize, fontStyle)) + { + var factor = font.Height / (double)Math.Abs(font.FontFamily.GetLineSpacing(fontStyle)); + + var ascender = factor * Math.Abs(font.FontFamily.GetCellAscent(fontStyle)); + var descender = factor * Math.Abs(font.FontFamily.GetCellDescent(fontStyle)); + var leading = font.Height - ascender - descender; + + return new FontMetrics(ascender, descender, leading); + } + } + + /// + public double MeasureTextWidth(string text, string fontFamily, double fontSize, double fontWeight) + { + if (text == null) + { + return 0.0; + } + + var fontStyle = fontWeight < 700 ? FontStyle.Regular : FontStyle.Bold; + using (var font = CreateFont(fontFamily, fontSize, fontStyle)) + { + this.stringFormat.Alignment = StringAlignment.Near; + this.stringFormat.LineAlignment = StringAlignment.Near; + var size = this.g.MeasureString(text, font, int.MaxValue, this.stringFormat); + return size.Width; + } + } + /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + protected virtual void Dispose(bool disposing) { // dispose images foreach (var i in this.imageCache) diff --git a/Source/OxyPlot.WindowsForms/OxyPlot.WindowsForms.csproj b/Source/OxyPlot.WindowsForms/OxyPlot.WindowsForms.csproj index fcd6337e4..6762f1830 100644 --- a/Source/OxyPlot.WindowsForms/OxyPlot.WindowsForms.csproj +++ b/Source/OxyPlot.WindowsForms/OxyPlot.WindowsForms.csproj @@ -20,7 +20,7 @@ - + diff --git a/Source/OxyPlot.Wpf/CanvasRenderContext.cs b/Source/OxyPlot.Wpf/CanvasRenderContext.cs index d9449b1af..78e5a7fdc 100644 --- a/Source/OxyPlot.Wpf/CanvasRenderContext.cs +++ b/Source/OxyPlot.Wpf/CanvasRenderContext.cs @@ -9,6 +9,7 @@ namespace OxyPlot.Wpf { + using OxyPlot.Rendering; using System; using System.Collections.Generic; using System.IO; @@ -27,7 +28,7 @@ namespace OxyPlot.Wpf /// /// Implements for . /// - public class CanvasRenderContext : IRenderContext + public class CanvasRenderContext : IRenderContext, ITextMeasurer { /// /// The maximum number of figures per geometry. @@ -101,8 +102,16 @@ public CanvasRenderContext(Canvas canvas) this.UseStreamGeometry = true; this.RendersToScreen = true; this.BalancedLineDrawingThicknessLimit = 3.5; + this.DefaultFontFamily = "Segoe UI"; + + this.TextArranger = new TextArranger(this, new SimpleTextTrimmer()); } + /// + /// Gets or sets the used by this instance. + /// + public TextArranger TextArranger { get; set; } + /// /// Gets or sets the text measurement method. /// @@ -133,6 +142,11 @@ public CanvasRenderContext(Canvas canvas) /// true if the context renders to screen; otherwise, false. public bool RendersToScreen { get; set; } + /// + /// Gets or sets the default font familiy. + /// + public string DefaultFontFamily { get; set; } + /// public void DrawEllipse(OxyRect rect, OxyColor fill, OxyColor stroke, double thickness, EdgeRenderingMode edgeRenderingMode) { @@ -404,9 +418,9 @@ public void DrawRectangles(IList rectangles, OxyColor fill, OxyColor st /// The font family. /// Size of the font (in device independent units, 1/96 inch). /// The font weight. - /// The rotation angle. - /// The horizontal alignment. - /// The vertical alignment. + /// The rotation angle. + /// The horizontal alignment. + /// The vertical alignment. /// The maximum size of the text (in device independent units, 1/96 inch). public void DrawText( ScreenPoint p, @@ -415,90 +429,51 @@ public void DrawRectangles(IList rectangles, OxyColor fill, OxyColor st string fontFamily, double fontSize, double fontWeight, - double rotate, - HorizontalAlignment halign, - VerticalAlignment valign, + double rotation, + HorizontalAlignment horizontalAlignment, + VerticalAlignment verticalAlignment, OxySize? maxSize) { - var tb = this.CreateAndAdd(); - tb.Text = text; - tb.Foreground = this.GetCachedBrush(fill); - if (fontFamily != null) - { - tb.FontFamily = this.GetCachedFontFamily(fontFamily); - } - - if (fontSize > 0) - { - tb.FontSize = fontSize; - } + fontFamily ??= this.DefaultFontFamily; + this.TextArranger.ArrangeText(p, text, fontFamily, fontSize, fontWeight, rotation, horizontalAlignment, verticalAlignment, maxSize, HorizontalAlignment.Left, TextVerticalAlignment.Top, out var lines, out var linePositions); - if (fontWeight > 0) + for (int i = 0; i < lines.Length; i++) { - tb.FontWeight = GetFontWeight(fontWeight); - } - - TextOptions.SetTextFormattingMode(tb, this.TextFormattingMode); + var line = lines[i]; + var linePosition = new Point(linePositions[i].X, linePositions[i].Y); - double dx = 0; - double dy = 0; + var tb = this.CreateAndAdd(); + tb.Text = line; + tb.Foreground = this.GetCachedBrush(fill); + tb.FontFamily = this.GetCachedFontFamily(fontFamily); - if (maxSize != null || halign != HorizontalAlignment.Left || valign != VerticalAlignment.Top) - { - tb.Measure(new Size(1000, 1000)); - var size = tb.DesiredSize; - if (maxSize != null) + if (fontSize > 0) { - if (size.Width > maxSize.Value.Width + 1e-3) - { - size.Width = Math.Max(maxSize.Value.Width, 0); - } - - if (size.Height > maxSize.Value.Height + 1e-3) - { - size.Height = Math.Max(maxSize.Value.Height, 0); - } - - tb.Width = size.Width; - tb.Height = size.Height; + tb.FontSize = fontSize; } - if (halign == HorizontalAlignment.Center) + if (fontWeight > 0) { - dx = -size.Width / 2; + tb.FontWeight = GetFontWeight(fontWeight); } - if (halign == HorizontalAlignment.Right) - { - dx = -size.Width; - } + TextOptions.SetTextFormattingMode(tb, this.TextFormattingMode); - if (valign == VerticalAlignment.Middle) + var transform = new TransformGroup(); + if (Math.Abs(rotation) > double.Epsilon) { - dy = -size.Height / 2; + transform.Children.Add(new RotateTransform(rotation)); } - if (valign == VerticalAlignment.Bottom) + transform.Children.Add(new TranslateTransform(linePosition.X, linePosition.Y)); + tb.RenderTransform = transform; + if (tb.Clip != null) { - dy = -size.Height; + tb.Clip.Transform = tb.RenderTransform.Inverse as Transform; } - } - - var transform = new TransformGroup(); - transform.Children.Add(new TranslateTransform(dx, dy)); - if (Math.Abs(rotate) > double.Epsilon) - { - transform.Children.Add(new RotateTransform(rotate)); - } - transform.Children.Add(new TranslateTransform(p.X, p.Y)); - tb.RenderTransform = transform; - if (tb.Clip != null) - { - tb.Clip.Transform = tb.RenderTransform.Inverse as Transform; + tb.SetValue(RenderOptions.ClearTypeHintProperty, ClearTypeHint.Enabled); } - - tb.SetValue(RenderOptions.ClearTypeHintProperty, ClearTypeHint.Enabled); } /// @@ -513,38 +488,8 @@ public void DrawRectangles(IList rectangles, OxyColor fill, OxyColor st /// public OxySize MeasureText(string text, string fontFamily, double fontSize, double fontWeight) { - if (string.IsNullOrEmpty(text)) - { - return OxySize.Empty; - } - - if (this.TextMeasurementMethod == TextMeasurementMethod.GlyphTypeface) - { - return this.MeasureTextByGlyphTypeface(text, fontFamily, fontSize, fontWeight); - } - - var tb = new TextBlock { Text = text }; - - TextOptions.SetTextFormattingMode(tb, this.TextFormattingMode); - - if (fontFamily != null) - { - tb.FontFamily = new FontFamily(fontFamily); - } - - if (fontSize > 0) - { - tb.FontSize = fontSize; - } - - if (fontWeight > 0) - { - tb.FontWeight = GetFontWeight(fontWeight); - } - - tb.Measure(new Size(1000, 1000)); - - return new OxySize(tb.DesiredSize.Width, tb.DesiredSize.Height); + fontFamily ??= this.DefaultFontFamily; + return this.TextArranger.MeasureText(text, fontFamily, fontSize, fontWeight); } /// @@ -635,6 +580,63 @@ public void ResetClip() this.clip = null; } + /// + public FontMetrics GetFontMetrics(string fontFamily, double fontSize, double fontWeight) + { + var typeface = new Typeface( + new FontFamily(fontFamily), FontStyles.Normal, GetFontWeight(fontWeight), FontStretches.Normal); + + if (!typeface.TryGetGlyphTypeface(out var glyphTypeface)) + { + return GetFontMetrics(this.DefaultFontFamily, fontSize, fontWeight); + throw new InvalidOperationException("No glyph typeface found"); + } + + var lineHeight = Math.Abs(glyphTypeface.Height) * fontSize; + var ascender = Math.Abs(typeface.FontFamily.Baseline) * fontSize; + var descender = lineHeight - ascender; + var leading = (Math.Abs(typeface.FontFamily.LineSpacing) * fontSize) - lineHeight; + + return new FontMetrics(ascender, descender, leading); + } + + /// + public double MeasureTextWidth(string text, string fontFamily, double fontSize, double fontWeight) + { + if (string.IsNullOrEmpty(text)) + { + return 0.0; + } + + if (this.TextMeasurementMethod == TextMeasurementMethod.GlyphTypeface) + { + return this.MeasureTextByGlyphTypeface(text, fontFamily, fontSize, fontWeight).Width; + } + + var tb = new TextBlock { Text = text }; + + TextOptions.SetTextFormattingMode(tb, this.TextFormattingMode); + + if (fontFamily != null) + { + tb.FontFamily = new FontFamily(fontFamily); + } + + if (fontSize > 0) + { + tb.FontSize = fontSize; + } + + if (fontWeight > 0) + { + tb.FontWeight = GetFontWeight(fontWeight); + } + + tb.Measure(new Size(1000, 1000)); + + return tb.DesiredSize.Width; + } + /// /// Cleans up resources not in use. /// @@ -1035,10 +1037,10 @@ private void DrawLineBalanced(IList points, OxyColor stroke, double } /// - /// Converts an to a . + /// Converts an to a . /// /// The rectangle. - /// A . + /// A . private static Rect ToRect(OxyRect r) { return new Rect(r.Left, r.Top, r.Width, r.Height); diff --git a/Source/OxyPlot.Wpf/OxyPlot.Wpf.csproj b/Source/OxyPlot.Wpf/OxyPlot.Wpf.csproj index 824f2f2c1..12757faf8 100644 --- a/Source/OxyPlot.Wpf/OxyPlot.Wpf.csproj +++ b/Source/OxyPlot.Wpf/OxyPlot.Wpf.csproj @@ -27,7 +27,7 @@ - - + + diff --git a/Source/OxyPlot/Pdf/PdfRenderContext.cs b/Source/OxyPlot/Pdf/PdfRenderContext.cs index 15c47d100..ae2e0b82a 100644 --- a/Source/OxyPlot/Pdf/PdfRenderContext.cs +++ b/Source/OxyPlot/Pdf/PdfRenderContext.cs @@ -13,11 +13,12 @@ namespace OxyPlot using System.Collections.Generic; using System.IO; using System.Linq; + using OxyPlot.Rendering; /// /// Implements an producing PDF documents by . /// - public class PdfRenderContext : RenderContextBase + public class PdfRenderContext : RenderContextBase, ITextMeasurer { /// /// The current document. @@ -46,8 +47,18 @@ public PdfRenderContext(double width, double height, OxyColor background) this.doc.SetFillColor(background); this.doc.FillRectangle(0, 0, width, height); } + + var textTrimmer = new SimpleTextTrimmer(); + textTrimmer.Ellipsis = SimpleTextTrimmer.AsciiEllipsis; + + this.TextArranger = new TextArranger(this, textTrimmer); } + /// + /// Gets or sets the for this instance. + /// + public TextArranger TextArranger { get; set; } + /// /// Saves the output to the specified stream. /// @@ -248,9 +259,9 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, /// The font family. /// Size of the font. /// The font weight. - /// The rotation angle. - /// The horizontal alignment. - /// The vertical alignment. + /// The rotation angle. + /// The horizontal alignment. + /// The vertical alignment. /// The maximum size of the text. public override void DrawText( ScreenPoint p, @@ -259,66 +270,33 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, string fontFamily, double fontSize, double fontWeight, - double rotate, - HorizontalAlignment halign, - VerticalAlignment valign, + double rotation, + HorizontalAlignment horizontalAlignment, + VerticalAlignment verticalAlignment, OxySize? maxSize) { this.doc.SaveState(); - this.doc.SetFont(fontFamily, fontSize / 96 * 72, fontWeight > 500); + this.doc.SetFont(fontFamily, ConvertToPoints(fontSize), fontWeight > 500); this.doc.SetFillColor(fill); - double width, height; - this.doc.MeasureText(text, out width, out height); - if (maxSize != null) - { - if (width > maxSize.Value.Width) - { - width = Math.Max(maxSize.Value.Width, 0); - } - - if (height > maxSize.Value.Height) - { - height = Math.Max(maxSize.Value.Height, 0); - } - } - - double dx = 0; - if (halign == HorizontalAlignment.Center) - { - dx = -width / 2; - } + this.doc.Translate(p.X, this.doc.PageHeight - p.Y); - if (halign == HorizontalAlignment.Right) + if (Math.Abs(rotation) > 1e-6) { - dx = -width; + this.doc.Rotate(-rotation); } - double dy = 0; + // arrange around the origin with no rotation, because PortableDocument does the rotation for us + this.TextArranger.ArrangeText(new ScreenPoint(0, 0), text, fontFamily, fontSize, fontWeight, 0.0, horizontalAlignment, verticalAlignment, maxSize, HorizontalAlignment.Left, TextVerticalAlignment.Bottom, out var lines, out var linePositions); - if (valign == VerticalAlignment.Middle) + for (int i = 0; i < lines.Length; i++) { - dy = -height / 2; - } + var line = lines[i]; + var linePosition = linePositions[i]; - if (valign == VerticalAlignment.Top) - { - dy = -height; + this.doc.DrawText(linePosition.X, -linePosition.Y, line); } - double y = this.doc.PageHeight - p.Y; - - this.doc.Translate(p.X, y); - if (Math.Abs(rotate) > 1e-6) - { - this.doc.Rotate(-rotate); - } - - this.doc.Translate(dx, dy); - - // this.doc.DrawRectangle(0, 0, width, height); - this.doc.SetClippingRectangle(0, 0, width, height); - this.doc.DrawText(0, 0, text); this.doc.RestoreState(); } @@ -332,10 +310,7 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, /// The text size. public override OxySize MeasureText(string text, string fontFamily, double fontSize, double fontWeight) { - this.doc.SetFont(fontFamily, fontSize / 96 * 72, fontWeight > 500); - double width, height; - this.doc.MeasureText(text, out width, out height); - return new OxySize(width, height); + return this.TextArranger.MeasureText(text, fontFamily, fontSize, fontWeight); } /// @@ -410,6 +385,22 @@ public override void ResetClip() this.doc.RestoreState(); } + /// + public FontMetrics GetFontMetrics(string fontFamily, double fontSize, double fontWeight) + { + var font = PortableDocument.GetFont(fontFamily, fontWeight > 500, false); + return font.GetFontMetrics(ConvertToPoints(fontSize)); + } + + /// + public double MeasureTextWidth(string text, string fontFamily, double fontSize, double fontWeight) + { + this.doc.SetFont(fontFamily, ConvertToPoints(fontSize), fontWeight > 500); + double width, height; + this.doc.MeasureText(text, out width, out height); + return width; + } + /// /// Converts the specified to a . /// @@ -428,6 +419,16 @@ private static LineJoin Convert(LineJoin lineJoin) } } + /// + /// Converts nominal units (1/96 inch) to points (1/72 inch). + /// + /// The measure in nominal units. + /// The measure in points. + private static double ConvertToPoints(double nominalUnits) + { + return nominalUnits / 96 * 72; + } + /// /// Sets the width of the line. /// @@ -435,7 +436,7 @@ private static LineJoin Convert(LineJoin lineJoin) private void SetLineWidth(double thickness) { // Convert from 1/96 inch to points - this.doc.SetLineWidth(thickness / 96 * 72); + this.doc.SetLineWidth(ConvertToPoints(thickness)); } /// @@ -445,7 +446,7 @@ private void SetLineWidth(double thickness) /// The dash phase (in 1/96 inch units). private void SetLineDashPattern(double[] dashArray, double dashPhase) { - this.doc.SetLineDashPattern(dashArray.Select(d => d / 96 * 72).ToArray(), dashPhase / 96 * 72); + this.doc.SetLineDashPattern(dashArray.Select(ConvertToPoints).ToArray(), ConvertToPoints(dashPhase)); } } } diff --git a/Source/OxyPlot/Pdf/PortableDocument.cs b/Source/OxyPlot/Pdf/PortableDocument.cs index e42987313..4918c1a3d 100644 --- a/Source/OxyPlot/Pdf/PortableDocument.cs +++ b/Source/OxyPlot/Pdf/PortableDocument.cs @@ -950,7 +950,7 @@ private static string Ascii85Encode(byte[] ba) /// Use bold if set to true. /// Use italic if set to true. /// The font. - private static PortableDocumentFont GetFont(string fontName, bool bold, bool italic) + public static PortableDocumentFont GetFont(string fontName, bool bold, bool italic) { if (fontName != null) { @@ -1279,4 +1279,4 @@ public void Write(PdfWriter w) } } } -} \ No newline at end of file +} diff --git a/Source/OxyPlot/Pdf/PortableDocumentFont.cs b/Source/OxyPlot/Pdf/PortableDocumentFont.cs index c1dbdcb7a..4c85e8d78 100644 --- a/Source/OxyPlot/Pdf/PortableDocumentFont.cs +++ b/Source/OxyPlot/Pdf/PortableDocumentFont.cs @@ -9,6 +9,10 @@ namespace OxyPlot { + using System; + + using OxyPlot.Rendering; + /// /// Represents a font that can be used in a . /// @@ -132,5 +136,19 @@ public void Measure(string text, double fontSize, out double width, out double h width = wmax * fontSize / 1000; height = lineCount * (this.Ascent - this.Descent) * fontSize / 1000; } + + /// + /// Gets minimal font metrics for the font. + /// + /// The font size. + /// The font metrics. + public FontMetrics GetFontMetrics(double fontSize) + { + var ascender = fontSize * Math.Abs(this.Ascent) / 1000; + var descender = fontSize * Math.Abs(this.Descent) / 1000; + var leading = 0.0; + + return new FontMetrics(ascender, descender, leading); + } } } diff --git a/Source/OxyPlot/Rendering/FontMetrics.cs b/Source/OxyPlot/Rendering/FontMetrics.cs new file mode 100644 index 000000000..3770f8b73 --- /dev/null +++ b/Source/OxyPlot/Rendering/FontMetrics.cs @@ -0,0 +1,62 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2020 OxyPlot contributors +// +// +// Contains metrics for a given font. +// +// -------------------------------------------------------------------------------------------------------------------- + +using System; + +namespace OxyPlot.Rendering +{ + /// + /// Contains metrics for a given font. + /// + public class FontMetrics + { + /// + /// Initializes a new instance of the class. + /// + /// The ascender. + /// The descender. + /// The leading. + public FontMetrics(double ascender, double descender, double leading) + { + if (ascender < 0 || descender < 0 || leading < 0) + { + throw new ArgumentException("All font metrics must be non-negative."); + } + + Ascender = ascender; + Descender = descender; + Leading = leading; + } + + /// + /// Gets the distance from the baseline to the top of the font. + /// + public double Ascender { get; } + + /// + /// Gets the distance from the baseline to the bottom of the font. + /// + public double Descender { get; } + + /// + /// Gets the distance between the bottom of a line of text and the top of the next line of text. + /// + public double Leading { get; } + + /// + /// Gets the cell height of the font, equal to the sum of the and . + /// + public double CellHeight => this.Ascender + this.Descender; + + /// + /// Gets the line height of the font, equal to the sum of the and . + /// + public double LineHeight => this.CellHeight + this.Leading; + } +} diff --git a/Source/OxyPlot/Rendering/ITextMeasurer.cs b/Source/OxyPlot/Rendering/ITextMeasurer.cs new file mode 100644 index 000000000..ef8dc51ce --- /dev/null +++ b/Source/OxyPlot/Rendering/ITextMeasurer.cs @@ -0,0 +1,36 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2020 OxyPlot contributors +// +// +// Measures text. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace OxyPlot.Rendering +{ + /// + /// Measures text. + /// + public interface ITextMeasurer + { + /// + /// Determines basic font metrics for the given font. + /// + /// The font family. + /// The font size in 1/96ths of an inch. + /// The font weight. + /// The font metrics. + FontMetrics GetFontMetrics(string fontFamily, double fontSize, double fontWeight); + + /// + /// Measures the width of one line of text. + /// + /// The single line of text to measure. + /// The font family. + /// The font size in 1/96ths of an inch. + /// The font weight. + /// The width in pixels of the text. + double MeasureTextWidth(string text, string fontFamily, double fontSize, double fontWeight); + } +} diff --git a/Source/OxyPlot/Rendering/ITextTrimmer.cs b/Source/OxyPlot/Rendering/ITextTrimmer.cs new file mode 100644 index 000000000..9da2128fc --- /dev/null +++ b/Source/OxyPlot/Rendering/ITextTrimmer.cs @@ -0,0 +1,29 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2020 OxyPlot contributors +// +// +// Trims text. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace OxyPlot.Rendering +{ + /// + /// Trims text. + /// + public interface ITextTrimmer + { + /// + /// Trims the given line of text so that it fits in the given width. + /// + /// The to use. + /// The line of text to trim. + /// The width in which the text must fix. + /// The font family. + /// The font size in 1/96ths of an inch. + /// The font weight. + /// The trimmed line of text. + string Trim(ITextMeasurer textMeasurer, string line, double width, string fontFamily, double fontSize, double fontWeight); + } +} diff --git a/Source/OxyPlot/Rendering/SimpleTextTrimmer.cs b/Source/OxyPlot/Rendering/SimpleTextTrimmer.cs new file mode 100644 index 000000000..d2c6c55f0 --- /dev/null +++ b/Source/OxyPlot/Rendering/SimpleTextTrimmer.cs @@ -0,0 +1,216 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2020 OxyPlot contributors +// +// +// A simple trimmer that doesn't use glyph information. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace OxyPlot.Rendering +{ + using System; + using System.Collections.Generic; + using System.Text.RegularExpressions; + + /// + /// A simple trimmer that doesn't use glyph information. + /// + public class SimpleTextTrimmer : ITextTrimmer + { + /// + /// The default ellipsis, comprising three ascii stop symbols. + /// + public static readonly string AsciiEllipsis = "..."; + + /// + /// The unicode horizontal ellipsis. + /// + public static readonly string UnicodeEllipsis = "…"; + + /// + /// Initializes a new instance of the class. + /// + public SimpleTextTrimmer() + { + this.TrimToWord = false; + this.Ellipsis = UnicodeEllipsis; + this.AppendEllipsis = true; + } + + /// + /// Gets or sets a value indicating whether text should be trimmed to word boundaries. + /// + public bool TrimToWord { get; set; } + + /// + /// Gets or sets a value to append to any trimmed text when is true. + /// + public string Ellipsis { get; set; } + + /// + /// Gets or sets a value indicating whether an ellipsis should be appended to any trimmed text. + /// + public bool AppendEllipsis { get; set; } + + /// + /// Gets a list of indexes that correspond to the first c after a character in the single line of text given. + /// + /// The text. + /// The list of character boundaries. + public static IList GetCharacterBoundaries(string text) + { + var res = new List(); + + // handle surrogate pairs explicitly + var matches = Regex.Matches(text, @"([\uD800-\uDBFF}][\uDC00-\uDFFF]|[^\s])\p{M}*", RegexOptions.Singleline); + for (int i = 0; i < matches.Count; i++) + { + var m = matches[i]; + res.Add(m.Index + m.Length); + } + + return res; + } + + /// + /// Gets a list of indexes that correspond to the first char after a word in the single line of text given. + /// + /// The text. + /// The list of word boundaries. + public static IList GetWordBoundaries(string text) + { + var res = new List(); + + var matches = Regex.Matches(text, @"[^\s]+", RegexOptions.Singleline); + for (int i = 0; i < matches.Count; i++) + { + var m = matches[i]; + res.Add(m.Index + m.Length); + } + + return res; + } + + /// + /// Trims the given text at the given boundaries. + /// + /// The to use. + /// The line of text to trim. + /// The width in which the text must fix. + /// The font family. + /// The font size in 1/96ths of an inch. + /// The font weight. + /// The ellipsis, if any, to append to the end of the text. + /// The word boundaries at which to trim. + /// The trimmed line of text. + public static string TrimAtBoundaries(ITextMeasurer textMeasurer, string line, double width, string fontFamily, double fontSize, double fontWeight, string ellipsis, IList boundaries) + { + if (BoundaryCheck(textMeasurer, line, width, fontFamily, fontSize, fontWeight, ellipsis, out var boundaryResult)) + { + return boundaryResult; + } + + ellipsis = ellipsis ?? string.Empty; + + int s = -1; + int e = boundaries.Count - 1; + + while (e > s) + { + var m = s + ((e - s + 1) / 2); + var lineWidth = textMeasurer.MeasureTextWidth(line.Substring(0, boundaries[m]) + ellipsis, fontFamily, fontSize, fontWeight); + + if (lineWidth > width) + { + e = m - 1; + } + else + { + s = m; + } + } + + if (s == -1) + { + // TODO: need to think about this condition? + return ellipsis; + } + else + { + return line.Substring(0, boundaries[s]) + ellipsis; + } + } + + /// + public string Trim(ITextMeasurer textMeasurer, string line, double width, string fontFamily, double fontSize, double fontWeight) + { + var ellipsis = this.AppendEllipsis ? this.Ellipsis : null; + + if (this.TrimToWord) + { + var boundaries = GetWordBoundaries(line); + return TrimAtBoundaries(textMeasurer, line, width, fontFamily, fontSize, fontWeight, ellipsis, boundaries); + } + else + { + var boundaries = GetCharacterBoundaries(line); + return TrimAtBoundaries(textMeasurer, line, width, fontFamily, fontSize, fontWeight, ellipsis, boundaries); + } + } + + /// + /// Performs common boundary condition checks. Returns true if the should be returned immediately in lieu of other trimming. + /// + /// The to use. + /// The line of text to trim. + /// The width in which the text must fix. + /// The font family. + /// The font size in 1/96ths of an inch. + /// The font weight. + /// The ellipsis, if any, to append to the end of the text. + /// The resulting text if a boundary condition was observed, otherwise null. + /// true if a boundary condition was met, otherwise false. + private static bool BoundaryCheck(ITextMeasurer textMeasurer, string line, double width, string fontFamily, double fontSize, double fontWeight, string ellipsis, out string boundaryResult) + { + if (textMeasurer == null) + { + throw new ArgumentNullException(nameof(textMeasurer)); + } + + if (line == null) + { + throw new ArgumentNullException(nameof(line)); + } + + if (width <= 0) + { + // nothing will fit + boundaryResult = string.Empty; + return true; + } + + var lineWidth = textMeasurer.MeasureTextWidth(line, fontFamily, fontSize, fontWeight); + if (lineWidth <= width) + { + // do nothing + boundaryResult = line; + return true; + } + + if (ellipsis != null) + { + var ellipsisWidth = textMeasurer.MeasureTextWidth(ellipsis, fontFamily, fontSize, fontWeight); + if (width < ellipsisWidth) + { + // ellipsis won't fit + boundaryResult = string.Empty; + return true; + } + } + + boundaryResult = null; + return false; + } + } +} diff --git a/Source/OxyPlot/Rendering/TextArranger.cs b/Source/OxyPlot/Rendering/TextArranger.cs new file mode 100644 index 000000000..6063da917 --- /dev/null +++ b/Source/OxyPlot/Rendering/TextArranger.cs @@ -0,0 +1,259 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2020 OxyPlot contributors +// +// +// Arranges text. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace OxyPlot.Rendering +{ + using System; + using System.Linq; + using System.Xml.Linq; + + /// + /// Arranges text. + /// + public class TextArranger + { + /// + /// Initializes a new instance of the class. + /// + /// The to be used by this instance. + /// The to be used by this instance. + public TextArranger(ITextMeasurer textMeasurer, ITextTrimmer textTrimmer) + { + this.TextMeasurer = textMeasurer ?? throw new ArgumentNullException(nameof(textMeasurer)); + this.TextTrimmer = textTrimmer ?? throw new ArgumentNullException(nameof(textTrimmer)); + + this.SquashTrimmedTextBounds = false; + } + + /// + /// Gets or sets the used by this instance. + /// + public ITextMeasurer TextMeasurer { get; set; } + + /// + /// Gets or sets the used by this instance. + /// + public ITextTrimmer TextTrimmer { get; set; } + + /// + /// Gets or sets a value indicating whether to squash the bounds of trimmed text to the size the text. + /// + public bool SquashTrimmedTextBounds { get; set; } + + /// + /// Splits the text into multiple lines, and indicates where they should be rendered given the given target alignment. + /// + /// The position. + /// The text. + /// The font family. + /// Size of the font (in device independent units, 1/96 inch). + /// The font weight. + /// The rotation angle. + /// The horizontal alignment. + /// The vertical alignment. + /// The maximum size of the text (in device independent units, 1/96 inch). If set to null, the text will not be clipped. + /// The horixontal alignment used to render the text. + /// The vertical alignment used to render the text. + /// The separate lines of text. + /// The point at which to render each line. + /// + /// Non-null is not supported. + /// + public void ArrangeText( + ScreenPoint p, + string text, + string fontFamily, + double fontSize, + double fontWeight, + double rotation, + HorizontalAlignment horizontalAlignment, + VerticalAlignment verticalAlignment, + OxySize? maxSize, + HorizontalAlignment targetHorizontalAlignment, + TextVerticalAlignment targetVerticalAlignment, + out string[] lines, + out ScreenPoint[] linePositions) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + + // measure the size of the whole text + var wholeSize = this.MeasureText(text, fontFamily, fontSize, fontWeight); + + // infer font metrics from wholeSize + var metrics = this.TextMeasurer.GetFontMetrics(fontFamily, fontSize, fontWeight); + var lineHeight = metrics.CellHeight; + var leading = metrics.Leading; + var descender = metrics.Descender; + + // get the rendering bounds from maxSize if necessary + var bounds = wholeSize; + if (maxSize != null) + { + bounds = new OxySize(Math.Ceiling(Math.Min(bounds.Width, maxSize.Value.Width)), Math.Ceiling(Math.Min(bounds.Height, maxSize.Value.Height))); + } + + // split into lines + lines = StringHelper.SplitLines(text); + + // if the text is too tall, we need to remove some lines + if (bounds.Height < wholeSize.Height && lines.Length > 1) + { + var clippedLineCount = 1 + (int)((bounds.Height - lineHeight) / (lineHeight + leading)); + lines = lines.Take(clippedLineCount).ToArray(); + + if (this.SquashTrimmedTextBounds) + { + bounds = new OxySize(bounds.Width, ((lineHeight + leading) * lines.Length) - leading); + } + } + + // if the text is too wide, we need to trim the lines down a bit + if (bounds.Width < wholeSize.Width) + { + for (int i = 0; i < lines.Length; i++) + { + lines[i] = this.TextTrimmer.Trim(this.TextMeasurer, lines[i], bounds.Width, fontFamily, fontSize, fontWeight); + } + + if (this.SquashTrimmedTextBounds) + { + bounds = new OxySize(lines.Max(l => this.TextMeasurer.MeasureTextWidth(l, fontFamily, fontSize, fontWeight)), bounds.Height); + } + } + + // do these once + var sin = Math.Sin(rotation / 180.0 * Math.PI); + var cos = Math.Cos(rotation / 180.0 * Math.PI); + + // turn metrics into vectors + var offsetBoundsWidth = new ScreenVector(cos * bounds.Width, sin * bounds.Width); + var offsetBoundsHeight = new ScreenVector(-sin * bounds.Height, cos * bounds.Height); + + var offsetLineHeight = new ScreenVector(-sin * lineHeight, cos * lineHeight); + var offsetLeading = new ScreenVector(-sin * leading, cos * leading); + var offsetDescender = new ScreenVector(-sin * descender, cos * descender); + + // align to bounds + var offsetBoundsX = offsetBoundsWidth * 0.0; // keep centerline + var offsetBoundsY = offsetBoundsHeight * (ResolveOffset(verticalAlignment) - 0.5); // find the top of the top line + + p += offsetBoundsX + offsetBoundsY; + + // align lines within bounds + bool useBaselineOffset = targetVerticalAlignment == TextVerticalAlignment.Baseline; + if (useBaselineOffset) + { + targetVerticalAlignment = TextVerticalAlignment.Bottom; + } + + var offsetLineXRelative = ResolveOffset(targetHorizontalAlignment) - ResolveOffset(horizontalAlignment); // multiply later when we know the line width + var offsetLineY = offsetLineHeight * (0.5 - ResolveOffset((VerticalAlignment)targetVerticalAlignment)); + + if (useBaselineOffset) + { + offsetLineY -= offsetDescender; + } + + // position the lines + linePositions = new ScreenPoint[lines.Length]; + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var lineWidth = this.TextMeasurer.MeasureTextWidth(line, fontFamily, fontSize, fontWeight); + + var offsetLineWidth = new ScreenVector(cos * lineWidth, sin * lineWidth); + + var offsetLineX = offsetLineWidth * offsetLineXRelative; + + linePositions[i] = p + ((offsetLineHeight + offsetLeading) * i) + offsetLineX + offsetLineY; + } + } + + /// + /// Measures the size of the text as it would be when arranged by the ArrangeText method."/>. + /// + /// The text. + /// The font family. + /// Size of the font (in device independent units, 1/96 inch). + /// The font weight. + /// The size of the text. + /// Returns an with no width and height if the text is null or empty. + public OxySize MeasureText( + string text, + string fontFamily, + double fontSize, + double fontWeight) + { + if (string.IsNullOrEmpty(text)) + { + // It is a bit of a lie to do this here, but otherwise everyone else has to do it + return OxySize.Empty; + } + + var lines = StringHelper.SplitLines(text); + + var width = lines.Max(l => this.TextMeasurer.MeasureTextWidth(l, fontFamily, fontSize, fontWeight)); + + var metrics = this.TextMeasurer.GetFontMetrics(fontFamily, fontSize, fontWeight); + + var cellHeight = metrics.CellHeight; + var leading = metrics.Leading; + var lineCount = lines.Length; + + var height = ((cellHeight + leading) * lineCount) - leading; + + return new OxySize(width, height); + } + + /// + /// Translates a into a relative offset. + /// + /// The horizontal alignent. + /// The offset. + /// + /// Left -> -0.5 + /// Center -> 0.0 + /// Right -> +0.5 + /// + private static double ResolveOffset(HorizontalAlignment horizontalAlignment) + { + return horizontalAlignment switch + { + HorizontalAlignment.Left => -0.5, + HorizontalAlignment.Center => 0.0, + HorizontalAlignment.Right => 0.5, + _ => throw new ArgumentException(nameof(horizontalAlignment)), + }; + } + + /// + /// Translates a into a relative offset. + /// + /// The vertical alignment. + /// The offset. + /// + /// Top -> +0.5 + /// Middle -> 0.0 + /// Bottom -> -0.5 + /// + private static double ResolveOffset(VerticalAlignment verticalAlignment) + { + return verticalAlignment switch + { + VerticalAlignment.Top => 0.5, + VerticalAlignment.Middle => 0.0, + VerticalAlignment.Bottom => -0.5, + _ => throw new ArgumentException(nameof(verticalAlignment)), + }; + } + } +} diff --git a/Source/OxyPlot/Rendering/TextVerticalAlignment.cs b/Source/OxyPlot/Rendering/TextVerticalAlignment.cs new file mode 100644 index 000000000..8d076b00f --- /dev/null +++ b/Source/OxyPlot/Rendering/TextVerticalAlignment.cs @@ -0,0 +1,37 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) 2020 OxyPlot contributors +// +// +// Specifies vertical alignment for text. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace OxyPlot +{ + /// + /// Specifies vertical alignment for text. + /// + public enum TextVerticalAlignment + { + /// + /// Aligned at the top. + /// + Top = -1, + + /// + /// Aligned in the middle. + /// + Middle = 0, + + /// + /// Aligned at the bottom. + /// + Bottom = 1, + + /// + /// Aligned at the baseline. + /// + Baseline = 2, + } +} diff --git a/Source/OxyPlot/Svg/SvgExporter.cs b/Source/OxyPlot/Svg/SvgExporter.cs index 618589ef5..145c9e862 100644 --- a/Source/OxyPlot/Svg/SvgExporter.cs +++ b/Source/OxyPlot/Svg/SvgExporter.cs @@ -9,6 +9,7 @@ namespace OxyPlot { + using OxyPlot.Rendering; using System.IO; /// @@ -41,15 +42,10 @@ public SvgExporter() /// public bool IsDocument { get; set; } - /// - /// Gets or sets a value indicating whether to use a workaround for vertical text alignment to support renderers with limited support for the dominate-baseline attribute. - /// - public bool UseVerticalTextAlignmentWorkaround { get; set; } - /// /// Gets or sets the text measurer. /// - public IRenderContext TextMeasurer { get; set; } + public ITextMeasurer TextMeasurer { get; set; } /// /// Exports the specified model to a stream. @@ -60,15 +56,14 @@ public SvgExporter() /// The height (points). /// if set to true, the xml headers will be included (?xml and !DOCTYPE). /// The text measurer. - /// Whether to use the workaround for vertical text alignment - public static void Export(IPlotModel model, Stream stream, double width, double height, bool isDocument, IRenderContext textMeasurer = null, bool useVerticalTextAlignmentWorkaround = false) + public static void Export(IPlotModel model, Stream stream, double width, double height, bool isDocument, ITextMeasurer textMeasurer = null) { if (textMeasurer == null) { textMeasurer = new PdfRenderContext(width, height, model.Background); } - using (var rc = new SvgRenderContext(stream, width, height, isDocument, textMeasurer, model.Background, useVerticalTextAlignmentWorkaround)) + using (var rc = new SvgRenderContext(stream, width, height, isDocument, textMeasurer, model.Background)) { model.Update(true); model.Render(rc, new OxyRect(0, 0, width, height)); @@ -86,13 +81,12 @@ public static void Export(IPlotModel model, Stream stream, double width, double /// if set to true, the xml headers will be included (?xml and !DOCTYPE). /// The text measurer. /// The plot as an SVG string. - /// Whether to use the workaround for vertical text alignment - public static string ExportToString(IPlotModel model, double width, double height, bool isDocument, IRenderContext textMeasurer = null, bool useVerticalTextAlignmentWorkaround = false) + public static string ExportToString(IPlotModel model, double width, double height, bool isDocument, ITextMeasurer textMeasurer = null) { string svg; using (var ms = new MemoryStream()) { - Export(model, ms, width, height, isDocument, textMeasurer, useVerticalTextAlignmentWorkaround); + Export(model, ms, width, height, isDocument, textMeasurer); ms.Flush(); ms.Position = 0; var sr = new StreamReader(ms); @@ -109,7 +103,7 @@ public static string ExportToString(IPlotModel model, double width, double heigh /// The target stream. public void Export(IPlotModel model, Stream stream) { - Export(model, stream, this.Width, this.Height, this.IsDocument, this.TextMeasurer, this.UseVerticalTextAlignmentWorkaround); + Export(model, stream, this.Width, this.Height, this.IsDocument, this.TextMeasurer); } /// @@ -119,7 +113,7 @@ public void Export(IPlotModel model, Stream stream) /// the SVG content as a string. public string ExportToString(IPlotModel model) { - return ExportToString(model, this.Width, this.Height, this.IsDocument, this.TextMeasurer, this.UseVerticalTextAlignmentWorkaround); + return ExportToString(model, this.Width, this.Height, this.IsDocument, this.TextMeasurer); } } } diff --git a/Source/OxyPlot/Svg/SvgRenderContext.cs b/Source/OxyPlot/Svg/SvgRenderContext.cs index 4166680ba..714aabfd5 100644 --- a/Source/OxyPlot/Svg/SvgRenderContext.cs +++ b/Source/OxyPlot/Svg/SvgRenderContext.cs @@ -12,6 +12,7 @@ namespace OxyPlot using System; using System.Collections.Generic; using System.IO; + using OxyPlot.Rendering; /// /// Provides a render context for scalable vector graphics output. @@ -37,8 +38,7 @@ public class SvgRenderContext : RenderContextBase, IDisposable /// Create an SVG document if set to true. /// The text measurer. /// The background. - /// Whether to use the workaround for vertical text alignment. - public SvgRenderContext(Stream s, double width, double height, bool isDocument, IRenderContext textMeasurer, OxyColor background, bool useVerticalTextAlignmentWorkaround = false) + public SvgRenderContext(Stream s, double width, double height, bool isDocument, ITextMeasurer textMeasurer, OxyColor background) { if (textMeasurer == null) { @@ -46,8 +46,7 @@ public SvgRenderContext(Stream s, double width, double height, bool isDocument, } this.w = new SvgWriter(s, width, height, isDocument); - this.TextMeasurer = textMeasurer; - this.UseVerticalTextAlignmentWorkaround = useVerticalTextAlignmentWorkaround; + this.TextArranger = new TextArranger(textMeasurer, new SimpleTextTrimmer()); if (background.IsVisible()) { @@ -56,15 +55,10 @@ public SvgRenderContext(Stream s, double width, double height, bool isDocument, } /// - /// Gets or sets the text measurer. + /// Gets or sets the used by this instance. /// - /// The text measurer. - public IRenderContext TextMeasurer { get; set; } - - /// - /// Gets or sets a value indicating whether to use a workaround for vertical text alignment to support renderers with limited support for the dominate-baseline attribute. - /// - public bool UseVerticalTextAlignmentWorkaround { get; set; } + /// The text arranger. + public TextArranger TextArranger { get; set; } /// /// Closes the svg writer. @@ -137,7 +131,7 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, /// The font family. /// Size of the font. /// The font weight. - /// The rotate. + /// The rotation. /// The horizontal alignment. /// The vertical alignment. /// Size of the max. @@ -148,7 +142,7 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, string fontFamily, double fontSize, double fontWeight, - double rotate, + double rotation, HorizontalAlignment halign, VerticalAlignment valign, OxySize? maxSize) @@ -158,51 +152,19 @@ public override void DrawRectangle(OxyRect rect, OxyColor fill, OxyColor stroke, return; } - var lines = StringHelper.SplitLines(text); + this.TextArranger.ArrangeText(p, text, fontFamily, fontSize, fontWeight, rotation, halign, valign, maxSize, halign, TextVerticalAlignment.Baseline, out var lines, out var linePositions); - var textSize = this.MeasureText(text, fontFamily, fontSize, fontWeight); - var lineHeight = textSize.Height / lines.Length; - var lineOffset = new ScreenVector(-Math.Sin(rotate / 180.0 * Math.PI) * lineHeight, +Math.Cos(rotate / 180.0 * Math.PI) * lineHeight); - - if (this.UseVerticalTextAlignmentWorkaround) + for (int i = 0; i < lines.Length; i++) { - // offset the position, and set the valign to neutral value of `Bottom` - double offsetRatio = valign == VerticalAlignment.Bottom ? (1.0 - lines.Length) : valign == VerticalAlignment.Top ? 1.0 : (1.0 - (lines.Length / 2.0)); - valign = VerticalAlignment.Bottom; - - p += lineOffset * offsetRatio; + var line = lines[i]; + var linePosition = linePositions[i]; - foreach (var line in lines) - { - var size = this.MeasureText(line, fontFamily, fontSize, fontWeight); - this.w.WriteText(p, line, c, fontFamily, fontSize, fontWeight, rotate, halign, valign); - - p += lineOffset; - } - } - else - { - if (valign == VerticalAlignment.Bottom) + if (string.IsNullOrWhiteSpace(line)) { - for (var i = lines.Length - 1; i >= 0; i--) - { - var line = lines[i]; - var size = this.MeasureText(line, fontFamily, fontSize, fontWeight); - this.w.WriteText(p, line, c, fontFamily, fontSize, fontWeight, rotate, halign, valign); - - p -= lineOffset; - } + continue; } - else - { - foreach (var line in lines) - { - var size = this.MeasureText(line, fontFamily, fontSize, fontWeight); - this.w.WriteText(p, line, c, fontFamily, fontSize, fontWeight, rotate, halign, valign); - p += lineOffset; - } - } + this.w.WriteText(linePosition, line, c, fontFamily, fontSize, fontWeight, rotation, halign, VerticalAlignment.Bottom); } } @@ -224,12 +186,7 @@ public void Flush() /// The text size. public override OxySize MeasureText(string text, string fontFamily, double fontSize, double fontWeight) { - if (string.IsNullOrEmpty(text)) - { - return OxySize.Empty; - } - - return this.TextMeasurer.MeasureText(text, fontFamily, fontSize, fontWeight); + return this.TextArranger.MeasureText(text, fontFamily, fontSize, fontWeight); } /// @@ -263,10 +220,10 @@ public override OxySize MeasureText(string text, string fontFamily, double fontS } /// - /// Releases unmanaged and - optionally - managed resources + /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - private void Dispose(bool disposing) + protected virtual void Dispose(bool disposing) { if (!this.disposed) {