From 0d6343ae1ef06ba46919c49a914e4d6fd6ee772d Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 11:59:26 +0800 Subject: [PATCH 1/3] feat: add semantic text style axes --- CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs | 10 +- CSharpMath.Core.Tests/Atom/TextStyleTests.cs | 104 +++++++++++ .../TextLaTeXParserTests.cs | 55 +++++- .../DebugApi/PublicAPI.Unshipped.txt | 3 + .../ReleaseApi/PublicAPI.Unshipped.txt | 3 + CSharpMath.Rendering/Text/TextAtom.cs | 35 +++- .../Text/TextAtomListBuilder.cs | 1 + CSharpMath.Rendering/Text/TextLaTeXParser.cs | 42 ++++- CSharpMath.Rendering/Text/TextTypesetter.cs | 8 +- CSharpMath/Atom/LaTeXParser.cs | 77 ++++++-- CSharpMath/Atom/LaTeXSettings.cs | 21 ++- CSharpMath/Atom/MathAtom.cs | 13 +- CSharpMath/Atom/MathList.cs | 5 +- CSharpMath/Atom/TextStyle.cs | 174 ++++++++++++++++++ CSharpMath/Display/Typesetter.cs | 1 + CSharpMath/PublicAPI.Unshipped.txt | 52 ++++++ 16 files changed, 545 insertions(+), 59 deletions(-) create mode 100644 CSharpMath.Core.Tests/Atom/TextStyleTests.cs create mode 100644 CSharpMath/Atom/TextStyle.cs diff --git a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs index bb6a4b32..c0798b6a 100644 --- a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs +++ b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs @@ -1108,10 +1108,14 @@ public void TestFont(string inputCommand, bool readsToEnd, FontStyle style, stri public void TestFontRecursive(string input) { var list = ParseLaTeX(input); Assert.Collection(list, - CheckAtom("x", variable => Assert.Equal(FontStyle.Roman, variable.FontStyle)), + CheckAtom("x", variable => { + Assert.Equal(FontStyle.Roman, variable.FontStyle); + Assert.Equal(FontFamily.Roman, variable.TextStyle.Family); + Assert.Equal(FontPosture.Italic, variable.TextStyle.Posture); + }), CheckAtom("y", variable => Assert.Equal(FontStyle.Default, variable.FontStyle)) ); - Assert.Equal(@"\mathrm{x}y", LaTeXParser.MathListToLaTeX(list).ToString()); + Assert.Equal(@"\mathrm{\mathit{x}}y", LaTeXParser.MathListToLaTeX(list).ToString()); } [Fact] @@ -1634,4 +1638,4 @@ public void TestErrorSurrogates() { } } } -} \ No newline at end of file +} diff --git a/CSharpMath.Core.Tests/Atom/TextStyleTests.cs b/CSharpMath.Core.Tests/Atom/TextStyleTests.cs new file mode 100644 index 00000000..31364a61 --- /dev/null +++ b/CSharpMath.Core.Tests/Atom/TextStyleTests.cs @@ -0,0 +1,104 @@ +using System; +using CSharpMath.Atom; +using CSharpMath.Atom.Atoms; +using Xunit; + +namespace CSharpMath.Core.AtomTests { + public class TextStyleTests { + [Theory] + [InlineData(FontStyle.Default)] + [InlineData(FontStyle.Roman)] + [InlineData(FontStyle.Bold)] + [InlineData(FontStyle.Caligraphic)] + [InlineData(FontStyle.Typewriter)] + [InlineData(FontStyle.Italic)] + [InlineData(FontStyle.SansSerif)] + [InlineData(FontStyle.Fraktur)] + [InlineData(FontStyle.Blackboard)] + [InlineData(FontStyle.BoldItalic)] + public void LegacyStylesRoundTripExactly(FontStyle legacy) { + var style = TextStyle.FromFontStyle(legacy); + Assert.True(style.TryGetFontStyle(out var roundTrip)); + Assert.Equal(legacy, roundTrip); + Assert.Equal(legacy, style.ToFontStyle()); + } + + [Fact] + public void IndependentAxesComposeWithoutErasingEachOther() { + var style = TextStyleChange.FromFontStyleCommand(FontStyle.SansSerif).ApplyTo(TextStyle.Default); + style = TextStyleChange.FromFontStyleCommand(FontStyle.Bold).ApplyTo(style); + style = style.WithPosture(FontPosture.Slanted).WithCapitals(FontCapitals.SmallCapitals); + + Assert.Equal(FontFamily.SansSerif, style.Family); + Assert.Equal(FontWeight.Bold, style.Weight); + Assert.Equal(FontPosture.Slanted, style.Posture); + Assert.Equal(FontCapitals.SmallCapitals, style.Capitals); + Assert.False(style.TryGetFontStyle(out _)); + Assert.Equal(FontStyle.SansSerif, style.ToFontStyle()); + } + + [Fact] + public void NestedMathCommandsComposeRestoreAndRoundTrip() { + var parsed = LaTeXParserTest.ParseLaTeX(@$"a\mathsf{{\mathbf{{\mathit{{b}}}}c}}d"); + + Assert.Equal(TextStyle.Default, parsed[0].TextStyle); + Assert.Equal(new TextStyle(FontFamily.SansSerif, FontWeight.Bold, FontPosture.Italic, FontCapitals.Normal), + parsed[1].TextStyle); + Assert.Equal(new TextStyle(FontFamily.SansSerif, FontWeight.Regular, FontPosture.Upright, FontCapitals.Normal), + parsed[2].TextStyle); + Assert.Equal(TextStyle.Default, parsed[3].TextStyle); + + var serialized = LaTeXParser.MathListToLaTeX(parsed).ToString(); + Assert.Equal(@$"a\mathsf{{\mathbf{{\mathit{{b}}}}}}\mathsf{{c}}d", serialized); + Assert.Equal(parsed, LaTeXParserTest.ParseLaTeX(serialized)); + } + + [Fact] + public void CloneAndEqualityPreserveSemanticRequests() { + var original = new Variable("x") { + TextStyle = new TextStyle(FontFamily.Roman, FontWeight.Bold, FontPosture.Slanted, FontCapitals.SmallCapitals) + }; + var clone = original.Clone(false); + var different = new Variable("x") { TextStyle = original.TextStyle.WithCapitals(FontCapitals.Normal) }; + + Assert.Equal(original, clone); + Assert.Equal(original.TextStyle, clone.TextStyle); + Assert.NotEqual(original, different); + Assert.Equal("x", original.Nucleus); + } + + [Fact] + public void LegacyPropertyRemainsACompatibilityLayer() { + var atom = new Variable("x") { FontStyle = FontStyle.BoldItalic }; + Assert.Equal(FontStyle.BoldItalic, atom.FontStyle); + Assert.Equal(FontWeight.Bold, atom.TextStyle.Weight); + Assert.Equal(FontPosture.Italic, atom.TextStyle.Posture); + } + + [Fact] + public void InvalidSemanticAxesAreRejected() { + Assert.Throws(() => new TextStyle((FontFamily)99, FontWeight.Regular, FontPosture.Upright, FontCapitals.Normal)); + Assert.Throws(() => TextStyle.Default.WithWeight((FontWeight)99)); + Assert.Throws(() => TextStyle.Default.WithPosture((FontPosture)99)); + Assert.Throws(() => TextStyle.Default.WithCapitals((FontCapitals)99)); + Assert.Throws(() => new TextStyleChange((FontFamily)99, null, null, null)); + Assert.Throws(() => new TextStyleChange(null, (FontWeight)99, null, null)); + } + + [Fact] + public void StyleScopeRestoresStateAfterMalformedArgument() { + var parser = new LaTeXParser(@"\mathbf{"); + Assert.NotNull(parser.Build().Error); + Assert.Equal(TextStyle.Default, parser.CurrentTextStyle); + Assert.False(parser.TextMode); + } + + [Fact] + public void UnsupportedAxesFailSerializationRatherThanProjecting() { + var atom = new Variable("x") { + TextStyle = TextStyle.Default.WithPosture(FontPosture.Slanted).WithCapitals(FontCapitals.SmallCapitals) + }; + Assert.Throws(() => LaTeXParser.MathListToLaTeX(new MathList(atom))); + } + } +} diff --git a/CSharpMath.Rendering.Text.Tests/TextLaTeXParserTests.cs b/CSharpMath.Rendering.Text.Tests/TextLaTeXParserTests.cs index 05b8459d..f357f5ed 100644 --- a/CSharpMath.Rendering.Text.Tests/TextLaTeXParserTests.cs +++ b/CSharpMath.Rendering.Text.Tests/TextLaTeXParserTests.cs @@ -122,11 +122,62 @@ public void StyleDefault() { var input = @"\textnormal abc"; var atom = Parse(input); Assert.Equal(new TextAtom.List(new TextAtom[] { - new TextAtom.Style(new TextAtom.Text("a"), Atom.FontStyle.Roman), + new TextAtom.Style(new TextAtom.Text("a"), new Atom.TextStyleChange( + Atom.FontFamily.Roman, Atom.FontWeight.Regular, Atom.FontPosture.Upright, Atom.FontCapitals.Normal)), new TextAtom.Text("bc"), }), atom); Assert.Equal(@"\textrm{a}bc", TextLaTeXParser.TextAtomToLaTeX(atom).ToString()); } + [Fact] + public void NestedStylesComposeOnlyTheirOwnAxes() { + const string input = @"\textsf{\textbf{\textit{x}}y}z"; + var root = Assert.IsType(Parse(input)); + var sans = Assert.IsType(root.Content[0]); + var sansContent = Assert.IsType(sans.Content); + var bold = Assert.IsType(sansContent.Content[0]); + var italic = Assert.IsType(bold.Content); + var text = Assert.IsType(italic.Content); + + var normal = Atom.TextStyle.Default.WithFamily(Atom.FontFamily.Roman); + var sansStyle = sans.StyleChange.ApplyTo(normal); + var boldStyle = bold.StyleChange.ApplyTo(sansStyle); + var italicStyle = italic.StyleChange.ApplyTo(boldStyle); + Assert.Equal(Atom.FontFamily.SansSerif, italicStyle.Family); + Assert.Equal(Atom.FontWeight.Bold, italicStyle.Weight); + Assert.Equal(Atom.FontPosture.Italic, italicStyle.Posture); + Assert.Equal("x", text.Content); + Assert.Equal(input, TextLaTeXParser.TextAtomToLaTeX(root).ToString()); + } + [Fact] + public void SlantedSmallCapRequestPreservesSourceAndStructuralEquality() { + var change = new Atom.TextStyleChange(null, null, + Atom.FontPosture.Slanted, Atom.FontCapitals.SmallCapitals); + var styled = new TextAtom.Style(new TextAtom.Text("lowercase"), change); + var same = new TextAtom.Style(new TextAtom.Text("lowercase"), change); + var italic = new TextAtom.Style(new TextAtom.Text("lowercase"), + new Atom.TextStyleChange(null, null, Atom.FontPosture.Italic, Atom.FontCapitals.SmallCapitals)); + + Assert.Equal(styled, same); + Assert.NotEqual(styled, italic); + Assert.Equal("lowercase", Assert.IsType(styled.Content).Content); + Assert.Equal(Atom.FontPosture.Slanted, styled.StyleChange.Posture); + Assert.Equal(Atom.FontCapitals.SmallCapitals, styled.StyleChange.Capitals); + } + + [Fact] + public void MultiAxisStyleSerializesWithoutLegacyProjectionLoss() { + var change = new Atom.TextStyleChange( + Atom.FontFamily.SansSerif, Atom.FontWeight.Bold, Atom.FontPosture.Italic, null); + var atom = new TextAtom.Style(new TextAtom.Text("x"), change); + Assert.Equal(@"\textsf{\textbf{\textit{x}}}", TextLaTeXParser.TextAtomToLaTeX(atom).ToString()); + } + + [Fact] + public void UnsupportedTextAxesFailSerializationExplicitly() { + var atom = new TextAtom.Style(new TextAtom.Text("x"), new Atom.TextStyleChange( + null, null, Atom.FontPosture.Slanted, Atom.FontCapitals.SmallCapitals)); + Assert.Throws(() => TextLaTeXParser.TextAtomToLaTeX(atom)); + } [Theory] [InlineData(@"\! ", -3, true)] [InlineData(@"\, ", 3, true)] @@ -504,4 +555,4 @@ public void Error(string badInput, string expected) { Assert.Equal(expected.Replace("\r", null), actual); } } -} \ 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 2a58f4f8..28b4dae4 100644 --- a/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt +++ b/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt @@ -3,6 +3,9 @@ CSharpMath.Rendering.Text.TextAtom.RelativeSize.RelativeSize(CSharpMath.Renderin CSharpMath.Rendering.Text.TextAtom.RelativeSize.Content.get -> CSharpMath.Rendering.Text.TextAtom! CSharpMath.Rendering.Text.TextAtom.RelativeSize.Declaration.get -> string! CSharpMath.Rendering.Text.TextAtom.RelativeSize.Magnification.get -> float +CSharpMath.Rendering.Text.TextAtom.Style.Style(CSharpMath.Rendering.Text.TextAtom! content, CSharpMath.Atom.TextStyleChange styleChange) -> void +CSharpMath.Rendering.Text.TextAtom.Style.StyleChange.get -> CSharpMath.Atom.TextStyleChange +CSharpMath.Rendering.Text.TextAtomListBuilder.Style(CSharpMath.Rendering.Text.TextAtom! atom, CSharpMath.Atom.TextStyleChange styleChange) -> void override CSharpMath.Rendering.Text.TextAtom.RelativeSize.SingleChar(CSharpMath.Atom.FontStyle style) -> int? override CSharpMath.Rendering.Text.TextAtom.RelativeSize.Equals(CSharpMath.Rendering.Text.TextAtom! atom) -> bool override CSharpMath.Rendering.Text.TextAtom.RelativeSize.GetHashCode() -> int diff --git a/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt b/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt index 2a58f4f8..a2d6a528 100644 --- a/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt +++ b/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt @@ -6,3 +6,6 @@ CSharpMath.Rendering.Text.TextAtom.RelativeSize.Magnification.get -> float override CSharpMath.Rendering.Text.TextAtom.RelativeSize.SingleChar(CSharpMath.Atom.FontStyle style) -> int? override CSharpMath.Rendering.Text.TextAtom.RelativeSize.Equals(CSharpMath.Rendering.Text.TextAtom! atom) -> bool override CSharpMath.Rendering.Text.TextAtom.RelativeSize.GetHashCode() -> int +CSharpMath.Rendering.Text.TextAtom.Style.Style(CSharpMath.Rendering.Text.TextAtom! content, CSharpMath.Atom.TextStyleChange styleChange) -> void +CSharpMath.Rendering.Text.TextAtom.Style.StyleChange.get -> CSharpMath.Atom.TextStyleChange +CSharpMath.Rendering.Text.TextAtomListBuilder.Style(CSharpMath.Rendering.Text.TextAtom! atom, CSharpMath.Atom.TextStyleChange styleChange) -> void diff --git a/CSharpMath.Rendering/Text/TextAtom.cs b/CSharpMath.Rendering/Text/TextAtom.cs index 1ff8a281..4f5d33a6 100644 --- a/CSharpMath.Rendering/Text/TextAtom.cs +++ b/CSharpMath.Rendering/Text/TextAtom.cs @@ -6,6 +6,7 @@ namespace CSharpMath.Rendering.Text { //Base type public abstract class TextAtom : System.IEquatable { public abstract int? SingleChar(FontStyle style); + internal virtual int? SingleChar(TextStyle style) => SingleChar(style.ToFontStyle()); public abstract bool Equals(TextAtom a); public override bool Equals(object obj) => obj is TextAtom a && Equals(a); public abstract override int GetHashCode(); @@ -47,6 +48,7 @@ public Accent(TextAtom content, string accent) => public TextAtom Content { get; } public string AccentChar { get; } public override int? SingleChar(FontStyle style) => Content.SingleChar(style); + internal override int? SingleChar(TextStyle style) => Content.SingleChar(style); public override bool Equals(TextAtom atom) => atom is Accent a && a.AccentChar == AccentChar && a.Content.Equals(Content); public override int GetHashCode() => (AccentChar, Content).GetHashCode(); } @@ -60,23 +62,32 @@ public Math(MathList content, bool displayStyle) => public override int GetHashCode() => (DisplayStyle, Content).GetHashCode(); } public sealed class Style : TextAtom { - public Style(TextAtom content, FontStyle style) => - (Content, FontStyle) = - (content, style == FontStyle.Default - //FontStyle.Default is FontStyle.Italic, FontStyle.Roman is no change to characters - ? FontStyle.Roman - : style); + readonly FontStyle legacyFontStyle; + public Style(TextAtom content, FontStyle style) { + Content = content; + legacyFontStyle = style == FontStyle.Default ? FontStyle.Roman : style; + StyleChange = TextStyleChange.FromFontStyleCommand( + style == FontStyle.Default ? FontStyle.Roman : style); + } + public Style(TextAtom content, TextStyleChange styleChange) { + Content = content; + StyleChange = styleChange; + legacyFontStyle = styleChange.ApplyTo(TextStyle.Default).ToFontStyle(); + } public TextAtom Content { get; } - public FontStyle FontStyle { get; } - public override int? SingleChar(FontStyle style) => Content.SingleChar(FontStyle); - public override bool Equals(TextAtom atom) => atom is Style s && s.FontStyle == FontStyle && s.Content.Equals(Content); - public override int GetHashCode() => (FontStyle, Content).GetHashCode(); + public FontStyle FontStyle => legacyFontStyle; + public TextStyleChange StyleChange { get; } + public override int? SingleChar(FontStyle style) => SingleChar(TextStyle.FromFontStyle(style)); + internal override int? SingleChar(TextStyle style) => Content.SingleChar(StyleChange.ApplyTo(style)); + public override bool Equals(TextAtom atom) => atom is Style s && s.StyleChange.Equals(StyleChange) && s.Content.Equals(Content); + public override int GetHashCode() => (StyleChange, Content).GetHashCode(); } public sealed class Size : TextAtom { public Size(TextAtom content, float pointSize) => (Content, PointSize) = (content, pointSize); public TextAtom Content { get; } public float PointSize { get; } public override int? SingleChar(FontStyle style) => Content.SingleChar(style); + internal override int? SingleChar(TextStyle style) => Content.SingleChar(style); public override bool Equals(TextAtom atom) => atom is Size s && s.PointSize == PointSize && s.Content.Equals(Content); public override int GetHashCode() => (PointSize, Content).GetHashCode(); } @@ -92,6 +103,7 @@ public RelativeSize(TextAtom content, string declaration) { public string Declaration { get; } public float Magnification { get; } public override int? SingleChar(FontStyle style) => Content.SingleChar(style); + internal override int? SingleChar(TextStyle style) => Content.SingleChar(style); public override bool Equals(TextAtom atom) => atom is RelativeSize s && s.Declaration == Declaration && s.Magnification == Magnification && s.Content.Equals(Content); public override int GetHashCode() => (Declaration, Magnification, Content).GetHashCode(); @@ -101,6 +113,7 @@ public sealed class Colored : TextAtom { public TextAtom Content { get; } public System.Drawing.Color Colour { get; } public override int? SingleChar(FontStyle style) => Content.SingleChar(style); + internal override int? SingleChar(TextStyle style) => Content.SingleChar(style); public override bool Equals(TextAtom atom) => atom is Colored c && c.Colour == Colour && c.Content.Equals(Content); public override int GetHashCode() => (Colour, Content).GetHashCode(); } @@ -109,6 +122,8 @@ public sealed class List : TextAtom { public IReadOnlyList Content { get; } public override int? SingleChar(FontStyle style) => Content.Count == 1 ? Content[0].SingleChar(style) : null; + internal override int? SingleChar(TextStyle style) => + Content.Count == 1 ? Content[0].SingleChar(style) : null; public override bool Equals(TextAtom atom) => atom is List l && l.Content.Count == Content.Count diff --git a/CSharpMath.Rendering/Text/TextAtomListBuilder.cs b/CSharpMath.Rendering/Text/TextAtomListBuilder.cs index ef6c8644..6d611a9d 100644 --- a/CSharpMath.Rendering/Text/TextAtomListBuilder.cs +++ b/CSharpMath.Rendering/Text/TextAtomListBuilder.cs @@ -43,6 +43,7 @@ public void Text(string text) { } public void Space(Atom.Length space) => Add(new TextAtom.Space(space)); public void Style(TextAtom atom, Atom.FontStyle style) => Add(new TextAtom.Style(atom, style)); + public void Style(TextAtom atom, Atom.TextStyleChange styleChange) => Add(new TextAtom.Style(atom, styleChange)); public void Size(TextAtom atom, float fontSize) => Add(new TextAtom.Size(atom, fontSize)); public void Color(TextAtom atom, Color color) => Add(new TextAtom.Colored(atom, color)); public Atom.Result Math(string mathLaTeX, bool displayStyle, int startAt, ref int endAt) { diff --git a/CSharpMath.Rendering/Text/TextLaTeXParser.cs b/CSharpMath.Rendering/Text/TextLaTeXParser.cs index 30a31bf8..f767b9d0 100644 --- a/CSharpMath.Rendering/Text/TextLaTeXParser.cs +++ b/CSharpMath.Rendering/Text/TextLaTeXParser.cs @@ -403,7 +403,13 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se out var fontStyle): { int tmp_commandLength = textStyle.Length; if (ReadArgumentAtom(latex) - .Bind(builtContent => atoms.Style(builtContent, fontStyle)) + .Bind(builtContent => { + if (textStyle == "textnormal") + atoms.Style(builtContent, new TextStyleChange( + FontFamily.Roman, FontWeight.Regular, FontPosture.Upright, FontCapitals.Normal)); + else + atoms.Style(builtContent, fontStyle); + }) .Error is string error) return error; break; @@ -487,11 +493,35 @@ public static StringBuilder TextAtomToLaTeX(TextAtom atom, StringBuilder? b = nu b.Append('\\').Append(TextLaTeXSettings.PredefinedAccents.SecondToFirst[a.AccentChar]).Append('{'); return TextAtomToLaTeX(a.Content, b).Append('}'); case TextAtom.Style t: - b.Append('\\') - .Append(LaTeXSettings.FontStyles.SecondToFirst[t.FontStyle] is var style && style.StartsWith("math") - ? style.Replace("math", "text") : style) - .Append('{'); - return TextAtomToLaTeX(t.Content, b).Append('}'); + var change = t.StyleChange; + if (change.Posture == Atom.FontPosture.Slanted || change.Capitals == Atom.FontCapitals.SmallCapitals) + throw new InvalidOperationException("Slanted and small-capitals serialization is deferred to #300."); + var wrappers = new System.Collections.Generic.List(); + // The text parser represents \textnormal as an explicit Roman, + // regular, upright, normal reset. Emit its canonical single command, + // rather than nesting three equivalent reset commands. + if (change.Family == Atom.FontFamily.Roman + && change.Weight == Atom.FontWeight.Regular + && change.Posture == Atom.FontPosture.Upright + && change.Capitals == Atom.FontCapitals.Normal) { + b.Append(@"\textrm{"); + TextAtomToLaTeX(t.Content, b); + return b.Append('}'); + } + if (change.Family is { } family) + wrappers.Add(family == Atom.FontFamily.Default ? "textrm" : family switch { + Atom.FontFamily.Roman => "textrm", Atom.FontFamily.SansSerif => "textsf", + Atom.FontFamily.Monospace => "texttt", Atom.FontFamily.Calligraphic => "textcal", + Atom.FontFamily.Fraktur => "textfrak", Atom.FontFamily.Blackboard => "textbb", + _ => throw new InvalidCodePathException("Unknown semantic font family.") + }); + if (change.Weight is { } weight) + wrappers.Add(weight == Atom.FontWeight.Bold ? "textbf" : "textrm"); + if (change.Posture is { } posture) + wrappers.Add(posture == Atom.FontPosture.Italic ? "textit" : "textrm"); + foreach (var wrapper in wrappers) b.Append('\\').Append(wrapper).Append('{'); + TextAtomToLaTeX(t.Content, b); + return b.Append('}', wrappers.Count); case TextAtom.Size z: b.Append(@"\fontsize{").Append(z.PointSize).Append("}{"); return TextAtomToLaTeX(z.Content, b).Append('}'); diff --git a/CSharpMath.Rendering/Text/TextTypesetter.cs b/CSharpMath.Rendering/Text/TextTypesetter.cs index eb70d1a9..805281a6 100644 --- a/CSharpMath.Rendering/Text/TextTypesetter.cs +++ b/CSharpMath.Rendering/Text/TextTypesetter.cs @@ -39,7 +39,7 @@ void AddDisplaysWithLineBreaks( TextLayoutLineBuilder line, List> displayList, List> displayMathList, - FontStyle style, + TextStyle style, Color? color ) { @@ -52,7 +52,7 @@ void AddDisplaysWithLineBreaks( break; case TextAtom.Style st: AddDisplaysWithLineBreaks - (st.Content, fonts, line, displayList, displayMathList, st.FontStyle, color); + (st.Content, fonts, line, displayList, displayMathList, st.StyleChange.ApplyTo(style), color); break; case TextAtom.Size sz: AddDisplaysWithLineBreaks @@ -108,7 +108,7 @@ void FinalizeInlineDisplay(float ascender, float rawDescender, line.Add(display, ascender, -rawDescender, lineGap); } case TextAtom.Text t: - var content = UnicodeFontChanger.ChangeFont(t.Content, style); + var content = UnicodeFontChanger.ChangeFont(t.Content, style.ToFontStyle()); var glyphs = GlyphFinder.Instance.FindGlyphs(fonts, content); //Calling Select(g => g.Typeface).Distinct() speeds up query up to 10 times, //Calling Max(Func<,>) instead of Select(Func<,>).Max() speeds up query 2 times @@ -199,7 +199,7 @@ void FinalizeInlineDisplay(float ascender, float rawDescender, globalLine, relativePositionList, absolutePositionList, - FontStyle.Roman /*FontStyle.Default is FontStyle.Italic, FontStyle.Roman is no change to characters*/, + TextStyle.Default.WithFamily(FontFamily.Roman), null ); BreakLine(globalLine, relativePositionList, absolutePositionList); //remember to finalize the last line diff --git a/CSharpMath/Atom/LaTeXParser.cs b/CSharpMath/Atom/LaTeXParser.cs index a5edf4bd..40a448d6 100644 --- a/CSharpMath/Atom/LaTeXParser.cs +++ b/CSharpMath/Atom/LaTeXParser.cs @@ -29,13 +29,18 @@ public class InnerEnvironment : IEnvironment { public string Chars { get; } public int NextChar { get; private set; } public bool TextMode { get; set; } //_spacesAllowed in iosMath - public FontStyle CurrentFontStyle { get; set; } + TextStyle currentTextStyle; + public FontStyle CurrentFontStyle { + get => currentTextStyle.ToFontStyle(); + set => currentTextStyle = TextStyle.FromFontStyle(value); + } + public TextStyle CurrentTextStyle { get => currentTextStyle; set => currentTextStyle = value; } internal RelativeSizeDeclaration CurrentRelativeSize { get; set; } internal RelativeSizeDeclaration PendingRelativeSize { get; set; } public Stack Environments { get; } = new Stack(); public LaTeXParser(string str) { Chars = str; - CurrentFontStyle = FontStyle.Default; + CurrentTextStyle = TextStyle.Default; } public Result Build() => BuildInternal(false); public char ReadChar() => Chars[NextChar++]; @@ -107,7 +112,7 @@ private Result BuildInternal(bool oneCharOnly, char stopChar = '\0', M atom = resultAtom; break; } - atom.FontStyle = CurrentFontStyle; + atom.TextStyle = CurrentTextStyle; atom.RelativeSize = CurrentRelativeSize; atom.RelativeSizeDeclared = PendingRelativeSize != RelativeSizeDeclaration.None; PendingRelativeSize = RelativeSizeDeclaration.None; @@ -493,7 +498,7 @@ static string BoundaryToLaTeX(Boundary delimiter) => : delimiter.Nucleus ?? ""; private static void MathListToLaTeX - (MathList mathList, StringBuilder builder, FontStyle outerFontStyle) { + (MathList mathList, StringBuilder builder, TextStyle outerFontStyle) { var currentRelativeSize = RelativeSizeDeclaration.None; bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder, #if !NETSTANDARD2_0 && !NET45 @@ -516,21 +521,57 @@ bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder, return false; } + static int AppendStyleCommands(StringBuilder builder, TextStyle outerStyle, TextStyle style) { + if (style.Posture == FontPosture.Slanted || style.Capitals != FontCapitals.Normal) + throw new InvalidOperationException("Slanted and small-capital serialization is introduced with their LaTeX command integration."); + var opened = 0; + if (!outerStyle.Equals(TextStyle.Default)) { + builder.Append(@"\mathnormal{"); + opened++; + } + if (style.TryGetFontStyle(out var legacyStyle)) { + if (legacyStyle != FontStyle.Default) { + builder.Append('\\').Append(LaTeXSettings.FontStyles.SecondToFirst[legacyStyle]).Append('{'); + opened++; + } + return opened; + } + if (style.Family != FontFamily.Default) { + var familyStyle = style.Family switch { + FontFamily.Roman => FontStyle.Roman, + FontFamily.SansSerif => FontStyle.SansSerif, + FontFamily.Monospace => FontStyle.Typewriter, + FontFamily.Calligraphic => FontStyle.Caligraphic, + FontFamily.Fraktur => FontStyle.Fraktur, + FontFamily.Blackboard => FontStyle.Blackboard, + _ => throw new InvalidCodePathException("Unknown semantic font family.") + }; + builder.Append('\\').Append(LaTeXSettings.FontStyles.SecondToFirst[familyStyle]).Append('{'); + opened++; + } + if (style.Weight == FontWeight.Bold) { + builder.Append(@"\mathbf{"); + opened++; + } + if (style.Posture == FontPosture.Italic) { + builder.Append(@"\mathit{"); + opened++; + } + return opened; + } + if (mathList is null) throw new ArgumentNullException(nameof(mathList)); if (mathList.IsEmpty()) return; var currentFontStyle = outerFontStyle; + var openStyleGroups = 0; foreach (var atom in mathList) { - if (currentFontStyle != atom.FontStyle) { - if (currentFontStyle != outerFontStyle) { - // close the previous font style - builder.Append('}'); - } - if (atom.FontStyle != outerFontStyle) { - // open a new font style - builder.Append('\\').Append(LaTeXSettings.FontStyles.SecondToFirst[atom.FontStyle]).Append('{'); - } + if (!currentFontStyle.Equals(atom.TextStyle)) { + builder.Append('}', openStyleGroups); + openStyleGroups = atom.TextStyle.Equals(outerFontStyle) + ? 0 + : AppendStyleCommands(builder, outerFontStyle, atom.TextStyle); } - currentFontStyle = atom.FontStyle; + currentFontStyle = atom.TextStyle; switch (atom) { case Comment { Nucleus: var comment }: builder.Append('%').Append(comment).Append('\n'); @@ -736,7 +777,7 @@ bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder, break; } static void AppendScript - (StringBuilder builder, MathList script, char scriptChar, FontStyle currentFontStyle) { + (StringBuilder builder, MathList script, char scriptChar, TextStyle currentFontStyle) { if (script.IsNonEmpty()) { builder.Append(scriptChar).Append('{'); var lengthBeforeScript = builder.Length; @@ -750,13 +791,11 @@ static void AppendScript AppendScript(builder, atom.Subscript, '_', currentFontStyle); AppendScript(builder, atom.Superscript, '^', currentFontStyle); } - if (currentFontStyle != outerFontStyle) { - builder.Append('}'); - } + builder.Append('}', openStyleGroups); } public static StringBuilder MathListToLaTeX(MathList mathList, StringBuilder? sb = null) { sb ??= new StringBuilder(); - MathListToLaTeX(mathList, sb, FontStyle.Default); + MathListToLaTeX(mathList, sb, TextStyle.Default); return sb; } } diff --git a/CSharpMath/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index af46781e..26250166 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -364,19 +364,19 @@ public static class LaTeXSettings { new AliasBiDictionary((command, fontStyle) => { Commands.Add(@"\" + command, (parser, accumulate, stopChar) => { var oldSpacesAllowed = parser.TextMode; - var oldFontStyle = parser.CurrentFontStyle; + var oldTextStyle = parser.CurrentTextStyle; parser.TextMode = command == "text"; - parser.CurrentFontStyle = fontStyle; + parser.CurrentTextStyle = TextStyleChange.FromFontStyleCommand(fontStyle).ApplyTo(parser.CurrentTextStyle); var readsToEnd = !command.AsSpan().StartsWithInvariant("math") && !command.AsSpan().StartsWithInvariant("text"); - return (readsToEnd ? parser.ReadUntil(stopChar, accumulate) : parser.ReadArgument()).Bind(r => { - parser.CurrentFontStyle = oldFontStyle; - parser.TextMode = oldSpacesAllowed; - if (readsToEnd) - return OkStop(accumulate); - else return OkStyled(r); - }); + var result = readsToEnd ? parser.ReadUntil(stopChar, accumulate) : parser.ReadArgument(); + parser.CurrentTextStyle = oldTextStyle; + parser.TextMode = oldSpacesAllowed; + if (result.Error is string error) return error; + if (readsToEnd) return OkStop(accumulate); + var (styled, _) = result; + return OkStyled(styled); }); }) { { "mathnormal", FontStyle.Default }, @@ -453,6 +453,9 @@ public static StringBuilder ColorToString(Color color, StringBuilder sb) { var atomWithoutScripts = atom.Clone(false); atomWithoutScripts.Superscript.Clear(); atomWithoutScripts.Subscript.Clear(); + // Style is serialized by LaTeXParser around the symbol command and is not + // part of a predefined symbol's identity. + atomWithoutScripts.TextStyle = TextStyle.Default; if (atomWithoutScripts is IMathListContainer container) foreach (var list in container.InnerLists) list.Clear(); diff --git a/CSharpMath/Atom/MathAtom.cs b/CSharpMath/Atom/MathAtom.cs index 6d0ee7a0..c4a15994 100644 --- a/CSharpMath/Atom/MathAtom.cs +++ b/CSharpMath/Atom/MathAtom.cs @@ -22,7 +22,12 @@ public string TypeName { public string Nucleus { get; set; } public MathList Superscript { get; private set; } public MathList Subscript { get; private set; } - public FontStyle FontStyle { get; set; } + TextStyle textStyle; + public FontStyle FontStyle { + get => textStyle.ToFontStyle(); + set => textStyle = TextStyle.FromFontStyle(value); + } + public TextStyle TextStyle { get => textStyle; set => textStyle = value; } RelativeSizeDeclaration relativeSize; internal RelativeSizeDeclaration RelativeSize { get => relativeSize; @@ -58,7 +63,7 @@ protected TAtom ApplyCommonPropertiesOn(bool finalize, TAtom newAtom) newAtom.Superscript = Superscript.Clone(finalize); newAtom.Subscript = Subscript.Clone(finalize); newAtom.IndexRange = IndexRange; - newAtom.FontStyle = FontStyle; + newAtom.TextStyle = TextStyle; newAtom.RelativeSize = RelativeSize; newAtom.RelativeSizeDeclared = RelativeSizeDeclared; return newAtom; @@ -100,11 +105,11 @@ public bool EqualsAtom(MathAtom otherAtom) => LaTeXSettings.RelativeSizeMagnification(otherAtom.RelativeSize) && GetType() == otherAtom.GetType() && //IndexRange == otherAtom.IndexRange && - //FontStyle == otherAtom.FontStyle && + TextStyle.Equals(otherAtom.TextStyle) && Superscript.NullCheckingStructuralEquality(otherAtom.Superscript) && Subscript.NullCheckingStructuralEquality(otherAtom.Subscript); public override bool Equals(object obj) => obj is MathAtom a && EqualsAtom(a); bool IEquatable.Equals(MathAtom otherAtom) => EqualsAtom(otherAtom); - public override int GetHashCode() => (Superscript, Subscript, Nucleus, LaTeXSettings.RelativeSizeMagnification(RelativeSize)).GetHashCode(); + public override int GetHashCode() => (Superscript, Subscript, Nucleus, TextStyle, LaTeXSettings.RelativeSizeMagnification(RelativeSize)).GetHashCode(); } } diff --git a/CSharpMath/Atom/MathList.cs b/CSharpMath/Atom/MathList.cs index d3869673..94b7b740 100644 --- a/CSharpMath/Atom/MathList.cs +++ b/CSharpMath/Atom/MathList.cs @@ -68,7 +68,8 @@ public MathList Clone(bool finalize) { newList[prevDisplayedIndex] = b.ToUnaryOperator(); break; } - if ((prevNode, newNode) is (Number { Superscript.Count: 0, Subscript.Count: 0 } n, Number)) { + if ((prevNode, newNode) is (Number { Superscript.Count: 0, Subscript.Count: 0 } n, Number) && + prevNode.TextStyle.Equals(newNode.TextStyle)) { n.Fuse(newNode); continue; // do not add the new node; we fused it instead. } @@ -122,4 +123,4 @@ public virtual void Add(MathAtom item) { public bool Remove(MathAtom item) => Atoms.Remove(item); public MathList Slice(int index, int count) => new MathList { Atoms = Atoms.GetRange(index, count) }; } -} \ No newline at end of file +} diff --git a/CSharpMath/Atom/TextStyle.cs b/CSharpMath/Atom/TextStyle.cs new file mode 100644 index 00000000..cb8b86cd --- /dev/null +++ b/CSharpMath/Atom/TextStyle.cs @@ -0,0 +1,174 @@ +using System; + +namespace CSharpMath.Atom { + public enum FontFamily { + Default, + Roman, + SansSerif, + Monospace, + Calligraphic, + Fraktur, + Blackboard + } + + public enum FontWeight { + Regular, + Bold + } + + public enum FontPosture { + Upright, + Italic, + Slanted + } + + public enum FontCapitals { + Normal, + SmallCapitals + } + + /// A composable, semantic description of a requested text or math style. + public readonly struct TextStyle : IEquatable { + public TextStyle(FontFamily family, FontWeight weight, FontPosture posture, FontCapitals capitals) { + Validate(family, weight, posture, capitals); + (Family, Weight, Posture, Capitals) = (family, weight, posture, capitals); + } + internal static void Validate(FontFamily family, FontWeight weight, FontPosture posture, FontCapitals capitals) { + if (!Enum.IsDefined(typeof(FontFamily), family)) throw new ArgumentOutOfRangeException(nameof(family)); + if (!Enum.IsDefined(typeof(FontWeight), weight)) throw new ArgumentOutOfRangeException(nameof(weight)); + if (!Enum.IsDefined(typeof(FontPosture), posture)) throw new ArgumentOutOfRangeException(nameof(posture)); + if (!Enum.IsDefined(typeof(FontCapitals), capitals)) throw new ArgumentOutOfRangeException(nameof(capitals)); + } + + public static TextStyle Default => default; + public FontFamily Family { get; } + public FontWeight Weight { get; } + public FontPosture Posture { get; } + public FontCapitals Capitals { get; } + + public TextStyle WithFamily(FontFamily family) => new TextStyle(family, Weight, Posture, Capitals); + public TextStyle WithWeight(FontWeight weight) => new TextStyle(Family, weight, Posture, Capitals); + public TextStyle WithPosture(FontPosture posture) => new TextStyle(Family, Weight, posture, Capitals); + public TextStyle WithCapitals(FontCapitals capitals) => new TextStyle(Family, Weight, Posture, capitals); + + public static TextStyle FromFontStyle(FontStyle style) => style switch { + FontStyle.Default => Default, + FontStyle.Roman => Default.WithFamily(FontFamily.Roman), + FontStyle.Bold => Default.WithWeight(FontWeight.Bold), + FontStyle.Caligraphic => Default.WithFamily(FontFamily.Calligraphic), + FontStyle.Typewriter => Default.WithFamily(FontFamily.Monospace), + FontStyle.Italic => Default.WithPosture(FontPosture.Italic), + FontStyle.SansSerif => Default.WithFamily(FontFamily.SansSerif), + FontStyle.Fraktur => Default.WithFamily(FontFamily.Fraktur), + FontStyle.Blackboard => Default.WithFamily(FontFamily.Blackboard), + FontStyle.BoldItalic => Default.WithWeight(FontWeight.Bold).WithPosture(FontPosture.Italic), + _ => throw new ArgumentOutOfRangeException(nameof(style)) + }; + + /// Returns an exact legacy representation when all requested axes fit . + public bool TryGetFontStyle(out FontStyle style) { + if (Capitals != FontCapitals.Normal || Posture == FontPosture.Slanted) { + style = default; + return false; + } + if (Family == FontFamily.Default) { + style = (Weight, Posture) switch { + (FontWeight.Regular, FontPosture.Upright) => FontStyle.Default, + (FontWeight.Bold, FontPosture.Upright) => FontStyle.Bold, + (FontWeight.Regular, FontPosture.Italic) => FontStyle.Italic, + (FontWeight.Bold, FontPosture.Italic) => FontStyle.BoldItalic, + _ => default + }; + return true; + } + if (Weight != FontWeight.Regular || Posture != FontPosture.Upright) { + style = default; + return false; + } + style = Family switch { + FontFamily.Roman => FontStyle.Roman, + FontFamily.SansSerif => FontStyle.SansSerif, + FontFamily.Monospace => FontStyle.Typewriter, + FontFamily.Calligraphic => FontStyle.Caligraphic, + FontFamily.Fraktur => FontStyle.Fraktur, + FontFamily.Blackboard => FontStyle.Blackboard, + _ => default + }; + return true; + } + + /// + /// Projects this semantic style onto the legacy enum. Family takes precedence for combinations + /// that the enum cannot represent; slanted projects to italic and the capitals axis is preserved + /// only by this value, not by the returned enum. Use to detect loss. + /// + public FontStyle ToFontStyle() { + if (TryGetFontStyle(out var exact)) return exact; + if (Family != FontFamily.Default) + return Family switch { + FontFamily.Roman => FontStyle.Roman, + FontFamily.SansSerif => FontStyle.SansSerif, + FontFamily.Monospace => FontStyle.Typewriter, + FontFamily.Calligraphic => FontStyle.Caligraphic, + FontFamily.Fraktur => FontStyle.Fraktur, + FontFamily.Blackboard => FontStyle.Blackboard, + _ => FontStyle.Default + }; + return (Weight, Posture) switch { + (FontWeight.Bold, FontPosture.Upright) => FontStyle.Bold, + (FontWeight.Bold, _) => FontStyle.BoldItalic, + (FontWeight.Regular, FontPosture.Italic or FontPosture.Slanted) => FontStyle.Italic, + _ => FontStyle.Default + }; + } + + public bool Equals(TextStyle other) => + Family == other.Family && Weight == other.Weight && Posture == other.Posture && Capitals == other.Capitals; + public override bool Equals(object obj) => obj is TextStyle other && Equals(other); + public override int GetHashCode() => (Family, Weight, Posture, Capitals).GetHashCode(); + } + + /// A set of optional axis changes applied by one scoped style command. + public readonly struct TextStyleChange : IEquatable { + public TextStyleChange(FontFamily? family, FontWeight? weight, FontPosture? posture, FontCapitals? capitals) { + if (family.HasValue && !Enum.IsDefined(typeof(FontFamily), family.Value)) throw new ArgumentOutOfRangeException(nameof(family)); + if (weight.HasValue && !Enum.IsDefined(typeof(FontWeight), weight.Value)) throw new ArgumentOutOfRangeException(nameof(weight)); + if (posture.HasValue && !Enum.IsDefined(typeof(FontPosture), posture.Value)) throw new ArgumentOutOfRangeException(nameof(posture)); + if (capitals.HasValue && !Enum.IsDefined(typeof(FontCapitals), capitals.Value)) throw new ArgumentOutOfRangeException(nameof(capitals)); + (Family, Weight, Posture, Capitals) = (family, weight, posture, capitals); + } + + public FontFamily? Family { get; } + public FontWeight? Weight { get; } + public FontPosture? Posture { get; } + public FontCapitals? Capitals { get; } + + public TextStyle ApplyTo(TextStyle style) => new TextStyle( + Family ?? style.Family, + Weight ?? style.Weight, + Posture ?? style.Posture, + Capitals ?? style.Capitals); + + public static TextStyleChange FromFontStyleCommand(FontStyle style) => style switch { + FontStyle.Default => new TextStyleChange(FontFamily.Default, FontWeight.Regular, FontPosture.Upright, FontCapitals.Normal), + FontStyle.Roman => new TextStyleChange(FontFamily.Roman, null, null, null), + FontStyle.Bold => new TextStyleChange(null, FontWeight.Bold, null, null), + FontStyle.Caligraphic => new TextStyleChange(FontFamily.Calligraphic, null, null, null), + FontStyle.Typewriter => new TextStyleChange(FontFamily.Monospace, null, null, null), + FontStyle.Italic => new TextStyleChange(null, null, FontPosture.Italic, null), + FontStyle.SansSerif => new TextStyleChange(FontFamily.SansSerif, null, null, null), + FontStyle.Fraktur => new TextStyleChange(FontFamily.Fraktur, null, null, null), + FontStyle.Blackboard => new TextStyleChange(FontFamily.Blackboard, null, null, null), + FontStyle.BoldItalic => new TextStyleChange(null, FontWeight.Bold, FontPosture.Italic, null), + _ => throw new ArgumentOutOfRangeException(nameof(style)) + }; + + internal static TextStyleChange TextNormal => + new TextStyleChange(FontFamily.Roman, FontWeight.Regular, FontPosture.Upright, FontCapitals.Normal); + + public bool Equals(TextStyleChange other) => + Family == other.Family && Weight == other.Weight && Posture == other.Posture && Capitals == other.Capitals; + public override bool Equals(object obj) => obj is TextStyleChange other && Equals(other); + public override int GetHashCode() => (Family, Weight, Posture, Capitals).GetHashCode(); + } +} diff --git a/CSharpMath/Display/Typesetter.cs b/CSharpMath/Display/Typesetter.cs index ae56f9f0..cd1d1b09 100644 --- a/CSharpMath/Display/Typesetter.cs +++ b/CSharpMath/Display/Typesetter.cs @@ -149,6 +149,7 @@ List _PreprocessMathList() { // combine ordinary atoms together if (newAtom is Ordinary && prevAtom is Ordinary o && o.RelativeSize == newAtom.RelativeSize && + o.TextStyle.Equals(newAtom.TextStyle) && o.Superscript.IsEmpty() && o.Subscript.IsEmpty()) { prevAtom.Fuse(newAtom); // skip the current node as we fused it diff --git a/CSharpMath/PublicAPI.Unshipped.txt b/CSharpMath/PublicAPI.Unshipped.txt index 027786f8..73d609b2 100644 --- a/CSharpMath/PublicAPI.Unshipped.txt +++ b/CSharpMath/PublicAPI.Unshipped.txt @@ -1 +1,53 @@ +CSharpMath.Atom.FontCapitals +CSharpMath.Atom.FontCapitals.Normal = 0 -> CSharpMath.Atom.FontCapitals +CSharpMath.Atom.FontCapitals.SmallCapitals = 1 -> CSharpMath.Atom.FontCapitals +CSharpMath.Atom.FontFamily +CSharpMath.Atom.FontFamily.Blackboard = 6 -> CSharpMath.Atom.FontFamily +CSharpMath.Atom.FontFamily.Calligraphic = 4 -> CSharpMath.Atom.FontFamily +CSharpMath.Atom.FontFamily.Default = 0 -> CSharpMath.Atom.FontFamily +CSharpMath.Atom.FontFamily.Fraktur = 5 -> CSharpMath.Atom.FontFamily +CSharpMath.Atom.FontFamily.Monospace = 3 -> CSharpMath.Atom.FontFamily +CSharpMath.Atom.FontFamily.Roman = 1 -> CSharpMath.Atom.FontFamily +CSharpMath.Atom.FontFamily.SansSerif = 2 -> CSharpMath.Atom.FontFamily +CSharpMath.Atom.FontPosture +CSharpMath.Atom.FontPosture.Italic = 1 -> CSharpMath.Atom.FontPosture +CSharpMath.Atom.FontPosture.Slanted = 2 -> CSharpMath.Atom.FontPosture +CSharpMath.Atom.FontPosture.Upright = 0 -> CSharpMath.Atom.FontPosture +CSharpMath.Atom.FontWeight +CSharpMath.Atom.FontWeight.Bold = 1 -> CSharpMath.Atom.FontWeight +CSharpMath.Atom.FontWeight.Regular = 0 -> CSharpMath.Atom.FontWeight +CSharpMath.Atom.LaTeXParser.CurrentTextStyle.get -> CSharpMath.Atom.TextStyle +CSharpMath.Atom.LaTeXParser.CurrentTextStyle.set -> void +CSharpMath.Atom.MathAtom.TextStyle.get -> CSharpMath.Atom.TextStyle +CSharpMath.Atom.MathAtom.TextStyle.set -> void +CSharpMath.Atom.TextStyle +CSharpMath.Atom.TextStyle.Capitals.get -> CSharpMath.Atom.FontCapitals +CSharpMath.Atom.TextStyle.Equals(CSharpMath.Atom.TextStyle other) -> bool +CSharpMath.Atom.TextStyle.Family.get -> CSharpMath.Atom.FontFamily +CSharpMath.Atom.TextStyle.Posture.get -> CSharpMath.Atom.FontPosture +CSharpMath.Atom.TextStyle.TextStyle() -> void +CSharpMath.Atom.TextStyle.TextStyle(CSharpMath.Atom.FontFamily family, CSharpMath.Atom.FontWeight weight, CSharpMath.Atom.FontPosture posture, CSharpMath.Atom.FontCapitals capitals) -> void +CSharpMath.Atom.TextStyle.ToFontStyle() -> CSharpMath.Atom.FontStyle +CSharpMath.Atom.TextStyle.TryGetFontStyle(out CSharpMath.Atom.FontStyle style) -> bool +CSharpMath.Atom.TextStyle.Weight.get -> CSharpMath.Atom.FontWeight +CSharpMath.Atom.TextStyle.WithCapitals(CSharpMath.Atom.FontCapitals capitals) -> CSharpMath.Atom.TextStyle +CSharpMath.Atom.TextStyle.WithFamily(CSharpMath.Atom.FontFamily family) -> CSharpMath.Atom.TextStyle +CSharpMath.Atom.TextStyle.WithPosture(CSharpMath.Atom.FontPosture posture) -> CSharpMath.Atom.TextStyle +CSharpMath.Atom.TextStyle.WithWeight(CSharpMath.Atom.FontWeight weight) -> CSharpMath.Atom.TextStyle +CSharpMath.Atom.TextStyleChange +CSharpMath.Atom.TextStyleChange.ApplyTo(CSharpMath.Atom.TextStyle style) -> CSharpMath.Atom.TextStyle +CSharpMath.Atom.TextStyleChange.Capitals.get -> CSharpMath.Atom.FontCapitals? +CSharpMath.Atom.TextStyleChange.Equals(CSharpMath.Atom.TextStyleChange other) -> bool +CSharpMath.Atom.TextStyleChange.Family.get -> CSharpMath.Atom.FontFamily? +CSharpMath.Atom.TextStyleChange.Posture.get -> CSharpMath.Atom.FontPosture? +CSharpMath.Atom.TextStyleChange.TextStyleChange() -> void +CSharpMath.Atom.TextStyleChange.TextStyleChange(CSharpMath.Atom.FontFamily? family, CSharpMath.Atom.FontWeight? weight, CSharpMath.Atom.FontPosture? posture, CSharpMath.Atom.FontCapitals? capitals) -> void +CSharpMath.Atom.TextStyleChange.Weight.get -> CSharpMath.Atom.FontWeight? +override CSharpMath.Atom.TextStyle.Equals(object! obj) -> bool +override CSharpMath.Atom.TextStyle.GetHashCode() -> int +override CSharpMath.Atom.TextStyleChange.Equals(object! obj) -> bool +override CSharpMath.Atom.TextStyleChange.GetHashCode() -> int static CSharpMath.Atom.LaTeXSettings.RelativeSizes.get -> System.Collections.Generic.IReadOnlyDictionary! +static CSharpMath.Atom.TextStyle.Default.get -> CSharpMath.Atom.TextStyle +static CSharpMath.Atom.TextStyle.FromFontStyle(CSharpMath.Atom.FontStyle style) -> CSharpMath.Atom.TextStyle +static CSharpMath.Atom.TextStyleChange.FromFontStyleCommand(CSharpMath.Atom.FontStyle style) -> CSharpMath.Atom.TextStyleChange From 20b888532675530e043122c16968eb5e9e767804 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 12:16:17 +0800 Subject: [PATCH 2/3] fix: project semantic text styles for rendering --- CSharpMath.Rendering/Text/TextAtom.cs | 15 ++++++++++++++- CSharpMath.Rendering/Text/TextTypesetter.cs | 2 +- CSharpMath/Atom/Atoms/Number.cs | 5 ++++- CSharpMath/Atom/Atoms/Variable.cs | 5 ++++- CSharpMath/Display/Typesetter.cs | 19 +++++++++++++++++-- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CSharpMath.Rendering/Text/TextAtom.cs b/CSharpMath.Rendering/Text/TextAtom.cs index 4f5d33a6..68d5d39b 100644 --- a/CSharpMath.Rendering/Text/TextAtom.cs +++ b/CSharpMath.Rendering/Text/TextAtom.cs @@ -3,10 +3,23 @@ namespace CSharpMath.Rendering.Text { using Atom; + static class TextStyleProjection { + internal static FontStyle ToFontStyleForText(this TextStyle style) { + if (style.Family == FontFamily.Roman && + (style.Weight != FontWeight.Regular || style.Posture != FontPosture.Upright)) + return (style.Weight, style.Posture) switch { + (FontWeight.Bold, FontPosture.Upright) => FontStyle.Bold, + (FontWeight.Regular, FontPosture.Italic or FontPosture.Slanted) => FontStyle.Italic, + (FontWeight.Bold, _) => FontStyle.BoldItalic, + _ => FontStyle.Roman + }; + return style.ToFontStyle(); + } + } //Base type public abstract class TextAtom : System.IEquatable { public abstract int? SingleChar(FontStyle style); - internal virtual int? SingleChar(TextStyle style) => SingleChar(style.ToFontStyle()); + internal virtual int? SingleChar(TextStyle style) => SingleChar(style.ToFontStyleForText()); public abstract bool Equals(TextAtom a); public override bool Equals(object obj) => obj is TextAtom a && Equals(a); public abstract override int GetHashCode(); diff --git a/CSharpMath.Rendering/Text/TextTypesetter.cs b/CSharpMath.Rendering/Text/TextTypesetter.cs index 805281a6..4d7e4394 100644 --- a/CSharpMath.Rendering/Text/TextTypesetter.cs +++ b/CSharpMath.Rendering/Text/TextTypesetter.cs @@ -108,7 +108,7 @@ void FinalizeInlineDisplay(float ascender, float rawDescender, line.Add(display, ascender, -rawDescender, lineGap); } case TextAtom.Text t: - var content = UnicodeFontChanger.ChangeFont(t.Content, style.ToFontStyle()); + var content = UnicodeFontChanger.ChangeFont(t.Content, style.ToFontStyleForText()); var glyphs = GlyphFinder.Instance.FindGlyphs(fonts, content); //Calling Select(g => g.Typeface).Distinct() speeds up query up to 10 times, //Calling Max(Func<,>) instead of Select(Func<,>).Max() speeds up query 2 times diff --git a/CSharpMath/Atom/Atoms/Number.cs b/CSharpMath/Atom/Atoms/Number.cs index 69dccfb5..8e470654 100644 --- a/CSharpMath/Atom/Atoms/Number.cs +++ b/CSharpMath/Atom/Atoms/Number.cs @@ -8,5 +8,8 @@ public Number(string number) : base(number) { } public Ordinary ToOrdinary( System.Func fontChanger) => ApplyCommonPropertiesOn(false, new Ordinary(fontChanger(Nucleus, FontStyle))); + internal Ordinary ToOrdinary( + System.Func fontChanger) => + ApplyCommonPropertiesOn(false, new Ordinary(fontChanger(Nucleus, TextStyle))); } -} \ No newline at end of file +} diff --git a/CSharpMath/Atom/Atoms/Variable.cs b/CSharpMath/Atom/Atoms/Variable.cs index dbfa4be8..6b2ff133 100644 --- a/CSharpMath/Atom/Atoms/Variable.cs +++ b/CSharpMath/Atom/Atoms/Variable.cs @@ -7,5 +7,8 @@ public Variable(string variable) : base(variable) { } public Ordinary ToOrdinary( System.Func fontChanger) => ApplyCommonPropertiesOn(false, new Ordinary(fontChanger(Nucleus, FontStyle))); + internal Ordinary ToOrdinary( + System.Func fontChanger) => + ApplyCommonPropertiesOn(false, new Ordinary(fontChanger(Nucleus, TextStyle))); } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Typesetter.cs b/CSharpMath/Display/Typesetter.cs index cd1d1b09..9d2ddecd 100644 --- a/CSharpMath/Display/Typesetter.cs +++ b/CSharpMath/Display/Typesetter.cs @@ -8,6 +8,19 @@ using CSharpMath.Display.FrontEnd; namespace CSharpMath.Display { + internal static class TextStyleProjection { + internal static FontStyle ToFontStyleForText(TextStyle style) { + if (style.Family == FontFamily.Roman && + (style.Weight != FontWeight.Regular || style.Posture != FontPosture.Upright)) + return (style.Weight, style.Posture) switch { + (FontWeight.Bold, FontPosture.Upright) => FontStyle.Bold, + (FontWeight.Regular, FontPosture.Italic or FontPosture.Slanted) => FontStyle.Italic, + (FontWeight.Bold, _) => FontStyle.BoldItalic, + _ => FontStyle.Roman + }; + return style.ToFontStyle(); + } + } public static class Typesetter { public static ListDisplay CreateLine (MathList list, TFont font, TypesettingContext context, LineStyle style) @@ -139,8 +152,10 @@ List _PreprocessMathList() { // These are not a TeX type nodes. TeX does this during parsing the input. // switch to using the font specified in the atom and convert it to ordinary var newAtom = atom switch { - Variable v => v.ToOrdinary(UnicodeFontChanger.ChangeFont), - Number n => n.ToOrdinary(UnicodeFontChanger.ChangeFont), + Variable v => v.ToOrdinary((content, textStyle) => + UnicodeFontChanger.ChangeFont(content, TextStyleProjection.ToFontStyleForText(textStyle))), + Number n => n.ToOrdinary((content, textStyle) => + UnicodeFontChanger.ChangeFont(content, TextStyleProjection.ToFontStyleForText(textStyle))), // TeX treats unary operators as Ordinary. So will we. UnaryOperator u => u.ToOrdinary(), _ => atom From fe22a45884f388604148c2abd319bbf92a386b9a Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 12:30:37 +0800 Subject: [PATCH 3/3] style: format semantic font serializer --- CSharpMath.Rendering/Text/TextLaTeXParser.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CSharpMath.Rendering/Text/TextLaTeXParser.cs b/CSharpMath.Rendering/Text/TextLaTeXParser.cs index f767b9d0..df088396 100644 --- a/CSharpMath.Rendering/Text/TextLaTeXParser.cs +++ b/CSharpMath.Rendering/Text/TextLaTeXParser.cs @@ -510,9 +510,12 @@ public static StringBuilder TextAtomToLaTeX(TextAtom atom, StringBuilder? b = nu } if (change.Family is { } family) wrappers.Add(family == Atom.FontFamily.Default ? "textrm" : family switch { - Atom.FontFamily.Roman => "textrm", Atom.FontFamily.SansSerif => "textsf", - Atom.FontFamily.Monospace => "texttt", Atom.FontFamily.Calligraphic => "textcal", - Atom.FontFamily.Fraktur => "textfrak", Atom.FontFamily.Blackboard => "textbb", + Atom.FontFamily.Roman => "textrm", + Atom.FontFamily.SansSerif => "textsf", + Atom.FontFamily.Monospace => "texttt", + Atom.FontFamily.Calligraphic => "textcal", + Atom.FontFamily.Fraktur => "textfrak", + Atom.FontFamily.Blackboard => "textbb", _ => throw new InvalidCodePathException("Unknown semantic font family.") }); if (change.Weight is { } weight)