From 9c8387f22f3f1569f78086b94d7be4efdba6f903 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 08:49:28 +0800 Subject: [PATCH 1/3] feat: support relative LaTeX sizes --- .../Atom/RelativeSizeTests.cs | 82 +++++++++++++ .../Display/RelativeSizeGeometryTests.cs | 44 +++++++ CSharpMath.Rendering.Tests/TestRendering.cs | 64 ++++++++++- .../RelativeSizeTests.cs | 108 ++++++++++++++++++ .../DebugApi/PublicAPI.Unshipped.txt | 8 ++ .../ReleaseApi/PublicAPI.Unshipped.txt | 8 ++ CSharpMath.Rendering/Text/TextAtom.cs | 18 ++- .../Text/TextAtomListBuilder.cs | 25 +++- CSharpMath.Rendering/Text/TextLaTeXParser.cs | 16 ++- .../Text/TextLaTeXSettings.cs | 7 +- CSharpMath.Rendering/Text/TextTypesetter.cs | 7 +- CSharpMath/Atom/LaTeXParser.cs | 38 +++++- CSharpMath/Atom/LaTeXSettings.cs | 28 ++++- CSharpMath/Atom/MathAtom.cs | 21 +++- CSharpMath/Display/Typesetter.cs | 56 +++++---- CSharpMath/PublicAPI.Unshipped.txt | 1 + 16 files changed, 490 insertions(+), 41 deletions(-) create mode 100644 CSharpMath.Core.Tests/Atom/RelativeSizeTests.cs create mode 100644 CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs create mode 100644 CSharpMath.Rendering.Text.Tests/RelativeSizeTests.cs diff --git a/CSharpMath.Core.Tests/Atom/RelativeSizeTests.cs b/CSharpMath.Core.Tests/Atom/RelativeSizeTests.cs new file mode 100644 index 000000000..4cebaa223 --- /dev/null +++ b/CSharpMath.Core.Tests/Atom/RelativeSizeTests.cs @@ -0,0 +1,82 @@ +using CSharpMath.Atom; +using Xunit; + +namespace CSharpMath.Core.AtomTests { + public class RelativeSizeTests { + [Theory] + [InlineData("tiny")] + [InlineData("scriptsize")] + [InlineData("footnotesize")] + [InlineData("small")] + [InlineData("normalsize")] + [InlineData("large")] + [InlineData("Large")] + [InlineData("LARGE")] + [InlineData("huge")] + [InlineData("Huge")] + public void AllDeclarationsArePreserved(string command) { + var list = LaTeXParserTest.ParseLaTeX($@"\{command} ab"); + Assert.Equal(2, list.Count); + Assert.Equal($@"\{command} ab", LaTeXParser.MathListToLaTeX(list).ToString()); + } + + [Fact] + public void TransitionsScopesAndRoundTripAreStructural() { + const string input = @"\small a{\large b}c\normalsize d"; + var serialized = LaTeXParser.MathListToLaTeX(LaTeXParserTest.ParseLaTeX(input)).ToString(); + var reparsed = LaTeXParserTest.ParseLaTeX(serialized); + Assert.Equal(serialized, LaTeXParser.MathListToLaTeX(reparsed).ToString()); + Assert.Contains(@"\large", serialized); + Assert.Contains(@"\normalsize", serialized); + } + + [Fact] + public void GroupedLargeFollowedByDefaultRoundTrips() { + const string input = @"{\large a}b"; + var serialized = LaTeXParser.MathListToLaTeX(LaTeXParserTest.ParseLaTeX(input)).ToString(); + Assert.Equal(serialized, LaTeXParser.MathListToLaTeX(LaTeXParserTest.ParseLaTeX(serialized)).ToString()); + Assert.Equal(LaTeXParserTest.ParseLaTeX(input), LaTeXParserTest.ParseLaTeX(serialized)); + } + + [Fact] + public void PublicRelativeSizeMutationAffectsEqualityAndRejectsInvalidValues() { + var a = LaTeXParserTest.ParseLaTeX("a")[0]; + var b = a.Clone(false); + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ExplicitNormalSizeEqualsImplicitDefaultButSerializesWhenDeclared() { + var implicitDefault = LaTeXParserTest.ParseLaTeX("a"); + var explicitDefault = LaTeXParserTest.ParseLaTeX(@"\normalsize a"); + Assert.Equal(implicitDefault, explicitDefault); + Assert.Equal(implicitDefault[0].GetHashCode(), explicitDefault[0].GetHashCode()); + Assert.Contains(@"\normalsize", LaTeXParser.MathListToLaTeX(explicitDefault).ToString()); + } + + [Fact] + public void EmptyGroupRestoresSizeAndScriptsInheritOnce() { + var list = LaTeXParserTest.ParseLaTeX(@"\large {}x^2y"); + Assert.Equal(2, list.Count); + } + + [Fact] + public void ParserReportsErrorsAndDoesNotLeakState() { + var parser = new LaTeXParser(@"\small{a"); + var (_, error) = parser.Build(); + Assert.NotNull(error); + Assert.Equal("b", LaTeXParserTest.ParseLaTeX("b")[0].Nucleus); + } + + [Fact] + public void SameParserRestoresSizeAfterNestedError() { + var parser = new LaTeXParser(@"\large\unknown c"); + Assert.NotNull(parser.Build().Error); + MathList? tailList = null; + parser.Build().Match(value => tailList = value, Assert.Null); + Assert.NotNull(tailList); + Assert.Equal("u", tailList![0].Nucleus); + } + } +} diff --git a/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs b/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs new file mode 100644 index 000000000..65c4522d3 --- /dev/null +++ b/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs @@ -0,0 +1,44 @@ +using CSharpMath.Atom; +using CSharpMath.Display; +using CSharpMath.Core.BackEnd; +using Xunit; +using CSharpMath.Core.AtomTests; + +namespace CSharpMath.Core.DisplayTests { + public class RelativeSizeGeometryTests { + static readonly TestFont Font = new TestFont(10); + static readonly Display.FrontEnd.TypesettingContext Context = TestTypesettingContext.Instance; + [Theory] + [InlineData("tiny", .5f)] [InlineData("scriptsize", .7f)] [InlineData("footnotesize", .8f)] + [InlineData("small", .9f)] [InlineData("normalsize", 1f)] [InlineData("large", 1.2f)] + [InlineData("Large", 1.44f)] [InlineData("LARGE", 1.728f)] [InlineData("huge", 2.074f)] [InlineData("Huge", 2.488f)] + public void RelativeSizesScaleRenderedGlyph(string command, float ratio) { + var plain = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX("a"), Font, Context, LineStyle.Text); + var sized = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX($@"\{command} a"), Font, Context, LineStyle.Text); + Assert.InRange(sized.Width / plain.Width, ratio - .002f, ratio + .002f); + Assert.InRange(sized.Ascent / plain.Ascent, ratio - .01f, ratio + .01f); + Assert.InRange(sized.Descent / plain.Descent, ratio - .01f, ratio + .01f); + } + [Fact] public void LargeScriptScalesExactlyOnce() { + var normal = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX("x^2"), Font, Context, LineStyle.Text); + var large = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(@"\large x^2"), Font, Context, LineStyle.Text); + Assert.InRange(large.Width / normal.Width, 1.19f, 1.21f); + Assert.InRange(large.Ascent / normal.Ascent, 1.19f, 1.21f); + Assert.InRange(large.Descent / normal.Descent, 1.19f, 1.21f); + } + [Theory] + [InlineData(@"\frac{a}{b}", @"\large \frac{a}{b}")] + [InlineData(@"\sqrt{a}", @"\large \sqrt{a}")] + [InlineData(@"\left(a\right)", @"\large \left(a\right)")] + [InlineData(@"\hat{a}", @"\large \hat{a}")] + [InlineData(@"\underline{a}", @"\large \underline{a}")] + [InlineData(@"\begin{matrix}a&b\end{matrix}", @"\large \begin{matrix}a&b\end{matrix}")] + public void CompoundRelativeSizeScalesGeometry(string baselineLatex, string sizedLatex) { + var baseline = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(baselineLatex), Font, Context, LineStyle.Display); + var sized = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(sizedLatex), Font, Context, LineStyle.Display); + Assert.InRange(sized.Width / baseline.Width, 1.19f, 1.21f); + Assert.InRange(sized.Ascent / baseline.Ascent, 1.15f, 1.25f); + Assert.InRange(sized.Descent / baseline.Descent, 1.15f, 1.25f); + } + } +} diff --git a/CSharpMath.Rendering.Tests/TestRendering.cs b/CSharpMath.Rendering.Tests/TestRendering.cs index e281aa218..542b07927 100644 --- a/CSharpMath.Rendering.Tests/TestRendering.cs +++ b/CSharpMath.Rendering.Tests/TestRendering.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using SkiaSharp; using Xunit; namespace CSharpMath.Rendering.Tests { @@ -68,6 +69,67 @@ public abstract class TestRendering protected abstract double FileSizeTolerance { get; } protected abstract void DrawToStream(Painter painter, Stream stream, float textPainterCanvasWidth, TextAlignment alignment) where TContent : class; + + // These checks deliberately inspect the produced pixels instead of comparing snapshots. + // They are inherited by both frontends, which catches frontend-specific scaling regressions. + protected (int left, int top, int right, int bottom) RenderedAlphaBounds(string latex) { + using var stream = new MemoryStream(); + var painter = new TTextPainter { FontSize = 40, LaTeX = latex }; + painter.HighlightColor = painter.UnwrapColor(System.Drawing.Color.Transparent); + DrawToStream(painter, stream, 1000, TextAlignment.TopLeft); + Assert.Null(painter.ErrorMessage); + stream.Position = 0; + return DecodeAlphaBounds(stream); + } + + protected (int left, int top, int right, int bottom) RenderedMathAlphaBounds(string latex) { + using var stream = new MemoryStream(); + var painter = new TMathPainter { FontSize = 40, LaTeX = latex, LineStyle = Atom.LineStyle.Display }; + painter.HighlightColor = painter.UnwrapColor(System.Drawing.Color.Transparent); + DrawToStream(painter, stream, 1000, TextAlignment.TopLeft); + Assert.Null(painter.ErrorMessage); + stream.Position = 0; + return DecodeAlphaBounds(stream); + } + + static (int left, int top, int right, int bottom) DecodeAlphaBounds(Stream stream) { + using var bitmap = SKBitmap.Decode(stream); + Assert.NotNull(bitmap); + var left = bitmap.Width; var top = bitmap.Height; var right = -1; var bottom = -1; + for (var y = 0; y < bitmap.Height; y++) for (var x = 0; x < bitmap.Width; x++) + if (bitmap.GetPixel(x, y).Alpha > 10) { + left = Math.Min(left, x); top = Math.Min(top, y); + right = Math.Max(right, x); bottom = Math.Max(bottom, y); + } + Assert.True(right >= left && bottom >= top, "Rendered output has no occupied pixels."); + return (left, top, right, bottom); + } + + [Fact] + public void RelativeSizePixelsChangeInExpectedDirection() { + var plain = RenderedAlphaBounds("a"); + var small = RenderedAlphaBounds(@"\small a"); + var large = RenderedAlphaBounds(@"\large a"); + Assert.True(large.right - large.left > plain.right - plain.left); + Assert.True(small.right - small.left < plain.right - plain.left); + Assert.True(large.bottom - large.top > plain.bottom - plain.top); + } + + [Fact] + public void RelativeSizeGroupedTransitionsRenderWithoutErrors() { + var plain = RenderedAlphaBounds("abc"); + var bounds = RenderedAlphaBounds(@"{\small a}b{\large c}"); + Assert.True(bounds.right > bounds.left && bounds.bottom > bounds.top); + Assert.True(bounds.right - bounds.left > plain.right - plain.left); + } + + [Fact] + public void RelativeSizeNestedCompoundFormulaRendersWithoutErrors() { + var plain = RenderedMathAlphaBounds(@"\frac{\sqrt{x^2}}{y}"); + var large = RenderedMathAlphaBounds(@"\large \frac{\sqrt{x^2}}{y}"); + Assert.True(large.right - large.left > plain.right - plain.left); + Assert.True(large.bottom - large.top > plain.bottom - plain.top); + } [Theory, ClassData(typeof(TestRenderingMathData))] public void MathDisplay(string file, string latex) => Run(file, latex, new TMathPainter { LineStyle = Atom.LineStyle.Display }); @@ -202,4 +264,4 @@ public virtual void MathPainterSettings(string file, TMathPainter painter) => public void TextPainterSettings(string file, TTextPainter painter) => Run(file, @"Inline \color{red}{Maths}: $\int_{a_1^2}^{a_2^2}\color{green}\sqrt\frac x2dx$Display \color{red}{Maths}: $$\int_{a_1^2}^{a_2^2}\color{green}\sqrt\frac x2dx$$", painter); } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering.Text.Tests/RelativeSizeTests.cs b/CSharpMath.Rendering.Text.Tests/RelativeSizeTests.cs new file mode 100644 index 000000000..3d3fc6000 --- /dev/null +++ b/CSharpMath.Rendering.Text.Tests/RelativeSizeTests.cs @@ -0,0 +1,108 @@ +using System.Linq; +using CSharpMath.Rendering.BackEnd; +using CSharpMath.Rendering.Text; +using Xunit; + +namespace CSharpMath.Rendering.Text.Tests { + public class RelativeSizeTests { + static readonly Fonts Font = new Fonts(Enumerable.Empty(), 20); + + static (float width, float ascent, float descent) Layout(string latex) { + var atom = TextLaTeXParser.TextAtomFromLaTeX(latex) + .Match(value => value, error => throw new Xunit.Sdk.XunitException(error)); + var display = TextTypesetter.Layout(atom, Font, float.PositiveInfinity).relative; + return (display.Width, display.Ascent, display.Descent); + } + + [Theory] + [InlineData("tiny", .5f)] + [InlineData("scriptsize", .7f)] + [InlineData("footnotesize", .8f)] + [InlineData("small", .9f)] + [InlineData("normalsize", 1f)] + [InlineData("large", 1.2f)] + [InlineData("Large", 1.44f)] + [InlineData("LARGE", 1.728f)] + [InlineData("huge", 2.074f)] + [InlineData("Huge", 2.488f)] + public void AllDeclarationsPreserveCanonicalRatio(string command, float ratio) { + var atom = TextLaTeXParser.TextAtomFromLaTeX($@"\{command} a") + .Match(value => value, error => throw new Xunit.Sdk.XunitException(error)); + var sized = Assert.IsType(atom); + Assert.Equal(command, sized.Declaration); + Assert.Equal(ratio, sized.Magnification); + Assert.Equal($@"\{command}{{a}}", TextLaTeXParser.TextAtomToLaTeX(atom).ToString()); + } + + [Fact] + public void GroupScopeAndSequentialTransitionsDoNotLeak() { + var atom = TextLaTeXParser.TextAtomFromLaTeX(@"{\small a}b\large c") + .Match(value => value, error => throw new Xunit.Sdk.XunitException(error)); + var list = Assert.IsType(atom); + Assert.IsType(list.Content[0]); + Assert.IsType(list.Content[1]); + Assert.IsType(list.Content[2]); + Assert.Equal(@"\small{a}b\large{c}", TextLaTeXParser.TextAtomToLaTeX(atom).ToString()); + } + + [Fact] + public void RelativeSizeEqualityIncludesMagnificationAndContent() { + var a = new TextAtom.RelativeSize(new TextAtom.Text("a"), "small"); + var b = new TextAtom.RelativeSize(new TextAtom.Text("a"), "small"); + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.NotEqual(a, new TextAtom.RelativeSize(new TextAtom.Text("b"), "small")); + } + + [Fact] + public void RelativeSizesChangeActualGlyphGeometry() { + var normal = Layout("a"); + foreach (var (command, ratio) in new[] { + ("tiny", .5f), ("scriptsize", .7f), ("footnotesize", .8f), ("small", .9f), + ("normalsize", 1f), ("large", 1.2f), ("Large", 1.44f), ("LARGE", 1.728f), + ("huge", 2.074f), ("Huge", 2.488f) }) { + var sized = Layout($@"\{command} a"); + Assert.InRange(sized.width / normal.width, ratio - .035f, ratio + .035f); + Assert.InRange(sized.ascent / normal.ascent, ratio - .06f, ratio + .06f); + Assert.InRange(sized.descent / normal.descent, ratio - .06f, ratio + .06f); + } + } + + [Fact] + public void RelativeSizeRestoresAcrossLinesAndCoexistsWithFontSize() { + var normal = Layout("a\\\\a"); + var mixed = Layout(@"\small a\\a \fontsize{40}{b}"); + Assert.True(mixed.width > normal.width); + var serialized = TextLaTeXParser.TextAtomToLaTeX( + TextLaTeXParser.TextAtomFromLaTeX(@"\small a\\a \fontsize{40}{b}") + .Match(value => value, error => throw new Xunit.Sdk.XunitException(error))).ToString(); + Assert.Contains(@"\small", serialized); + Assert.Contains(@"\fontsize", serialized); + } + + [Fact] + public void RelativeAndAbsoluteFontSizeDeclarationsHaveDefinedPrecedence() { + var absolute = Layout(@"\fontsize{40}{a}"); + var relativeThenAbsolute = Layout(@"\small\fontsize{40}{a}"); + var absoluteThenRelative = Layout(@"\fontsize{40}{\small a}"); + Assert.InRange(relativeThenAbsolute.width / absolute.width, .995f, 1.005f); + // Relative declarations are based on the externally supplied painter font, + // even when nested inside an arbitrary absolute \fontsize declaration. + Assert.InRange(absoluteThenRelative.width / absolute.width, .445f, .455f); + } + + [Fact] + public void RelativeSizeScalesInlineAndDisplayMath() { + var normalInline = Layout("$x$"); + var largeInline = Layout(@"\large $x$"); + Assert.True(largeInline.width > normalInline.width); + var normalDisplay = TextTypesetter.Layout( + TextLaTeXParser.TextAtomFromLaTeX("$$\\frac{a}{b}$$") + .Match(value => value, error => throw new Xunit.Sdk.XunitException(error)), Font, float.PositiveInfinity).absolute; + var largeDisplay = TextTypesetter.Layout( + TextLaTeXParser.TextAtomFromLaTeX(@"\large $$\frac{a}{b}$$") + .Match(value => value, error => throw new Xunit.Sdk.XunitException(error)), Font, float.PositiveInfinity).absolute; + Assert.True(largeDisplay.Width > normalDisplay.Width); + } + } +} diff --git a/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt b/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt index e69de29bb..2a58f4f81 100644 --- a/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt +++ b/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt @@ -0,0 +1,8 @@ +CSharpMath.Rendering.Text.TextAtom.RelativeSize +CSharpMath.Rendering.Text.TextAtom.RelativeSize.RelativeSize(CSharpMath.Rendering.Text.TextAtom! content, string! declaration) -> void +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 +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 e69de29bb..2a58f4f81 100644 --- a/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt +++ b/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt @@ -0,0 +1,8 @@ +CSharpMath.Rendering.Text.TextAtom.RelativeSize +CSharpMath.Rendering.Text.TextAtom.RelativeSize.RelativeSize(CSharpMath.Rendering.Text.TextAtom! content, string! declaration) -> void +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 +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/Text/TextAtom.cs b/CSharpMath.Rendering/Text/TextAtom.cs index 8a2a75dd6..1ff8a281a 100644 --- a/CSharpMath.Rendering/Text/TextAtom.cs +++ b/CSharpMath.Rendering/Text/TextAtom.cs @@ -80,6 +80,22 @@ public sealed class Size : TextAtom { public override bool Equals(TextAtom atom) => atom is Size s && s.PointSize == PointSize && s.Content.Equals(Content); public override int GetHashCode() => (PointSize, Content).GetHashCode(); } + public sealed class RelativeSize : TextAtom { + public RelativeSize(TextAtom content, string declaration) { + if (!TextLaTeXSettings.RelativeSizes.ContainsKey(declaration)) + throw new System.ArgumentException("Unknown relative size declaration", nameof(declaration)); + Content = content; + Declaration = declaration; + Magnification = TextLaTeXSettings.RelativeSizes[declaration]; + } + public TextAtom Content { get; } + public string Declaration { get; } + public float Magnification { get; } + public override int? SingleChar(FontStyle 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(); + } public sealed class Colored : TextAtom { public Colored(TextAtom content, System.Drawing.Color colour) => (Content, Colour) = (content, colour); public TextAtom Content { get; } @@ -107,4 +123,4 @@ public sealed class Comment : TextAtom { public override int GetHashCode() => Content.GetHashCode(); } } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/Text/TextAtomListBuilder.cs b/CSharpMath.Rendering/Text/TextAtomListBuilder.cs index 6d9f99354..ef6c86443 100644 --- a/CSharpMath.Rendering/Text/TextAtomListBuilder.cs +++ b/CSharpMath.Rendering/Text/TextAtomListBuilder.cs @@ -5,7 +5,28 @@ namespace CSharpMath.Rendering.Text { public class TextAtomListBuilder : IReadOnlyList { readonly List _list = new List(); + string? declaration; + float magnification = 1; + int runStart = -1; private void Add(TextAtom atom) => _list.Add(atom); + void CloseRun() { + if (runStart >= 0 && runStart < _list.Count) { + var content = _list.GetRange(runStart, _list.Count - runStart); + _list.RemoveRange(runStart, _list.Count - runStart); + _list.Add(new TextAtom.RelativeSize(BuildList(content), declaration!)); + } + runStart = -1; + } + static TextAtom BuildList(List content) => content.Count == 1 ? content[0] : new TextAtom.List(content); + internal void RelativeSize(string name, float ratio) { + CloseRun(); declaration = name; magnification = ratio; runStart = _list.Count; + } + internal string? RelativeDeclaration => declaration; + internal float RelativeMagnification => magnification; + internal void RestoreRelativeSize(string? name, float ratio) { + CloseRun(); declaration = name; magnification = ratio; + if (name != null) runStart = _list.Count; + } public void ControlSpace() => Add(new TextAtom.ControlSpace()); public void Accent(TextAtom atom, string accent) => Add(new TextAtom.Accent(atom, accent)); public void Text(string text) { @@ -38,7 +59,7 @@ public Atom.Result Math(string mathLaTeX, bool displayStyle, int startAt, ref in public void List(IReadOnlyList textAtoms) => Add(new TextAtom.List(textAtoms)); public void Break() => Add(new TextAtom.Newline()); public void Comment(string comment) => Add(new TextAtom.Comment(comment)); - public TextAtom Build() => _list.Count == 1 ? _list[0] : new TextAtom.List(this); + public TextAtom Build() { CloseRun(); return _list.Count == 1 ? _list[0] : new TextAtom.List(this); } public int TextLength { get; set; } = 0; [System.Diagnostics.CodeAnalysis.DisallowNull] // setter value cannot be null public TextAtom? Last { get => Count == 0 ? null : _list[Count - 1]; set => _list[Count - 1] = value; } @@ -48,4 +69,4 @@ public Atom.Result Math(string mathLaTeX, bool displayStyle, int startAt, ref in IEnumerator IEnumerable.GetEnumerator() => _list.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _list.GetEnumerator(); } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/Text/TextLaTeXParser.cs b/CSharpMath.Rendering/Text/TextLaTeXParser.cs index 569f92ed7..30a31bf84 100644 --- a/CSharpMath.Rendering/Text/TextLaTeXParser.cs +++ b/CSharpMath.Rendering/Text/TextLaTeXParser.cs @@ -103,6 +103,8 @@ Result CheckDollarCount(int startAt, ref int endAt, TextAtomListBuilder atoms) { } Result BuildBreakList(ReadOnlySpan latex, TextAtomListBuilder atoms, int i, bool oneCharOnly, char stopChar) { + var savedDeclaration = atoms.RelativeDeclaration; + var savedMagnification = atoms.RelativeMagnification; void ParagraphBreak() { atoms.Break(); atoms.Space(Length.ParagraphIndent); @@ -185,6 +187,7 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se //Unescaped text section, not inside display/inline math mode switch (textSection) { case var _ when stopChar > 0 && textSection[0] == stopChar: + atoms.RestoreRelativeSize(savedDeclaration, savedMagnification); return Ok(i); case var _ when textSection.Is('$'): throw new InvalidCodePathException("The $ case should have been accounted for."); @@ -372,6 +375,9 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se atoms.Size(resizedContent, fontSize); break; } + case var relative when TextLaTeXSettings.RelativeSizes.TryGetValue(relative, out var ratio): + atoms.RelativeSize(relative, ratio); + break; case "color": { var (color, error) = ReadColor(latex, ref textSection); if (error != null) return error; @@ -428,7 +434,10 @@ Result ReadColor(ReadOnlySpan latexInput, ref ReadOnlySpan se if (oneCharOnly) return Ok(i); } if (backslashEscape) return @"Invalid command \"; - if (stopChar > 0) return stopChar == '}' ? "Expected }, unbalanced braces" : $@"Expected {stopChar}"; + if (stopChar > 0) { + atoms.RestoreRelativeSize(savedDeclaration, savedMagnification); + return stopChar == '}' ? "Expected }, unbalanced braces" : $@"Expected {stopChar}"; + } return Ok(i); } var error = BuildBreakList(latexSource.AsSpan(), globalAtoms, 0, false, '\0').Error; @@ -486,6 +495,9 @@ public static StringBuilder TextAtomToLaTeX(TextAtom atom, StringBuilder? b = nu case TextAtom.Size z: b.Append(@"\fontsize{").Append(z.PointSize).Append("}{"); return TextAtomToLaTeX(z.Content, b).Append('}'); + case TextAtom.RelativeSize z: + b.Append('\\').Append(z.Declaration).Append('{'); + return TextAtomToLaTeX(z.Content, b).Append('}'); case TextAtom.Colored c: b.Append(@"\color{"); LaTeXSettings.ColorToString(c.Colour, b).Append("}{"); @@ -504,4 +516,4 @@ public static StringBuilder TextAtomToLaTeX(TextAtom atom, StringBuilder? b = nu } } } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/Text/TextLaTeXSettings.cs b/CSharpMath.Rendering/Text/TextLaTeXSettings.cs index ef054e325..994e48247 100644 --- a/CSharpMath.Rendering/Text/TextLaTeXSettings.cs +++ b/CSharpMath.Rendering/Text/TextLaTeXSettings.cs @@ -1,6 +1,9 @@ namespace CSharpMath.Rendering.Text { + using System.Collections.Generic; using CSharpMath.Atom; - public static class TextLaTeXSettings { + public static partial class TextLaTeXSettings { + internal static IReadOnlyDictionary RelativeSizes => CSharpMath.Atom.LaTeXSettings.RelativeSizes; + public static AliasBiDictionary PredefinedTextSymbols { get; } = new AliasBiDictionary { /*Ten special characters and their commands: @@ -165,4 +168,4 @@ public static class TextLaTeXSettings { { "threeunderdot", "\u20E8" } //not in iosMath*/ }; } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/Text/TextTypesetter.cs b/CSharpMath.Rendering/Text/TextTypesetter.cs index 02552cd25..eb70d1a93 100644 --- a/CSharpMath.Rendering/Text/TextTypesetter.cs +++ b/CSharpMath.Rendering/Text/TextTypesetter.cs @@ -59,6 +59,11 @@ void AddDisplaysWithLineBreaks( (sz.Content, new Fonts(fonts, sz.PointSize), line, displayList, displayMathList, style, color); break; + case TextAtom.RelativeSize rs: + AddDisplaysWithLineBreaks(rs.Content, + new Fonts(fonts, inputFont.PointSize * rs.Magnification), line, displayList, + displayMathList, style, color); + break; case TextAtom.Colored c: AddDisplaysWithLineBreaks (c.Content, fonts, line, displayList, displayMathList, style, c.Colour); @@ -216,4 +221,4 @@ void FinalizeInlineDisplay(float ascender, float rawDescender, return (new Display(relativePositionList), new Display(absolutePositionList)); } } -} \ No newline at end of file +} diff --git a/CSharpMath/Atom/LaTeXParser.cs b/CSharpMath/Atom/LaTeXParser.cs index 1250d8e7a..3bbaaaf41 100644 --- a/CSharpMath/Atom/LaTeXParser.cs +++ b/CSharpMath/Atom/LaTeXParser.cs @@ -30,6 +30,8 @@ public class InnerEnvironment : IEnvironment { public int NextChar { get; private set; } public bool TextMode { get; set; } //_spacesAllowed in iosMath public FontStyle CurrentFontStyle { get; set; } + internal RelativeSizeDeclaration CurrentRelativeSize { get; set; } + internal RelativeSizeDeclaration PendingRelativeSize { get; set; } public Stack Environments { get; } = new Stack(); public LaTeXParser(string str) { Chars = str; @@ -58,32 +60,47 @@ private Result BuildInternal(bool oneCharOnly, char stopChar = '\0', M throw new InvalidCodePathException("Cannot set both oneCharOnly and stopChar"); } r ??= new MathList(); + var savedRelativeSize = CurrentRelativeSize; + var savedPendingRelativeSize = PendingRelativeSize; MathAtom? prevAtom = null; while (HasCharacters) { MathAtom? atom = null; if (Chars[NextChar] == stopChar && stopChar > '\0') { NextChar++; + CurrentRelativeSize = savedRelativeSize; + PendingRelativeSize = savedPendingRelativeSize; return r; } var ((handler, splitIndex), error) = LaTeXSettings.Commands.TryLookup(Chars.AsSpan(NextChar)); if (error != null) { NextChar++; // Point to the start of the erroneous command + CurrentRelativeSize = savedRelativeSize; + PendingRelativeSize = savedPendingRelativeSize; return error; } NextChar += splitIndex; (MathAtom?, MathList?) handlerResult; (handlerResult, error) = handler(this, r, stopChar); - if (error != null) return error; + if (error != null) { + CurrentRelativeSize = savedRelativeSize; + PendingRelativeSize = savedPendingRelativeSize; + return error; + } switch (handlerResult) { case ( { } /* dummy */, { } atoms): // Atoms producer (pre-styled) r.Append(atoms); prevAtom = r.Atoms.LastOrDefault(); - if (oneCharOnly) + if (oneCharOnly) { + CurrentRelativeSize = savedRelativeSize; + PendingRelativeSize = savedPendingRelativeSize; return r; + } else continue; case (null, { } @return): // Environment ender + CurrentRelativeSize = savedRelativeSize; + PendingRelativeSize = savedPendingRelativeSize; return @return; case (null, null): // Atom modifier continue; @@ -92,12 +109,19 @@ private Result BuildInternal(bool oneCharOnly, char stopChar = '\0', M break; } atom.FontStyle = CurrentFontStyle; + atom.RelativeSize = CurrentRelativeSize; + atom.RelativeSizeDeclared = PendingRelativeSize != RelativeSizeDeclaration.None; + PendingRelativeSize = RelativeSizeDeclaration.None; r.Add(atom); prevAtom = atom; if (oneCharOnly) { + CurrentRelativeSize = savedRelativeSize; + PendingRelativeSize = savedPendingRelativeSize; return r; // we consumed our character. } } + CurrentRelativeSize = savedRelativeSize; + PendingRelativeSize = savedPendingRelativeSize; return stopChar switch { '\0' => r, '}' => "Missing closing brace", @@ -471,11 +495,17 @@ static string BoundaryToLaTeX(Boundary delimiter) => private static void MathListToLaTeX (MathList mathList, StringBuilder builder, FontStyle outerFontStyle) { - static bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder, + var currentRelativeSize = RelativeSizeDeclaration.None; + bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder, #if !NETSTANDARD2_0 && !NET45 [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] #endif out string? command) { + if (atom.RelativeSizeDeclared || atom.RelativeSize != currentRelativeSize) { + builder.Append('\\').Append(atom.RelativeSize == RelativeSizeDeclaration.None + ? "normalsize" : LaTeXSettings.RelativeSizeNames[atom.RelativeSize]).Append(' '); + currentRelativeSize = atom.RelativeSize; + } if (LaTeXSettings.CommandForAtom(atom) is string name) { command = name; builder.Append(name); @@ -731,4 +761,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 3ef5599a9..0146b193d 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -1,5 +1,6 @@ using System; -using System.Collections.Generic; + using System.Collections.Generic; + using System.Collections.ObjectModel; using System.Drawing; using System.Globalization; using System.Linq; @@ -9,6 +10,10 @@ namespace CSharpMath.Atom { using Atoms; //https://mirror.hmc.edu/ctan/macros/latex/contrib/unicode-math/unimath-symbols.pdf public static class LaTeXSettings { + public static IReadOnlyDictionary RelativeSizes { get; } = new ReadOnlyDictionary(new Dictionary { + ["tiny"] = .5f, ["scriptsize"] = .7f, ["footnotesize"] = .8f, ["small"] = .9f, + ["normalsize"] = 1, ["large"] = 1.2f, ["Large"] = 1.44f, ["LARGE"] = 1.728f, + ["huge"] = 2.074f, ["Huge"] = 2.488f }); static readonly Dictionary boundaryDelimitersReverse = new Dictionary(); public static IReadOnlyDictionary BoundaryDelimitersReverse => boundaryDelimitersReverse; public static LaTeXCommandDictionary BoundaryDelimiters { get; } = @@ -1188,5 +1193,24 @@ atom is Accent accent // { @"\supsetneqq", new Relation("⫌") }, // Glyph not in Latin Modern Math // \varsupsetneqq -> ⫌ + U+FE00 (Variation Selector 1) Not dealing with variation selectors, thank you very much }; + internal static IReadOnlyDictionary RelativeSizeDeclarations { get; } = + new Dictionary { + ["tiny"] = RelativeSizeDeclaration.Tiny, ["scriptsize"] = RelativeSizeDeclaration.ScriptSize, + ["footnotesize"] = RelativeSizeDeclaration.FootnoteSize, ["small"] = RelativeSizeDeclaration.Small, + ["normalsize"] = RelativeSizeDeclaration.NormalSize, ["large"] = RelativeSizeDeclaration.Large, + ["Large"] = RelativeSizeDeclaration.Large2, ["LARGE"] = RelativeSizeDeclaration.Large3, + ["huge"] = RelativeSizeDeclaration.Huge, ["Huge"] = RelativeSizeDeclaration.Huge2 }; + internal static IReadOnlyDictionary RelativeSizeNames { get; } = + RelativeSizeDeclarations.ToDictionary(p => p.Value, p => p.Key); + internal static float RelativeSizeMagnification(RelativeSizeDeclaration declaration) => + declaration == RelativeSizeDeclaration.None ? 1 : RelativeSizes[RelativeSizeNames[declaration]]; + static LaTeXSettings() { + foreach (var pair in RelativeSizeDeclarations) + Commands.Add(@"\" + pair.Key, (parser, accumulate, stopChar) => { + parser.CurrentRelativeSize = pair.Value; + parser.PendingRelativeSize = pair.Value; + return Ok(null); + }); + } } -} \ No newline at end of file +} diff --git a/CSharpMath/Atom/MathAtom.cs b/CSharpMath/Atom/MathAtom.cs index 4b060e155..6d0ee7a00 100644 --- a/CSharpMath/Atom/MathAtom.cs +++ b/CSharpMath/Atom/MathAtom.cs @@ -3,6 +3,9 @@ using System.Text; namespace CSharpMath.Atom { + internal enum RelativeSizeDeclaration { + None, Tiny, ScriptSize, FootnoteSize, Small, NormalSize, Large, Large2, Large3, Huge, Huge2 + } public abstract class MathAtom : IMathObject, IEquatable { public string TypeName { get { @@ -20,6 +23,16 @@ public string TypeName { public MathList Superscript { get; private set; } public MathList Subscript { get; private set; } public FontStyle FontStyle { get; set; } + RelativeSizeDeclaration relativeSize; + internal RelativeSizeDeclaration RelativeSize { + get => relativeSize; + set { + if (value < RelativeSizeDeclaration.None || value > RelativeSizeDeclaration.Huge2) + throw new ArgumentOutOfRangeException(nameof(value)); + relativeSize = value; + } + } + internal bool RelativeSizeDeclared { get; set; } /// Defaults to zero, only has a value after finalization public Range IndexRange { get; set; } @@ -46,6 +59,8 @@ protected TAtom ApplyCommonPropertiesOn(bool finalize, TAtom newAtom) newAtom.Subscript = Subscript.Clone(finalize); newAtom.IndexRange = IndexRange; newAtom.FontStyle = FontStyle; + newAtom.RelativeSize = RelativeSize; + newAtom.RelativeSizeDeclared = RelativeSizeDeclared; return newAtom; } public MathAtom Clone(bool finalize) => @@ -81,6 +96,8 @@ public override string ToString() => TypeName + " " + DebugString; public bool EqualsAtom(MathAtom otherAtom) => Nucleus == otherAtom.Nucleus && + LaTeXSettings.RelativeSizeMagnification(RelativeSize) == + LaTeXSettings.RelativeSizeMagnification(otherAtom.RelativeSize) && GetType() == otherAtom.GetType() && //IndexRange == otherAtom.IndexRange && //FontStyle == otherAtom.FontStyle && @@ -88,6 +105,6 @@ public bool EqualsAtom(MathAtom otherAtom) => 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).GetHashCode(); + public override int GetHashCode() => (Superscript, Subscript, Nucleus, LaTeXSettings.RelativeSizeMagnification(RelativeSize)).GetHashCode(); } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Typesetter.cs b/CSharpMath/Display/Typesetter.cs index 8c58f6bf9..b90efdc50 100644 --- a/CSharpMath/Display/Typesetter.cs +++ b/CSharpMath/Display/Typesetter.cs @@ -81,7 +81,8 @@ public static GlyphDisplay CreateAccentGlyphDisplay where TFont : IFont { - internal readonly TFont _font; + internal TFont _font; + readonly TFont _baseFont; internal readonly TypesettingContext _context; internal readonly FontMathTable _mathTable; internal TFont _styleFont; @@ -114,6 +115,7 @@ public class Typesetter where TFont : IFont { internal Typesetter(TFont font, TypesettingContext context, LineStyle style, bool cramped, bool spaced) { _font = font; + _baseFont = font; _context = context; _mathTable = context.MathTable; _style = style; @@ -145,7 +147,9 @@ List _PreprocessMathList() { }; // This is Rule 14 to merge ordinary characters. // combine ordinary atoms together - if (newAtom is Ordinary && prevAtom is Ordinary o && o.Superscript.IsEmpty() && o.Subscript.IsEmpty()) { + if (newAtom is Ordinary && prevAtom is Ordinary o && + o.RelativeSize == newAtom.RelativeSize && + o.Superscript.IsEmpty() && o.Subscript.IsEmpty()) { prevAtom.Fuse(newAtom); // skip the current node as we fused it continue; @@ -163,6 +167,10 @@ List _PreprocessMathList() { private void CreateDisplayAtoms(List preprocessedAtoms) { MathAtom? prevAtom = null; foreach (var atom in preprocessedAtoms) { + var magnification = LaTeXSettings.RelativeSizeMagnification(atom.RelativeSize); + _font = magnification == 1 ? _baseFont : + _context.MathFontCloner.Invoke(_baseFont, _baseFont.PointSize * magnification); + _styleFont = _context.MathFontCloner.Invoke(_font, _mathTable.GetStyleSize(_style, _font)); switch (atom) { case Number _: case Variable _: @@ -186,7 +194,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { case Colored colored: AddDisplayLine(false); AddInterElementSpace(prevAtom, colored); - var colorDisplay = CreateLine(colored.InnerList, _font, _context, _style, false); + var colorDisplay = CreateLine(colored.InnerList, _baseFont, _context, _style, false); colorDisplay.SetTextColorRecursive(colored.Color); colorDisplay.Position = _currentPosition; _currentPosition.X += colorDisplay.Width; @@ -195,7 +203,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { case ColorBox colorBox: AddDisplayLine(false); AddInterElementSpace(prevAtom, colorBox); - colorDisplay = CreateLine(colorBox.InnerList, _font, _context, _style, false); + colorDisplay = CreateLine(colorBox.InnerList, _baseFont, _context, _style, false); colorDisplay.BackColor = colorBox.Color; colorDisplay.Position = _currentPosition; _currentPosition.X += colorDisplay.Width; @@ -208,7 +216,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { if (rad.Degree.IsNonEmpty()) { // add the degree to the radical displayRad.SetDegree( - CreateLine(rad.Degree, _styleFont, _context, LineStyle.Script, false), + CreateLine(rad.Degree, _baseFont, _context, LineStyle.Script, false), _styleFont, _mathTable); } _displayAtoms.Add(displayRad); @@ -235,7 +243,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { if (inner.LeftBoundary != Boundary.Empty || inner.RightBoundary != Boundary.Empty) { innerDisplay = MakeInner(inner, atom.IndexRange); } else { - innerDisplay = CreateLine(inner.InnerList, _font, _context, _style, _cramped); + innerDisplay = CreateLine(inner.InnerList, _baseFont, _context, _style, _cramped); } innerDisplay.Position = _currentPosition; _currentPosition.X += innerDisplay.Width; @@ -248,7 +256,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { AddDisplayLine(false); AddInterElementSpace(prevAtom, underline); var innerListDisplay = Typesetter.CreateLine - (underline.InnerList, _font, _context, _style, _cramped); + (underline.InnerList, _baseFont, _context, _style, _cramped); var underlineDisplay = new OverOrUnderlineDisplay(innerListDisplay, _currentPosition) { LineShiftUp = -(innerListDisplay.Descent + _mathTable.UnderbarVerticalGap(_styleFont)), @@ -265,7 +273,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { AddDisplayLine(false); AddInterElementSpace(prevAtom, overline); innerListDisplay = Typesetter.CreateLine - (overline.InnerList, _font, _context, _style, true); + (overline.InnerList, _baseFont, _context, _style, true); var overlineDisplay = new OverOrUnderlineDisplay(innerListDisplay, _currentPosition) { LineShiftUp = innerListDisplay.Ascent + _mathTable.OverbarVerticalGap(_font) @@ -320,7 +328,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { case RaiseBox raiseBox: AddDisplayLine(false); var raisedDisplay = - CreateLine(raiseBox.InnerList, _font, _context, _style, false); + CreateLine(raiseBox.InnerList, _baseFont, _context, _style, false); var raisedPosition = _currentPosition; raisedPosition.Y += raiseBox.Raise.ActualLength(_mathTable, _font); raisedDisplay.Position = raisedPosition; @@ -394,7 +402,7 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { private IDisplay MakeAccent(Accent accent) { var accentee = - CreateLine(accent.InnerList, _font, _context, _style, true); + CreateLine(accent.InnerList, _baseFont, _context, _style, true); if (accent.Nucleus.Length == 0) { //no accent return accentee; @@ -420,7 +428,7 @@ private IDisplay MakeAccent(Accent accent) { // Note: Latex adjusts the heights in case the height of the char is different // in non-cramped mode. However this shouldn't be the case since cramping // only affects fractions and superscripts. We skip adjusting the heights. - accentee = CreateLine(accent.InnerList, _font, _context, _style, _cramped); + accentee = CreateLine(accent.InnerList, _baseFont, _context, _style, _cramped); } } @@ -454,7 +462,7 @@ private void MakeScripts(MathAtom atom, IDisplay display, int ind if (atom.Subscript.IsEmpty()) throw new InvalidCodePathException ($"MakeScripts was called when both supercript and subscript of atom were null."); - var subscript = CreateLine(atom.Subscript, _font, _context, _scriptStyle, _subscriptCramped); + var subscript = CreateLine(atom.Subscript, _baseFont, _context, _scriptStyle, _subscriptCramped); subscript.LinePosition = LinePosition.Subscript; subscript.IndexInParent = index; subscriptShiftDown = @@ -469,7 +477,7 @@ private void MakeScripts(MathAtom atom, IDisplay display, int ind // If we get here, superscript is not null var superscript = - CreateLine(atom.Superscript, _font, _context, _scriptStyle, _superscriptCramped); + CreateLine(atom.Superscript, _baseFont, _context, _scriptStyle, _superscriptCramped); superscript.LinePosition = LinePosition.Superscript; superscript.IndexInParent = index; superscriptShiftUp = Math.Max(superscriptShiftUp, _superscriptShiftUp); @@ -482,7 +490,7 @@ private void MakeScripts(MathAtom atom, IDisplay display, int ind return; } // If we get here, we have both a superscript and a subscript. - var subscriptB = CreateLine(atom.Subscript, _font, _context, _scriptStyle, _subscriptCramped); + var subscriptB = CreateLine(atom.Subscript, _baseFont, _context, _scriptStyle, _subscriptCramped); subscriptB.LinePosition = LinePosition.Subscript; subscriptB.IndexInParent = index; subscriptShiftDown = Math.Max(subscriptShiftDown, _mathTable.SubscriptShiftDown(_styleFont)); @@ -548,7 +556,7 @@ IGlyphDisplay _GetRadicalGlyph(float radicalHeight) { : new GlyphDisplay (glyph, Range.NotFound, _styleFont, glyphAscent, glyphDescent, glyphWidth); } - var innerDisplay = CreateLine(radicand, _font, _context, _style, true); + var innerDisplay = CreateLine(radicand, _baseFont, _context, _style, true); var radicalVerticalGap = _style == LineStyle.Display ? _mathTable.RadicalDisplayStyleVerticalGap(_styleFont) @@ -622,9 +630,9 @@ float _FractionDelimiterHeight() => : _mathTable.FractionDelimiterSize(_styleFont); var numeratorDisplay = - CreateLine(fraction.Numerator, _font, _context, _fractionStyle, false); + CreateLine(fraction.Numerator, _baseFont, _context, _fractionStyle, false); var denominatorDisplay = - CreateLine(fraction.Denominator, _font, _context, _fractionStyle, true); + CreateLine(fraction.Denominator, _baseFont, _context, _fractionStyle, true); var numeratorShiftUp = _NumeratorShiftUp(fraction.HasRule); var denominatorShiftDown = _DenominatorShiftDown(fraction.HasRule); @@ -697,7 +705,7 @@ private InnerDisplay MakeInner(Inner inner, Range range) { if (inner.LeftBoundary == Boundary.Empty && inner.RightBoundary == Boundary.Empty) { throw new InvalidCodePathException("Inner should have a boundary to call this function."); } - var innerListDisplay = CreateLine(inner.InnerList, _font, _context, _style, _cramped, true); + var innerListDisplay = CreateLine(inner.InnerList, _baseFont, _context, _style, _cramped, true); float axisHeight = _mathTable.AxisHeight(_styleFont); // delta is the max distance from the axis. float delta = @@ -741,11 +749,11 @@ private IGlyphDisplay FindGlyphForBoundary( private UnderAnnotationDisplay MakeUnderAnnotation(UnderAnnotation underAnnotation, Range range) { - var innerListDisplay = CreateLine(underAnnotation.InnerList, _font, _context, _style, _cramped, true); + var innerListDisplay = CreateLine(underAnnotation.InnerList, _baseFont, _context, _style, _cramped, true); ListDisplay? underListDisplay = null; if (underAnnotation.UnderList is { Count: > 0 }) { - underListDisplay = CreateLine(underAnnotation.UnderList, _font, _context, _scriptStyle, _subscriptCramped, true); + underListDisplay = CreateLine(underAnnotation.UnderList, _baseFont, _context, _scriptStyle, _subscriptCramped, true); } float axisHeight = _mathTable.AxisHeight(_styleFont); @@ -963,7 +971,7 @@ private List>> TypesetCells(Table table, float[] var colDispalys = new List>(); r.Add(colDispalys); for (int i = 0; i < row.Count; i++) { - var disp = CreateLine(row[i], _font, _context, _style, false); + var disp = CreateLine(row[i], _baseFont, _context, _style, false); columnWidths[i] = Math.Max(disp.Width, columnWidths[i]); colDispalys.Add(disp); } @@ -1104,11 +1112,11 @@ private IDisplay AddLimitsToDisplay(IDisplay displ ListDisplay? subscript = null; if (op.Superscript.IsNonEmpty()) { superscript = - CreateLine(op.Superscript, _font, _context, _scriptStyle, _superscriptCramped); + CreateLine(op.Superscript, _baseFont, _context, _scriptStyle, _superscriptCramped); } if (op.Subscript.IsNonEmpty()) { subscript = - CreateLine(op.Subscript, _font, _context, _scriptStyle, _subscriptCramped); + CreateLine(op.Subscript, _baseFont, _context, _scriptStyle, _subscriptCramped); } var opsDisplay = new LargeOpLimitsDisplay( display, @@ -1133,4 +1141,4 @@ private IDisplay AddLimitsToDisplay(IDisplay displ return display; } } -} \ No newline at end of file +} diff --git a/CSharpMath/PublicAPI.Unshipped.txt b/CSharpMath/PublicAPI.Unshipped.txt index e69de29bb..027786f84 100644 --- a/CSharpMath/PublicAPI.Unshipped.txt +++ b/CSharpMath/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +static CSharpMath.Atom.LaTeXSettings.RelativeSizes.get -> System.Collections.Generic.IReadOnlyDictionary! From f50fb5b3b1572dbdfccf7ce216cd2c33ce9dc936 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 09:09:04 +0800 Subject: [PATCH 2/3] fix: preserve radical sizing defaults --- .../Display/RelativeSizeGeometryTests.cs | 27 ++++++++++++++++--- CSharpMath/Atom/LaTeXParser.cs | 3 +-- CSharpMath/Atom/LaTeXSettings.cs | 10 ++++--- CSharpMath/Display/Typesetter.cs | 4 ++- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs b/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs index 65c4522d3..42c8b33c4 100644 --- a/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs +++ b/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs @@ -9,9 +9,16 @@ public class RelativeSizeGeometryTests { static readonly TestFont Font = new TestFont(10); static readonly Display.FrontEnd.TypesettingContext Context = TestTypesettingContext.Instance; [Theory] - [InlineData("tiny", .5f)] [InlineData("scriptsize", .7f)] [InlineData("footnotesize", .8f)] - [InlineData("small", .9f)] [InlineData("normalsize", 1f)] [InlineData("large", 1.2f)] - [InlineData("Large", 1.44f)] [InlineData("LARGE", 1.728f)] [InlineData("huge", 2.074f)] [InlineData("Huge", 2.488f)] + [InlineData("tiny", .5f)] + [InlineData("scriptsize", .7f)] + [InlineData("footnotesize", .8f)] + [InlineData("small", .9f)] + [InlineData("normalsize", 1f)] + [InlineData("large", 1.2f)] + [InlineData("Large", 1.44f)] + [InlineData("LARGE", 1.728f)] + [InlineData("huge", 2.074f)] + [InlineData("Huge", 2.488f)] public void RelativeSizesScaleRenderedGlyph(string command, float ratio) { var plain = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX("a"), Font, Context, LineStyle.Text); var sized = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX($@"\{command} a"), Font, Context, LineStyle.Text); @@ -19,7 +26,8 @@ public void RelativeSizesScaleRenderedGlyph(string command, float ratio) { Assert.InRange(sized.Ascent / plain.Ascent, ratio - .01f, ratio + .01f); Assert.InRange(sized.Descent / plain.Descent, ratio - .01f, ratio + .01f); } - [Fact] public void LargeScriptScalesExactlyOnce() { + [Fact] + public void LargeScriptScalesExactlyOnce() { var normal = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX("x^2"), Font, Context, LineStyle.Text); var large = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(@"\large x^2"), Font, Context, LineStyle.Text); Assert.InRange(large.Width / normal.Width, 1.19f, 1.21f); @@ -27,6 +35,17 @@ [Fact] public void LargeScriptScalesExactlyOnce() { Assert.InRange(large.Descent / normal.Descent, 1.19f, 1.21f); } [Theory] + [InlineData(LineStyle.Display)] + [InlineData(LineStyle.Text)] + [InlineData(LineStyle.Script)] + [InlineData(LineStyle.ScriptScript)] + public void CubeRootDegreeUsesLegacyStyleBaseAndScalesOnce(LineStyle style) { + var normal = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(@"\sqrt[3]2"), Font, Context, style); + var large = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(@"\large \sqrt[3]2"), Font, Context, style); + Assert.True(normal.Width > 0 && normal.Ascent > 0 && normal.Descent >= 0); + Assert.InRange(large.Width / normal.Width, 1.19f, 1.21f); + } + [Theory] [InlineData(@"\frac{a}{b}", @"\large \frac{a}{b}")] [InlineData(@"\sqrt{a}", @"\large \sqrt{a}")] [InlineData(@"\left(a\right)", @"\large \left(a\right)")] diff --git a/CSharpMath/Atom/LaTeXParser.cs b/CSharpMath/Atom/LaTeXParser.cs index 3bbaaaf41..a5edf4bd1 100644 --- a/CSharpMath/Atom/LaTeXParser.cs +++ b/CSharpMath/Atom/LaTeXParser.cs @@ -96,8 +96,7 @@ private Result BuildInternal(bool oneCharOnly, char stopChar = '\0', M CurrentRelativeSize = savedRelativeSize; PendingRelativeSize = savedPendingRelativeSize; return r; - } - else continue; + } else continue; case (null, { } @return): // Environment ender CurrentRelativeSize = savedRelativeSize; PendingRelativeSize = savedPendingRelativeSize; diff --git a/CSharpMath/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index 0146b193d..af46781e2 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -1,6 +1,6 @@ using System; - using System.Collections.Generic; - using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Drawing; using System.Globalization; using System.Linq; @@ -13,7 +13,8 @@ public static class LaTeXSettings { public static IReadOnlyDictionary RelativeSizes { get; } = new ReadOnlyDictionary(new Dictionary { ["tiny"] = .5f, ["scriptsize"] = .7f, ["footnotesize"] = .8f, ["small"] = .9f, ["normalsize"] = 1, ["large"] = 1.2f, ["Large"] = 1.44f, ["LARGE"] = 1.728f, - ["huge"] = 2.074f, ["Huge"] = 2.488f }); + ["huge"] = 2.074f, ["Huge"] = 2.488f + }); static readonly Dictionary boundaryDelimitersReverse = new Dictionary(); public static IReadOnlyDictionary BoundaryDelimitersReverse => boundaryDelimitersReverse; public static LaTeXCommandDictionary BoundaryDelimiters { get; } = @@ -1199,7 +1200,8 @@ atom is Accent accent ["footnotesize"] = RelativeSizeDeclaration.FootnoteSize, ["small"] = RelativeSizeDeclaration.Small, ["normalsize"] = RelativeSizeDeclaration.NormalSize, ["large"] = RelativeSizeDeclaration.Large, ["Large"] = RelativeSizeDeclaration.Large2, ["LARGE"] = RelativeSizeDeclaration.Large3, - ["huge"] = RelativeSizeDeclaration.Huge, ["Huge"] = RelativeSizeDeclaration.Huge2 }; + ["huge"] = RelativeSizeDeclaration.Huge, ["Huge"] = RelativeSizeDeclaration.Huge2 + }; internal static IReadOnlyDictionary RelativeSizeNames { get; } = RelativeSizeDeclarations.ToDictionary(p => p.Value, p => p.Key); internal static float RelativeSizeMagnification(RelativeSizeDeclaration declaration) => diff --git a/CSharpMath/Display/Typesetter.cs b/CSharpMath/Display/Typesetter.cs index b90efdc50..ae56f9f04 100644 --- a/CSharpMath/Display/Typesetter.cs +++ b/CSharpMath/Display/Typesetter.cs @@ -215,8 +215,10 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { var displayRad = MakeRadical(rad.Radicand, rad.IndexRange); if (rad.Degree.IsNonEmpty()) { // add the degree to the radical + var degreeBaseFont = _context.MathFontCloner.Invoke( + _baseFont, _mathTable.GetStyleSize(_style, _baseFont)); displayRad.SetDegree( - CreateLine(rad.Degree, _baseFont, _context, LineStyle.Script, false), + CreateLine(rad.Degree, degreeBaseFont, _context, LineStyle.Script, false), _styleFont, _mathTable); } _displayAtoms.Add(displayRad); From e1561810f84b70b8b88a12567c3855ab81de055e Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 09:21:05 +0800 Subject: [PATCH 3/3] style: order relative size test imports --- CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs b/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs index 42c8b33c4..1cc98a3b9 100644 --- a/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs +++ b/CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs @@ -1,8 +1,8 @@ using CSharpMath.Atom; -using CSharpMath.Display; +using CSharpMath.Core.AtomTests; using CSharpMath.Core.BackEnd; +using CSharpMath.Display; using Xunit; -using CSharpMath.Core.AtomTests; namespace CSharpMath.Core.DisplayTests { public class RelativeSizeGeometryTests {