Inconsistent behavior with UpdatePageLayout() when using tables with different column counts

@alexey.noskov
We’re currently considering rolling back our Aspose.Words version to prevent this issue.

As we’ve unfortunately experienced performance-issues earlier this year (with the version 25.02) version due to this bug, I’d like to ask, if you know which Aspose.Words version would be the latest, that’s not affected by the infinite-loop bug, but also still generates the tables without any issue (=> max. version 24.12).

@M.Heinz The problem you have mentioned occurred because of an infinite loop introduced after implanting character spacing control feature. This feature has been introduced in 25.2 version. So there should not be the problem in versions prior 25.2 version.

Thanks so much for your information!

1 Like

@M.Heinz The issue occurs because the your code relies on a trick that merges consecutive table into a single table, and the trick happens to produce an acceptable result in some scenarios, but not others. You should modify your code.

The code actually creates not one but two consecutive tables. There is MS Word behavior that merges such tables into a single table. Aspose.Words tries to imitate that behavior.
The logic that merges the tables relies on known table column widths. The problematic tables are auto-fit tables, and column widths are normally computed when building document layout (as actual column widths depend on the content metrics not known at the document model level). Here lies the problem:

When the document is saved to docx without updating document layout first, document validation logic sees that no column widths are computed by layout. The widths are needed to produce a valid docx. So validation codes employs a simplified version of table layout that does not work very well in general case, but produces some column widths that are better than none. The logic is applied to each table independently, before the tables are merged. As the tables are very simple, the computed widths are enough to produce the expected result when the tables are combined and saved to .docx.
If document layout is built first, validation code does not try to compute table column widths independently. The tables are just merged using the default cell width for all cells, producing a jagged table where the cell in row 1 spans only the first column. As the cell has preferred width 100%, the column occupies most of the available space.
The table that layout operates on is equivalent to a table generated by the following code. Basically it is your code but instead of two consecutive tables it generates two rows in a single table. Otherwise, the code is the same:

// First row with 100% width
Table table = builder.StartTable();
Cell cell = builder.InsertCell();
cell.CellFormat.PreferredWidth = PreferredWidth.FromPercent(100);
builder.Write("row 1 - single cell");
builder.EndRow();

// Last row with 2 cells 50% width each
// table = builder.StartTable();
cell = builder.InsertCell();
cell.CellFormat.PreferredWidth = PreferredWidth.FromPercent(50);
builder.Write("row 2 - cell 1");
cell = builder.InsertCell();
cell.CellFormat.PreferredWidth = PreferredWidth.FromPercent(50);
builder.Write("row 2 - cell 2");
builder.EndTable();

Using this code with your tests will consistently generate a jagged table, both with or without layout update, as the code does not employ “consecutive table merge trick”.

It sounds complex, because it actually is complex.

In order to get the desired result, the code should indicate that the cell in row 1 spans 2 columns. This can be done by adding a merged cell. The below code will consistently generate the desired table for all your tests, as it does not rely on “consecutive table merge trick”:

// First row with 100% width
Table table = builder.StartTable();
Cell cell = builder.InsertCell();
cell.CellFormat.PreferredWidth = PreferredWidth.FromPercent(100);
builder.Write("row 1 : 2 merged cells");
cell.CellFormat.HorizontalMerge = CellMerge.First;

// Add a merged cell to maintain the regular table structure.
cell = builder.InsertCell();
cell.CellFormat.HorizontalMerge = CellMerge.Previous;

builder.EndRow();

// Last row with 2 cells 50% width each
cell = builder.InsertCell();
cell.CellFormat.PreferredWidth = PreferredWidth.FromPercent(50);
builder.Write("row 2 - cell 1");
cell = builder.InsertCell();
cell.CellFormat.PreferredWidth = PreferredWidth.FromPercent(50);
builder.Write("row 2 - cell 2");
builder.EndTable();

@alexey.noskov What do you mean by the following statement?

I can update the table.AutoFit to any available enum-value and the results are only getting worse, if I’m not using the default (presumably AutoFitBehavior.AutoFitToContents).

I’ve tried setting the AutoFit behavior to the only non-AutoFit* value (for both tables), but that broke even those cases, which were working previously:

table.AutoFit(AutoFitBehavior.FixedColumnWidths); // new code
table.PreferredWidth = PreferredWidth.FromPercent(100);  // new code
builder.EndTable();

Other than that, your suggested solution unfortunately doesn’t satisfy our expectations for two reasons:

  1. Up until (and including) Aspose.Words 24.12, our code was working just fine and did yield the expected result of correctly auto-merging the the tables without any of our intervention required.
  2. Of course, the sample I’ve submitted to you was highly simplified. In reality our customers are coming up with the total number of rows/tables and an arbitrary number of columns/cells for each table/row.
    Simply imagine having 3 tables with 1, 2 and 3 cells respectively. To have those cells take up 100%, 50% and 33% respectively, I’d need to have 6 cells total in each row; first row merging all 6 cells into one; second row merging cells 1-3 and 4-6 and for the final row merge cells 1+2, 3+4 and 5+6.
    And this issue only worsens if you have 1, 2 and 5 cells per row or increase the number of rows with unique amounts of cell counts even more…

Why would we need to deal with this manually now, when it was working just fine previously?

We’re fine with the table(s) taking up 100% of the page’s width (except for the margins of course) and we’re setting the cell widths for each row to 100% / number_of_cells anyways; thus we wouldn’t need any dynamic width computation here.

Are there any other settings, that we’re missing right now, that would help us achieve this outcome?

Kind regards

@M.Heinz

I mean that the table cell width are not explicitly specified in your code.

I will forward your concerns to our development team.

For now, as a workaround, I can suggest only to save/open document to DOCX before rendering to force Aspose.Words to perform document validation and merge the tables.

I’m a bit confused:

  • For one, the comment on the cell.CellFormat.Width property basically states, that it’s not recommended to be used as a setter.
  • Secondly, setting e.g. cell.CellFormat.Width = PreferredWidth.FromPercent(50).Value; doesn’t seem to work either. Maybe because the Width property is expecting a size in points!? But how’d I convert 50% in the respective point count?

Could you please give me a hint as to how to set the width of cells explicitly?

@M.Heinz

This comment is applicable for existing table cells in document created by MS Word. But in your case you are generating tables from scratch. So you can safely use CellFormat.Width property to set cell width.

You can calculate absolute value in points. For example see the following code:

PageSetup ps = doc.FirstSection.PageSetup;
double pageWidth = ps.PageWidth - ps.LeftMargin - ps.RightMargin;

// Calculate width of cell that is 50% of page width.
double cellWidth = pageWidth * 0.5;

I’m sorry, but I still don’t see as to how AutoFitBehavior.FixedColumnWidths’ changes anything regarding this issue:

I’ve added the fixed column width statements and set all the cell.Widths accordingly, but still the output generates as described initially. (Only difference being, that it now exceeds the page’s width.)

private static Aspose.Words.Document BuildDocument()
{
    // Set Aspose words licence:
    AsposeLicenceHelper.SetLicence_Words();

    var doc = new Aspose.Words.Document();
    var builder = new DocumentBuilder(doc);

    Aspose.Words.PageSetup ps = doc.FirstSection.PageSetup;
    double pageWidth = ps.PageWidth - ps.LeftMargin - ps.RightMargin;

    // First row with 100% width
    var table = builder.StartTable();
    var cell = builder.InsertCell();
    table.AutoFit(AutoFitBehavior.FixedColumnWidths);
    cell.CellFormat.Width = pageWidth * 1; // 100%
    builder.Write("row 1 - only cell");
    builder.EndTable();

    // Second row with 2 cells 50% width each
    table = builder.StartTable();
    cell = builder.InsertCell();
    table.AutoFit(AutoFitBehavior.FixedColumnWidths);
    cell.CellFormat.Width = pageWidth * 0.5; // 50%
    builder.Write("row 2 - cell 1");
    cell = builder.InsertCell();
    cell.CellFormat.Width = pageWidth * 0.5; // 50%
    builder.Write("row 2 - cell 2");
    builder.EndTable();

    return doc;
}

// The test cases remain unchanged.

And replacing the following snippet by builder.EndRow(); doesn’t make any difference, as long, as I’m not generating the proper amount of cells per table/row; which in this (highly simplified) case would be 2 (and configuring them as merged in the first table/row to yield the desired output).

    builder.EndTable();

    // Second row with 2 cells 50% width each
    table = builder.StartTable();

But as stated previously: I’m not eager to generate, configure and properly merge the cells, when dealing with e.g. rows of 3, 4 and 5 columns/cells respectively.

@M.Heinz table.AutoFit(AutoFitBehavior.FixedColumnWidths); method should be called when the table is already built, i.e. after builder.EndTable();. this method recalculates table cell widths according to the specified AutoFitBehavior. So calling this method before finishing the table is not quite correct.

@alexey.noskov Thanks so far; this works now identical for all test cases:

private static Aspose.Words.Document BuildDocument()
{
    // Set Aspose words licence:
    AsposeLicenceHelper.SetLicence_Words();

    var doc = new Aspose.Words.Document();
    var builder = new DocumentBuilder(doc);

    Aspose.Words.PageSetup ps = doc.FirstSection.PageSetup;
    double pageWidth = ps.PageWidth - ps.LeftMargin - ps.RightMargin;

    var table = builder.StartTable();

    for (int rowIdx = 1; rowIdx <= 5; rowIdx++)
    {
        for (var cellIdx = 1; cellIdx <= rowIdx; cellIdx++)
        {
            var cell = builder.InsertCell();
            cell.CellFormat.Width = pageWidth / rowIdx; // total width divided by number of cells per row
            builder.Write($"row {rowIdx} - cell {cellIdx}");
        }
        builder.EndRow();
    }

    builder.EndTable();
    table.AutoFit(AutoFitBehavior.FixedColumnWidths);

    return doc;
}

Well, kind of… :rofl: Care to explain, what happens in row #4?

|1...................................|
|1................|2.................|
|1..........|2...........|3..........|
|1........|2......|3........|4...............|
|1.....|2.....|3......|4.....|5......|

Test1_working.docx (7.4 KB)

@M.Heinz Feedback from development team:

The behavior you rely on is an undocumented side affect of a rather strange and probably unintentional sequence of steps happening when saving a table constructed by their code to .docx.

You have a point though that manually computing cell merges in your scenario is not practical.

We suggest to modify your code as follows to specify the required actions explicitly.

The code below will produce the desired results without the need for manual cell merge or grid span assignment:

public static void Test28535()
{
    Document doc = new Document();
    DocumentBuilder builder = new DocumentBuilder(doc);

    // Make a table with a single row and 3 cells.
    // The cells will have widths computed from their preferred widths assigned.
    Table firstTable = Test28535AddTable(builder, 3);
    // 5 cells:
    Table t5 = Test28535AddTable(builder, 5);
    // 4 cells:
    Table t4 = Test28535AddTable(builder, 4);

    // Copy all tables rows to a single table.
    MergeTablesPrimitive(firstTable, t5);
    MergeTablesPrimitive(firstTable, t4);

    // This will produce correct cell column counts from the cell widths
    // computed when individual tables were created.
    firstTable.AutoFit(AutoFitBehavior.FixedColumnWidths);
    // All cells will get fixed preferred width
    // computed from the current cell width.
    // Cell preferred in percent units will be replaced with absolute values.
    // Also, table width will be determined by cell widths.
    // The table will not have auto-fit layout (cell widths will not adapt to contents).

    // (Optional) This will make the resulting table 100% again.
    firstTable.PreferredWidth = PreferredWidth.FromPercent(100.0);
    // The table will be resized on rendering or saving to docx.
    // Pdf will match docx output both with or without the above call.

    // Saving order will not matter.
    doc.Save("28535.aw.pdf");
    doc.Save("28535.aw.docx");
}

private static Table Test28535AddTable(DocumentBuilder builder, int columnCount)
{
    // Add the specified number of cells with the same preferred width.
    PreferredWidth preferred = PreferredWidth.FromPercent(100.0 / columnCount);

    Table table = builder.StartTable();
    for (int i = 0; i < columnCount; ++i)
    {
        Cell cell = builder.InsertCell();
        cell.CellFormat.PreferredWidth = preferred;

        // This is just for readability, not needed for the case to work.
        builder.Write(string.Format("{0}", preferred));
    }
    builder.EndTable();

    // This will assign equal cell widths according to the preferred widths.
    table.AutoFit(AutoFitBehavior.AutoFitToWindow);
    // The widths will not match MS Word precisely,
    // but it is actually what is needed to preserve their relations
    // with cells in other rows.

    return table;
}

private static void MergeTablesPrimitive(Table remaining, Table merged)
{
    // Just move all table rows from the second table to the end of the first.
    while (merged.FirstChild != null)
        remaining.AppendChild(merged.FirstChild);

    // And remove the empty table.
    merged.Remove();
}

The output of the above snippet is attached:
28535.aw.docx (5.8 KB)
28535.aw.pdf (32.4 KB)

1 Like

@alexey.noskov Thanks, that’s great to be able to use the PreferredWidth again.

Unfortunately, the code from your development team has the same flaw as the code from my previous post. If you either replace t5 with a table having 2 columns or simply add on an other table with 2 columns, you’ll get a rather strange output, where one line will be longer than the others for no apparent reason. In this example, it’s the first row being longer; in my previous post, it was affecting the 4th row.

/// <summary>
/// Swapped t5 for a t2: Instead of 5 cells in that table, just 2 cells.
/// </summary>
public static Document Test28535_modified()
{
    Document doc = new Document();
    DocumentBuilder builder = new DocumentBuilder(doc);

    Table firstTable = Test28535AddTable(builder, 3);
    Table t2 = Test28535AddTable(builder, 2);
    Table t4 = Test28535AddTable(builder, 4);

    MergeTablesPrimitive(firstTable, t2);
    MergeTablesPrimitive(firstTable, t4);

    firstTable.AutoFit(AutoFitBehavior.FixedColumnWidths);
    firstTable.PreferredWidth = PreferredWidth.FromPercent(100.0);

    //doc.Save(Working_Directory + "28535.aw.pdf");
    doc.Save(Working_Directory + "28535.aw.docx");
}

28535.aw.docx (17.9 KB)

If you, again with your code, merge tables with 1…7 cells respectively in that order, it’ll be the last row sticking out.

What’s up with that?

@M.Heinz Unfortunately, I cannot reproduce the problem on my side. Your code produces the following output on my side: out.docx (7.3 KB)

Here is fool code used for testing:

/// <summary>
/// Swapped t5 for a t2: Instead of 5 cells in that table, just 2 cells.
/// </summary>
public static void Test28535_modified()
{
    Document doc = new Document();
    DocumentBuilder builder = new DocumentBuilder(doc);

    Table firstTable = Test28535AddTable(builder, 3);
    Table t2 = Test28535AddTable(builder, 2);
    Table t4 = Test28535AddTable(builder, 4);

    MergeTablesPrimitive(firstTable, t2);
    MergeTablesPrimitive(firstTable, t4);

    firstTable.AutoFit(AutoFitBehavior.FixedColumnWidths);
    firstTable.PreferredWidth = PreferredWidth.FromPercent(100.0);

    //doc.Save(Working_Directory + "28535.aw.pdf");
    doc.Save(@"C:\Temp\out.docx");
}

private static Table Test28535AddTable(DocumentBuilder builder, int columnCount)
{
    // Add the specified number of cells with the same preferred width.
    PreferredWidth preferred = PreferredWidth.FromPercent(100.0 / columnCount);

    Table table = builder.StartTable();
    for (int i = 0; i < columnCount; ++i)
    {
        Cell cell = builder.InsertCell();
        cell.CellFormat.PreferredWidth = preferred;

        // This is just for readability, not needed for the case to work.
        builder.Write(string.Format("{0}", preferred));
    }
    builder.EndTable();

    // This will assign equal cell widths according to the preferred widths.
    table.AutoFit(AutoFitBehavior.AutoFitToWindow);
    // The widths will not match MS Word precisely,
    // but it is actually what is needed to preserve their relations
    // with cells in other rows.

    return table;
}

private static void MergeTablesPrimitive(Table remaining, Table merged)
{
    // Just move all table rows from the second table to the end of the first.
    while (merged.FirstChild != null)
        remaining.AppendChild(merged.FirstChild);

    // And remove the empty table.
    merged.Remove();
}
1 Like

I can run any of the 3 public functions to get a result, with different row lengths.

I’ve tried several combinations of:

  • Aspose.Words 25.07 and 25.08
  • .NET Framework 4.8 and .NET 8
  • UnitTest and Console-application

None of the tested combinations has yielded me a good result.

These files are from a console application, Aspose.Words 25.08, .NET 8 setup on Win10:
28535.docx (17.9 KB)
28535_modified.docx (18.0 KB)
Test28535_modified_2_rows_per_table.docx (18.1 KB)

And the entire project for the console app:
TestConsole.zip (5.5 KB)

@M.Heinz On my side the result is different. With your code only 28535_modified.docx looks incorrect. After removing the following line:

firstTable.AutoFit(AutoFitBehavior.FixedColumnWidths);

All the tables look fine:
28535.docx (17.9 KB)
28535_modified.docx (18.0 KB)
Test28535_modified_2_rows_per_table.docx (18.0 KB)

1 Like

My bad on the ConsoleApp btw., it was still configured to be a .NET Framework 4.8 app (in the TestConsole.zip).

Nonetheless, with my original code, all of the tests with a .NET 8 console app yield the same results for me: One part looks broken.

Without the firstTable.AutoFit(AutoFitBehavior.FixedColumnWidths); all preliminary test cases look good. Thanks so much for the brilliant catch!

I guess, we’ll migrate production code to this setup then, pending all other test cases.

Thanks again so much for your help!

2 Likes

Just if someone is curious: What we actually ended up having to do, was using

  • a single table,
  • more cells per row and then merging them and
  • fixed cell widths (not PreferredWidths).

All other avenues either didn’t display all cells with equivalent widths or had some rows stick out on the right most edge for no apparent reason (in some cases).

Something similar to this:

// data
List<List<string>> rows = null; // set data

// For equal spacing, we need to insert LCM(cell_count_of_all_rows) cells into each row and merge them appropriately:
var cellsPerRow = LeastCommonMultiple(rows.Select(cells => cells.Count).Distinct());

var totalWidth = builder.PageSetup.PageWidth - builder.PageSetup.LeftMargin - builder.PageSetup.RightMargin;

var table = builder.StartTable();
foreach (var cells in rows) // "cells" are the cells to display for the current row
{
    var cellWidthPts = totalWidth / cellsPerRow;
    var cellsPerCell = cellsPerRow / cells.Count; // How many cells need to be merged to one cell for the current row. (Because "cells.Count" is part of the LCM computation, this division does not have a remainder!)

    foreach (var cellValue in cells)
    {
        var cell = builder.InsertCell();
        cell.CellFormat.HorizontalMerge = CellMerge.None;
        cell.CellFormat.Width = cellWidthPts;

		builder.Write(cellValue);
		
		// Merge cells, s.t. the cells will use an equal width in the table
		if (cellsPerCell > 1)
		{
			cell.CellFormat.HorizontalMerge = CellMerge.First;

			// idx 0 equals "cell"
			for (var i = 1; i < cellsPerCell; i++)
			{
				var dummyCell = builder.InsertCell();
				dummyCell.CellFormat.Width = cellWidthPts;
				dummyCell.CellFormat.HorizontalMerge = CellMerge.Previous;
			}
		}
    }
    builder.EndRow();
}
builder.EndTable();
table.AutoFit(AutoFitBehavior.FixedColumnWidths);


// == Helper functions ==

private static int GreatestCommonDivisor(int n1, int n2)
{
    if (n2 == 0)
        return n1;
    else
        return GreatestCommonDivisor(n2, n1 % n2);
}

private static int LeastCommonMultiple(IEnumerable<int> numbers)
{
    return numbers.Aggregate((a, b) => a * b / GreatestCommonDivisor(a, b));
}
2 Likes

Interesting that the behavior change lined up exactly with the 25.01 update. Did you ever try locking the table’s AutoFit settings explicitly to Fixed Column Widths before calling UpdatePageLayout()? I’ve seen that prevent Aspose from recalculating widths in ways you don’t expect. And also, thanks for posting the final solution

Yeah, we did. But because we’ve only been setting the PreferredWidth with our previous solution, and that apparently doesn’t set the Width property instantaneously ( → it only does set Width during rendering, i.e. UpdatePageLayout() → which since the update breaks in our use case…) and thus when calling table.AutoFit(AutoFitBehavior.FixedColumnWidths);, none of the cell Widths are set and subsequently all cells assume the min. required width to fit their respective contents. We explicitly want the table to use 100% of the page width though to achieve a consistent look and feel regardless of cell contents.

(Meaning that both these tables

|a    |b    |
| --- | --- |
|short|short|

and

|a          |b          |
| --------- | --------- |
|much longer|much longer|

should use the same width per column and total width; which, due to the text length in the second row, they clearly don’t in this example…)

A final idea, we were having is to format the entire thing using even more tables. Basically making a table with one column and the required number of rows and then in each row/cell insert an entirely new table which contains the actual PreferredWidth in % and then using AutoFitBehaviour.AutoFitToWindow throughout. This obviously might misalign other stuff, e.g. like

||a somewhat long text|b ||
|  ---------------------  |
||lorem ipsum|lorem ipsum||

but we haven’t been required to mess with that, because we’ve managed to achieve the posted workaround first. And of course formatting this to look pretty would have been an other nightmare…

1 Like