From 9e73a16b9dd44c0c751242fede4484d67ad37302 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 00:17:15 +0800 Subject: [PATCH 1/6] feat(parser): support not relation command --- CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs | 33 +++++++++++++++++++ CSharpMath/Atom/LaTeXSettings.cs | 24 +++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs index 9d122367..4035e04a 100644 --- a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs +++ b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs @@ -96,6 +96,39 @@ public void ControlWordDoesNotConsumeFollowingStar() { Assert.Equal(@"\sin *", LaTeXParser.MathListToLaTeX(list).ToString()); } + [Theory] + [InlineData(@"\not=", "≠", @"\neq ")] + [InlineData(@"\not<", "≮", @"\nless ")] + [InlineData(@"\not\leq", "≰", @"\nleq ")] + [InlineData(@"\not\in", "∉", @"\notin ")] + 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); + } + + [Fact] + public void NotReportsUnsupportedRelationWithSourcePosition() { + var (list, error) = LaTeXParser.MathListFromLaTeX(@"\not\approx"); + + Assert.Null(list); + Assert.StartsWith("Error: \\not does not support relation ≈", error); + Assert.Contains("\n\\not\\approx\n", error); + Assert.Contains("↑ (pos ", error); + } + /// 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/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index 3ef5599a..1c78a4ed 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -193,6 +193,28 @@ 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"); + + // Resolve the negation through the symbol table so the resulting atom + // remains a normal relation and serializes back to canonical LaTeX. + var command = CommandForAtom(relation); + var negatedCommand = command switch { + "=" => @"\neq", + "<" => @"\nless", + ">" => @"\ngtr", + @"\in" => @"\notin", + _ when command is { Length: > 1 } && command[0] == '\\' => + @"\n" + command.Substring(1), + _ => null + }; + if (negatedCommand is null || AtomForCommand(negatedCommand) is not Relation negated) + return Err($@"\not does not support relation {relation.Nucleus}"); + return Ok(negated); + }) }, { @"\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 +1211,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 +} From 0b84fd0bbae7c0418533193650055b9f39aff6e1 Mon Sep 17 00:00:00 2001 From: Happypig375 Date: Mon, 31 Aug 2026 04:06:44 +0800 Subject: [PATCH 2/6] feat(parser): support generic not relation overlays --- CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs | 36 +++++++++++--- .../TestCommandDisplay.cs | 47 ++++++++++++++++++- CSharpMath/Atom/LaTeXParser.cs | 15 +++++- CSharpMath/Atom/LaTeXSettings.cs | 9 ++-- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs index 4035e04a..b53f4fef 100644 --- a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs +++ b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs @@ -120,13 +120,37 @@ public void NotRejectsNonRelation(string input) { } [Fact] - public void NotReportsUnsupportedRelationWithSourcePosition() { - var (list, error) = LaTeXParser.MathListFromLaTeX(@"\not\approx"); + public void NotUsesCombiningOverlayForUnsupportedRelation() { + var list = ParseLaTeX(@"\not\approx"); - Assert.Null(list); - Assert.StartsWith("Error: \\not does not support relation ≈", error); - Assert.Contains("\n\\not\\approx\n", error); - Assert.Contains("↑ (pos ", error); + var relation = Assert.IsType(Assert.Single(list)); + Assert.Equal("≈\u0338", relation.Nucleus); + var serialized = LaTeXParser.MathListToLaTeX(list).ToString(); + Assert.Equal(@"\not \approx ", serialized); + Assert.Equal(list, ParseLaTeX(serialized)); + } + + [Fact] + public void NotSerializesScriptsOnOuterRelation() { + var list = ParseLaTeX(@"\not\approx^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 \approx ^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 } diff --git a/CSharpMath.Rendering.Tests/TestCommandDisplay.cs b/CSharpMath.Rendering.Tests/TestCommandDisplay.cs index 0f2e04d1..b4d14004 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 NotApproximatelyOverlaysApproximately() { + var fonts = new Fonts(Array.Empty(), 20); + var negatedLine = Assert.Single(ParseLine(@"\not\approx", fonts).Displays); + var baseLine = Assert.Single(ParseLine(@"\approx", 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, 0x2248); + 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/Atom/LaTeXParser.cs b/CSharpMath/Atom/LaTeXParser.cs index 1250d8e7..2af3dd4f 100644 --- a/CSharpMath/Atom/LaTeXParser.cs +++ b/CSharpMath/Atom/LaTeXParser.cs @@ -679,6 +679,19 @@ static bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder, MathListToLaTeX(r.InnerList, builder, currentFontStyle); builder.Append('}'); break; + case Relation { Nucleus: { } nucleus } + when LaTeXSettings.CommandForAtom(atom) is null + && nucleus.Length > 0 && nucleus[nucleus.Length - 1] == '\u0338': + builder.Append(@"\not "); + var baseRelation = new Relation(nucleus.Substring(0, nucleus.Length - 1)); + 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 +744,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 1c78a4ed..1ab8969c 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -211,9 +211,12 @@ public static class LaTeXSettings { @"\n" + command.Substring(1), _ => null }; - if (negatedCommand is null || AtomForCommand(negatedCommand) is not Relation negated) - return Err($@"\not does not support relation {relation.Nucleus}"); - return Ok(negated); + if (negatedCommand != null && AtomForCommand(negatedCommand) is Relation negated) + return Ok(negated); + + // A combining long solidus is the generic representation for a + // relation without a dedicated Unicode/LaTeX negation. + return Ok(new Relation(relation.Nucleus + "\u0338")); }) }, { @"\operatorname", (parser, accumulate, stopChar) => { if (!parser.ReadCharIfAvailable('{')) return "Expected {"; From 54f2b358f5b76d2dfedd5f83ded4e1a942739deb Mon Sep 17 00:00:00 2001 From: Happypig375 Date: Mon, 31 Aug 2026 11:42:54 +0800 Subject: [PATCH 3/6] test(rendering): cover not relation command --- CSharpMath.Rendering.Tests/TestCommandDisplay.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CSharpMath.Rendering.Tests/TestCommandDisplay.cs b/CSharpMath.Rendering.Tests/TestCommandDisplay.cs index b4d14004..5edba8da 100644 --- a/CSharpMath.Rendering.Tests/TestCommandDisplay.cs +++ b/CSharpMath.Rendering.Tests/TestCommandDisplay.cs @@ -58,6 +58,19 @@ public void NotApproximatelyOverlaysApproximately() { Assert.True(overlayInk.IntersectsWith(baseInk)); } + [Fact] + public void NotEqualsRendersAsSingleGlyph() { + var fonts = new Fonts(Array.Empty(), 20); + var line = Assert.IsType>( + Assert.Single(ParseLine(@"\not=", fonts).Displays)); + var run = Assert.Single(line.Runs); + + Assert.Single(run.Run.GlyphInfos); + var expected = GlyphFinder.Instance.Lookup(fonts, 0x2260); + Assert.Equal(expected.Info.GlyphIndex, run.Run.GlyphInfos[0].Glyph.Info.GlyphIndex); + Assert.Same(expected.Typeface, run.Run.GlyphInfos[0].Glyph.Typeface); + } + static ListDisplay ParseLine(string latex, Fonts fonts) { var result = Atom.LaTeXParser.MathListFromLaTeX(latex); Assert.Null(result.Error); From f0fe617aa524d93f937d9290cad70874ada814cf Mon Sep 17 00:00:00 2001 From: Happypig375 Date: Mon, 31 Aug 2026 13:13:23 +0800 Subject: [PATCH 4/6] test(rendering): add not relation baselines --- .../MathDisplay/NotEquals.png | Bin 0 -> 717 bytes .../MathInline/NotEquals.png | Bin 0 -> 717 bytes CSharpMath.Rendering.Tests/TestCommandDisplay.cs | 13 ------------- .../TestRenderingMathData.cs | 1 + 4 files changed, 1 insertion(+), 13 deletions(-) create mode 100644 CSharpMath.Rendering.Tests/MathDisplay/NotEquals.png create mode 100644 CSharpMath.Rendering.Tests/MathInline/NotEquals.png diff --git a/CSharpMath.Rendering.Tests/MathDisplay/NotEquals.png b/CSharpMath.Rendering.Tests/MathDisplay/NotEquals.png new file mode 100644 index 0000000000000000000000000000000000000000..4eae74b5d63504585ea80714bcb809c817463062 GIT binary patch literal 717 zcmV;;0y6!HP)b1=uE6eq3ZMd10jdC1 z@bZO5Pmcs6tyY@J`69_6=lZUetF=FtW%-Lt&|0HyTSzG}37lcMvPJl<2V8U>bgcz6o=QyMvPrnbzOU{@sUddIGs*V zN}(tU%=7HEMi$OUDWQ}C0$f2ZVnR3mN)}~SRTZ?>D9aMlG(0WOov0Vw-_$x`p*j+4izaXU;H@+NA$Xg^6L+uJu zHUK-mOw;5QEM1Z~XAxI_W@pzbPsuDx0GNXzwF6_=w#upcMF;oe83)V8DjxR2( z&QQk}m;apUy3VvsYNV8AzX&0MTe1)W_xs)a{EwJ%9C5u~gI<7}Wm(Mir~QZX`HZ@* z&AUg5Qpz|^;mW#>4O;`Cn7-`6(g3KYFMF_vv7arWN8_JhDYIDd#U;8kc6^EPY_VYx zvl3fO)G~`5UtFR(Lpc+Bhp1v=ob!Joniz@?kB;)`{-SMLk~m|pA*GCx`F>Z>Y?>zc z2%xowlo9|?mL7*>#=S{Lkl;W%DDJ7_Ru@Lf*|{PG9yQ7ZY>(vIl9IamN=I zQfD|rb1=uE6eq3ZMd10jdC1 z@bZO5Pmcs6tyY@J`69_6=lZUetF=FtW%-Lt&|0HyTSzG}37lcMvPJl<2V8U>bgcz6o=QyMvPrnbzOU{@sUddIGs*V zN}(tU%=7HEMi$OUDWQ}C0$f2ZVnR3mN)}~SRTZ?>D9aMlG(0WOov0Vw-_$x`p*j+4izaXU;H@+NA$Xg^6L+uJu zHUK-mOw;5QEM1Z~XAxI_W@pzbPsuDx0GNXzwF6_=w#upcMF;oe83)V8DjxR2( z&QQk}m;apUy3VvsYNV8AzX&0MTe1)W_xs)a{EwJ%9C5u~gI<7}Wm(Mir~QZX`HZ@* z&AUg5Qpz|^;mW#>4O;`Cn7-`6(g3KYFMF_vv7arWN8_JhDYIDd#U;8kc6^EPY_VYx zvl3fO)G~`5UtFR(Lpc+Bhp1v=ob!Joniz@?kB;)`{-SMLk~m|pA*GCx`F>Z>Y?>zc z2%xowlo9|?mL7*>#=S{Lkl;W%DDJ7_Ru@Lf*|{PG9yQ7ZY>(vIl9IamN=I zQfD|r(), 20); - var line = Assert.IsType>( - Assert.Single(ParseLine(@"\not=", fonts).Displays)); - var run = Assert.Single(line.Runs); - - Assert.Single(run.Run.GlyphInfos); - var expected = GlyphFinder.Instance.Lookup(fonts, 0x2260); - Assert.Equal(expected.Info.GlyphIndex, run.Run.GlyphInfos[0].Glyph.Info.GlyphIndex); - Assert.Same(expected.Typeface, run.Run.GlyphInfos[0].Glyph.Typeface); - } - static ListDisplay ParseLine(string latex, Fonts fonts) { var result = Atom.LaTeXParser.MathListFromLaTeX(latex); Assert.Null(result.Error); diff --git a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs index 569e546e..c17b22b6 100644 --- a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs +++ b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs @@ -74,6 +74,7 @@ public sealed class TestRenderingMathData : TestRenderingSharedData Date: Mon, 31 Aug 2026 13:28:00 +0800 Subject: [PATCH 5/6] test(rendering): cover generic not overlay --- .../MathDisplay/NotApproximately.png | Bin 0 -> 1686 bytes .../MathInline/NotApproximately.png | Bin 0 -> 1686 bytes .../TestRenderingMathData.cs | 1 + 3 files changed, 1 insertion(+) create mode 100644 CSharpMath.Rendering.Tests/MathDisplay/NotApproximately.png create mode 100644 CSharpMath.Rendering.Tests/MathInline/NotApproximately.png diff --git a/CSharpMath.Rendering.Tests/MathDisplay/NotApproximately.png b/CSharpMath.Rendering.Tests/MathDisplay/NotApproximately.png new file mode 100644 index 0000000000000000000000000000000000000000..7b723fb0238b64c338c63592bd4ae644fd0cf8f4 GIT binary patch literal 1686 zcmV;H25I?;P)@VCpEq~Oow?uL7Xl@D z^5=8UllS?)Z@SR$_XEt4UcY{g=g*(x$&)8|@#017*uuQQtgf!&`1lx?E?vUs&!1z* z{`udYY7Fh}?&A3P7yxkl_U+X1xq^A~<_-J%(W6JH<8uQu7z|)p7671Ht>WUvi>a8o zfqDD(E&lxZ0|40E+~mg04NO>Ki;Ii6fB!x=W-efkj*jr@(}1vAn#D@87@U?Af#2xS7w){{B7=4-WwV8yg#$AeaZ3Xm5G& z;K9(?nJ%-FlM}42uEO_ytgNiy%a<=$SXju6pXtoJfBzo7?*pe<83uw`hZ$vGlA2kV zJoY82nT2`x?%hcHlGMz?FA5 zH?qt;&x0gMkR&M+1g9Bmx7(=K>yRXgWv-~A_T|ofglK}V_cZ<1OS07%Of8mx!JZI z)M~X9kRS+wZQGgWZES3?*uA|y?sW+v0RZlg%Y~toGLRw=$t^2Os;Z`9fByU#oH=tQ z01#BG)zop*G#Ln`lzYF_JJaoU;dve?rO-4jSFLABRaKa#2})^fzpm@4fhEhnc%Fww zqY-<5GoLF-C(CkX8pqvS*9}BbKjY;CUWIQ5@@Lqa=zVY}-At5#!%}+DP^&WqKv%u|GoxraBzV2^>qNi_3PJhND!Q6 zE{tzBnFQ!l4)zWW!7r7SiKvD0Zr3#cWglrVB2=e z42IKhUDsnDkBY3mwA*bqG2@M#siE2TeN-wH_`VOrFbWU1Q8|u-Mxz18abo+=pFfXZ zzkUG#UcGvSd-v`Qy^inu&~-gF!;Fq%p;DQ#EDOHxLlnh{KyaF+a7rCj2(q-a#Efkg zM7T&2f+SaDqDp0EYikRZWue#WBn^4J4HB`;a6lRd#t4I_F{45-T&uaaf6!q2=XeW3?^Jkn)F@C zQq;aEiUQYlp=lbo@kW=mOw-Kepr<+oc>MS=I-L#xU~g})P}k3+seJ{Q#J=R!)L=TD z4qJUGVk(PhYB1w`JSwWGz(hYDt*@`=wl76B6_}l!9R$ID<4lo{M@2Q2(Jb%(zD%mp zGjnioF!JNk-%{x?<4%poQK@A%$?D5EDm62ctiFt+Qemc9Vki@4Fc@HWcNZwpz9dx| zOsCVq@87?HDP;v^!c4=yBvlH`wCqb#rNB(fz9dyjpSgDJ8oqx0TDpBns+7$9`0)b( gaQE)rseu5%zoyhReI-`IlK=n!07*qoM6N<$g6T#nkN^Mx literal 0 HcmV?d00001 diff --git a/CSharpMath.Rendering.Tests/MathInline/NotApproximately.png b/CSharpMath.Rendering.Tests/MathInline/NotApproximately.png new file mode 100644 index 0000000000000000000000000000000000000000..7b723fb0238b64c338c63592bd4ae644fd0cf8f4 GIT binary patch literal 1686 zcmV;H25I?;P)@VCpEq~Oow?uL7Xl@D z^5=8UllS?)Z@SR$_XEt4UcY{g=g*(x$&)8|@#017*uuQQtgf!&`1lx?E?vUs&!1z* z{`udYY7Fh}?&A3P7yxkl_U+X1xq^A~<_-J%(W6JH<8uQu7z|)p7671Ht>WUvi>a8o zfqDD(E&lxZ0|40E+~mg04NO>Ki;Ii6fB!x=W-efkj*jr@(}1vAn#D@87@U?Af#2xS7w){{B7=4-WwV8yg#$AeaZ3Xm5G& z;K9(?nJ%-FlM}42uEO_ytgNiy%a<=$SXju6pXtoJfBzo7?*pe<83uw`hZ$vGlA2kV zJoY82nT2`x?%hcHlGMz?FA5 zH?qt;&x0gMkR&M+1g9Bmx7(=K>yRXgWv-~A_T|ofglK}V_cZ<1OS07%Of8mx!JZI z)M~X9kRS+wZQGgWZES3?*uA|y?sW+v0RZlg%Y~toGLRw=$t^2Os;Z`9fByU#oH=tQ z01#BG)zop*G#Ln`lzYF_JJaoU;dve?rO-4jSFLABRaKa#2})^fzpm@4fhEhnc%Fww zqY-<5GoLF-C(CkX8pqvS*9}BbKjY;CUWIQ5@@Lqa=zVY}-At5#!%}+DP^&WqKv%u|GoxraBzV2^>qNi_3PJhND!Q6 zE{tzBnFQ!l4)zWW!7r7SiKvD0Zr3#cWglrVB2=e z42IKhUDsnDkBY3mwA*bqG2@M#siE2TeN-wH_`VOrFbWU1Q8|u-Mxz18abo+=pFfXZ zzkUG#UcGvSd-v`Qy^inu&~-gF!;Fq%p;DQ#EDOHxLlnh{KyaF+a7rCj2(q-a#Efkg zM7T&2f+SaDqDp0EYikRZWue#WBn^4J4HB`;a6lRd#t4I_F{45-T&uaaf6!q2=XeW3?^Jkn)F@C zQq;aEiUQYlp=lbo@kW=mOw-Kepr<+oc>MS=I-L#xU~g})P}k3+seJ{Q#J=R!)L=TD z4qJUGVk(PhYB1w`JSwWGz(hYDt*@`=wl76B6_}l!9R$ID<4lo{M@2Q2(Jb%(zD%mp zGjnioF!JNk-%{x?<4%poQK@A%$?D5EDm62ctiFt+Qemc9Vki@4Fc@HWcNZwpz9dx| zOsCVq@87?HDP;v^!c4=yBvlH`wCqb#rNB(fz9dyjpSgDJ8oqx0TDpBns+7$9`0)b( gaQE)rseu5%zoyhReI-`IlK=n!07*qoM6N<$g6T#nkN^Mx literal 0 HcmV?d00001 diff --git a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs index c17b22b6..ca2b603f 100644 --- a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs +++ b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs @@ -75,6 +75,7 @@ public sealed class TestRenderingMathData : TestRenderingSharedData Date: Mon, 31 Aug 2026 19:50:42 +0800 Subject: [PATCH 6/6] fix(parser): complete not relation negation handling --- CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs | 67 +++++++++++++++--- .../MathDisplay/NotApproximately.png | Bin 1686 -> 1228 bytes .../MathDisplay/NotProportionalTo.png | Bin 0 -> 1697 bytes .../MathInline/NotApproximately.png | Bin 1686 -> 1228 bytes .../MathInline/NotProportionalTo.png | Bin 0 -> 1697 bytes .../TestCommandDisplay.cs | 8 +-- .../TestRenderingMathData.cs | 3 +- CSharpMath/Atom/LaTeXParser.cs | 6 +- CSharpMath/Atom/LaTeXSettings.cs | 49 +++++++++---- 9 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 CSharpMath.Rendering.Tests/MathDisplay/NotProportionalTo.png create mode 100644 CSharpMath.Rendering.Tests/MathInline/NotProportionalTo.png diff --git a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs index b53f4fef..dfacb74a 100644 --- a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs +++ b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs @@ -99,8 +99,45 @@ public void ControlWordDoesNotConsumeFollowingStar() { [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); @@ -119,26 +156,40 @@ public void NotRejectsNonRelation(string input) { Assert.Contains(@"\not must be followed by a relation", error); } - [Fact] - public void NotUsesCombiningOverlayForUnsupportedRelation() { - var list = ParseLaTeX(@"\not\approx"); + [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); + Assert.Equal("∝\u0338", relation.Nucleus); var serialized = LaTeXParser.MathListToLaTeX(list).ToString(); - Assert.Equal(@"\not \approx ", serialized); + Assert.Equal(@"\not \propto ", serialized); Assert.Equal(list, ParseLaTeX(serialized)); } [Fact] public void NotSerializesScriptsOnOuterRelation() { - var list = ParseLaTeX(@"\not\approx^2"); + var list = ParseLaTeX(@"\not\propto^2"); var relation = Assert.IsType(Assert.Single(list)); - Assert.Equal("≈\u0338", relation.Nucleus); + Assert.Equal("∝\u0338", relation.Nucleus); Assert.Equal("2", Assert.Single(relation.Superscript).Nucleus); var serialized = LaTeXParser.MathListToLaTeX(list).ToString(); - Assert.Equal(@"\not \approx ^2", serialized); + Assert.Equal(@"\not \propto ^2", serialized); Assert.Equal(list, ParseLaTeX(serialized)); } diff --git a/CSharpMath.Rendering.Tests/MathDisplay/NotApproximately.png b/CSharpMath.Rendering.Tests/MathDisplay/NotApproximately.png index 7b723fb0238b64c338c63592bd4ae644fd0cf8f4..ae819d815caa757883a1a1a26779427d2c403f78 100644 GIT binary patch delta 1221 zcmV;$1Umbc4a^CU7k@7Z1^@s6PPOvN00004b3#c}2nYxWd>ie1kH=%g7ooLAQ4}DA;C{btr0VEctWoMjmZA%~3n@18$dYp2OD#@?Qc^9l|OF#!0rRRI`d&@>IClrY8w&lzJV z%W{*YlO%yL27i=N48yQ%;$u@jI|bdo}+1+w?5Q$9g-xOtmnheVtWgv8zW8A+5Ocpb3~Txvn)fJ zrZefGl$x$37-OjGdV2qKI>A~C#@KFQnddo_Qn=smvwwN4CV;A{W<-P#;G9Q`o3oHo zYFbwgT5IHa4j}{xAs@>ZXEaJ_pyi^TQA#1tbKu9aZQH(8p{113b>ov6hT-k1Z>`1o ze1TV zAcUar`+wa9jSzyiZE?L`gJ)7oTrQW@jJHJBbvU2TQ`ozZT_Omx%jJTmX;9boA+j7l z20EY5!F@sqIOpJ;2l219hLjRr*Uieov&0dQaWOdz!(lRGjf@XM$VA8(vs##+MawMA zoJ=`VFJU2Hgb+b}aNl;BDn=yBvX@~$OU-VO*nh`9flJkt(kTNerO3eI^@hn%Hu zw_DVMM-qXj9hcsY3eA{h`@Tot_u!ldg^N-O&N+r*Kwa0tEDObIMH8QR)lw}$08Mb~j25T*fqIlXP zScAWQT_h|KkZ}zM##mIoZ-FC22*EH6L49#Of>%-o-bT?kK+N+1fXCx8`WNSX1Tgk} zKQ5nbx!SfJ0gM1f&iQm-2r=FFFG9#zmgU&C?RdZ6$H(I__I*DJAx6en@P1jAtAF1P z2p&*Y2Cc(KOE7R0ojk6Db{1?1}qY}Uohet=?W6$_~u5Wq;N0D*(Ru*&E jbwKOG6}RO9)Hm=ON|;S0crF3t00000NkvXXu0mjfW-LaH delta 1683 zcmV;E25kAv36>3z7k@$s1^@s6`rqzO00004b3#c}2nYxWd@VCpEq~Oow?uL7Xl@D@_*-Z&y)B0zHhqF@Am`D zkzT)kjpxswo@xy3?(X9F_!t0i z`}XbB@wtL|^X3iv`_ZFEspE44GZ+kDSr!1GTCL*Z#fzz!xq*56_AUPW`2zsh+}z~G z%neLfVvCE5xPO2DJ~w7AV2+NC@afYh0KlzVw{Y&Y*J`N8L0RS5t8<`-O2bgGYdGO%D(Ab$Svy+n(tgf!Y z_kFCatl-O+FIZSu$c&%q%)Ed99=`7br&$>Wf?0yRXgWv-~A_T|oC>m&7{f3E0D&L~sbgH2@B{#XEXyMwBDvYN9n@;I6p$bYfoIG%Z)HXGv96n5GFzX>7l)>#2by%f5J?heo3jdw(;ZD@iBI za%LLG-CWlVL{a2sDSRKVT)7ee2o@I?gZ=&ez%UH%Im4rIpuo-E5k zv)P2_d1IX~%W|wqCKW3)&-37U9z;wjjWB#I(z+lH#DsbPGW2qDlkZ72wMeI~Re zWr+|1RaMdJ^$z6Nk`aDesobpXKi>(_DP#tl$PAqYZYk5||= z|7}I>%d=#+p_><7eTgc1|4_Ly<9Qx*U60K(czND!Q6E{tzBntIHZkLkoT;JN_kC0<75Kgn!!QaDw^2EcgGQqP$8lo&&!0b!Uw^-T0RUdT zdWC!U?hU<;@B7eoJvPIPj$)xwnXxPjzVAa6#fd<0nx$|`9aRXjw6w&GZ5BkhND_i1 zS7f3}WoBz@3zlV}*X!l#6?tV@R^|eSAP5jeku~3NU5+;r4y%?F%!LaVKq-Z7+kXut zl<)hHBq>#Pc@#S5Vbu~VGk?c%Sc#RP<>h5&Lxz3MODcRvMwNXGCR|FI^j*nP)V?T+ z0@rn+X&SfjMwhir)6C_dr#c0A{P;0CoeltCZ*Q+q*UzJ=eFd1rzU0-^U^<-+TYV{F zDvM}pFynkYDypf#L_Z#_udnB}FGV#Kn4O&+1i^pfOp%XAMKzVtEPwC+zD%mpGjnio zF!JNk-%{x?<4%poQK@A%$?D5EDm62ctiFt+Qemc9Vki@4Fc@HWcNZwpz9dx|OsCVq z@87?HDP;v^!c4=yBvlH`wCqb#rNB(fz9dyjpSgDJ8oqx0TDpBns+7$9`0)b(aQE)r dseu5%zoyhReI-`IlMDa=002ovPDHLkV1njOFCG8@ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/NotProportionalTo.png b/CSharpMath.Rendering.Tests/MathDisplay/NotProportionalTo.png new file mode 100644 index 0000000000000000000000000000000000000000..7e42f45d08b0a157cf7412abf0bd5d0d3a3e6372 GIT binary patch literal 1697 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 delta 1683 zcmV;E25kAv36>3z7k@$s1^@s6`rqzO00004b3#c}2nYxWd@VCpEq~Oow?uL7Xl@D@_*-Z&y)B0zHhqF@Am`D zkzT)kjpxswo@xy3?(X9F_!t0i z`}XbB@wtL|^X3iv`_ZFEspE44GZ+kDSr!1GTCL*Z#fzz!xq*56_AUPW`2zsh+}z~G z%neLfVvCE5xPO2DJ~w7AV2+NC@afYh0KlzVw{Y&Y*J`N8L0RS5t8<`-O2bgGYdGO%D(Ab$Svy+n(tgf!Y z_kFCatl-O+FIZSu$c&%q%)Ed99=`7br&$>Wf?0yRXgWv-~A_T|oC>m&7{f3E0D&L~sbgH2@B{#XEXyMwBDvYN9n@;I6p$bYfoIG%Z)HXGv96n5GFzX>7l)>#2by%f5J?heo3jdw(;ZD@iBI za%LLG-CWlVL{a2sDSRKVT)7ee2o@I?gZ=&ez%UH%Im4rIpuo-E5k zv)P2_d1IX~%W|wqCKW3)&-37U9z;wjjWB#I(z+lH#DsbPGW2qDlkZ72wMeI~Re zWr+|1RaMdJ^$z6Nk`aDesobpXKi>(_DP#tl$PAqYZYk5||= z|7}I>%d=#+p_><7eTgc1|4_Ly<9Qx*U60K(czND!Q6E{tzBntIHZkLkoT;JN_kC0<75Kgn!!QaDw^2EcgGQqP$8lo&&!0b!Uw^-T0RUdT zdWC!U?hU<;@B7eoJvPIPj$)xwnXxPjzVAa6#fd<0nx$|`9aRXjw6w&GZ5BkhND_i1 zS7f3}WoBz@3zlV}*X!l#6?tV@R^|eSAP5jeku~3NU5+;r4y%?F%!LaVKq-Z7+kXut zl<)hHBq>#Pc@#S5Vbu~VGk?c%Sc#RP<>h5&Lxz3MODcRvMwNXGCR|FI^j*nP)V?T+ z0@rn+X&SfjMwhir)6C_dr#c0A{P;0CoeltCZ*Q+q*UzJ=eFd1rzU0-^U^<-+TYV{F zDvM}pFynkYDypf#L_Z#_udnB}FGV#Kn4O&+1i^pfOp%XAMKzVtEPwC+zD%mpGjnio zF!JNk-%{x?<4%poQK@A%$?D5EDm62ctiFt+Qemc9Vki@4Fc@HWcNZwpz9dx|OsCVq z@87?HDP;v^!c4=yBvlH`wCqb#rNB(fz9dyjpSgDJ8oqx0TDpBns+7$9`0)b(aQE)r dseu5%zoyhReI-`IlMDa=002ovPDHLkV1njOFCG8@ diff --git a/CSharpMath.Rendering.Tests/MathInline/NotProportionalTo.png b/CSharpMath.Rendering.Tests/MathInline/NotProportionalTo.png new file mode 100644 index 0000000000000000000000000000000000000000..7e42f45d08b0a157cf7412abf0bd5d0d3a3e6372 GIT binary patch literal 1697 zcmV;S244AzP)6XoFr9YzJHqB6MK!QZ>IzGv=#J(l)hyAT-TPX7c2H z-kJBE$)Rqy>tT-Y>C-2yt*zn6kt2BV;sq8L7kSy?fBIx2;L)Q;Xt&$=@ZkeGoldCi z&^%eD*X!ZbsZ%&OI6%2v#{2j0L*;X28QZpTaBu(sxO?|*tbDG>Y;A4v^SLI&7{kk# zF9856D=R3M%dt9hLuPYx6Tg4|1_0c?eLJzv+>r4lRw|Wn{rdI9I&(p$-EQOU+qVFK zD_5?75RzDLF33E4_Kct3xpQY=y}6iY&YU@e{r!CqLh$+XXOv2%#CkJtncdx8?CD%q*F$t*zAX zC5o9QgOuNUc_bs;UDU zvMdYh>+3Noh@yx_qmhYFuh+xU(h`3C`o-_9*XyYyQps4B1x?dH2tlLK$kZTX43$a+ zuIr9Os8XqL=N3f~&1N$biJ@c|V_05Z27t^&g7<2g2HUnn*V70ERR91VKYqlC6DRU; zNuFm+(*$EIlTM=`gd|GCbzNwhmUxe4Ss@}TD=WE)V31{etF&6J+ztBhtsC_rUDu)O zI{!~8<^B11`0yd_-@l)`&zNOw+XkgH)Zr-beJu*ZFhV}%TWeuq0qBP>*REX~sm~Z0 z-vRxxejJFRnD|nv)hZZcuq=!B=LeOmtE(eN5-~Ea>q3&GiG?P*uE+d`QVP>F`9ys3 z?BaH*JIZV!vLie0PyqYPoOW&%a<>Y+)s#%-+0~;EDeL?=9m5W z@dE&`wzf8O^(767WF{rCAXHU_APDgXIQ;h!#*rANna}w1PZa)&OSxRe*|TTI>SLAVx-(Wl%~dZXN%v z6G9-%GTyy=msl@2ndU_z8T7;QNg)_q*6VdVfBu}8UA%ZPRF^R}^2in=tcO<9q9XFtyaV9*RT2J(XCsz zaQygjY;0`AhHO7=&x1rV{^FWp7_sy>D=f=GrBVT741yr=lsUNi;)gH6^05eYyIn8o z=yW=sD2g5-#A`GfiFF1Yx~_WwJOCac#A`O2UboxzzJLGjEiNv40K9U!9J{aCYlEm8>b_@(>2q9eCl}aU(E?)!`MUh9qj^psG(QlX#0##LGVZWj%*xA_u zQdVCWV=xRO(+g}g8h`VzEVAizI-aiUi4q7Ngb+_r6tC0iB(|}zu;2mk&Ye4#Nv^F{ z%Tp9(Xum-h$h|>pSr*>`2<`$%k~B0!ef;<_9z1vejJWz@+csR+<+~8*%QBcJO>MXN z(xpq-+uK8_RKk}pUqYWFP6|`WXWH#H_V)Gw09UVGErbZBMJBj;G;V4<3{#O=)^A_N zMd6l-hA%}C!8FJug)c>+P?_cZ_GMfYYMHd~Wzr~=Oi*I??%gZ0E1*a+j4`}=^$GxR z{`~ooHjnb6Ff!ZQ+vxRrz!V(cM`2`w-@e?qaigeiLMVjHw8EDt3L&$(xyjG7eDpRc r6hh|f*RKG8W5 Assert.Contains(typefaces, font => font.GetGlyphIndex(ch.Value) != 0); [Fact] - public void NotApproximatelyOverlaysApproximately() { + public void NotProportionalToOverlaysProportionalTo() { var fonts = new Fonts(Array.Empty(), 20); - var negatedLine = Assert.Single(ParseLine(@"\not\approx", fonts).Displays); - var baseLine = Assert.Single(ParseLine(@"\approx", fonts).Displays); + 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; @@ -38,7 +38,7 @@ public void NotApproximatelyOverlaysApproximately() { Assert.Single(baseTextLine.Runs); Assert.Equal(2, negatedRun.Run.Length); - var expectedBase = GlyphFinder.Instance.Lookup(fonts, 0x2248); + 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); diff --git a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs index ca2b603f..8e801aaf 100644 --- a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs +++ b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs @@ -76,6 +76,7 @@ public sealed class TestRenderingMathData : TestRenderingSharedData 0 && nucleus[nucleus.Length - 1] == '\u0338': + && nucleus.Normalize(NormalizationForm.FormD) is { } decomposed + && decomposed.Length > 0 && decomposed[decomposed.Length - 1] == '\u0338': builder.Append(@"\not "); - var baseRelation = new Relation(nucleus.Substring(0, nucleus.Length - 1)); + 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(@"\")) diff --git a/CSharpMath/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index 1ab8969c..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; } = @@ -199,24 +225,17 @@ public static class LaTeXSettings { || relation.Subscript.IsNonEmpty() || relation.Superscript.IsNonEmpty()) return Err(@"\not must be followed by a relation"); - // Resolve the negation through the symbol table so the resulting atom - // remains a normal relation and serializes back to canonical LaTeX. + // 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); - var negatedCommand = command switch { - "=" => @"\neq", - "<" => @"\nless", - ">" => @"\ngtr", - @"\in" => @"\notin", - _ when command is { Length: > 1 } && command[0] == '\\' => - @"\n" + command.Substring(1), - _ => null - }; - if (negatedCommand != null && AtomForCommand(negatedCommand) is Relation negated) + if (command != null && NegatedRelationCommands.TryGetValue(command, out var negatedCommand) + && AtomForCommand(negatedCommand) is Relation negated) return Ok(negated); - // A combining long solidus is the generic representation for a - // relation without a dedicated Unicode/LaTeX negation. - return Ok(new Relation(relation.Nucleus + "\u0338")); + // 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 {";