Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions CSharpMath.Core.Tests/Atom/MulticolumnTests.cs
Original file line number Diff line number Diff line change
@@ -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<Table>(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<Table>(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<Inner>(Assert.Single(parsed));
var table = Assert.IsType<Table>(Assert.Single(outer.InnerList));
Assert.Equal(2, table.NColumns);
Assert.Equal(2, table.GetColumnSpan(0, 0));
Assert.NotEmpty(table.HorizontalLines);
}
}
}
50 changes: 48 additions & 2 deletions CSharpMath/Atom/Atoms/Table.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
///<summary>A table. Not part of TeX.</summary>
public sealed class Table : MathAtom, IMathListContainer {
public Table(string? environment, List<List<MathList>>? cells = null) : base(string.Empty) =>
Expand All @@ -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<List<MathList>>(Cells.Select(list =>
new List<MathList>(list.Select(sublist => sublist.Clone(finalize)))))
Expand Down Expand Up @@ -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.</summary>
public List<int> HorizontalLines { get; set; } = new List<int>();
/// <summary>Cell column spans, parallel to <see cref="Cells"/>; unspecified cells span one column.</summary>
public List<List<int>> ColumnSpans { get; set; } = new List<List<int>>();
/// <summary>Explicit alignment for spanning cells, parallel to <see cref="ColumnSpans"/>.</summary>
public List<List<ColumnAlignment?>> SpanAlignments { get; set; } = new List<List<ColumnAlignment?>>();
public List<List<string?>> SpanSpecifications { get; set; } = new List<List<string?>>();
/// <summary>The name of the environment that this table denotes</summary>
public string? Environment { get; set; }
/// <summary>Number of rows</summary>
public int NRows => Cells.Count;
/// <summary>Number of columns</summary>
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<MathList>());
while (Cells[iRow].Count <= iColumn) Cells[iRow].Add(new MathList());
Expand All @@ -69,20 +92,43 @@ 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<IEnumerable<MathList>> rows) =>
SequenceHash(rows.Select(SequenceHash));
private static int NestedSequenceHash<T>(IEnumerable<IEnumerable<T>> rows) =>
SequenceHash(rows.Select(SequenceHash));
private static int SequenceHash<T>(IEnumerable<T> items) {
unchecked {
var hash = 17;
foreach (var item in items) hash = hash * 31 + (item?.GetHashCode() ?? 0);
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<int>());
while (ColumnSpans[row].Count <= column) ColumnSpans[row].Add(1);
ColumnSpans[row][column] = span;
while (SpanAlignments.Count <= row) SpanAlignments.Add(new List<ColumnAlignment?>());
while (SpanAlignments[row].Count <= column) SpanAlignments[row].Add(null);
SpanAlignments[row][column] = alignment;
while (SpanSpecifications.Count <= row) SpanSpecifications.Add(new List<string?>());
while (SpanSpecifications[row].Count <= column) SpanSpecifications[row].Add(null);
SpanSpecifications[row][column] = specification;
}
}
}
32 changes: 32 additions & 0 deletions CSharpMath/Atom/LaTeXParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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('&');
}
Expand Down
17 changes: 17 additions & 0 deletions CSharpMath/Atom/LaTeXSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading