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)
{