diff --git a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs index 9d122367..dfacb74a 100644 --- a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs +++ b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs @@ -96,6 +96,114 @@ public void ControlWordDoesNotConsumeFollowingStar() { Assert.Equal(@"\sin *", LaTeXParser.MathListToLaTeX(list).ToString()); } + [Theory] + [InlineData(@"\not=", "≠", @"\neq ")] + [InlineData(@"\not<", "≮", @"\nless ")] + [InlineData(@"\not>", "≯", @"\ngtr ")] + [InlineData(@"\not\leq", "≰", @"\nleq ")] + [InlineData(@"\not\le", "≰", @"\nleq ")] + [InlineData(@"\not\leqslant", "⩽\u0338", @"\nleqslant ")] + [InlineData(@"\not\leqq", "≦\u0338", @"\nleqq ")] + [InlineData(@"\not\in", "∉", @"\notin ")] + [InlineData(@"\not\geq", "≱", @"\ngeq ")] + [InlineData(@"\not\ge", "≱", @"\ngeq ")] + [InlineData(@"\not\geqslant", "⩾\u0338", @"\ngeqslant ")] + [InlineData(@"\not\geqq", "≧\u0338", @"\ngeqq ")] + [InlineData(@"\not\prec", "⊀", @"\nprec ")] + [InlineData(@"\not\preceq", "⪯\u0338", @"\npreceq ")] + [InlineData(@"\not\preccurlyeq", "⋠", @"\npreccurlyeq ")] + [InlineData(@"\not\sim", "≁", @"\nsim ")] + [InlineData(@"\not\mid", "∤", @"\nshortmid ")] + [InlineData(@"\not\shortmid", "∤", @"\nshortmid ")] + [InlineData(@"\not\vdash", "⊬", @"\nvdash ")] + [InlineData(@"\not\vDash", "⊭", @"\nvDash ")] + [InlineData(@"\not\Vdash", "⊮", @"\nVdash ")] + [InlineData(@"\not\subseteq", "⊈", @"\nsubseteq ")] + [InlineData(@"\not\supseteq", "⊉", @"\nsupseteq ")] + [InlineData(@"\not\succ", "⊁", @"\nsucc ")] + [InlineData(@"\not\succeq", "⪰\u0338", @"\nsucceq ")] + [InlineData(@"\not\succcurlyeq", "⋡", @"\nsucccurlyeq ")] + [InlineData(@"\not\parallel", "∦", @"\nshortparallel ")] + [InlineData(@"\not\shortparallel", "∦", @"\nshortparallel ")] + [InlineData(@"\not\vartriangleleft", "⋪", @"\ntriangleleft ")] + [InlineData(@"\not\trianglelefteq", "⋬", @"\ntrianglelefteq ")] + [InlineData(@"\not\vartriangleright", "⋫", @"\ntriangleright ")] + [InlineData(@"\not\trianglerighteq", "⋭", @"\ntrianglerighteq ")] + [InlineData(@"\not\cong", "≇", @"\ncong ")] + [InlineData(@"\not\gets", "↚", @"\nleftarrow ")] + [InlineData(@"\not\leftarrow", "↚", @"\nleftarrow ")] + [InlineData(@"\not\Leftarrow", "⇍", @"\nLeftarrow ")] + [InlineData(@"\not\rightarrow", "↛", @"\nrightarrow ")] + [InlineData(@"\not\to", "↛", @"\nrightarrow ")] + [InlineData(@"\not\Rightarrow", "⇏", @"\nRightarrow ")] + [InlineData(@"\not\leftrightarrow", "↮", @"\nleftrightarrow ")] + [InlineData(@"\not\Leftrightarrow", "⇎", @"\nLeftrightarrow ")] + public void NotNegatesRelation(string input, string nucleus, string output) { + var list = ParseLaTeX(input); + + var relation = Assert.IsType(Assert.Single(list)); + Assert.Equal(nucleus, relation.Nucleus); + Assert.Equal(output, LaTeXParser.MathListToLaTeX(list).ToString()); + } + + [Theory] + [InlineData(@"\not x")] + [InlineData(@"\not\frac12")] + public void NotRejectsNonRelation(string input) { + var parser = new LaTeXParser(input); + var (_, error) = parser.Build(); + + Assert.Contains(@"\not must be followed by a relation", error); + } + + [Theory] + [InlineData(@"\not\approx", "≉", @"\not \approx ")] + [InlineData(@"\not\equiv", "≢", @"\not \equiv ")] + [InlineData(@"\not\subset", "⊄", @"\not \subset ")] + [InlineData(@"\not\ni", "∌", @"\not \ni ")] + public void NotUsesUnicodePrecomposedNegationWhenAvailable(string input, string nucleus, string output) { + var list = ParseLaTeX(input); + + var relation = Assert.IsType(Assert.Single(list)); + Assert.Equal(nucleus, relation.Nucleus); + var serialized = LaTeXParser.MathListToLaTeX(list).ToString(); + Assert.Equal(output, serialized); + Assert.Equal(list, ParseLaTeX(serialized)); + } + + [Fact] + public void NotUsesCombiningOverlayWhenNoPrecomposedNegationExists() { + var list = ParseLaTeX(@"\not\propto"); + var relation = Assert.IsType(Assert.Single(list)); + Assert.Equal("∝\u0338", relation.Nucleus); + var serialized = LaTeXParser.MathListToLaTeX(list).ToString(); + Assert.Equal(@"\not \propto ", serialized); + Assert.Equal(list, ParseLaTeX(serialized)); + } + + [Fact] + public void NotSerializesScriptsOnOuterRelation() { + var list = ParseLaTeX(@"\not\propto^2"); + + var relation = Assert.IsType(Assert.Single(list)); + Assert.Equal("∝\u0338", relation.Nucleus); + Assert.Equal("2", Assert.Single(relation.Superscript).Nucleus); + var serialized = LaTeXParser.MathListToLaTeX(list).ToString(); + Assert.Equal(@"\not \propto ^2", serialized); + Assert.Equal(list, ParseLaTeX(serialized)); + } + + [Fact] + public void NotRepeatedNegationRoundTrips() { + var list = ParseLaTeX(@"\not\not="); + + var relation = Assert.IsType(Assert.Single(list)); + Assert.Equal("≠\u0338", relation.Nucleus); + var serialized = LaTeXParser.MathListToLaTeX(list).ToString(); + Assert.Equal(@"\not \neq ", serialized); + Assert.Equal(list, ParseLaTeX(serialized)); + } + /// new[] { Base list }, new[] { Script of first atom }, new[] { Script of first atom inside script of first atom } [Theory] [InlineData("x^2", "x^2", new[] { typeof(Variable) }, new[] { typeof(Number) })] diff --git a/CSharpMath.Rendering.Tests/MathDisplay/NotApproximately.png b/CSharpMath.Rendering.Tests/MathDisplay/NotApproximately.png new file mode 100644 index 00000000..ae819d81 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/NotApproximately.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/NotEquals.png b/CSharpMath.Rendering.Tests/MathDisplay/NotEquals.png new file mode 100644 index 00000000..4eae74b5 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/NotEquals.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/NotProportionalTo.png b/CSharpMath.Rendering.Tests/MathDisplay/NotProportionalTo.png new file mode 100644 index 00000000..7e42f45d Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/NotProportionalTo.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/NotApproximately.png b/CSharpMath.Rendering.Tests/MathInline/NotApproximately.png new file mode 100644 index 00000000..ae819d81 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathInline/NotApproximately.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/NotEquals.png b/CSharpMath.Rendering.Tests/MathInline/NotEquals.png new file mode 100644 index 00000000..4eae74b5 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathInline/NotEquals.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/NotProportionalTo.png b/CSharpMath.Rendering.Tests/MathInline/NotProportionalTo.png new file mode 100644 index 00000000..7e42f45d Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathInline/NotProportionalTo.png differ diff --git a/CSharpMath.Rendering.Tests/TestCommandDisplay.cs b/CSharpMath.Rendering.Tests/TestCommandDisplay.cs index 0f2e04d1..3287a6c1 100644 --- a/CSharpMath.Rendering.Tests/TestCommandDisplay.cs +++ b/CSharpMath.Rendering.Tests/TestCommandDisplay.cs @@ -2,6 +2,10 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using CSharpMath.Atom; +using CSharpMath.Display; +using CSharpMath.Display.Displays; +using CSharpMath.Display.FrontEnd; using Xunit; namespace CSharpMath.Rendering.Tests { @@ -20,5 +24,46 @@ public TestCommandDisplay() => [MemberData(nameof(AllCommandValues))] public void CommandsAreDisplayable(Rune ch) => Assert.Contains(typefaces, font => font.GetGlyphIndex(ch.Value) != 0); + + [Fact] + public void NotProportionalToOverlaysProportionalTo() { + var fonts = new Fonts(Array.Empty(), 20); + var negatedLine = Assert.Single(ParseLine(@"\not\propto", fonts).Displays); + var baseLine = Assert.Single(ParseLine(@"\propto", fonts).Displays); + Assert.IsType>(negatedLine); + Assert.IsType>(baseLine); + var negatedTextLine = (TextLineDisplay)negatedLine; + var baseTextLine = (TextLineDisplay)baseLine; + var negatedRun = Assert.Single(negatedTextLine.Runs); + Assert.Single(baseTextLine.Runs); + Assert.Equal(2, negatedRun.Run.Length); + + var expectedBase = GlyphFinder.Instance.Lookup(fonts, 0x221D); + var expectedOverlay = GlyphFinder.Instance.Lookup(fonts, 0x0338); + Assert.Equal(expectedBase.Info.GlyphIndex, negatedRun.Run.GlyphInfos[0].Glyph.Info.GlyphIndex); + Assert.Same(expectedBase.Typeface, negatedRun.Run.GlyphInfos[0].Glyph.Typeface); + Assert.Equal(expectedOverlay.Info.GlyphIndex, negatedRun.Run.GlyphInfos[1].Glyph.Info.GlyphIndex); + Assert.Same(expectedOverlay.Typeface, negatedRun.Run.GlyphInfos[1].Glyph.Typeface); + + var advances = GlyphBoundsProvider.Instance.GetAdvancesForGlyphs( + fonts, negatedRun.Run.Glyphs, negatedRun.Run.Length).Advances.ToArray(); + Assert.Equal(0, advances[1]); + Assert.Equal(baseTextLine.Width, negatedTextLine.Width); + + var bounds = GlyphBoundsProvider.Instance.GetBoundingRectsForGlyphs( + fonts, negatedRun.Run.Glyphs, negatedRun.Run.Length).ToArray(); + var baseInk = bounds[0]; + var overlayInk = bounds[1]; + overlayInk.Offset(advances[0], 0); + Assert.True(overlayInk.IntersectsWith(baseInk)); + } + + static ListDisplay ParseLine(string latex, Fonts fonts) { + var result = Atom.LaTeXParser.MathListFromLaTeX(latex); + Assert.Null(result.Error); + return Assert.IsType>( + Typesetter.CreateLine(result.Match(list => list, _ => throw new InvalidOperationException()), + fonts, TypesettingContext.Instance, LineStyle.Display)); + } } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs index 569e546e..8e801aaf 100644 --- a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs +++ b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs @@ -74,6 +74,9 @@ public sealed class TestRenderingMathData : TestRenderingSharedData 0 && decomposed[decomposed.Length - 1] == '\u0338': + builder.Append(@"\not "); + var baseRelation = new Relation( + decomposed.Substring(0, decomposed.Length - 1).Normalize(NormalizationForm.FormC)); + if (LaTeXSettings.CommandForAtom(baseRelation) is string baseCommand) { + builder.Append(baseCommand); + if (baseCommand.AsSpan().StartsWithInvariant(@"\")) + builder.Append(' '); + } else { + builder.Append(baseRelation.Nucleus); + } + break; case var _ when MathAtomToLaTeX(atom, builder, out _): break; case Atoms.Space space: @@ -731,4 +746,4 @@ public static StringBuilder MathListToLaTeX(MathList mathList, StringBuilder? sb return sb; } } -} \ No newline at end of file +} diff --git a/CSharpMath/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index 3ef5599a..18ccdddb 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -9,6 +9,32 @@ namespace CSharpMath.Atom { using Atoms; //https://mirror.hmc.edu/ctan/macros/latex/contrib/unicode-math/unimath-symbols.pdf public static class LaTeXSettings { + // Unlike LaTeX, where \not is a relation glyph that overstrikes the next + // atom (see the kernel's \neq/\ne definitions and \not declaration: + // https://github.com/latex3/latex2e/blob/develop/base/fontdef.dtx#L1122-L1128, + // https://github.com/latex3/latex2e/blob/develop/base/fontdef.dtx#L1167-L1170), + // CSharpMath consumes one Relation and chooses the best Unicode negation. + // This follows UTR #25's preference for precomposed operators, using U+0338 + // only when no precomposed form exists: https://www.unicode.org/reports/tr25/. + static readonly IReadOnlyDictionary NegatedRelationCommands = + new Dictionary { + ["="] = @"\neq", ["<"] = @"\nless", [">"] = @"\ngtr", + [@"\in"] = @"\notin", [@"\leq"] = @"\nleq", [@"\leqslant"] = @"\nleqslant", + [@"\leqq"] = @"\nleqq", [@"\prec"] = @"\nprec", [@"\preceq"] = @"\npreceq", + [@"\preccurlyeq"] = @"\npreccurlyeq", + [@"\sim"] = @"\nsim", [@"\mid"] = @"\nmid", [@"\shortmid"] = @"\nshortmid", + [@"\vdash"] = @"\nvdash", [@"\vDash"] = @"\nvDash", [@"\Vdash"] = @"\nVdash", + [@"\vartriangleleft"] = @"\ntriangleleft", [@"\trianglelefteq"] = @"\ntrianglelefteq", + [@"\subseteq"] = @"\nsubseteq", [@"\geq"] = @"\ngeq", [@"\geqslant"] = @"\ngeqslant", + [@"\geqq"] = @"\ngeqq", [@"\succ"] = @"\nsucc", [@"\succeq"] = @"\nsucceq", + [@"\succcurlyeq"] = @"\nsucccurlyeq", + [@"\parallel"] = @"\nparallel", [@"\shortparallel"] = @"\nshortparallel", + [@"\vartriangleright"] = @"\ntriangleright", [@"\trianglerighteq"] = @"\ntrianglerighteq", + [@"\supseteq"] = @"\nsupseteq", [@"\cong"] = @"\ncong", + [@"\leftarrow"] = @"\nleftarrow", [@"\Leftarrow"] = @"\nLeftarrow", + [@"\rightarrow"] = @"\nrightarrow", [@"\Rightarrow"] = @"\nRightarrow", + [@"\leftrightarrow"] = @"\nleftrightarrow", [@"\Leftrightarrow"] = @"\nLeftrightarrow", + }; static readonly Dictionary boundaryDelimitersReverse = new Dictionary(); public static IReadOnlyDictionary BoundaryDelimitersReverse => boundaryDelimitersReverse; public static LaTeXCommandDictionary BoundaryDelimiters { get; } = @@ -193,6 +219,24 @@ public static class LaTeXSettings { Ok(new RaiseBox(raise, innerList))); }); } }, + { @"\not", (parser, accumulate, stopChar) => + parser.ReadArgument().Bind(target => { + if (target.Count != 1 || target[0] is not Relation relation + || relation.Subscript.IsNonEmpty() || relation.Superscript.IsNonEmpty()) + return Err(@"\not must be followed by a relation"); + + // Prefer an explicit, semantically exact named negation. In particular, + // do not infer names such as \lneq or \lnapprox: those are refinements, + // not simply \not applied to the positive relation. + var command = CommandForAtom(relation); + if (command != null && NegatedRelationCommands.TryGetValue(command, out var negatedCommand) + && AtomForCommand(negatedCommand) is Relation negated) + return Ok(negated); + + // UTR #25 recommends a precomposed operator where Unicode provides + // one, and the combining long solidus otherwise. + return Ok(new Relation((relation.Nucleus + "\u0338").Normalize(NormalizationForm.FormC))); + }) }, { @"\operatorname", (parser, accumulate, stopChar) => { if (!parser.ReadCharIfAvailable('{')) return "Expected {"; // An operator name is a word, so letters -- but a letter may be written as a command: @@ -1189,4 +1233,4 @@ atom is Accent accent // \varsupsetneqq -> ⫌ + U+FE00 (Variation Selector 1) Not dealing with variation selectors, thank you very much }; } -} \ No newline at end of file +}