Skip to content

Commit 3235972

Browse files
committed
fix(parser): complete not relation negation handling
1 parent 726320e commit 3235972

9 files changed

Lines changed: 103 additions & 30 deletions

File tree

‎CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs‎

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,45 @@ public void ControlWordDoesNotConsumeFollowingStar() {
9999
[Theory]
100100
[InlineData(@"\not=", "≠", @"\neq ")]
101101
[InlineData(@"\not<", "≮", @"\nless ")]
102+
[InlineData(@"\not>", "≯", @"\ngtr ")]
102103
[InlineData(@"\not\leq", "≰", @"\nleq ")]
104+
[InlineData(@"\not\le", "≰", @"\nleq ")]
105+
[InlineData(@"\not\leqslant", "⩽\u0338", @"\nleqslant ")]
106+
[InlineData(@"\not\leqq", "≦\u0338", @"\nleqq ")]
103107
[InlineData(@"\not\in", "∉", @"\notin ")]
108+
[InlineData(@"\not\geq", "≱", @"\ngeq ")]
109+
[InlineData(@"\not\ge", "≱", @"\ngeq ")]
110+
[InlineData(@"\not\geqslant", "⩾\u0338", @"\ngeqslant ")]
111+
[InlineData(@"\not\geqq", "≧\u0338", @"\ngeqq ")]
112+
[InlineData(@"\not\prec", "⊀", @"\nprec ")]
113+
[InlineData(@"\not\preceq", "⪯\u0338", @"\npreceq ")]
114+
[InlineData(@"\not\preccurlyeq", "⋠", @"\npreccurlyeq ")]
115+
[InlineData(@"\not\sim", "≁", @"\nsim ")]
116+
[InlineData(@"\not\mid", "∤", @"\nshortmid ")]
117+
[InlineData(@"\not\shortmid", "∤", @"\nshortmid ")]
118+
[InlineData(@"\not\vdash", "⊬", @"\nvdash ")]
119+
[InlineData(@"\not\vDash", "⊭", @"\nvDash ")]
120+
[InlineData(@"\not\Vdash", "⊮", @"\nVdash ")]
121+
[InlineData(@"\not\subseteq", "⊈", @"\nsubseteq ")]
122+
[InlineData(@"\not\supseteq", "⊉", @"\nsupseteq ")]
123+
[InlineData(@"\not\succ", "⊁", @"\nsucc ")]
124+
[InlineData(@"\not\succeq", "⪰\u0338", @"\nsucceq ")]
125+
[InlineData(@"\not\succcurlyeq", "⋡", @"\nsucccurlyeq ")]
126+
[InlineData(@"\not\parallel", "∦", @"\nshortparallel ")]
127+
[InlineData(@"\not\shortparallel", "∦", @"\nshortparallel ")]
128+
[InlineData(@"\not\vartriangleleft", "⋪", @"\ntriangleleft ")]
129+
[InlineData(@"\not\trianglelefteq", "⋬", @"\ntrianglelefteq ")]
130+
[InlineData(@"\not\vartriangleright", "⋫", @"\ntriangleright ")]
131+
[InlineData(@"\not\trianglerighteq", "⋭", @"\ntrianglerighteq ")]
132+
[InlineData(@"\not\cong", "≇", @"\ncong ")]
133+
[InlineData(@"\not\gets", "↚", @"\nleftarrow ")]
134+
[InlineData(@"\not\leftarrow", "↚", @"\nleftarrow ")]
135+
[InlineData(@"\not\Leftarrow", "⇍", @"\nLeftarrow ")]
136+
[InlineData(@"\not\rightarrow", "↛", @"\nrightarrow ")]
137+
[InlineData(@"\not\to", "↛", @"\nrightarrow ")]
138+
[InlineData(@"\not\Rightarrow", "⇏", @"\nRightarrow ")]
139+
[InlineData(@"\not\leftrightarrow", "↮", @"\nleftrightarrow ")]
140+
[InlineData(@"\not\Leftrightarrow", "⇎", @"\nLeftrightarrow ")]
104141
public void NotNegatesRelation(string input, string nucleus, string output) {
105142
var list = ParseLaTeX(input);
106143

@@ -119,26 +156,40 @@ public void NotRejectsNonRelation(string input) {
119156
Assert.Contains(@"\not must be followed by a relation", error);
120157
}
121158

122-
[Fact]
123-
public void NotUsesCombiningOverlayForUnsupportedRelation() {
124-
var list = ParseLaTeX(@"\not\approx");
159+
[Theory]
160+
[InlineData(@"\not\approx", "≉", @"\not \approx ")]
161+
[InlineData(@"\not\equiv", "≢", @"\not \equiv ")]
162+
[InlineData(@"\not\subset", "⊄", @"\not \subset ")]
163+
[InlineData(@"\not\ni", "∌", @"\not \ni ")]
164+
public void NotUsesUnicodePrecomposedNegationWhenAvailable(string input, string nucleus, string output) {
165+
var list = ParseLaTeX(input);
166+
167+
var relation = Assert.IsType<Relation>(Assert.Single(list));
168+
Assert.Equal(nucleus, relation.Nucleus);
169+
var serialized = LaTeXParser.MathListToLaTeX(list).ToString();
170+
Assert.Equal(output, serialized);
171+
Assert.Equal(list, ParseLaTeX(serialized));
172+
}
125173

174+
[Fact]
175+
public void NotUsesCombiningOverlayWhenNoPrecomposedNegationExists() {
176+
var list = ParseLaTeX(@"\not\propto");
126177
var relation = Assert.IsType<Relation>(Assert.Single(list));
127-
Assert.Equal("≈\u0338", relation.Nucleus);
178+
Assert.Equal("∝\u0338", relation.Nucleus);
128179
var serialized = LaTeXParser.MathListToLaTeX(list).ToString();
129-
Assert.Equal(@"\not \approx ", serialized);
180+
Assert.Equal(@"\not \propto ", serialized);
130181
Assert.Equal(list, ParseLaTeX(serialized));
131182
}
132183

133184
[Fact]
134185
public void NotSerializesScriptsOnOuterRelation() {
135-
var list = ParseLaTeX(@"\not\approx^2");
186+
var list = ParseLaTeX(@"\not\propto^2");
136187

137188
var relation = Assert.IsType<Relation>(Assert.Single(list));
138-
Assert.Equal("≈\u0338", relation.Nucleus);
189+
Assert.Equal("∝\u0338", relation.Nucleus);
139190
Assert.Equal("2", Assert.Single(relation.Superscript).Nucleus);
140191
var serialized = LaTeXParser.MathListToLaTeX(list).ToString();
141-
Assert.Equal(@"\not \approx ^2", serialized);
192+
Assert.Equal(@"\not \propto ^2", serialized);
142193
Assert.Equal(list, ParseLaTeX(serialized));
143194
}
144195

-458 Bytes
Loading
1.66 KB
Loading
-458 Bytes
Loading
1.66 KB
Loading

‎CSharpMath.Rendering.Tests/TestCommandDisplay.cs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ public void CommandsAreDisplayable(Rune ch) =>
2626
Assert.Contains(typefaces, font => font.GetGlyphIndex(ch.Value) != 0);
2727

2828
[Fact]
29-
public void NotApproximatelyOverlaysApproximately() {
29+
public void NotProportionalToOverlaysProportionalTo() {
3030
var fonts = new Fonts(Array.Empty<Typography.OpenFont.Typeface>(), 20);
31-
var negatedLine = Assert.Single(ParseLine(@"\not\approx", fonts).Displays);
32-
var baseLine = Assert.Single(ParseLine(@"\approx", fonts).Displays);
31+
var negatedLine = Assert.Single(ParseLine(@"\not\propto", fonts).Displays);
32+
var baseLine = Assert.Single(ParseLine(@"\propto", fonts).Displays);
3333
Assert.IsType<TextLineDisplay<Fonts, Glyph>>(negatedLine);
3434
Assert.IsType<TextLineDisplay<Fonts, Glyph>>(baseLine);
3535
var negatedTextLine = (TextLineDisplay<Fonts, Glyph>)negatedLine;
@@ -38,7 +38,7 @@ public void NotApproximatelyOverlaysApproximately() {
3838
Assert.Single(baseTextLine.Runs);
3939
Assert.Equal(2, negatedRun.Run.Length);
4040

41-
var expectedBase = GlyphFinder.Instance.Lookup(fonts, 0x2248);
41+
var expectedBase = GlyphFinder.Instance.Lookup(fonts, 0x221D);
4242
var expectedOverlay = GlyphFinder.Instance.Lookup(fonts, 0x0338);
4343
Assert.Equal(expectedBase.Info.GlyphIndex, negatedRun.Run.GlyphInfos[0].Glyph.Info.GlyphIndex);
4444
Assert.Same(expectedBase.Typeface, negatedRun.Run.GlyphInfos[0].Glyph.Typeface);

‎CSharpMath.Rendering.Tests/TestRenderingMathData.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public sealed class TestRenderingMathData : TestRenderingSharedData<TestRenderin
7676
public const string Nothing = @"";
7777
public const string NotEquals = @"\not=";
7878
public const string NotApproximately = @"\not\approx";
79+
public const string NotProportionalTo = @"\not\propto";
7980
public const string Overline = @"\overline{Overline}";
8081
public const string Pi = @"\pi";
8182
public const string Phi = @"\phi";
@@ -137,4 +138,4 @@ public sealed class TestRenderingMathData : TestRenderingSharedData<TestRenderin
137138

138139
public const string VectorProjection = @"Proj_\vec{v}\vec{u}=|\vec u|\cos\theta\times\frac\vec v{|\vec v|}=|\vec u|\frac{\vec u \cdot \vec v}{|\vec u||\vec v|}\times\frac\vec v{|\vec v|}\\\text{Suppose \mathit{u} and \mathit v are unit vectors, }Proj_\vec v\vec u = (\vec u\cdot\vec v)\vec v";
139140
}
140-
}
141+
}

‎CSharpMath/Atom/LaTeXParser.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -681,9 +681,11 @@ static bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder,
681681
break;
682682
case Relation { Nucleus: { } nucleus }
683683
when LaTeXSettings.CommandForAtom(atom) is null
684-
&& nucleus.Length > 0 && nucleus[nucleus.Length - 1] == '\u0338':
684+
&& nucleus.Normalize(NormalizationForm.FormD) is { } decomposed
685+
&& decomposed.Length > 0 && decomposed[decomposed.Length - 1] == '\u0338':
685686
builder.Append(@"\not ");
686-
var baseRelation = new Relation(nucleus.Substring(0, nucleus.Length - 1));
687+
var baseRelation = new Relation(
688+
decomposed.Substring(0, decomposed.Length - 1).Normalize(NormalizationForm.FormC));
687689
if (LaTeXSettings.CommandForAtom(baseRelation) is string baseCommand) {
688690
builder.Append(baseCommand);
689691
if (baseCommand.AsSpan().StartsWithInvariant(@"\"))

‎CSharpMath/Atom/LaTeXSettings.cs‎

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,32 @@ namespace CSharpMath.Atom {
99
using Atoms;
1010
//https://mirror.hmc.edu/ctan/macros/latex/contrib/unicode-math/unimath-symbols.pdf
1111
public static class LaTeXSettings {
12+
// Unlike LaTeX, where \not is a relation glyph that overstrikes the next
13+
// atom (see the kernel's \neq/\ne definitions and \not declaration:
14+
// https://github.com/latex3/latex2e/blob/develop/base/fontdef.dtx#L1122-L1128,
15+
// https://github.com/latex3/latex2e/blob/develop/base/fontdef.dtx#L1167-L1170),
16+
// CSharpMath consumes one Relation and chooses the best Unicode negation.
17+
// This follows UTR #25's preference for precomposed operators, using U+0338
18+
// only when no precomposed form exists: https://www.unicode.org/reports/tr25/.
19+
static readonly IReadOnlyDictionary<string, string> NegatedRelationCommands =
20+
new Dictionary<string, string> {
21+
["="] = @"\neq", ["<"] = @"\nless", [">"] = @"\ngtr",
22+
[@"\in"] = @"\notin", [@"\leq"] = @"\nleq", [@"\leqslant"] = @"\nleqslant",
23+
[@"\leqq"] = @"\nleqq", [@"\prec"] = @"\nprec", [@"\preceq"] = @"\npreceq",
24+
[@"\preccurlyeq"] = @"\npreccurlyeq",
25+
[@"\sim"] = @"\nsim", [@"\mid"] = @"\nmid", [@"\shortmid"] = @"\nshortmid",
26+
[@"\vdash"] = @"\nvdash", [@"\vDash"] = @"\nvDash", [@"\Vdash"] = @"\nVdash",
27+
[@"\vartriangleleft"] = @"\ntriangleleft", [@"\trianglelefteq"] = @"\ntrianglelefteq",
28+
[@"\subseteq"] = @"\nsubseteq", [@"\geq"] = @"\ngeq", [@"\geqslant"] = @"\ngeqslant",
29+
[@"\geqq"] = @"\ngeqq", [@"\succ"] = @"\nsucc", [@"\succeq"] = @"\nsucceq",
30+
[@"\succcurlyeq"] = @"\nsucccurlyeq",
31+
[@"\parallel"] = @"\nparallel", [@"\shortparallel"] = @"\nshortparallel",
32+
[@"\vartriangleright"] = @"\ntriangleright", [@"\trianglerighteq"] = @"\ntrianglerighteq",
33+
[@"\supseteq"] = @"\nsupseteq", [@"\cong"] = @"\ncong",
34+
[@"\leftarrow"] = @"\nleftarrow", [@"\Leftarrow"] = @"\nLeftarrow",
35+
[@"\rightarrow"] = @"\nrightarrow", [@"\Rightarrow"] = @"\nRightarrow",
36+
[@"\leftrightarrow"] = @"\nleftrightarrow", [@"\Leftrightarrow"] = @"\nLeftrightarrow",
37+
};
1238
static readonly Dictionary<Boundary, string> boundaryDelimitersReverse = new Dictionary<Boundary, string>();
1339
public static IReadOnlyDictionary<Boundary, string> BoundaryDelimitersReverse => boundaryDelimitersReverse;
1440
public static LaTeXCommandDictionary<Boundary> BoundaryDelimiters { get; } =
@@ -199,24 +225,17 @@ public static class LaTeXSettings {
199225
|| relation.Subscript.IsNonEmpty() || relation.Superscript.IsNonEmpty())
200226
return Err(@"\not must be followed by a relation");
201227

202-
// Resolve the negation through the symbol table so the resulting atom
203-
// remains a normal relation and serializes back to canonical LaTeX.
228+
// Prefer an explicit, semantically exact named negation. In particular,
229+
// do not infer names such as \lneq or \lnapprox: those are refinements,
230+
// not simply \not applied to the positive relation.
204231
var command = CommandForAtom(relation);
205-
var negatedCommand = command switch {
206-
"=" => @"\neq",
207-
"<" => @"\nless",
208-
">" => @"\ngtr",
209-
@"\in" => @"\notin",
210-
_ when command is { Length: > 1 } && command[0] == '\\' =>
211-
@"\n" + command.Substring(1),
212-
_ => null
213-
};
214-
if (negatedCommand != null && AtomForCommand(negatedCommand) is Relation negated)
232+
if (command != null && NegatedRelationCommands.TryGetValue(command, out var negatedCommand)
233+
&& AtomForCommand(negatedCommand) is Relation negated)
215234
return Ok(negated);
216235

217-
// A combining long solidus is the generic representation for a
218-
// relation without a dedicated Unicode/LaTeX negation.
219-
return Ok(new Relation(relation.Nucleus + "\u0338"));
236+
// UTR #25 recommends a precomposed operator where Unicode provides
237+
// one, and the combining long solidus otherwise.
238+
return Ok(new Relation((relation.Nucleus + "\u0338").Normalize(NormalizationForm.FormC)));
220239
}) },
221240
{ @"\operatorname", (parser, accumulate, stopChar) => {
222241
if (!parser.ReadCharIfAvailable('{')) return "Expected {";

0 commit comments

Comments
 (0)