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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ All notable changes to this project will be documented in this file.
`DocxSessionOps`/JSON, WASM/npm, the stdio host + `docx-scalpel`, and MCP
`docxodus_track_changes` (now also usable as a `docxodus_mutations` batch step). See
`docs/architecture/docx_mutation_api.md` and `docx_agent_server.md`.
- **Native content-control operations (#452).** `DocxSession` now enumerates and fills
Word structured-document tags as first-class objects. `ListContentControls` /
`GetContentControl` return every `w:sdt` in outer-before-inner story order under a
stable `sdt:{scope}:{unid}` anchor derived from the native `w:sdtPr/w:id`, with family,
placement, owning part, parent/depth, native metadata, data binding, current text, list
item values, and an explicit `CanMutate`/`UnsupportedReason` decision.
`FillContentControlText`, `FillContentControlRichText`, `SetContentControlChecked`,
`SetContentControlDate`, `SelectContentControlItem`, `FillContentControlPicture`,
`AddRepeatingSectionItem`, and `RemoveRepeatingSectionItem` mutate through the wrapper
without rebuilding it, preserving `w:sdtPr` metadata and the placeholder definition.
Data-bound controls fail closed unless `bindingPolicy: detach_target` removes the
target's own binding; a bound or locked ancestor always fails closed, and no Custom XML
part is ever edited. `sdt` becomes an AnchorIndex kind in both the WML projector and the
IR emitter, and `ListInlineSpans` reports outer-to-inner `ContentControlAnchorIds`.
Rippled through the JSON facade, WASM/npm, the stdio host and `docx-scalpel`, and the
new `docxodus_content_controls` MCP tool. Design: `docs/architecture/native_content_controls.md`.
- **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
Expand Down
1,563 changes: 1,563 additions & 0 deletions Docxodus.Tests/DocxSessionContentControlTests.cs

Large diffs are not rendered by default.

18 changes: 14 additions & 4 deletions Docxodus.Tests/DocxSessionTrackedStructuredDeleteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ public void DS473_DeleteRange_TracksBlockContentControlInsteadOfHardRemovingIt()
var from = FindByText(session, projection, "delete start");
var controlled = FindByText(session, projection, "controlled paragraph");
var to = FindByText(session, projection, "after");
var controlAnchor = Assert.Single(projection.AnchorIndex.Values,
target => target.Anchor.Kind == "sdt").Anchor.Id;

var result = session.DeleteRange(from, to);

Assert.True(result.Success, result.Error?.Message);
AssertAnchorAccounting(result, new[] { from, controlled }, Array.Empty<string>());
AssertAnchorAccounting(result, new[] { from, controlAnchor, controlled }, Array.Empty<string>());

var tracked = session.Save();
var body = Body(tracked);
Expand All @@ -65,13 +67,16 @@ public void DS474_NestedLockedDataBoundControls_TrackAndRoundTrip()
var outerParagraph = FindByText(session, projection, "outer paragraph");
var innerParagraph = FindByText(session, projection, "inner paragraph");
var to = FindByText(session, projection, "after");
var controls = projection.AnchorIndex.Values
.Where(target => target.Anchor.Kind == "sdt")
.Select(target => target.Anchor.Id);

var result = session.DeleteRange(from, to);

Assert.True(result.Success, result.Error?.Message);
AssertAnchorAccounting(
result,
new[] { from, outerParagraph, innerParagraph },
new[] { from, outerParagraph, innerParagraph }.Concat(controls),
Array.Empty<string>());

var tracked = session.Save();
Expand Down Expand Up @@ -123,6 +128,8 @@ public void DS475_ControlContainingTable_TracksEveryDescendantAnchorAndRoundTrip
.Where(target => tableUnids.Contains(target.Unid))
.Select(target => target.Anchor.Id)
.Append(from)
.Append(Assert.Single(projection.AnchorIndex.Values,
target => target.Anchor.Kind == "sdt").Anchor.Id)
.Distinct(StringComparer.Ordinal)
.ToList();

Expand Down Expand Up @@ -198,11 +205,13 @@ public void DS477_DeleteSection_TracksControlAndReportsSectionPropertyFallThroug
var heading = FindByText(session, projection, "Delete section");
var controlled = FindByText(session, projection, "controlled section payload");
var section = projection.AnchorIndex.Values.Single(target => target.Anchor.Kind == "sec").Anchor.Id;
var control = Assert.Single(projection.AnchorIndex.Values,
target => target.Anchor.Kind == "sdt").Anchor.Id;

var result = session.DeleteSection(heading);

Assert.True(result.Success, result.Error?.Message);
AssertAnchorAccounting(result, new[] { heading, controlled }, new[] { section });
AssertAnchorAccounting(result, new[] { heading, control, controlled }, new[] { section });
var tracked = session.Save();
Assert.Single(Body(tracked).Elements(W.sdt));
Assert.Empty(Body(tracked).Elements(W.sectPr));
Expand Down Expand Up @@ -267,7 +276,8 @@ private static string FindByText(
MarkdownProjection projection,
string text) =>
projection.AnchorIndex.Values
.Single(target => session.GetAnchorInfo(target.Anchor.Id)?.TextPreview == text)
.Single(target => target.Anchor.Kind is "p" or "h" or "li"
&& session.GetAnchorInfo(target.Anchor.Id)?.TextPreview == text)
.Anchor.Id;

private static Paragraph ParagraphWithText(string text) =>
Expand Down
10 changes: 5 additions & 5 deletions Docxodus.Tests/Ir/IrMarkdownRuleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ public void Rule_InlineSdt_DroppedFromMarkdown()
/// <summary>A block-level <c>w:sdt</c> wrapping a paragraph is SKIPPED by the oracle's EmitBlocks
/// (it dispatches only direct w:p/w:tbl/w:sectPr), so its paragraph does not render.</summary>
[Fact]
public void Rule_BlockSdt_SkippedFromMarkdown()
public void Rule_BlockSdt_PublicAnchorIsIndexedButSkippedFromMarkdown()
{
var doc = IrTestDocuments.FromBodyXml(
"<w:p><w:r><w:t>before</w:t></w:r></w:p>" +
Expand All @@ -433,11 +433,11 @@ public void Rule_BlockSdt_SkippedFromMarkdown()
var inner = Assert.IsType<IrParagraph>(Assert.Single(sdt.Blocks));
var result = IrMarkdownEmitter.Emit(ir, new WmlToMarkdownConverterSettings());

// The public projection follows the oracle's split behavior: skip the direct wrapper in
// markdown, but index its descendant paragraph. The internal sdt anchor must not leak.
// The wrapper stays visually transparent in markdown, but issue #452 makes its native
// content-control identity a public anchor alongside the descendant paragraph.
Assert.DoesNotContain("inside cc", result.Markdown);
Assert.Contains(inner.Anchor.ToString(), result.AnchorIndex.Keys);
Assert.DoesNotContain(sdt.Anchor.ToString(), result.AnchorIndex.Keys);
Assert.Contains(sdt.Anchor.ToString(), result.AnchorIndex.Keys);
}

[Fact]
Expand All @@ -458,7 +458,7 @@ public void Rule_BlockSdtInTableCell_ContributesDescendantTextAndAnchor()

Assert.Contains("inside cell", result.Markdown);
Assert.Contains(inner.Anchor.ToString(), result.AnchorIndex.Keys);
Assert.DoesNotContain(sdt.Anchor.ToString(), result.AnchorIndex.Keys);
Assert.Contains(sdt.Anchor.ToString(), result.AnchorIndex.Keys);
}

/// <summary>A tab inside a formatted run lands INSIDE that run's delimiter span (the oracle groups a
Expand Down
38 changes: 19 additions & 19 deletions Docxodus.Tests/Ir/Snapshots/HC031-Complicated-Document.ir.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
]
},
{
"anchor": "sdt:body:a4240f17299ed0b88a05ebb7e3590384",
"anchor": "sdt:body:0c9af22aec280129a1f46a236694488f",
"type": "sdt",
"contentHash": "4b57b97aff43ba1220268daed6894bd0c08da6150c1cab662dc1da5153088958",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -150,7 +150,7 @@
"kind": "textbox",
"blocks": [
{
"anchor": "sdt:body:440372fc62a2b36996f6769119c98114",
"anchor": "sdt:body:0eef9c68583303b0754f76c0029b2bb1",
"type": "sdt",
"contentHash": "7d33858406211b5e505bac5a1b87060c2c8c3df270e40861437274826c2e4213",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -181,7 +181,7 @@
]
},
{
"anchor": "sdt:body:2eae02b12b9a25dcbd7326d159bf65fb",
"anchor": "sdt:body:7f1fb0840f143d351ef9f420dbd57a2a",
"type": "sdt",
"contentHash": "dff16bff657b6a4ac6493e91a77ff8d8600aeaa5da6347df5dadd3346b5fcb23",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -212,7 +212,7 @@
"kind": "textbox",
"blocks": [
{
"anchor": "sdt:body:62979d9ccd29681f8bff69d209ffa4a7",
"anchor": "sdt:body:087728399799d03912c172cc7aba5eab",
"type": "sdt",
"contentHash": "7d33858406211b5e505bac5a1b87060c2c8c3df270e40861437274826c2e4213",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -243,7 +243,7 @@
]
},
{
"anchor": "sdt:body:24778015538abdc0ab367f4d7ba19b0e",
"anchor": "sdt:body:9a02cd18d1925f618f99903f2890984f",
"type": "sdt",
"contentHash": "dff16bff657b6a4ac6493e91a77ff8d8600aeaa5da6347df5dadd3346b5fcb23",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -1082,7 +1082,7 @@
]
},
{
"anchor": "sdt:body:8e7bd9a5f888a8641a4bea0fa536f02f",
"anchor": "sdt:body:dc8dc057002bf7360977a92eedca01ea",
"type": "sdt",
"contentHash": "d51ea8e3f63566224ff2559998100a84e11a0c50fa6ba9036b593ac63d7a6e14",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -1677,7 +1677,7 @@
]
},
{
"anchor": "sdt:body:ce958d2d376da20fefb7b712683bdb4e",
"anchor": "sdt:body:275d5a5f1fa83298c796856758dad83b",
"type": "sdt",
"contentHash": "4cc88c77d2e8927f742a9cf27757cad54993c5c1bb534b1a2e680ef65833ee1b",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2457,7 +2457,7 @@
]
},
{
"anchor": "sdt:body:6becbfcb52de31c64e084df1e1d256c8",
"anchor": "sdt:body:59b652d4cf89a793357e7226842d9d63",
"type": "sdt",
"contentHash": "98630870fc851ebd7816ae5f8271066fe80785b0b3ea50da3c8ae1968ccc2465",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand All @@ -2483,7 +2483,7 @@
"contentHash": "22fec58fca0e3a994c82398f9ea8d452503852ece6fd1462d9841d048eb52273",
"blocks": [
{
"anchor": "sdt:body:3c87db1d0f0c9b0896fbb06e4d5399d9",
"anchor": "sdt:body:32abd0df4bd4f61d0e120b33ac71a360",
"type": "sdt",
"contentHash": "cfa2d7762d61c2ec871091ea87587d76d4e5466ea8dcbf6223c3594a86ff0244",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2514,7 +2514,7 @@
"contentHash": "f022e4004c472fecfabb85138c6d2da835166adb27460f8d1c0169b0c7779cdd",
"blocks": [
{
"anchor": "sdt:body:2dc57aa9d3af8a1cd498a11fdbe89812",
"anchor": "sdt:body:191bd4a68d144fb29bcb19593b3178e6",
"type": "sdt",
"contentHash": "3e18226c25f5a8c2bf52a11c73ac656e2b8af7270aceb1670db89ae0a325ef6b",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2545,7 +2545,7 @@
"contentHash": "1b55311accf54d846c30ac2d27760014afbc04901f245776e792d83588b1a104",
"blocks": [
{
"anchor": "sdt:body:ead3bf4ef9ab3103fa09a4d85907c865",
"anchor": "sdt:body:77e503ee479d2e6fe015e0ef4a37c74d",
"type": "sdt",
"contentHash": "7a8774cc7815afd82ce3094d6d0be449907a874038af2db59ebc260d19908e2e",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2583,7 +2583,7 @@
"contentHash": "456287db0929146e82894b20e875775a1078d051de4550cd158cab0b99f8bb53",
"blocks": [
{
"anchor": "sdt:body:b04e902b02d7d3e5a8657b16882389d3",
"anchor": "sdt:body:77607c31a604e15799c5e92de9f916ae",
"type": "sdt",
"contentHash": "fcc26208c7222f1c7f639c6b2632040af98c181cd5f38acf02645635acb38cb6",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2614,7 +2614,7 @@
"contentHash": "99eed1a3a642c4b772024107d6eb6ef77c54ea1c68fbc3cdde6a1dde10d26c1a",
"blocks": [
{
"anchor": "sdt:body:714d020d7db900b2e3b4a0540bec9b90",
"anchor": "sdt:body:c0c5c5f490ef8ad94e47548f7cd0d82c",
"type": "sdt",
"contentHash": "663a7fa6e96407e7e126ab39076ea702e14f259477ae661046c65692c939f2c1",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2645,7 +2645,7 @@
"contentHash": "9adee2e6e03e12d8161398dbaff56502a0c759a95f56b14b61ed35331e1e10e4",
"blocks": [
{
"anchor": "sdt:body:9240def35a2f18fe998edcd4977cdbeb",
"anchor": "sdt:body:b3f66f6e1e099f63030d2298ae4a551c",
"type": "sdt",
"contentHash": "9c933a3b9b4f820ea8133647896efb76a6f989d58fc3982edb466186d632f1ab",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2683,7 +2683,7 @@
"contentHash": "28cdd0090102dde6d7ab026cf61fe8ecf82edcac6d105778a1b17d18825cdd98",
"blocks": [
{
"anchor": "sdt:body:d530d6169288100f3f757dc9c1b34436",
"anchor": "sdt:body:370429caf04b0f20a6a04ff502df90ca",
"type": "sdt",
"contentHash": "fac0be64ec845ccee4deb7dddc5870f4e8c7139a26f46aa3888798b0c1b1f437",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2714,7 +2714,7 @@
"contentHash": "fd164dd6280c14a5e3c69efed32e0d0295c3e8635f8c02b1e14eca11567efa24",
"blocks": [
{
"anchor": "sdt:body:4c4eefcbbf20fb8d1c91a6eb20a80292",
"anchor": "sdt:body:34ded9dda06d15fa97f7fabe02d41307",
"type": "sdt",
"contentHash": "eea1d23d6aba71d138a8027275749edbebaf773983c3edf6a27e3805ae05ff8f",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2745,7 +2745,7 @@
"contentHash": "bdf527fffb888a048076789648b2055f75bc2520364fc0fe92fd9ca267456fea",
"blocks": [
{
"anchor": "sdt:body:6091681bd2e7623c7e3330570b40d7b6",
"anchor": "sdt:body:5f4ece19a898776d32f6724e379d9dbe",
"type": "sdt",
"contentHash": "27b7350e7fe06adecd0b74ad6936933d0d9ba058f5107affc1e6ae20af4b9b97",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2784,7 +2784,7 @@
"inlines": []
},
{
"anchor": "sdt:body:4d38fb19d5ad706c83fac66179b4df50",
"anchor": "sdt:body:c03ea2ee6569f350b77d5c20c6e546cc",
"type": "sdt",
"contentHash": "09da095aacf269c2a28b7f93f548f52c91df0e361b69fd2d1e49ceb26c19b93a",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down Expand Up @@ -2983,7 +2983,7 @@
"kind": "Default",
"blocks": [
{
"anchor": "sdt:hdr1:376033737024b6ce510463080dc9c60c",
"anchor": "sdt:hdr1:f8321de5ea52a12b4aaf85f44ea92792",
"type": "sdt",
"contentHash": "3f1eceb5cf6eda6cfade38335adce8cd3ca5a5f08ffcad4b53268f0236a08b5e",
"formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658",
Expand Down
Loading
Loading