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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions Docxodus.Tests/DocxSessionS1FeaturesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down
385 changes: 385 additions & 0 deletions Docxodus.Tests/DocxSessionTableAddressingTests.cs

Large diffs are not rendered by default.

25 changes: 12 additions & 13 deletions Docxodus.Tests/DocxSessionTableEditTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Docxodus.Tests;

/// <summary>
/// Tests for post-insert table editing on <see cref="DocxSession"/>: 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.
/// </summary>
public class DocxSessionTableEditTests
{
Expand All @@ -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;

/// <summary>Insert a rows×cols table and return its created cell-paragraph anchors (row-major).
/// One-line <paramref name="contents"/> entries stay one paragraph per cell, so the returned
/// anchors line up with the row-major cell order.</summary>
/// <summary>Insert a rows×cols table and return its created canonical cell anchors (row-major).</summary>
private static (DocxSession session, string[] cells) NewTable(int rows, int cols,
string[]? contents = null, int[]? widths = null)
{
Expand Down Expand Up @@ -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);

Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)));
}

Expand All @@ -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);
Expand Down
16 changes: 10 additions & 6 deletions Docxodus.Tests/Ir/IrMarkdownEquivalenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,11 @@ public void MarkdownEquivalence_MustPassFixtures(string fixtureName)
// --- index comparison -------------------------------------------------

/// <summary>
/// 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 <c>col</c> anchors identify <c>w:gridCol</c> 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 <paramref name="diff"/> on mismatch.
/// </summary>
private static bool BodyIndexEqual(
Expand All @@ -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))
Expand All @@ -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}");

Expand Down
20 changes: 20 additions & 0 deletions Docxodus.Tests/Ir/IrReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,26 @@ public void Read_Table_StructureAndAnchors()
}
}

[Fact]
public void Read_TableProjectionAnchors_AllUseClosedIrVocabulary()
{
var doc = IrTestDocuments.FromBodyXml(
"<w:tbl>" +
"<w:tblPr/><w:tblGrid><w:gridCol w:w=\"100\"/><w:gridCol w:w=\"200\"/></w:tblGrid>" +
"<w:tr><w:tc><w:p><w:r><w:t>left</w:t></w:r></w:p></w:tc>" +
"<w:tc><w:p><w:r><w:t>right</w:t></w:r></w:p></w:tc></w:tr>" +
"</w:tbl>");

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<IrTable>(IrReader.Read(doc).Body.Blocks.Single());
}

[Fact]
public void Read_NestedTable_Recurses()
{
Expand Down
2 changes: 2 additions & 0 deletions Docxodus.Tests/Ir/IrValueTypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 14 additions & 18 deletions Docxodus.Tests/McpServerDispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading