diff --git a/CSharpMath.Core.Tests/Atom/LongDivisionTests.cs b/CSharpMath.Core.Tests/Atom/LongDivisionTests.cs new file mode 100644 index 00000000..6a10ebd1 --- /dev/null +++ b/CSharpMath.Core.Tests/Atom/LongDivisionTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Linq; +using System.Reflection; +using CSharpMath.Atom; +using CSharpMath.Atom.Atoms; +using Xunit; + +namespace CSharpMath.Core.AtomTests { + public class LongDivisionTests { + static string CellText(MathList cell) => cell.Count == 0 ? "" : cell[0] switch { + Number number => number.Nucleus, + Underline underline => CellText(underline.InnerList), + Overline overline => CellText(overline.InnerList), + Inner inner => CellText(inner.InnerList), + IMathListContainer container => CellText(Assert.Single(container.InnerLists)), + _ => cell.DebugString + }; + static LongDivision Parse(string source) { + var result = LaTeXParser.MathListFromLaTeX(source); + Assert.Null(result.Error); + var (list, _) = result; + return Assert.IsType(list[0]); + } + + [Theory] + [InlineData("12345", "13", "949", 8)] + [InlineData("123", "1234", "0", 123)] + [InlineData("31415926", "2", "15707963", 0)] + [InlineData("81", "3", "27", 0)] + [InlineData("1132", "99", "11", 43)] + [InlineData("86491", "94", "920", 11)] + public void ComputesRepresentativeDivisions(string numerator, string denominator, string quotient, int remainder) { + var atom = new LongDivision(numerator, denominator); + Assert.Equal(quotient, atom.QuotientText); + Assert.Equal(remainder.ToString(), atom.Remainder); + Assert.NotEmpty(atom.Steps); + } + + [Fact] + public void ParsesAsSemanticAtomAndRoundTrips() { + var atom = Parse(@"\longdiv{00123}{0007}"); + Assert.Equal("123", atom.Numerator); + Assert.Equal("7", atom.Denominator); + Assert.Equal(@"\longdiv{123}{7}", LaTeXParser.MathListToLaTeX(new MathList(atom)).ToString()); + Assert.Equal(atom, atom.Clone(false)); + } + + [Theory] + [InlineData(@"\longdiv{12}{0}")] + [InlineData(@"\longdiv{-12}{3}")] + [InlineData(@"\longdiv{12.5}{3}")] + [InlineData(@"\longdiv{12}{abc}")] + [InlineData(@"\longdiv{123456789012345678901234567890}{3}")] + public void RejectsInvalidOperands(string source) { + var result = LaTeXParser.MathListFromLaTeX(source); + Assert.NotNull(result.Error); + Assert.Contains("longdiv", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ErrorDoesNotPoisonFollowingParse() { + Assert.NotNull(LaTeXParser.MathListFromLaTeX(@"\longdiv{1}{0}").Error); + var result = LaTeXParser.MathListFromLaTeX(@"\longdiv{81}{3}"); + Assert.Null(result.Error); + var (list, _) = result; + Assert.IsType(list[0]); + } + + [Theory] + [InlineData(@"\longdiv{12}{3", "missing } for denominator", 14)] + [InlineData(@"\longdiv{{12}}{3}", "must be a nonnegative", 14)] + [InlineData(@"\longdiv{12}{x}", "must be a nonnegative", 15)] + public void MalformedOperandsReportSourceContext(string source, string detail, int expectedPosition) { + var result = LaTeXParser.MathListFromLaTeX(source); + Assert.NotNull(result.Error); + Assert.Contains(detail, result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Contains("\u2191 (pos " + expectedPosition + ")", result.Error); + Assert.Contains(source.TrimEnd('}'), result.Error); + } + + [Fact] + public void TraceRetainsZeroDigitsAndPlaceColumns() { + var atom = new LongDivision("12345", "13"); + Assert.Equal(new[] { 0, 0, 9, 4, 9 }, atom.Steps.Select(s => s.QuotientDigit)); + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, atom.Steps.Select(s => s.DecimalColumn)); + Assert.Equal("8", atom.Steps.Last().Remainder); + Assert.Equal(atom.Remainder, atom.Steps.Last().Remainder); + } + + [Theory] + [InlineData("3", "5", "0", "3")] + [InlineData("0", "7", "0", "0")] + [InlineData("1005", "5", "201", "0")] + public void HandlesShortAndExactDivisions(string numerator, string denominator, string quotient, string remainder) { + var atom = new LongDivision(numerator, denominator); + Assert.Equal(quotient, atom.QuotientText); + Assert.Equal(remainder, atom.Remainder); + Assert.Equal(numerator.Length, atom.Steps.Count); + } + + [Fact] + public void LayoutHasBracketRuleAndOneFinalRemainder() { + var atom = new LongDivision("12345", "13"); + var layout = (Table)typeof(LongDivision).GetMethod("CreateLayout", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(atom, null)!; + var dividend = layout.Cells[1][1]; + Assert.Equal("LongDivisionHeader", dividend[0].GetType().Name); + var header = Assert.IsAssignableFrom(dividend[0]); + var digits = Assert.IsType(Assert.Single(Assert.Single(header.InnerLists))); + Assert.Equal("12345", digits.Nucleus); + Assert.Equal("949", CellText(layout.Cells[0][1])); + Assert.Equal(ColumnAlignment.Right, layout.GetAlignment(1)); + Assert.Equal(1, layout.Cells.Count(row => row.Any(cell => CellText(cell) == atom.Remainder))); + } + + [Fact] + public void LayoutPreservesProductPlaceValuesAndRunningTotals() { + var atom = new LongDivision("12345", "13"); + var layout = (Table)typeof(LongDivision).GetMethod("CreateLayout", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(atom, null)!; + Assert.Equal(new[] { "949", "12345", "11700", "645", "520", "125", "117", "8" }, + layout.Cells.Select(row => CellText(row[1]))); + } + + [Fact] + public void LayoutRetainsInternalZeroStepsWithoutRowsForZeroProducts() { + var atom = new LongDivision("1005", "5"); + Assert.Contains(atom.Steps, step => step.QuotientDigit == 0); + var layout = (Table)typeof(LongDivision).GetMethod("CreateLayout", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(atom, null)!; + Assert.Equal("201", CellText(layout.Cells[0][1])); + Assert.Equal(new[] { "201", "1005", "1000", "5", "5", "0" }, + layout.Cells.Select(row => CellText(row[1]))); + Assert.Equal(1, layout.Cells.Skip(2).Count(row => row.Any(cell => CellText(cell) == "0"))); + } + + [Theory] + [InlineData("81", "3", new[] { "27", "81", "60", "21", "21", "0" })] + [InlineData("3", "5", new[] { "0", "3", "3" })] + [InlineData("0", "7", new[] { "0", "0", "0" })] + [InlineData("86491", "94", new[] { "920", "86491", "84600", "1891", "1880", "11" })] + public void LayoutShowsExactlyOneFinalRemainder(string numerator, string denominator, string[] expected) { + var atom = new LongDivision(numerator, denominator); + var layout = (Table)typeof(LongDivision).GetMethod("CreateLayout", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(atom, null)!; + Assert.Equal(expected, layout.Cells.Select(row => CellText(row[1]))); + Assert.Equal(atom.Remainder, CellText(layout.Cells.Last()[1])); + } + } +} diff --git a/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionExact.png b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionExact.png new file mode 100644 index 00000000..ed0038ba Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionExact.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionInternalZero.png b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionInternalZero.png new file mode 100644 index 00000000..d5e8959e Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionInternalZero.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionRemainder.png b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionRemainder.png new file mode 100644 index 00000000..64377339 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionRemainder.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionShort.png b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionShort.png new file mode 100644 index 00000000..c37c2c23 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionShort.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionTrailingZero.png b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionTrailingZero.png new file mode 100644 index 00000000..910e39b6 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathDisplay/LongDivisionTrailingZero.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/LongDivisionExact.png b/CSharpMath.Rendering.Tests/MathInline/LongDivisionExact.png new file mode 100644 index 00000000..ed0038ba Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathInline/LongDivisionExact.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/LongDivisionInternalZero.png b/CSharpMath.Rendering.Tests/MathInline/LongDivisionInternalZero.png new file mode 100644 index 00000000..d5e8959e Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathInline/LongDivisionInternalZero.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/LongDivisionRemainder.png b/CSharpMath.Rendering.Tests/MathInline/LongDivisionRemainder.png new file mode 100644 index 00000000..64377339 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathInline/LongDivisionRemainder.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/LongDivisionShort.png b/CSharpMath.Rendering.Tests/MathInline/LongDivisionShort.png new file mode 100644 index 00000000..c37c2c23 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathInline/LongDivisionShort.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/LongDivisionTrailingZero.png b/CSharpMath.Rendering.Tests/MathInline/LongDivisionTrailingZero.png new file mode 100644 index 00000000..910e39b6 Binary files /dev/null and b/CSharpMath.Rendering.Tests/MathInline/LongDivisionTrailingZero.png differ diff --git a/CSharpMath.Rendering.Tests/TestLongDivisionRendering.cs b/CSharpMath.Rendering.Tests/TestLongDivisionRendering.cs new file mode 100644 index 00000000..b4a03dd0 --- /dev/null +++ b/CSharpMath.Rendering.Tests/TestLongDivisionRendering.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using System.Linq; +using CSharpMath.Avalonia; +using CSharpMath.SkiaSharp; +using SkiaSharp; +using Xunit; +using AvaloniaMathPainter = CSharpMath.Avalonia.MathPainter; +using SkiaMathPainter = CSharpMath.SkiaSharp.MathPainter; + +namespace CSharpMath.Rendering.Tests { + public sealed class TestLongDivisionRendering { + [Theory] + [InlineData("12345", "13")] + [InlineData("1005", "5")] + [InlineData("3", "5")] + [InlineData("0", "7")] + [InlineData("9999999999999999999999999999", "1")] + public void SkiaProducesMeasuredLongDivision(string numerator, string denominator) { + using var stream = new SkiaMathPainter { LaTeX = $@"\longdiv{{{numerator}}}{{{denominator}}}" }.DrawAsStream(); + Assert.NotNull(stream); + Assert.True(stream!.Length > 0); + } + + [Theory] + [InlineData("12345", "13")] + [InlineData("1005", "5")] + [InlineData("3", "5")] + [InlineData("0", "7")] + [InlineData("9999999999999999999999999999", "1")] + public void AvaloniaProducesMeasuredLongDivision(string numerator, string denominator) { + global::Avalonia.Skia.SkiaPlatform.Initialize(); + using var stream = new MemoryStream(); + new AvaloniaMathPainter { + LaTeX = $@"\longdiv{{{numerator}}}{{{denominator}}}" + }.DrawAsPng(stream); + Assert.True(stream.Length > 0); + } + + [Fact] + public void ScalingChangesGeometryWithoutChangingSemanticRows() { + var painter = new SkiaMathPainter { LaTeX = @"\longdiv{12345}{13}" }; + var normal = painter.Measure(2000); + painter.FontSize *= 2; + var large = painter.Measure(2000); + Assert.True(large.Width > normal.Width); + Assert.True(large.Height > normal.Height); + var result = CSharpMath.Atom.LaTeXParser.MathListFromLaTeX(@"\longdiv{12345}{13}"); + Assert.Null(result.Error); + var (list, _) = result; + var atom = Assert.IsType(list[0]); + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, atom.Steps.Select(s => s.DecimalColumn)); + } + + [Theory] + [InlineData(@"\longdiv{12345}{13}", "display")] + [InlineData(@"\text{answer: }\longdiv{1005}{5}", "text")] + [InlineData(@"x_{\longdiv{3}{5}}", "script")] + public void DisplayTextAndScriptRemainNonEmptyWhenScaled(string latex, string kind) { + var painter = new SkiaMathPainter { LaTeX = latex }; + var before = painter.Measure(2000); + painter.FontSize *= 0.5f; + var half = painter.Measure(2000); + painter.FontSize *= 4; + var twice = painter.Measure(2000); + Assert.True(before.Width > 0 && before.Height > 0, kind); + Assert.True(half.Width > 0 && half.Height > 0, kind); + Assert.True(twice.Width > half.Width && twice.Height > half.Height, kind); + } + + [Theory] + [InlineData(@"\longdiv{12345}{13}", CSharpMath.Atom.LineStyle.Text)] + [InlineData(@"\longdiv{12345}{13}", CSharpMath.Atom.LineStyle.Script)] + public void LongDivisionBarConnectsToDelimiterInTextAndScriptStyles(string latex, CSharpMath.Atom.LineStyle style) { + using var skia = RenderSkia(latex, style); + AssertBarConnectsToDelimiter(skia, latex); + global::Avalonia.Skia.SkiaPlatform.Initialize(); + using var avalonia = new MemoryStream(); + new AvaloniaMathPainter { LaTeX = latex, LineStyle = style }.DrawAsPng(avalonia); + avalonia.Position = 0; + AssertBarConnectsToDelimiter(avalonia, latex); + } + + private static MemoryStream RenderSkia(string latex, CSharpMath.Atom.LineStyle style) { + using var rendered = new SkiaMathPainter { LaTeX = latex, LineStyle = style }.DrawAsStream(); + var copy = new MemoryStream(); + rendered!.CopyTo(copy); + copy.Position = 0; + return copy; + } + + private static void AssertBarConnectsToDelimiter(Stream png, string latex) { + using var bitmap = SKBitmap.Decode(png); + Assert.NotNull(bitmap); + var dark = new bool[bitmap!.Width, bitmap.Height]; + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) { + var color = bitmap.GetPixel(x, y); + dark[x, y] = color.Alpha > 32 && color.Red < 190 && color.Green < 190 && color.Blue < 190; + } + + var bestY = -1; + var bestStart = 0; + var bestLength = 0; + for (var y = 0; y < bitmap.Height; y++) { + var start = -1; + for (var x = 0; x <= bitmap.Width; x++) { + if (x < bitmap.Width && dark[x, y]) { + if (start < 0) start = x; + } else if (start >= 0) { + if (x - start > bestLength) { + bestY = y; + bestStart = start; + bestLength = x - start; + } + start = -1; + } + } + } + Assert.True(bestLength >= 8, $"No overbar found for {latex}."); + + var ruleBottom = bestY; + for (var y = bestY + 1; y < bitmap.Height; y++) { + var run = LongestDarkRun(dark, bitmap.Width, y); + if (run.Length < bestLength * 0.8) break; + ruleBottom = y; + } + + // The connected component must reach below the rule near its left endpoint. + var visited = new bool[bitmap.Width, bitmap.Height]; + var pending = new System.Collections.Generic.Queue<(int X, int Y)>(); + for (var x = bestStart; x < bestStart + bestLength; x++) { + visited[x, bestY] = true; + pending.Enqueue((x, bestY)); + } + var reachesBelow = false; + while (pending.Count > 0) { + var (x, y) = pending.Dequeue(); + if (y > ruleBottom + Math.Max(3, bitmap.Height / 12) && x <= bestStart + Math.Max(8, bitmap.Height / 8)) reachesBelow = true; + foreach (var (nx, ny) in new[] { (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1) }) + if (nx >= 0 && nx < bitmap.Width && ny >= 0 && ny < bitmap.Height && dark[nx, ny] && !visited[nx, ny]) { + visited[nx, ny] = true; + pending.Enqueue((nx, ny)); + } + } + Assert.True(reachesBelow, $"Overbar is not connected to the delimiter for {latex}."); + } + + private static (int Start, int Length) LongestDarkRun(bool[,] dark, int width, int y) { + var bestStart = 0; + var bestLength = 0; + var start = -1; + for (var x = 0; x <= width; x++) { + if (x < width && dark[x, y]) { + if (start < 0) start = x; + } else if (start >= 0) { + if (x - start > bestLength) (bestStart, bestLength) = (start, x - start); + start = -1; + } + } + return (bestStart, bestLength); + } + } +} diff --git a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs index 569e546e..32681e2a 100644 --- a/CSharpMath.Rendering.Tests/TestRenderingMathData.cs +++ b/CSharpMath.Rendering.Tests/TestRenderingMathData.cs @@ -32,6 +32,11 @@ public sealed class TestRenderingMathData : TestRenderingSharedData 9) throw new ArgumentOutOfRangeException(nameof(quotientDigit)); + if (broughtDownDigit < '0' || broughtDownDigit > '9') throw new ArgumentOutOfRangeException(nameof(broughtDownDigit)); + if (decimalColumn < 0) throw new ArgumentOutOfRangeException(nameof(decimalColumn)); + (PartialDividend, QuotientDigit, Product, Remainder, BroughtDownDigit, DecimalColumn) = (partialDividend, quotientDigit, product, remainder, broughtDownDigit, decimalColumn); + } + } + /// Semantic representation of a bounded decimal long division. + public sealed class LongDivision : MathAtom, IMathListContainer { + // decimal is used deliberately to keep the operation bounded and AOT-safe. + public const int MaxDigits = 28; + public string Numerator { get; } + public string Denominator { get; } + public string QuotientText { get; } + public string Remainder { get; } + public IReadOnlyList Steps { get; } + public LongDivision(string numerator, string denominator) : base() { + Numerator = Validate(numerator, nameof(numerator)); Denominator = Validate(denominator, nameof(denominator)); + if (Denominator.TrimStart('0').Length == 0) throw new ArgumentException("Denominator must not be zero.", nameof(denominator)); + var n = BigInteger.Parse(Numerator, CultureInfo.InvariantCulture); var d = BigInteger.Parse(Denominator, CultureInfo.InvariantCulture); + QuotientText = (n / d).ToString(CultureInfo.InvariantCulture); Remainder = (n % d).ToString(CultureInfo.InvariantCulture); Steps = BuildSteps(d); + } + static string Validate(string value, string name) { if (string.IsNullOrWhiteSpace(value) || value.Length > MaxDigits || value.Any(c => c < '0' || c > '9')) throw new ArgumentException("Expected a nonnegative decimal integer of at most 28 digits.", name); var t = value.TrimStart('0'); return t.Length == 0 ? "0" : t; } + MathList Digits(string text) => new MathList(new Number(text)); + IReadOnlyList BuildSteps(BigInteger divisor) { + var result = new List(); var partial = BigInteger.Zero; + for (int i = 0; i < Numerator.Length; i++) { partial = partial * 10 + (Numerator[i] - '0'); var q = (int)(partial / divisor); var product = q * divisor; partial -= product; result.Add(new LongDivisionStep((partial + product).ToString(CultureInfo.InvariantCulture), q, product.ToString(CultureInfo.InvariantCulture), partial.ToString(CultureInfo.InvariantCulture), Numerator[i], i)); } + return result.AsReadOnly(); + } + internal Table CreateLayout() { + var table = new Table { InterColumnSpacing = 1, InterRowAdditionalSpacing = 1 }; + table.SetCell(Digits(QuotientText), 0, 1); + table.SetCell(Digits(Denominator), 1, 0); + table.SetCell(new MathList(new LongDivisionHeader(Digits(Numerator))), 1, 1); + table.SetAlignment(ColumnAlignment.Right, 0); + table.SetAlignment(ColumnAlignment.Right, 1); + + var row = 2; + foreach (var step in Steps.Where(step => step.QuotientDigit > 0)) { + var trailingZeroes = new string('0', Numerator.Length - step.DecimalColumn - 1); + var product = step.Product + trailingZeroes; + table.SetCell(new MathList(new Underline(Digits(product))), row++, 1); + + // Bring down every remaining dividend digit so place value is retained. + var runningText = step.Remainder + Numerator.Substring(step.DecimalColumn + 1); + var running = BigInteger.Parse(runningText, CultureInfo.InvariantCulture) + .ToString(CultureInfo.InvariantCulture); + table.SetCell(Digits(running), row++, 1); + } + if (!Steps.Any(step => step.QuotientDigit > 0)) + table.SetCell(Digits(Remainder), row, 1); + return table; + } + public IEnumerable InnerLists => CreateLayout().Cells.SelectMany(r => r); + public override bool ScriptsAllowed => false; + public new LongDivision Clone(bool finalize) => (LongDivision)base.Clone(finalize); + protected override MathAtom CloneInside(bool finalize) => new LongDivision(Numerator, Denominator); + public override string DebugString => $@"\longdiv{{{Numerator}}}{{{Denominator}}}"; + public override bool Equals(object obj) => obj is LongDivision d && EqualsAtom(d) && Numerator == d.Numerator && Denominator == d.Denominator; + public override int GetHashCode() => (base.GetHashCode(), Numerator, Denominator).GetHashCode(); + } + + // The long-division bar and closing delimiter have a font-specific junction + // which cannot be represented by a generic Overline around an Inner. + internal sealed class LongDivisionHeader : MathAtom, IMathListContainer { + internal MathList Dividend { get; } + internal LongDivisionHeader(MathList dividend) => Dividend = dividend ?? throw new ArgumentNullException(nameof(dividend)); + public IEnumerable InnerLists => new[] { Dividend }; + public override bool ScriptsAllowed => false; + protected override MathAtom CloneInside(bool finalize) => new LongDivisionHeader(Dividend.Clone(finalize)); + public override string DebugString => $@"\longdivheader{{{Dividend.DebugString}}}"; + } +} diff --git a/CSharpMath/Atom/LaTeXParser.cs b/CSharpMath/Atom/LaTeXParser.cs index 1250d8e7..9201867b 100644 --- a/CSharpMath/Atom/LaTeXParser.cs +++ b/CSharpMath/Atom/LaTeXParser.cs @@ -43,6 +43,17 @@ public void UndoReadChar() => : NextChar--; public bool HasCharacters => NextChar < Chars.Length; public Result ReadArgument(MathList? appendTo = null) => BuildInternal(true, r: appendTo); + internal Result ReadLongDivisionOperand(string name) { + if (!ReadCharIfAvailable('{')) return new Result("\\longdiv: missing {" + name); + var start = NextChar; var depth = 1; + while (HasCharacters && depth > 0) { var c = ReadChar(); if (c == '{') depth++; else if (c == '}') depth--; } + if (depth != 0) return new Result("\\longdiv: missing } for " + name); + var value = Chars.Substring(start, NextChar - start - 1).Trim(); + if (value.Length == 0 || value.Any(c => c < '0' || c > '9')) return new Result("\\longdiv: " + name + " must be a nonnegative decimal integer"); + if (value.Length > Atoms.LongDivision.MaxDigits) return new Result("\\longdiv: " + name + " exceeds " + Atoms.LongDivision.MaxDigits + " digits"); + if (value.All(c => c == '0') && name == "denominator") return new Result("\\longdiv: denominator must not be zero"); + return Result.Ok(value); + } public Result ReadArgumentOptional(MathList? appendTo = null) => ReadCharIfAvailable('[') ? BuildInternal(false, ']', r: appendTo).Bind(mathList => (MathList?)mathList) @@ -609,6 +620,9 @@ static bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder, .Append('}'); } break; + case LongDivision division: + builder.Append(@"\longdiv{").Append(division.Numerator).Append("}{").Append(division.Denominator).Append('}'); + break; case Overline over: builder.Append(@"\overline{"); MathListToLaTeX(over.InnerList, builder, currentFontStyle); @@ -731,4 +745,4 @@ public static StringBuilder MathListToLaTeX(MathList mathList, StringBuilder? sb return sb; } } -} \ No newline at end of file +} diff --git a/CSharpMath/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index 3ef5599a..5982af8b 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -133,6 +133,12 @@ public static class LaTeXSettings { parser.ReadArgument().Bind(numerator => parser.ReadArgument().Bind(denominator => Ok(new Fraction(numerator, denominator)))) }, + { @"\longdiv", (parser, accumulate, stopChar) => + parser.ReadLongDivisionOperand("numerator").Bind(numerator => + parser.ReadLongDivisionOperand("denominator").Bind(denominator => { + try { return Ok(new LongDivision(numerator, denominator)); } + catch (ArgumentException e) { return Err("\\longdiv: " + e.Message); } + })) }, { @"\binom", (parser, accumulate, stopChar) => parser.ReadArgument().Bind(numerator => parser.ReadArgument().Bind(denominator => @@ -1189,4 +1195,4 @@ atom is Accent accent // \varsupsetneqq -> ⫌ + U+FE00 (Variation Selector 1) Not dealing with variation selectors, thank you very much }; } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Displays/LongDivisionHeaderDisplay.cs b/CSharpMath/Display/Displays/LongDivisionHeaderDisplay.cs new file mode 100644 index 00000000..c83060c0 --- /dev/null +++ b/CSharpMath/Display/Displays/LongDivisionHeaderDisplay.cs @@ -0,0 +1,61 @@ +using System.Drawing; +using CSharpMath.Atom; + +namespace CSharpMath.Display.Displays { + using FrontEnd; + + internal sealed class LongDivisionHeaderDisplay : IDisplay + where TFont : IFont { + readonly IDisplay _dividend; + readonly IGlyphDisplay _delimiter; + readonly float _lineShiftUp; + readonly float _lineThickness; + + internal LongDivisionHeaderDisplay(IDisplay dividend, + IGlyphDisplay delimiter, float lineShiftUp, + float lineThickness, Range range) { + _dividend = dividend; + _delimiter = delimiter; + _lineShiftUp = lineShiftUp; + _lineThickness = lineThickness; + Range = range; + } + + public float Ascent => System.Math.Max(_lineShiftUp + _lineThickness / 2, + System.Math.Max(_dividend.Position.Y - Position.Y + _dividend.Ascent, + _delimiter.Position.Y - Position.Y + _delimiter.Ascent)); + public float Descent => System.Math.Max(-_lineShiftUp + _lineThickness / 2, + System.Math.Max(-(_dividend.Position.Y - Position.Y) + _dividend.Descent, + -(_delimiter.Position.Y - Position.Y) + _delimiter.Descent)); + public float Width => System.Math.Max(_dividend.Position.X - Position.X + _dividend.Width, + _delimiter.Position.X - Position.X + _delimiter.Width); + public Range Range { get; } + PointF _position; + public PointF Position { + get => _position; + set { + var delta = new PointF(value.X - _position.X, value.Y - _position.Y); + _position = value; + _dividend.Position = new PointF(_dividend.Position.X + delta.X, _dividend.Position.Y + delta.Y); + _delimiter.Position = new PointF(_delimiter.Position.X + delta.X, _delimiter.Position.Y + delta.Y); + } + } + public bool HasScript { get; set; } + public Color? TextColor { get; set; } + public Color? BackColor { get; set; } + public void Draw(IGraphicsContext context) { + this.DrawBackground(context); + _delimiter.Draw(context); + _dividend.Draw(context); + context.SaveState(); + context.DrawLine(Position.X, Position.Y + _lineShiftUp, + Position.X + Width, Position.Y + _lineShiftUp, _lineThickness, TextColor); + context.RestoreState(); + } + public void SetTextColorRecursive(Color? textColor) { + TextColor ??= textColor; + _delimiter.SetTextColorRecursive(textColor); + _dividend.SetTextColorRecursive(textColor); + } + } +} diff --git a/CSharpMath/Display/Typesetter.cs b/CSharpMath/Display/Typesetter.cs index 8c58f6bf..1dd4a61e 100644 --- a/CSharpMath/Display/Typesetter.cs +++ b/CSharpMath/Display/Typesetter.cs @@ -311,6 +311,21 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { _displayAtoms.Add(tableDisplay); _currentPosition.X += tableDisplay.Width; break; + case LongDivision division: + AddDisplayLine(false); + AddInterElementSpace(prevAtom, division); + var divisionDisplay = MakeTable(division.CreateLayout()); + _displayAtoms.Add(divisionDisplay); + _currentPosition.X += divisionDisplay.Width; + break; + case LongDivisionHeader header: + AddDisplayLine(false); + AddInterElementSpace(prevAtom, header); + var headerDisplay = MakeLongDivisionHeader(header); + headerDisplay.Position = _currentPosition; + _displayAtoms.Add(headerDisplay); + _currentPosition.X += headerDisplay.Width; + break; case LargeOperator op: AddDisplayLine(false); AddInterElementSpace(prevAtom, op); @@ -720,6 +735,25 @@ private InnerDisplay MakeInner(Inner inner, Range range) { return new InnerDisplay(innerListDisplay, leftGlyph, rightGlyph, range); } + private LongDivisionHeaderDisplay MakeLongDivisionHeader(LongDivisionHeader header) { + var inner = new Inner(new Boundary(")"), header.Dividend, Boundary.Empty); + var innerDisplay = MakeInner(inner, header.IndexRange); + var dividend = innerDisplay.Inner; + var delimiter = innerDisplay.Left ?? throw new InvalidCodePathException("Long division delimiter was not created."); + var spacing = 2 * _mathTable.MuUnit(_styleFont); + var thickness = _mathTable.OverbarRuleThickness(_styleFont); + var lineShiftUp = dividend.Ascent + _mathTable.OverbarVerticalGap(_font) + + thickness + _mathTable.OverbarExtraAscender(_font); + // Raise the delimiter independently until its ink bounds meet the center + // of the rule. The half-rule overlap keeps the junction closed after + // rasterization, as CTAN's smashed and raised \big) does. + var delimiterTop = delimiter.Ascent + delimiter.Position.Y; + delimiter.Position = new PointF(0, lineShiftUp - delimiterTop); + dividend.Position = new PointF(delimiter.Width + spacing, 0); + return new LongDivisionHeaderDisplay(dividend, delimiter, + lineShiftUp, thickness, header.IndexRange); + } + private IGlyphDisplay FindGlyphForBoundary( string delimiter, float glyphHeight) { var leftGlyph = _context.GlyphFinder.FindGlyphForCharacterAtIndex(_font, 0, delimiter); @@ -1133,4 +1167,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 e69de29b..8087feaf 100644 --- a/CSharpMath/PublicAPI.Unshipped.txt +++ b/CSharpMath/PublicAPI.Unshipped.txt @@ -0,0 +1,23 @@ +#nullable enable +CSharpMath.Atom.Atoms.LongDivisionStep +CSharpMath.Atom.Atoms.LongDivisionStep.LongDivisionStep(string! partialDividend, int quotientDigit, string! product, string! remainder, char broughtDownDigit, int decimalColumn) -> void +CSharpMath.Atom.Atoms.LongDivisionStep.PartialDividend.get -> string! +CSharpMath.Atom.Atoms.LongDivisionStep.QuotientDigit.get -> int +CSharpMath.Atom.Atoms.LongDivisionStep.Product.get -> string! +CSharpMath.Atom.Atoms.LongDivisionStep.Remainder.get -> string! +CSharpMath.Atom.Atoms.LongDivisionStep.BroughtDownDigit.get -> char +CSharpMath.Atom.Atoms.LongDivisionStep.DecimalColumn.get -> int +CSharpMath.Atom.Atoms.LongDivision +CSharpMath.Atom.Atoms.LongDivision.LongDivision(string! numerator, string! denominator) -> void +const CSharpMath.Atom.Atoms.LongDivision.MaxDigits = 28 -> int +CSharpMath.Atom.Atoms.LongDivision.Numerator.get -> string! +CSharpMath.Atom.Atoms.LongDivision.Denominator.get -> string! +CSharpMath.Atom.Atoms.LongDivision.QuotientText.get -> string! +CSharpMath.Atom.Atoms.LongDivision.Remainder.get -> string! +CSharpMath.Atom.Atoms.LongDivision.Steps.get -> System.Collections.Generic.IReadOnlyList! +CSharpMath.Atom.Atoms.LongDivision.InnerLists.get -> System.Collections.Generic.IEnumerable! +override CSharpMath.Atom.Atoms.LongDivision.ScriptsAllowed.get -> bool +CSharpMath.Atom.Atoms.LongDivision.Clone(bool finalize) -> CSharpMath.Atom.Atoms.LongDivision! +override CSharpMath.Atom.Atoms.LongDivision.DebugString.get -> string! +override CSharpMath.Atom.Atoms.LongDivision.Equals(object! obj) -> bool +override CSharpMath.Atom.Atoms.LongDivision.GetHashCode() -> int