diff --git a/AGENTS.md b/AGENTS.md index 6f642deba..ff855f896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,4 +39,4 @@ On Linux (or with `SHINYPDF_DEVCONTAINER=true`) the Linux native assets are adde ## Current focus -Open issues: more underline options (#11), template designer app (#12). Markdown follow-ups not yet built: tables, images, task lists. +Open issues: template designer app (#12). Markdown follow-ups not yet built: tables, images, task lists. diff --git a/docs/reference/text.md b/docs/reference/text.md index 14046e964..ed28b9474 100644 --- a/docs/reference/text.md +++ b/docs/reference/text.md @@ -198,6 +198,9 @@ container.Column(column => | Italic | `Italic(bool value = true)` | same | | Underline | `Underline(bool value = true)` | same | | Strikethrough | `Strikethrough(bool value = true)` | same | +| Decoration line | `DecorationColor(string)`, `DecorationThickness(float)`, `DecorationSolid()`, `DecorationDouble()`, `DecorationDotted()`, `DecorationDashed()`, `DecorationWavy()` | same | +| Underline position | `UnderlinePositionAuto()`, `UnderlineAtBaseline()`, `UnderlineBelowGlyphs()` | same | +| Strikethrough position | `StrikethroughThroughGlyphs()`, `StrikethroughAtBaseline()` | same | | Position | `NormalPosition()`, `Subscript()`, `Superscript()` | same | | Wrapping | `WrapAnywhere(bool value = true)` | same | | Direction | `DirectionAuto()`, `DirectionFromLeftToRight()`, `DirectionFromRightToLeft()` | same | @@ -255,12 +258,6 @@ container.Text(text => `Italic()`, `Underline()` and `Strikethrough()` accept an optional `bool`, so you can switch an inherited decoration off, for example `Underline(false)`. -Decoration lines use the text color and the position and thickness defined by the font. There are no options for line color, thickness or style. Current behavior with sub- and superscript: - -- Superscript: the underline is drawn at the position of normal-sized text, so it lines up with the surrounding text. -- Subscript: the underline follows the lowered, smaller glyphs. -- Strikethrough follows the glyphs; its thickness is scaled to 62.5% for sub- and superscript. - ```csharp container.Text(text => { @@ -272,6 +269,65 @@ container.Text(text => }); ``` +#### Decoration line color, thickness and style + +By default, decoration lines use the text color, the thickness defined by the font, and a solid stroke. These settings apply to both underline and strikethrough: + +- `DecorationColor(string value)`: line color, independent of the text color. Same color formats and validation as `FontColor`. +- `DecorationThickness(float value)`: line thickness in points. Values `<= 0` throw `ArgumentException`. For `DecorationDouble()` it is the thickness of each of the two lines. +- `DecorationSolid()` (default), `DecorationDouble()`, `DecorationDotted()`, `DecorationDashed()`, `DecorationWavy()`: line style. Dots, dashes and waves scale with the thickness. + +```csharp +container.Text(text => +{ + text.Span("spelling mistake").Underline().DecorationWavy().DecorationColor(Colors.Red.Medium); + text.Span(" and "); + text.Span("removed").Strikethrough().DecorationDouble().DecorationThickness(0.75f); +}); +``` + +#### Underline position with sub- and superscript + +The underline position decides where underlines of sub- and superscript spans are drawn. Normal text is not affected. + +| Method | Superscript | Subscript | +|---|---|---| +| `UnderlinePositionAuto()` (default) | at the position of normal-sized text, in line with the surrounding text | follows the lowered, smaller glyphs | +| `UnderlineAtBaseline()` | at the position of normal-sized text | at the position of normal-sized text | +| `UnderlineBelowGlyphs()` | directly below the raised glyphs | follows the lowered, smaller glyphs | + +Use `UnderlineAtBaseline()` for a continuous underline across formulas like `H₂O`. + +```csharp +container.Text(text => +{ + text.DefaultTextStyle(x => x.Underline().UnderlineAtBaseline()); + text.Span("H"); + text.Span("2").Subscript(); + text.Span("O"); +}); +``` + +#### Strikethrough position with sub- and superscript + +The strikethrough position decides where strikethroughs of sub- and superscript spans are drawn. Normal text is not affected. + +| Method | Sub- and superscript | +|---|---| +| `StrikethroughThroughGlyphs()` (default) | through the shifted, smaller glyphs; unless `DecorationThickness` is set, the thickness is scaled to 62.5% | +| `StrikethroughAtBaseline()` | at the position and thickness of normal-sized text, in line with the surrounding text | + +Use `StrikethroughAtBaseline()` for a single continuous line across formulas like `E=mc²`. + +```csharp +container.Text(text => +{ + text.DefaultTextStyle(x => x.Strikethrough().StrikethroughAtBaseline()); + text.Span("E=mc"); + text.Span("2").Superscript(); +}); +``` + ### Subscript and superscript `Subscript()` and `Superscript()` render the span at 62.5% of the font size, shifted down by 10% or up by 35% of the font size. To keep strokes visually as thick as the surrounding text, the font is matched one weight step heavier (+100). `NormalPosition()` resets an inherited position. @@ -396,6 +452,8 @@ Values used when nothing else sets a property (`TextStyle.LibraryDefault`, inter | Weight | Normal (400) | | Position | Normal | | Italic, underline, strikethrough, wrap anywhere | off | +| Decoration line | text color, font thickness, solid | +| Underline position | Auto | | Direction | Auto | | Fallback | `Noto Color Emoji` (embedded), black | diff --git a/src/ShinyPDF.Examples/TextExamples.cs b/src/ShinyPDF.Examples/TextExamples.cs index 5b0927d05..2bbe7a2a8 100644 --- a/src/ShinyPDF.Examples/TextExamples.cs +++ b/src/ShinyPDF.Examples/TextExamples.cs @@ -344,6 +344,125 @@ public void SuperscriptSubscript_Effects() }); } + [Test] + public void SuperscriptSubscript_UnderlinePosition() + { + RenderingTest + .Create() + .PageSize(800, 300) + .ProduceImages() + .ShowResults() + .Render(container => + { + container + .Padding(25) + .DefaultTextStyle(x => x.FontSize(30).Underline()) + .Column(column => + { + column.Spacing(25); + + column.Item().Text(text => + { + text.Span("Auto: E = mc"); + text.Span("2").Superscript(); + text.Span(", H"); + text.Span("2").Subscript(); + text.Span("O"); + }); + + column.Item().Text(text => + { + text.DefaultTextStyle(x => x.UnderlineAtBaseline()); + + text.Span("Baseline: E = mc"); + text.Span("2").Superscript(); + text.Span(", H"); + text.Span("2").Subscript(); + text.Span("O"); + }); + + column.Item().Text(text => + { + text.DefaultTextStyle(x => x.UnderlineBelowGlyphs()); + + text.Span("Below glyphs: E = mc"); + text.Span("2").Superscript(); + text.Span(", H"); + text.Span("2").Subscript(); + text.Span("O"); + }); + }); + }); + } + + [Test] + public void SuperscriptSubscript_StrikethroughPosition() + { + RenderingTest + .Create() + .PageSize(800, 200) + .ProduceImages() + .ShowResults() + .Render(container => + { + container + .Padding(25) + .DefaultTextStyle(x => x.FontSize(30).Strikethrough()) + .Column(column => + { + column.Spacing(25); + + column.Item().Text(text => + { + text.Span("Through glyphs: (E = mc"); + text.Span("2").Superscript(); + text.Span("), H"); + text.Span("2").Subscript(); + text.Span("O"); + }); + + column.Item().Text(text => + { + text.DefaultTextStyle(x => x.StrikethroughAtBaseline()); + + text.Span("Baseline: (E = mc"); + text.Span("2").Superscript(); + text.Span("), H"); + text.Span("2").Subscript(); + text.Span("O"); + }); + }); + }); + } + + [Test] + public void DecorationStyles() + { + RenderingTest + .Create() + .PageSize(600, 420) + .ProduceImages() + .ShowResults() + .Render(container => + { + container + .Padding(25) + .DefaultTextStyle(x => x.FontSize(24)) + .Column(column => + { + column.Spacing(15); + + column.Item().Text("Solid underline").Underline(); + column.Item().Text("Double underline").Underline().DecorationDouble(); + column.Item().Text("Dotted underline").Underline().DecorationDotted(); + column.Item().Text("Dashed underline").Underline().DecorationDashed(); + column.Item().Text("Wavy red underline").Underline().DecorationWavy().DecorationColor(Colors.Red.Medium); + column.Item().Text("Thick blue underline").Underline().DecorationThickness(3).DecorationColor(Colors.Blue.Medium); + column.Item().Text("Dashed strikethrough").Strikethrough().DecorationDashed(); + }); + }); + } + [Test] public void ParagraphSpacing() { diff --git a/src/ShinyPDF.UnitTests/TestEngine/MockCanvas.cs b/src/ShinyPDF.UnitTests/TestEngine/MockCanvas.cs index 2fdd1d94f..e4467347a 100644 --- a/src/ShinyPDF.UnitTests/TestEngine/MockCanvas.cs +++ b/src/ShinyPDF.UnitTests/TestEngine/MockCanvas.cs @@ -18,6 +18,7 @@ internal class MockCanvas : ICanvas public void Scale(float scaleX, float scaleY) => ScaleFunc(scaleX, scaleY); public void DrawRectangle(Position vector, Size size, string color) => DrawRectFunc(vector, size, color); + public void DrawTextDecoration(Position vector, float width, float thickness, string color, TextDecorationStyle style) => throw new NotImplementedException(); public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style) => throw new NotImplementedException(); public void DrawImage(SKImage image, Position position, Size size) => DrawImageFunc(image, position, size); diff --git a/src/ShinyPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs b/src/ShinyPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs index 46fe68cae..c473edaf7 100644 --- a/src/ShinyPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs +++ b/src/ShinyPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs @@ -16,6 +16,7 @@ internal class OperationRecordingCanvas : ICanvas public void Scale(float scaleX, float scaleY) => Operations.Add(new CanvasScaleOperation(scaleX, scaleY)); public void DrawRectangle(Position vector, Size size, string color) => Operations.Add(new CanvasDrawRectangleOperation(vector, size, color)); + public void DrawTextDecoration(Position vector, float width, float thickness, string color, TextDecorationStyle style) => throw new NotImplementedException(); public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style) => throw new NotImplementedException(); public void DrawImage(SKImage image, Position position, Size size) => Operations.Add(new CanvasDrawImageOperation(position, size)); diff --git a/src/ShinyPDF.UnitTests/TextDecorationTests.cs b/src/ShinyPDF.UnitTests/TextDecorationTests.cs new file mode 100644 index 000000000..ccb63ae25 --- /dev/null +++ b/src/ShinyPDF.UnitTests/TextDecorationTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using ShinyPDF.Drawing; +using ShinyPDF.Elements.Text.Calculation; +using ShinyPDF.Elements.Text.Items; +using ShinyPDF.Fluent; +using ShinyPDF.Helpers; +using ShinyPDF.Infrastructure; +using SkiaSharp; + +namespace ShinyPDF.UnitTests +{ + [TestFixture] + public class TextDecorationTests + { + [Test] + public void Underline_ByDefault_IsSolidInTextColor() + { + var lines = Draw(TextStyle.Default.Underline().FontColor(Colors.Red.Medium)); + + var line = lines.Single(); + Assert.That(line.Style, Is.EqualTo(TextDecorationStyle.Solid)); + Assert.That(line.Color, Is.EqualTo(Colors.Red.Medium)); + } + + [Test] + public void DecorationColor_OverridesTextColor() + { + var lines = Draw(TextStyle.Default.Underline().FontColor(Colors.Red.Medium).DecorationColor(Colors.Blue.Medium)); + + Assert.That(lines.Single().Color, Is.EqualTo(Colors.Blue.Medium)); + } + + [Test] + public void DecorationThickness_OverridesFontThickness() + { + var lines = Draw(TextStyle.Default.Underline().DecorationThickness(3)); + + Assert.That(lines.Single().Thickness, Is.EqualTo(3)); + } + + [TestCase((int)TextDecorationStyle.Double)] + [TestCase((int)TextDecorationStyle.Dotted)] + [TestCase((int)TextDecorationStyle.Dashed)] + [TestCase((int)TextDecorationStyle.Wavy)] + public void DecorationStyle_IsPassedToCanvas(int styleValue) + { + var style = (TextDecorationStyle)styleValue; + var lines = Draw(TextStyle.Default.Underline().Mutate(TextStyleProperty.DecorationStyle, style)); + + var line = lines.Single(); + Assert.That(line.Style, Is.EqualTo(style)); + Assert.That(line.Width, Is.GreaterThan(0)); + } + + [Test] + public void DecorationOptions_ApplyToStrikethrough() + { + var lines = Draw(TextStyle.Default.Strikethrough().DecorationWavy().DecorationColor(Colors.Green.Medium).DecorationThickness(2)); + + var line = lines.Single(); + Assert.That(line.Style, Is.EqualTo(TextDecorationStyle.Wavy)); + Assert.That(line.Color, Is.EqualTo(Colors.Green.Medium)); + Assert.That(line.Thickness, Is.EqualTo(2)); + } + + [Test] + public void UnderlinePosition_Auto_KeepsExistingPlacement() + { + var normal = UnderlineOffset(TextStyle.Default); + + // superscript underline stays on the baseline, subscript underline moves down with the glyphs + Assert.That(UnderlineOffset(TextStyle.Default.Superscript()), Is.EqualTo(normal)); + Assert.That(UnderlineOffset(TextStyle.Default.Subscript()), Is.GreaterThan(normal)); + } + + [Test] + public void UnderlinePosition_Baseline_AlignsWithNormalText() + { + var normal = UnderlineOffset(TextStyle.Default); + + Assert.That(UnderlineOffset(TextStyle.Default.Subscript().UnderlineAtBaseline()), Is.EqualTo(normal)); + Assert.That(UnderlineOffset(TextStyle.Default.Superscript().UnderlineAtBaseline()), Is.EqualTo(normal)); + } + + [Test] + public void UnderlinePosition_BelowGlyphs_FollowsShiftedGlyphs() + { + var normal = UnderlineOffset(TextStyle.Default); + + Assert.That(UnderlineOffset(TextStyle.Default.Superscript().UnderlineBelowGlyphs()), Is.LessThan(normal)); + Assert.That(UnderlineOffset(TextStyle.Default.Subscript().UnderlineBelowGlyphs()), Is.EqualTo(UnderlineOffset(TextStyle.Default.Subscript()))); + } + + [Test] + public void StrikethroughPosition_ThroughGlyphs_FollowsShiftedGlyphs() + { + var normal = Strikeout(TextStyle.Default); + + Assert.That(Strikeout(TextStyle.Default.Superscript()).Offset, Is.LessThan(normal.Offset)); + Assert.That(Strikeout(TextStyle.Default.Subscript()).Offset, Is.GreaterThan(normal.Offset)); + Assert.That(Strikeout(TextStyle.Default.Superscript().StrikethroughThroughGlyphs()).Offset, Is.EqualTo(Strikeout(TextStyle.Default.Superscript()).Offset)); + } + + [Test] + public void StrikethroughPosition_Baseline_AlignsWithNormalText() + { + var normal = Strikeout(TextStyle.Default); + + foreach (var style in new[] { TextStyle.Default.Superscript(), TextStyle.Default.Subscript() }) + { + var line = Strikeout(style.StrikethroughAtBaseline()); + Assert.That(line.Offset, Is.EqualTo(normal.Offset)); + Assert.That(line.Thickness, Is.EqualTo(normal.Thickness)); + } + } + + [Test] + public void DecorationProperties_AreInheritedAndOverridden() + { + var parent = TextStyle.Default + .DecorationColor(Colors.Red.Medium) + .DecorationThickness(2) + .DecorationDashed() + .UnderlineAtBaseline() + .StrikethroughAtBaseline(); + + var inherited = TextStyle.Default.ApplyInheritedStyle(parent); + Assert.That(inherited.DecorationColor, Is.EqualTo(Colors.Red.Medium)); + Assert.That(inherited.DecorationThickness, Is.EqualTo(2)); + Assert.That(inherited.DecorationStyle, Is.EqualTo(TextDecorationStyle.Dashed)); + Assert.That(inherited.UnderlinePosition, Is.EqualTo(UnderlinePosition.Baseline)); + Assert.That(inherited.StrikethroughPosition, Is.EqualTo(StrikethroughPosition.Baseline)); + + var overridden = TextStyle.Default.DecorationDotted().UnderlineBelowGlyphs().StrikethroughThroughGlyphs().ApplyInheritedStyle(parent); + Assert.That(overridden.DecorationStyle, Is.EqualTo(TextDecorationStyle.Dotted)); + Assert.That(overridden.UnderlinePosition, Is.EqualTo(UnderlinePosition.BelowGlyphs)); + Assert.That(overridden.StrikethroughPosition, Is.EqualTo(StrikethroughPosition.ThroughGlyphs)); + } + + [Test] + public void GlobalStyle_DefaultsToSolidAndAutoPosition() + { + var style = TextStyle.Default.ApplyGlobalStyle(); + + Assert.That(style.DecorationStyle, Is.EqualTo(TextDecorationStyle.Solid)); + Assert.That(style.UnderlinePosition, Is.EqualTo(UnderlinePosition.Auto)); + Assert.That(style.StrikethroughPosition, Is.EqualTo(StrikethroughPosition.ThroughGlyphs)); + Assert.That(style.DecorationColor, Is.Null); + Assert.That(style.DecorationThickness, Is.Null); + } + + [Test] + public void DecorationThickness_MustBePositive() + { + Assert.Throws(() => TextStyle.Default.DecorationThickness(0)); + } + + [Test] + public void DecorationColor_IsValidated() + { + Assert.Throws(() => TextStyle.Default.DecorationColor("not a color")); + } + + [Test] + public void Document_WithAllDecorationStyles_GeneratesPdf() + { + var pdf = Document + .Create(document => document.Page(page => page.Content().Text(text => + { + text.Span("solid ").Underline(); + text.Span("double ").Underline().DecorationDouble(); + text.Span("dotted ").Underline().DecorationDotted(); + text.Span("dashed ").Underline().DecorationDashed(); + text.Span("wavy ").Underline().DecorationWavy().DecorationColor(Colors.Red.Medium); + text.Span("strike").Strikethrough().DecorationThickness(2); + text.Span("2").Subscript().Underline().UnderlineAtBaseline(); + text.Span("2").Superscript().Strikethrough().StrikethroughAtBaseline(); + }))) + .GeneratePdf(); + + Assert.That(pdf, Is.Not.Empty); + } + + private static float UnderlineOffset(TextStyle style) + { + return Draw(style.Underline()).Single().Offset; + } + + private static DecorationLine Strikeout(TextStyle style) + { + return Draw(style.Strikethrough()).Single(); + } + + private static List Draw(TextStyle style) + { + var span = new TextBlockSpan + { + Text = "Decorated", + Style = style.ApplyGlobalStyle() + }; + + var canvas = new DecorationRecordingCanvas(); + var pageContext = new PageContext(); + + var measurement = span.Measure(new TextMeasurementRequest + { + Canvas = canvas, + PageContext = pageContext, + AvailableWidth = 1000, + IsFirstElementInBlock = true, + IsFirstElementInLine = true + })!; + + span.Draw(new TextDrawingRequest + { + Canvas = canvas, + PageContext = pageContext, + StartIndex = measurement.StartIndex, + EndIndex = measurement.EndIndex, + TotalAscent = measurement.Ascent, + TextSize = new Size(measurement.Width, measurement.Height) + }); + + return canvas.Lines; + } + + private record DecorationLine(float Offset, float Width, float Thickness, string Color, TextDecorationStyle Style); + + private class DecorationRecordingCanvas : ICanvas + { + public List Lines { get; } = new(); + + public void DrawRectangle(Position vector, Size size, string color) => Lines.Add(new DecorationLine(vector.Y, size.Width, size.Height, color, TextDecorationStyle.Solid)); + public void DrawTextDecoration(Position vector, float width, float thickness, string color, TextDecorationStyle style) => Lines.Add(new DecorationLine(vector.Y, width, thickness, color, style)); + + public void Translate(Position vector) { } + public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style) { } + public void DrawImage(SKImage image, Position position, Size size) { } + public void DrawHyperlink(string url, Size size) { } + public void DrawSectionLink(string sectionName, Size size) { } + public void DrawSection(string sectionName) { } + public void Rotate(float angle) { } + public void Scale(float scaleX, float scaleY) { } + } + } +} diff --git a/src/ShinyPDF/Drawing/FreeCanvas.cs b/src/ShinyPDF/Drawing/FreeCanvas.cs index ed5ffe122..2c2060781 100644 --- a/src/ShinyPDF/Drawing/FreeCanvas.cs +++ b/src/ShinyPDF/Drawing/FreeCanvas.cs @@ -38,7 +38,12 @@ public void Translate(Position vector) public void DrawRectangle(Position vector, Size size, string color) { - + + } + + public void DrawTextDecoration(Position vector, float width, float thickness, string color, TextDecorationStyle style) + { + } public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style) diff --git a/src/ShinyPDF/Drawing/SkiaCanvasBase.cs b/src/ShinyPDF/Drawing/SkiaCanvasBase.cs index ffb57f71e..e43aabb38 100644 --- a/src/ShinyPDF/Drawing/SkiaCanvasBase.cs +++ b/src/ShinyPDF/Drawing/SkiaCanvasBase.cs @@ -30,6 +30,78 @@ public void DrawRectangle(Position vector, Size size, string color) Canvas.DrawRect(vector.X, vector.Y, size.Width, size.Height, paint); } + public void DrawTextDecoration(Position vector, float width, float thickness, string color, TextDecorationStyle style) + { + if (width < Size.Epsilon || thickness < Size.Epsilon) + return; + + if (Canvas == null) return; + + if (style is TextDecorationStyle.Solid or TextDecorationStyle.Double) + { + // double is drawn as two solid lines, each with the given thickness and a gap of the same size + DrawRectangle(vector, new Size(width, thickness), color); + + if (style == TextDecorationStyle.Double) + DrawRectangle(new Position(vector.X, vector.Y + 2 * thickness), new Size(width, thickness), color); + + return; + } + + using var paint = new SKPaint + { + Color = SKColor.Parse(color), + IsAntialias = true, + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness + }; + + var centerY = vector.Y + thickness / 2; + + if (style == TextDecorationStyle.Wavy) + { + DrawWavyLine(paint, vector.X, centerY, width, thickness); + return; + } + + if (style == TextDecorationStyle.Dotted) + { + // zero-length dashes with round caps render as dots + paint.StrokeCap = SKStrokeCap.Round; + using var dots = SKPathEffect.CreateDash(new[] { 0f, 2 * thickness }, 0); + paint.PathEffect = dots; + Canvas.DrawLine(vector.X + thickness / 2, centerY, vector.X + width, centerY, paint); + return; + } + + using var dashes = SKPathEffect.CreateDash(new[] { 3 * thickness, 2 * thickness }, 0); + paint.PathEffect = dashes; + Canvas.DrawLine(vector.X, centerY, vector.X + width, centerY, paint); + } + + private void DrawWavyLine(SKPaint paint, float x, float centerY, float width, float thickness) + { + var amplitude = thickness * 1.5f; + var halfWave = thickness * 3; + + using var path = new SKPath(); + path.MoveTo(x, centerY); + + var direction = -1; + + for (var startX = x; startX < x + width; startX += halfWave) + { + // the quadratic control point at twice the amplitude puts the peak at the amplitude + path.QuadTo(startX + halfWave / 2, centerY + direction * amplitude * 2, startX + halfWave, centerY); + direction = -direction; + } + + Canvas!.Save(); + Canvas.ClipRect(new SKRect(x, centerY - amplitude - thickness, x + width, centerY + amplitude + thickness)); + Canvas.DrawPath(path, paint); + Canvas.Restore(); + } + public void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style) { if (Canvas == null) return; diff --git a/src/ShinyPDF/Elements/Text/Items/TextBlockSpan.cs b/src/ShinyPDF/Elements/Text/Items/TextBlockSpan.cs index eacd0ad1e..b5441f77b 100644 --- a/src/ShinyPDF/Elements/Text/Items/TextBlockSpan.cs +++ b/src/ShinyPDF/Elements/Text/Items/TextBlockSpan.cs @@ -156,35 +156,76 @@ public virtual void Draw(TextDrawingRequest request) // draw underline if ((Style.HasUnderline ?? false) && fontMetrics.UnderlinePosition.HasValue) { - if (Style.FontPosition == FontPosition.Superscript) - { - var normalMetrics = Style - .Mutate(TextStyleProperty.FontPosition, FontPosition.Normal) - .ToFontMetrics(); - - if (normalMetrics.UnderlinePosition.HasValue) - { - DrawLine(normalMetrics.UnderlinePosition.Value, normalMetrics.UnderlineThickness ?? 1); - } - } - else - { - DrawLine(fontMetrics.UnderlinePosition.Value + glyphOffsetY, fontMetrics.UnderlineThickness ?? 1); - } + var underline = GetUnderlinePlacement(fontMetrics.UnderlinePosition.Value, fontMetrics.UnderlineThickness); + + if (underline.HasValue) + DrawLine(underline.Value.offset, underline.Value.thickness); } // draw stroke if ((Style.HasStrikethrough ?? false) && fontMetrics.StrikeoutPosition.HasValue) { - var strikeoutThickness = fontMetrics.StrikeoutThickness ?? 1; - strikeoutThickness *= Style.FontPosition == FontPosition.Normal ? 1f : 0.625f; + var strikeout = GetStrikeoutPlacement(fontMetrics.StrikeoutPosition.Value, fontMetrics.StrikeoutThickness); - DrawLine(fontMetrics.StrikeoutPosition.Value + glyphOffsetY, strikeoutThickness); + if (strikeout.HasValue) + DrawLine(strikeout.Value.offset, strikeout.Value.thickness); } void DrawLine(float offset, float thickness) { - request.Canvas.DrawRectangle(new Position(0, offset), new Size(request.TextSize.Width, thickness), Style.Color ?? TextStyle.LibraryDefault.Color!); + thickness = Style.DecorationThickness ?? thickness; + var color = Style.DecorationColor ?? Style.Color ?? TextStyle.LibraryDefault.Color!; + var decorationStyle = Style.DecorationStyle ?? TextDecorationStyle.Solid; + + if (decorationStyle == TextDecorationStyle.Solid) + request.Canvas.DrawRectangle(new Position(0, offset), new Size(request.TextSize.Width, thickness), color); + else + request.Canvas.DrawTextDecoration(new Position(0, offset), request.TextSize.Width, thickness, color, decorationStyle); + } + + (float offset, float thickness)? GetUnderlinePlacement(float glyphUnderlinePosition, float? glyphUnderlineThickness) + { + var underlinePosition = Style.UnderlinePosition ?? UnderlinePosition.Auto; + + var alignWithBaseline = Style.FontPosition switch + { + FontPosition.Superscript => underlinePosition != UnderlinePosition.BelowGlyphs, + FontPosition.Subscript => underlinePosition == UnderlinePosition.Baseline, + _ => false + }; + + if (!alignWithBaseline) + return (glyphUnderlinePosition + glyphOffsetY, glyphUnderlineThickness ?? 1); + + var normalMetrics = Style + .Mutate(TextStyleProperty.FontPosition, FontPosition.Normal) + .ToFontMetrics(); + + if (!normalMetrics.UnderlinePosition.HasValue) + return null; + + return (normalMetrics.UnderlinePosition.Value, normalMetrics.UnderlineThickness ?? 1); + } + + (float offset, float thickness)? GetStrikeoutPlacement(float glyphStrikeoutPosition, float? glyphStrikeoutThickness) + { + var alignWithBaseline = Style.FontPosition != FontPosition.Normal + && Style.StrikethroughPosition == StrikethroughPosition.Baseline; + + if (!alignWithBaseline) + { + var thickness = (glyphStrikeoutThickness ?? 1) * (Style.FontPosition == FontPosition.Normal ? 1f : 0.625f); + return (glyphStrikeoutPosition + glyphOffsetY, thickness); + } + + var normalMetrics = Style + .Mutate(TextStyleProperty.FontPosition, FontPosition.Normal) + .ToFontMetrics(); + + if (!normalMetrics.StrikeoutPosition.HasValue) + return null; + + return (normalMetrics.StrikeoutPosition.Value, normalMetrics.StrikeoutThickness ?? 1); } float GetGlyphOffset() diff --git a/src/ShinyPDF/Fluent/TextSpanDescriptorExtensions.cs b/src/ShinyPDF/Fluent/TextSpanDescriptorExtensions.cs index a440ed57b..d08ab8372 100644 --- a/src/ShinyPDF/Fluent/TextSpanDescriptorExtensions.cs +++ b/src/ShinyPDF/Fluent/TextSpanDescriptorExtensions.cs @@ -94,6 +94,104 @@ public static T WrapAnywhere(this T descriptor, bool value = true) where T : return descriptor; } + #region Decoration + + /// + /// Sets the color of underline and strikethrough lines. By default, the text color is used. + /// + public static T DecorationColor(this T descriptor, string value) where T : TextSpanDescriptor + { + ColorValidator.Validate(value); + descriptor.MutateTextStyle(x => x.DecorationColor(value)); + return descriptor; + } + + /// + /// Sets the thickness of underline and strikethrough lines, in points. By default, the thickness provided by the font is used. + /// + public static T DecorationThickness(this T descriptor, float value) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.DecorationThickness(value)); + return descriptor; + } + + public static T DecorationSolid(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.DecorationSolid()); + return descriptor; + } + + public static T DecorationDouble(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.DecorationDouble()); + return descriptor; + } + + public static T DecorationDotted(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.DecorationDotted()); + return descriptor; + } + + public static T DecorationDashed(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.DecorationDashed()); + return descriptor; + } + + public static T DecorationWavy(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.DecorationWavy()); + return descriptor; + } + + /// + /// Default. Superscript underlines stay on the baseline, subscript underlines follow the lowered glyphs. + /// + public static T UnderlinePositionAuto(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.UnderlinePositionAuto()); + return descriptor; + } + + /// + /// Draws sub/superscript underlines where they would be for normal text, aligned with the surrounding text. + /// + public static T UnderlineAtBaseline(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.UnderlineAtBaseline()); + return descriptor; + } + + /// + /// Draws sub/superscript underlines directly below the shifted glyphs. + /// + public static T UnderlineBelowGlyphs(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.UnderlineBelowGlyphs()); + return descriptor; + } + + /// + /// Default. Draws sub/superscript strikethroughs through the shifted glyphs. + /// + public static T StrikethroughThroughGlyphs(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.StrikethroughThroughGlyphs()); + return descriptor; + } + + /// + /// Draws sub/superscript strikethroughs where they would be for normal text, aligned with the surrounding text. + /// + public static T StrikethroughAtBaseline(this T descriptor) where T : TextSpanDescriptor + { + descriptor.MutateTextStyle(x => x.StrikethroughAtBaseline()); + return descriptor; + } + + #endregion + #region Weight public static T Thin(this T descriptor) where T : TextSpanDescriptor diff --git a/src/ShinyPDF/Fluent/TextStyleExtensions.cs b/src/ShinyPDF/Fluent/TextStyleExtensions.cs index cc6db2cf9..93105158c 100644 --- a/src/ShinyPDF/Fluent/TextStyleExtensions.cs +++ b/src/ShinyPDF/Fluent/TextStyleExtensions.cs @@ -68,6 +68,110 @@ public static TextStyle WrapAnywhere(this TextStyle style, bool value = true) return style.Mutate(TextStyleProperty.WrapAnywhere, value); } + #region Decoration + + /// + /// Sets the color of underline and strikethrough lines. By default, the text color is used. + /// + public static TextStyle DecorationColor(this TextStyle style, string value) + { + ColorValidator.Validate(value); + return style.Mutate(TextStyleProperty.DecorationColor, value); + } + + /// + /// Sets the thickness of underline and strikethrough lines, in points. By default, the thickness provided by the font is used. + /// + public static TextStyle DecorationThickness(this TextStyle style, float value) + { + if (value <= 0) + throw new ArgumentException("Decoration thickness must be greater than 0."); + + return style.Mutate(TextStyleProperty.DecorationThickness, value); + } + + public static TextStyle DecorationSolid(this TextStyle style) + { + return style.DecorationStyle(TextDecorationStyle.Solid); + } + + public static TextStyle DecorationDouble(this TextStyle style) + { + return style.DecorationStyle(TextDecorationStyle.Double); + } + + public static TextStyle DecorationDotted(this TextStyle style) + { + return style.DecorationStyle(TextDecorationStyle.Dotted); + } + + public static TextStyle DecorationDashed(this TextStyle style) + { + return style.DecorationStyle(TextDecorationStyle.Dashed); + } + + public static TextStyle DecorationWavy(this TextStyle style) + { + return style.DecorationStyle(TextDecorationStyle.Wavy); + } + + private static TextStyle DecorationStyle(this TextStyle style, TextDecorationStyle decorationStyle) + { + return style.Mutate(TextStyleProperty.DecorationStyle, decorationStyle); + } + + /// + /// Default. Superscript underlines stay on the baseline, subscript underlines follow the lowered glyphs. + /// + public static TextStyle UnderlinePositionAuto(this TextStyle style) + { + return style.UnderlinePosition(Infrastructure.UnderlinePosition.Auto); + } + + /// + /// Draws sub/superscript underlines where they would be for normal text, aligned with the surrounding text. + /// + public static TextStyle UnderlineAtBaseline(this TextStyle style) + { + return style.UnderlinePosition(Infrastructure.UnderlinePosition.Baseline); + } + + /// + /// Draws sub/superscript underlines directly below the shifted glyphs. + /// + public static TextStyle UnderlineBelowGlyphs(this TextStyle style) + { + return style.UnderlinePosition(Infrastructure.UnderlinePosition.BelowGlyphs); + } + + private static TextStyle UnderlinePosition(this TextStyle style, UnderlinePosition position) + { + return style.Mutate(TextStyleProperty.UnderlinePosition, position); + } + + /// + /// Default. Draws sub/superscript strikethroughs through the shifted glyphs. + /// + public static TextStyle StrikethroughThroughGlyphs(this TextStyle style) + { + return style.StrikethroughPosition(Infrastructure.StrikethroughPosition.ThroughGlyphs); + } + + /// + /// Draws sub/superscript strikethroughs where they would be for normal text, aligned with the surrounding text. + /// + public static TextStyle StrikethroughAtBaseline(this TextStyle style) + { + return style.StrikethroughPosition(Infrastructure.StrikethroughPosition.Baseline); + } + + private static TextStyle StrikethroughPosition(this TextStyle style, StrikethroughPosition position) + { + return style.Mutate(TextStyleProperty.StrikethroughPosition, position); + } + + #endregion + #region Weight public static TextStyle Weight(this TextStyle style, FontWeight weight) diff --git a/src/ShinyPDF/Infrastructure/ICanvas.cs b/src/ShinyPDF/Infrastructure/ICanvas.cs index fb07eea03..5da1fed72 100644 --- a/src/ShinyPDF/Infrastructure/ICanvas.cs +++ b/src/ShinyPDF/Infrastructure/ICanvas.cs @@ -8,6 +8,7 @@ internal interface ICanvas void Translate(Position vector); void DrawRectangle(Position vector, Size size, string color); + void DrawTextDecoration(Position vector, float width, float thickness, string color, TextDecorationStyle style); void DrawText(SKTextBlob skTextBlob, Position position, TextStyle style); void DrawImage(SKImage image, Position position, Size size); diff --git a/src/ShinyPDF/Infrastructure/StrikethroughPosition.cs b/src/ShinyPDF/Infrastructure/StrikethroughPosition.cs new file mode 100644 index 000000000..4f624ed32 --- /dev/null +++ b/src/ShinyPDF/Infrastructure/StrikethroughPosition.cs @@ -0,0 +1,10 @@ +namespace ShinyPDF.Infrastructure +{ + internal enum StrikethroughPosition + { + /// The strikethrough follows the shifted sub/superscript glyphs. + ThroughGlyphs, + /// The strikethrough is drawn where it would be for normal text, aligned with surrounding text. + Baseline + } +} diff --git a/src/ShinyPDF/Infrastructure/TextDecorationStyle.cs b/src/ShinyPDF/Infrastructure/TextDecorationStyle.cs new file mode 100644 index 000000000..4e9f3de80 --- /dev/null +++ b/src/ShinyPDF/Infrastructure/TextDecorationStyle.cs @@ -0,0 +1,11 @@ +namespace ShinyPDF.Infrastructure +{ + internal enum TextDecorationStyle + { + Solid, + Double, + Dotted, + Dashed, + Wavy + } +} diff --git a/src/ShinyPDF/Infrastructure/TextStyle.cs b/src/ShinyPDF/Infrastructure/TextStyle.cs index 3222ee55c..38be08e2c 100644 --- a/src/ShinyPDF/Infrastructure/TextStyle.cs +++ b/src/ShinyPDF/Infrastructure/TextStyle.cs @@ -16,6 +16,11 @@ public record TextStyle internal bool? IsItalic { get; set; } internal bool? HasStrikethrough { get; set; } internal bool? HasUnderline { get; set; } + internal string? DecorationColor { get; set; } + internal float? DecorationThickness { get; set; } + internal TextDecorationStyle? DecorationStyle { get; set; } + internal UnderlinePosition? UnderlinePosition { get; set; } + internal StrikethroughPosition? StrikethroughPosition { get; set; } internal bool? WrapAnywhere { get; set; } internal TextDirection? Direction { get; set; } @@ -34,6 +39,9 @@ public record TextStyle IsItalic = false, HasStrikethrough = false, HasUnderline = false, + DecorationStyle = TextDecorationStyle.Solid, + UnderlinePosition = Infrastructure.UnderlinePosition.Auto, + StrikethroughPosition = Infrastructure.StrikethroughPosition.ThroughGlyphs, WrapAnywhere = false, Direction = TextDirection.Auto, Fallback = new TextStyle diff --git a/src/ShinyPDF/Infrastructure/TextStyleManager.cs b/src/ShinyPDF/Infrastructure/TextStyleManager.cs index 94b9a4dfe..43367cacf 100644 --- a/src/ShinyPDF/Infrastructure/TextStyleManager.cs +++ b/src/ShinyPDF/Infrastructure/TextStyleManager.cs @@ -17,6 +17,11 @@ internal enum TextStyleProperty IsItalic, HasStrikethrough, HasUnderline, + DecorationColor, + DecorationThickness, + DecorationStyle, + UnderlinePosition, + StrikethroughPosition, WrapAnywhere, Fallback, Direction @@ -182,7 +187,72 @@ private static TextStyle MutateStyle(this TextStyle origin, TextStyleProperty pr return origin with { HasUnderline = castedValue }; } - + + if (property == TextStyleProperty.DecorationColor) + { + if (!overrideValue && origin.DecorationColor != null) + return origin; + + var castedValue = (string?)value; + + if (origin.DecorationColor == castedValue) + return origin; + + return origin with { DecorationColor = castedValue }; + } + + if (property == TextStyleProperty.DecorationThickness) + { + if (!overrideValue && origin.DecorationThickness != null) + return origin; + + var castedValue = (float?)value; + + if (origin.DecorationThickness == castedValue) + return origin; + + return origin with { DecorationThickness = castedValue }; + } + + if (property == TextStyleProperty.DecorationStyle) + { + if (!overrideValue && origin.DecorationStyle != null) + return origin; + + var castedValue = (TextDecorationStyle?)value; + + if (origin.DecorationStyle == castedValue) + return origin; + + return origin with { DecorationStyle = castedValue }; + } + + if (property == TextStyleProperty.UnderlinePosition) + { + if (!overrideValue && origin.UnderlinePosition != null) + return origin; + + var castedValue = (UnderlinePosition?)value; + + if (origin.UnderlinePosition == castedValue) + return origin; + + return origin with { UnderlinePosition = castedValue }; + } + + if (property == TextStyleProperty.StrikethroughPosition) + { + if (!overrideValue && origin.StrikethroughPosition != null) + return origin; + + var castedValue = (StrikethroughPosition?)value; + + if (origin.StrikethroughPosition == castedValue) + return origin; + + return origin with { StrikethroughPosition = castedValue }; + } + if (property == TextStyleProperty.WrapAnywhere) { if (!overrideValue && origin.WrapAnywhere != null) @@ -269,6 +339,11 @@ private static TextStyle ApplyStyleProperties(this TextStyle style, TextStyle pa result = MutateStyle(result, TextStyleProperty.IsItalic, parent.IsItalic, overrideStyle); result = MutateStyle(result, TextStyleProperty.HasStrikethrough, parent.HasStrikethrough, overrideStyle); result = MutateStyle(result, TextStyleProperty.HasUnderline, parent.HasUnderline, overrideStyle); + result = MutateStyle(result, TextStyleProperty.DecorationColor, parent.DecorationColor, overrideStyle); + result = MutateStyle(result, TextStyleProperty.DecorationThickness, parent.DecorationThickness, overrideStyle); + result = MutateStyle(result, TextStyleProperty.DecorationStyle, parent.DecorationStyle, overrideStyle); + result = MutateStyle(result, TextStyleProperty.UnderlinePosition, parent.UnderlinePosition, overrideStyle); + result = MutateStyle(result, TextStyleProperty.StrikethroughPosition, parent.StrikethroughPosition, overrideStyle); result = MutateStyle(result, TextStyleProperty.WrapAnywhere, parent.WrapAnywhere, overrideStyle); result = MutateStyle(result, TextStyleProperty.Direction, parent.Direction, overrideStyle); diff --git a/src/ShinyPDF/Infrastructure/UnderlinePosition.cs b/src/ShinyPDF/Infrastructure/UnderlinePosition.cs new file mode 100644 index 000000000..ac422e590 --- /dev/null +++ b/src/ShinyPDF/Infrastructure/UnderlinePosition.cs @@ -0,0 +1,12 @@ +namespace ShinyPDF.Infrastructure +{ + internal enum UnderlinePosition + { + /// Superscript underlines stay on the baseline, subscript underlines follow the lowered glyphs. + Auto, + /// The underline is drawn where it would be for normal text, aligned with surrounding text. + Baseline, + /// The underline is drawn directly below the shifted sub/superscript glyphs. + BelowGlyphs + } +}