diff --git a/CSharpMath.Core.Tests/Atom/MulticolumnTests.cs b/CSharpMath.Core.Tests/Atom/MulticolumnTests.cs new file mode 100644 index 00000000..11338f24 --- /dev/null +++ b/CSharpMath.Core.Tests/Atom/MulticolumnTests.cs @@ -0,0 +1,70 @@ +using System.Linq; +using CSharpMath.Atom; +using CSharpMath.Atom.Atoms; +using Xunit; + +namespace CSharpMath.Core.AtomTests { + public class MulticolumnTests { + static Table Parse(string source) { + var result = LaTeXParser.MathListFromLaTeX(source); + Assert.Null(result.Error); + var (list, _) = result; + return Assert.IsType(Assert.Single(list)); + } + + [Fact] + public void ParsesSpanAlignmentAndRoundTrips() { + var source = @"\begin{array}{|l|c|r|}\multicolumn{2}{l|}{a}&b\\c&d&e\end{array}"; + var table = Parse(source); + Assert.Equal(3, table.NColumns); + Assert.Equal(2, table.GetColumnSpan(0, 0)); + Assert.Equal(ColumnAlignment.Left, table.GetSpanAlignment(0, 0)); + Assert.Equal("l|", table.SpanSpecifications[0][0]); + Assert.Contains(@"\multicolumn{2}{l|}", LaTeXParser.MathListToLaTeX(new MathList(table)).ToString()); + Assert.Equal(table, Parse(LaTeXParser.MathListToLaTeX(new MathList(table)).ToString())); + } + + [Theory] + [InlineData(@"\multicolumn{2}{c}{x}")] + [InlineData(@"\begin{array}{cc}\multicolumn{0}{c}{x}\end{array}")] + [InlineData(@"\begin{array}{cc}\multicolumn{3}{c}{x}\end{array}")] + [InlineData(@"\begin{array}{cc}\multicolumn{2}{x}{x}\end{array}")] + public void RejectsUnsupportedOrInvalidSpans(string source) { + Assert.NotNull(LaTeXParser.MathListFromLaTeX(source).Error); + } + + [Fact] + public void MatrixSupportsSpans() { + var result = LaTeXParser.MathListFromLaTeX(@"\begin{matrix}\multicolumn{2}{c}{x}&y\\z&w\end{matrix}"); + Assert.Null(result.Error); + var (list, _) = result; + var table = Assert.IsType
(Assert.Single(list)); + Assert.Equal(2, table.GetColumnSpan(0, 0)); + } + + [Fact] + public void SpanAlignmentAndFollowingCellsRemainDistinctAcrossRows() { + var table = Parse(@"\begin{array}{|l|c|r|}\hline\multicolumn{2}{|c|}{x}&z\\a&\multicolumn{2}{r|}{q}\end{array}"); + Assert.Equal(3, table.NColumns); + Assert.Equal(2, table.GetColumnSpan(0, 0)); + Assert.Equal(ColumnAlignment.Center, table.GetSpanAlignment(0, 0)); + Assert.Equal(1, table.GetColumnSpan(0, 1)); + Assert.Equal(2, table.GetColumnSpan(1, 1)); + Assert.Equal(ColumnAlignment.Right, table.GetSpanAlignment(1, 1)); + Assert.Equal(1, table.GetColumnSpan(-1, -1)); + Assert.Null(table.GetSpanAlignment(-1, -1)); + } + + [Fact] + public void DelimitersAndHorizontalRulesComposeWithSpans() { + var result = LaTeXParser.MathListFromLaTeX(@"\left(\begin{array}{cc}\multicolumn{2}{c}{x}\\y&z\\\hline\end{array}\right)"); + Assert.Null(result.Error); + var (parsed, _) = result; + var outer = Assert.IsType(Assert.Single(parsed)); + var table = Assert.IsType
(Assert.Single(outer.InnerList)); + Assert.Equal(2, table.NColumns); + Assert.Equal(2, table.GetColumnSpan(0, 0)); + Assert.NotEmpty(table.HorizontalLines); + } + } +} diff --git a/CSharpMath/Atom/Atoms/Table.cs b/CSharpMath/Atom/Atoms/Table.cs index b596d181..9227ee66 100644 --- a/CSharpMath/Atom/Atoms/Table.cs +++ b/CSharpMath/Atom/Atoms/Table.cs @@ -2,6 +2,21 @@ using System.Linq; namespace CSharpMath.Atom.Atoms { + internal sealed class MulticolumnAtom : MathAtom { + public MulticolumnAtom(int span, ColumnAlignment alignment, MathList content, string specification = "") : base(string.Empty) { + if (span < 1) throw new System.ArgumentOutOfRangeException(nameof(span)); + Span = span; Alignment = alignment; Specification = specification; Content = content; + } + public int Span { get; } + public ColumnAlignment Alignment { get; } + public string Specification { get; } + public MathList Content { get; } + public override bool ScriptsAllowed => false; + protected override MathAtom CloneInside(bool finalize) => new MulticolumnAtom(Span, Alignment, Content.Clone(finalize), Specification); + public override string DebugString => $@"\multicolumn{{{Span}}}{{{Specification}}}{{{Content.DebugString}}}"; + public override bool Equals(object obj) => obj is MulticolumnAtom m && Span == m.Span && Alignment == m.Alignment && Specification == m.Specification && Content.Equals(m.Content); + public override int GetHashCode() => (Span, Alignment, Specification, Content).GetHashCode(); + } ///A table. Not part of TeX. public sealed class Table : MathAtom, IMathListContainer { public Table(string? environment, List>? cells = null) : base(string.Empty) => @@ -16,6 +31,9 @@ public Table() : this(null) { } CellStyle = CellStyle, VerticalLines = VerticalLines.ToList(), HorizontalLines = HorizontalLines.ToList(), + ColumnSpans = ColumnSpans.Select(row => row.ToList()).ToList(), + SpanAlignments = SpanAlignments.Select(row => row.ToList()).ToList(), + SpanSpecifications = SpanSpecifications.Select(row => row.ToList()).ToList(), Alignments = Alignments.ToList(), Cells = new List>(Cells.Select(list => new List(list.Select(sublist => sublist.Clone(finalize))))) @@ -43,12 +61,17 @@ public Table() : this(null) { } /// environment). Length NRows+1: index 0 = above row 0 … NRows = below the last /// row. Empty for every non-array environment. public List HorizontalLines { get; set; } = new List(); + /// Cell column spans, parallel to ; unspecified cells span one column. + public List> ColumnSpans { get; set; } = new List>(); + /// Explicit alignment for spanning cells, parallel to . + public List> SpanAlignments { get; set; } = new List>(); + public List> SpanSpecifications { get; set; } = new List>(); /// The name of the environment that this table denotes public string? Environment { get; set; } /// Number of rows public int NRows => Cells.Count; /// Number of columns - public int NColumns => NRows == 0 ? 0 : Cells.Max(row => row.Count); + public int NColumns => NRows == 0 ? 0 : Cells.Select((row, i) => row.Select((_, j) => GetColumnSpan(i, j)).Sum()).DefaultIfEmpty(0).Max(); public void SetCell(MathList list, int iRow, int iColumn) { while (Cells.Count <= iRow) Cells.Add(new List()); while (Cells[iRow].Count <= iColumn) Cells[iRow].Add(new MathList()); @@ -69,14 +92,21 @@ public bool EqualsTable(Table otherTable) => CellStyle == otherTable.CellStyle && VerticalLines.SequenceEqual(otherTable.VerticalLines) && HorizontalLines.SequenceEqual(otherTable.HorizontalLines) && + ColumnSpans.SequenceEqual(otherTable.ColumnSpans, (a, b) => a.SequenceEqual(b)) && + SpanAlignments.SequenceEqual(otherTable.SpanAlignments, (a, b) => a.SequenceEqual(b)) && + SpanSpecifications.SequenceEqual(otherTable.SpanSpecifications, (a, b) => a.SequenceEqual(b)) && Environment == otherTable.Environment; public override bool Equals(object obj) => obj is Table t ? EqualsTable(t) : false; public override int GetHashCode() => (base.GetHashCode(), NestedSequenceHash(Cells), SequenceHash(Alignments), InterColumnSpacing, InterRowAdditionalSpacing, CellStyle, - (SequenceHash(VerticalLines), SequenceHash(HorizontalLines), Environment)).GetHashCode(); + (SequenceHash(VerticalLines), SequenceHash(HorizontalLines), + NestedSequenceHash(ColumnSpans), NestedSequenceHash(SpanAlignments), + NestedSequenceHash(SpanSpecifications), Environment)).GetHashCode(); private static int NestedSequenceHash(IEnumerable> rows) => SequenceHash(rows.Select(SequenceHash)); + private static int NestedSequenceHash(IEnumerable> rows) => + SequenceHash(rows.Select(SequenceHash)); private static int SequenceHash(IEnumerable items) { unchecked { var hash = 17; @@ -84,5 +114,21 @@ private static int SequenceHash(IEnumerable items) { return hash; } } + public int GetColumnSpan(int row, int column) => row >= 0 && column >= 0 && row < ColumnSpans.Count && column < ColumnSpans[row].Count + ? ColumnSpans[row][column] : 1; + public ColumnAlignment? GetSpanAlignment(int row, int column) => row >= 0 && column >= 0 && row < SpanAlignments.Count && column < SpanAlignments[row].Count + ? SpanAlignments[row][column] : null; + internal void SetColumnSpan(int row, int column, int span, ColumnAlignment alignment, string specification = "") { + if (span < 1) throw new System.ArgumentOutOfRangeException(nameof(span)); + while (ColumnSpans.Count <= row) ColumnSpans.Add(new List()); + while (ColumnSpans[row].Count <= column) ColumnSpans[row].Add(1); + ColumnSpans[row][column] = span; + while (SpanAlignments.Count <= row) SpanAlignments.Add(new List()); + while (SpanAlignments[row].Count <= column) SpanAlignments[row].Add(null); + SpanAlignments[row][column] = alignment; + while (SpanSpecifications.Count <= row) SpanSpecifications.Add(new List()); + while (SpanSpecifications[row].Count <= column) SpanSpecifications[row].Add(null); + SpanSpecifications[row][column] = specification; + } } } diff --git a/CSharpMath/Atom/LaTeXParser.cs b/CSharpMath/Atom/LaTeXParser.cs index 422ac411..bcf96a9c 100644 --- a/CSharpMath/Atom/LaTeXParser.cs +++ b/CSharpMath/Atom/LaTeXParser.cs @@ -812,6 +812,15 @@ void RecordHorizontalLine() { case var _ when _matrixEnvironments.TryGetValue(name, out var delimiters): table.Environment = "matrix"; // TableEnvironment is set to matrix as delimiters are converted to latex outside the table. table.InterColumnSpacing = 18; + for (var r = 0; r < table.Cells.Count; r++) { + var logicalColumn = 0; + for (var c = 0; c < table.Cells[r].Count; c++, logicalColumn++) + if (table.Cells[r][c].Count == 1 && table.Cells[r][c][0] is MulticolumnAtom multi) { + table.Cells[r][c] = multi.Content; + table.SetColumnSpan(r, c, multi.Span, multi.Alignment, multi.Specification); + logicalColumn += multi.Span - 1; + } + } // All the cells render in textstyle, stored on the table rather than per-cell. table.CellStyle = LineStyle.Text; return delimiters switch { @@ -851,6 +860,21 @@ void RecordHorizontalLine() { break; } } + var declaredColumns = table.Alignments.Count; + for (var r = 0; r < table.Cells.Count; r++) { + var logicalColumn = 0; + for (var c = 0; c < table.Cells[r].Count; c++, logicalColumn++) { + if (table.Cells[r][c].Count != 1 || table.Cells[r][c][0] is not MulticolumnAtom multi) continue; + if (multi.Span > declaredColumns - logicalColumn) + return @"\multicolumn span exceeds the array column count or overlaps another cell"; + table.Cells[r][c] = multi.Content; + table.SetColumnSpan(r, c, multi.Span, multi.Alignment, multi.Specification); + logicalColumn += multi.Span - 1; + } + // Preserve the historical array behavior: ordinary cells beyond + // the declared specification are tolerated and dropped by the + // renderer. Multicolumn spans remain strictly validated above. + } // Note: rows may declare fewer/more cells than the spec; extra cells are // dropped and missing ones render empty, matching pre-port behavior. while (vLines.Count < table.NColumns + 1) vLines.Add(0); @@ -1275,6 +1299,13 @@ static string Wrap(MathList operand, Atoms.FractionStyle style) { } for (int j = 0; j < row.Count; j++) { var cell = row[j]; + var span = table.GetColumnSpan(i, j); + if (span > 1) { + var spec = table.SpanSpecifications.Count > i && table.SpanSpecifications[i].Count > j + ? table.SpanSpecifications[i][j] : null; + builder.Append(@"\multicolumn{").Append(span).Append("}{") + .Append(spec ?? table.GetAlignment(j).ToString().ToLowerInvariant()).Append("}{"); + } if (table.Environment == "matrix" && cell.Count >= 1 && cell[0] is Style) { @@ -1303,6 +1334,7 @@ static string Wrap(MathList operand, Atoms.FractionStyle style) { cell = cell.Slice(1, cell.Count - 1); } MathListToLaTeX(cell, builder, currentFontStyle); + if (span > 1) builder.Append('}'); if (j < row.Count - 1) { builder.Append('&'); } diff --git a/CSharpMath/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index 2efa1eca..a34f2801 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -200,6 +200,23 @@ public static class LaTeXSettings { { @"\begin", (parser, accumulate, stopChar) => parser.ReadEnvironment().Bind(env => parser.ReadTable(env, null, false, stopChar)).Bind(Ok) }, + { @"\multicolumn", (parser, accumulate, stopChar) => { + var environment = parser.Environments.PeekOrDefault() as LaTeXParser.TableEnvironment; + if (environment?.Name is not ("array" or "matrix" or "pmatrix" or "bmatrix" or "Bmatrix" or "vmatrix" or "Vmatrix")) + return @"\multicolumn is only valid inside an array or matrix"; + var (countText, countError) = parser.ReadRawArgument(); + if (countError != null || !int.TryParse(countText, out var count) || count < 1) + return @"\multicolumn requires a positive span count"; + var (spec, specError) = parser.ReadRawArgument(); + if (specError != null || string.IsNullOrWhiteSpace(spec) || spec.Any(c => c is not ('l' or 'c' or 'r' or '|')) || !spec.Any(c => c is 'l' or 'c' or 'r')) + return @"\multicolumn requires an l, c, or r alignment specification"; + var alignment = spec.First(c => c is 'l' or 'c' or 'r') switch { + 'l' => ColumnAlignment.Left, + 'c' => ColumnAlignment.Center, + _ => ColumnAlignment.Right + }; + return parser.ReadArgument().Bind(content => ((MathAtom?)new MulticolumnAtom(count, alignment, content, spec), (MathList?)null)); + } }, // \color and its 2-argument alias \textcolor { @"\color", (parser, accumulate, stopChar) => parser.ReadColor().Bind( diff --git a/CSharpMath/Display/Typesetter.cs b/CSharpMath/Display/Typesetter.cs index 09bac458..f37d5d9f 100644 --- a/CSharpMath/Display/Typesetter.cs +++ b/CSharpMath/Display/Typesetter.cs @@ -1274,10 +1274,28 @@ private List>> TypesetCells(Table table, float[] r.Add(colDispalys); for (int i = 0; i < row.Count; i++) { var disp = CreateLine(row[i], _font, _context, cellStyle, false); - columnWidths[i] = Math.Max(disp.Width, columnWidths[i]); + var column = row.Take(i).Select((_, j) => table.GetColumnSpan(r.Count - 1, j)).Sum(); + var span = table.GetColumnSpan(r.Count - 1, i); + if (span == 1) columnWidths[column] = Math.Max(disp.Width, columnWidths[column]); colDispalys.Add(disp); } } + // A spanning cell may be the only contributor to one or more logical + // columns. Allocate its excess width across those columns so the frame + // remains wide enough and inter-column spacing is counted exactly once. + for (var rowIndex = 0; rowIndex < r.Count; rowIndex++) { + var column = 0; + for (var cell = 0; cell < r[rowIndex].Count; cell++) { + var span = table.GetColumnSpan(rowIndex, cell); + if (span > 1 && column + span <= columnWidths.Length) { + var spacing = (span - 1) * table.InterColumnSpacing * CellStyleFontSize(table) / 18f; + var excess = r[rowIndex][cell].Width - (Enumerable.Range(column, span).Sum(c => columnWidths[c]) + spacing); + if (excess > 0) + for (var c = column; c < column + span; c++) columnWidths[c] += excess / span; + } + column += span; + } + } return r; } /// The line style the cells of this table actually render in. Some envs @@ -1305,8 +1323,8 @@ private IDisplay MakeTable(Table table) { List>? verticalRuleXs = null; if (!hasRules) { var rowDisplays = new List>(); - foreach (var row in displays) { - rowDisplays.Add(MakeRowWithColumns(row, table, columnWidths)); + for (var rowIndex = 0; rowIndex < displays.Count; rowIndex++) { + rowDisplays.Add(MakeRowWithColumns(displays[rowIndex], table, columnWidths, rowIndex)); } // position all the rows PositionRows(rowDisplays, table); @@ -1324,6 +1342,21 @@ private IDisplay MakeTable(Table table) { float padding = rulePaddingMultiplier * _styleFont.PointSize; float ruleGap = ruleGapMultiplier * _styleFont.PointSize; float cellStyleMuUnit = CellStyleFontSize(table) / 18f; + var suppressedBoundaries = new HashSet(); + for (var row = 0; row < table.Cells.Count; row++) { + var column = 0; + for (var cell = 0; cell < table.Cells[row].Count; cell++) { + var span = table.GetColumnSpan(row, cell); + if (span > 1) { + var specification = table.SpanSpecifications.Count > row && table.SpanSpecifications[row].Count > cell + ? table.SpanSpecifications[row][cell] ?? "" : ""; + for (var boundary = column + 1; boundary < column + span; boundary++) suppressedBoundaries.Add(boundary); + if (!specification.StartsWith("|", StringComparison.Ordinal)) suppressedBoundaries.Add(column); + if (!specification.EndsWith("|", StringComparison.Ordinal)) suppressedBoundaries.Add(column + span); + } + column += span; + } + } columnOffsets = new float[nColumns]; verticalRuleXs = new List>(); @@ -1331,7 +1364,7 @@ private IDisplay MakeTable(Table table) { for (int boundary = 0; boundary <= nColumns; boundary++) { float gapBase = boundary == 0 || boundary == nColumns ? 0 : table.InterColumnSpacing * cellStyleMuUnit; - int count = boundary < table.VerticalLines.Count ? table.VerticalLines[boundary] : 0; + int count = suppressedBoundaries.Contains(boundary) ? 0 : boundary < table.VerticalLines.Count ? table.VerticalLines[boundary] : 0; var ruleXs = new List(); if (count > 0) { // No padding outside the outermost rules so they sit flush at the box edges. @@ -1355,8 +1388,8 @@ private IDisplay MakeTable(Table table) { float contentWidth = x; var ruledRowDisplays = new List>(); - foreach (var row in displays) { - ruledRowDisplays.Add(MakeRuledRowWithColumns(row, table, columnWidths, columnOffsets)); + for (var rowIndex = 0; rowIndex < displays.Count; rowIndex++) { + ruledRowDisplays.Add(MakeRuledRowWithColumns(displays[rowIndex], table, columnWidths, columnOffsets, rowIndex)); } // position all the rows @@ -1414,22 +1447,27 @@ private IDisplay MakeTable(Table table) { /// Like MakeRowWithColumns but using precomputed shared column offsets. private ListDisplay MakeRuledRowWithColumns( - List> row, Table table, float[] columnWidths, float[] columnOffsets) { + List> row, Table table, float[] columnWidths, float[] columnOffsets, int rowIndex) { Range rowRange = Range.NotFound; + var column = 0; for (int i = 0; i < row.Count; i++) { var entry = row[i]; - var alignment = table.GetAlignment(i); - var cellPosition = columnOffsets[i]; + var span = table.GetColumnSpan(rowIndex, i); + var width = Enumerable.Range(column, span).Sum(c => columnWidths[c]) + + (span - 1) * table.InterColumnSpacing * CellStyleFontSize(table) / 18f; + var alignment = table.GetSpanAlignment(rowIndex, i) ?? table.GetAlignment(column); + var cellPosition = columnOffsets[column]; switch (alignment) { case ColumnAlignment.Right: - cellPosition += (columnWidths[i] - entry.Width); + cellPosition += width - entry.Width; break; case ColumnAlignment.Center: - cellPosition += (columnWidths[i] - entry.Width) / 2; + cellPosition += (width - entry.Width) / 2; break; } entry.Position = new PointF(cellPosition, 0); rowRange += entry.Range; + column += span; } var ruled = new ListDisplay(row.ToArray()); if (rowRange != Range.NotFound) { @@ -1439,14 +1477,17 @@ private ListDisplay MakeRuledRowWithColumns( } private ListDisplay MakeRowWithColumns - (List> row, Table table, float[] columnWidths) { + (List> row, Table table, float[] columnWidths, int rowIndex) { float columnStart = 0; + var column = 0; Range rowRange = Range.NotFound; float cellStyleMuUnit = CellStyleFontSize(table) / 18f; for (int i = 0; i < row.Count; i++) { var entry = row[i]; - float columnWidth = columnWidths[i]; - var alignment = table.GetAlignment(i); + var span = table.GetColumnSpan(rowIndex, i); + float columnWidth = Enumerable.Range(column, span).Sum(c => columnWidths[c]) + + (span - 1) * table.InterColumnSpacing * cellStyleMuUnit; + var alignment = table.GetSpanAlignment(rowIndex, i) ?? table.GetAlignment(column); var cellPosition = columnStart; switch (alignment) { case ColumnAlignment.Right: @@ -1458,7 +1499,8 @@ private ListDisplay MakeRowWithColumns } entry.Position = new PointF(cellPosition, 0); rowRange += entry.Range; - columnStart += (columnWidth + table.InterColumnSpacing * cellStyleMuUnit); + columnStart += columnWidth + table.InterColumnSpacing * cellStyleMuUnit; + column += span; } return new ListDisplay(row.ToArray()); } diff --git a/CSharpMath/PublicAPI.Unshipped.txt b/CSharpMath/PublicAPI.Unshipped.txt index c035cc15..f6af1c02 100644 --- a/CSharpMath/PublicAPI.Unshipped.txt +++ b/CSharpMath/PublicAPI.Unshipped.txt @@ -212,3 +212,11 @@ virtual CSharpMath.Atom.MathAtom.Nucleus.set -> void *REMOVED*CSharpMath.Atom.MathAtom.FontStyle.set -> void *REMOVED*CSharpMath.Atom.MathAtom.Nucleus.get -> string! *REMOVED*CSharpMath.Atom.MathAtom.Nucleus.set -> void +CSharpMath.Atom.Atoms.Table.ColumnSpans.get -> System.Collections.Generic.List!>! +CSharpMath.Atom.Atoms.Table.ColumnSpans.set -> void +CSharpMath.Atom.Atoms.Table.SpanAlignments.get -> System.Collections.Generic.List!>! +CSharpMath.Atom.Atoms.Table.SpanAlignments.set -> void +CSharpMath.Atom.Atoms.Table.SpanSpecifications.get -> System.Collections.Generic.List!>! +CSharpMath.Atom.Atoms.Table.SpanSpecifications.set -> void +CSharpMath.Atom.Atoms.Table.GetColumnSpan(int row, int column) -> int +CSharpMath.Atom.Atoms.Table.GetSpanAlignment(int row, int column) -> CSharpMath.Atom.ColumnAlignment?