diff --git a/CSharpMath.Rendering.Tests/TestBidiParagraphs.cs b/CSharpMath.Rendering.Tests/TestBidiParagraphs.cs new file mode 100644 index 00000000..cb050b71 --- /dev/null +++ b/CSharpMath.Rendering.Tests/TestBidiParagraphs.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using CSharpMath.Rendering.Text; +using Xunit; + +namespace CSharpMath.Rendering.Tests { + // The cases explicitly labelled "BidiCharacterTest" below are a transcribed/modified, + // representative subset of Unicode 17.0 BidiCharacterTest-17.0.0.txt. They do not claim + // full UAX #9 conformance. Versioned source URL and row references are retained below. + // Normative algorithm: https://www.unicode.org/reports/tr9/tr9-51.html (revision 51). + // Versioned vectors: https://www.unicode.org/Public/17.0.0/ucd/BidiCharacterTest.txt. + // Redistributed under Unicode License v3; see Unicode-Data-LICENSE.txt in this project. + public class TestBidiParagraphs { + sealed class UnknownTextAtom : TextAtom { + public override int? SingleChar(Atom.FontStyle style) => null; + public override bool Equals(TextAtom atom) => ReferenceEquals(this, atom); + public override int GetHashCode() => 0; + } + + static void AssertRuns( + BidiParagraph paragraph, + byte expectedBaseLevel, + params (int start, int length, byte level)[] expected) { + Assert.Equal(expectedBaseLevel, paragraph.BaseLevel); + Assert.Equal(expected.Length, paragraph.Runs.Count); + Assert.Equal(expected, paragraph.Runs.Select(r => (r.LogicalStart, r.Length, r.EmbeddingLevel))); + + Assert.Equal(paragraph.Text.Length, paragraph.Length); + var end = checked(paragraph.LogicalStart + paragraph.Length); + var cursor = paragraph.LogicalStart; + foreach (var run in paragraph.Runs) { + Assert.NotNull(run); + Assert.True(run.Length > 0); + Assert.Equal(cursor, run.LogicalStart); + Assert.Equal(run.LogicalStart, run.Start); + Assert.Equal((run.EmbeddingLevel & 1) != 0, run.IsRightToLeft); + + var runEnd = checked(run.LogicalStart + run.Length); + Assert.InRange(runEnd, paragraph.LogicalStart + 1, end); + AssertNotInsideSurrogatePair(paragraph, run.LogicalStart); + AssertNotInsideSurrogatePair(paragraph, runEnd); + cursor = runEnd; + } + Assert.Equal(end, cursor); + } + + static void AssertNotInsideSurrogatePair(BidiParagraph paragraph, int globalBoundary) { + var paragraphEnd = paragraph.LogicalStart + paragraph.Length; + if (globalBoundary <= paragraph.LogicalStart || globalBoundary >= paragraphEnd) + return; + + var localBoundary = globalBoundary - paragraph.LogicalStart; + Assert.False( + char.IsHighSurrogate(paragraph.Text[localBoundary - 1]) && + char.IsLowSurrogate(paragraph.Text[localBoundary])); + } + + // Focused #289 regression derived directly from UAX #9 revision 51, not a + // BidiCharacterTest row. Starting classes are L L L WS R R AL WS EN EN EN AN AN. + // W2/W3, N1/N2, and I2 resolve levels to 0 0 0 0 1 1 1 1 2 2 2 2 2. + [Fact] + public void MixedHebrewArabicAndDigitsRegressionHasThreeRuns() => + AssertRuns( + new BidiParagraph("abc אבج 123٤٥"), + 0, + (0, 4, 0), + (4, 4, 1), + (8, 5, 2)); + + // Focused #289 regression derived directly from UAX #9 revision 51, not a + // BidiCharacterTest row. N0 resolves the brackets to L; X5b gives the LRI level 0 + // and pushes level 2; W7/I1 leave its digits at level 2; X6a gives the PDI level 0. + // The resulting levels are 0 0 0 1 1 1 0 0 2 2 2 0. + [Fact] + public void BracketsAndLriRegressionHasFiveRuns() => + AssertRuns( + new BidiParagraph("A (אבג)\u2066123\u2069"), + 0, + (0, 3, 0), + (3, 3, 1), + (6, 2, 0), + (8, 3, 2), + (11, 1, 0)); + + // BidiCharacterTest-17.0.0.txt:46, direction 0. + // 0061 0062 0063 0020 0028 0064 0065 0066 0020 0627 0628 062C 0029 0020 05D0 05D1 05D2 + [Fact] + public void Unicode17HebrewArabicLatinLtrVector() => + AssertRuns( + new BidiParagraph("abc (def ابج) אבג", TextDirection.LeftToRight), + 0, + (0, 9, 0), + (9, 3, 1), + (12, 2, 0), + (14, 3, 1)); + + // BidiCharacterTest-17.0.0.txt:47, direction 1; same code points as line 46. + [Fact] + public void Unicode17HebrewArabicLatinRtlVector() => + AssertRuns( + new BidiParagraph("abc (def ابج) אבג", TextDirection.RightToLeft), + 1, + (0, 3, 2), + (3, 2, 1), + (5, 3, 2), + (8, 9, 1)); + + // UAX #9 P2/P3 selects LTR for the line-46 vector, so Auto has its direction-0 levels. + [Fact] + public void AutoUsesLtrVectorLevelsWhenFirstStrongCharacterIsL() => + AssertRuns( + new BidiParagraph("abc (def ابج) אבג", TextDirection.Auto), + 0, + (0, 9, 0), + (9, 3, 1), + (12, 2, 0), + (14, 3, 1)); + + // UAX #9 P2/P3 selects RTL for BidiCharacterTest lines 52-53, so Auto has line 53's levels. + // 05D0 05D1 05D2 0020 0028 0627 0628 062C 0020 0064 0065 0066 0029 0020 0061 0062 0063 + [Fact] + public void AutoUsesRtlVectorLevelsWhenFirstStrongCharacterIsR() => + AssertRuns( + new BidiParagraph("אבג (ابج def) abc", TextDirection.Auto), + 1, + (0, 9, 1), + (9, 3, 2), + (12, 2, 1), + (14, 3, 2)); + + // BidiCharacterTest-17.0.0.txt:143, direction 1. + // 0061 0020 0031 0020 0032 002D 0033 + [Fact] + public void Unicode17EuropeanDigitsRtlVector() => + AssertRuns( + new BidiParagraph("a 1 2-3", TextDirection.RightToLeft), + 1, + (0, 7, 2)); + + // BidiCharacterTest-17.0.0.txt:144, direction 0. + // 05D0 0020 0031 002D 0032 + [Fact] + public void Unicode17HebrewEuropeanDigitsLtrVector() => + AssertRuns( + new BidiParagraph("א 1-2", TextDirection.LeftToRight), + 0, + (0, 2, 1), + (2, 3, 2)); + + // BidiCharacterTest-17.0.0.txt:197, direction 0. + // 0061 0028 0661 0029 + [Fact] + public void Unicode17ArabicIndicDigitAndBracketsLtrVector() => + AssertRuns( + new BidiParagraph("a(١)", TextDirection.LeftToRight), + 0, + (0, 2, 0), + (2, 1, 2), + (3, 1, 0)); + + // BidiCharacterTest-17.0.0.txt:198, direction 1; same code points as line 197. + [Fact] + public void Unicode17ArabicIndicDigitAndBracketsRtlVector() => + AssertRuns( + new BidiParagraph("a(١)", TextDirection.RightToLeft), + 1, + (0, 1, 2), + (1, 1, 1), + (2, 1, 2), + (3, 1, 1)); + + // BidiCharacterTest-17.0.0.txt:181, direction 0. + // 0061 0028 05D0 005B 05D1 005D 0021 0029 0062 + [Fact] + public void Unicode17PairedBracketsAndNeutralPunctuationLtrVector() => + AssertRuns( + new BidiParagraph("a(א[ב]!)b", TextDirection.LeftToRight), + 0, + (0, 2, 0), + (2, 4, 1), + (6, 3, 0)); + + // BidiCharacterTest-17.0.0.txt:182, direction 1; same code points as line 181. + [Fact] + public void Unicode17PairedBracketsAndNeutralPunctuationRtlVector() => + AssertRuns( + new BidiParagraph("a(א[ב]!)b", TextDirection.RightToLeft), + 1, + (0, 1, 2), + (1, 7, 1), + (8, 1, 2)); + + // BidiCharacterTest-17.0.0.txt:282, direction 0. + // 0061 0028 0062 005B 0063 2068 05D0 2069 0064 005D 0065 0029 0066 + [Fact] + public void Unicode17FsiAndPdiLtrVector() => + AssertRuns( + new BidiParagraph("a(b[c\u2068א\u2069d]e)f", TextDirection.LeftToRight), + 0, + (0, 6, 0), + (6, 1, 1), + (7, 6, 0)); + + // BidiCharacterTest-17.0.0.txt:283, direction 1; same code points as line 282. + [Fact] + public void Unicode17FsiAndPdiRtlVector() => + AssertRuns( + new BidiParagraph("a(b[c\u2068א\u2069d]e)f", TextDirection.RightToLeft), + 1, + (0, 6, 2), + (6, 1, 3), + (7, 6, 2)); + + // BidiCharacterTest-17.0.0.txt:293, direction 0. + // 0061 0028 0062 2067 05D0 0066 2069 05D4 0029 05D5 + [Fact] + public void Unicode17RliAndPdiLtrVector() => + AssertRuns( + new BidiParagraph("a(b\u2067אf\u2069ד)ו", TextDirection.LeftToRight), + 0, + (0, 4, 0), + (4, 1, 1), + (5, 1, 2), + (6, 1, 0), + (7, 1, 1), + (8, 1, 0), + (9, 1, 1)); + + // BidiCharacterTest-17.0.0.txt:294, direction 1; same code points as line 293. + [Fact] + public void Unicode17RliAndPdiRtlVector() => + AssertRuns( + new BidiParagraph("a(b\u2067אf\u2069ד)ו", TextDirection.RightToLeft), + 1, + (0, 1, 2), + (1, 1, 1), + (2, 1, 2), + (3, 1, 1), + (4, 1, 3), + (5, 1, 4), + (6, 4, 1)); + + // BidiCharacterTest-17.0.0.txt:304, direction 0. + // 05D0 0028 05D1 2066 0061 05D5 2069 0065 0029 0066 + [Fact] + public void Unicode17LriAndPdiLtrVector() => + AssertRuns( + new BidiParagraph("א(ב\u2066aו\u2069e)f", TextDirection.LeftToRight), + 0, + (0, 1, 1), + (1, 1, 0), + (2, 1, 1), + (3, 1, 0), + (4, 1, 2), + (5, 1, 3), + (6, 4, 0)); + + // BidiCharacterTest-17.0.0.txt:305, direction 1; same code points as line 304. + [Fact] + public void Unicode17LriAndPdiRtlVector() => + AssertRuns( + new BidiParagraph("א(ב\u2066aו\u2069e)f", TextDirection.RightToLeft), + 1, + (0, 4, 1), + (4, 1, 2), + (5, 1, 3), + (6, 1, 1), + (7, 1, 2), + (8, 1, 1), + (9, 1, 2)); + + [Fact] + public void SplitsTerminatorsAndRetainsEmptySegments() { + var paragraphs = BidiResolver.ResolveParagraphs("\r\none\n\r\n"); + Assert.Equal(new[] { "", "one", "", "" }, paragraphs.Select(p => p.Text)); + Assert.Equal(new[] { "\r\n", "\n", "\r\n", "" }, paragraphs.Select(p => p.Separator)); + Assert.Equal(new[] { 0, 2, 6, 8 }, paragraphs.Select(p => p.LogicalStart)); + Assert.All(paragraphs, paragraph => Assert.Equal(TextDirection.Auto, paragraph.Direction)); + } + + [Fact] + public void AllowsEmptyInputWithRequestedBaseDirection() { + var paragraphs = BidiResolver.ResolveParagraphs("", TextDirection.RightToLeft); + + var paragraph = Assert.Single(paragraphs); + Assert.Equal("", paragraph.Text); + Assert.Equal("", paragraph.Separator); + Assert.Equal(0, paragraph.LogicalStart); + Assert.Equal(TextDirection.RightToLeft, paragraph.Direction); + AssertRuns(paragraph, 1); + } + + [Fact] + public void RejectsInvalidArgumentsWithoutChangingPainterDirection() { + Assert.Throws(() => new BidiParagraph(null!)); + Assert.Throws(() => BidiResolver.ResolveParagraphs(null!)); + Assert.Throws( + () => new BidiParagraph("x", (TextDirection)99)); + Assert.Throws( + () => BidiResolver.ResolveParagraphs("", (TextDirection)99)); + Assert.Throws(() => new TextRun(-1, 1, 0)); + Assert.Throws(() => new TextRun(0, 0, 0)); + + var painter = new SkiaSharp.TextPainter(); + Assert.Equal(TextDirection.LeftToRight, painter.TextDirection); + Assert.Throws( + () => painter.TextDirection = (TextDirection)99); + Assert.Equal(TextDirection.LeftToRight, painter.TextDirection); + } + + [Fact] + public void SupplementaryCharactersHaveContiguousGlobalUtf16Runs() { + const string text = "A😀אב\r\nאב😀A"; + var paragraphs = BidiResolver.ResolveParagraphs(text); + + Assert.Equal(2, paragraphs.Count); + Assert.Equal("\r\n", paragraphs[0].Separator); + AssertRuns(paragraphs[0], 0, (0, 3, 0), (3, 2, 1)); + AssertRuns(paragraphs[1], 1, (7, 4, 1), (11, 1, 2)); + } + + [Fact] + public void TextPainterProjectsEverySupportedAtomWithGlobalOffsets() { + var wrappedText = new TextAtom.Style( + new TextAtom.Size( + new TextAtom.Colored( + new TextAtom.Accent(new TextAtom.Text("A😀"), "^"), + Color.Red), + 18), + Atom.FontStyle.Roman); + var painter = new SkiaSharp.TextPainter { + Content = new TextAtom.List(new TextAtom[] { + wrappedText, + new TextAtom.Space(Atom.Length.Point), + new TextAtom.ControlSpace(), + new TextAtom.Comment("omitted"), + new UnknownTextAtom(), + new TextAtom.Newline(), + new TextAtom.Math(new Atom.MathList(), false), + new TextAtom.Comment("also omitted"), + new TextAtom.Text("אב") + }), + TextDirection = TextDirection.Auto + }; + + var paragraphs = painter.BidiParagraphs; + Assert.Equal(2, paragraphs.Count); + Assert.Equal("A😀 ", paragraphs[0].Text); + Assert.Equal("\r\n", paragraphs[0].Separator); + Assert.Equal("\uFFFCאב", paragraphs[1].Text); + Assert.Equal("", paragraphs[1].Separator); + AssertRuns(paragraphs[0], 0, (0, 5, 0)); + AssertRuns(paragraphs[1], 1, (7, 3, 1)); + } + + [Fact] + public void TextPainterMetadataIsImmutableAndRecomputed() { + var painter = new SkiaSharp.TextPainter { + Content = new TextAtom.Text("abc"), + TextDirection = TextDirection.Auto + }; + var first = painter.BidiParagraphs; + var firstParagraph = Assert.Single(first); + + Assert.Throws( + () => ((IList)first).Add(new BidiParagraph("x"))); + Assert.Throws( + () => ((IList)firstParagraph.Runs).Add(new TextRun(0, 1, 0))); + + painter.Content = new TextAtom.Text("אב"); + painter.TextDirection = TextDirection.RightToLeft; + var second = painter.BidiParagraphs; + var secondParagraph = Assert.Single(second); + var third = painter.BidiParagraphs; + + Assert.NotSame(first, second); + Assert.NotSame(second, third); + Assert.Equal("abc", firstParagraph.Text); + Assert.Equal((byte)0, firstParagraph.BaseLevel); + Assert.Equal("אב", secondParagraph.Text); + Assert.Equal((byte)1, secondParagraph.BaseLevel); + } + + [Fact] + public void PainterBaseDirectionDefaultsToLtrAndIsNotAnAlignmentInput() { + var painter = new SkiaSharp.TextPainter { Content = new TextAtom.Text("אב") }; + + Assert.Equal(TextDirection.LeftToRight, painter.TextDirection); + Assert.Equal((byte)0, Assert.Single(painter.BidiParagraphs).BaseLevel); + var metadataProperty = typeof(SkiaSharp.TextPainter) + .GetProperty(nameof(SkiaSharp.TextPainter.BidiParagraphs)); + Assert.NotNull(metadataProperty); + Assert.Empty(metadataProperty.GetIndexParameters()); + + painter.TextDirection = TextDirection.Auto; + Assert.Equal((byte)1, Assert.Single(painter.BidiParagraphs).BaseLevel); + } + } +} diff --git a/CSharpMath.Rendering.Tests/Unicode-Data-LICENSE.txt b/CSharpMath.Rendering.Tests/Unicode-Data-LICENSE.txt new file mode 100644 index 00000000..de8a6460 --- /dev/null +++ b/CSharpMath.Rendering.Tests/Unicode-Data-LICENSE.txt @@ -0,0 +1,35 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2026 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/CSharpMath.Rendering/FrontEnd/TextPainter.cs b/CSharpMath.Rendering/FrontEnd/TextPainter.cs index 91e4164f..ddce26b3 100644 --- a/CSharpMath.Rendering/FrontEnd/TextPainter.cs +++ b/CSharpMath.Rendering/FrontEnd/TextPainter.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; using System.Drawing; using System.Linq; +using System.Text; namespace CSharpMath.Rendering.FrontEnd { using BackEnd; @@ -16,6 +18,85 @@ public abstract class TextPainter : Painter? Display { get; protected set; } + TextDirection _textDirection = TextDirection.LeftToRight; + + /// Gets or sets the base direction used by . + /// + /// The default is for compatibility. This setting + /// is independent of the supplied to drawing methods. It currently + /// changes ordering metadata only; drawing remains in logical order. + /// + public TextDirection TextDirection { + get => _textDirection; + set => _textDirection = BidiParagraph.ValidateDirection(value); + } + + /// + /// Gets freshly resolved paragraphs for a synthetic logical-text projection of . + /// + /// + /// + /// Every paragraph and run index is a global UTF-16 offset into that projection. It is not an + /// index into the LaTeX source or the tree. + /// + /// + /// concatenates child projections in logical order. + /// , , , and + /// recursively project their content. Both + /// and project to U+0020; + /// projects to CRLF; and projects to + /// U+FFFC OBJECT REPLACEMENT CHARACTER. Comments, null content, and unknown atom types are + /// omitted. + /// + /// + /// The returned graph is an immutable snapshot and is recomputed on every access. Resolved + /// levels come from the already-vendored Typography.TextBreak.SheenBidi implementation. + /// Visual reordering and shaping are deferred to issues #290 and #291. + /// + /// + public IReadOnlyList BidiParagraphs => + BidiResolver.ResolveParagraphs(ContentToBidiText(Content), TextDirection); + + static string ContentToBidiText(TextAtom? atom) { + var result = new StringBuilder(); + AppendBidiText(result, atom); + return result.ToString(); + } + + static void AppendBidiText(StringBuilder result, TextAtom? atom) { + switch (atom) { + case TextAtom.Text text: + result.Append(text.Content); + break; + case TextAtom.Newline: + result.Append("\r\n"); + break; + case TextAtom.Space: + case TextAtom.ControlSpace: + result.Append(' '); + break; + case TextAtom.Math: + result.Append('\uFFFC'); + break; + case TextAtom.Style style: + AppendBidiText(result, style.Content); + break; + case TextAtom.Size size: + AppendBidiText(result, size.Content); + break; + case TextAtom.Colored colored: + AppendBidiText(result, colored.Content); + break; + case TextAtom.Accent accent: + AppendBidiText(result, accent.Content); + break; + case TextAtom.List list: + foreach (var child in list.Content) + AppendBidiText(result, child); + break; + } + } + //display maths should always be center-aligned regardless of parameter for Draw() //so special case them into _absoluteXCoordDisplay instead of using _relativeXCoordDisplay public ListDisplay _absoluteXCoordDisplay = new ListDisplay(System.Array.Empty>()); @@ -120,4 +201,4 @@ public void DrawOneLine(TCanvas canvas, float x, float y) { } public new TextPainter ShallowClone() => (TextPainter)MemberwiseClone(); } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt b/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt index e69de29b..f08f8741 100644 --- a/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt +++ b/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt @@ -0,0 +1,26 @@ +#nullable enable +CSharpMath.Rendering.Text.BidiParagraph +CSharpMath.Rendering.Text.BidiParagraph.BidiParagraph(string! text, CSharpMath.Rendering.Text.TextDirection direction = CSharpMath.Rendering.Text.TextDirection.Auto) -> void +CSharpMath.Rendering.Text.BidiParagraph.BaseLevel.get -> byte +CSharpMath.Rendering.Text.BidiParagraph.Direction.get -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.BidiParagraph.Length.get -> int +CSharpMath.Rendering.Text.BidiParagraph.LogicalStart.get -> int +CSharpMath.Rendering.Text.BidiParagraph.Runs.get -> System.Collections.Generic.IReadOnlyList! +CSharpMath.Rendering.Text.BidiParagraph.Separator.get -> string! +CSharpMath.Rendering.Text.BidiParagraph.Text.get -> string! +CSharpMath.Rendering.Text.BidiResolver +static CSharpMath.Rendering.Text.BidiResolver.ResolveParagraphs(string! text, CSharpMath.Rendering.Text.TextDirection direction = CSharpMath.Rendering.Text.TextDirection.Auto) -> System.Collections.Generic.IReadOnlyList! +CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.TextDirection.Auto = 0 -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.TextDirection.LeftToRight = 1 -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.TextDirection.RightToLeft = 2 -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.TextRun +CSharpMath.Rendering.Text.TextRun.TextRun(int logicalStart, int length, byte embeddingLevel) -> void +CSharpMath.Rendering.Text.TextRun.EmbeddingLevel.get -> byte +CSharpMath.Rendering.Text.TextRun.IsRightToLeft.get -> bool +CSharpMath.Rendering.Text.TextRun.Length.get -> int +CSharpMath.Rendering.Text.TextRun.LogicalStart.get -> int +CSharpMath.Rendering.Text.TextRun.Start.get -> int +CSharpMath.Rendering.FrontEnd.TextPainter.TextDirection.get -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.FrontEnd.TextPainter.TextDirection.set -> void +CSharpMath.Rendering.FrontEnd.TextPainter.BidiParagraphs.get -> System.Collections.Generic.IReadOnlyList! diff --git a/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt b/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt index e69de29b..f08f8741 100644 --- a/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt +++ b/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt @@ -0,0 +1,26 @@ +#nullable enable +CSharpMath.Rendering.Text.BidiParagraph +CSharpMath.Rendering.Text.BidiParagraph.BidiParagraph(string! text, CSharpMath.Rendering.Text.TextDirection direction = CSharpMath.Rendering.Text.TextDirection.Auto) -> void +CSharpMath.Rendering.Text.BidiParagraph.BaseLevel.get -> byte +CSharpMath.Rendering.Text.BidiParagraph.Direction.get -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.BidiParagraph.Length.get -> int +CSharpMath.Rendering.Text.BidiParagraph.LogicalStart.get -> int +CSharpMath.Rendering.Text.BidiParagraph.Runs.get -> System.Collections.Generic.IReadOnlyList! +CSharpMath.Rendering.Text.BidiParagraph.Separator.get -> string! +CSharpMath.Rendering.Text.BidiParagraph.Text.get -> string! +CSharpMath.Rendering.Text.BidiResolver +static CSharpMath.Rendering.Text.BidiResolver.ResolveParagraphs(string! text, CSharpMath.Rendering.Text.TextDirection direction = CSharpMath.Rendering.Text.TextDirection.Auto) -> System.Collections.Generic.IReadOnlyList! +CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.TextDirection.Auto = 0 -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.TextDirection.LeftToRight = 1 -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.TextDirection.RightToLeft = 2 -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.Text.TextRun +CSharpMath.Rendering.Text.TextRun.TextRun(int logicalStart, int length, byte embeddingLevel) -> void +CSharpMath.Rendering.Text.TextRun.EmbeddingLevel.get -> byte +CSharpMath.Rendering.Text.TextRun.IsRightToLeft.get -> bool +CSharpMath.Rendering.Text.TextRun.Length.get -> int +CSharpMath.Rendering.Text.TextRun.LogicalStart.get -> int +CSharpMath.Rendering.Text.TextRun.Start.get -> int +CSharpMath.Rendering.FrontEnd.TextPainter.TextDirection.get -> CSharpMath.Rendering.Text.TextDirection +CSharpMath.Rendering.FrontEnd.TextPainter.TextDirection.set -> void +CSharpMath.Rendering.FrontEnd.TextPainter.BidiParagraphs.get -> System.Collections.Generic.IReadOnlyList! diff --git a/CSharpMath.Rendering/Text/TextDirection.cs b/CSharpMath.Rendering/Text/TextDirection.cs new file mode 100644 index 00000000..29302e36 --- /dev/null +++ b/CSharpMath.Rendering/Text/TextDirection.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; + +using Typography.TextBreak.SheenBidi; + +namespace CSharpMath.Rendering.Text { + /// Specifies the base direction used to resolve a bidi paragraph. + public enum TextDirection { + /// Derive the base direction from the first strong character, defaulting to LTR. + Auto, + /// Resolve the paragraph with a left-to-right base direction. + LeftToRight, + /// Resolve the paragraph with a right-to-left base direction. + RightToLeft + } + + /// A non-empty logical UTF-16 range and its UAX #9 embedding level. + public sealed class TextRun { + /// Creates a logical UTF-16 run with a resolved embedding level. + /// The run's global UTF-16 offset. + /// The positive run length in UTF-16 code units. + /// The resolved UAX #9 embedding level. + /// + /// is negative or is not positive. + /// + public TextRun(int logicalStart, int length, byte embeddingLevel) { + if (logicalStart < 0) + throw new ArgumentOutOfRangeException(nameof(logicalStart)); + if (length <= 0) + throw new ArgumentOutOfRangeException(nameof(length)); + + LogicalStart = logicalStart; + Length = length; + EmbeddingLevel = embeddingLevel; + } + + /// Gets this run's global UTF-16 offset in the resolved logical text. + public int LogicalStart { get; } + /// Gets this run's length in UTF-16 code units. + public int Length { get; } + /// Gets the resolved UAX #9 embedding level. + public byte EmbeddingLevel { get; } + /// Gets whether the embedding level is right-to-left. + public bool IsRightToLeft => (EmbeddingLevel & 1) != 0; + /// Gets . + public int Start => LogicalStart; + } + + /// + /// Immutable logical paragraph ordering metadata; it does not shape or lay out visual lines. + /// + public sealed class BidiParagraph { + readonly IReadOnlyList _runs; + + /// Resolves one logical paragraph into contiguous embedding-level runs. + /// The paragraph text, without a terminating separator. + /// The requested base-direction mode. + /// is null. + /// + /// is not a defined value. + /// + public BidiParagraph(string text, TextDirection direction = TextDirection.Auto) : + this(ValidateText(text), 0, string.Empty, ValidateDirection(direction)) { } + + internal BidiParagraph( + string text, + int logicalStart, + string separator, + TextDirection direction) { + Text = text; + LogicalStart = logicalStart; + Length = text.Length; + Separator = separator; + Direction = direction; + + var baseLevel = direction == TextDirection.RightToLeft ? (byte)1 : (byte)0; + var runs = new List(); + if (text.Length != 0) { + var paragraph = new Paragraph(text, ToBaseDirection(direction)); + baseLevel = paragraph.BaseLevel; + AddRuns(runs, text, logicalStart, paragraph.Levels); + } + + BaseLevel = baseLevel; + _runs = runs.AsReadOnly(); + } + + static void AddRuns( + ICollection runs, + string text, + int logicalStart, + IReadOnlyList levels) { + var start = 0; + var level = levels[0]; + for (var index = 1; index < levels.Count; index++) { + // SheenBidi classifies a surrogate pair as one scalar while exposing one level per UTF-16 + // code unit. Keep the pair atomic if its low-surrogate BN level differs transiently. + if (char.IsHighSurrogate(text[index - 1]) && char.IsLowSurrogate(text[index])) + continue; + if (levels[index] == level) + continue; + + runs.Add(new TextRun(logicalStart + start, index - start, level)); + start = index; + level = levels[index]; + } + runs.Add(new TextRun(logicalStart + start, levels.Count - start, level)); + } + + static string ValidateText(string text) { + if (text == null) + throw new ArgumentNullException(nameof(text)); + return text; + } + + internal static TextDirection ValidateDirection(TextDirection direction) { + if (direction < TextDirection.Auto || direction > TextDirection.RightToLeft) + throw new ArgumentOutOfRangeException(nameof(direction)); + return direction; + } + + static BaseDirection ToBaseDirection(TextDirection direction) => direction switch { + TextDirection.LeftToRight => BaseDirection.LeftToRight, + TextDirection.RightToLeft => BaseDirection.RightToLeft, + _ => BaseDirection.AutoLeftToRight + }; + + /// Gets the paragraph text without its terminating separator. + public string Text { get; } + /// Gets the paragraph's global UTF-16 offset in the resolved logical text. + public int LogicalStart { get; } + /// Gets the paragraph text length in UTF-16 code units, excluding . + public int Length { get; } + /// Gets the exact CR, LF, or CRLF separator following this paragraph, if any. + public string Separator { get; } + /// Gets the resolved paragraph embedding level. + public byte BaseLevel { get; } + /// Gets contiguous logical runs covering . + public IReadOnlyList Runs => _runs; + /// Gets the requested base-direction mode. + public TextDirection Direction { get; } + } + + /// Resolves logical-order paragraphs, retaining all original separators. + public static class BidiResolver { + /// + /// Splits logical text at CR, LF, and CRLF and resolves every resulting paragraph. + /// + /// The complete logical text. + /// The requested base-direction mode for every paragraph. + /// + /// An immutable list that retains empty paragraphs and exact separators. Paragraph and run + /// positions are global UTF-16 offsets into . + /// + /// is null. + /// + /// is not a defined value. + /// + public static IReadOnlyList ResolveParagraphs( + string text, + TextDirection direction = TextDirection.Auto) { + if (text == null) + throw new ArgumentNullException(nameof(text)); + BidiParagraph.ValidateDirection(direction); + + var result = new List(); + var start = 0; + var index = 0; + while (index < text.Length) { + if (text[index] != '\r' && text[index] != '\n') { + index++; + continue; + } + + var separatorStart = index++; + if (text[separatorStart] == '\r' && index < text.Length && text[index] == '\n') + index++; + + result.Add(new BidiParagraph( + text.Substring(start, separatorStart - start), + start, + text.Substring(separatorStart, index - separatorStart), + direction)); + start = index; + } + + result.Add(new BidiParagraph(text.Substring(start), start, string.Empty, direction)); + return result.AsReadOnly(); + } + } +}