diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca402d7f..8aa8351c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
+- **Canonical table addressing and complete table-operation ripple (#450, absorbing
+ #471).** Tables now expose explicit stable identities for the `w:tbl`, every
+ `w:tr`, every physical `w:tc`, and every `w:tblGrid/w:gridCol`, plus
+ `GetTableMetadata`, `ResolveTableCellAnchor`, and
+ `ResolveTableCellCoordinate` for bidirectional anchor ↔ zero-based Word-grid
+ coordinates. All cell operations now consume one unambiguous canonical `tc`
+ anchor; a legacy paragraph inside a cell is translated to its nearest cell for
+ the compatibility window, while `tbl`/`tr`/unrelated anchors fail with
+ `TableAnchorMigrationRequired` and remediation. This also fixes nested-table
+ operations accidentally retargeting an outer cell. Shape-changing edits return
+ `EditResult.TableAnchors` with deterministic retained (before/after location),
+ added, and invalidated table/row/column/cell identities. Missing or
+ underspecified `tblGrid` columns are read-only virtual metadata until a column or
+ width transaction materializes real `gridCol` anchors and reports the virtual
+ identities invalidated. `DocumentStructure` keeps its path-based `Id` for
+ compatibility and adds canonical `AnchorId`; its table geometry now honors
+ `gridBefore`/`gridAfter`, horizontal spans, and actual vertical-merge row spans.
+ Rippled through JSON/ops, WASM/npm (including `SetTableRowOptions`), the Python
+ host/client (all table operations), and MCP. MCP table actions now use distinct
+ schema fields: `anchorId` for insertion, `tableAnchorId` for table reads, and
+ `cellAnchorId` for every cell operation. Existing merge OOXML semantics are
+ preserved; emitting new tracked table revisions remains #455. Coverage:
+ `DocxSessionTableAddressingTests` DT250–DT257, the existing table/MCP suites, and
+ `python/tests/test_table_addressing.py`.
- **`DocxSessionSettings.UndoMemoryBudgetBytes`** (wire `undoMemoryBudgetBytes`,
Python `undo_memory_budget_bytes`) — an approximate ceiling on the memory held
by undo/redo snapshots, default **128 MiB**. `UndoDepth` never bounded memory:
diff --git a/Docxodus.Tests/DocxSessionS1FeaturesTests.cs b/Docxodus.Tests/DocxSessionS1FeaturesTests.cs
index 79d26f3e..f0e6f510 100644
--- a/Docxodus.Tests/DocxSessionS1FeaturesTests.cs
+++ b/Docxodus.Tests/DocxSessionS1FeaturesTests.cs
@@ -236,7 +236,7 @@ public void DS205_InsertTable_BuildsGridWithSeededContentAndAlignment()
CellContents = new[] { "Texas", "7370", "01-0627671", "(State)", "(SIC)", "(IRS)" },
});
Assert.True(r.Success, r.Error?.Message);
- Assert.Equal(6, r.Created.Count); // one cell-paragraph anchor per cell
+ Assert.Equal(6, r.Created.Count); // one canonical tc anchor per cell
var root = DocumentXml(session.Save());
var tbl = root.Descendants(W + "tbl").Single();
@@ -320,9 +320,13 @@ public void DS207_InsertTable_CreatedCellsAreFillableByAnchor()
});
Assert.True(r.Success, r.Error?.Message);
- // Each created cell anchor is addressable for a subsequent edit.
+ // InsertTable reports canonical cells; resolve the cell's created paragraph for text CRUD.
var cellAnchor = r.Created.First();
- var fill = session.ReplaceText(cellAnchor.Id, "FILLED");
+ Assert.Equal("tc", cellAnchor.Kind);
+ var resolved = session.ResolveTableCellAnchor(cellAnchor.Id);
+ Assert.True(resolved.Success, resolved.Error?.Message);
+ var paragraphAnchor = Assert.Single(resolved.Cell!.ParagraphAnchors);
+ var fill = session.ReplaceText(paragraphAnchor.Id, "FILLED");
Assert.True(fill.Success, fill.Error?.Message);
Assert.Contains("FILLED", DocumentXml(session.Save()).Descendants(W + "tbl").Single().Value);
}
diff --git a/Docxodus.Tests/DocxSessionTableAddressingTests.cs b/Docxodus.Tests/DocxSessionTableAddressingTests.cs
new file mode 100644
index 00000000..d6510dd6
--- /dev/null
+++ b/Docxodus.Tests/DocxSessionTableAddressingTests.cs
@@ -0,0 +1,385 @@
+#nullable enable
+
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Validation;
+using DocumentFormat.OpenXml.Wordprocessing;
+using Docxodus.Tests.Ir;
+using Xunit;
+
+namespace Docxodus.Tests;
+
+/// Canonical table identity/coordinate coverage for issue #450 (including #471).
+public class DocxSessionTableAddressingTests
+{
+ private static readonly XNamespace W =
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+
+ private static byte[] BodyDoc(string bodyXml) =>
+ IrTestDocuments.FromBodyXml(bodyXml).DocumentByteArray;
+
+ private static string TableXml(string grid, string rows) =>
+ $"{grid}{rows}";
+
+ private static string CellXml(string text, string properties = "") =>
+ $"{properties}{text}";
+
+ private static string Grid(int count) =>
+ "" + string.Concat(Enumerable.Range(0, count)
+ .Select(index => $"")) + "";
+
+ private static string AnchorId(DocxSession session, string kind, string? scope = null, int skip = 0) =>
+ session.AnchorIndex().Values
+ .Where(target => target.Anchor.Kind == kind && (scope is null || target.Anchor.Scope == scope))
+ .Skip(skip).First().Anchor.Id;
+
+ private static XElement MainXml(byte[] bytes)
+ {
+ using var stream = new MemoryStream(bytes);
+ using var document = WordprocessingDocument.Open(stream, false);
+ return document.MainDocumentPart!.GetXDocument().Root!;
+ }
+
+ private static void AssertSchemaValid(byte[] bytes)
+ {
+ using var stream = new MemoryStream(bytes);
+ using var document = WordprocessingDocument.Open(stream, false);
+ var errors = new OpenXmlValidator().Validate(document)
+ .Select(error => $"{error.Path?.XPath}: {error.Description}").ToList();
+ Assert.True(errors.Count == 0, "OOXML schema errors:\n" + string.Join("\n", errors));
+ }
+
+ [Fact]
+ public void DT250_RectangularMetadata_ExposesEveryIdentityAndRoundTripsCoordinates()
+ {
+ using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs());
+ var bodyParagraph = AnchorId(session, "p");
+ var inserted = session.InsertTable(bodyParagraph, Position.After, 2, 3);
+ Assert.True(inserted.Success, inserted.Error?.Message);
+ Assert.All(inserted.Created, anchor => Assert.Equal("tc", anchor.Kind));
+
+ var tableId = AnchorId(session, "tbl");
+ var result = session.GetTableMetadata(tableId);
+ Assert.True(result.Success, result.Error?.Message);
+ var table = result.Metadata!;
+ Assert.Equal("tbl", table.Anchor.Kind);
+ Assert.Equal(3, table.Columns.Count);
+ Assert.All(table.Columns, column =>
+ {
+ Assert.Equal("col", column.Anchor.Kind);
+ Assert.False(column.IsVirtual);
+ });
+ Assert.Equal(2, table.Rows.Count);
+ Assert.All(table.Rows, row => Assert.Equal("tr", row.Anchor.Kind));
+ Assert.All(table.Rows.SelectMany(row => row.Cells), cell => Assert.Equal("tc", cell.Anchor.Kind));
+
+ foreach (var cell in table.Rows.SelectMany(row => row.Cells))
+ {
+ var byAnchor = session.ResolveTableCellAnchor(cell.Anchor.Id);
+ var byCoordinate = session.ResolveTableCellCoordinate(
+ table.Anchor.Id, cell.RowIndex, cell.ColumnIndex);
+ Assert.True(byAnchor.Success, byAnchor.Error?.Message);
+ Assert.True(byCoordinate.Success, byCoordinate.Error?.Message);
+ Assert.Equal(cell.Anchor.Id, byAnchor.Cell!.Anchor.Id);
+ Assert.Equal(cell.Anchor.Id, byCoordinate.Cell!.Anchor.Id);
+ }
+
+ var columnIds = table.Columns.Select(column => column.Anchor.Id).ToArray();
+ Assert.True(session.SetColumnWidths(inserted.Created[0].Id, new[] { 2000, 3000, 4000 }).Success);
+ var stable = session.GetTableMetadata(tableId).Metadata!;
+ Assert.Equal(columnIds, stable.Columns.Select(column => column.Anchor.Id).ToArray());
+
+ using var reopened = new DocxSession(session.Save(persistAnchorIds: true));
+ var afterReopen = reopened.GetTableMetadata(tableId);
+ Assert.True(afterReopen.Success, afterReopen.Error?.Message);
+ Assert.Equal(stable.Anchor.Id, afterReopen.Metadata!.Anchor.Id);
+ Assert.Equal(stable.Columns.Select(column => column.Anchor.Id),
+ afterReopen.Metadata.Columns.Select(column => column.Anchor.Id));
+ Assert.Equal(stable.Rows.Select(row => row.Anchor.Id),
+ afterReopen.Metadata.Rows.Select(row => row.Anchor.Id));
+ Assert.Equal(stable.Rows.SelectMany(row => row.Cells).Select(cell => cell.Anchor.Id),
+ afterReopen.Metadata.Rows.SelectMany(row => row.Cells).Select(cell => cell.Anchor.Id));
+ }
+
+ [Fact]
+ public void DT251_RaggedSpannedVerticalGrid_HasExactCoordinatesAndRowSpan()
+ {
+ var rows =
+ "" +
+ CellXml("lead", "") + "" +
+ "" +
+ CellXml("", "") + "";
+ using var session = new DocxSession(BodyDoc(TableXml(Grid(4), rows) + ""));
+ var tableId = AnchorId(session, "tbl");
+ var table = session.GetTableMetadata(tableId).Metadata!;
+
+ Assert.Equal((1, 1), (table.Rows[0].GridBefore, table.Rows[0].GridAfter));
+ var lead = Assert.Single(table.Rows[0].Cells);
+ var continuation = Assert.Single(table.Rows[1].Cells);
+ Assert.Equal((1, 2, 2), (lead.ColumnIndex, lead.ColumnSpan, lead.RowSpan));
+ Assert.Equal(TableVerticalMergeRole.Restart, lead.VerticalMerge);
+ Assert.Equal((0, TableVerticalMergeRole.Continue),
+ (continuation.RowSpan, continuation.VerticalMerge));
+ Assert.Equal(lead.Anchor.Id,
+ session.ResolveTableCellCoordinate(tableId, 0, 1).Cell!.Anchor.Id);
+ Assert.Equal(lead.Anchor.Id,
+ session.ResolveTableCellCoordinate(tableId, 0, 2).Cell!.Anchor.Id);
+ var leadingGap = session.ResolveTableCellCoordinate(tableId, 0, 0);
+ var trailingGap = session.ResolveTableCellCoordinate(tableId, 0, 3);
+ Assert.False(leadingGap.Success);
+ Assert.False(trailingGap.Success);
+ Assert.Equal(EditErrorCode.AnchorNotFound, leadingGap.Error!.Code);
+ Assert.Contains("(0, 0)", leadingGap.Error.Message);
+
+ var structure = DocumentStructureAnalyzer.Analyze(
+ IrTestDocuments.FromBodyXml(TableXml(Grid(4), rows) + ""));
+ var structureTable = Assert.Single(structure.FindByType(DocumentElementType.Table));
+ Assert.Equal(tableId, structureTable.AnchorId);
+ var cells = structure.FindByType(DocumentElementType.TableCell).ToList();
+ Assert.Equal((1, 2, 2), (cells[0].ColumnIndex, cells[0].ColumnSpan, cells[0].RowSpan));
+ Assert.Equal(0, cells[1].RowSpan);
+ Assert.Equal(lead.Anchor.Id, cells[0].AnchorId);
+ Assert.Equal(continuation.Anchor.Id, cells[1].AnchorId);
+ Assert.Equal(table.Columns.Select(column => column.Anchor.Id),
+ structure.GetTableColumns(structureTable.Id).Select(column => column.AnchorId));
+
+ // Inserting at the exact start of a trailing omission creates a physical cell; the
+ // existing omitted suffix moves right and remains an omission. Deletion is its inverse.
+ var trailingInsert = session.InsertTableColumn(lead.Anchor.Id, Position.After);
+ Assert.True(trailingInsert.Success, trailingInsert.Error?.Message);
+ Assert.Equal(2, trailingInsert.Created.Count);
+ var withTrailingInsert = session.GetTableMetadata(tableId).Metadata!;
+ Assert.Equal(5, withTrailingInsert.Columns.Count);
+ Assert.All(withTrailingInsert.Rows, row =>
+ {
+ Assert.Equal(1, row.GridAfter);
+ Assert.Equal(2, row.Cells.Count);
+ Assert.Equal(3, row.Cells[^1].ColumnIndex);
+ });
+ var trailingDelete = session.DeleteTableColumn(trailingInsert.Created[0].Id);
+ Assert.True(trailingDelete.Success, trailingDelete.Error?.Message);
+ var afterTrailingDelete = session.GetTableMetadata(tableId).Metadata!;
+ Assert.Equal(4, afterTrailingDelete.Columns.Count);
+ Assert.All(afterTrailingDelete.Rows, row => Assert.Equal(1, row.GridAfter));
+
+ // A row that omits the insertion coordinate through gridBefore adjusts that omission;
+ // the addressed row receives a cell at its first physical boundary.
+ var asymmetricRows =
+ "" +
+ CellXml("a") + CellXml("b") + "" +
+ "" +
+ CellXml("c") + CellXml("d") + "";
+ using var leadingSession = new DocxSession(
+ BodyDoc(TableXml(Grid(4), asymmetricRows) + ""));
+ var leadingTableId = AnchorId(leadingSession, "tbl");
+ var leadingBefore = leadingSession.GetTableMetadata(leadingTableId).Metadata!;
+ var leadingInsert = leadingSession.InsertTableColumn(
+ leadingBefore.Rows[0].Cells[0].Anchor.Id, Position.Before);
+ Assert.True(leadingInsert.Success, leadingInsert.Error?.Message);
+ Assert.Single(leadingInsert.Created);
+ var withLeadingInsert = leadingSession.GetTableMetadata(leadingTableId).Metadata!;
+ Assert.Equal((1, 3), (withLeadingInsert.Rows[0].GridBefore, withLeadingInsert.Rows[1].GridBefore));
+ var leadingDelete = leadingSession.DeleteTableColumn(leadingInsert.Created[0].Id);
+ Assert.True(leadingDelete.Success, leadingDelete.Error?.Message);
+ var afterLeadingDelete = leadingSession.GetTableMetadata(leadingTableId).Metadata!;
+ Assert.Equal((1, 2), (afterLeadingDelete.Rows[0].GridBefore, afterLeadingDelete.Rows[1].GridBefore));
+
+ // At the far edge of a shorter row's trailing omission, the row remains ragged: the
+ // appended grid column is omitted there and materialized only in the full-width row.
+ var trailingEdgeRows =
+ "" + CellXml("e") + CellXml("f") + CellXml("g") + CellXml("h") + "" +
+ "" +
+ CellXml("i") + CellXml("j") + "";
+ using var trailingEdgeSession = new DocxSession(
+ BodyDoc(TableXml(Grid(4), trailingEdgeRows) + ""));
+ var trailingEdgeTableId = AnchorId(trailingEdgeSession, "tbl");
+ var trailingEdgeBefore = trailingEdgeSession.GetTableMetadata(trailingEdgeTableId).Metadata!;
+ var trailingEdgeInsert = trailingEdgeSession.InsertTableColumn(
+ trailingEdgeBefore.Rows[0].Cells[^1].Anchor.Id, Position.After);
+ Assert.True(trailingEdgeInsert.Success, trailingEdgeInsert.Error?.Message);
+ Assert.Single(trailingEdgeInsert.Created);
+ var withTrailingEdgeInsert = trailingEdgeSession.GetTableMetadata(trailingEdgeTableId).Metadata!;
+ Assert.Equal((0, 3),
+ (withTrailingEdgeInsert.Rows[0].GridAfter, withTrailingEdgeInsert.Rows[1].GridAfter));
+ var trailingEdgeDelete = trailingEdgeSession.DeleteTableColumn(trailingEdgeInsert.Created[0].Id);
+ Assert.True(trailingEdgeDelete.Success, trailingEdgeDelete.Error?.Message);
+ var afterTrailingEdgeDelete = trailingEdgeSession.GetTableMetadata(trailingEdgeTableId).Metadata!;
+ Assert.Equal((0, 2),
+ (afterTrailingEdgeDelete.Rows[0].GridAfter, afterTrailingEdgeDelete.Rows[1].GridAfter));
+ }
+
+ [Fact]
+ public void DT252_NestedTableCanonicalCell_NeverRetargetsOuterCell()
+ {
+ var nested = TableXml(Grid(1), "" + CellXml("inner") + "");
+ var outerCell = "outer-before" +
+ nested + "outer-after";
+ using var session = new DocxSession(BodyDoc(
+ TableXml(Grid(1), "" + outerCell + "") + ""));
+ var outerTableId = AnchorId(session, "tbl", skip: 0);
+ var innerTableId = AnchorId(session, "tbl", skip: 1);
+ var outer = session.GetTableMetadata(outerTableId).Metadata!;
+ var inner = session.GetTableMetadata(innerTableId).Metadata!;
+ var outerCellMetadata = Assert.Single(Assert.Single(outer.Rows).Cells);
+ var innerCellMetadata = Assert.Single(Assert.Single(inner.Rows).Cells);
+ Assert.Equal(2, outerCellMetadata.ParagraphAnchors.Count);
+ Assert.Single(innerCellMetadata.ParagraphAnchors);
+ Assert.DoesNotContain(innerCellMetadata.ParagraphAnchors[0], outerCellMetadata.ParagraphAnchors);
+
+ var replaced = session.ReplaceCellContent(innerCellMetadata.Anchor.Id, "inner-new");
+ Assert.True(replaced.Success, replaced.Error?.Message);
+ Assert.Equal(innerCellMetadata.Anchor.Id, Assert.Single(replaced.Modified).Id);
+ var insertedInnerRow = session.InsertTableRow(innerCellMetadata.Anchor.Id, Position.After);
+ Assert.True(insertedInnerRow.Success, insertedInnerRow.Error?.Message);
+ Assert.Equal(innerTableId, insertedInnerRow.TableAnchors!.Retained
+ .Single(entry => entry.Before.EntityKind == TableAnchorEntityKind.Table).After.Anchor.Id);
+ Assert.Single(session.GetTableMetadata(outerTableId).Metadata!.Rows);
+ Assert.Equal(2, session.GetTableMetadata(innerTableId).Metadata!.Rows.Count);
+ var xml = MainXml(session.Save());
+ var tables = xml.Descendants(W + "tbl").ToList();
+ Assert.Single(tables[0].Elements(W + "tr"));
+ Assert.Equal(2, tables[1].Elements(W + "tr").Count());
+ Assert.Contains("outer-before", string.Concat(tables[0].Elements(W + "tr")
+ .Elements(W + "tc").Elements(W + "p").Descendants(W + "t")));
+ Assert.Equal("inner-new", string.Concat(tables[1].Descendants(W + "t").Select(text => text.Value)));
+
+ var structural = session.SetCellShading(innerTableId, "FF0000");
+ Assert.False(structural.Success);
+ Assert.Equal(EditErrorCode.TableAnchorMigrationRequired, structural.Error!.Code);
+ Assert.Contains("canonical tc anchor", structural.Error.Message);
+ }
+
+ [Fact]
+ public void DT253_LegacyParagraphTranslation_IsNearestCellOnlyAndCanonicalizesResult()
+ {
+ using var session = new DocxSession(BodyDoc(
+ TableXml(Grid(1), "" + CellXml("old") + "") + "body"));
+ var cell = AnchorId(session, "tc");
+ var cellParagraph = session.GetTableMetadata(AnchorId(session, "tbl"))
+ .Metadata!.Rows[0].Cells[0].ParagraphAnchors[0].Id;
+ var bodyParagraph = session.AnchorIndex().Values
+ .First(target => target.Anchor.Kind == "p" && target.Anchor.Id != cellParagraph).Anchor.Id;
+ var translated = session.ReplaceCellContent(cellParagraph, "new");
+ Assert.True(translated.Success, translated.Error?.Message);
+ Assert.Equal(cell, Assert.Single(translated.Modified).Id);
+
+ var rejected = session.InsertTableRow(bodyParagraph, Position.After);
+ Assert.False(rejected.Success);
+ Assert.Equal(EditErrorCode.TableAnchorMigrationRequired, rejected.Error!.Code);
+ Assert.Contains("GetTableMetadata", rejected.Error.Message);
+ }
+
+ [Fact]
+ public void DT254_StructuralMappings_AreDeterministicAndCanonical()
+ {
+ using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs());
+ var inserted = session.InsertTable(AnchorId(session, "p"), Position.After, 2, 2);
+ var tableId = AnchorId(session, "tbl");
+ var before = session.GetTableMetadata(tableId).Metadata!;
+ var oldBottomLeft = before.Rows[1].Cells[0];
+
+ var rowInsert = session.InsertTableRow(before.Rows[0].Cells[0].Anchor.Id, Position.After);
+ Assert.True(rowInsert.Success, rowInsert.Error?.Message);
+ Assert.All(rowInsert.Created, anchor => Assert.Equal("tc", anchor.Kind));
+ var mapping = rowInsert.TableAnchors!;
+ Assert.Equal(3, mapping.Added.Count); // one tr + two tc
+ Assert.Empty(mapping.Invalidated);
+ var shifted = mapping.Retained.Single(entry => entry.Before.Anchor.Id == oldBottomLeft.Anchor.Id);
+ Assert.Equal(1, shifted.Before.RowIndex);
+ Assert.Equal(2, shifted.After.RowIndex);
+
+ var after = session.GetTableMetadata(tableId).Metadata!;
+ var doomed = after.Rows[1].Cells[1].Anchor.Id;
+ var columnDelete = session.DeleteTableColumn(doomed);
+ Assert.True(columnDelete.Success, columnDelete.Error?.Message);
+ Assert.All(columnDelete.Removed, anchor => Assert.Equal("tc", anchor.Kind));
+ Assert.Contains(columnDelete.TableAnchors!.Invalidated,
+ location => location.Anchor.Id == doomed && location.EntityKind == TableAnchorEntityKind.Cell);
+ Assert.Equal(columnDelete.Removed.Select(anchor => anchor.Id),
+ columnDelete.TableAnchors.Invalidated
+ .Where(location => location.EntityKind == TableAnchorEntityKind.Cell)
+ .Select(location => location.Anchor.Id));
+ }
+
+ [Fact]
+ public void DT255_MissingGrid_MetadataIsReadOnlyAndColumnEditReplacesVirtualIdentities()
+ {
+ using var session = new DocxSession(BodyDoc(
+ TableXml("", "" + CellXml("a") + CellXml("b") + "") + ""));
+ var tableId = AnchorId(session, "tbl");
+ var before = session.GetTableMetadata(tableId).Metadata!;
+ Assert.Equal(2, before.Columns.Count);
+ Assert.All(before.Columns, column => Assert.True(column.IsVirtual));
+ Assert.All(before.Columns, column => Assert.Matches("^[0-9a-f]{32}$", column.Anchor.Unid));
+ Assert.Null(MainXml(session.Save()).Descendants(W + "tblGrid").FirstOrDefault());
+
+ var edit = session.InsertTableColumn(before.Rows[0].Cells[0].Anchor.Id, Position.After);
+ Assert.True(edit.Success, edit.Error?.Message);
+ Assert.All(before.Columns, column => Assert.Contains(edit.TableAnchors!.Invalidated,
+ location => location.Anchor.Id == column.Anchor.Id && location.IsVirtual));
+ var after = session.GetTableMetadata(tableId).Metadata!;
+ Assert.Equal(3, after.Columns.Count);
+ Assert.All(after.Columns, column => Assert.False(column.IsVirtual));
+ Assert.All(after.Columns, column => Assert.Contains(edit.TableAnchors!.Added,
+ location => location.Anchor.Id == column.Anchor.Id));
+ }
+
+ [Fact]
+ public void DT256_HeaderTable_UsesScopedCanonicalAnchors()
+ {
+ using var stream = new MemoryStream();
+ using (var document = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true))
+ {
+ var main = document.AddMainDocumentPart();
+ main.Document = new Document(new Body(new Paragraph(new Run(new Text("body")))));
+ var header = main.AddNewPart();
+ header.Header = new Header();
+ header.PutXDocument(XDocument.Parse(
+ $"{TableXml(Grid(1), "" + CellXml("header") + "")}"));
+ var relationship = main.GetIdOfPart(header);
+ main.Document.Body!.Append(new SectionProperties(
+ new HeaderReference { Id = relationship, Type = HeaderFooterValues.Default }));
+ main.Document.Save();
+ }
+
+ using var session = new DocxSession(stream.ToArray());
+ var tableId = AnchorId(session, "tbl", "hdr1");
+ var metadata = session.GetTableMetadata(tableId).Metadata!;
+ Assert.Equal("hdr1", metadata.Anchor.Scope);
+ Assert.Equal("hdr1", metadata.Rows[0].Cells[0].Anchor.Scope);
+ var edit = session.ReplaceCellContent(metadata.Rows[0].Cells[0].Anchor.Id, "header-new");
+ Assert.True(edit.Success, edit.Error?.Message);
+ Assert.Equal("hdr1", Assert.Single(edit.Modified).Scope);
+ }
+
+ [Fact]
+ public void DT257_TableInsideRevisionWrapper_HasCanonicalAnchorsWithoutRevisionEmission()
+ {
+ var revisedRow =
+ "" + CellXml("revision-table") + "";
+ var input = BodyDoc(TableXml(Grid(1), revisedRow) + "");
+ AssertSchemaValid(input);
+ using var session = new DocxSession(input, new DocxSessionSettings
+ {
+ TrackedChanges = TrackedChangeMode.RenderInline,
+ });
+ var tableId = AnchorId(session, "tbl");
+ var metadata = session.GetTableMetadata(tableId).Metadata!;
+ var edit = session.InsertTableRow(metadata.Rows[0].Cells[0].Anchor.Id, Position.After);
+ Assert.True(edit.Success, edit.Error?.Message);
+ Assert.All(edit.Created, anchor => Assert.Equal("tc", anchor.Kind));
+
+ var saved = session.Save();
+ AssertSchemaValid(saved);
+ var xml = MainXml(saved);
+ var tableElement = xml.Descendants(W + "tbl").Single();
+ Assert.Equal(2, tableElement.Elements(W + "tr").Count());
+ Assert.Single(tableElement.Elements(W + "tr").First().Element(W + "trPr")!.Elements(W + "ins"));
+ Assert.DoesNotContain(tableElement.Elements(W + "tr").Skip(1).DescendantsAndSelf(),
+ element => element.Name == W + "ins");
+ }
+}
diff --git a/Docxodus.Tests/DocxSessionTableEditTests.cs b/Docxodus.Tests/DocxSessionTableEditTests.cs
index 32e6cb80..d76b2c2d 100644
--- a/Docxodus.Tests/DocxSessionTableEditTests.cs
+++ b/Docxodus.Tests/DocxSessionTableEditTests.cs
@@ -12,7 +12,7 @@ namespace Docxodus.Tests;
///
/// Tests for post-insert table editing on : insert/delete row,
-/// insert/delete column — addressed by a cell-paragraph anchor. Test IDs use the DT2xx range.
+/// insert/delete column — addressed by a canonical cell anchor. Test IDs use the DT2xx range.
///
public class DocxSessionTableEditTests
{
@@ -30,9 +30,7 @@ private static string FirstBodyParagraph(DocxSession session) =>
session.Project().AnchorIndex.Values
.First(t => t.Anchor.Scope == "body" && t.Anchor.Kind is "p" or "h").Anchor.Id;
- /// Insert a rows×cols table and return its created cell-paragraph anchors (row-major).
- /// One-line entries stay one paragraph per cell, so the returned
- /// anchors line up with the row-major cell order.
+ /// Insert a rows×cols table and return its created canonical cell anchors (row-major).
private static (DocxSession session, string[] cells) NewTable(int rows, int cols,
string[]? contents = null, int[]? widths = null)
{
@@ -388,7 +386,9 @@ public void DT220_MergeCells_Horizontal_WritesGridSpanSumsWidthAndKeepsContent()
var r = session.MergeCells(cells[0], rowSpan: 1, colSpan: 3);
Assert.True(r.Success, r.Error?.Message);
- Assert.Empty(r.Removed); // Append keeps every non-empty paragraph — nothing is lost
+ // Content is preserved, while the two absorbed canonical cell identities are invalidated.
+ Assert.Equal(2, r.Removed.Count);
+ Assert.All(r.Removed, anchor => Assert.Equal("tc", anchor.Kind));
Assert.Empty(r.Created);
Assert.Equal(cells[0], Assert.Single(r.Modified).Id);
@@ -410,10 +410,9 @@ public void DT221_MergeCells_Vertical_WritesRestartAndEmptiedContinuations()
var r = session.MergeCells(cells[0], rowSpan: 3, colSpan: 1);
Assert.True(r.Success, r.Error?.Message);
- // The absorbed text moved into the lead cell, so nothing is reported removed; each
- // continuation cell is re-seeded with one fresh (still addressable) paragraph.
+ // The absorbed text moved into the lead cell and every canonical cell shell survives.
Assert.Empty(r.Removed);
- Assert.Equal(2, r.Created.Count);
+ Assert.Empty(r.Created);
var tbl = SingleTable(session);
Assert.Equal(new[] { 2, 2, 2 }, CellCounts(tbl)); // a vertical merge removes no cells
@@ -583,8 +582,8 @@ public void DT229_UnmergeCells_UnmergedCellIsRejected()
Assert.Equal(EditErrorCode.InvalidTableMerge, bad.Error!.Code);
var bodyP = FirstBodyParagraph(session);
- Assert.Equal(EditErrorCode.AnchorWrongKind, session.MergeCells(bodyP, 2, 2).Error!.Code);
- Assert.Equal(EditErrorCode.AnchorWrongKind, session.UnmergeCells(bodyP).Error!.Code);
+ Assert.Equal(EditErrorCode.TableAnchorMigrationRequired, session.MergeCells(bodyP, 2, 2).Error!.Code);
+ Assert.Equal(EditErrorCode.TableAnchorMigrationRequired, session.UnmergeCells(bodyP).Error!.Code);
}
[Fact]
@@ -755,10 +754,10 @@ public void DT239_MergedTable_ProjectsOpaquelyAndKeepsItsCellsAddressable()
Assert.Contains("```table", projection.Markdown);
// …whose width is the GRID extent (3), not the merged first row's cell count (2).
Assert.Contains("rows: 2\ncols: 3", projection.Markdown.Replace("\r\n", "\n"));
- // …while every surviving cell paragraph stays individually addressable.
+ // …while every surviving canonical cell stays individually addressable.
Assert.Contains(cells[0], projection.AnchorIndex.Keys);
Assert.Contains(cells[2], projection.AnchorIndex.Keys);
- Assert.True(session.ReplaceText(cells[2], "still editable").Success);
+ Assert.True(session.ReplaceCellContent(cells[2], "still editable").Success);
Assert.Equal("still editable", CellText(Cell(SingleTable(session), 0, 1)));
}
@@ -771,7 +770,7 @@ public void DT240_MergedTable_RoundTripsThroughDocxDiff()
Assert.True(session.MergeCells(cells[0], 2, 2).Success);
var left = new WmlDocument("left.docx", session.Save());
- Assert.True(session.ReplaceText(cells[0], "revised headline").Success);
+ Assert.True(session.ReplaceCellContent(cells[0], "revised headline").Success);
var right = new WmlDocument("right.docx", session.Save());
var redline = DocxDiff.Compare(left, right);
diff --git a/Docxodus.Tests/Ir/IrMarkdownEquivalenceTests.cs b/Docxodus.Tests/Ir/IrMarkdownEquivalenceTests.cs
index 1ee7021c..c7ce69c7 100644
--- a/Docxodus.Tests/Ir/IrMarkdownEquivalenceTests.cs
+++ b/Docxodus.Tests/Ir/IrMarkdownEquivalenceTests.cs
@@ -287,10 +287,11 @@ public void MarkdownEquivalence_MustPassFixtures(string fixtureName)
// --- index comparison -------------------------------------------------
///
- /// Compare the oracle and IR anchor indexes restricted to BODY entries (Task 1 scope). For each
- /// body anchor the oracle produced, the IR must produce an entry with the same Anchor.Id/Kind/
- /// Scope/Unid, identical PartUri, and identical TextPreview. AutoNumberPrefix is EXCLUDED from
- /// the comparison — the IR counter walk lands in M1.4-T3 (see emitter TODO). Returns a
+ /// Compare the oracle and IR anchor indexes restricted to IR-owned BODY entries (Task 1 scope).
+ /// Metadata-only col anchors identify w:gridCol elements for canonical table
+ /// addressing; the markdown IR intentionally models the table/row/cell hierarchy instead. For
+ /// every other body anchor the oracle produced, the IR must produce an entry with the same
+ /// Anchor.Id/Kind/Scope/Unid, identical PartUri, TextPreview, and AutoNumberPrefix. Returns a
/// human-readable diff in on mismatch.
///
private static bool BodyIndexEqual(
@@ -299,7 +300,10 @@ private static bool BodyIndexEqual(
out string diff)
{
var sb = new StringBuilder();
- var oracleBody = oracle.Where(kv => kv.Value.Anchor.Scope == "body")
+ static bool IsComparableBodyAnchor(AnchorTarget target) =>
+ target.Anchor.Scope == "body" && target.Anchor.Kind != "col";
+
+ var oracleBody = oracle.Where(kv => IsComparableBodyAnchor(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal);
foreach (var (key, oTarget) in oracleBody.OrderBy(kv => kv.Key, StringComparer.Ordinal))
@@ -325,7 +329,7 @@ private static bool BodyIndexEqual(
}
// Body anchors the IR produced that the oracle did not.
- foreach (var key in ir.Keys.Where(k => ir[k].Anchor.Scope == "body"))
+ foreach (var key in ir.Keys.Where(k => IsComparableBodyAnchor(ir[k])))
if (!oracleBody.ContainsKey(key))
sb.AppendLine($" extra in IR: {key}");
diff --git a/Docxodus.Tests/Ir/IrReaderTests.cs b/Docxodus.Tests/Ir/IrReaderTests.cs
index 2d7464a1..c4aace40 100644
--- a/Docxodus.Tests/Ir/IrReaderTests.cs
+++ b/Docxodus.Tests/Ir/IrReaderTests.cs
@@ -161,6 +161,26 @@ public void Read_Table_StructureAndAnchors()
}
}
+ [Fact]
+ public void Read_TableProjectionAnchors_AllUseClosedIrVocabulary()
+ {
+ var doc = IrTestDocuments.FromBodyXml(
+ "" +
+ "" +
+ "left" +
+ "right" +
+ "");
+
+ var projection = WmlToMarkdownConverter.Convert(
+ doc, new WmlToMarkdownConverterSettings());
+ var projectedKinds = projection.AnchorIndex.Values
+ .Select(target => IrAnchor.KindFromToken(target.Anchor.Kind))
+ .ToList();
+
+ Assert.Equal(2, projectedKinds.Count(kind => kind == IrAnchorKind.Col));
+ Assert.IsType(IrReader.Read(doc).Body.Blocks.Single());
+ }
+
[Fact]
public void Read_NestedTable_Recurses()
{
diff --git a/Docxodus.Tests/Ir/IrValueTypeTests.cs b/Docxodus.Tests/Ir/IrValueTypeTests.cs
index 3bddca13..bf47f1e8 100644
--- a/Docxodus.Tests/Ir/IrValueTypeTests.cs
+++ b/Docxodus.Tests/Ir/IrValueTypeTests.cs
@@ -42,6 +42,8 @@ public void IrAnchor_ToString_MatchesProjectionGrammar()
[Fact]
public void IrAnchor_KindTokens_RoundTrip()
{
+ Assert.Equal(IrAnchorKind.Col, IrAnchor.KindFromToken("col"));
+
foreach (IrAnchorKind kind in Enum.GetValues(typeof(IrAnchorKind)))
{
var token = IrAnchor.KindToken(kind);
diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs
index 9cadade1..5ca51a49 100644
--- a/Docxodus.Tests/McpServerDispatcherTests.cs
+++ b/Docxodus.Tests/McpServerDispatcherTests.cs
@@ -547,32 +547,28 @@ public void MCP060_Table_InsertRowAndReplaceCellContent()
$$"""{"sessionId":{{sessionArg}},"action":"insert","anchorId":"{{anchor}}","position":"after","rows":2,"columns":2}""")));
Assert.True(inserted.GetProperty("success").GetBoolean());
- // Two Docxodus ops address "the same cell" with two different anchor kinds:
- // ReplaceCellContent requires the "tc" (cell) anchor itself (not returned by InsertTable's
- // Created list — only the cell-paragraph anchors are — so it's found via search), while
- // row/column ops require a "p" (paragraph-inside-the-cell) anchor from Created directly.
- // See the docxodus_table schema note. Use the LAST "p" anchor (a different cell than the
- // one replace_cell_content below rewrites) for insert_row — replacing a cell's content
- // removes and recreates its paragraph, invalidating any anchor into that same cell.
- string? pAnchor = null;
- foreach (var created in inserted.GetProperty("created").EnumerateArray())
- {
- if (created.GetProperty("kind").GetString() == "p") pAnchor = created.GetProperty("id").GetString();
- }
- Assert.NotNull(pAnchor);
-
- var tcSearch = Parse(Dispatcher.Call(_store, "docxodus_search", J(
- $$"""{"sessionId":{{sessionArg}},"mode":"kind","query":"tc"}""")));
- var tcAnchor = tcSearch.GetProperty("matches")[0].GetProperty("id").GetString()!;
+ // Every cell operation consumes the same canonical tc anchor, returned directly by insert.
+ var tcAnchor = inserted.GetProperty("created")[0].GetProperty("id").GetString()!;
+ Assert.Equal("tc", inserted.GetProperty("created")[0].GetProperty("kind").GetString());
+ var otherTcAnchor = inserted.GetProperty("created")[3].GetProperty("id").GetString()!;
var replaced = Parse(Dispatcher.Call(_store, "docxodus_table", J(
$$"""{"sessionId":{{sessionArg}},"action":"replace_cell_content","cellAnchorId":"{{tcAnchor}}","markdown":"cell text"}""")));
Assert.True(replaced.GetProperty("success").GetBoolean());
var rowAddedJson = Dispatcher.Call(_store, "docxodus_table", J(
- $$"""{"sessionId":{{sessionArg}},"action":"insert_row","cellAnchorId":"{{pAnchor}}","position":"after"}"""));
+ $$"""{"sessionId":{{sessionArg}},"action":"insert_row","cellAnchorId":"{{otherTcAnchor}}","position":"after"}"""));
var rowAdded = Parse(rowAddedJson);
Assert.True(rowAdded.GetProperty("success").GetBoolean(), rowAddedJson);
+
+ var tableAnchor = Parse(Dispatcher.Call(_store, "docxodus_search", J(
+ $$"""{"sessionId":{{sessionArg}},"mode":"kind","query":"tbl"}""")))
+ .GetProperty("matches")[0].GetProperty("id").GetString()!;
+ var metadata = Parse(Dispatcher.Call(_store, "docxodus_table", J(
+ $$"""{"sessionId":{{sessionArg}},"action":"get_metadata","tableAnchorId":"{{tableAnchor}}"}""")));
+ Assert.True(metadata.GetProperty("success").GetBoolean());
+ Assert.Equal("col", metadata.GetProperty("metadata").GetProperty("columns")[0]
+ .GetProperty("anchor").GetProperty("kind").GetString());
}
[Fact]
diff --git a/Docxodus/DocumentStructure.cs b/Docxodus/DocumentStructure.cs
index 9e7514e1..4d5426e4 100644
--- a/Docxodus/DocumentStructure.cs
+++ b/Docxodus/DocumentStructure.cs
@@ -42,6 +42,11 @@ public class DocumentElement
///
public string Id { get; init; } = "";
+ /// Canonical live-session anchor when the element is addressable. Table structures
+ /// expose tbl/tr/tc anchors here while remains as the
+ /// compatibility path identifier.
+ public string? AnchorId { get; init; }
+
///
/// Type of this element.
///
@@ -98,6 +103,15 @@ public class TableColumnInfo
///
public string TableId { get; init; } = "";
+ /// Canonical col anchor of the underlying w:gridCol.
+ public string AnchorId { get; init; } = "";
+
+ /// Canonical tbl anchor of the owning table.
+ public string TableAnchorId { get; init; } = "";
+
+ /// Whether the identity represents a missing grid slot rather than a physical gridCol.
+ public bool IsVirtual { get; init; }
+
///
/// Zero-based column index.
///
@@ -108,6 +122,9 @@ public class TableColumnInfo
///
public List CellIds { get; init; } = new();
+ /// Canonical tc anchors of the physical cells covering the column.
+ public List CellAnchorIds { get; init; } = new();
+
///
/// Total number of rows in this column.
///
@@ -200,7 +217,12 @@ public static DocumentStructure Analyze(WmlDocument doc)
};
}
- var body = XElement.Parse(mainPart.Document.Body.OuterXml);
+ // Seed from the same w:document root as the live-session projector. Seeding from a
+ // detached w:body would produce internally deterministic IDs that nevertheless differed
+ // from the canonical anchors a DocxSession over these exact bytes accepts.
+ var documentRoot = mainPart.GetXDocument().Root!;
+ UnidHelper.AssignToAllElementsDeterministic(documentRoot);
+ var body = documentRoot.Element(W + "body")!;
var elementsById = new Dictionary();
var tableColumns = new Dictionary();
@@ -366,6 +388,7 @@ private static DocumentElement AnalyzeTable(
{
var id = $"{parentId}/tbl-{index}";
var rows = new List();
+ var metadata = Internal.TableGridModel.BuildMetadata(table, BodyAnchorForElement);
// Track column info
var columnCells = new Dictionary>();
@@ -373,26 +396,33 @@ private static DocumentElement AnalyzeTable(
int rowIndex = 0;
foreach (var tr in table.Elements(W + "tr"))
{
- var rowElement = AnalyzeTableRow(tr, id, rowIndex, elementsById, tableColumns, columnCells);
+ var rowElement = AnalyzeTableRow(tr, id, rowIndex, metadata.Rows[rowIndex],
+ elementsById, tableColumns, columnCells);
rows.Add(rowElement);
rowIndex++;
}
// Build TableColumnInfo entries
- foreach (var (colIdx, cellIds) in columnCells)
+ foreach (var column in metadata.Columns)
{
- var colId = $"{id}/col-{colIdx}";
+ var colId = $"{id}/col-{column.ColumnIndex}";
+ columnCells.TryGetValue(column.ColumnIndex, out var cellIds);
tableColumns[colId] = new TableColumnInfo
{
TableId = id,
- ColumnIndex = colIdx,
- CellIds = cellIds
+ TableAnchorId = metadata.Anchor.Id,
+ AnchorId = column.Anchor.Id,
+ IsVirtual = column.IsVirtual,
+ ColumnIndex = column.ColumnIndex,
+ CellIds = cellIds ?? new List(),
+ CellAnchorIds = column.CellAnchorIds.ToList(),
};
}
var element = new DocumentElement
{
Id = id,
+ AnchorId = metadata.Anchor.Id,
Type = DocumentElementType.Table,
TextPreview = $"[Table: {rowIndex} rows]",
Index = index,
@@ -408,6 +438,7 @@ private static DocumentElement AnalyzeTableRow(
XElement tr,
string tableId,
int rowIndex,
+ TableRowMetadata rowMetadata,
Dictionary elementsById,
Dictionary tableColumns,
Dictionary> columnCells)
@@ -415,31 +446,30 @@ private static DocumentElement AnalyzeTableRow(
var id = $"{tableId}/tr-{rowIndex}";
var cells = new List();
- int columnIndex = 0;
int cellIndex = 0;
foreach (var tc in tr.Elements(W + "tc"))
{
- var cellElement = AnalyzeTableCell(tc, id, cellIndex, columnIndex, rowIndex, elementsById, tableColumns);
+ var cellMetadata = rowMetadata.Cells[cellIndex];
+ var cellElement = AnalyzeTableCell(tc, id, cellIndex, cellMetadata, elementsById, tableColumns);
cells.Add(cellElement);
- // Track cell for column info
- if (!columnCells.ContainsKey(columnIndex))
+ // A horizontally-spanning cell belongs to every grid column it covers.
+ for (int columnIndex = cellMetadata.ColumnIndex;
+ columnIndex < cellMetadata.ColumnIndex + cellMetadata.ColumnSpan;
+ columnIndex++)
{
- columnCells[columnIndex] = new List();
+ if (!columnCells.ContainsKey(columnIndex))
+ columnCells[columnIndex] = new List();
+ columnCells[columnIndex].Add(cellElement.Id);
}
- columnCells[columnIndex].Add(cellElement.Id);
-
- // Account for column span
- var gridSpan = tc.Element(W + "tcPr")?.Element(W + "gridSpan")?.Attribute(W + "val")?.Value;
- var span = gridSpan != null ? int.Parse(gridSpan) : 1;
- columnIndex += span;
cellIndex++;
}
var element = new DocumentElement
{
Id = id,
+ AnchorId = rowMetadata.Anchor.Id,
Type = DocumentElementType.TableRow,
TextPreview = $"[Row {rowIndex + 1}: {cellIndex} cells]",
Index = cellIndex,
@@ -456,39 +486,26 @@ private static DocumentElement AnalyzeTableCell(
XElement tc,
string rowId,
int cellIndex,
- int columnIndex,
- int rowIndex,
+ TableCellMetadata metadata,
Dictionary elementsById,
Dictionary tableColumns)
{
var id = $"{rowId}/tc-{cellIndex}";
- // Get span info
- var tcPr = tc.Element(W + "tcPr");
- var gridSpanAttr = tcPr?.Element(W + "gridSpan")?.Attribute(W + "val")?.Value;
- var columnSpan = gridSpanAttr != null ? int.Parse(gridSpanAttr) : 1;
-
- var vMerge = tcPr?.Element(W + "vMerge");
- int? rowSpan = null;
- if (vMerge != null)
- {
- var val = vMerge.Attribute(W + "val")?.Value;
- rowSpan = val == "restart" ? 1 : 0; // 0 means continuation
- }
-
// Analyze cell content (paragraphs and nested tables)
var children = AnalyzeChildren(tc, id, elementsById, tableColumns);
var element = new DocumentElement
{
Id = id,
+ AnchorId = metadata.Anchor.Id,
Type = DocumentElementType.TableCell,
TextPreview = GetTextPreview(tc),
Index = cellIndex,
- ColumnIndex = columnIndex,
- RowIndex = rowIndex,
- ColumnSpan = columnSpan > 1 ? columnSpan : null,
- RowSpan = rowSpan,
+ ColumnIndex = metadata.ColumnIndex,
+ RowIndex = metadata.RowIndex,
+ ColumnSpan = metadata.ColumnSpan > 1 ? metadata.ColumnSpan : null,
+ RowSpan = metadata.VerticalMerge == TableVerticalMergeRole.None ? null : metadata.RowSpan,
Children = children,
XmlElement = tc
};
@@ -497,6 +514,15 @@ private static DocumentElement AnalyzeTableCell(
return element;
}
+ private static Anchor? BodyAnchorForElement(XElement element)
+ {
+ var kind = WmlToMarkdownConverter.KindFor(element);
+ var unid = (string?)element.Attribute(PtOpenXml.Unid);
+ return kind is null || unid is null
+ ? null
+ : new Anchor($"{kind}:body:{unid}", kind, "body", unid);
+ }
+
private static string? GetTextPreview(XElement element, int maxLength = 100)
{
var sb = new StringBuilder();
diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs
index 6d4a28e7..fe4b48f4 100644
--- a/Docxodus/DocxSession.cs
+++ b/Docxodus/DocxSession.cs
@@ -9,6 +9,7 @@
using System.Linq;
using System.Xml.Linq;
using DocumentFormat.OpenXml.Packaging;
+using GridCell = Docxodus.Internal.TableGridCell;
namespace Docxodus;
@@ -1157,6 +1158,12 @@ public enum EditErrorCode
/// no merge markup.
InvalidTableMerge,
+ /// The supplied anchor is not the canonical tc cell anchor and cannot be
+ /// translated by the compatibility shim. During the compatibility window only a legacy
+ /// paragraph/heading/list-item anchor physically inside the intended cell is translated;
+ /// use table metadata or coordinate resolution to obtain the cell's tc anchor.
+ TableAnchorMigrationRequired,
+
MalformedXml,
DisallowedNamespace,
IncompatibleElementType,
@@ -1192,6 +1199,9 @@ public sealed class EditResult
public IReadOnlyList Modified { get; init; } = Array.Empty();
public MarkdownPatch? Patch { get; init; }
+ /// Structural identity mapping populated by table shape mutations.
+ public TableAnchorMapping? TableAnchors { get; init; }
+
///
/// Populated by AddAnnotation/RemoveAnnotation/UpdateAnnotation/MoveAnnotation
/// with the affected annotation id. Null for every other op.
@@ -2049,6 +2059,76 @@ public bool Exists(string anchorId)
return target is null ? null : Internal.BlockMetadataOps.GetBlockMetadata(_doc!, target);
}
+ /// Resolve a canonical tbl anchor to the live table's complete structural
+ /// metadata. This never climbs from a nested table to an enclosing table.
+ public TableMetadataResult GetTableMetadata(string tableAnchorId)
+ {
+ if (_disposed) return new TableMetadataResult
+ {
+ Error = new EditError(EditErrorCode.SessionDisposed, "session disposed", tableAnchorId),
+ };
+ var target = FindAnchor(tableAnchorId);
+ if (target is null) return new TableMetadataResult
+ {
+ Error = new EditError(EditErrorCode.AnchorNotFound, "table anchor not found", tableAnchorId),
+ };
+ var table = target.Resolve(_doc!);
+ if (target.Anchor.Kind != "tbl" || table?.Name != W.tbl) return new TableMetadataResult
+ {
+ Error = new EditError(EditErrorCode.AnchorWrongKind,
+ "GetTableMetadata requires the table's canonical tbl anchor", tableAnchorId),
+ };
+ return new TableMetadataResult
+ {
+ Success = true,
+ Metadata = Internal.TableGridModel.BuildMetadata(table, AnchorForElement),
+ };
+ }
+
+ /// Resolve a canonical tc anchor to its table-grid coordinate and spans.
+ public TableCellResolutionResult ResolveTableCellAnchor(string cellAnchorId)
+ {
+ if (_disposed) return CellResolutionFail(EditErrorCode.SessionDisposed, "session disposed", cellAnchorId);
+ var target = FindAnchor(cellAnchorId);
+ if (target is null)
+ return CellResolutionFail(EditErrorCode.AnchorNotFound, "cell anchor not found", cellAnchorId);
+ var cell = target.Resolve(_doc!);
+ if (target.Anchor.Kind != "tc" || cell?.Name != W.tc)
+ return CellResolutionFail(EditErrorCode.TableAnchorMigrationRequired,
+ "ResolveTableCellAnchor requires a canonical tc anchor; obtain one from table metadata or ResolveTableCellCoordinate",
+ cellAnchorId);
+ var row = cell.Ancestors(W.tr).FirstOrDefault();
+ var table = row?.Ancestors(W.tbl).FirstOrDefault();
+ if (row is null || table is null)
+ return CellResolutionFail(EditErrorCode.InternalError, "malformed table cell", cellAnchorId);
+ var metadata = Internal.TableGridModel.BuildMetadata(table, AnchorForElement);
+ var resolved = metadata.Rows.SelectMany(item => item.Cells)
+ .FirstOrDefault(item => item.Anchor.Id == target.Anchor.Id);
+ return resolved is null
+ ? CellResolutionFail(EditErrorCode.InternalError, "cell is absent from its table grid", cellAnchorId)
+ : new TableCellResolutionResult { Success = true, Cell = resolved };
+ }
+
+ /// Resolve a zero-based table-grid coordinate to the physical cell covering it.
+ /// Coordinates omitted by w:gridBefore/w:gridAfter resolve as not found;
+ /// every coordinate covered by a w:gridSpan resolves to the same cell anchor.
+ public TableCellResolutionResult ResolveTableCellCoordinate(
+ string tableAnchorId, int rowIndex, int columnIndex)
+ {
+ var tableResult = GetTableMetadata(tableAnchorId);
+ if (!tableResult.Success)
+ return new TableCellResolutionResult { Error = tableResult.Error };
+ var cell = Internal.TableGridModel.CellAt(tableResult.Metadata!, rowIndex, columnIndex);
+ return cell is null
+ ? CellResolutionFail(EditErrorCode.AnchorNotFound,
+ $"no table cell covers coordinate ({rowIndex}, {columnIndex})", tableAnchorId)
+ : new TableCellResolutionResult { Success = true, Cell = cell };
+ }
+
+ private static TableCellResolutionResult CellResolutionFail(
+ EditErrorCode code, string message, string? anchorId) =>
+ new() { Error = new EditError(code, message, anchorId) };
+
///
/// Bulk variant of . Unknown anchor ids map
/// to null; duplicate ids are deduped; iteration order matches
@@ -5613,24 +5693,17 @@ private int CountRealValidationErrors()
public EditResult ReplaceCellContent(string cellAnchorId, string markdownPayload)
{
- if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed");
- var target = FindAnchor(cellAnchorId);
- if (target is null)
- return EditResult.Fail(EditErrorCode.AnchorNotFound, "anchor not found", cellAnchorId);
- if (target.Anchor.Kind != "tc")
- return EditResult.Fail(EditErrorCode.AnchorWrongKind, "ReplaceCellContent requires a cell anchor", cellAnchorId);
+ if (ResolveCell(cellAnchorId, out _, out var cell, out _, out _, out var target) is { } resolveError)
+ return resolveError;
var parsed = Internal.MarkdownPayloadParser.Parse(markdownPayload);
if (!parsed.Success)
return EditResult.Fail(parsed.Error!.Code, parsed.Error.Message, cellAnchorId);
- var cell = target.Resolve(_doc!);
- if (cell is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", cellAnchorId);
-
_history.RecordPreOp(TakeSnapshot());
try
{
- foreach (var p in cell.Elements(W.p).ToList()) p.Remove();
+ foreach (var p in cell!.Elements(W.p).ToList()) p.Remove();
foreach (var block in parsed.Blocks)
{
@@ -5647,8 +5720,8 @@ public EditResult ReplaceCellContent(string cellAnchorId, string markdownPayload
return new EditResult
{
Success = true,
- Modified = new[] { target.Anchor },
- Patch = PatchFor(target),
+ Modified = new[] { target!.Anchor },
+ Patch = PatchFor(target!),
};
}
catch (Exception ex)
@@ -7491,8 +7564,7 @@ private static void InsertInlineAtOffset(XElement paragraph, int offset, XElemen
///
/// Insert a × table before/after the block named
/// by . controls borders, per-cell markdown
- /// (row-major), and cell alignment. Returns the created cell-paragraph anchors (row-major), so the
- /// caller can address and fill/format each cell.
+ /// (row-major), and cell alignment. Returns the created canonical tc anchors (row-major).
///
public EditResult InsertTable(string anchorId, Position pos, int rows, int cols, TableInsertOptions? options = null)
{
@@ -7540,6 +7612,7 @@ public EditResult InsertTable(string anchorId, Position pos, int rows, int cols,
var tbl = new XElement(W.tbl, tblPr, tblGrid);
var cellParagraphs = new List();
+ var cells = new List();
for (int r = 0; r < rows; r++)
{
@@ -7554,6 +7627,7 @@ public EditResult InsertTable(string anchorId, Position pos, int rows, int cols,
var paras = BuildCellParagraphs(md, opts.CellAlignment);
foreach (var p in paras) tc.Add(p);
cellParagraphs.AddRange(paras);
+ cells.Add(tc);
tr.Add(tc);
}
tbl.Add(tr);
@@ -7579,19 +7653,14 @@ public EditResult InsertTable(string anchorId, Position pos, int rows, int cols,
foreach (var p in cellParagraphs) PromoteHyperlinkRelationships(p);
InvalidateProjectionCache();
- var index = AnchorIndex();
- var created = new List();
- foreach (var p in cellParagraphs)
- {
- var unid = (string)p.Attribute(PtOpenXml.Unid)!;
- if (AnchorForUnid(unid, PartUriOf(p)) is { } a)
- created.Add(a);
- }
+ var created = ResolveAnchorsForElements(cells);
+ var metadata = Internal.TableGridModel.BuildMetadata(tbl, AnchorForElement);
return new EditResult
{
Success = true,
Created = created,
+ TableAnchors = Internal.TableGridModel.Map(null, metadata),
Patch = PatchFor(target),
};
}
@@ -7648,7 +7717,7 @@ private static XElement BuildTableBorders(bool borderless)
return bdr;
}
- // ─── Table editing (row / column CRUD), addressed by a cell-paragraph anchor ──────────
+ // ─── Table editing (row / column CRUD), addressed by a canonical tc anchor ─────────────
//
// The grid model (issue #340): a row's cells tile w:tblGrid columns left→right, each
// covering w:gridSpan columns (default 1) from an origin shifted by w:trPr/w:gridBefore.
@@ -7658,8 +7727,11 @@ private static XElement BuildTableBorders(bool borderless)
// one narrows it, and a merge a structural edit cannot preserve is rejected — never
// silently torn.
- /// Resolve a cell-paragraph anchor to its (paragraph, cell, row, table, anchor
- /// target). Returns a failure EditResult on any miss, else null.
+ /// Resolve the canonical tc anchor to its cell/row/table. For the documented
+ /// compatibility window, a legacy paragraph/heading/list-item anchor is translated to its
+ /// nearest ancestor cell; every returned target is canonicalized to that cell. Structural
+ /// anchors are never climbed through, which prevents a nested tc/tr/tbl
+ /// from silently retargeting its enclosing outer cell.
private EditResult? ResolveCell(string cellAnchorId, out XElement? p, out XElement? tc,
out XElement? tr, out XElement? tbl, out AnchorTarget? target)
{
@@ -7670,64 +7742,46 @@ private static XElement BuildTableBorders(bool borderless)
return EditResult.Fail(EditErrorCode.AnchorNotFound, $"anchor not found: {cellAnchorId}", cellAnchorId);
p = target.Resolve(_doc!);
if (p is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", cellAnchorId);
- tc = p.Ancestors(W.tc).FirstOrDefault();
+ if (target.Anchor.Kind == "tc" && p.Name == W.tc)
+ tc = p;
+ else if (target.Anchor.Kind is "p" or "h" or "li")
+ tc = p.Ancestors(W.tc).FirstOrDefault();
if (tc is null)
- return EditResult.Fail(EditErrorCode.AnchorWrongKind,
- "table row/column ops require an anchor inside a table cell", cellAnchorId);
+ return EditResult.Fail(EditErrorCode.TableAnchorMigrationRequired,
+ "table cell operations require a canonical tc anchor; legacy p/h/li anchors are translated only when physically inside the intended cell. Use GetTableMetadata or ResolveTableCellCoordinate to obtain the tc anchor.",
+ cellAnchorId);
tr = tc.Ancestors(W.tr).FirstOrDefault();
tbl = tr?.Ancestors(W.tbl).FirstOrDefault();
if (tr is null || tbl is null)
return EditResult.Fail(EditErrorCode.InternalError, "malformed table (cell has no row/table)", cellAnchorId);
+ var canonical = AnchorForElement(tc);
+ if (canonical is null || FindAnchor(canonical.Value.Id) is not { } canonicalTarget)
+ return EditResult.Fail(EditErrorCode.InternalError, "cell has no canonical tc anchor", cellAnchorId);
+ target = canonicalTarget;
return null;
}
- /// A cell's geometry inside its row: the element, its first w:tblGrid column and
- /// how many columns it spans. is exclusive.
- private readonly record struct GridCell(XElement Tc, int Start, int Span)
- {
- internal int End => Start + Span;
- }
-
- private static int? ValOf(XElement? e) => e is null ? null : (int?)e.Attribute(W.val);
-
- /// The row's cells with their grid-column geometry, left→right.
- private static List RowGrid(XElement tr)
- {
- int col = ValOf(tr.Element(W.trPr)?.Element(W.gridBefore)) ?? 0;
- var cells = new List();
- foreach (var tc in tr.Elements(W.tc))
- {
- int span = Math.Max(1, ValOf(tc.Element(W.tcPr)?.Element(W.gridSpan)) ?? 1);
- cells.Add(new GridCell(tc, col, span));
- col += span;
- }
- return cells;
- }
+ /// The row's cells with their shared-model grid geometry, left→right.
+ private static List RowGrid(XElement tr) => Internal.TableGridModel.RowGrid(tr);
/// The cell covering , or null when the row has none.
- private static GridCell? CellCovering(IEnumerable grid, int gridCol)
- {
- foreach (var c in grid) if (gridCol >= c.Start && gridCol < c.End) return c;
- return null;
- }
+ private static GridCell? CellCovering(IEnumerable grid, int gridCol) =>
+ Internal.TableGridModel.CellCovering(grid, gridCol);
/// The cell of occupying exactly 's grid
/// columns — how a vertical-merge run is followed from row to row.
- private static XElement? AlignedCell(XElement tr, GridCell shape)
- {
- foreach (var c in RowGrid(tr))
- if (c.Start == shape.Start && c.End == shape.End) return c.Tc;
- return null;
- }
+ private static XElement? AlignedCell(XElement tr, GridCell shape) =>
+ Internal.TableGridModel.AlignedCell(tr, shape);
/// The cell's vertical-merge role: null = none, true = w:vMerge w:val="restart"
/// (a merge's lead cell), false = a continuation (bare w:vMerge, or val="continue").
- private static bool? VMergeRestart(XElement tc)
- {
- var vm = tc.Element(W.tcPr)?.Element(W.vMerge);
- if (vm is null) return null;
- return string.Equals((string?)vm.Attribute(W.val), "restart", StringComparison.OrdinalIgnoreCase);
- }
+ private static bool? VMergeRestart(XElement tc) =>
+ Internal.TableGridModel.VerticalMergeRole(tc) switch
+ {
+ TableVerticalMergeRole.Restart => true,
+ TableVerticalMergeRole.Continue => false,
+ _ => null,
+ };
private static void SetVMerge(XElement tc, bool? restart)
{
@@ -7759,10 +7813,46 @@ private static void BumpCellWidth(XElement tc, int delta)
tcW.SetAttributeValue(W._w, Math.Max(0, ((int?)tcW.Attribute(W._w) ?? 0) + delta));
}
+ private static void SetRowGridOmission(XElement row, XName name, int value)
+ {
+ var property = row.Element(W.trPr)?.Element(name);
+ if (value <= 0)
+ {
+ property?.Remove();
+ return;
+ }
+ if (property is null)
+ throw new InvalidOperationException($"row has no existing {name.LocalName} omission to adjust");
+ property.SetAttributeValue(W.val, value);
+ }
+
private static List GridColWidths(XElement tbl) =>
tbl.Element(W.tblGrid)?.Elements(W.gridCol).Select(g => (int?)g.Attribute(W._w) ?? 0).ToList()
?? new List();
+ /// Materialize real gridCol elements only inside a structural transaction. Metadata
+ /// inspection remains read-only and reports virtual columns; the before/after mapping then
+ /// invalidates those virtual identities and adds these real anchors explicitly.
+ private static void EnsureGridColumnsForMutation(XElement table)
+ {
+ int count = GridColumnCount(table);
+ var grid = table.Element(W.tblGrid);
+ if (grid is null)
+ {
+ grid = new XElement(W.tblGrid);
+ var properties = table.Element(W.tblPr);
+ if (properties is null) table.AddFirst(grid);
+ else properties.AddAfterSelf(grid);
+ }
+ int missing = count - grid.Elements(W.gridCol).Count();
+ for (int index = 0; index < missing; index++)
+ {
+ var column = new XElement(W.gridCol);
+ UnidHelper.AssignToSelfAndDescendants(column);
+ grid.Add(column);
+ }
+ }
+
private static int SumGridWidths(List widths, int from, int toExclusive)
{
int sum = 0;
@@ -7771,28 +7861,35 @@ private static int SumGridWidths(List widths, int from, int toExclusive)
}
/// The table's grid width — w:tblGrid's column count, falling back to the widest row.
- private static int GridColumnCount(XElement tbl)
- {
- int cols = tbl.Element(W.tblGrid)?.Elements(W.gridCol).Count() ?? 0;
- if (cols > 0) return cols;
- return tbl.Elements(W.tr).Select(tr => RowGrid(tr) is { Count: > 0 } g ? g[^1].End : 0)
- .DefaultIfEmpty(0).Max();
- }
+ private static int GridColumnCount(XElement tbl) => Internal.TableGridModel.GridColumnCount(tbl);
- /// After a structural edit, resolve the freshly-projected anchors for the given paragraphs.
- private List ResolveAnchorsForParagraphs(IEnumerable paras)
+ /// After a structural edit, resolve freshly-projected anchors for live elements.
+ private List ResolveAnchorsForElements(IEnumerable elements)
{
- var index = AnchorIndex();
+ _ = AnchorIndex();
var result = new List();
- foreach (var para in paras)
+ foreach (var element in elements)
{
- var unid = (string?)para.Attribute(PtOpenXml.Unid);
- if (unid is not null && AnchorForUnid(unid, PartUriOf(para)) is { } a)
+ var unid = (string?)element.Attribute(PtOpenXml.Unid);
+ if (unid is not null && AnchorForUnid(unid, PartUriOf(element)) is { } a)
result.Add(a);
}
return result;
}
+ private TableMetadata CaptureTableMetadata(XElement table) =>
+ Internal.TableGridModel.BuildMetadata(table, AnchorForElement);
+
+ private TableAnchorMapping CompleteTableMapping(TableMetadata before, XElement table) =>
+ Internal.TableGridModel.Map(before,
+ table.Parent is null ? null : Internal.TableGridModel.BuildMetadata(table, AnchorForElement));
+
+ private static IReadOnlyList InvalidatedCellAnchors(TableAnchorMapping mapping) =>
+ mapping.Invalidated
+ .Where(location => location.EntityKind == TableAnchorEntityKind.Cell)
+ .Select(location => location.Anchor)
+ .ToList();
+
/// An empty clone of 's shell (width, borders, shading,
/// valign). Merge markup is always dropped — a clone is a fresh cell, never half of somebody
/// else's merge — except w:gridSpan when is set, which a new
@@ -7816,12 +7913,13 @@ private static XElement NewEmptyCellLike(XElement referenceCell, bool keepSpan =
/// Insert a row before/after the row containing . The new
/// row mirrors the reference row's grid shape (cell widths and w:gridSpans) and starts
/// empty; where a vertical merge crosses the insertion boundary the new row joins it as a
- /// continuation rather than punching a hole through it. Returns the new cell-paragraph anchors.
+ /// continuation rather than punching a hole through it. Returns the new canonical cell anchors.
public EditResult InsertTableRow(string cellAnchorId, Position pos)
{
- if (ResolveCell(cellAnchorId, out _, out _, out var tr, out _, out var target) is { } err)
+ if (ResolveCell(cellAnchorId, out _, out _, out var tr, out var tbl, out var target) is { } err)
return err;
+ var before = CaptureTableMetadata(tbl!);
_history.RecordPreOp(TakeSnapshot());
try
{
@@ -7839,14 +7937,14 @@ public EditResult InsertTableRow(string cellAnchorId, Position pos)
.Select(e => new XElement(e)).ToList();
if (shape is { Count: > 0 }) newTr.Add(new XElement(W.trPr, shape));
- var newParas = new List();
+ var newCells = new List();
foreach (var g in RowGrid(tr))
{
var newTc = NewEmptyCellLike(g.Tc, keepSpan: true);
if (acrossGrid is not null && CellCovering(acrossGrid, g.Start) is { } across
&& VMergeRestart(across.Tc) == false)
SetVMerge(newTc, restart: false);
- newParas.Add(newTc.Element(W.p)!);
+ newCells.Add(newTc);
newTr.Add(newTc);
}
UnidHelper.AssignToSelfAndDescendants(newTr);
@@ -7857,7 +7955,8 @@ public EditResult InsertTableRow(string cellAnchorId, Position pos)
return new EditResult
{
Success = true,
- Created = ResolveAnchorsForParagraphs(newParas),
+ Created = ResolveAnchorsForElements(newCells),
+ TableAnchors = CompleteTableMapping(before, tbl!),
Patch = PatchFor(target!),
};
}
@@ -7873,7 +7972,7 @@ public EditResult InsertTableRow(string cellAnchorId, Position pos)
/// a new empty cell in every row (cloning that column's width) plus a matching w:gridCol. A row
/// whose cell straddles the new boundary — a horizontal merge spanning it — widens by one
/// column instead of gaining a cell, so the grid stays consistent. Returns the new
- /// cell-paragraph anchors (top→bottom); rows that only widened contribute none.
+ /// canonical cell anchors (top→bottom); rows that only widened contribute none.
public EditResult InsertTableColumn(string cellAnchorId, Position pos)
{
if (ResolveCell(cellAnchorId, out _, out var tc, out var tr, out var tbl, out var target) is { } err)
@@ -7882,9 +7981,11 @@ public EditResult InsertTableColumn(string cellAnchorId, Position pos)
var anchorCell = RowGrid(tr!).First(g => g.Tc == tc);
int boundary = pos == Position.Before ? anchorCell.Start : anchorCell.End;
+ var before = CaptureTableMetadata(tbl!);
_history.RecordPreOp(TakeSnapshot());
try
{
+ EnsureGridColumnsForMutation(tbl!);
// Mirror the structural change in w:tblGrid first, so the new column's width is
// known before the cells that must carry it are written.
var widths = GridColWidths(tbl!);
@@ -7893,15 +7994,30 @@ public EditResult InsertTableColumn(string cellAnchorId, Position pos)
int newWidth = widths.Count > 0 ? widths[srcCol] : 0;
if (tbl!.Element(W.tblGrid) is { } grid && grid.Elements(W.gridCol).ToList() is { Count: > 0 } cols)
{
- var clone = new XElement(cols[srcCol]);
+ var clone = new XElement(W.gridCol,
+ cols[srcCol].Attributes().Where(attribute => attribute.Name != PtOpenXml.Unid));
+ UnidHelper.AssignToSelfAndDescendants(clone);
if (boundary >= cols.Count) cols[^1].AddAfterSelf(clone);
else cols[boundary].AddBeforeSelf(clone);
}
- var newParas = new List();
+ var newCells = new List();
foreach (var row in tbl.Elements(W.tr))
{
var rowGrid = RowGrid(row);
+ int gridBefore = Internal.TableGridModel.GridBefore(row);
+ int rowEnd = rowGrid.Count == 0 ? gridBefore : rowGrid[^1].End;
+ int gridAfter = Internal.TableGridModel.GridAfter(row);
+ if (boundary < gridBefore)
+ {
+ SetRowGridOmission(row, W.gridBefore, gridBefore + 1);
+ continue;
+ }
+ if (boundary > rowEnd && boundary <= rowEnd + gridAfter)
+ {
+ SetRowGridOmission(row, W.gridAfter, gridAfter + 1);
+ continue;
+ }
// A cell straddling the boundary extends rather than splits: inserting "inside"
// a horizontal merge widens it.
if (rowGrid.FirstOrDefault(c => c.Start < boundary && c.End > boundary) is { Tc: not null } straddle)
@@ -7917,7 +8033,7 @@ public EditResult InsertTableColumn(string cellAnchorId, Position pos)
var newTc = NewEmptyCellLike(refTc);
if (newWidth > 0) SetCellWidth(newTc, newWidth);
UnidHelper.AssignToSelfAndDescendants(newTc);
- newParas.Add(newTc.Element(W.p)!);
+ newCells.Add(newTc);
if (left.Tc is not null) left.Tc.AddAfterSelf(newTc);
else right.Tc!.AddBeforeSelf(newTc);
}
@@ -7926,7 +8042,8 @@ public EditResult InsertTableColumn(string cellAnchorId, Position pos)
return new EditResult
{
Success = true,
- Created = ResolveAnchorsForParagraphs(newParas),
+ Created = ResolveAnchorsForElements(newCells),
+ TableAnchors = CompleteTableMapping(before, tbl),
Patch = PatchFor(target!),
};
}
@@ -7946,12 +8063,11 @@ public EditResult DeleteTableRow(string cellAnchorId)
if (ResolveCell(cellAnchorId, out _, out _, out var tr, out var tbl, out var target) is { } err)
return err;
+ var before = CaptureTableMetadata(tbl!);
_history.RecordPreOp(TakeSnapshot());
try
{
- var index = AnchorIndex();
- var removed = CellParagraphAnchorsIn(tr!);
- if (tbl!.Elements(W.tr).Count() <= 1) { foreach (var a in CellParagraphAnchorsIn(tbl)) if (!removed.Contains(a)) removed.Add(a); tbl.Remove(); }
+ if (tbl!.Elements(W.tr).Count() <= 1) tbl.Remove();
else
{
if (tr!.ElementsAfterSelf(W.tr).FirstOrDefault() is { } next)
@@ -7962,7 +8078,14 @@ public EditResult DeleteTableRow(string cellAnchorId)
}
InvalidateProjectionCache();
- return new EditResult { Success = true, Removed = removed, Patch = PatchFor(target!) };
+ var mapping = CompleteTableMapping(before, tbl);
+ return new EditResult
+ {
+ Success = true,
+ Removed = InvalidatedCellAnchors(mapping),
+ TableAnchors = mapping,
+ Patch = PatchFor(target!),
+ };
}
catch (Exception ex)
{
@@ -7983,28 +8106,41 @@ public EditResult DeleteTableColumn(string cellAnchorId)
int doomed = RowGrid(tr!).First(g => g.Tc == tc).Start;
+ var before = CaptureTableMetadata(tbl!);
_history.RecordPreOp(TakeSnapshot());
try
{
- var index = AnchorIndex();
+ EnsureGridColumnsForMutation(tbl!);
var grid = tbl!.Element(W.tblGrid);
int colCount = GridColumnCount(tbl);
int lostWidth = GridColWidths(tbl) is { } widths && doomed < widths.Count ? widths[doomed] : 0;
- var removed = new List();
- if (colCount <= 1) { foreach (var a in CellParagraphAnchorsIn(tbl)) removed.Add(a); tbl.Remove(); }
+ if (colCount <= 1) tbl.Remove();
else
{
foreach (var row in tbl.Elements(W.tr).ToList())
{
- if (CellCovering(RowGrid(row), doomed) is not { } cell) continue;
+ var rowGrid = RowGrid(row);
+ int gridBefore = Internal.TableGridModel.GridBefore(row);
+ int rowEnd = rowGrid.Count == 0 ? gridBefore : rowGrid[^1].End;
+ int gridAfter = Internal.TableGridModel.GridAfter(row);
+ if (doomed < gridBefore)
+ {
+ SetRowGridOmission(row, W.gridBefore, gridBefore - 1);
+ continue;
+ }
+ if (doomed >= rowEnd && doomed < rowEnd + gridAfter)
+ {
+ SetRowGridOmission(row, W.gridAfter, gridAfter - 1);
+ continue;
+ }
+ if (CellCovering(rowGrid, doomed) is not { } cell) continue;
if (cell.Span > 1)
{
SetGridSpan(cell.Tc, cell.Span - 1);
BumpCellWidth(cell.Tc, -lostWidth);
continue;
}
- foreach (var a in CellParagraphAnchorsIn(cell.Tc)) removed.Add(a);
cell.Tc.Remove();
}
var cols = grid?.Elements(W.gridCol).ToList();
@@ -8012,7 +8148,14 @@ public EditResult DeleteTableColumn(string cellAnchorId)
}
InvalidateProjectionCache();
- return new EditResult { Success = true, Removed = removed, Patch = PatchFor(target!) };
+ var mapping = CompleteTableMapping(before, tbl);
+ return new EditResult
+ {
+ Success = true,
+ Removed = InvalidatedCellAnchors(mapping),
+ TableAnchors = mapping,
+ Patch = PatchFor(target!),
+ };
}
catch (Exception ex)
{
@@ -8022,28 +8165,14 @@ public EditResult DeleteTableColumn(string cellAnchorId)
}
}
- /// The cell-paragraph anchors under (a tc/tr/tbl), in document order.
- /// Resolved via so a table inside a header/footer story cannot
- /// report a body paragraph's anchor through a colliding content-addressed unid.
- private List CellParagraphAnchorsIn(XElement scope)
- {
- var result = new List();
- foreach (var para in scope.Descendants(W.p))
- {
- if (AnchorForElement(para) is { } a) result.Add(a);
- }
- return result;
- }
-
- // ─── Cell merge / unmerge (issue #340 Stage B), addressed by a cell-paragraph anchor ──
+ // ─── Cell merge / unmerge (issue #340 Stage B), addressed by a canonical tc anchor ─────
//
- // Anchor semantics: a merge never invents or hides anchors. Absorbed cells' paragraphs are
- // either moved into the surviving cell (Append, so their anchors live on) or removed
- // (reported in Removed). A vertical-merge continuation cell keeps exactly one empty w:p —
- // CT_Tc requires a block-level child — whose anchor stays addressable even though Word
- // renders nothing for it; writing to it is legal but invisible, so unmerge first. A table
- // carrying any merge projects as an opaque ```table``` block (the markdown projection's
- // existing rule), with its cell paragraphs still individually addressable.
+ // Anchor semantics: the surviving tc retains its canonical anchor; absorbed tc anchors are
+ // invalidated and reported in Removed/TableAnchors.Invalidated. Append moves absorbed content
+ // into the survivor, preserving those paragraph anchors. A vertical-merge continuation cell
+ // remains a canonical tc and keeps exactly one empty w:p because CT_Tc requires a block child.
+ // It stays addressable even though Word renders nothing for it; unmerge before writing visible
+ // content. The markdown projection still treats any table carrying a merge as opaque.
private static EditResult MergeFail(string message, string anchorId) =>
EditResult.Fail(EditErrorCode.InvalidTableMerge, message, anchorId);
@@ -8135,14 +8264,10 @@ public EditResult MergeCells(string cellAnchorId, int rowSpan, int colSpan,
"absorbed cells are not empty (use Content = Append to keep their content, or Discard to drop it)",
cellAnchorId);
+ var before = CaptureTableMetadata(tbl);
_history.RecordPreOp(TakeSnapshot());
try
{
- var doomed = absorbed.SelectMany(x => x.Descendants(W.p))
- .Select(p => (Para: p, Anchor: AnchorForElement(p)))
- .Where(x => x.Anchor is not null).ToList();
- var created = new List();
-
// Content first: everything the merge absorbs MOVES into the surviving cell. Detach
// before re-adding — XContainer.Add clones a still-parented node, which would leave
// the original behind (and duplicate its Unid).
@@ -8163,18 +8288,17 @@ public EditResult MergeCells(string cellAnchorId, int rowSpan, int colSpan,
if (width > 0) SetCellWidth(keep, width);
if (rowSpan == 1) continue;
SetVMerge(keep, restart: i == 0);
- if (i > 0 && EmptyCellBody(keep) is { } fresh) created.Add(fresh);
+ if (i > 0) _ = EmptyCellBody(keep);
}
InvalidateProjectionCache();
+ var mapping = CompleteTableMapping(before, tbl);
return new EditResult
{
Success = true,
- Created = ResolveAnchorsForParagraphs(created),
- // Whatever did not survive the merge — the absorbed cells' paragraphs under
- // Discard, and every empty filler paragraph under Append.
- Removed = doomed.Where(x => !x.Para.Ancestors().Contains(tbl)).Select(x => x.Anchor!.Value).ToList(),
+ Removed = InvalidatedCellAnchors(mapping),
Modified = new[] { AnchorForUnid(target!.Unid, target.PartUri) ?? target.Anchor },
+ TableAnchors = mapping,
Patch = PatchFor(target),
};
}
@@ -8220,6 +8344,7 @@ public EditResult UnmergeCells(string cellAnchorId)
r1++;
}
+ var before = CaptureTableMetadata(tbl);
_history.RecordPreOp(TakeSnapshot());
try
{
@@ -8242,7 +8367,7 @@ public EditResult UnmergeCells(string cellAnchorId)
UnidHelper.AssignToSelfAndDescendants(unit);
tail.AddAfterSelf(unit);
tail = unit;
- created.Add(unit.Element(W.p)!);
+ created.Add(unit);
}
}
@@ -8250,8 +8375,9 @@ public EditResult UnmergeCells(string cellAnchorId)
return new EditResult
{
Success = true,
- Created = ResolveAnchorsForParagraphs(created),
+ Created = ResolveAnchorsForElements(created),
Modified = new[] { AnchorForUnid(target!.Unid, target.PartUri) ?? target.Anchor },
+ TableAnchors = CompleteTableMapping(before, tbl),
Patch = PatchFor(target),
};
}
@@ -8263,7 +8389,7 @@ public EditResult UnmergeCells(string cellAnchorId)
}
}
- // ─── Table styling (issue #315 Stage A), addressed by a cell-paragraph anchor ─────────
+ // ─── Table styling (issue #315 Stage A), addressed by a canonical tc anchor ───────────
//
// Localized w:tblPr / w:trPr / w:tcPr writes over the grid model above.
@@ -8327,7 +8453,7 @@ private static XElement GetOrCreateTcPr(XElement tc)
}
/// The shared "styling applied" result: the target anchor in Modified + a patch.
- private EditResult TableStyleResult(AnchorTarget target)
+ private EditResult TableStyleResult(AnchorTarget target, TableAnchorMapping? tableAnchors = null)
{
InvalidateProjectionCache();
var updated = AnchorForUnid(target.Unid, target.PartUri) ?? target.Anchor;
@@ -8336,6 +8462,7 @@ private EditResult TableStyleResult(AnchorTarget target)
Success = true,
Modified = new[] { updated },
Patch = PatchFor(target),
+ TableAnchors = tableAnchors,
};
}
@@ -8351,6 +8478,7 @@ public EditResult SetColumnWidths(string cellAnchorId, IReadOnlyList widths
if (ResolveCell(cellAnchorId, out _, out _, out _, out var tbl, out var target) is { } err)
return err;
+ var before = Internal.TableGridModel.BuildMetadata(tbl!, AnchorForElement);
var grid = tbl!.Element(W.tblGrid);
int colCount = GridColumnCount(tbl);
if (widthsTwips is null || widthsTwips.Count != colCount || widthsTwips.Any(w => w <= 0))
@@ -8368,9 +8496,18 @@ public EditResult SetColumnWidths(string cellAnchorId, IReadOnlyList widths
if (pr is not null) pr.AddAfterSelf(grid);
else tbl.AddFirst(grid);
}
- grid.RemoveNodes();
- foreach (var w in widthsTwips)
- grid.Add(new XElement(W.gridCol, new XAttribute(W._w, w)));
+ var gridColumns = grid.Elements(W.gridCol).ToList();
+ for (int index = 0; index < widthsTwips.Count; index++)
+ {
+ if (index < gridColumns.Count)
+ {
+ gridColumns[index].SetAttributeValue(W._w, widthsTwips[index]);
+ continue;
+ }
+ var column = new XElement(W.gridCol, new XAttribute(W._w, widthsTwips[index]));
+ UnidHelper.AssignToSelfAndDescendants(column);
+ grid.Add(column);
+ }
// A merged cell is as wide as the grid columns it spans, so widths are summed over
// each cell's grid range rather than read off its position in the row.
@@ -8388,7 +8525,7 @@ public EditResult SetColumnWidths(string cellAnchorId, IReadOnlyList widths
new XElement(W.tblLayout, new XAttribute(W.type, "fixed")),
TblPrChildOrder);
- return TableStyleResult(target!);
+ return TableStyleResult(target!, CompleteTableMapping(before, tbl));
}
catch (Exception ex)
{
diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs
index 3210cc09..6f29ca18 100644
--- a/Docxodus/Internal/DocxSessionJson.cs
+++ b/Docxodus/Internal/DocxSessionJson.cs
@@ -421,6 +421,11 @@ public static string Serialize(EditResult r)
sb.Append(",\"created\":"); AppendAnchorArray(sb, r.Created);
sb.Append(",\"removed\":"); AppendAnchorArray(sb, r.Removed);
sb.Append(",\"modified\":"); AppendAnchorArray(sb, r.Modified);
+ if (r.TableAnchors is not null)
+ {
+ sb.Append(",\"tableAnchors\":");
+ AppendTableAnchorMapping(sb, r.TableAnchors);
+ }
if (r.AnnotationId is not null)
sb.Append(",\"annotationId\":").Append(JsonString(r.AnnotationId));
if (r.Patch is not null)
@@ -434,6 +439,127 @@ public static string Serialize(EditResult r)
return sb.ToString();
}
+ public static string SerializeTableMetadataResult(TableMetadataResult result)
+ {
+ var sb = new StringBuilder(1024);
+ sb.Append("{\"success\":").Append(result.Success ? "true" : "false");
+ if (result.Error is not null) { sb.Append(",\"error\":"); AppendEditError(sb, result.Error); }
+ if (result.Metadata is not null) { sb.Append(",\"metadata\":"); AppendTableMetadata(sb, result.Metadata); }
+ return sb.Append('}').ToString();
+ }
+
+ public static string SerializeTableCellResolutionResult(TableCellResolutionResult result)
+ {
+ var sb = new StringBuilder(512);
+ sb.Append("{\"success\":").Append(result.Success ? "true" : "false");
+ if (result.Error is not null) { sb.Append(",\"error\":"); AppendEditError(sb, result.Error); }
+ if (result.Cell is not null) { sb.Append(",\"cell\":"); AppendTableCellMetadata(sb, result.Cell); }
+ return sb.Append('}').ToString();
+ }
+
+ private static void AppendEditError(StringBuilder sb, EditError error)
+ {
+ sb.Append("{\"code\":\"").Append(EnumToSnake(error.Code)).Append('"')
+ .Append(",\"message\":").Append(JsonString(error.Message));
+ if (error.AnchorId is not null) sb.Append(",\"anchorId\":").Append(JsonString(error.AnchorId));
+ sb.Append('}');
+ }
+
+ private static void AppendTableMetadata(StringBuilder sb, TableMetadata metadata)
+ {
+ sb.Append("{\"anchor\":"); AppendAnchorValue(sb, metadata.Anchor);
+ sb.Append(",\"columns\":[");
+ for (int i = 0; i < metadata.Columns.Count; i++)
+ {
+ if (i > 0) sb.Append(',');
+ var column = metadata.Columns[i];
+ sb.Append("{\"anchor\":"); AppendAnchorValue(sb, column.Anchor);
+ sb.Append(",\"tableAnchorId\":").Append(JsonString(column.TableAnchorId))
+ .Append(",\"columnIndex\":").Append(column.ColumnIndex)
+ .Append(",\"widthTwips\":").Append(column.WidthTwips)
+ .Append(",\"isVirtual\":").Append(column.IsVirtual ? "true" : "false")
+ .Append(",\"cellAnchorIds\":"); AppendStringArray(sb, column.CellAnchorIds);
+ sb.Append('}');
+ }
+ sb.Append("],\"rows\":[");
+ for (int i = 0; i < metadata.Rows.Count; i++)
+ {
+ if (i > 0) sb.Append(',');
+ var row = metadata.Rows[i];
+ sb.Append("{\"anchor\":"); AppendAnchorValue(sb, row.Anchor);
+ sb.Append(",\"tableAnchorId\":").Append(JsonString(row.TableAnchorId))
+ .Append(",\"rowIndex\":").Append(row.RowIndex)
+ .Append(",\"gridBefore\":").Append(row.GridBefore)
+ .Append(",\"gridAfter\":").Append(row.GridAfter)
+ .Append(",\"cells\":[");
+ for (int c = 0; c < row.Cells.Count; c++)
+ {
+ if (c > 0) sb.Append(',');
+ AppendTableCellMetadata(sb, row.Cells[c]);
+ }
+ sb.Append("]}");
+ }
+ sb.Append("]}");
+ }
+
+ private static void AppendTableCellMetadata(StringBuilder sb, TableCellMetadata cell)
+ {
+ sb.Append("{\"anchor\":"); AppendAnchorValue(sb, cell.Anchor);
+ sb.Append(",\"tableAnchorId\":").Append(JsonString(cell.TableAnchorId))
+ .Append(",\"rowAnchorId\":").Append(JsonString(cell.RowAnchorId))
+ .Append(",\"rowIndex\":").Append(cell.RowIndex)
+ .Append(",\"columnIndex\":").Append(cell.ColumnIndex)
+ .Append(",\"rowSpan\":").Append(cell.RowSpan)
+ .Append(",\"columnSpan\":").Append(cell.ColumnSpan)
+ .Append(",\"verticalMerge\":").Append(JsonString(cell.VerticalMerge.ToString().ToLowerInvariant()))
+ .Append(",\"paragraphAnchors\":"); AppendAnchorArray(sb, cell.ParagraphAnchors);
+ sb.Append('}');
+ }
+
+ private static void AppendTableAnchorMapping(StringBuilder sb, TableAnchorMapping mapping)
+ {
+ sb.Append("{\"retained\":[");
+ for (int i = 0; i < mapping.Retained.Count; i++)
+ {
+ if (i > 0) sb.Append(',');
+ sb.Append("{\"before\":"); AppendTableAnchorLocation(sb, mapping.Retained[i].Before);
+ sb.Append(",\"after\":"); AppendTableAnchorLocation(sb, mapping.Retained[i].After);
+ sb.Append('}');
+ }
+ sb.Append("],\"added\":[");
+ for (int i = 0; i < mapping.Added.Count; i++)
+ {
+ if (i > 0) sb.Append(',');
+ AppendTableAnchorLocation(sb, mapping.Added[i]);
+ }
+ sb.Append("],\"invalidated\":[");
+ for (int i = 0; i < mapping.Invalidated.Count; i++)
+ {
+ if (i > 0) sb.Append(',');
+ AppendTableAnchorLocation(sb, mapping.Invalidated[i]);
+ }
+ sb.Append("]}");
+ }
+
+ private static void AppendTableAnchorLocation(StringBuilder sb, TableAnchorLocation location)
+ {
+ sb.Append("{\"anchor\":"); AppendAnchorValue(sb, location.Anchor);
+ sb.Append(",\"entityKind\":").Append(JsonString(location.EntityKind.ToString().ToLowerInvariant()));
+ if (location.RowIndex is not null) sb.Append(",\"rowIndex\":").Append(location.RowIndex.Value);
+ if (location.ColumnIndex is not null) sb.Append(",\"columnIndex\":").Append(location.ColumnIndex.Value);
+ if (location.RowSpan is not null) sb.Append(",\"rowSpan\":").Append(location.RowSpan.Value);
+ if (location.ColumnSpan is not null) sb.Append(",\"columnSpan\":").Append(location.ColumnSpan.Value);
+ if (location.IsVirtual) sb.Append(",\"isVirtual\":true");
+ sb.Append('}');
+ }
+
+ private static void AppendAnchorValue(StringBuilder sb, Anchor anchor) =>
+ sb.Append("{\"id\":").Append(JsonString(anchor.Id))
+ .Append(",\"kind\":").Append(JsonString(anchor.Kind))
+ .Append(",\"scope\":").Append(JsonString(anchor.Scope))
+ .Append(",\"unid\":").Append(JsonString(anchor.Unid))
+ .Append('}');
+
public static string SerializeEditResults(IReadOnlyList results)
{
var sb = new StringBuilder(256);
diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs
index c35d1b99..de09063f 100644
--- a/Docxodus/Internal/DocxSessionOps.cs
+++ b/Docxodus/Internal/DocxSessionOps.cs
@@ -386,6 +386,19 @@ public static string ClearListStartOverride(int handle, string anchorId) =>
// ─── Tier D: tables ─────────────────────────────────────────────────
+ public static string GetTableMetadata(int handle, string tableAnchorId) =>
+ DocxSessionJson.SerializeTableMetadataResult(
+ SessionRegistry.Get(handle).GetTableMetadata(tableAnchorId));
+
+ public static string ResolveTableCellAnchor(int handle, string cellAnchorId) =>
+ DocxSessionJson.SerializeTableCellResolutionResult(
+ SessionRegistry.Get(handle).ResolveTableCellAnchor(cellAnchorId));
+
+ public static string ResolveTableCellCoordinate(
+ int handle, string tableAnchorId, int rowIndex, int columnIndex) =>
+ DocxSessionJson.SerializeTableCellResolutionResult(
+ SessionRegistry.Get(handle).ResolveTableCellCoordinate(tableAnchorId, rowIndex, columnIndex));
+
public static string ReplaceCellContent(int handle, string cellAnchorId, string markdown) =>
DocxSessionJson.Serialize(SessionRegistry.Get(handle).ReplaceCellContent(cellAnchorId, markdown));
diff --git a/Docxodus/Internal/TableGridModel.cs b/Docxodus/Internal/TableGridModel.cs
new file mode 100644
index 00000000..7b1afe32
--- /dev/null
+++ b/Docxodus/Internal/TableGridModel.cs
@@ -0,0 +1,243 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#nullable enable
+
+using System.Xml.Linq;
+
+namespace Docxodus.Internal;
+
+/// A physical cell's geometry in the Word table grid. is exclusive.
+internal readonly record struct TableGridCell(XElement Cell, int Start, int Span)
+{
+ internal XElement Tc => Cell;
+ internal int End => Start + Span;
+}
+
+///
+/// Single owner of Word table-grid geometry and canonical structural metadata. Mutation code,
+/// live-session resolution, and byte-oriented structure analysis all use this model so omitted
+/// columns, spans, and vertical merges cannot acquire competing interpretations.
+///
+internal static class TableGridModel
+{
+ private static int ValOf(XElement? e) => Math.Max(0, (int?)e?.Attribute(W.val) ?? 0);
+
+ internal static int GridBefore(XElement row) => ValOf(row.Element(W.trPr)?.Element(W.gridBefore));
+
+ internal static int GridAfter(XElement row) => ValOf(row.Element(W.trPr)?.Element(W.gridAfter));
+
+ internal static List RowGrid(XElement row)
+ {
+ int column = GridBefore(row);
+ var cells = new List();
+ foreach (var cell in row.Elements(W.tc))
+ {
+ int span = Math.Max(1, (int?)cell.Element(W.tcPr)?.Element(W.gridSpan)?.Attribute(W.val) ?? 1);
+ cells.Add(new TableGridCell(cell, column, span));
+ column += span;
+ }
+ return cells;
+ }
+
+ internal static TableGridCell? CellCovering(IEnumerable grid, int column)
+ {
+ foreach (var cell in grid)
+ if (column >= cell.Start && column < cell.End)
+ return cell;
+ return null;
+ }
+
+ internal static XElement? AlignedCell(XElement row, TableGridCell shape)
+ {
+ foreach (var cell in RowGrid(row))
+ if (cell.Start == shape.Start && cell.End == shape.End)
+ return cell.Cell;
+ return null;
+ }
+
+ internal static TableVerticalMergeRole VerticalMergeRole(XElement cell)
+ {
+ var merge = cell.Element(W.tcPr)?.Element(W.vMerge);
+ if (merge is null) return TableVerticalMergeRole.None;
+ return string.Equals((string?)merge.Attribute(W.val), "restart", StringComparison.OrdinalIgnoreCase)
+ ? TableVerticalMergeRole.Restart
+ : TableVerticalMergeRole.Continue;
+ }
+
+ internal static int GridColumnCount(XElement table)
+ {
+ int explicitCount = table.Element(W.tblGrid)?.Elements(W.gridCol).Count() ?? 0;
+ int rowCount = table.Elements(W.tr)
+ .Select(row =>
+ {
+ var grid = RowGrid(row);
+ int end = grid.Count == 0 ? GridBefore(row) : grid[^1].End;
+ return end + GridAfter(row);
+ })
+ .DefaultIfEmpty(0)
+ .Max();
+ return Math.Max(explicitCount, rowCount);
+ }
+
+ internal static TableMetadata BuildMetadata(XElement table, Func anchorForElement)
+ {
+ var tableAnchor = anchorForElement(table)
+ ?? throw new InvalidOperationException("table has no canonical anchor");
+ var rowElements = table.Elements(W.tr).ToList();
+ var rows = new List(rowElements.Count);
+
+ for (int rowIndex = 0; rowIndex < rowElements.Count; rowIndex++)
+ {
+ var row = rowElements[rowIndex];
+ var rowAnchor = anchorForElement(row)
+ ?? throw new InvalidOperationException("table row has no canonical anchor");
+ var cells = new List();
+ foreach (var geometry in RowGrid(row))
+ {
+ var cellAnchor = anchorForElement(geometry.Cell)
+ ?? throw new InvalidOperationException("table cell has no canonical anchor");
+ var role = VerticalMergeRole(geometry.Cell);
+ int rowSpan = role == TableVerticalMergeRole.Continue
+ ? 0
+ : role == TableVerticalMergeRole.Restart
+ ? VerticalSpan(rowElements, rowIndex, geometry)
+ : 1;
+ cells.Add(new TableCellMetadata
+ {
+ Anchor = cellAnchor,
+ TableAnchorId = tableAnchor.Id,
+ RowAnchorId = rowAnchor.Id,
+ RowIndex = rowIndex,
+ ColumnIndex = geometry.Start,
+ RowSpan = rowSpan,
+ ColumnSpan = geometry.Span,
+ VerticalMerge = role,
+ ParagraphAnchors = geometry.Cell.Elements(W.p)
+ .Select(anchorForElement)
+ .Where(anchor => anchor is not null)
+ .Select(anchor => anchor!.Value)
+ .ToList(),
+ });
+ }
+ rows.Add(new TableRowMetadata
+ {
+ Anchor = rowAnchor,
+ TableAnchorId = tableAnchor.Id,
+ RowIndex = rowIndex,
+ GridBefore = GridBefore(row),
+ GridAfter = GridAfter(row),
+ Cells = cells,
+ });
+ }
+
+ int columnCount = GridColumnCount(table);
+ var gridColumns = table.Element(W.tblGrid)?.Elements(W.gridCol).ToList() ?? new List();
+ var columns = new List(columnCount);
+ for (int columnIndex = 0; columnIndex < columnCount; columnIndex++)
+ {
+ Anchor columnAnchor;
+ int width = 0;
+ if (columnIndex < gridColumns.Count)
+ {
+ columnAnchor = anchorForElement(gridColumns[columnIndex])
+ ?? throw new InvalidOperationException("table grid column has no canonical anchor");
+ width = Math.Max(0, (int?)gridColumns[columnIndex].Attribute(W._w) ?? 0);
+ }
+ else
+ {
+ // OOXML normally has w:tblGrid. Keep malformed/legacy tables inspectable without
+ // mutating a read call; the next structural mutation materializes real gridCols.
+ string unid = UnidHelper.ShortHash(
+ $"{tableAnchor.Unid}:virtual-col:{columnIndex}", hexChars: 32);
+ columnAnchor = new Anchor($"col:{tableAnchor.Scope}:{unid}", "col", tableAnchor.Scope, unid);
+ }
+ columns.Add(new TableColumnMetadata
+ {
+ Anchor = columnAnchor,
+ TableAnchorId = tableAnchor.Id,
+ ColumnIndex = columnIndex,
+ WidthTwips = width,
+ IsVirtual = columnIndex >= gridColumns.Count,
+ CellAnchorIds = rows
+ .SelectMany(row => row.Cells)
+ .Where(cell => columnIndex >= cell.ColumnIndex
+ && columnIndex < cell.ColumnIndex + cell.ColumnSpan)
+ .Select(cell => cell.Anchor.Id)
+ .ToList(),
+ });
+ }
+
+ return new TableMetadata { Anchor = tableAnchor, Columns = columns, Rows = rows };
+ }
+
+ internal static TableCellMetadata? CellAt(TableMetadata table, int rowIndex, int columnIndex)
+ {
+ if (rowIndex < 0 || rowIndex >= table.Rows.Count || columnIndex < 0) return null;
+ return table.Rows[rowIndex].Cells.FirstOrDefault(cell =>
+ columnIndex >= cell.ColumnIndex && columnIndex < cell.ColumnIndex + cell.ColumnSpan);
+ }
+
+ internal static IReadOnlyList Locations(TableMetadata metadata)
+ {
+ var locations = new List
+ {
+ new() { Anchor = metadata.Anchor, EntityKind = TableAnchorEntityKind.Table },
+ };
+ locations.AddRange(metadata.Columns.Select(column => new TableAnchorLocation
+ {
+ Anchor = column.Anchor,
+ EntityKind = TableAnchorEntityKind.Column,
+ ColumnIndex = column.ColumnIndex,
+ IsVirtual = column.IsVirtual,
+ }));
+ foreach (var row in metadata.Rows)
+ {
+ locations.Add(new TableAnchorLocation
+ {
+ Anchor = row.Anchor,
+ EntityKind = TableAnchorEntityKind.Row,
+ RowIndex = row.RowIndex,
+ });
+ locations.AddRange(row.Cells.Select(cell => new TableAnchorLocation
+ {
+ Anchor = cell.Anchor,
+ EntityKind = TableAnchorEntityKind.Cell,
+ RowIndex = cell.RowIndex,
+ ColumnIndex = cell.ColumnIndex,
+ RowSpan = cell.RowSpan,
+ ColumnSpan = cell.ColumnSpan,
+ }));
+ }
+ return locations;
+ }
+
+ internal static TableAnchorMapping Map(TableMetadata? before, TableMetadata? after)
+ {
+ var oldLocations = before is null ? Array.Empty() : Locations(before);
+ var newLocations = after is null ? Array.Empty() : Locations(after);
+ var oldById = oldLocations.ToDictionary(location => location.Anchor.Id, StringComparer.Ordinal);
+ var newById = newLocations.ToDictionary(location => location.Anchor.Id, StringComparer.Ordinal);
+ return new TableAnchorMapping
+ {
+ Retained = oldLocations
+ .Where(location => newById.ContainsKey(location.Anchor.Id))
+ .Select(location => new RetainedTableAnchor(location, newById[location.Anchor.Id]))
+ .ToList(),
+ Added = newLocations.Where(location => !oldById.ContainsKey(location.Anchor.Id)).ToList(),
+ Invalidated = oldLocations.Where(location => !newById.ContainsKey(location.Anchor.Id)).ToList(),
+ };
+ }
+
+ private static int VerticalSpan(IReadOnlyList rows, int rowIndex, TableGridCell restart)
+ {
+ int span = 1;
+ for (int index = rowIndex + 1; index < rows.Count; index++)
+ {
+ var aligned = AlignedCell(rows[index], restart);
+ if (aligned is null || VerticalMergeRole(aligned) != TableVerticalMergeRole.Continue) break;
+ span++;
+ }
+ return span;
+ }
+}
diff --git a/Docxodus/Ir/IrAnchor.cs b/Docxodus/Ir/IrAnchor.cs
index 59bd3417..212fc347 100644
--- a/Docxodus/Ir/IrAnchor.cs
+++ b/Docxodus/Ir/IrAnchor.cs
@@ -8,7 +8,8 @@ namespace Docxodus.Ir;
/// The kind component of an IR anchor. The token strings (see )
/// are the markdown-projection anchor kinds produced by WmlToMarkdownConverter.KindFor,
/// extended with /// for
-/// IR-internal use.
+/// IR-internal use. is projection/index metadata for a table's
+/// w:gridCol; it is part of the closed anchor vocabulary even though it is not an IR block.
///
internal enum IrAnchorKind
{
@@ -18,6 +19,7 @@ internal enum IrAnchorKind
Tbl,
Tr,
Tc,
+ Col,
Cmt,
Fn,
En,
@@ -45,6 +47,7 @@ internal readonly record struct IrAnchor(IrAnchorKind Kind, string Scope, string
IrAnchorKind.Tbl => "tbl",
IrAnchorKind.Tr => "tr",
IrAnchorKind.Tc => "tc",
+ IrAnchorKind.Col => "col",
IrAnchorKind.Cmt => "cmt",
IrAnchorKind.Fn => "fn",
IrAnchorKind.En => "en",
@@ -65,6 +68,7 @@ internal readonly record struct IrAnchor(IrAnchorKind Kind, string Scope, string
"tbl" => IrAnchorKind.Tbl,
"tr" => IrAnchorKind.Tr,
"tc" => IrAnchorKind.Tc,
+ "col" => IrAnchorKind.Col,
"cmt" => IrAnchorKind.Cmt,
"fn" => IrAnchorKind.Fn,
"en" => IrAnchorKind.En,
diff --git a/Docxodus/TableAddressing.cs b/Docxodus/TableAddressing.cs
new file mode 100644
index 00000000..11c3c318
--- /dev/null
+++ b/Docxodus/TableAddressing.cs
@@ -0,0 +1,120 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#nullable enable
+
+namespace Docxodus;
+
+/// The role of a physical w:tc in a vertical-merge run.
+public enum TableVerticalMergeRole
+{
+ None,
+ Restart,
+ Continue,
+}
+
+/// The structural table identity represented by a table-anchor mapping entry.
+public enum TableAnchorEntityKind
+{
+ Table,
+ Row,
+ Column,
+ Cell,
+}
+
+/// Metadata for one physical w:tc, addressed by its canonical tc anchor.
+public sealed record TableCellMetadata
+{
+ required public Anchor Anchor { get; init; }
+ required public string TableAnchorId { get; init; }
+ required public string RowAnchorId { get; init; }
+ public int RowIndex { get; init; }
+ public int ColumnIndex { get; init; }
+ public int RowSpan { get; init; } = 1;
+ public int ColumnSpan { get; init; } = 1;
+ public TableVerticalMergeRole VerticalMerge { get; init; }
+
+ /// Direct cell paragraphs only. Paragraphs in nested tables belong to their own cells.
+ public IReadOnlyList ParagraphAnchors { get; init; } = Array.Empty();
+}
+
+/// Metadata for one physical w:tr.
+public sealed record TableRowMetadata
+{
+ required public Anchor Anchor { get; init; }
+ required public string TableAnchorId { get; init; }
+ public int RowIndex { get; init; }
+ public int GridBefore { get; init; }
+ public int GridAfter { get; init; }
+ public IReadOnlyList Cells { get; init; } = Array.Empty();
+}
+
+/// Metadata for one table grid column, identified by its w:gridCol anchor.
+public sealed record TableColumnMetadata
+{
+ required public Anchor Anchor { get; init; }
+ required public string TableAnchorId { get; init; }
+ public int ColumnIndex { get; init; }
+ public int WidthTwips { get; init; }
+
+ /// True only when an absent/underspecified w:tblGrid required a read-only
+ /// coordinate identity. A shape/width transaction materializes a real gridCol anchor and
+ /// reports this virtual identity invalidated.
+ public bool IsVirtual { get; init; }
+
+ /// Physical cells covering this grid column, top-to-bottom.
+ public IReadOnlyList CellAnchorIds { get; init; } = Array.Empty();
+}
+
+///
+/// The canonical table-addressing view of one w:tbl. Table, row, column, and cell
+/// identities are explicit; cells use zero-based Word table-grid coordinates.
+///
+public sealed record TableMetadata
+{
+ required public Anchor Anchor { get; init; }
+ public IReadOnlyList Columns { get; init; } = Array.Empty();
+ public IReadOnlyList Rows { get; init; } = Array.Empty();
+}
+
+/// Result of resolving a table anchor to its metadata.
+public sealed record TableMetadataResult
+{
+ public bool Success { get; init; }
+ public EditError? Error { get; init; }
+ public TableMetadata? Metadata { get; init; }
+}
+
+/// Result of either direction of canonical cell-anchor/coordinate resolution.
+public sealed record TableCellResolutionResult
+{
+ public bool Success { get; init; }
+ public EditError? Error { get; init; }
+ public TableCellMetadata? Cell { get; init; }
+}
+
+/// A structural table anchor plus its location at one point in a mutation.
+public sealed record TableAnchorLocation
+{
+ required public Anchor Anchor { get; init; }
+ public TableAnchorEntityKind EntityKind { get; init; }
+ public int? RowIndex { get; init; }
+ public int? ColumnIndex { get; init; }
+ public int? RowSpan { get; init; }
+ public int? ColumnSpan { get; init; }
+ public bool IsVirtual { get; init; }
+}
+
+/// A stable structural identity retained across a table mutation.
+public sealed record RetainedTableAnchor(TableAnchorLocation Before, TableAnchorLocation After);
+
+///
+/// Deterministic structural identity map for a table mutation. Retained entries are ordered by
+/// their old location; added entries by their new location; invalidated entries by their old location.
+///
+public sealed record TableAnchorMapping
+{
+ public IReadOnlyList Retained { get; init; } = Array.Empty();
+ public IReadOnlyList Added { get; init; } = Array.Empty();
+ public IReadOnlyList Invalidated { get; init; } = Array.Empty();
+}
diff --git a/Docxodus/WmlToHtmlConverter.cs b/Docxodus/WmlToHtmlConverter.cs
index 41216b59..cc86719b 100644
--- a/Docxodus/WmlToHtmlConverter.cs
+++ b/Docxodus/WmlToHtmlConverter.cs
@@ -5103,6 +5103,9 @@ private static object ProcessTableCell(WordprocessingDocument wordDoc, WmlToHtml
var cell = new XElement(Xhtml.td,
rowSpan,
colSpan,
+ settings.StampAnchors && (string)element.Attribute(PtOpenXml.Unid) != null
+ ? new XAttribute("data-anchor", (string)element.Attribute(PtOpenXml.Unid))
+ : null,
CreateBorderDivs(wordDoc, settings, element.Elements()));
cell.AddAnnotation(style);
diff --git a/Docxodus/WmlToMarkdownConverter.cs b/Docxodus/WmlToMarkdownConverter.cs
index 112f1928..1525d22e 100644
--- a/Docxodus/WmlToMarkdownConverter.cs
+++ b/Docxodus/WmlToMarkdownConverter.cs
@@ -569,6 +569,7 @@ private static (IReadOnlyDictionary Index, List
if (n == W.tbl) return "tbl";
if (n == W.tr) return "tr";
if (n == W.tc) return "tc";
+ if (n == W.gridCol) return "col";
if (n == W.sectPr) return "sec";
if (n == W.footnote) return "fn";
if (n == W.endnote) return "en";
diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md
index 1c74cbf4..95e33b06 100644
--- a/docs/architecture/docx_agent_server.md
+++ b/docs/architecture/docx_agent_server.md
@@ -411,23 +411,21 @@ why this is "apply-then-undo" rather than a true no-op dry run.
### `docxodus_table` — tables
`insert`, `insert_row`, `insert_column`, `delete_row`, `delete_column`, `replace_cell_content`,
-plus the post-insert styling actions (issue #315 Stage A): `set_column_widths` (`widths`, one
+the read actions `get_metadata`, `resolve_cell_anchor`, `resolve_cell_coordinate`, plus the
+post-insert styling actions (issue #315 Stage A): `set_column_widths` (`widths`, one
positive twip value per column), `set_borders` (`borderScope` `all`/`outside`/`inside`,
`borderStyle` — `none` removes the targeted edges — `borderSize`, `borderColor`), `set_shading`
(`fill` hex/`auto`, omit to clear; `shadingScope` `cell`/`row` — row is header-row banding), and
-`set_repeat_header_row` (`repeat`, default true). All four take the same `"p"`-kind
-cell-paragraph `cellAnchorId` the row/column ops take.
-
-**Anchor-kind trap worth calling out explicitly** (discovered writing the test suite for this
-tool, `MCP060`): `ReplaceCellContent` requires the cell's own `"tc"`-kind anchor
-(`target.Anchor.Kind != "tc"` is a hard `AnchorWrongKind` failure in `DocxSession.cs`), while
-`InsertTableRow`/`InsertTableColumn`/`DeleteTableRow`/`DeleteTableColumn` require a `"p"`-kind
-anchor whose ancestor is a `w:tc` (`ResolveCell`'s `p.Ancestors(W.tc)` check) — a bare `"tc"`
-anchor fails those with the same error code. `InsertTable`'s `created` list only contains the
-`"p"` (cell-paragraph) anchors; a `"tc"` anchor has to come from `docxodus_search` with `mode:
-kind, query: "tc"`. This is a pre-existing Docxodus API asymmetry, not something this server
-smooths over — documented here (and in the tool's own schema) so an agent doesn't have to
-rediscover it by trial and error.
+`set_repeat_header_row` (`repeat`, default true), `set_row_options`, `merge_cells`, and
+`unmerge_cells`.
+
+The input schema does not overload anchor fields: `insert` uses `anchorId` for the neighboring
+block, metadata/coordinate reads use `tableAnchorId` (`tbl`), and every cell operation uses
+`cellAnchorId` (`tc`). `insert` returns canonical `tc` anchors in `created`; `get_metadata`
+enumerates the explicit `tbl`/`tr`/`col`/`tc` identities and coordinates. A legacy paragraph
+inside a cell is still translated during the compatibility window, but new tool calls should use
+only the canonical fields and identities. Shape mutations include a deterministic `tableAnchors`
+retained/added/invalidated mapping in their result.
## Inline preview (MCP Apps / ChatGPT Apps)
@@ -504,9 +502,6 @@ never claiming a capability it doesn't have:
depth like any other edit sequence, and a crash between "apply" and "undo" would leave the
session mutated — acceptable for a local, single-process tool server, worth knowing if this
surface is ever exposed somewhere more failure-sensitive.
-- **`docxodus_table`'s `cellAnchorId` is two different anchor kinds depending on the action** —
- see the tool reference above. Not fixable at this layer without hiding a real asymmetry in the
- underlying API; documented instead.
## Testing
diff --git a/docs/architecture/docx_mutation_api.md b/docs/architecture/docx_mutation_api.md
index 96aab0ed..fd08bb2e 100644
--- a/docs/architecture/docx_mutation_api.md
+++ b/docs/architecture/docx_mutation_api.md
@@ -71,7 +71,7 @@ When you pass markdown into `ReplaceText`, `InsertParagraph`, or `ReplaceCellCon
This is symmetric by design: anything the projector can emit, the parser can accept, so an agent can read markdown out and write markdown in. Anything outside the subset is rejected with a typed error that names either the v1 op to use instead or the v2 op planned to address it. The full table of accepted and rejected syntax is in the spec — the practical shorthand:
- If you can see it in the projection output, you can write it in a payload.
-- If you need a table → `InsertTable(anchor, Position, rows, cols, TableInsertOptions?)` (borderless, row-major `CellContents`, `CellAlignment`, per-column `ColumnWidths`), then edit cells with `ReplaceCellContent` or address each cell-paragraph anchor; reshape with `InsertTableRow`/`InsertTableColumn`/`DeleteTableRow`/`DeleteTableColumn` (by a cell-paragraph anchor; v1 assumes a rectangular grid, no `w:gridSpan`). Style it after insert (issue #315 Stage A, same cell-paragraph addressing): `SetColumnWidths(cellAnchor, widthsTwips)` retunes `w:tblGrid` + every `w:tcW` and pins fixed layout; `SetTableBorders(cellAnchor, TableBorderSpec?)` writes `w:tblPr/w:tblBorders` for the spec's scope (`All`/`Outside`/`Inside`) only, style `"none"` removing those edges; `SetCellShading(cellAnchor, fill, TableShadingScope)` writes `w:tcPr/w:shd` (`val="clear"`) on the cell or its whole row (header-row banding; null fill clears); `SetRepeatHeaderRow(cellAnchor, bool)` toggles `w:trPr/w:tblHeader` (Word honors it on a run of rows starting at row 1). Bad widths/fill/size → `InvalidTableStyling`. Merge cells with `MergeCells(cellAnchor, rowSpan, colSpan, TableMergeOptions?)` / `UnmergeCells(cellAnchor)` (issue #340 Stage B — see [the grid model](#table-cell-merge-the-grid-model) below).
+- If you need a table → `InsertTable(anchor, Position, rows, cols, TableInsertOptions?)` (borderless, row-major `CellContents`, `CellAlignment`, per-column `ColumnWidths`). It returns canonical `tc` anchors. Discover the whole shape with `GetTableMetadata(tblAnchor)` or translate in either direction with `ResolveTableCellAnchor(tcAnchor)` / `ResolveTableCellCoordinate(tblAnchor, row, column)`. Every cell-content, shape, merge, and styling operation takes that same canonical `tc` anchor: `ReplaceCellContent`, `InsertTableRow`/`InsertTableColumn`/`DeleteTableRow`/`DeleteTableColumn`, `SetColumnWidths`, `SetTableBorders`, `SetCellShading`, `SetRepeatHeaderRow`, `SetTableRowOptions`, `MergeCells`, and `UnmergeCells`. See [Canonical table addressing](#canonical-table-addressing) and [the grid model](#table-cell-merge-the-grid-model).
- If you need a footnote or endnote → `InsertFootnote(anchor, offset, markdown)` / `InsertEndnote(...)`; a `[^label]` reference in a *payload* stays rejected, because a label can't name a note the payload doesn't define.
- If you need a comment → `AddComment(anchor, span?, author, markdown, initials?, date?)`, or target a tracked change from `ListRevisions()` with `AddCommentToRevision(revisionId, author, markdown, initials?, date?)`; reply with `AddCommentReply(parentCmtAnchor, author, markdown, initials?, date?)`, and resolve/reopen with `SetCommentResolved(cmtAnchor, resolved)`. A `{#cmt:...}` token in a *payload* stays rejected, because inline comment tokens are projection output only (see the Comments section).
- If you need an image → still a v2 op, currently rejected with a clear error.
@@ -105,6 +105,7 @@ Each mutation reports which anchors it created, removed, or modified. This table
| `SetListStartOverride(li, value)` | — | — | the anchored item + every following member of its numbering instance (all repointed to a dedicated `w:num`) | the anchored item |
| `ClearListStartOverride(li)` | — | — | every member of the item's numbering instance (all repointed together) | the anchored item |
| `ReplaceCellContent(tc, md)` | — | descendant inline anchors (rare) | `tc` | `tc` |
+| table row/column CRUD, merge/unmerge | new `tr`/`tc` identities where applicable | invalidated `tr`/`tc` identities | addressed `tc` where applicable | enclosing `tbl`; full structural map in `TableAnchors` |
| `SetHeaderText(p, kind, md)` / `SetFooterText(...)` | the new header/footer paragraph anchors (scope `hdr{N}`/`ftr{N}`) | — (reused-part old paragraphs cease to exist; not separately reported in v1) | — | whole document |
| `InsertPageNumberField(p, field?)` | — | — | `p` (the paragraph the field is appended to) | `p` |
| `InsertFootnote(p, offset, md)` / `InsertEndnote(...)` | the note definition (`fn`/`en`) + its paragraphs (scope `fn`/`en`) | — | `p` (the citing paragraph) | whole document |
@@ -1393,6 +1394,54 @@ const result = session.raw.replaceXml(anchor, modified);
Starting from a known-valid XML fragment and modifying it locally is dramatically less error-prone than constructing OOXML from scratch — namespace declarations, attribute ordering, and child-element validity are all preserved from the original.
+## Canonical table addressing
+
+The canonical address for every cell operation is the physical `w:tc` anchor (`tc:{scope}:{unid}`).
+`InsertTable` and cell-creating mutations return `tc` anchors in `Created`; callers do not need to
+search for a paragraph merely to identify its cell. `GetTableMetadata(tblAnchor)` returns the table
+anchor and explicit ordered row, column, and physical-cell metadata. A cell records its row index,
+starting grid column, horizontal/vertical spans, vertical-merge role, owning table/row identities,
+and only its **direct** paragraph anchors. Paragraphs in a nested table belong solely to that nested
+table's cells.
+
+Resolution is deliberately bidirectional:
+
+- `ResolveTableCellAnchor(tcAnchor)` returns the cell's current coordinate and spans.
+- `ResolveTableCellCoordinate(tblAnchor, rowIndex, columnIndex)` returns the physical cell covering
+ that Word-grid coordinate, including a horizontally spanned cell. A coordinate in a
+ `gridBefore`/`gridAfter` gap returns `AnchorNotFound`; it never guesses a neighboring cell.
+
+Compatibility is narrow and deterministic. A legacy `p`/`h`/`li` anchor whose nearest ancestor is
+a cell is translated to that nearest `tc`, so old callers have a migration window and nested tables
+cannot retarget an outer cell. Passing `tbl`, `tr`, or an unrelated paragraph to a cell operation
+returns `TableAnchorMigrationRequired` with instructions to call `GetTableMetadata` or coordinate
+resolution. New code should never cache or manufacture cell-paragraph addressing.
+
+Every table-shape mutation populates `EditResult.TableAnchors`:
+
+- `Retained` pairs each stable identity's before/after grid location;
+- `Added` lists new table/row/column/cell identities at their new locations;
+- `Invalidated` lists identities that no longer resolve at their former locations.
+
+The lists are deterministic (old-location order for retained/invalidated, new-location order for
+added), so clients can update a cached coordinate model without matching by array position.
+`Created`/`Removed` remain the concise mutation result and use canonical `tc` identities;
+`TableAnchors` is the complete structural account.
+
+Real columns are the `col` anchors of `w:tblGrid/w:gridCol` and retain their Unids when widths or
+neighboring columns change. A table with a missing or underspecified `tblGrid` is not mutated by a
+read: metadata derives deterministic virtual `col` identities (`IsVirtual = true`). The first
+column/width transaction materializes real `gridCol` elements inside that transaction and reports
+the virtual columns invalidated and the real columns added. Persist identities across a close/reopen
+checkpoint with `Save(persistAnchorIds: true)` (or the equivalent session setting); a normal clean
+save intentionally strips all Unid bookkeeping, including table identities.
+
+`DocumentStructure` retains its path-based `Id` as a compatibility/display locator and adds
+`AnchorId` for addressable table/row/cell elements. `TableColumnInfo` similarly carries both legacy
+path ids and canonical column/table/cell anchors plus `IsVirtual`. Its coordinates use this same
+grid model, so `gridBefore`/`gridAfter`, `gridSpan`, and actual `vMerge` runs cannot diverge from the
+live session APIs.
+
## Table cell merge: the grid model
`MergeCells`/`UnmergeCells` (issue #340 Stage B) and the row/column CRUD around them share one
@@ -1428,24 +1477,26 @@ and give each its `w:tblGrid` width. Addressing a *continuation* cell unmerges t
op walks up to the restart and back down through every column-aligned continuation. A cell with no
merge markup is `InvalidTableMerge`, not a silent no-op.
-**Anchor semantics.** A merge never invents or hides anchors:
+**Anchor semantics.** Content blocks keep their own identities when moved, while physical cell
+shells follow the canonical structural lifecycle:
-| Cell | What happens to its paragraphs |
+| Cell | What happens |
|---|---|
-| The surviving (lead) cell | Untouched; its anchor is returned in `Modified` |
-| An absorbed cell, `Content = Append` (default) | Non-empty blocks are **moved** into the lead cell — same elements, same unids, so their anchors survive and nothing appears in `Removed` |
-| An absorbed cell, `Content = Discard` | Removed; their anchors come back in `Removed` |
+| The surviving (lead) cell | Its `tc` identity is retained and returned in `Modified` |
+| An absorbed cell, `Content = Append` (default) | Non-empty blocks are **moved** into the lead cell with their Unids; the absorbed `tc` identity is invalidated and returned in `Removed`/`TableAnchors.Invalidated` |
+| An absorbed cell, `Content = Discard` | Its content is dropped and its `tc` identity is invalidated |
| An absorbed cell, `Content = Reject` | Nothing happens — a non-empty absorbed cell fails the whole op |
-| A vertical-merge continuation | Reduced to exactly one empty `w:p` (CT_Tc requires a block child). A cell that was *already* one empty paragraph keeps it — and keeps its anchor. Otherwise the fresh paragraph's anchor is reported in `Created` |
+| A vertical-merge continuation | The `tc` survives at the same coordinate and is retained; its body is reduced to the one empty `w:p` CT_Tc requires |
-A continuation cell's paragraph stays addressable even though Word renders nothing for it: writing
-to it is legal but invisible, so unmerge first. `Created`/`Removed` always describe reality — an
-`Append` merge of a filled 3×1 column reports neither, because every paragraph is still there.
+A continuation `tc` stays addressable even though Word renders its body invisibly; unmerge before
+writing content intended to display. A horizontal append merge invalidates absorbed cell shells even
+though their content blocks survive inside the lead cell.
**Projection.** A table carrying any merge fails the projector's GFM-simplicity predicate (any
`w:gridSpan > 1` or any `w:vMerge` disqualifies), so it renders as the opaque ` ```table ` block
-with its `{#tbl:…}` anchor — and every cell paragraph stays individually addressable in the anchor
-index, so `ReplaceText`/`ApplyFormat` on a merged table's cells work unchanged.
+with its `{#tbl:…}` anchor — and every surviving cell stays individually addressable in the anchor
+index. Use `ReplaceCellContent(tc, …)` for whole-cell content, or a direct paragraph anchor from
+table metadata for paragraph-grained `ReplaceText`/`ApplyFormat`.
**Span-aware CRUD.** The four reshaping ops each have one defined behavior where a merge is in the
way — extend, narrow, or repair, never tear:
@@ -1472,7 +1523,7 @@ Errors are grouped by what the agent should do in response, not by where in the
|---|---|
| Re-project and re-derive the anchor from current text | `AnchorNotFound` |
| Re-list revisions (`ListRevisions`) and reissue with a current id | `RevisionNotFound` |
-| Re-read the anchor's kind via `GetAnchorInfo`, reissue with the right op or coordinates | `AnchorWrongKind`, `AnchorsNotAdjacent`, `InvalidPosition`, `OffsetOutOfRange`, `EmptyCommentSpan` |
+| Re-read the anchor's kind via `GetAnchorInfo`, reissue with the right op or coordinates | `AnchorWrongKind`, `TableAnchorMigrationRequired`, `AnchorsNotAdjacent`, `InvalidPosition`, `OffsetOutOfRange`, `EmptyCommentSpan` |
| Fix the markdown payload (the message names what's wrong) | `MalformedMarkdown`, `UnsupportedMarkdownSyntax`, `AnchorTokenInPayload` |
| Call the v1 op the message names, or fall back to `Raw.InsertXml` | `TableInsertNotSupported`, `FootnoteRefNotSupported`, `CommentMarkerNotSupported`, `ImageInsertNotSupported` |
| Re-query (no `ListStyles()` API in v1; the agent guesses from the projection) | `UnknownStyle`, `InvalidListLevel` |
diff --git a/docs/architecture/ir_editor_roadmap.md b/docs/architecture/ir_editor_roadmap.md
index ed2e4f58..d96585f5 100644
--- a/docs/architecture/ir_editor_roadmap.md
+++ b/docs/architecture/ir_editor_roadmap.md
@@ -200,18 +200,19 @@ nuance: per-item numbering *continuation* for a block rendered in isolation is w
author colors; serve the redline/review use case.
**Acceptance:** edits land as `w:ins`/`w:del` with author attribution, visible in the editor.
-### M7 — Table-cell & table-structure editing · effort M · ✅ **DONE** (except cell-merge)
+### M7 — Table-cell & table-structure editing · effort M · ✅ **DONE**
**Shipped (resolving the S-1 smoke-test gaps):** cell text edits/round-trips; **Enter inside a
cell** splits the cell paragraph in place (stacked lines — value over label, multi-line
addresses); first-class row/column ops `DocxSession.{InsertTableRow,InsertTableColumn,
-DeleteTableRow,DeleteTableColumn}` (by a cell-paragraph anchor; deleting the last row/col removes
-the table) surfaced through the bridge + `DocxEditor` + a floating table toolbar; per-column
-`TableInsertOptions.ColumnWidths`; a visual table grid picker in the demo. v1 assumes a
-rectangular grid (no `w:gridSpan`). Tests: C# `DocxSessionTableEditTests` DT201–DT207 +
+DeleteTableRow,DeleteTableColumn}` (by a canonical `tc` anchor; deleting the last row/col removes
+the table) surfaced through the bridge + `DocxEditor` + a floating table toolbar; explicit
+`tbl`/`tr`/`col`/`tc` metadata and anchor ↔ grid-coordinate resolution; deterministic structural
+anchor mappings; span-aware merge/unmerge and ragged-grid CRUD; per-column
+`TableInsertOptions.ColumnWidths`; a visual table grid picker in the demo. Tests: C#
+`DocxSessionTableEditTests`, `DocxSessionTableAddressingTests` DT250–DT257, and
`DocxSessionS1FeaturesTests` DS214/DS215; browser `editor-cell-multiparagraph` /
`editor-table-edit` / `editor-table-colwidths` / `editor-demo-grid`.
-**Remaining:** horizontal/vertical **cell merge** (`w:gridSpan`/`w:vMerge`) and drag-to-resize
-columns — still via `session.Raw.*` for now.
+**Remaining:** drag-to-resize columns; programmatic widths are first-class.
### M8 — React wrapper · effort S
**Approach:** `useDocxEditor` hook + `` component over the pure-TS core, in
diff --git a/npm/src/editor.ts b/npm/src/editor.ts
index 58c0f945..76746e16 100644
--- a/npm/src/editor.ts
+++ b/npm/src/editor.ts
@@ -2919,17 +2919,19 @@ export class DocxEditor {
// ─── Table row / column editing (active block must be inside a table cell) ──────────
- /** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
+ /** Run a table-structure op on the active cell's canonical tc anchor and re-render. */
private tableEdit(run: (cellAnchor: string) => string): void {
const block = this.activeBlock;
- if (this.closed || !block || !block.closest("table")) return;
+ const cell = block?.closest("td, th");
+ if (this.closed || !block || !cell || !block.closest("table")) return;
const unid = block.getAttribute("data-anchor");
if (!unid) return;
- let fullId = this.anchorIdOf(block);
- if (!fullId) return;
+ const paragraphId = this.anchorIdOf(block);
+ const cellId = this.anchorIdOf(cell);
+ if (!paragraphId || !cellId) return;
const idx = this.blockIndex(block);
- fullId = this.syncBlock(block, fullId); // flush uncommitted cell text first
- const res = this.parseEdit(run(fullId));
+ this.syncBlock(block, paragraphId); // flush uncommitted cell text first
+ const res = this.parseEdit(run(cellId));
if (!res.success) return;
this.refreshAfter(block, idx, false);
}
diff --git a/npm/src/index.ts b/npm/src/index.ts
index d47d91ac..1f5acbbf 100644
--- a/npm/src/index.ts
+++ b/npm/src/index.ts
@@ -1654,6 +1654,7 @@ export async function getDocumentStructure(
// Convert from PascalCase to camelCase
const convertElement = (el: any): DocumentElement => ({
id: el.Id || el.id,
+ anchorId: el.AnchorId || el.anchorId,
type: el.Type || el.type,
textPreview: el.TextPreview || el.textPreview,
index: el.Index ?? el.index,
@@ -1666,8 +1667,12 @@ export async function getDocumentStructure(
const convertTableColumn = (col: any): TableColumnInfo => ({
tableId: col.TableId || col.tableId,
+ anchorId: col.AnchorId || col.anchorId,
+ tableAnchorId: col.TableAnchorId || col.tableAnchorId,
+ isVirtual: col.IsVirtual ?? col.isVirtual ?? false,
columnIndex: col.ColumnIndex ?? col.columnIndex,
cellIds: col.CellIds || col.cellIds || [],
+ cellAnchorIds: col.CellAnchorIds || col.cellAnchorIds || [],
rowCount: col.RowCount ?? col.rowCount,
});
diff --git a/npm/src/session.ts b/npm/src/session.ts
index fe2b1f9d..2ee951ed 100644
--- a/npm/src/session.ts
+++ b/npm/src/session.ts
@@ -30,7 +30,10 @@ import type {
ParagraphFormatOp,
TableBorderSpec,
TableInsertOptions,
+ TableMetadataResult,
+ TableCellResolutionResult,
TableMergeContent,
+ TableRowOptions,
TableShadingScope,
ListFormat,
GrepOptions,
@@ -192,7 +195,7 @@ export class DocxSession {
/**
* Insert a `rows`×`cols` table before/after the block. `options` controls borders, row-major
- * cell markdown, and cell alignment. The returned `EditResult.created` lists the cell-paragraph
+ * cell markdown, and cell alignment. The returned `EditResult.created` lists canonical `tc`
* anchors (row-major), so each cell can then be addressed to fill/format.
*/
insertTable(
@@ -208,10 +211,33 @@ export class DocxSession {
) as EditResult;
}
+ /** Resolve a canonical `tbl` anchor to explicit table/row/column/cell identities. */
+ getTableMetadata(tableAnchorId: string): TableMetadataResult {
+ return JSON.parse(this.wasm.GetTableMetadata(this.handle, tableAnchorId)) as TableMetadataResult;
+ }
+
+ /** Resolve a canonical `tc` anchor to its zero-based table-grid coordinate and spans. */
+ resolveTableCellAnchor(cellAnchorId: string): TableCellResolutionResult {
+ return JSON.parse(
+ this.wasm.ResolveTableCellAnchor(this.handle, cellAnchorId),
+ ) as TableCellResolutionResult;
+ }
+
+ /** Resolve a zero-based table-grid coordinate to the physical `tc` covering it. */
+ resolveTableCellCoordinate(
+ tableAnchorId: string,
+ rowIndex: number,
+ columnIndex: number,
+ ): TableCellResolutionResult {
+ return JSON.parse(
+ this.wasm.ResolveTableCellCoordinate(this.handle, tableAnchorId, rowIndex, columnIndex),
+ ) as TableCellResolutionResult;
+ }
+
/**
- * Table row/column editing, addressed by a cell-paragraph anchor (e.g. one returned from
- * {@link insertTable}'s `created`). Insert clones the reference row/column's widths and starts
- * empty (`created` lists the new cell-paragraph anchors); delete of the last row/column removes
+ * Table row/column editing, addressed by the canonical `tc` anchor returned from
+ * {@link insertTable}'s `created` or table metadata. Insert clones the reference row/column's
+ * widths and starts empty (`created` lists new `tc` anchors); delete of the last row/column removes
* the whole table. All four are grid-aware: inserting across a merge extends it, deleting
* through one narrows it, and deleting a vertical merge's lead row promotes the next row to
* carry it — the grid is never left ragged.
@@ -262,7 +288,7 @@ export class DocxSession {
}
/**
- * Table styling, addressed by a cell-paragraph anchor — the post-insert counterpart of
+ * Table styling, addressed by a canonical `tc` anchor — the post-insert counterpart of
* {@link insertTable}'s options (issue #315 Stage A). `setColumnWidths` retunes `w:tblGrid` +
* every row's cell width (one positive twip value per column) and pins the table to fixed
* layout, exactly as inserting with explicit `columnWidths` would.
@@ -310,6 +336,20 @@ export class DocxSession {
) as EditResult;
}
+ /** Apply row layout options to the row containing the canonical cell anchor. */
+ setTableRowOptions(cellAnchorId: string, options: TableRowOptions): EditResult {
+ return JSON.parse(
+ this.wasm.SetTableRowOptions(
+ this.handle,
+ cellAnchorId,
+ options.repeatHeader ?? null,
+ options.allowBreakAcrossPages ?? null,
+ options.heightTwips ?? null,
+ options.heightRule ?? "atLeast",
+ ),
+ ) as EditResult;
+ }
+
// ─── Headers / footers / page numbers ────────────────────────────────
/**
diff --git a/npm/src/types.ts b/npm/src/types.ts
index 84408d3a..c2d1c2c2 100644
--- a/npm/src/types.ts
+++ b/npm/src/types.ts
@@ -1124,6 +1124,9 @@ export interface DocxodusWasmExports {
MergeParagraphs: (handle: number, first: string, second: string) => string;
InsertHorizontalRule: (handle: number, anchor: string, pos: string, ruleJson: string) => string;
InsertTable: (handle: number, anchor: string, pos: string, rows: number, cols: number, optionsJson: string) => string;
+ GetTableMetadata: (handle: number, tableAnchor: string) => string;
+ ResolveTableCellAnchor: (handle: number, cellAnchor: string) => string;
+ ResolveTableCellCoordinate: (handle: number, tableAnchor: string, rowIndex: number, columnIndex: number) => string;
InsertTableRow: (handle: number, cellAnchor: string, pos: string) => string;
InsertTableColumn: (handle: number, cellAnchor: string, pos: string) => string;
DeleteTableRow: (handle: number, cellAnchor: string) => string;
@@ -1134,6 +1137,8 @@ export interface DocxodusWasmExports {
SetTableBorders: (handle: number, cellAnchor: string, specJson: string) => string;
SetCellShading: (handle: number, cellAnchor: string, fill: string, scope: string) => string;
SetRepeatHeaderRow: (handle: number, cellAnchor: string, repeat: boolean) => string;
+ SetTableRowOptions: (handle: number, cellAnchor: string, repeatHeader: boolean | null,
+ allowBreakAcrossPages: boolean | null, heightTwips: number | null, heightRule: string) => string;
SetHeaderText: (handle: number, anchor: string, kind: string, markdown: string) => string;
SetFooterText: (handle: number, anchor: string, kind: string, markdown: string) => string;
InsertPageNumberField: (handle: number, anchor: string, field: string, format: string) => string;
@@ -1276,6 +1281,7 @@ export type EditErrorCode =
| "invalid_paragraph_format"
| "invalid_table_styling"
| "invalid_table_merge"
+ | "table_anchor_migration_required"
| "malformed_xml"
| "disallowed_namespace"
| "incompatible_element_type"
@@ -1313,6 +1319,8 @@ export interface EditResult {
created: AnchorRef[];
removed: AnchorRef[];
modified: AnchorRef[];
+ /** Deterministic structural identity map for table shape mutations. */
+ tableAnchors?: TableAnchorMapping;
patch?: MarkdownPatch;
/** Set by the annotation ops (addAnnotation/removeAnnotation/updateAnnotation/
* moveAnnotation) with the affected annotation id; absent for every other op. */
@@ -1505,6 +1513,85 @@ export interface TableInsertOptions {
columnWidths?: number[];
}
+export type TableVerticalMergeRole = "none" | "restart" | "continue";
+export type TableAnchorEntityKind = "table" | "row" | "column" | "cell";
+
+export interface TableCellMetadata {
+ anchor: AnchorRef;
+ tableAnchorId: string;
+ rowAnchorId: string;
+ rowIndex: number;
+ columnIndex: number;
+ rowSpan: number;
+ columnSpan: number;
+ verticalMerge: TableVerticalMergeRole;
+ /** Direct cell paragraphs only; nested-table paragraphs belong to their own cells. */
+ paragraphAnchors: AnchorRef[];
+}
+
+export interface TableRowMetadata {
+ anchor: AnchorRef;
+ tableAnchorId: string;
+ rowIndex: number;
+ gridBefore: number;
+ gridAfter: number;
+ cells: TableCellMetadata[];
+}
+
+export interface TableColumnMetadata {
+ anchor: AnchorRef;
+ tableAnchorId: string;
+ columnIndex: number;
+ widthTwips: number;
+ /** True when an absent/underspecified tblGrid required a read-only coordinate identity. */
+ isVirtual: boolean;
+ cellAnchorIds: string[];
+}
+
+export interface TableMetadata {
+ anchor: AnchorRef;
+ columns: TableColumnMetadata[];
+ rows: TableRowMetadata[];
+}
+
+export interface TableMetadataResult {
+ success: boolean;
+ error?: EditError;
+ metadata?: TableMetadata;
+}
+
+export interface TableCellResolutionResult {
+ success: boolean;
+ error?: EditError;
+ cell?: TableCellMetadata;
+}
+
+export interface TableAnchorLocation {
+ anchor: AnchorRef;
+ entityKind: TableAnchorEntityKind;
+ rowIndex?: number;
+ columnIndex?: number;
+ rowSpan?: number;
+ columnSpan?: number;
+ isVirtual?: boolean;
+}
+
+export interface TableAnchorMapping {
+ retained: { before: TableAnchorLocation; after: TableAnchorLocation }[];
+ added: TableAnchorLocation[];
+ invalidated: TableAnchorLocation[];
+}
+
+export type TableRowHeightRule = "auto" | "atLeast" | "exact";
+
+export interface TableRowOptions {
+ repeatHeader?: boolean;
+ allowBreakAcrossPages?: boolean;
+ /** Zero removes an explicit height. */
+ heightTwips?: number;
+ heightRule?: TableRowHeightRule;
+}
+
/** Which table edges `DocxSession.setTableBorders` targets: `"outside"` = top/left/bottom/right,
* `"inside"` = the inner grid lines (`w:insideH`/`w:insideV`), `"all"` = both. */
export type TableBorderScope = "all" | "outside" | "inside";
@@ -2320,6 +2407,8 @@ export enum DocumentElementType {
export interface DocumentElement {
/** Unique element ID (path-based, e.g., "doc/tbl-0/tr-1/tc-2") */
id: string;
+ /** Canonical session anchor when this element is addressable. */
+ anchorId?: string;
/** Element type */
type: DocumentElementType | string;
/** Preview of text content (first ~100 characters) */
@@ -2344,10 +2433,17 @@ export interface DocumentElement {
export interface TableColumnInfo {
/** ID of the table this column belongs to */
tableId: string;
+ /** Canonical `col` anchor. */
+ anchorId: string;
+ /** Canonical owning `tbl` anchor. */
+ tableAnchorId: string;
+ isVirtual: boolean;
/** Zero-based column index */
columnIndex: number;
/** IDs of all cells in this column */
cellIds: string[];
+ /** Canonical `tc` anchors for cells covering this column. */
+ cellAnchorIds: string[];
/** Total number of rows in this column */
rowCount: number;
}
diff --git a/python/README.md b/python/README.md
index ec305a32..556fcf5d 100644
--- a/python/README.md
+++ b/python/README.md
@@ -106,6 +106,7 @@ python -m venv .venv
- `tests/test_smoke.py` — end-to-end mirror of `Docxodus.Tests/DocxSessionSmokeTest.cs`. v1 acceptance gate.
- `tests/test_lifecycle.py` — proves session persistence, idempotent close, singleton host, finalizer fallback.
+- `tests/test_table_addressing.py` — canonical table identities, coordinate resolution, every table mutation, mappings, and anchor-stable reopen.
Tests share the Docxodus monorepo's `TestFiles/` corpus so divergence between Python and .NET on identical inputs is detectable.
@@ -125,7 +126,7 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a
| **B: footnotes/endnotes** | `insert_footnote`, `insert_endnote` |
| **B: native comments** | `add_comment`, `add_comment_to_revision`, `add_comment_reply`, `update_comment`, `set_comment_resolved`, `remove_comment`, `list_comments` |
| **C: formatting** | `apply_format`, `apply_format_by_substring`, `set_paragraph_style`, `set_paragraph_format`, `set_list_level`, `remove_list_membership`, `apply_list_format`, `apply_list_format_range`, `set_list_start_override`, `clear_list_start_override` |
-| **D: tables** | `replace_cell_content` |
+| **D: tables** | `get_table_metadata`, `resolve_table_cell_anchor`, `resolve_table_cell_coordinate`, `insert_table`, `insert_table_row`, `insert_table_column`, `delete_table_row`, `delete_table_column`, `merge_cells`, `unmerge_cells`, `set_column_widths`, `set_table_borders`, `set_cell_shading`, `set_repeat_header_row`, `set_table_row_options`, `replace_cell_content` |
| **D: tracked changes** | `set_tracked_changes`, `set_revision_author`, `list_revisions`, `accept_revision`, `reject_revision` |
| **E: annotations** | `add_annotation`, `remove_annotation`, `update_annotation`, `move_annotation` |
| **Raw XML** | `session.raw.get_xml`, `session.raw.insert_xml`, `session.raw.replace_xml` |
diff --git a/python/src/docx_scalpel/__init__.py b/python/src/docx_scalpel/__init__.py
index dd09cfc5..084a5153 100644
--- a/python/src/docx_scalpel/__init__.py
+++ b/python/src/docx_scalpel/__init__.py
@@ -53,7 +53,10 @@
ProjectionDepth,
ProjectionScopes,
RegexOptions,
+ TableAnchorEntityKind,
TableRenderMode,
+ TableRowHeightRule,
+ TableVerticalMergeRole,
TrackedChangeMode,
WhitespaceMode,
)
@@ -109,6 +112,7 @@
ParagraphBorderEdge,
ParagraphFormatOp,
ReplaceOptions,
+ RetainedTableAnchor,
RevisionListEntry,
RunFormatting,
RunFragment,
@@ -116,6 +120,17 @@
TemplatePlaceholder,
TextMatch,
WmlToMarkdownConverterSettings,
+ TableAnchorLocation,
+ TableAnchorMapping,
+ TableBorderSpec,
+ TableCellMetadata,
+ TableCellResolutionResult,
+ TableColumnMetadata,
+ TableInsertOptions,
+ TableMetadata,
+ TableMetadataResult,
+ TableRowMetadata,
+ TableRowOptions,
)
try:
@@ -172,6 +187,7 @@
"ParagraphBorderEdge",
"ParagraphFormatOp",
"ReplaceOptions",
+ "RetainedTableAnchor",
"RevisionListEntry",
"RunFormatting",
"RunFragment",
@@ -179,6 +195,17 @@
"TemplatePlaceholder",
"TextMatch",
"WmlToMarkdownConverterSettings",
+ "TableAnchorLocation",
+ "TableAnchorMapping",
+ "TableBorderSpec",
+ "TableCellMetadata",
+ "TableCellResolutionResult",
+ "TableColumnMetadata",
+ "TableInsertOptions",
+ "TableMetadata",
+ "TableMetadataResult",
+ "TableRowMetadata",
+ "TableRowOptions",
"DocxDiffSettings",
"DocxDiffRevision",
"DocxDiffFormatChange",
@@ -209,7 +236,10 @@
"ProjectionDepth",
"ProjectionScopes",
"RegexOptions",
+ "TableAnchorEntityKind",
"TableRenderMode",
+ "TableRowHeightRule",
+ "TableVerticalMergeRole",
"TrackedChangeMode",
"WhitespaceMode",
# errors
diff --git a/python/src/docx_scalpel/enums.py b/python/src/docx_scalpel/enums.py
index b6aff83d..c0a2fb76 100644
--- a/python/src/docx_scalpel/enums.py
+++ b/python/src/docx_scalpel/enums.py
@@ -34,6 +34,9 @@
"AnchorIdRendering",
"RegexOptions",
"ConflictResolution",
+ "TableAnchorEntityKind",
+ "TableRowHeightRule",
+ "TableVerticalMergeRole",
]
@@ -44,6 +47,31 @@ class Position(str, Enum):
AFTER = "after"
+class TableAnchorEntityKind(str, Enum):
+ """Structural identity kind in a table-anchor mutation mapping."""
+
+ TABLE = "table"
+ ROW = "row"
+ COLUMN = "column"
+ CELL = "cell"
+
+
+class TableVerticalMergeRole(str, Enum):
+ """A physical cell's role in a Word vertical-merge run."""
+
+ NONE = "none"
+ RESTART = "restart"
+ CONTINUE = "continue"
+
+
+class TableRowHeightRule(str, Enum):
+ """Interpretation of an explicit table-row height."""
+
+ AUTO = "auto"
+ AT_LEAST = "atLeast"
+ EXACT = "exact"
+
+
class HeaderFooterKind(str, Enum):
"""Which header/footer story ``set_header_text``/``set_footer_text`` targets.
@@ -133,6 +161,9 @@ class EditErrorCode(str, Enum):
INVALID_LIST_START_VALUE = "invalid_list_start_value"
INVALID_PAGE_NUMBERING = "invalid_page_numbering"
INVALID_PARAGRAPH_FORMAT = "invalid_paragraph_format"
+ INVALID_TABLE_STYLING = "invalid_table_styling"
+ INVALID_TABLE_MERGE = "invalid_table_merge"
+ TABLE_ANCHOR_MIGRATION_REQUIRED = "table_anchor_migration_required"
MALFORMED_XML = "malformed_xml"
DISALLOWED_NAMESPACE = "disallowed_namespace"
INCOMPATIBLE_ELEMENT_TYPE = "incompatible_element_type"
diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py
index 39ca65ff..696b6df3 100644
--- a/python/src/docx_scalpel/session.py
+++ b/python/src/docx_scalpel/session.py
@@ -21,7 +21,7 @@
from __future__ import annotations
import base64
-from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping
+from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, Sequence
if TYPE_CHECKING:
from types import TracebackType
@@ -74,6 +74,11 @@
SectionInfo,
TemplatePlaceholder,
TextMatch,
+ TableBorderSpec,
+ TableCellResolutionResult,
+ TableInsertOptions,
+ TableMetadataResult,
+ TableRowOptions,
)
__all__ = [
@@ -1378,7 +1383,111 @@ def clear_list_start_override(self, anchor_id: str) -> EditResult:
# -- Tier D: tables ---------------------------------------------------
+ def get_table_metadata(self, table_anchor_id: str) -> TableMetadataResult:
+ """Resolve a canonical ``tbl`` anchor to explicit table/row/column/cell identities."""
+ return TableMetadataResult._from_wire(
+ self._call("get_table_metadata", {"tableAnchorId": table_anchor_id})
+ )
+
+ def resolve_table_cell_anchor(self, cell_anchor_id: str) -> TableCellResolutionResult:
+ """Resolve a canonical ``tc`` anchor to its zero-based table-grid coordinate."""
+ return TableCellResolutionResult._from_wire(
+ self._call("resolve_table_cell_anchor", {"cellAnchorId": cell_anchor_id})
+ )
+
+ def resolve_table_cell_coordinate(
+ self, table_anchor_id: str, row_index: int, column_index: int
+ ) -> TableCellResolutionResult:
+ """Resolve a table-grid coordinate to the physical ``tc`` covering it."""
+ return TableCellResolutionResult._from_wire(
+ self._call("resolve_table_cell_coordinate", {
+ "tableAnchorId": table_anchor_id,
+ "rowIndex": row_index,
+ "columnIndex": column_index,
+ })
+ )
+
+ def insert_table(
+ self, anchor_id: str, position: Position, rows: int, columns: int,
+ options: TableInsertOptions | None = None,
+ ) -> EditResult:
+ return EditResult._from_wire(self._call("insert_table", {
+ "anchorId": anchor_id, "position": position.value, "rows": rows,
+ "columns": columns, "options": options.to_wire() if options else {},
+ }))
+
+ def insert_table_row(self, cell_anchor_id: str, position: Position) -> EditResult:
+ return EditResult._from_wire(self._call("insert_table_row", {
+ "cellAnchorId": cell_anchor_id, "position": position.value,
+ }))
+
+ def insert_table_column(self, cell_anchor_id: str, position: Position) -> EditResult:
+ return EditResult._from_wire(self._call("insert_table_column", {
+ "cellAnchorId": cell_anchor_id, "position": position.value,
+ }))
+
+ def delete_table_row(self, cell_anchor_id: str) -> EditResult:
+ return EditResult._from_wire(
+ self._call("delete_table_row", {"cellAnchorId": cell_anchor_id})
+ )
+
+ def delete_table_column(self, cell_anchor_id: str) -> EditResult:
+ return EditResult._from_wire(
+ self._call("delete_table_column", {"cellAnchorId": cell_anchor_id})
+ )
+
+ def merge_cells(
+ self, cell_anchor_id: str, row_span: int, column_span: int,
+ content: str = "append",
+ ) -> EditResult:
+ return EditResult._from_wire(self._call("merge_cells", {
+ "cellAnchorId": cell_anchor_id, "rowSpan": row_span,
+ "columnSpan": column_span, "content": content,
+ }))
+
+ def unmerge_cells(self, cell_anchor_id: str) -> EditResult:
+ return EditResult._from_wire(
+ self._call("unmerge_cells", {"cellAnchorId": cell_anchor_id})
+ )
+
+ def set_column_widths(self, cell_anchor_id: str, widths: Sequence[int]) -> EditResult:
+ return EditResult._from_wire(self._call("set_column_widths", {
+ "cellAnchorId": cell_anchor_id, "widths": list(widths),
+ }))
+
+ def set_table_borders(
+ self, cell_anchor_id: str, spec: TableBorderSpec | None = None,
+ ) -> EditResult:
+ return EditResult._from_wire(self._call("set_table_borders", {
+ "cellAnchorId": cell_anchor_id, "spec": spec.to_wire() if spec else {},
+ }))
+
+ def set_cell_shading(
+ self, cell_anchor_id: str, fill: str | None, scope: str = "cell",
+ ) -> EditResult:
+ return EditResult._from_wire(self._call("set_cell_shading", {
+ "cellAnchorId": cell_anchor_id, "fill": fill, "scope": scope,
+ }))
+
+ def set_repeat_header_row(self, cell_anchor_id: str, repeat: bool) -> EditResult:
+ return EditResult._from_wire(self._call("set_repeat_header_row", {
+ "cellAnchorId": cell_anchor_id, "repeat": repeat,
+ }))
+
+ def set_table_row_options(
+ self, cell_anchor_id: str, options: TableRowOptions,
+ ) -> EditResult:
+ return EditResult._from_wire(self._call("set_table_row_options", {
+ "cellAnchorId": cell_anchor_id,
+ "repeatHeader": options.repeat_header,
+ "allowBreakAcrossPages": options.allow_break_across_pages,
+ "heightTwips": options.height_twips,
+ "heightRule": options.height_rule,
+ }))
+
def replace_cell_content(self, cell_anchor_id: str, markdown: str) -> EditResult:
+ """Replace content of the canonical ``tc`` anchor. Legacy paragraph-in-cell anchors
+ remain translated during the documented compatibility window."""
return EditResult._from_wire(
self._call(
"replace_cell_content",
diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py
index 620376e6..afc64ef8 100644
--- a/python/src/docx_scalpel/types.py
+++ b/python/src/docx_scalpel/types.py
@@ -34,7 +34,10 @@
PlaceholderKind,
PlaceholderKinds,
ProjectionScopes,
+ TableAnchorEntityKind,
TableRenderMode,
+ TableRowHeightRule,
+ TableVerticalMergeRole,
TrackedChangeMode,
WhitespaceMode,
)
@@ -80,6 +83,18 @@
"DocxDiffConflictCompetitor",
"DocxDiffConflict",
"DocxDiffConsolidatedRevision",
+ "TableInsertOptions",
+ "TableBorderSpec",
+ "TableRowOptions",
+ "TableCellMetadata",
+ "TableRowMetadata",
+ "TableColumnMetadata",
+ "TableMetadata",
+ "TableMetadataResult",
+ "TableCellResolutionResult",
+ "TableAnchorLocation",
+ "RetainedTableAnchor",
+ "TableAnchorMapping",
]
@@ -107,6 +122,168 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "Anchor":
return cls(id=d["id"], kind=d["kind"], scope=d["scope"], unid=d["unid"])
+@dataclass(frozen=True, slots=True)
+class TableInsertOptions:
+ borderless: bool = False
+ cell_contents: tuple[str, ...] = ()
+ cell_alignment: str | None = None
+ column_widths: tuple[int, ...] = ()
+
+ def to_wire(self) -> dict[str, Any]:
+ result: dict[str, Any] = {"borderless": self.borderless}
+ if self.cell_contents:
+ result["cellContents"] = list(self.cell_contents)
+ if self.cell_alignment is not None:
+ result["cellAlignment"] = self.cell_alignment
+ if self.column_widths:
+ result["columnWidths"] = list(self.column_widths)
+ return result
+
+
+@dataclass(frozen=True, slots=True)
+class TableBorderSpec:
+ scope: str = "all"
+ style: str | None = None
+ size: int | None = None
+ color: str | None = None
+
+ def to_wire(self) -> dict[str, Any]:
+ return {key: value for key, value in {
+ "scope": self.scope, "style": self.style, "size": self.size, "color": self.color,
+ }.items() if value is not None}
+
+
+@dataclass(frozen=True, slots=True)
+class TableRowOptions:
+ repeat_header: bool | None = None
+ allow_break_across_pages: bool | None = None
+ height_twips: int | None = None
+ height_rule: TableRowHeightRule = TableRowHeightRule.AT_LEAST
+
+
+@dataclass(frozen=True, slots=True)
+class TableCellMetadata:
+ anchor: Anchor
+ table_anchor_id: str
+ row_anchor_id: str
+ row_index: int
+ column_index: int
+ row_span: int
+ column_span: int
+ vertical_merge: TableVerticalMergeRole
+ paragraph_anchors: tuple[Anchor, ...] = ()
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "TableCellMetadata":
+ return cls(
+ anchor=Anchor._from_wire(d["anchor"]),
+ table_anchor_id=d["tableAnchorId"], row_anchor_id=d["rowAnchorId"],
+ row_index=int(d["rowIndex"]), column_index=int(d["columnIndex"]),
+ row_span=int(d["rowSpan"]), column_span=int(d["columnSpan"]),
+ vertical_merge=TableVerticalMergeRole(d.get("verticalMerge", "none")),
+ paragraph_anchors=tuple(Anchor._from_wire(a) for a in d.get("paragraphAnchors", ())),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class TableRowMetadata:
+ anchor: Anchor
+ table_anchor_id: str
+ row_index: int
+ grid_before: int
+ grid_after: int
+ cells: tuple[TableCellMetadata, ...] = ()
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "TableRowMetadata":
+ return cls(
+ anchor=Anchor._from_wire(d["anchor"]), table_anchor_id=d["tableAnchorId"],
+ row_index=int(d["rowIndex"]), grid_before=int(d.get("gridBefore", 0)),
+ grid_after=int(d.get("gridAfter", 0)),
+ cells=tuple(TableCellMetadata._from_wire(c) for c in d.get("cells", ())),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class TableColumnMetadata:
+ anchor: Anchor
+ table_anchor_id: str
+ column_index: int
+ width_twips: int
+ is_virtual: bool
+ cell_anchor_ids: tuple[str, ...] = ()
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "TableColumnMetadata":
+ return cls(
+ anchor=Anchor._from_wire(d["anchor"]), table_anchor_id=d["tableAnchorId"],
+ column_index=int(d["columnIndex"]), width_twips=int(d.get("widthTwips", 0)),
+ is_virtual=bool(d.get("isVirtual", False)),
+ cell_anchor_ids=tuple(d.get("cellAnchorIds", ())),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class TableMetadata:
+ anchor: Anchor
+ columns: tuple[TableColumnMetadata, ...] = ()
+ rows: tuple[TableRowMetadata, ...] = ()
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "TableMetadata":
+ return cls(
+ anchor=Anchor._from_wire(d["anchor"]),
+ columns=tuple(TableColumnMetadata._from_wire(c) for c in d.get("columns", ())),
+ rows=tuple(TableRowMetadata._from_wire(r) for r in d.get("rows", ())),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class TableAnchorLocation:
+ anchor: Anchor
+ entity_kind: TableAnchorEntityKind
+ row_index: int | None = None
+ column_index: int | None = None
+ row_span: int | None = None
+ column_span: int | None = None
+ is_virtual: bool = False
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "TableAnchorLocation":
+ return cls(
+ anchor=Anchor._from_wire(d["anchor"]),
+ entity_kind=TableAnchorEntityKind(d["entityKind"]),
+ row_index=d.get("rowIndex"), column_index=d.get("columnIndex"),
+ row_span=d.get("rowSpan"), column_span=d.get("columnSpan"),
+ is_virtual=bool(d.get("isVirtual", False)),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class RetainedTableAnchor:
+ before: TableAnchorLocation
+ after: TableAnchorLocation
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "RetainedTableAnchor":
+ return cls(TableAnchorLocation._from_wire(d["before"]), TableAnchorLocation._from_wire(d["after"]))
+
+
+@dataclass(frozen=True, slots=True)
+class TableAnchorMapping:
+ retained: tuple[RetainedTableAnchor, ...] = ()
+ added: tuple[TableAnchorLocation, ...] = ()
+ invalidated: tuple[TableAnchorLocation, ...] = ()
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "TableAnchorMapping":
+ return cls(
+ retained=tuple(RetainedTableAnchor._from_wire(x) for x in d.get("retained", ())),
+ added=tuple(TableAnchorLocation._from_wire(x) for x in d.get("added", ())),
+ invalidated=tuple(TableAnchorLocation._from_wire(x) for x in d.get("invalidated", ())),
+ )
+
+
@dataclass(frozen=True, slots=True)
class AnchorTarget:
"""Search-result anchor with extra metadata (``partUri``, ``textPreview``)."""
@@ -589,6 +766,36 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "EditError":
)
+@dataclass(frozen=True, slots=True)
+class TableMetadataResult:
+ success: bool
+ metadata: TableMetadata | None = None
+ error: EditError | None = None
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "TableMetadataResult":
+ metadata = d.get("metadata")
+ error = d.get("error")
+ return cls(bool(d.get("success", False)),
+ TableMetadata._from_wire(metadata) if metadata else None,
+ EditError._from_wire(error) if error else None)
+
+
+@dataclass(frozen=True, slots=True)
+class TableCellResolutionResult:
+ success: bool
+ cell: TableCellMetadata | None = None
+ error: EditError | None = None
+
+ @classmethod
+ def _from_wire(cls, d: Mapping[str, Any]) -> "TableCellResolutionResult":
+ cell = d.get("cell")
+ error = d.get("error")
+ return cls(bool(d.get("success", False)),
+ TableCellMetadata._from_wire(cell) if cell else None,
+ EditError._from_wire(error) if error else None)
+
+
@dataclass(frozen=True, slots=True)
class MarkdownPatch:
"""A scoped markdown re-projection produced by a successful mutation."""
@@ -620,6 +827,7 @@ class EditResult:
patch: MarkdownPatch | None = None
error: EditError | None = None
annotation_id: str | None = None
+ table_anchors: TableAnchorMapping | None = None
@classmethod
def _from_wire(cls, d: Mapping[str, Any]) -> "EditResult":
@@ -633,6 +841,8 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "EditResult":
patch=MarkdownPatch._from_wire(patch_d) if patch_d else None,
error=EditError._from_wire(err_d) if err_d else None,
annotation_id=d.get("annotationId"),
+ table_anchors=TableAnchorMapping._from_wire(d["tableAnchors"])
+ if d.get("tableAnchors") else None,
)
diff --git a/python/tests/test_table_addressing.py b/python/tests/test_table_addressing.py
new file mode 100644
index 00000000..dfc25bc2
--- /dev/null
+++ b/python/tests/test_table_addressing.py
@@ -0,0 +1,105 @@
+"""Canonical table addressing and complete Python table-op ripple (#450)."""
+
+from __future__ import annotations
+
+from docx_scalpel import (
+ Position,
+ TableAnchorEntityKind,
+ TableBorderSpec,
+ TableInsertOptions,
+ TableRowHeightRule,
+ TableRowOptions,
+ open_session,
+)
+
+
+def test_table_metadata_resolution_and_all_mutations(tour_plan_bytes: bytes) -> None:
+ with open_session(tour_plan_bytes) as session:
+ body = next(
+ anchor for anchor in session.project().anchor_index.values()
+ if anchor.scope == "body" and anchor.kind in ("p", "h", "li")
+ )
+ inserted = session.insert_table(
+ body.id,
+ Position.AFTER,
+ 2,
+ 2,
+ TableInsertOptions(
+ cell_contents=("A", "B", "C", "D"),
+ column_widths=(1800, 2200),
+ ),
+ )
+ assert inserted.success
+ assert inserted.created and all(anchor.kind == "tc" for anchor in inserted.created)
+ assert inserted.table_anchors is not None
+ table_id = next(
+ location.anchor.id for location in inserted.table_anchors.added
+ if location.entity_kind is TableAnchorEntityKind.TABLE
+ )
+
+ metadata_result = session.get_table_metadata(table_id)
+ assert metadata_result.success and metadata_result.metadata is not None
+ metadata = metadata_result.metadata
+ assert metadata.anchor.kind == "tbl"
+ assert [column.anchor.kind for column in metadata.columns] == ["col", "col"]
+ assert all(not column.is_virtual for column in metadata.columns)
+ assert [row.anchor.kind for row in metadata.rows] == ["tr", "tr"]
+ cells = [cell for row in metadata.rows for cell in row.cells]
+ assert len(cells) == 4 and all(cell.anchor.kind == "tc" for cell in cells)
+
+ first = cells[0]
+ by_anchor = session.resolve_table_cell_anchor(first.anchor.id)
+ by_coordinate = session.resolve_table_cell_coordinate(table_id, 0, 0)
+ assert by_anchor.success and by_anchor.cell == first
+ assert by_coordinate.success and by_coordinate.cell == first
+
+ assert session.replace_cell_content(first.anchor.id, "replaced").success
+ assert session.set_column_widths(first.anchor.id, (2000, 2400)).success
+ assert session.set_table_borders(
+ first.anchor.id, TableBorderSpec(scope="outside", style="single", size=8)
+ ).success
+ assert session.set_cell_shading(first.anchor.id, "D9EAF7").success
+ assert session.set_repeat_header_row(first.anchor.id, True).success
+ assert session.set_table_row_options(
+ first.anchor.id,
+ TableRowOptions(
+ repeat_header=True,
+ allow_break_across_pages=False,
+ height_twips=480,
+ height_rule=TableRowHeightRule.AT_LEAST,
+ ),
+ ).success
+
+ inserted_row = session.insert_table_row(first.anchor.id, Position.AFTER)
+ assert inserted_row.success and inserted_row.table_anchors is not None
+ inserted_column = session.insert_table_column(first.anchor.id, Position.AFTER)
+ assert inserted_column.success and inserted_column.table_anchors is not None
+
+ current = session.get_table_metadata(table_id).metadata
+ assert current is not None
+ merge_anchor = current.rows[0].cells[0].anchor.id
+ merged = session.merge_cells(merge_anchor, 1, 2)
+ assert merged.success and merged.table_anchors is not None
+ assert all(anchor.kind == "tc" for anchor in merged.removed)
+ unmerged = session.unmerge_cells(merge_anchor)
+ assert unmerged.success and unmerged.table_anchors is not None
+ assert all(anchor.kind == "tc" for anchor in unmerged.created)
+
+ current = session.get_table_metadata(table_id).metadata
+ assert current is not None
+ deleted_row = session.delete_table_row(current.rows[-1].cells[0].anchor.id)
+ assert deleted_row.success and deleted_row.table_anchors is not None
+ current = session.get_table_metadata(table_id).metadata
+ assert current is not None
+ deleted_column = session.delete_table_column(current.rows[0].cells[-1].anchor.id)
+ assert deleted_column.success and deleted_column.table_anchors is not None
+
+ retained_column_ids = tuple(
+ column.anchor.id for column in session.get_table_metadata(table_id).metadata.columns
+ )
+ saved = session.save(persist_anchor_ids=True)
+
+ with open_session(saved) as reopened:
+ reopened_metadata = reopened.get_table_metadata(table_id)
+ assert reopened_metadata.success and reopened_metadata.metadata is not None
+ assert tuple(column.anchor.id for column in reopened_metadata.metadata.columns) == retained_column_ids
diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs
index 4b0f56ef..0b980199 100644
--- a/tools/mcp-server/Dispatcher.cs
+++ b/tools/mcp-server/Dispatcher.cs
@@ -688,6 +688,12 @@ private static string Table(SessionStore store, JsonElement args)
private static string RunTableAction(DocSession session, string action, JsonElement args) => action switch
{
+ "get_metadata" => DocxSessionOps.GetTableMetadata(
+ session.Handle, Str(args, "tableAnchorId")),
+ "resolve_cell_anchor" => DocxSessionOps.ResolveTableCellAnchor(
+ session.Handle, Str(args, "cellAnchorId")),
+ "resolve_cell_coordinate" => DocxSessionOps.ResolveTableCellCoordinate(
+ session.Handle, Str(args, "tableAnchorId"), Int(args, "rowIndex"), Int(args, "columnIndex")),
"insert" => DocxSessionOps.InsertTable(
session.Handle, Str(args, "anchorId"), ParsePos(args),
Int(args, "rows"), Int(args, "columns"), BuildTableInsertOptionsJson(args)),
diff --git a/tools/mcp-server/README.md b/tools/mcp-server/README.md
index dbed77c2..ba716306 100644
--- a/tools/mcp-server/README.md
+++ b/tools/mcp-server/README.md
@@ -94,7 +94,7 @@ markdown projection and search tools return:
| `docxodus_annotate` | Anchor-addressed highlight/label annotations (a custom-XML overlay for external tools, distinct from comments) |
| `docxodus_track_changes` | List tracked changes; accept/reject one by id, or all |
| `docxodus_mutations` | Apply or dry-run-preview a batch of the above as one call |
-| `docxodus_table` | Create tables; edit rows/columns/cell content |
+| `docxodus_table` | Create/read tables; resolve canonical cell anchors ↔ grid coordinates; edit rows/columns/cell content/style |
## Known gaps
diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs
index e16caa31..6039f452 100644
--- a/tools/mcp-server/ToolCatalog.cs
+++ b/tools/mcp-server/ToolCatalog.cs
@@ -338,24 +338,27 @@ internal static class ToolCatalog
"""),
new ToolDefinition(
"docxodus_table",
- "Create tables, edit their rows/columns/cell content, merge/unmerge cells, and style them after insert (column widths, borders, shading, and row layout).",
+ "Inspect canonical table identities and coordinates; create tables; edit rows, columns, and cell content; merge/unmerge cells; and style them after insert.",
"""
{
"type": "object",
"properties": {
"sessionId": { "type": "string" },
- "action": { "type": "string", "enum": ["insert", "insert_row", "insert_column", "delete_row", "delete_column", "replace_cell_content", "merge_cells", "unmerge_cells", "set_column_widths", "set_borders", "set_shading", "set_repeat_header_row", "set_row_options"] },
+ "action": { "type": "string", "enum": ["get_metadata", "resolve_cell_anchor", "resolve_cell_coordinate", "insert", "insert_row", "insert_column", "delete_row", "delete_column", "replace_cell_content", "merge_cells", "unmerge_cells", "set_column_widths", "set_borders", "set_shading", "set_repeat_header_row", "set_row_options"] },
"anchorId": { "type": "string", "description": "insert: reference block (paired with position)." },
+ "tableAnchorId": { "type": "string", "description": "get_metadata/resolve_cell_coordinate: the table's canonical tbl anchor." },
"position": { "type": "string", "enum": ["before", "after"], "description": "insert: relative to anchorId. insert_row/insert_column: relative to cellAnchorId." },
"rows": { "type": "integer" }, "columns": { "type": "integer" },
"cellContents": { "type": "array", "items": { "type": "string" } },
"cellAlignment": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"columnWidths": { "type": "array", "items": { "type": "integer" } },
"borderless": { "type": "boolean" },
- "cellAnchorId": { "type": "string", "description": "insert_row/insert_column/delete_row/delete_column/set_*: a 'p' (paragraph-inside-the-cell) anchor in the target cell/row/table, e.g. from docxodus_table's own insert result or docxodus_search. replace_cell_content: the cell's own 'tc' anchor instead (e.g. from docxodus_search with mode kind, query 'tc') — these two anchor kinds are not interchangeable." },
+ "cellAnchorId": { "type": "string", "description": "resolve_cell_anchor and every cell mutation: the cell's canonical tc anchor, returned by insert/insert_row/insert_column, get_metadata, resolve_cell_coordinate, or docxodus_search mode=kind query=tc. Legacy p/h/li anchors physically inside a cell are translated temporarily for migration; new callers must use tc." },
+ "rowIndex": { "type": "integer", "minimum": 0, "description": "resolve_cell_coordinate: zero-based physical row index." },
+ "columnIndex": { "type": "integer", "minimum": 0, "description": "resolve_cell_coordinate: zero-based table-grid column (honors gridBefore/gridAfter and gridSpan)." },
"markdown": { "type": "string", "description": "replace_cell_content payload." },
"rowSpan": { "type": "integer", "minimum": 1, "description": "merge_cells: how many rows down the merged rectangle runs from the anchor's cell (default 1). Becomes w:vMerge restart/continue." },
- "colSpan": { "type": "integer", "minimum": 1, "description": "merge_cells: how many cells right the rectangle runs from the anchor's cell (default 1). Becomes w:gridSpan. rowSpan x colSpan must be > 1." },
+ "colSpan": { "type": "integer", "minimum": 1, "description": "merge_cells: how many cells right the rectangle runs from the anchor's cell (default 1). Becomes w:gridSpan. rowSpan x colSpan must be > 1. unmerge_cells addressed at a vertical continuation unmerges the whole run." },
"mergeContent": { "type": "string", "enum": ["append", "discard", "reject"], "description": "merge_cells: what to do with the absorbed cells' content — append it to the surviving cell (default, lossless), discard it, or refuse the merge when any absorbed cell is non-empty." },
"widths": { "type": "array", "items": { "type": "integer" }, "description": "set_column_widths: one positive twip width per column, left→right (1440 = 1 inch). Rewrites w:tblGrid + every cell width and pins the table to fixed layout." },
"borderScope": { "type": "string", "enum": ["all", "outside", "inside"], "description": "set_borders: which edges to write (default all). Untargeted edges are left unchanged." },
diff --git a/tools/python-host/Dispatcher.cs b/tools/python-host/Dispatcher.cs
index a24144a9..f3907d5c 100644
--- a/tools/python-host/Dispatcher.cs
+++ b/tools/python-host/Dispatcher.cs
@@ -131,6 +131,38 @@ internal static class Dispatcher
"clear_list_start_override" => DocxSessionOps.ClearListStartOverride(
Handle(args), Str(args, "anchorId")),
+ "get_table_metadata" => DocxSessionOps.GetTableMetadata(
+ Handle(args), Str(args, "tableAnchorId")),
+ "resolve_table_cell_anchor" => DocxSessionOps.ResolveTableCellAnchor(
+ Handle(args), Str(args, "cellAnchorId")),
+ "resolve_table_cell_coordinate" => DocxSessionOps.ResolveTableCellCoordinate(
+ Handle(args), Str(args, "tableAnchorId"), Int(args, "rowIndex"), Int(args, "columnIndex")),
+ "insert_table" => DocxSessionOps.InsertTable(
+ Handle(args), Str(args, "anchorId"), ParsePos(args, "position"),
+ Int(args, "rows"), Int(args, "columns"), RawObjectOrEmpty(args, "options")),
+ "insert_table_row" => DocxSessionOps.InsertTableRow(
+ Handle(args), Str(args, "cellAnchorId"), ParsePos(args, "position")),
+ "insert_table_column" => DocxSessionOps.InsertTableColumn(
+ Handle(args), Str(args, "cellAnchorId"), ParsePos(args, "position")),
+ "delete_table_row" => DocxSessionOps.DeleteTableRow(Handle(args), Str(args, "cellAnchorId")),
+ "delete_table_column" => DocxSessionOps.DeleteTableColumn(Handle(args), Str(args, "cellAnchorId")),
+ "merge_cells" => DocxSessionOps.MergeCells(
+ Handle(args), Str(args, "cellAnchorId"), Int(args, "rowSpan"), Int(args, "columnSpan"),
+ OptStr(args, "content")),
+ "unmerge_cells" => DocxSessionOps.UnmergeCells(Handle(args), Str(args, "cellAnchorId")),
+ "set_column_widths" => DocxSessionOps.SetColumnWidths(
+ Handle(args), Str(args, "cellAnchorId"), RawArray(args, "widths")),
+ "set_table_borders" => DocxSessionOps.SetTableBorders(
+ Handle(args), Str(args, "cellAnchorId"), RawObjectOrEmpty(args, "spec")),
+ "set_cell_shading" => DocxSessionOps.SetCellShading(
+ Handle(args), Str(args, "cellAnchorId"), OptStr(args, "fill") ?? "",
+ OptStr(args, "scope") ?? "cell"),
+ "set_repeat_header_row" => DocxSessionOps.SetRepeatHeaderRow(
+ Handle(args), Str(args, "cellAnchorId"), OptBool(args, "repeat") ?? true),
+ "set_table_row_options" => DocxSessionOps.SetTableRowOptions(
+ Handle(args), Str(args, "cellAnchorId"), OptBool(args, "repeatHeader"),
+ OptBool(args, "allowBreakAcrossPages"), OptInt(args, "heightTwips"),
+ OptStr(args, "heightRule")),
"replace_cell_content" => DocxSessionOps.ReplaceCellContent(
Handle(args), Str(args, "cellAnchorId"), Str(args, "markdown")),
@@ -450,6 +482,26 @@ private static int IntOptional(JsonElement args, string name, int fallback)
? v.GetBoolean() : null;
}
+ private static int? OptInt(JsonElement args, string name)
+ {
+ if (args.ValueKind != JsonValueKind.Object) return null;
+ return args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
+ ? v.GetInt32() : null;
+ }
+
+ private static string RawArray(JsonElement args, string name)
+ {
+ if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var v)
+ || v.ValueKind != JsonValueKind.Array)
+ throw new FormatException($"args missing array \"{name}\"");
+ return v.GetRawText();
+ }
+
+ private static string RawObjectOrEmpty(JsonElement args, string name) =>
+ args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v)
+ && v.ValueKind == JsonValueKind.Object
+ ? v.GetRawText() : "";
+
private static PageNumberingOp ParsePageNumberingOp(JsonElement args, string name)
{
if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var op))
diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs
index de63fb4a..1ab06953 100644
--- a/wasm/DocxodusWasm/DocxSessionBridge.cs
+++ b/wasm/DocxodusWasm/DocxSessionBridge.cs
@@ -236,12 +236,25 @@ public static string InsertHorizontalRule(int h, string anchor, string posStr, s
///
/// Insert a rows×cols table before/after the anchor. is a
/// TableInsertOptions object ({ borderless?, cellContents?: string[], cellAlignment? }).
- /// Returns an EditResult whose created lists the cell-paragraph anchors (row-major).
+ /// Returns an EditResult whose created lists canonical tc anchors (row-major).
///
[JSExport]
public static string InsertTable(int h, string anchor, string posStr, int rows, int cols, string optionsJson) =>
DocxSessionOps.InsertTable(h, anchor, DocxSessionJson.ParsePos(posStr), rows, cols, optionsJson);
+ [JSExport]
+ public static string GetTableMetadata(int h, string tableAnchor) =>
+ DocxSessionOps.GetTableMetadata(h, tableAnchor);
+
+ [JSExport]
+ public static string ResolveTableCellAnchor(int h, string cellAnchor) =>
+ DocxSessionOps.ResolveTableCellAnchor(h, cellAnchor);
+
+ [JSExport]
+ public static string ResolveTableCellCoordinate(
+ int h, string tableAnchor, int rowIndex, int columnIndex) =>
+ DocxSessionOps.ResolveTableCellCoordinate(h, tableAnchor, rowIndex, columnIndex);
+
[JSExport]
public static string InsertTableRow(int h, string cellAnchor, string posStr) =>
DocxSessionOps.InsertTableRow(h, cellAnchor, DocxSessionJson.ParsePos(posStr));
@@ -298,6 +311,12 @@ public static string SetCellShading(int h, string cellAnchor, string fill, strin
public static string SetRepeatHeaderRow(int h, string cellAnchor, bool repeat) =>
DocxSessionOps.SetRepeatHeaderRow(h, cellAnchor, repeat);
+ [JSExport]
+ public static string SetTableRowOptions(int h, string cellAnchor, bool? repeatHeader,
+ bool? allowBreakAcrossPages, int? heightTwips, string heightRule) =>
+ DocxSessionOps.SetTableRowOptions(h, cellAnchor, repeatHeader, allowBreakAcrossPages,
+ heightTwips, heightRule);
+
///
/// Set the section's running header story ( = any body block in the
/// section) to . is "default" | "first" |