From 7592b7f533fba3f58acd2c531d03d0650eb9ec70 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 04:03:47 -0500 Subject: [PATCH 1/5] Add first-class content control operations --- .../DocxSessionContentControlTests.cs | 434 +++++++++++ Docxodus.Tests/Ir/IrMarkdownRuleTests.cs | 10 +- .../HC031-Complicated-Document.ir.json | 38 +- Docxodus.Tests/McpServerDispatcherTests.cs | 52 ++ Docxodus/DocxSession.ContentControls.cs | 734 ++++++++++++++++++ Docxodus/DocxSession.cs | 15 + Docxodus/Internal/ContentControlIdentity.cs | 111 +++ Docxodus/Internal/DocxSessionJson.cs | 75 ++ Docxodus/Internal/DocxSessionOps.cs | 69 ++ .../Internal/FormattingIntrospectionOps.cs | 5 + Docxodus/Ir/IrDocument.cs | 11 + Docxodus/Ir/IrMarkdownEmitter.cs | 34 +- Docxodus/Ir/IrReader.cs | 59 ++ Docxodus/UnidHelper.cs | 12 +- Docxodus/WmlToMarkdownConverter.cs | 1 + README.md | 1 + docs/architecture/docx_mutation_api.md | 4 +- docs/architecture/native_content_controls.md | 71 ++ npm/src/index.ts | 6 + npm/src/session.ts | 55 ++ npm/src/types.ts | 61 ++ python/src/docx_scalpel/__init__.py | 12 + python/src/docx_scalpel/enums.py | 10 + python/src/docx_scalpel/session.py | 74 ++ python/src/docx_scalpel/types.py | 98 +++ tools/mcp-server/Dispatcher.cs | 94 +++ tools/mcp-server/ToolCatalog.cs | 31 +- tools/python-host/Dispatcher.cs | 26 + wasm/DocxodusWasm/DocxSessionBridge.cs | 45 ++ 29 files changed, 2217 insertions(+), 31 deletions(-) create mode 100644 Docxodus.Tests/DocxSessionContentControlTests.cs create mode 100644 Docxodus/DocxSession.ContentControls.cs create mode 100644 Docxodus/Internal/ContentControlIdentity.cs create mode 100644 docs/architecture/native_content_controls.md diff --git a/Docxodus.Tests/DocxSessionContentControlTests.cs b/Docxodus.Tests/DocxSessionContentControlTests.cs new file mode 100644 index 00000000..099a8f5e --- /dev/null +++ b/Docxodus.Tests/DocxSessionContentControlTests.cs @@ -0,0 +1,434 @@ +// 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; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Validation; +using Docxodus; +using Docxodus.Ir; +using Xunit; + +namespace Docxodus.Tests; + +public sealed class DocxSessionContentControlTests +{ + private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + private static readonly XNamespace W14 = "http://schemas.microsoft.com/office/word/2010/wordml"; + private static readonly XNamespace W15 = "http://schemas.microsoft.com/office/word/2012/wordml"; + + [Fact] + public void CC001_Registry_IsOuterBeforeInner_AndReportsNativeMetadataPlacementAndFailures() + { + using var session = new DocxSession(BuildFixture()); + var controls = session.ListContentControls(); + Assert.Equal(new[] { "100", "101", "102", "103", "104", "105", "106", + "107", "107", null, "108", "109", "110", "111", "112" }, + controls.Select(control => control.NativeId).ToArray()); + + var outer = controls[0]; + var inner = controls[1]; + Assert.Equal(ContentControlType.RichText, outer.Type); + Assert.Equal(ContentControlPlacement.Block, outer.Placement); + Assert.Equal("outer-tag", outer.Tag); + Assert.Equal("Outer alias", outer.Alias); + Assert.Equal(outer.AnchorId, inner.ParentAnchorId); + Assert.Equal(1, inner.Depth); + Assert.Equal(ContentControlPlacement.Inline, inner.Placement); + Assert.Contains(inner.AnchorId, + session.ListInlineSpans(ParagraphAnchors(session).First(anchor => + session.Project().AnchorIndex[anchor].TextPreview.Contains("inner", StringComparison.Ordinal))) + .SelectMany(span => span.ContentControlAnchorIds)); + + Assert.True(controls.Single(control => control.NativeId == "106").IsBound); + Assert.True(controls.Single(control => control.NativeId == "106").CanDetachTargetBinding); + Assert.All(controls.Where(control => control.NativeId == "107"), control => + { + Assert.True(control.HasDuplicateNativeId); + Assert.False(control.CanMutate); + }); + Assert.False(controls.Single(control => control.NativeId is null).HasValidNativeId); + Assert.Equal(ContentControlType.Unsupported, + controls.Single(control => control.NativeId == "110").Type); + } + + [Fact] + public void CC002_TextFamilies_PreserveWrapperProperties_AndUndoRedo() + { + using var session = new DocxSession(BuildFixture()); + var controls = session.ListContentControls(); + string Id(string native) => controls.Single(control => control.NativeId == native).AnchorId; + + var nestedParent = session.FillContentControlRichText(Id("100"), "**replacement**"); + Assert.Equal(EditErrorCode.ContentControlNestedFillUnsupported, nestedParent.Error!.Code); + int before = session.UndoCount; + Assert.True(session.FillContentControlText(Id("101"), "new inner").Success); + Assert.True(session.SetContentControlChecked(Id("102"), true).Success); + Assert.True(session.SetContentControlDate(Id("103"), + DateTimeOffset.Parse("2031-05-06T00:00:00Z"), "May 6, 2031").Success); + Assert.True(session.SelectContentControlItem(Id("104"), "b").Success); + Assert.True(session.SelectContentControlItem(Id("105"), "Alpha").Success); + Assert.Equal(before + 5, session.UndoCount); + + var live = session.ListContentControls(); + Assert.Equal("new inner", live.Single(control => control.NativeId == "101").Text); + Assert.Equal("☒", live.Single(control => control.NativeId == "102").Text); + Assert.Equal("May 6, 2031", live.Single(control => control.NativeId == "103").Text); + Assert.Equal("Beta", live.Single(control => control.NativeId == "104").Text); + Assert.Equal("Alpha", live.Single(control => control.NativeId == "105").Text); + Assert.Equal("outer-tag", live.Single(control => control.NativeId == "100").Tag); + Assert.True(session.Undo()); + Assert.Equal("pick", session.ListContentControls().Single(control => control.NativeId == "105").Text); + Assert.True(session.Redo()); + Assert.Equal("Alpha", session.ListContentControls().Single(control => control.NativeId == "105").Text); + + var saved = session.Save(); + using var reopened = new DocxSession(saved); + Assert.Equal("☒", reopened.ListContentControls() + .Single(control => control.NativeId == "102").Text); + Assert.Equal("May 6, 2031", reopened.ListContentControls() + .Single(control => control.NativeId == "103").Text); + Assert.Equal("Beta", reopened.ListContentControls() + .Single(control => control.NativeId == "104").Text); + using var doc = WordprocessingDocument.Open(new MemoryStream(saved), false); + var validationErrors = new OpenXmlValidator(FileFormatVersions.Office2013).Validate(doc) + .Where(IsMaterialValidationError).ToList(); + Assert.True(validationErrors.Count == 0, string.Join(Environment.NewLine, + validationErrors.Select(validation => + $"{validation.Description} Node: {validation.Node?.OuterXml}"))); + } + + [Fact] + public void CC003_BindingFailsClosed_DetachIsTargetOnly_AndCustomXmlBytesStayExact() + { + var fixture = BuildFixture(); + var customBefore = CustomXmlBytes(fixture); + using var session = new DocxSession(fixture); + var bound = session.ListContentControls().Single(control => control.NativeId == "106"); + var refused = session.FillContentControlText(bound.AnchorId, "bound replacement"); + Assert.Equal(EditErrorCode.ContentControlBound, refused.Error!.Code); + Assert.Equal(0, session.UndoCount); + + var changed = session.FillContentControlText(bound.AnchorId, "detached replacement", + new ContentControlFillOptions { BindingPolicy = ContentControlBindingPolicy.DetachTarget }); + Assert.True(changed.Success, changed.Error?.Message); + var saved = session.Save(); + Assert.Equal(customBefore, CustomXmlBytes(saved)); + using var doc = WordprocessingDocument.Open(new MemoryStream(saved), false); + var control = doc.MainDocumentPart!.GetXDocument().Descendants(W + "sdt") + .Single(value => (string?)value.Element(W + "sdtPr")?.Element(W + "id")?.Attribute(W + "val") == "106"); + Assert.Null(control.Element(W + "sdtPr")?.Element(W + "dataBinding")); + Assert.Equal("detached replacement", + string.Concat(control.Descendants(W + "t").Select(text => text.Value))); + } + + [Fact] + public void CC004_EffectiveLocksMalformedUnsupportedAndTrackedModeFailWithoutHistory() + { + using var session = new DocxSession(BuildFixture()); + var controls = session.ListContentControls(); + EditResult Fill(string native) => session.FillContentControlText( + controls.First(control => control.NativeId == native).AnchorId, "x"); + Assert.Equal(EditErrorCode.ContentControlLocked, Fill("112").Error!.Code); + Assert.Equal(EditErrorCode.ContentControlMalformed, Fill("107").Error!.Code); + Assert.Equal(EditErrorCode.ContentControlUnsupported, Fill("110").Error!.Code); + Assert.Equal(0, session.UndoCount); + + session.SetTrackedChanges(TrackedChangeMode.RenderInline); + Assert.Equal(EditErrorCode.TrackedOperationUnsupported, Fill("101").Error!.Code); + var paragraph = ParagraphAnchors(session).First(anchor => + session.Project().AnchorIndex[anchor].TextPreview.Contains("inner", StringComparison.Ordinal)); + Assert.True(session.ReplaceTextAtSpan(paragraph, 7, 5, "INNER").Success); + } + + [Fact] + public void CC005_DefaultSaveReopen_RederivesSameSdtAnchorsFromNativeIds() + { + using var session = new DocxSession(BuildFixture()); + var original = session.ListContentControls() + .Where(control => control.HasValidNativeId && !control.HasDuplicateNativeId) + .ToDictionary(control => control.NativeId!, control => control.AnchorId); + Assert.True(session.FillContentControlText(original["101"], "identity-independent value").Success); + var saved = session.Save(false); + Assert.DoesNotContain("Unid", Encoding.UTF8.GetString(saved), StringComparison.Ordinal); + + using var reopened = new DocxSession(saved); + var after = reopened.ListContentControls() + .Where(control => control.HasValidNativeId && !control.HasDuplicateNativeId) + .ToDictionary(control => control.NativeId!, control => control.AnchorId); + Assert.Equal(original, after); + Assert.Equal("identity-independent value", reopened.GetContentControl(original["101"])!.Text); + } + + [Fact] + public void CC006_RepeatingSectionCloneFreshensNestedIds_AndIsUndoable() + { + using var session = new DocxSession(BuildFixture()); + var section = session.ListContentControls().Single(control => control.NativeId == "108"); + var add = session.AddRepeatingSectionItem(section.AnchorId); + Assert.True(add.Success, add.Error?.Message); + Assert.Single(add.Created); + var controls = session.ListContentControls(); + var sections = controls.Where(control => control.Type == ContentControlType.RepeatingSectionItem).ToList(); + Assert.Equal(2, sections.Count); + Assert.Equal(2, sections.Select(control => control.NativeId).Distinct().Count()); + Assert.All(sections, control => Assert.Equal(section.AnchorId, control.ParentAnchorId)); + var repeatedParagraphs = session.Project().AnchorIndex.Values.Where(value => + value.Anchor.Kind == "p" && value.TextPreview == "item").ToList(); + Assert.Equal(2, repeatedParagraphs.Count); + Assert.Equal(2, repeatedParagraphs.Select(value => value.Anchor.Id).Distinct().Count()); + + Assert.True(session.Undo()); + Assert.Single(session.ListContentControls().Where(control => + control.Type == ContentControlType.RepeatingSectionItem)); + Assert.True(session.Redo()); + var item = session.ListContentControls().Last(control => + control.Type == ContentControlType.RepeatingSectionItem); + Assert.True(session.RemoveRepeatingSectionItem(item.AnchorId).Success); + Assert.Single(session.ListContentControls().Where(control => + control.Type == ContentControlType.RepeatingSectionItem)); + } + + [Fact] + public void CC007_OracleAndIrExposeSameSdtIndex_WithoutChangingMarkdownBytes() + { + var fixture = BuildFixture(); + var settings = new WmlToMarkdownConverterSettings(); + var oracle = WmlToMarkdownConverter.Convert(new WmlDocument("controls.docx", fixture), settings); + var ir = IrMarkdownEmitter.Emit(IrReader.Read(new WmlDocument("controls.docx", fixture), + new IrReaderOptions { RetainSources = false }), settings).ToProjection(); + Assert.Equal(oracle.Markdown, ir.Markdown); + Assert.Equal(oracle.AnchorIndex.Keys, ir.AnchorIndex.Keys); + Assert.Equal(15, oracle.AnchorIndex.Values.Select(value => value.Anchor.Id).Distinct() + .Count(id => id.StartsWith("sdt:", StringComparison.Ordinal))); + + using var session = new DocxSession(fixture); + Assert.DoesNotContain(session.ListBlocks().Body, unit => unit.Kind == "sdt"); + int handle = Docxodus.Internal.DocxSessionOps.OpenSession(fixture, null); + try + { + var html = Docxodus.Internal.DocxSessionOps.RenderHtml( + handle, "dx-", false, false, 1.0); + Assert.Contains("inner", html); + Assert.DoesNotContain(" control.TryGetProperty("nativeId", out var id) + && id.GetString() == "101"); + Assert.Equal("plain_text", plain.GetProperty("type").GetString()); + Assert.Equal("inline", plain.GetProperty("placement").GetString()); + Assert.NotEmpty(plain.GetProperty("parentAnchorId").GetString()!); + + var anchor = plain.GetProperty("anchorId").GetString()!; + using var filled = JsonDocument.Parse( + Docxodus.Internal.DocxSessionOps.FillContentControlText( + handle, anchor, "transport value", "{}")); + Assert.True(filled.RootElement.GetProperty("success").GetBoolean()); + + using var invalidOptions = JsonDocument.Parse( + Docxodus.Internal.DocxSessionOps.FillContentControlText( + handle, anchor, "ignored", "{\"unknown\":true}")); + Assert.False(invalidOptions.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("invalid_content_control_value", invalidOptions.RootElement + .GetProperty("error").GetProperty("code").GetString()); + + using var invalidDate = JsonDocument.Parse( + Docxodus.Internal.DocxSessionOps.SetContentControlDate( + handle, controls.Single(control => control.TryGetProperty("nativeId", out var id) + && id.GetString() == "103") + .GetProperty("anchorId").GetString()!, "not-a-date", null, "{}")); + Assert.Equal("invalid_content_control_value", invalidDate.RootElement + .GetProperty("error").GetProperty("code").GetString()); + } + finally + { + Docxodus.Internal.DocxSessionOps.CloseSession(handle); + } + } + + [Fact] + public void CC009_PictureFill_ReusesNativeImageValidationAndRelationshipSeams() + { + using var session = new DocxSession(BuildPictureFixture()); + var picture = session.ListContentControls().Single(control => control.NativeId == "113"); + var before = Assert.Single(session.ListImages().Where(image => image.AnchorId is not null + && image.AnchorId.StartsWith("p:body:", StringComparison.Ordinal) + && image.IntrinsicWidthPixels == 2)); + Assert.True(session.FillContentControlPicture(picture.AnchorId, Png(7, 9)).Success); + var after = Assert.Single(session.ListImages().Where(image => image.AnchorId == before.AnchorId)); + Assert.Equal(7, after.IntrinsicWidthPixels); + Assert.Equal(9, after.IntrinsicHeightPixels); + Assert.Equal("picture-tag", session.GetContentControl(picture.AnchorId)!.Tag); + Assert.True(session.Undo()); + Assert.Equal(2, Assert.Single(session.ListImages().Where(image => + image.AnchorId == before.AnchorId)).IntrinsicWidthPixels); + + Assert.True(session.Redo()); + var saved = session.Save(); + using var reopened = new DocxSession(saved); + Assert.Equal(7, Assert.Single(reopened.ListImages().Where(image => + image.IntrinsicWidthPixels == 7)).IntrinsicWidthPixels); + using var document = WordprocessingDocument.Open(new MemoryStream(saved), false); + Assert.Empty(new OpenXmlValidator(FileFormatVersions.Office2013).Validate(document) + .Where(IsMaterialValidationError)); + } + + private static string[] ParagraphAnchors(DocxSession session) => session.Project().AnchorIndex.Values + .Where(value => value.Anchor.Kind is "p" or "h" or "li") + .Select(value => value.Anchor.Id).Distinct().ToArray(); + + internal static byte[] BuildFixture() + { + var bytes = DocxSessionTests.BuildDS001_SimpleTwoParagraphs(); + using var stream = new MemoryStream(); + stream.Write(bytes); + stream.Position = 0; + using (var doc = WordprocessingDocument.Open(stream, true)) + { + var main = doc.MainDocumentPart!; + var body = main.GetXDocument().Root!.Element(W + "body")!; + body.Elements().Where(value => value.Name != W + "sectPr").Remove(); + body.AddFirst( + BlockSdt("100", new XElement(W + "richText"), "outer value", + tag: "outer-tag", alias: "Outer alias", nestedInline: Sdt("101", + new XElement(W + "text"), "inner")), + BlockSdt("102", new XElement(W14 + "checkbox", + new XElement(W14 + "checked", new XAttribute(W14 + "val", "0")), + new XElement(W14 + "checkedState", new XAttribute(W14 + "val", "2612")), + new XElement(W14 + "uncheckedState", new XAttribute(W14 + "val", "2610"))), "☐"), + BlockSdt("103", new XElement(W + "date", + new XElement(W + "dateFormat", new XAttribute(W + "val", "MMMM d, yyyy"))), "date"), + BlockSdt("104", new XElement(W + "dropDownList", + Item("Alpha", "a"), Item("Beta", "b")), "pick"), + BlockSdt("105", new XElement(W + "comboBox", + Item("Alpha", "a"), Item("Beta", "b")), "pick"), + BlockSdt("106", new XElement(W + "text"), "bound", + binding: new XElement(W + "dataBinding", + new XAttribute(W + "storeItemID", "{11111111-1111-1111-1111-111111111111}"), + new XAttribute(W + "xpath", "/root/value"), + new XAttribute(W + "prefixMappings", "xmlns:x='urn:test'"))), + BlockSdt("107", new XElement(W + "text"), "duplicate one"), + BlockSdt("107", new XElement(W + "text"), "duplicate two"), + BlockSdt(null, new XElement(W + "text"), "missing id"), + RepeatingSection(), + BlockSdt("110", new XElement(W + "group"), "unsupported"), + BlockSdt("111", new XElement(W + "richText"), "locked outer", + lockToken: "contentLocked", nestedInline: Sdt("112", + new XElement(W + "text"), "locked child"))); + main.PutXDocument(); + + var custom = main.AddCustomXmlPart(CustomXmlPartType.CustomXml); + using var input = new MemoryStream(Encoding.UTF8.GetBytes("bound")); + custom.FeedData(input); + } + return stream.ToArray(); + } + + private static byte[] BuildPictureFixture() + { + var stream = new MemoryStream(); + stream.Write(BuildFixture()); + stream.Position = 0; + using (var document = WordprocessingDocument.Open(stream, true)) + { + var main = document.MainDocumentPart!; + var body = main.GetXDocument().Root!.Element(W + "body")!; + body.Add(BlockSdt("113", new XElement(W + "picture"), + "picture placeholder", tag: "picture-tag")); + main.PutXDocument(); + } + using var seed = new DocxSession(stream.ToArray()); + var paragraph = seed.Project().AnchorIndex.Values.Single(value => + value.Anchor.Kind == "p" && value.TextPreview.Contains("picture placeholder", + StringComparison.Ordinal)); + Assert.True(seed.InsertImage(paragraph.Anchor.Id, 0, Png(2, 3)).Success); + return seed.Save(); + } + + private static byte[] Png(int width, int height) + { + var bytes = new byte[24]; + new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0, 0, 0, 13, (byte)'I', (byte)'H', (byte)'D', (byte)'R' }.CopyTo(bytes, 0); + bytes[16] = (byte)(width >> 24); bytes[17] = (byte)(width >> 16); + bytes[18] = (byte)(width >> 8); bytes[19] = (byte)width; + bytes[20] = (byte)(height >> 24); bytes[21] = (byte)(height >> 16); + bytes[22] = (byte)(height >> 8); bytes[23] = (byte)height; + return bytes; + } + + private static XElement Sdt(string? id, XElement type, string text, + string? tag = null, string? alias = null, string? lockToken = null, + XElement? binding = null, XElement? nestedInline = null) => + new(W + "sdt", + new XElement(W + "sdtPr", + id is null ? null : new XElement(W + "id", new XAttribute(W + "val", id)), + tag is null ? null : new XElement(W + "tag", new XAttribute(W + "val", tag)), + alias is null ? null : new XElement(W + "alias", new XAttribute(W + "val", alias)), + lockToken is null ? null : new XElement(W + "lock", new XAttribute(W + "val", lockToken)), + binding, type), + new XElement(W + "sdtContent", + new XElement(W + "r", new XElement(W + "t", text)), nestedInline)); + + private static XElement BlockSdt(string? id, XElement type, string text, + string? tag = null, string? alias = null, string? lockToken = null, + XElement? binding = null, XElement? nestedInline = null) + { + var inline = Sdt(id, type, text, tag, alias, lockToken, binding, nestedInline); + inline.Element(W + "sdtContent")!.ReplaceNodes(new XElement(W + "p", + new XElement(W + "r", new XElement(W + "t", text)), nestedInline)); + return inline; + } + + private static XElement Item(string display, string value) => + new(W + "listItem", new XAttribute(W + "displayText", display), + new XAttribute(W + "value", value)); + + private static XElement RepeatingSection() => + new(W + "sdt", + new XElement(W + "sdtPr", + new XElement(W + "id", new XAttribute(W + "val", "108")), + new XElement(W15 + "repeatingSection")), + new XElement(W + "sdtContent", + new XElement(W + "sdt", + new XElement(W + "sdtPr", + new XElement(W + "id", new XAttribute(W + "val", "109")), + new XElement(W15 + "repeatingSectionItem")), + new XElement(W + "sdtContent", + new XElement(W + "p", new XElement(W + "r", new XElement(W + "t", "item"))))))); + + private static byte[] CustomXmlBytes(byte[] bytes) + { + using var doc = WordprocessingDocument.Open(new MemoryStream(bytes), false); + using var input = doc.MainDocumentPart!.CustomXmlParts.Single().GetStream(); + using var output = new MemoryStream(); + input.CopyTo(output); + return output.ToArray(); + } + + private static bool IsMaterialValidationError(ValidationErrorInfo error) => + error.Description?.Contains("The 'Ignorable' attribute", StringComparison.Ordinal) != true + && error.Description?.Contains("http://powertools.codeplex.com/2011:Unid", StringComparison.Ordinal) != true; +} diff --git a/Docxodus.Tests/Ir/IrMarkdownRuleTests.cs b/Docxodus.Tests/Ir/IrMarkdownRuleTests.cs index 45f31cef..be696eca 100644 --- a/Docxodus.Tests/Ir/IrMarkdownRuleTests.cs +++ b/Docxodus.Tests/Ir/IrMarkdownRuleTests.cs @@ -420,7 +420,7 @@ public void Rule_InlineSdt_DroppedFromMarkdown() /// A block-level w:sdt 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. [Fact] - public void Rule_BlockSdt_SkippedFromMarkdown() + public void Rule_BlockSdt_PublicAnchorIsIndexedButSkippedFromMarkdown() { var doc = IrTestDocuments.FromBodyXml( "before" + @@ -433,11 +433,11 @@ public void Rule_BlockSdt_SkippedFromMarkdown() var inner = Assert.IsType(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] @@ -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); } /// A tab inside a formatted run lands INSIDE that run's delimiter span (the oracle groups a diff --git a/Docxodus.Tests/Ir/Snapshots/HC031-Complicated-Document.ir.json b/Docxodus.Tests/Ir/Snapshots/HC031-Complicated-Document.ir.json index 6299ba9c..401c30fd 100644 --- a/Docxodus.Tests/Ir/Snapshots/HC031-Complicated-Document.ir.json +++ b/Docxodus.Tests/Ir/Snapshots/HC031-Complicated-Document.ir.json @@ -102,7 +102,7 @@ ] }, { - "anchor": "sdt:body:a4240f17299ed0b88a05ebb7e3590384", + "anchor": "sdt:body:0c9af22aec280129a1f46a236694488f", "type": "sdt", "contentHash": "4b57b97aff43ba1220268daed6894bd0c08da6150c1cab662dc1da5153088958", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -150,7 +150,7 @@ "kind": "textbox", "blocks": [ { - "anchor": "sdt:body:440372fc62a2b36996f6769119c98114", + "anchor": "sdt:body:0eef9c68583303b0754f76c0029b2bb1", "type": "sdt", "contentHash": "7d33858406211b5e505bac5a1b87060c2c8c3df270e40861437274826c2e4213", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -181,7 +181,7 @@ ] }, { - "anchor": "sdt:body:2eae02b12b9a25dcbd7326d159bf65fb", + "anchor": "sdt:body:7f1fb0840f143d351ef9f420dbd57a2a", "type": "sdt", "contentHash": "dff16bff657b6a4ac6493e91a77ff8d8600aeaa5da6347df5dadd3346b5fcb23", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -212,7 +212,7 @@ "kind": "textbox", "blocks": [ { - "anchor": "sdt:body:62979d9ccd29681f8bff69d209ffa4a7", + "anchor": "sdt:body:087728399799d03912c172cc7aba5eab", "type": "sdt", "contentHash": "7d33858406211b5e505bac5a1b87060c2c8c3df270e40861437274826c2e4213", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -243,7 +243,7 @@ ] }, { - "anchor": "sdt:body:24778015538abdc0ab367f4d7ba19b0e", + "anchor": "sdt:body:9a02cd18d1925f618f99903f2890984f", "type": "sdt", "contentHash": "dff16bff657b6a4ac6493e91a77ff8d8600aeaa5da6347df5dadd3346b5fcb23", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -1082,7 +1082,7 @@ ] }, { - "anchor": "sdt:body:8e7bd9a5f888a8641a4bea0fa536f02f", + "anchor": "sdt:body:dc8dc057002bf7360977a92eedca01ea", "type": "sdt", "contentHash": "d51ea8e3f63566224ff2559998100a84e11a0c50fa6ba9036b593ac63d7a6e14", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -1677,7 +1677,7 @@ ] }, { - "anchor": "sdt:body:ce958d2d376da20fefb7b712683bdb4e", + "anchor": "sdt:body:275d5a5f1fa83298c796856758dad83b", "type": "sdt", "contentHash": "4cc88c77d2e8927f742a9cf27757cad54993c5c1bb534b1a2e680ef65833ee1b", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2457,7 +2457,7 @@ ] }, { - "anchor": "sdt:body:6becbfcb52de31c64e084df1e1d256c8", + "anchor": "sdt:body:59b652d4cf89a793357e7226842d9d63", "type": "sdt", "contentHash": "98630870fc851ebd7816ae5f8271066fe80785b0b3ea50da3c8ae1968ccc2465", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2483,7 +2483,7 @@ "contentHash": "22fec58fca0e3a994c82398f9ea8d452503852ece6fd1462d9841d048eb52273", "blocks": [ { - "anchor": "sdt:body:3c87db1d0f0c9b0896fbb06e4d5399d9", + "anchor": "sdt:body:32abd0df4bd4f61d0e120b33ac71a360", "type": "sdt", "contentHash": "cfa2d7762d61c2ec871091ea87587d76d4e5466ea8dcbf6223c3594a86ff0244", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2514,7 +2514,7 @@ "contentHash": "f022e4004c472fecfabb85138c6d2da835166adb27460f8d1c0169b0c7779cdd", "blocks": [ { - "anchor": "sdt:body:2dc57aa9d3af8a1cd498a11fdbe89812", + "anchor": "sdt:body:191bd4a68d144fb29bcb19593b3178e6", "type": "sdt", "contentHash": "3e18226c25f5a8c2bf52a11c73ac656e2b8af7270aceb1670db89ae0a325ef6b", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2545,7 +2545,7 @@ "contentHash": "1b55311accf54d846c30ac2d27760014afbc04901f245776e792d83588b1a104", "blocks": [ { - "anchor": "sdt:body:ead3bf4ef9ab3103fa09a4d85907c865", + "anchor": "sdt:body:77e503ee479d2e6fe015e0ef4a37c74d", "type": "sdt", "contentHash": "7a8774cc7815afd82ce3094d6d0be449907a874038af2db59ebc260d19908e2e", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2583,7 +2583,7 @@ "contentHash": "456287db0929146e82894b20e875775a1078d051de4550cd158cab0b99f8bb53", "blocks": [ { - "anchor": "sdt:body:b04e902b02d7d3e5a8657b16882389d3", + "anchor": "sdt:body:77607c31a604e15799c5e92de9f916ae", "type": "sdt", "contentHash": "fcc26208c7222f1c7f639c6b2632040af98c181cd5f38acf02645635acb38cb6", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2614,7 +2614,7 @@ "contentHash": "99eed1a3a642c4b772024107d6eb6ef77c54ea1c68fbc3cdde6a1dde10d26c1a", "blocks": [ { - "anchor": "sdt:body:714d020d7db900b2e3b4a0540bec9b90", + "anchor": "sdt:body:c0c5c5f490ef8ad94e47548f7cd0d82c", "type": "sdt", "contentHash": "663a7fa6e96407e7e126ab39076ea702e14f259477ae661046c65692c939f2c1", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2645,7 +2645,7 @@ "contentHash": "9adee2e6e03e12d8161398dbaff56502a0c759a95f56b14b61ed35331e1e10e4", "blocks": [ { - "anchor": "sdt:body:9240def35a2f18fe998edcd4977cdbeb", + "anchor": "sdt:body:b3f66f6e1e099f63030d2298ae4a551c", "type": "sdt", "contentHash": "9c933a3b9b4f820ea8133647896efb76a6f989d58fc3982edb466186d632f1ab", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2683,7 +2683,7 @@ "contentHash": "28cdd0090102dde6d7ab026cf61fe8ecf82edcac6d105778a1b17d18825cdd98", "blocks": [ { - "anchor": "sdt:body:d530d6169288100f3f757dc9c1b34436", + "anchor": "sdt:body:370429caf04b0f20a6a04ff502df90ca", "type": "sdt", "contentHash": "fac0be64ec845ccee4deb7dddc5870f4e8c7139a26f46aa3888798b0c1b1f437", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2714,7 +2714,7 @@ "contentHash": "fd164dd6280c14a5e3c69efed32e0d0295c3e8635f8c02b1e14eca11567efa24", "blocks": [ { - "anchor": "sdt:body:4c4eefcbbf20fb8d1c91a6eb20a80292", + "anchor": "sdt:body:34ded9dda06d15fa97f7fabe02d41307", "type": "sdt", "contentHash": "eea1d23d6aba71d138a8027275749edbebaf773983c3edf6a27e3805ae05ff8f", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2745,7 +2745,7 @@ "contentHash": "bdf527fffb888a048076789648b2055f75bc2520364fc0fe92fd9ca267456fea", "blocks": [ { - "anchor": "sdt:body:6091681bd2e7623c7e3330570b40d7b6", + "anchor": "sdt:body:5f4ece19a898776d32f6724e379d9dbe", "type": "sdt", "contentHash": "27b7350e7fe06adecd0b74ad6936933d0d9ba058f5107affc1e6ae20af4b9b97", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2784,7 +2784,7 @@ "inlines": [] }, { - "anchor": "sdt:body:4d38fb19d5ad706c83fac66179b4df50", + "anchor": "sdt:body:c03ea2ee6569f350b77d5c20c6e546cc", "type": "sdt", "contentHash": "09da095aacf269c2a28b7f93f548f52c91df0e361b69fd2d1e49ceb26c19b93a", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", @@ -2983,7 +2983,7 @@ "kind": "Default", "blocks": [ { - "anchor": "sdt:hdr1:376033737024b6ce510463080dc9c60c", + "anchor": "sdt:hdr1:f8321de5ea52a12b4aaf85f44ea92792", "type": "sdt", "contentHash": "3f1eceb5cf6eda6cfade38335adce8cd3ca5a5f08ffcad4b53268f0236a08b5e", "formatFingerprint": "7a0e64a1117e67cf087c5f7f7f32ab6ccdc8e3157ee173652d96314f944cc658", diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs index bc38535d..a6dccbe3 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -1467,6 +1467,7 @@ public void MCP100_ToolCatalog_HasExpectedDistinctNamedToolsWithValidSchemas() "docxodus_annotate", "docxodus_close", "docxodus_comment", + "docxodus_content_controls", "docxodus_create", "docxodus_edit", "docxodus_format", @@ -2240,4 +2241,55 @@ public void MCP145_NativeImageBatchPreviewRollsBackParts_AndRejectsReadOnlyActio Assert.Equal("invalid_batch_step", invalid.GetProperty("failure").GetProperty("error").GetProperty("code").GetString()); } + + [Fact] + public void MCP146_ContentControls_ListFillDetachAndBatchPreview_AreFirstClass() + { + File.WriteAllBytes(_tempPath, DocxSessionContentControlTests.BuildFixture()); + var sessionId = OpenSession(); + var sessionArg = JsonSerializer.Serialize(sessionId); + var listed = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"list","scope":"body"}"""))) + .GetProperty("contentControls").EnumerateArray().ToArray(); + Assert.Equal(15, listed.Length); + var plain = listed.Single(control => control.TryGetProperty("nativeId", out var id) + && id.GetString() == "101"); + var plainAnchor = plain.GetProperty("anchorId").GetString()!; + var filled = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"fill_text","anchorId":{{JsonSerializer.Serialize(plainAnchor)}},"text":"MCP value"}"""))); + Assert.True(filled.GetProperty("success").GetBoolean()); + + var bound = listed.Single(control => control.TryGetProperty("nativeId", out var id) + && id.GetString() == "106"); + var boundAnchor = bound.GetProperty("anchorId").GetString()!; + var refused = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"fill_text","anchorId":{{JsonSerializer.Serialize(boundAnchor)}},"text":"no"}"""))); + Assert.Equal("content_control_bound", + refused.GetProperty("error").GetProperty("code").GetString()); + var detached = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"fill_text","anchorId":{{JsonSerializer.Serialize(boundAnchor)}},"text":"yes","bindingPolicy":"detach_target"}"""))); + Assert.True(detached.GetProperty("success").GetBoolean()); + + var previewArgs = JsonSerializer.Serialize(new + { + sessionId, + mode = "preview", + steps = new[] { new { tool = "docxodus_content_controls", + args = new { action = "fill_text", anchorId = plainAnchor, text = "preview" } } }, + }); + Assert.Equal("ok", Parse(Dispatcher.Call(_store, "docxodus_mutations", J(previewArgs))) + .GetProperty("status").GetString()); + var after = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"list"}"""))) + .GetProperty("contentControls").EnumerateArray().Single(control => + control.TryGetProperty("nativeId", out var id) && id.GetString() == "101"); + Assert.Equal("MCP value", after.GetProperty("text").GetString()); + + var tool = Assert.Single(ToolCatalog.Tools, + definition => definition.Name == "docxodus_content_controls"); + using var schema = JsonDocument.Parse(tool.InputSchemaJson); + Assert.Contains("detach_target", schema.RootElement.GetProperty("properties") + .GetProperty("bindingPolicy").GetProperty("enum").EnumerateArray() + .Select(value => value.GetString())); + } } diff --git a/Docxodus/DocxSession.ContentControls.cs b/Docxodus/DocxSession.ContentControls.cs new file mode 100644 index 00000000..0695d0ee --- /dev/null +++ b/Docxodus/DocxSession.ContentControls.cs @@ -0,0 +1,734 @@ +// 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; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using Docxodus.Internal; + +namespace Docxodus; + +public enum ContentControlType +{ + PlainText, + RichText, + Checkbox, + Date, + DropDownList, + ComboBox, + Picture, + RepeatingSection, + RepeatingSectionItem, + Unsupported, +} + +public enum ContentControlPlacement { Inline, Block, Row, Cell, Unknown } + +public enum ContentControlBindingPolicy +{ + /// Never alter a binding. Bound controls fail closed. + Preserve = 0, + + /// Remove only the selected control's own w:dataBinding before filling it. + /// A binding on any ancestor still fails closed. + DetachTarget = 1, +} + +public sealed record ContentControlFillOptions +{ + public ContentControlBindingPolicy BindingPolicy { get; init; } = ContentControlBindingPolicy.Preserve; +} + +public sealed record ContentControlBindingInfo( + string? StoreItemId, string? XPath, string? PrefixMappings); + +/// A native Word structured-document tag in outer-before-inner story order. +public sealed record ContentControlInfo +{ + required public string AnchorId { get; init; } + required public ContentControlType Type { get; init; } + required public ContentControlPlacement Placement { get; init; } + public string? NativeId { get; init; } + public string? Tag { get; init; } + public string? Alias { get; init; } + public string? Lock { get; init; } + public bool IsShowingPlaceholder { get; init; } + public ContentControlBindingInfo? Binding { get; init; } + public bool IsBound => Binding is not null; + required public string OwningPartUri { get; init; } + required public string Scope { get; init; } + public string? ParentAnchorId { get; init; } + public int Depth { get; init; } + public bool HasValidNativeId { get; init; } + public bool HasDuplicateNativeId { get; init; } + public bool CanMutate { get; init; } + public bool CanDetachTargetBinding { get; init; } + public string? UnsupportedReason { get; init; } + public string Text { get; init; } = string.Empty; + public IReadOnlyList ItemValues { get; init; } = Array.Empty(); +} + +public sealed partial class DocxSession +{ + private static readonly XNamespace ContentControlW = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + private static readonly XNamespace ContentControlW14 = + "http://schemas.microsoft.com/office/word/2010/wordml"; + private static readonly XNamespace ContentControlW15 = + "http://schemas.microsoft.com/office/word/2012/wordml"; + + private sealed record ContentControlCandidate( + OwnedPartRelationships.Owner Owner, + XElement Element, + ContentControlIdentity.Entry Identity, + ContentControlInfo Info); + + public IReadOnlyList ListContentControls( + ProjectionScopes scopes = ProjectionScopes.All) + { + ThrowIfDisposed(); + return BuildContentControlRegistry(scopes).Select(candidate => candidate.Info).ToList(); + } + + public ContentControlInfo? GetContentControl(string anchorId) + { + ThrowIfDisposed(); + return BuildContentControlRegistry(ProjectionScopes.All) + .FirstOrDefault(candidate => string.Equals(candidate.Info.AnchorId, anchorId, + StringComparison.Ordinal))?.Info; + } + + public EditResult FillContentControlText(string anchorId, string text, + ContentControlFillOptions? options = null) => + FillTextualContentControl(anchorId, text, rich: false, options); + + public EditResult FillContentControlRichText(string anchorId, string markdown, + ContentControlFillOptions? options = null) => + FillTextualContentControl(anchorId, markdown, rich: true, options); + + public EditResult SetContentControlChecked(string anchorId, bool isChecked, + ContentControlFillOptions? options = null) + { + if (ResolveContentControlForMutation(anchorId, ContentControlType.Checkbox, options, + out var candidate, out var error) is false) return error!; + if (ContainsNestedContentControl(candidate!.Element)) + return NestedFillError(anchorId); + + var checkbox = candidate.Element.Element(W.sdtPr)?.Element(ContentControlW14 + "checkbox"); + if (checkbox is null) + return EditResult.Fail(EditErrorCode.ContentControlMalformed, + "checkbox content control has no w14:checkbox properties", anchorId); + var checkedElement = checkbox.Element(ContentControlW14 + "checked"); + if (checkedElement is null) + { + checkedElement = new XElement(ContentControlW14 + "checked"); + checkbox.AddFirst(checkedElement); + } + + var stateElement = checkbox.Element(isChecked + ? ContentControlW14 + "checkedState" + : ContentControlW14 + "uncheckedState"); + var fallback = isChecked ? 0x2612 : 0x2610; + var glyph = TryParseHexScalar((string?)stateElement?.Attribute(ContentControlW14 + "val"), + out var scalar) ? char.ConvertFromUtf32(scalar) : char.ConvertFromUtf32(fallback); + + return MutateContentControl(candidate, options, () => + { + checkedElement.SetAttributeValue(ContentControlW14 + "val", isChecked ? "1" : "0"); + ReplaceControlWithPlainText(candidate.Element, glyph); + }); + } + + public EditResult SetContentControlDate(string anchorId, DateTimeOffset value, + string? displayText = null, ContentControlFillOptions? options = null) + { + if (ResolveContentControlForMutation(anchorId, ContentControlType.Date, options, + out var candidate, out var error) is false) return error!; + if (ContainsNestedContentControl(candidate!.Element)) + return NestedFillError(anchorId); + var date = candidate.Element.Element(W.sdtPr)?.Element(W.date); + if (date is null) + return EditResult.Fail(EditErrorCode.ContentControlMalformed, + "date content control has no w:date properties", anchorId); + var shown = displayText ?? value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + return MutateContentControl(candidate, options, () => + { + date.SetAttributeValue(W.fullDate, value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", + CultureInfo.InvariantCulture)); + ReplaceControlWithPlainText(candidate.Element, shown); + }); + } + + public EditResult SelectContentControlItem(string anchorId, string value, + ContentControlFillOptions? options = null) + { + if (ResolveContentControlForMutation(anchorId, + new[] { ContentControlType.DropDownList, ContentControlType.ComboBox }, options, + out var candidate, out var error) is false) return error!; + if (ContainsNestedContentControl(candidate!.Element)) + return NestedFillError(anchorId); + var props = candidate.Element.Element(W.sdtPr)!; + var list = props.Element(W.dropDownList) ?? props.Element(W.comboBox)!; + var matches = list.Elements(W.listItem).Where(item => + string.Equals((string?)item.Attribute(ContentControlW + "value"), value, StringComparison.Ordinal) + || string.Equals((string?)item.Attribute(W.displayText), value, StringComparison.Ordinal)).ToList(); + if (matches.Count != 1) + return EditResult.Fail(EditErrorCode.InvalidContentControlValue, + matches.Count == 0 + ? $"content control has no list item matching '{value}'" + : $"content control has multiple list items matching '{value}'", anchorId); + var display = (string?)matches[0].Attribute(W.displayText) + ?? (string?)matches[0].Attribute(ContentControlW + "value") ?? string.Empty; + return MutateContentControl(candidate, options, + () => ReplaceControlWithPlainText(candidate.Element, display)); + } + + public EditResult FillContentControlPicture(string anchorId, byte[] imageBytes, + ContentControlFillOptions? options = null) + { + if (ResolveContentControlForMutation(anchorId, ContentControlType.Picture, options, + out var candidate, out var error) is false) return error!; + var binary = ValidateImageBytes(imageBytes, anchorId); + if (binary.Error is not null) return binary.Error; + var images = EnumerateImageCandidates(ProjectionScopes.All).Where(image => + ReferenceEquals(image.Outer, candidate!.Element) + || image.Outer.Ancestors().Any(ancestor => ReferenceEquals(ancestor, candidate!.Element))) + .ToList(); + if (images.Count != 1) + return EditResult.Fail(EditErrorCode.ContentControlMalformed, + $"picture content control must contain exactly one mutable image; found {images.Count}", anchorId); + var image = images[0]; + if (image.Info.IsLinked) + return EditResult.Fail(EditErrorCode.LinkedImageReadOnly, + "a linked picture content control is read-only", anchorId); + if (!image.Info.CanMutate || image.Blip is null) + return EditResult.Fail(EditErrorCode.UnsupportedImageMarkup, + image.Info.UnsupportedReason ?? "picture content control uses unsupported image markup", anchorId); + + return MutateContentControl(candidate!, options, () => + { + var relationship = OwnedPartRelationships.FindOrAddImagePart(_doc!, candidate!.Owner.Part, + imageBytes, binary.ContentType!, binary.Format); + image.Blip.SetAttributeValue(ImageR + "embed", relationship.RelationshipId); + candidate.Element.Element(W.sdtPr)?.Element(W.showingPlcHdr)?.Remove(); + OwnedPartRelationships.SweepOrphanedImages(candidate.Owner.Part, ImageR + "embed", ImageR + "link"); + }); + } + + /// Clone one direct repeating-section item. The new item is inserted after + /// , or after the final item when omitted. + public EditResult AddRepeatingSectionItem(string sectionAnchorId, + string? afterItemAnchorId = null, ContentControlFillOptions? options = null) + { + if (ResolveContentControlForMutation(sectionAnchorId, ContentControlType.RepeatingSection, + options, out var section, out var error) is false) return error!; + var content = section!.Element.Element(W.sdtContent); + if (content is null) + return EditResult.Fail(EditErrorCode.ContentControlMalformed, + "repeating section has no w:sdtContent", sectionAnchorId); + var items = content.Elements(W.sdt).Where(IsRepeatingSectionItem).ToList(); + if (items.Count == 0 || content.Elements().Any(element => element.Name != W.sdt + || !IsRepeatingSectionItem(element))) + return EditResult.Fail(EditErrorCode.RepeatingSectionConstraint, + "repeating section must contain only one or more direct repeating-section-item controls", + sectionAnchorId); + + XElement template; + if (afterItemAnchorId is null) template = items[^1]; + else + { + var after = BuildContentControlRegistry(ProjectionScopes.All).FirstOrDefault(value => + string.Equals(value.Info.AnchorId, afterItemAnchorId, StringComparison.Ordinal)); + if (after is null || !items.Any(item => ReferenceEquals(item, after.Element))) + return EditResult.Fail(EditErrorCode.RepeatingSectionConstraint, + "afterItemAnchorId is not a direct item of the selected repeating section", + afterItemAnchorId); + template = after.Element; + } + if (FindUnsafeRepeatingCloneCarrier(template) is { } unsafeCarrier) + return EditResult.Fail(EditErrorCode.RepeatingSectionConstraint, + $"repeating item contains clone-sensitive markup ({unsafeCarrier.Name.LocalName})", + sectionAnchorId); + + _history.RecordPreOp(TakeSnapshot()); + try + { + DetachTargetBindingIfRequested(section.Element, options); + var clone = new XElement(template); + foreach (var element in clone.DescendantsAndSelf()) + element.Attribute(PtOpenXml.Unid)?.Remove(); + AssignFreshContentControlIds(clone); + UnidHelper.AssignToSelfAndDescendants(clone); + foreach (var docPr in clone.Descendants(WP.docPr)) + docPr.SetAttributeValue("id", NextDocumentPropertyId().ToString(CultureInfo.InvariantCulture)); + template.AddAfterSelf(clone); + ContentControlIdentity.AssignStableUnids(section.Owner.Part.GetXDocument().Root!); + InvalidateProjectionCache(); + var createdUnid = (string)clone.Attribute(PtOpenXml.Unid)!; + var created = new Anchor($"sdt:{section.Owner.Scope}:{createdUnid}", "sdt", + section.Owner.Scope, createdUnid); + return new EditResult { Success = true, Created = new[] { created }, + Modified = new[] { AnchorFromCandidate(section) } }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message, sectionAnchorId); + } + } + + public EditResult RemoveRepeatingSectionItem(string itemAnchorId) + { + if (ResolveContentControlForMutation(itemAnchorId, ContentControlType.RepeatingSectionItem, + options: null, out var item, out var error, removingWrapper: true) is false) return error!; + var outer = item!.Element.Parent?.Parent; + if (outer is null || outer.Name != W.sdt || !IsRepeatingSection(outer) + || item.Element.Parent?.Name != W.sdtContent) + return EditResult.Fail(EditErrorCode.RepeatingSectionConstraint, + "repeating-section item is not a direct child of a repeating section", itemAnchorId); + var siblings = item.Element.Parent.Elements(W.sdt).Where(IsRepeatingSectionItem).ToList(); + if (siblings.Count <= 1) + return EditResult.Fail(EditErrorCode.RepeatingSectionConstraint, + "a repeating section must retain at least one item", itemAnchorId); + var parentCandidate = BuildContentControlRegistry(ProjectionScopes.All).First(value => + ReferenceEquals(value.Element, outer)); + if (ValidateEffectiveLocks(parentCandidate, removingWrapper: false) is { } parentLock) + return parentLock; + if (ValidateBindingPolicy(parentCandidate, options: null) is { } bindingError) + return bindingError; + + _history.RecordPreOp(TakeSnapshot()); + try + { + var removed = AnchorFromCandidate(item); + item.Element.Remove(); + SweepOrphanedStoryRelationships(item.Owner.Part); + InvalidateProjectionCache(); + return new EditResult { Success = true, Removed = new[] { removed }, + Modified = new[] { AnchorFromCandidate(parentCandidate) } }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message, itemAnchorId); + } + } + + private EditResult FillTextualContentControl(string anchorId, string payload, bool rich, + ContentControlFillOptions? options) + { + var expected = rich ? ContentControlType.RichText : ContentControlType.PlainText; + if (ResolveContentControlForMutation(anchorId, expected, options, + out var candidate, out var error) is false) return error!; + if (ContainsNestedContentControl(candidate!.Element)) return NestedFillError(anchorId); + + if (!rich) + return MutateContentControl(candidate, options, + () => ReplaceControlWithPlainText(candidate.Element, payload)); + + var parsed = MarkdownPayloadParser.Parse(payload); + if (!parsed.Success) + return EditResult.Fail(parsed.Error!.Code, parsed.Error.Message, anchorId); + if (parsed.Blocks.Count == 0) + parsed = MarkdownPayloadParser.Parse(""); + if (candidate.Info.Placement == ContentControlPlacement.Inline && parsed.Blocks.Count != 1) + return EditResult.Fail(EditErrorCode.ContentControlPlacementUnsupported, + "an inline rich-text control accepts exactly one markdown block", anchorId); + if (candidate.Info.Placement is not (ContentControlPlacement.Inline or ContentControlPlacement.Block)) + return EditResult.Fail(EditErrorCode.ContentControlPlacementUnsupported, + "rich-text fill supports only inline and block content controls", anchorId); + + return MutateContentControl(candidate, options, () => + { + var content = candidate.Element.Element(W.sdtContent)!; + if (candidate.Info.Placement == ContentControlPlacement.Inline) + { + var block = parsed.Blocks.Count == 0 + ? new ParsedBlock(ParserBlockKind.Paragraph, 0, Array.Empty()) + : parsed.Blocks[0]; + content.ReplaceNodes(block.RunElements.Select(element => new XElement(element))); + } + else + { + var blocks = parsed.Blocks.Select(BuildParagraphFromParsedBlock).ToList(); + if (blocks.Count == 0) blocks.Add(new XElement(W.p)); + content.ReplaceNodes(blocks); + } + candidate.Element.Element(W.sdtPr)?.Element(W.showingPlcHdr)?.Remove(); + PromoteHyperlinkRelationships(candidate.Element); + }); + } + + private bool ResolveContentControlForMutation(string anchorId, ContentControlType expected, + ContentControlFillOptions? options, out ContentControlCandidate? candidate, + out EditResult? error, bool removingWrapper = false) => + ResolveContentControlForMutation(anchorId, new[] { expected }, options, + out candidate, out error, removingWrapper); + + private bool ResolveContentControlForMutation(string anchorId, + IReadOnlyCollection expected, ContentControlFillOptions? options, + out ContentControlCandidate? candidate, out EditResult? error, + bool removingWrapper = false) + { + candidate = null; + error = null; + if (_disposed) + { + error = EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + return false; + } + if (_trackedChanges == TrackedChangeMode.RenderInline) + { + error = EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, + "whole content-control fills cannot be represented faithfully as tracked revisions; use surgical text operations inside the control or switch modes", + anchorId); + return false; + } + candidate = BuildContentControlRegistry(ProjectionScopes.All).FirstOrDefault(value => + string.Equals(value.Info.AnchorId, anchorId, StringComparison.Ordinal)); + if (candidate is null) + { + error = EditResult.Fail(EditErrorCode.ContentControlNotFound, + $"content control not found: {anchorId}", anchorId); + return false; + } + if (!candidate.Identity.HasMutableIdentity) + { + error = EditResult.Fail(EditErrorCode.ContentControlMalformed, + candidate.Info.UnsupportedReason ?? "content control has no unique valid native w:id", anchorId); + return false; + } + if (candidate.Info.Type == ContentControlType.Unsupported) + { + error = EditResult.Fail(EditErrorCode.ContentControlUnsupported, + candidate.Info.UnsupportedReason ?? "unsupported content-control family", anchorId); + return false; + } + if (!expected.Contains(candidate.Info.Type)) + { + error = EditResult.Fail(EditErrorCode.ContentControlWrongType, + $"operation requires {string.Join(" or ", expected)} but target is {candidate.Info.Type}", anchorId); + return false; + } + if (candidate.Info.Placement == ContentControlPlacement.Unknown) + { + error = EditResult.Fail(EditErrorCode.ContentControlPlacementUnsupported, + candidate.Info.UnsupportedReason ?? "unsupported content-control placement", anchorId); + return false; + } + if (ValidateEffectiveLocks(candidate, removingWrapper) is { } lockError) + { + error = lockError; + return false; + } + if (ValidateBindingPolicy(candidate, options) is { } bindingError) + { + error = bindingError; + return false; + } + return true; + } + + private EditResult? ValidateEffectiveLocks(ContentControlCandidate candidate, bool removingWrapper) + { + foreach (var control in candidate.Element.AncestorsAndSelf(W.sdt)) + { + var token = (string?)control.Element(W.sdtPr)?.Element(ContentControlW + "lock")?.Attribute(W.val); + if (token is "contentLocked" or "sdtContentLocked") + return EditResult.Fail(EditErrorCode.ContentControlLocked, + "target content is locked by this control or an ancestor", candidate.Info.AnchorId); + if (removingWrapper && ReferenceEquals(control, candidate.Element) + && token is "sdtLocked" or "sdtContentLocked") + return EditResult.Fail(EditErrorCode.ContentControlLocked, + "target content-control wrapper is locked", candidate.Info.AnchorId); + } + return null; + } + + private EditResult? ValidateBindingPolicy(ContentControlCandidate candidate, + ContentControlFillOptions? options) + { + var boundControls = candidate.Element.AncestorsAndSelf(W.sdt).Where(control => + control.Element(W.sdtPr)?.Element(W.dataBinding) is not null).ToList(); + if (boundControls.Count == 0) return null; + var targetBound = boundControls.Any(control => ReferenceEquals(control, candidate.Element)); + var hasBoundAncestor = boundControls.Any(control => !ReferenceEquals(control, candidate.Element)); + if (hasBoundAncestor) + return EditResult.Fail(EditErrorCode.ContentControlBound, + "target is inside a data-bound ancestor; only the selected target's own binding may be detached", + candidate.Info.AnchorId); + if (!targetBound) return null; + if (options?.BindingPolicy == ContentControlBindingPolicy.DetachTarget) return null; + return EditResult.Fail(EditErrorCode.ContentControlBound, + "target is data-bound; retry with bindingPolicy=detach_target to remove only its w:dataBinding", + candidate.Info.AnchorId); + } + + private EditResult MutateContentControl(ContentControlCandidate candidate, + ContentControlFillOptions? options, Action mutation) + { + _history.RecordPreOp(TakeSnapshot()); + try + { + DetachTargetBindingIfRequested(candidate.Element, options); + mutation(); + UnidHelper.AssignToSelfAndDescendants(candidate.Element); + ContentControlIdentity.AssignStableUnids(candidate.Owner.Part.GetXDocument().Root!); + InvalidateProjectionCache(); + return new EditResult { Success = true, + Modified = new[] { AnchorFromCandidate(candidate) } }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message, candidate.Info.AnchorId); + } + } + + private static void DetachTargetBindingIfRequested(XElement control, + ContentControlFillOptions? options) + { + if (options?.BindingPolicy == ContentControlBindingPolicy.DetachTarget) + control.Element(W.sdtPr)?.Element(W.dataBinding)?.Remove(); + } + + private static void ReplaceControlWithPlainText(XElement control, string text) + { + var content = control.Element(W.sdtContent) + ?? throw new InvalidOperationException("content control has no w:sdtContent"); + var placement = DetectContentControlPlacement(control); + var oldRunProperties = content.Descendants(W.r).Select(run => run.Element(W.rPr)) + .FirstOrDefault(value => value is not null); + var run = new XElement(W.r, + oldRunProperties is null ? null : new XElement(oldRunProperties), + new XElement(W.t, new XAttribute(XNamespace.Xml + "space", "preserve"), text)); + if (placement == ContentControlPlacement.Inline) + { + content.ReplaceNodes(run); + } + else if (placement == ContentControlPlacement.Block) + { + var oldParagraphProperties = content.Elements(W.p).Select(p => p.Element(W.pPr)) + .FirstOrDefault(value => value is not null); + content.ReplaceNodes(new XElement(W.p, + oldParagraphProperties is null ? null : new XElement(oldParagraphProperties), run)); + } + else + { + throw new InvalidOperationException($"plain text cannot fill a {placement} content control"); + } + control.Element(W.sdtPr)?.Element(W.showingPlcHdr)?.Remove(); + } + + private static bool ContainsNestedContentControl(XElement control) => + control.Element(W.sdtContent)?.Descendants(W.sdt).Any() == true; + + private static EditResult NestedFillError(string anchorId) => + EditResult.Fail(EditErrorCode.ContentControlNestedFillUnsupported, + "whole-control fill is refused when the target contains nested controls; address the child control directly", + anchorId); + + private IReadOnlyList BuildContentControlRegistry(ProjectionScopes scopes) + { + var result = new List(); + foreach (var owner in OwnedPartRelationships.StoryParts(_doc!)) + { + if (!ScopeIncluded(owner.Scope, scopes)) continue; + var root = owner.Part.GetXDocument().Root; + if (root is null) continue; + var identities = ContentControlIdentity.AssignStableUnids(root); + var byElement = identities.ToDictionary(identity => identity.Element, + identity => identity); + var anchorByElement = identities.ToDictionary(identity => identity.Element, + identity => $"sdt:{owner.Scope}:{identity.Unid}"); + foreach (var identity in identities) + { + var element = identity.Element; + var props = element.Element(W.sdtPr); + var type = ClassifyContentControl(props); + var placement = DetectContentControlPlacement(element); + var binding = props?.Element(W.dataBinding); + var parent = element.Ancestors(W.sdt).FirstOrDefault(); + var lockToken = (string?)props?.Element(ContentControlW + "lock")?.Attribute(W.val); + string? unsupported = null; + if (!identity.HasValidNativeId) unsupported = "missing or invalid native w:sdtPr/w:id"; + else if (identity.IsDuplicateNativeId) unsupported = "duplicate native w:sdtPr/w:id in owning story"; + else if (placement == ContentControlPlacement.Unknown) unsupported = "unsupported or malformed OOXML placement"; + else if (type == ContentControlType.Unsupported) unsupported = "unsupported content-control family"; + + bool targetBound = binding is not null; + bool ancestorBound = element.Ancestors(W.sdt).Any(ancestor => + ancestor.Element(W.sdtPr)?.Element(W.dataBinding) is not null); + bool locked = element.AncestorsAndSelf(W.sdt).Any(control => + (string?)control.Element(W.sdtPr)?.Element(ContentControlW + "lock")?.Attribute(W.val) + is "contentLocked" or "sdtContentLocked"); + bool defaultMutable = unsupported is null && !locked && !targetBound && !ancestorBound; + + var items = props?.Elements().FirstOrDefault(value => + value.Name == W.dropDownList || value.Name == W.comboBox) + ?.Elements(W.listItem) + .Select(value => (string?)value.Attribute(ContentControlW + "value") ?? string.Empty).ToList() + ?? (IReadOnlyList)Array.Empty(); + var info = new ContentControlInfo + { + AnchorId = anchorByElement[element], + Type = type, + Placement = placement, + NativeId = identity.NativeId, + Tag = (string?)props?.Element(W.tag)?.Attribute(W.val), + Alias = (string?)props?.Element(W.alias)?.Attribute(W.val), + Lock = lockToken, + IsShowingPlaceholder = props?.Element(W.showingPlcHdr) is not null, + Binding = binding is null ? null : new ContentControlBindingInfo( + (string?)binding.Attribute(W.storeItemID), + (string?)binding.Attribute(W.xpath), + (string?)binding.Attribute(W.prefixMappings)), + OwningPartUri = owner.PartUri, + Scope = owner.Scope, + ParentAnchorId = parent is not null && anchorByElement.TryGetValue(parent, out var parentId) + ? parentId : null, + Depth = element.Ancestors(W.sdt).Count(), + HasValidNativeId = identity.HasValidNativeId, + HasDuplicateNativeId = identity.IsDuplicateNativeId, + CanMutate = defaultMutable, + CanDetachTargetBinding = unsupported is null && targetBound && !ancestorBound && !locked, + UnsupportedReason = unsupported ?? (locked ? "content locked by target or ancestor" + : ancestorBound ? "inside a data-bound ancestor" + : targetBound ? "target is data-bound; explicit detach_target is required" : null), + Text = string.Concat(element.Element(W.sdtContent)?.Descendants(W.t) + .Select(text => (string)text) ?? Enumerable.Empty()), + ItemValues = items, + }; + result.Add(new ContentControlCandidate(owner, element, byElement[element], info)); + } + } + return result; + } + + private static bool ScopeIncluded(string scope, ProjectionScopes scopes) => scope switch + { + "body" => scopes.HasFlag(ProjectionScopes.Body), + var value when value.StartsWith("hdr", StringComparison.Ordinal) => scopes.HasFlag(ProjectionScopes.Headers), + var value when value.StartsWith("ftr", StringComparison.Ordinal) => scopes.HasFlag(ProjectionScopes.Footers), + "fn" => scopes.HasFlag(ProjectionScopes.Footnotes), + "en" => scopes.HasFlag(ProjectionScopes.Endnotes), + "cmt" => scopes.HasFlag(ProjectionScopes.Comments), + _ => false, + }; + + private static ContentControlType ClassifyContentControl(XElement? props) + { + if (props is null) return ContentControlType.Unsupported; + if (props.Element(ContentControlW14 + "checkbox") is not null) return ContentControlType.Checkbox; + if (props.Element(ContentControlW15 + "repeatingSection") is not null) return ContentControlType.RepeatingSection; + if (props.Element(ContentControlW15 + "repeatingSectionItem") is not null) return ContentControlType.RepeatingSectionItem; + if (props.Element(W.picture) is not null) return ContentControlType.Picture; + if (props.Element(W.date) is not null) return ContentControlType.Date; + if (props.Element(W.dropDownList) is not null) return ContentControlType.DropDownList; + if (props.Element(W.comboBox) is not null) return ContentControlType.ComboBox; + if (props.Element(W.text) is not null) return ContentControlType.PlainText; + if (props.Element(ContentControlW + "richText") is not null) return ContentControlType.RichText; + var knownMetadata = new HashSet { W.id, W.tag, W.alias, W.dataBinding, + W.showingPlcHdr, ContentControlW + "lock", ContentControlW + "placeholder", + ContentControlW + "temporary", ContentControlW + "appearance", + ContentControlW + "color" }; + return props.Elements().All(element => knownMetadata.Contains(element.Name)) + ? ContentControlType.RichText + : ContentControlType.Unsupported; + } + + private static ContentControlPlacement DetectContentControlPlacement(XElement control) + { + var content = control.Element(W.sdtContent); + if (content is null) return ContentControlPlacement.Unknown; + var children = content.Elements().ToList(); + if (children.Count == 0) + { + if (control.Ancestors(W.p).Any()) return ContentControlPlacement.Inline; + return ContentControlPlacement.Block; + } + bool allInline = children.All(element => element.Name == W.r || element.Name == W.hyperlink + || element.Name == W.fldSimple || element.Name == W.sdt || element.Name == W.smartTag + || element.Name == W.bookmarkStart || element.Name == W.bookmarkEnd + || element.Name == W.commentRangeStart || element.Name == W.commentRangeEnd); + if (allInline && control.Ancestors(W.p).Any()) return ContentControlPlacement.Inline; + if (children.All(element => element.Name == W.tr || element.Name == W.sdt)) + return ContentControlPlacement.Row; + if (children.All(element => element.Name == W.tc || element.Name == W.sdt)) + return ContentControlPlacement.Cell; + if (children.All(element => element.Name == W.p || element.Name == W.tbl + || element.Name == W.sdt || element.Name == W.bookmarkStart || element.Name == W.bookmarkEnd)) + return ContentControlPlacement.Block; + return ContentControlPlacement.Unknown; + } + + private static bool IsRepeatingSection(XElement control) => + control.Element(W.sdtPr)?.Element(ContentControlW15 + "repeatingSection") is not null; + + private static bool IsRepeatingSectionItem(XElement control) => + control.Element(W.sdtPr)?.Element(ContentControlW15 + "repeatingSectionItem") is not null; + + private void AssignFreshContentControlIds(XElement root) + { + var used = new HashSet(); + foreach (var owner in OwnedPartRelationships.StoryParts(_doc!)) + foreach (var control in owner.Part.GetXDocument().Descendants(W.sdt)) + { + var raw = (string?)control.Element(W.sdtPr)?.Element(W.id)?.Attribute(W.val); + if (int.TryParse(raw, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var id)) + used.Add(id); + } + int next = 1; + foreach (var control in root.DescendantsAndSelf(W.sdt)) + { + while (used.Contains(next) && next < int.MaxValue) next++; + if (used.Contains(next)) throw new InvalidOperationException("no unused content-control id remains"); + used.Add(next); + var props = control.Element(W.sdtPr); + if (props is null) + { + props = new XElement(W.sdtPr); + control.AddFirst(props); + } + var id = props.Element(W.id); + if (id is null) + { + id = new XElement(W.id); + props.Add(id); + } + id.SetAttributeValue(W.val, next.ToString(CultureInfo.InvariantCulture)); + next++; + } + } + + private static XElement? FindUnsafeRepeatingCloneCarrier(XElement item) + { + var unsafeNames = new HashSet + { + W.bookmarkStart, W.bookmarkEnd, W.commentRangeStart, W.commentRangeEnd, + W.commentReference, W.footnoteReference, W.endnoteReference, + ContentControlW + "permStart", ContentControlW + "permEnd", + ContentControlW + "customXmlInsRangeStart", ContentControlW + "customXmlInsRangeEnd", + ContentControlW + "customXmlDelRangeStart", ContentControlW + "customXmlDelRangeEnd", + }; + return item.Descendants().FirstOrDefault(element => unsafeNames.Contains(element.Name)); + } + + private static bool TryParseHexScalar(string? value, out int scalar) + { + scalar = 0; + return !string.IsNullOrEmpty(value) + && int.TryParse(value, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out scalar) + && scalar is >= 0 and <= 0x10ffff && (scalar < 0xd800 || scalar > 0xdfff); + } + + private static Anchor AnchorFromCandidate(ContentControlCandidate candidate) => + new(candidate.Info.AnchorId, "sdt", candidate.Info.Scope, candidate.Identity.Unid); +} diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 2f407a5a..9cafae97 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -524,6 +524,10 @@ public sealed record InlineSpan required public string Text { get; init; } required public RunFormattingInfo Direct { get; init; } required public RunFormattingInfo Effective { get; init; } + + /// Outer-to-inner native content-control membership for this run. Empty when the + /// run is not inside a w:sdt. Each id is directly accepted by content-control operations. + public IReadOnlyList ContentControlAnchorIds { get; init; } = Array.Empty(); } /// Direct and effective formatting for one paragraph-like anchor. @@ -1665,6 +1669,17 @@ public enum EditErrorCode LinkedImageReadOnly, InvalidImageLayout, + ContentControlNotFound, + ContentControlMalformed, + ContentControlUnsupported, + ContentControlLocked, + ContentControlBound, + ContentControlWrongType, + InvalidContentControlValue, + ContentControlPlacementUnsupported, + ContentControlNestedFillUnsupported, + RepeatingSectionConstraint, + /// A zero-length span passed to , or a /// whole-block comment requested on a paragraph with no text — a comment range must /// cover at least one character. diff --git a/Docxodus/Internal/ContentControlIdentity.cs b/Docxodus/Internal/ContentControlIdentity.cs new file mode 100644 index 00000000..e6419e9f --- /dev/null +++ b/Docxodus/Internal/ContentControlIdentity.cs @@ -0,0 +1,111 @@ +// 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; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; + +namespace Docxodus.Internal; + +/// +/// Native identity for Word structured-document tags. A valid, unique w:sdtPr/w:id +/// is the durable identity Word itself persists; the projector's pt:Unid is only a +/// cache. This helper makes the cache a deterministic function of the native id so an +/// sdt: anchor survives the default Save(false) / reopen path. +/// +internal static class ContentControlIdentity +{ + internal sealed record Entry( + XElement Element, + string Unid, + string? NativeId, + bool HasValidNativeId, + bool IsDuplicateNativeId, + int DocumentOrdinal, + int DuplicateOrdinal) + { + internal bool HasMutableIdentity => HasValidNativeId && !IsDuplicateNativeId; + } + + /// + /// Assign native-derived Unids to every SDT below one story root and return the same + /// outer-before-inner registry order used by the public content-control listing. + /// Malformed controls still receive deterministic inspection identities, but callers + /// must gate mutation on . + /// + internal static IReadOnlyList AssignStableUnids(XElement storyRoot) => + AssignStableUnids(storyRoot, out _); + + internal static IReadOnlyList AssignStableUnids(XElement storyRoot, out bool changed) + { + ArgumentNullException.ThrowIfNull(storyRoot); + changed = false; + var controls = storyRoot.DescendantsAndSelf(W.sdt).ToList(); + if (controls.Count == 0) return Array.Empty(); + + var parsed = controls.Select((element, ordinal) => + { + var raw = (string?)element.Element(W.sdtPr)?.Element(W.id)?.Attribute(W.val); + var valid = TryCanonicalizeNativeId(raw, out var canonical); + return (element, ordinal, raw, valid, canonical); + }).ToList(); + + var counts = parsed.Where(value => value.valid) + .GroupBy(value => value.canonical!, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var duplicateOrdinals = new Dictionary(StringComparer.Ordinal); + var result = new List(parsed.Count); + + foreach (var value in parsed) + { + int duplicateOrdinal = 0; + bool duplicate = value.valid && counts[value.canonical!] > 1; + if (duplicate) + { + duplicateOrdinals.TryGetValue(value.canonical!, out duplicateOrdinal); + duplicateOrdinals[value.canonical!] = duplicateOrdinal + 1; + } + + // Unique, valid native ids are location- and content-independent. The fallback + // discriminator is intentionally only for non-writable malformed documents. + var seed = value.valid + ? duplicate + ? $"duplicate\0{value.canonical}\0{duplicateOrdinal}" + : $"native\0{value.canonical}" + : $"malformed\0{value.ordinal}\0{value.raw ?? ""}"; + var unid = HashToUnid(seed); + if (!string.Equals((string?)value.element.Attribute(PtOpenXml.Unid), unid, + StringComparison.Ordinal)) + { + value.element.SetAttributeValue(PtOpenXml.Unid, unid); + changed = true; + } + result.Add(new Entry(value.element, unid, + value.valid ? value.canonical : value.raw, + value.valid, duplicate, value.ordinal, duplicateOrdinal)); + } + return result; + } + + internal static bool TryCanonicalizeNativeId(string? raw, out string? canonical) + { + canonical = null; + if (string.IsNullOrWhiteSpace(raw) + || !int.TryParse(raw, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var id)) + return false; + canonical = id.ToString(CultureInfo.InvariantCulture); + return true; + } + + internal static string HashToUnid(string seed) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes("docxodus-content-control\0" + seed)); + return Convert.ToHexString(bytes.AsSpan(0, 16)).ToLowerInvariant(); + } +} diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs index 4e1cb525..95df4465 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -685,8 +685,32 @@ public static (double? Width, double? Height, bool PreserveAspect) ParseImageDim StrictDoubleNullable(root, "heightPoints"), StrictBool(root, "preserveAspect", true)); } + public static ContentControlFillOptions ParseContentControlFillOptions(string json) + { + if (string.IsNullOrEmpty(json)) return new ContentControlFillOptions(); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + RequireObject(root, "content-control fill options"); + RequireOnlyProperties(root, "bindingPolicy"); + var policy = StrictString(root, "bindingPolicy", "preserve") switch + { + "preserve" => ContentControlBindingPolicy.Preserve, + "detach_target" => ContentControlBindingPolicy.DetachTarget, + var token => throw new System.ArgumentException( + $"unknown bindingPolicy '{token}'; expected preserve or detach_target"), + }; + return new ContentControlFillOptions { BindingPolicy = policy }; + } + private static void RequireObject(JsonElement root, string description) { if (root.ValueKind != JsonValueKind.Object) throw new System.ArgumentException($"{description} must be a JSON object"); } + private static void RequireOnlyProperties(JsonElement root, params string[] names) + { + var allowed = new HashSet(names, System.StringComparer.Ordinal); + foreach (var property in root.EnumerateObject()) + if (!allowed.Contains(property.Name)) + throw new System.ArgumentException($"unknown option property '{property.Name}'"); + } private static string? StrictString(JsonElement root, string name, string? fallback) { if (!root.TryGetProperty(name, out var value)) return fallback; @@ -1122,6 +1146,55 @@ public static string SerializeImages(IReadOnlyList images) return sb.Append(']').ToString(); } + public static string SerializeContentControls(IReadOnlyList controls) + { + var sb = new StringBuilder(controls.Count * 600 + 2).Append('['); + for (int i = 0; i < controls.Count; i++) + { + if (i > 0) sb.Append(','); + var control = controls[i]; + sb.Append("{\"anchorId\":").Append(JsonString(control.AnchorId)) + .Append(",\"type\":").Append(JsonString(ToSnake(control.Type.ToString()))) + .Append(",\"placement\":").Append(JsonString(ToSnake(control.Placement.ToString()))); + AppendString(sb, "nativeId", control.NativeId); + AppendString(sb, "tag", control.Tag); + AppendString(sb, "alias", control.Alias); + AppendString(sb, "lock", control.Lock); + sb.Append(",\"isShowingPlaceholder\":").Append(control.IsShowingPlaceholder ? "true" : "false") + .Append(",\"isBound\":").Append(control.IsBound ? "true" : "false"); + if (control.Binding is not null) + { + sb.Append(",\"binding\":{"); + bool first = true; + void BindingValue(string name, string? value) + { + if (value is null) return; + if (!first) sb.Append(','); + first = false; + sb.Append(JsonString(name)).Append(':').Append(JsonString(value)); + } + BindingValue("storeItemId", control.Binding.StoreItemId); + BindingValue("xpath", control.Binding.XPath); + BindingValue("prefixMappings", control.Binding.PrefixMappings); + sb.Append('}'); + } + sb.Append(",\"owningPartUri\":").Append(JsonString(control.OwningPartUri)) + .Append(",\"scope\":").Append(JsonString(control.Scope)); + AppendString(sb, "parentAnchorId", control.ParentAnchorId); + sb.Append(",\"depth\":").Append(control.Depth) + .Append(",\"hasValidNativeId\":").Append(control.HasValidNativeId ? "true" : "false") + .Append(",\"hasDuplicateNativeId\":").Append(control.HasDuplicateNativeId ? "true" : "false") + .Append(",\"canMutate\":").Append(control.CanMutate ? "true" : "false") + .Append(",\"canDetachTargetBinding\":").Append(control.CanDetachTargetBinding ? "true" : "false"); + AppendString(sb, "unsupportedReason", control.UnsupportedReason); + sb.Append(",\"text\":").Append(JsonString(control.Text)) + .Append(",\"itemValues\":"); + AppendStringArray(sb, control.ItemValues); + sb.Append('}'); + } + return sb.Append(']').ToString(); + } + public static string SerializeImageCapabilities(ImageCapabilities capabilities) { var sb = new StringBuilder(1200).Append("{\"schemaVersion\":") @@ -2224,6 +2297,8 @@ private static void AppendInlineSpans(StringBuilder sb, IReadOnlyList DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveImage(imageId)); + // ─── Native content controls (issue #452) ───────────────────────── + + public static string ListContentControls(int handle, + ProjectionScopes scopes = ProjectionScopes.All) => + DocxSessionJson.SerializeContentControls( + SessionRegistry.Get(handle).ListContentControls(scopes)); + + public static string FillContentControlText(int handle, string anchorId, string text, + string optionsJson) => ContentControlOptions(anchorId, optionsJson, options => + SessionRegistry.Get(handle).FillContentControlText(anchorId, text, options)); + + public static string FillContentControlRichText(int handle, string anchorId, string markdown, + string optionsJson) => ContentControlOptions(anchorId, optionsJson, options => + SessionRegistry.Get(handle).FillContentControlRichText(anchorId, markdown, options)); + + public static string SetContentControlChecked(int handle, string anchorId, bool isChecked, + string optionsJson) => ContentControlOptions(anchorId, optionsJson, options => + SessionRegistry.Get(handle).SetContentControlChecked(anchorId, isChecked, options)); + + public static string SetContentControlDate(int handle, string anchorId, string value, + string? displayText, string optionsJson) + { + if (!System.DateTimeOffset.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) + return DocxSessionJson.Serialize(EditResult.Fail(EditErrorCode.InvalidContentControlValue, + "date value must be an ISO-8601 timestamp", anchorId)); + return ContentControlOptions(anchorId, optionsJson, options => + SessionRegistry.Get(handle).SetContentControlDate(anchorId, parsed, displayText, options)); + } + + public static string SelectContentControlItem(int handle, string anchorId, string value, + string optionsJson) => ContentControlOptions(anchorId, optionsJson, options => + SessionRegistry.Get(handle).SelectContentControlItem(anchorId, value, options)); + + public static string FillContentControlPicture(int handle, string anchorId, + string imageBase64, string optionsJson) + { + if (!TryDecodeImageBase64(imageBase64, anchorId, out var bytes, out var error)) return error!; + return ContentControlOptions(anchorId, optionsJson, options => + SessionRegistry.Get(handle).FillContentControlPicture(anchorId, bytes!, options)); + } + + public static string AddRepeatingSectionItem(int handle, string sectionAnchorId, + string? afterItemAnchorId, string optionsJson) => + ContentControlOptions(sectionAnchorId, optionsJson, options => + SessionRegistry.Get(handle).AddRepeatingSectionItem( + sectionAnchorId, afterItemAnchorId, options)); + + public static string RemoveRepeatingSectionItem(int handle, string itemAnchorId) => + DocxSessionJson.Serialize( + SessionRegistry.Get(handle).RemoveRepeatingSectionItem(itemAnchorId)); + + private static string ContentControlOptions(string anchorId, string optionsJson, + System.Func action) + { + try + { + return DocxSessionJson.Serialize(action( + DocxSessionJson.ParseContentControlFillOptions(optionsJson))); + } + catch (System.Exception ex) when (ex is System.Text.Json.JsonException + or System.ArgumentException) + { + return DocxSessionJson.Serialize(EditResult.Fail( + EditErrorCode.InvalidContentControlValue, + $"invalid content-control options JSON: {ex.Message}", anchorId)); + } + } + private static bool TryDecodeImageBase64(string? base64, string? anchorId, out byte[]? bytes, out string? error) { diff --git a/Docxodus/Internal/FormattingIntrospectionOps.cs b/Docxodus/Internal/FormattingIntrospectionOps.cs index 3cbc7220..90bbf00e 100644 --- a/Docxodus/Internal/FormattingIntrospectionOps.cs +++ b/Docxodus/Internal/FormattingIntrospectionOps.cs @@ -142,6 +142,11 @@ public static IReadOnlyList ListInlineSpans( FormattingAssembler.ResolveEffectiveRunProperties(doc, run), effective: true, effectiveStyleId: directStyleId), + ContentControlAnchorIds = run.Ancestors(W.sdt).Reverse() + .Select(control => (string?)control.Attribute(PtOpenXml.Unid)) + .Where(unid => !string.IsNullOrEmpty(unid)) + .Select(unid => $"sdt:{target.Anchor.Scope}:{unid}") + .ToArray(), }); } return result; diff --git a/Docxodus/Ir/IrDocument.cs b/Docxodus/Ir/IrDocument.cs index 45fec309..e9661db1 100644 --- a/Docxodus/Ir/IrDocument.cs +++ b/Docxodus/Ir/IrDocument.cs @@ -44,6 +44,13 @@ internal sealed record IrHeaderFooter(string ScopeName, IrHeaderFooterKind Kind, public IrNodeList References { get; init; } = IrNodeList.Empty(); } +/// Exact projector-order anchor fact captured while the IR reader's private package is +/// open. It keeps the IR markdown index in parity for transparent carriers such as inline, +/// row-level, and cell-level content controls that are deliberately not separate IR blocks. +internal sealed record IrProjectionAnchor( + IrAnchor Anchor, string PartUri, string TextPreview, string? AutoNumberPrefix, + bool IsEmptyParagraph); + /// /// The immutable root of a Document IR snapshot. /// @@ -77,6 +84,10 @@ internal sealed record IrDocument /// Provenance pin from part URI to its source document; reference-equal (not part of value equality). public required IReadOnlyDictionary Sources { get; init; } + /// All addressable elements in the oracle projector's exact scope/document order. + public IrNodeList ProjectionAnchors { get; init; } = + IrNodeList.Empty(); + /// Look up a block by anchor; returns null if no block carries that anchor. public IrBlock? FindByAnchor(IrAnchor anchor) => AnchorIndex.TryGetValue(anchor.ToString(), out var b) ? b : null; diff --git a/Docxodus/Ir/IrMarkdownEmitter.cs b/Docxodus/Ir/IrMarkdownEmitter.cs index 96a8c6cf..85f2e79e 100644 --- a/Docxodus/Ir/IrMarkdownEmitter.cs +++ b/Docxodus/Ir/IrMarkdownEmitter.cs @@ -97,6 +97,37 @@ private static (IReadOnlyDictionary Index, AnchorIdMap Ren AddIndexEntry(index, c.Anchor, partUri, ComputeScopeTextPreview(c.Blocks), autoNumber); } + // Reorder the modeled entries to the oracle's exact descendant walk and add transparent + // carrier anchors (notably inline/row/cell SDTs) that intentionally have no separate IR + // block. The reader captures these facts while its private package is still open, so this + // also works with RetainSources=false without constructing another document tree. + if (ir.ProjectionAnchors.Count > 0) + { + var ordered = new Dictionary(StringComparer.Ordinal); + foreach (var fact in ir.ProjectionAnchors) + { + if (settings.EmptyParagraphs == EmptyParagraphMode.Suppress && fact.IsEmptyParagraph) + continue; + var id = fact.Anchor.ToString(); + if (index.TryGetValue(id, out var modeled)) ordered[id] = modeled; + else + { + ordered[id] = new AnchorTarget + { + Anchor = ToPublicAnchor(fact.Anchor), + PartUri = fact.PartUri, + Unid = fact.Anchor.Unid, + TextPreview = fact.TextPreview, + AutoNumberPrefix = fact.AutoNumberPrefix, + }; + } + } + // Defensive: retain any modeled anchor absent from a legacy captured list. + foreach (var pair in index) + if (!ordered.ContainsKey(pair.Key)) ordered[pair.Key] = pair.Value; + index = ordered; + } + // Build the AnchorIdMap. Mirror the oracle exactly: the map is constructed by iterating // index.Values in INSERTION order (which the walk above keeps identical to the oracle's // DescendantsAndSelf order), so Abbreviated prefixes and Sequential counters match byte-for-byte. @@ -286,8 +317,7 @@ private static AnchorIdMap BuildAnchorIdMap( switch (b) { case IrSdtBlock sdt: - // The oracle's index walk descends into w:sdtContent, but KindFor does not make - // the w:sdt wrapper a public anchor. Preserve that split: recurse, do not yield sdt. + yield return (sdt.Anchor, ComputeTextPreview(sdt)); foreach (var inner in WalkAnchorsForIndex(sdt.Blocks, settings)) yield return inner; break; diff --git a/Docxodus/Ir/IrReader.cs b/Docxodus/Ir/IrReader.cs index 206f1b29..63d5f06c 100644 --- a/Docxodus/Ir/IrReader.cs +++ b/Docxodus/Ir/IrReader.cs @@ -7,6 +7,7 @@ using System.Text; using System.Xml.Linq; using DocumentFormat.OpenXml.Packaging; +using Docxodus.Internal; namespace Docxodus.Ir; @@ -180,6 +181,8 @@ public static IrDocument Read(WmlDocument doc, IrReaderOptions? options = null) comments = ReadCommentStore(main, styles, numbering, sources, anchorIndex, commentTracker!, retain, drawingGraphFallbackDocumentHash); + var projectionAnchors = CaptureProjectionAnchors(wdoc, options.Scopes); + return new IrDocument { Body = new IrScope("body", IrNodeList.From(blocks), partUri), @@ -193,9 +196,65 @@ public static IrDocument Read(WmlDocument doc, IrReaderOptions? options = null) ThemeFonts = BuildThemeFonts(main), AnchorIndex = anchorIndex, Sources = sources, + ProjectionAnchors = projectionAnchors, }; } + private static IrNodeList CaptureProjectionAnchors( + WordprocessingDocument document, IrScopes scopes) + { + var result = new List(); + foreach (var owner in OwnedPartRelationships.StoryParts(document)) + { + bool included = owner.Scope switch + { + "body" => scopes.HasFlag(IrScopes.Body), + var value when value.StartsWith("hdr", StringComparison.Ordinal) + || value.StartsWith("ftr", StringComparison.Ordinal) => + scopes.HasFlag(IrScopes.HeadersFooters), + "fn" or "en" => scopes.HasFlag(IrScopes.Notes), + "cmt" => scopes.HasFlag(IrScopes.Comments), + _ => false, + }; + if (!included) continue; + var root = owner.Part.GetXDocument().Root; + if (root is null) continue; + ContentControlIdentity.AssignStableUnids(root); + if (root.Annotation() is null) root.AddAnnotation(owner.Part); + + var skip = new HashSet(); + if (owner.Scope is "fn" or "en") + { + var noteName = owner.Scope == "fn" ? W + "footnote" : W + "endnote"; + foreach (var note in root.Elements(noteName)) + { + var type = (string?)note.Attribute(W + "type"); + if (type is null or "normal") continue; + skip.Add(note); + foreach (var descendant in note.Descendants()) skip.Add(descendant); + } + } + + foreach (var element in root.DescendantsAndSelf()) + { + if (skip.Contains(element)) continue; + var kind = WmlToMarkdownConverter.KindFor(element); + var unid = (string?)element.Attribute(PtOpenXml.Unid); + if (kind is null || unid is null) continue; + var previewText = string.Concat(element.Descendants(W + "t").Select(t => (string)t)); + var preview = previewText.Length > 80 ? previewText.Substring(0, 80) + "…" : previewText; + var autoNumber = owner.Scope == "body" && kind is "p" or "h" or "li" + ? ListNumberResolver.Resolve(element, document) : null; + bool emptyParagraph = element.Name == W + "p" + && !element.Descendants(W + "t").Any(t => !string.IsNullOrEmpty((string)t)); + result.Add(new IrProjectionAnchor( + new IrAnchor(IrAnchor.KindFromToken(kind), owner.Scope, unid), + owner.PartUri, preview, autoNumber, emptyParagraph)); + } + } + return IrNodeList.From(result); + } + /// /// Carries the part URI (for provenance), the owning (for /// resolving image relationships), and a per- image-bytes hash cache through diff --git a/Docxodus/UnidHelper.cs b/Docxodus/UnidHelper.cs index a4360c2c..4b0e3443 100644 --- a/Docxodus/UnidHelper.cs +++ b/Docxodus/UnidHelper.cs @@ -10,6 +10,7 @@ using System.Security.Cryptography; using System.Text; using System.Xml.Linq; +using Docxodus.Internal; namespace Docxodus; @@ -92,6 +93,10 @@ internal static void AssignToAllElements(XElement contentParent) /// index rebuilds. internal static bool AssignToAllElementsDeterministic(XElement contentParent) { + // Run the legacy deterministic walk first. Descendant block/run identities below an + // SDT must keep using the same structural seed they used before SDTs became public + // anchors; otherwise merely exposing the wrapper would rename every child anchor. + // The wrapper's own Unid is replaced from native w:id only after that walk completes. bool assignedRoot = false; if (contentParent.Name == W.footnote || contentParent.Name == W.endnote) { @@ -124,10 +129,15 @@ internal static bool AssignToAllElementsDeterministic(XElement contentParent) if (!live.Add(a)) break; // ancestors above are already marked } } - if (live is null) return assignedRoot; // fully assigned — nothing to do + if (live is null) + { + ContentControlIdentity.AssignStableUnids(contentParent, out bool changedControls); + return assignedRoot || changedControls; + } var parentUnid = (string?)contentParent.Attribute(PtOpenXml.Unid) ?? contentParent.Name.LocalName; AssignDescendantsDeterministic(contentParent, parentUnid, live); + ContentControlIdentity.AssignStableUnids(contentParent); return true; } diff --git a/Docxodus/WmlToMarkdownConverter.cs b/Docxodus/WmlToMarkdownConverter.cs index dd054e88..22d85ea1 100644 --- a/Docxodus/WmlToMarkdownConverter.cs +++ b/Docxodus/WmlToMarkdownConverter.cs @@ -583,6 +583,7 @@ private static (IReadOnlyDictionary Index, List if (n == W.footnote) return "fn"; if (n == W.endnote) return "en"; if (n == W.comment) return "cmt"; + if (n == W.sdt) return "sdt"; return null; } diff --git a/README.md b/README.md index 9aebed92..1feb7b58 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ reading first: |---|---| | [`ir_diff_engine.md`](docs/architecture/ir_diff_engine.md) | `DocxDiff` — pipeline, edit script, settings, parity with Word | | [`docx_mutation_api.md`](docs/architecture/docx_mutation_api.md) | `DocxSession` — full surface, anchor lifecycle, error catalog, markdown subset | +| [`native_content_controls.md`](docs/architecture/native_content_controls.md) | Native Word content-control registry, fills, binding/lock safety, and transports | | [`markdown_projection.md`](docs/architecture/markdown_projection.md) | The projection spec and anchor format | | [`docx_converter.md`](docs/architecture/docx_converter.md) | `WmlToHtmlConverter` internals | | [`editor_ui_surface.md`](docs/architecture/editor_ui_surface.md) | The browser editor, control by control | diff --git a/docs/architecture/docx_mutation_api.md b/docs/architecture/docx_mutation_api.md index 24023008..44305588 100644 --- a/docs/architecture/docx_mutation_api.md +++ b/docs/architecture/docx_mutation_api.md @@ -176,7 +176,9 @@ This is symmetric by design: anything the projector can emit, the parser can acc - 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. -- For everything OOXML can do that markdown can't (complex tables, math, content controls, drawings) → `session.Raw.*`. +- For everything OOXML can do that markdown can't (complex tables, math, and unmodeled drawings) → `session.Raw.*`. + Native Word content controls instead use the typed surface in + [`native_content_controls.md`](native_content_controls.md). We didn't pick CommonMark or GFM as the input language because the projector's subset is small and well-defined; running a full parser against that subset would import surprise (e.g., GFM tables silently splitting paragraphs, autolinks mis-classifying spans). The hand-rolled parser is ~300 LOC, has no dependencies, and gives us complete control over what gets rejected and why. diff --git a/docs/architecture/native_content_controls.md b/docs/architecture/native_content_controls.md new file mode 100644 index 00000000..c1ad2232 --- /dev/null +++ b/docs/architecture/native_content_controls.md @@ -0,0 +1,71 @@ +# Native content controls + +`DocxSession` treats Word structured-document tags (`w:sdt`) as first-class live +objects. `ListContentControls()` walks every requested story part and returns controls +in package story order and outer-before-inner document order. Every entry includes its +native `w:id`, type, placement, tag, alias, lock, placeholder state, binding facts, +owning part/scope, nesting parent/depth, current text/list values, and mutation status. + +## Identity and projection + +The public anchor is `sdt:{scope}:{unid}`. For a unique, valid signed 32-bit +`w:sdtPr/w:id`, `unid` is a deterministic hash of that native id; the scope identifies +the owning story. It therefore survives value edits and a normal clean save/reopen, +even though `PtOpenXml:Unid` bookkeeping is stripped. Missing, invalid, or duplicate +native ids remain enumerable under deterministic diagnostic anchors but are not +mutable. Repeating-item clones receive fresh native ids before their anchors are made +public. + +`sdt` is an AnchorIndex kind in both the WML projector and the immutable IR emitter. +The IR captures projector-order anchor facts while its private package is open, so +index parity also holds with `RetainSources=false`. The wrapper remains transparent to +markdown, HTML, and `ListBlocks`; those surfaces continue to render/list its content. +`ListInlineSpans` additionally returns outer-to-inner `contentControlAnchorIds` for each +run. + +## Mutations + +The typed surface is deliberately operation-specific: + +- `FillContentControlText` and `FillContentControlRichText` +- `SetContentControlChecked` and `SetContentControlDate` +- `SelectContentControlItem` for dropdowns and combo boxes +- `FillContentControlPicture`, using the native-image byte validation and relationship + management shared with issue #453 +- `AddRepeatingSectionItem` and `RemoveRepeatingSectionItem` + +Fills preserve `w:sdt`, `w:sdtPr`, `w:sdtEndPr`, and metadata not owned by the +operation. Text fills retain representative run/paragraph properties and clear only +the showing-placeholder marker. Picture fills replace the image relationship without +rebuilding the wrapper. Repeating clones freshen every nested content-control id and +drawing `docPr` id, and reject clone-sensitive bookmark, comment, permission, custom +XML range, and note-reference markup. The final item cannot be removed. + +Every successful operation is one undo/redo step. Whole-control fills are explicitly +rejected in `render_inline` tracked-change mode until issue #455 defines revision +semantics; surgical text/format operations inside a control remain available. + +## Locks, bindings, and nesting + +Content locks are effective through ancestors. A locked target or ancestor fails +without changing history. A whole-content replacement that would discard a nested +control is also refused; callers address the nested child directly. + +Bindings fail closed by default. `bindingPolicy: "detach_target"` is the only opt-in: +it removes the selected control's own `w:dataBinding` before the mutation. It never +removes an ancestor binding and never edits or regenerates a Custom XML data part. +A target inside a bound ancestor is always refused. + +## Transports + +The shared JSON facade, WASM bridge, TypeScript package, Python host/client, and MCP +server expose the same typed operations. Options JSON is strict: the only fill option +is `bindingPolicy`, with `preserve` (default) or `detach_target`. The MCP grouped tool +is `docxodus_content_controls`; its mutating actions participate in +`docxodus_mutations` apply/preview rollback, while `list` is read-only and rejected as +a batch step. Picture bytes cross JSON transports only as base64. + +Failures are structured `EditErrorCode` values, including not found, malformed, +unsupported family/placement, wrong type, locked, bound, invalid value, unsafe nested +fill, and repeating-section constraint errors. Refused operations do not consume undo +history. diff --git a/npm/src/index.ts b/npm/src/index.ts index cff151a2..aa4a2f3c 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -70,6 +70,12 @@ export type { BookmarkRangeSegment, CharSpan, CommentListEntry, + ContentControlBindingInfo, + ContentControlBindingPolicy, + ContentControlFillOptions, + ContentControlInfo, + ContentControlPlacement, + ContentControlType, DocumentAnnotation, DocumentRange, DocxSessionProjection, diff --git a/npm/src/session.ts b/npm/src/session.ts index a08a353f..a8534b23 100644 --- a/npm/src/session.ts +++ b/npm/src/session.ts @@ -11,6 +11,8 @@ import type { BulkEditResult, CharSpan, CommentListEntry, + ContentControlFillOptions, + ContentControlInfo, CrossBlockMatch, DiffEntry, DocumentAnnotation, @@ -1035,6 +1037,59 @@ export class DocxSession { return JSON.parse(this.wasm.RemoveImage(this.handle, imageId)) as EditResult; } + /** Native Word structured-document tags, in outer-before-inner story order. */ + listContentControls(scopes: ProjectionScopes = ProjectionScopes.All): ContentControlInfo[] { + return JSON.parse(this.wasm.ListContentControls(this.handle, scopes)) as ContentControlInfo[]; + } + + fillContentControlText(anchorId: string, text: string, + options: ContentControlFillOptions = {}): EditResult { + return JSON.parse(this.wasm.FillContentControlText( + this.handle, anchorId, text, JSON.stringify(options))) as EditResult; + } + + fillContentControlRichText(anchorId: string, markdown: string, + options: ContentControlFillOptions = {}): EditResult { + return JSON.parse(this.wasm.FillContentControlRichText( + this.handle, anchorId, markdown, JSON.stringify(options))) as EditResult; + } + + setContentControlChecked(anchorId: string, isChecked: boolean, + options: ContentControlFillOptions = {}): EditResult { + return JSON.parse(this.wasm.SetContentControlChecked( + this.handle, anchorId, isChecked, JSON.stringify(options))) as EditResult; + } + + setContentControlDate(anchorId: string, value: string | Date, displayText?: string, + options: ContentControlFillOptions = {}): EditResult { + const timestamp = value instanceof Date ? value.toISOString() : value; + return JSON.parse(this.wasm.SetContentControlDate( + this.handle, anchorId, timestamp, displayText ?? "", JSON.stringify(options))) as EditResult; + } + + selectContentControlItem(anchorId: string, value: string, + options: ContentControlFillOptions = {}): EditResult { + return JSON.parse(this.wasm.SelectContentControlItem( + this.handle, anchorId, value, JSON.stringify(options))) as EditResult; + } + + fillContentControlPicture(anchorId: string, bytes: Uint8Array, + options: ContentControlFillOptions = {}): EditResult { + return JSON.parse(this.wasm.FillContentControlPicture( + this.handle, anchorId, imageBytesToBase64(bytes), JSON.stringify(options))) as EditResult; + } + + addRepeatingSectionItem(sectionAnchorId: string, afterItemAnchorId?: string, + options: ContentControlFillOptions = {}): EditResult { + return JSON.parse(this.wasm.AddRepeatingSectionItem( + this.handle, sectionAnchorId, afterItemAnchorId ?? "", JSON.stringify(options))) as EditResult; + } + + removeRepeatingSectionItem(itemAnchorId: string): EditResult { + return JSON.parse(this.wasm.RemoveRepeatingSectionItem( + this.handle, itemAnchorId)) as EditResult; + } + listBookmarks(scopes: ProjectionScopes = ProjectionScopes.All): BookmarkInfo[] { return JSON.parse(this.wasm.ListBookmarks(this.handle, scopes)) as BookmarkInfo[]; } diff --git a/npm/src/types.ts b/npm/src/types.ts index 19016124..cafa7b40 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1206,6 +1206,15 @@ export interface DocxodusWasmExports { SetImageMetadata: (handle: number, imageId: string, altText: string | null, title: string | null) => string; SetImageFloatingLayout: (handle: number, imageId: string, layoutJson: string) => string; RemoveImage: (handle: number, imageId: string) => string; + ListContentControls: (handle: number, scopes: number) => string; + FillContentControlText: (handle: number, anchorId: string, text: string, optionsJson: string) => string; + FillContentControlRichText: (handle: number, anchorId: string, markdown: string, optionsJson: string) => string; + SetContentControlChecked: (handle: number, anchorId: string, isChecked: boolean, optionsJson: string) => string; + SetContentControlDate: (handle: number, anchorId: string, value: string, displayText: string, optionsJson: string) => string; + SelectContentControlItem: (handle: number, anchorId: string, value: string, optionsJson: string) => string; + FillContentControlPicture: (handle: number, anchorId: string, imageBase64: string, optionsJson: string) => string; + AddRepeatingSectionItem: (handle: number, sectionAnchorId: string, afterItemAnchorId: string, optionsJson: string) => string; + RemoveRepeatingSectionItem: (handle: number, itemAnchorId: string) => string; ListBookmarks: (handle: number, scopes: number) => string; AddBookmark: (handle: number, name: string, startAnchor: string, startOffset: number, endAnchor: string, endOffset: number) => string; RenameBookmark: (handle: number, name: string, newName: string) => string; @@ -1360,6 +1369,16 @@ export type EditErrorCode = | "revision_ambiguous" | "tracked_operation_unsupported" | "unresolved_structural_revision" + | "content_control_not_found" + | "content_control_malformed" + | "content_control_unsupported" + | "content_control_locked" + | "content_control_bound" + | "content_control_wrong_type" + | "invalid_content_control_value" + | "content_control_placement_unsupported" + | "content_control_nested_fill_unsupported" + | "repeating_section_constraint" | "internal_error"; export interface AnchorRef { @@ -1596,6 +1615,46 @@ export interface ImageCapabilities { supportsNetworkFetch: boolean; supportsFileIo: boolean; } +export type ContentControlType = "plain_text" | "rich_text" | "checkbox" | "date" + | "drop_down_list" | "combo_box" | "picture" | "repeating_section" + | "repeating_section_item" | "unsupported"; +export type ContentControlPlacement = "inline" | "block" | "row" | "cell" | "unknown"; +export type ContentControlBindingPolicy = "preserve" | "detach_target"; + +export interface ContentControlFillOptions { + bindingPolicy?: ContentControlBindingPolicy; +} + +export interface ContentControlBindingInfo { + storeItemId?: string; + xpath?: string; + prefixMappings?: string; +} + +export interface ContentControlInfo { + anchorId: string; + type: ContentControlType; + placement: ContentControlPlacement; + nativeId?: string; + tag?: string; + alias?: string; + lock?: string; + isShowingPlaceholder: boolean; + isBound: boolean; + binding?: ContentControlBindingInfo; + owningPartUri: string; + scope: string; + parentAnchorId?: string; + depth: number; + hasValidNativeId: boolean; + hasDuplicateNativeId: boolean; + canMutate: boolean; + canDetachTargetBinding: boolean; + unsupportedReason?: string; + text: string; + itemValues: string[]; +} + export interface DocumentRange { startAnchorId: string; startOffset: number; @@ -2598,6 +2657,8 @@ export interface InlineSpan { text: string; direct: RunFormattingInfo; effective: RunFormattingInfo; + /** Outer-to-inner native content controls containing this run. */ + contentControlAnchorIds: string[]; } /** Explicitly separated direct and effective formatting for one paragraph anchor. */ diff --git a/python/src/docx_scalpel/__init__.py b/python/src/docx_scalpel/__init__.py index 6dc88274..e6f74258 100644 --- a/python/src/docx_scalpel/__init__.py +++ b/python/src/docx_scalpel/__init__.py @@ -90,6 +90,12 @@ BulkEditResult, CharSpan, CommentListEntry, + ContentControlBindingInfo, + ContentControlBindingPolicy, + ContentControlFillOptions, + ContentControlInfo, + ContentControlPlacement, + ContentControlType, CrossBlockMatch, DocumentAnnotation, DocumentRange, @@ -212,6 +218,12 @@ "BlockSlice", "BulkEditResult", "CharSpan", + "ContentControlBindingInfo", + "ContentControlBindingPolicy", + "ContentControlFillOptions", + "ContentControlInfo", + "ContentControlPlacement", + "ContentControlType", "DocumentRange", "HyperlinkInfo", "FloatingImageLayout", diff --git a/python/src/docx_scalpel/enums.py b/python/src/docx_scalpel/enums.py index 334e9d29..662f4f06 100644 --- a/python/src/docx_scalpel/enums.py +++ b/python/src/docx_scalpel/enums.py @@ -194,6 +194,16 @@ class EditErrorCode(str, Enum): REVISION_AMBIGUOUS = "revision_ambiguous" TRACKED_OPERATION_UNSUPPORTED = "tracked_operation_unsupported" UNRESOLVED_STRUCTURAL_REVISION = "unresolved_structural_revision" + CONTENT_CONTROL_NOT_FOUND = "content_control_not_found" + CONTENT_CONTROL_MALFORMED = "content_control_malformed" + CONTENT_CONTROL_UNSUPPORTED = "content_control_unsupported" + CONTENT_CONTROL_LOCKED = "content_control_locked" + CONTENT_CONTROL_BOUND = "content_control_bound" + CONTENT_CONTROL_WRONG_TYPE = "content_control_wrong_type" + INVALID_CONTENT_CONTROL_VALUE = "invalid_content_control_value" + CONTENT_CONTROL_PLACEMENT_UNSUPPORTED = "content_control_placement_unsupported" + CONTENT_CONTROL_NESTED_FILL_UNSUPPORTED = "content_control_nested_fill_unsupported" + REPEATING_SECTION_CONSTRAINT = "repeating_section_constraint" INTERNAL_ERROR = "internal_error" @classmethod diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py index 1ace9d2a..d9ee755f 100644 --- a/python/src/docx_scalpel/session.py +++ b/python/src/docx_scalpel/session.py @@ -53,6 +53,8 @@ BulkEditResult, CharSpan, CommentListEntry, + ContentControlFillOptions, + ContentControlInfo, CrossBlockMatch, DocumentAnnotation, DocumentRange, @@ -928,6 +930,78 @@ def set_image_floating_layout(self, image_id: str, def remove_image(self, image_id: str) -> EditResult: return EditResult._from_wire(self._call("remove_image", {"imageId": image_id})) + def list_content_controls( + self, scopes: ProjectionScopes = ProjectionScopes.ALL + ) -> tuple[ContentControlInfo, ...]: + result = self._call("list_content_controls", {"scopes": int(scopes)}) + return tuple(ContentControlInfo._from_wire(item) for item in result) + + def fill_content_control_text(self, anchor_id: str, text: str, + options: ContentControlFillOptions | None = None) -> EditResult: + return EditResult._from_wire(self._call("fill_content_control_text", { + "anchorId": anchor_id, "text": text, + "options": (options or ContentControlFillOptions()).to_wire(), + })) + + def fill_content_control_rich_text( + self, anchor_id: str, markdown: str, + options: ContentControlFillOptions | None = None, + ) -> EditResult: + return EditResult._from_wire(self._call("fill_content_control_rich_text", { + "anchorId": anchor_id, "markdown": markdown, + "options": (options or ContentControlFillOptions()).to_wire(), + })) + + def set_content_control_checked( + self, anchor_id: str, checked: bool, + options: ContentControlFillOptions | None = None, + ) -> EditResult: + return EditResult._from_wire(self._call("set_content_control_checked", { + "anchorId": anchor_id, "checked": checked, + "options": (options or ContentControlFillOptions()).to_wire(), + })) + + def set_content_control_date( + self, anchor_id: str, value: str, display_text: str | None = None, + options: ContentControlFillOptions | None = None, + ) -> EditResult: + return EditResult._from_wire(self._call("set_content_control_date", { + "anchorId": anchor_id, "value": value, "displayText": display_text, + "options": (options or ContentControlFillOptions()).to_wire(), + })) + + def select_content_control_item( + self, anchor_id: str, value: str, + options: ContentControlFillOptions | None = None, + ) -> EditResult: + return EditResult._from_wire(self._call("select_content_control_item", { + "anchorId": anchor_id, "value": value, + "options": (options or ContentControlFillOptions()).to_wire(), + })) + + def fill_content_control_picture( + self, anchor_id: str, image_bytes: bytes, + options: ContentControlFillOptions | None = None, + ) -> EditResult: + return EditResult._from_wire(self._call("fill_content_control_picture", { + "anchorId": anchor_id, + "imageBase64": base64.b64encode(image_bytes).decode("ascii"), + "options": (options or ContentControlFillOptions()).to_wire(), + })) + + def add_repeating_section_item( + self, section_anchor_id: str, after_item_anchor_id: str | None = None, + options: ContentControlFillOptions | None = None, + ) -> EditResult: + return EditResult._from_wire(self._call("add_repeating_section_item", { + "sectionAnchorId": section_anchor_id, "afterItemAnchorId": after_item_anchor_id, + "options": (options or ContentControlFillOptions()).to_wire(), + })) + + def remove_repeating_section_item(self, item_anchor_id: str) -> EditResult: + return EditResult._from_wire(self._call( + "remove_repeating_section_item", {"itemAnchorId": item_anchor_id})) + def list_bookmarks(self, scopes: ProjectionScopes = ProjectionScopes.ALL) -> tuple[BookmarkInfo, ...]: result = self._call("list_bookmarks", {"scopes": int(scopes)}) return tuple(BookmarkInfo._from_wire(item) for item in result) diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py index e2dde5db..c375fb8a 100644 --- a/python/src/docx_scalpel/types.py +++ b/python/src/docx_scalpel/types.py @@ -115,6 +115,12 @@ "ImageFormatCapability", "ImageOccurrence", "ImageCapabilities", + "ContentControlType", + "ContentControlPlacement", + "ContentControlBindingPolicy", + "ContentControlFillOptions", + "ContentControlBindingInfo", + "ContentControlInfo", "BookmarkRangeSegment", "BookmarkInfo", "EditSummary", @@ -1004,6 +1010,96 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "ImageCapabilities": ) +class ContentControlType(str, Enum): + PLAIN_TEXT = "plain_text" + RICH_TEXT = "rich_text" + CHECKBOX = "checkbox" + DATE = "date" + DROP_DOWN_LIST = "drop_down_list" + COMBO_BOX = "combo_box" + PICTURE = "picture" + REPEATING_SECTION = "repeating_section" + REPEATING_SECTION_ITEM = "repeating_section_item" + UNSUPPORTED = "unsupported" + + +class ContentControlPlacement(str, Enum): + INLINE = "inline" + BLOCK = "block" + ROW = "row" + CELL = "cell" + UNKNOWN = "unknown" + + +class ContentControlBindingPolicy(str, Enum): + PRESERVE = "preserve" + DETACH_TARGET = "detach_target" + + +@dataclass(frozen=True, slots=True) +class ContentControlFillOptions: + binding_policy: ContentControlBindingPolicy = ContentControlBindingPolicy.PRESERVE + + def to_wire(self) -> dict[str, Any]: + return {"bindingPolicy": self.binding_policy.value} + + +@dataclass(frozen=True, slots=True) +class ContentControlBindingInfo: + store_item_id: str | None = None + xpath: str | None = None + prefix_mappings: str | None = None + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "ContentControlBindingInfo": + return cls(d.get("storeItemId"), d.get("xpath"), d.get("prefixMappings")) + + +@dataclass(frozen=True, slots=True) +class ContentControlInfo: + anchor_id: str + type: ContentControlType + placement: ContentControlPlacement + native_id: str | None + tag: str | None + alias: str | None + lock: str | None + is_showing_placeholder: bool + is_bound: bool + binding: ContentControlBindingInfo | None + owning_part_uri: str + scope: str + parent_anchor_id: str | None + depth: int + has_valid_native_id: bool + has_duplicate_native_id: bool + can_mutate: bool + can_detach_target_binding: bool + unsupported_reason: str | None + text: str + item_values: tuple[str, ...] + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "ContentControlInfo": + return cls( + anchor_id=d["anchorId"], type=ContentControlType(d["type"]), + placement=ContentControlPlacement(d["placement"]), native_id=d.get("nativeId"), + tag=d.get("tag"), alias=d.get("alias"), lock=d.get("lock"), + is_showing_placeholder=bool(d.get("isShowingPlaceholder", False)), + is_bound=bool(d.get("isBound", False)), + binding=(ContentControlBindingInfo._from_wire(d["binding"]) + if d.get("binding") else None), + owning_part_uri=d["owningPartUri"], scope=d["scope"], + parent_anchor_id=d.get("parentAnchorId"), depth=int(d.get("depth", 0)), + has_valid_native_id=bool(d.get("hasValidNativeId", False)), + has_duplicate_native_id=bool(d.get("hasDuplicateNativeId", False)), + can_mutate=bool(d.get("canMutate", False)), + can_detach_target_binding=bool(d.get("canDetachTargetBinding", False)), + unsupported_reason=d.get("unsupportedReason"), text=d.get("text", ""), + item_values=tuple(d.get("itemValues", ())), + ) + + @dataclass(frozen=True, slots=True) class BookmarkRangeSegment: owning_part_uri: str @@ -1299,6 +1395,7 @@ class InlineSpan: text: str direct: RunFormattingInfo effective: RunFormattingInfo + content_control_anchor_ids: tuple[str, ...] = () @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "InlineSpan": @@ -1307,6 +1404,7 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "InlineSpan": span=CharSpan._from_wire(d["span"]), text=d["text"], direct=RunFormattingInfo._from_wire(d["direct"]), effective=RunFormattingInfo._from_wire(d["effective"]), + content_control_anchor_ids=tuple(d.get("contentControlAnchorIds", ())), ) diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs index fdd7c56a..4e87ff26 100644 --- a/tools/mcp-server/Dispatcher.cs +++ b/tools/mcp-server/Dispatcher.cs @@ -42,6 +42,7 @@ internal static class Dispatcher "docxodus_comment" => Comment(store, args), "docxodus_links" => Links(store, args), "docxodus_images" => Images(store, args), + "docxodus_content_controls" => ContentControls(store, args), "docxodus_annotate" => Annotate(store, args), "docxodus_track_changes" => TrackChanges(store, args), "docxodus_mutations" => Mutations(store, args), @@ -625,6 +626,58 @@ private static string SetImageMetadata(DocSession session, JsonElement args) title.ValueKind == JsonValueKind.Null ? null : title.GetString()); } + // ─── Native content controls (issue #452) ───────────────────────── + + private static string ContentControls(SessionStore store, JsonElement args) + { + var session = Session(store, args); + var action = Str(args, "action"); + if (action == "list") + return RunContentControlsAction(session, action, args); + return Guarded(session, ParsePreconditions(args, MutationTarget(args)), () => + RunContentControlsAction(session, action, args)); + } + + private static string RunContentControlsAction( + DocSession session, string action, JsonElement args) => action switch + { + "list" => $"{{\"contentControls\":{DocxSessionOps.ListContentControls( + session.Handle, ParseLinkScopes(OptStr(args, "scope")))}}}", + "fill_text" => DocxSessionOps.FillContentControlText(session.Handle, + Str(args, "anchorId"), Str(args, "text"), BuildContentControlOptionsJson(args)), + "fill_rich_text" => DocxSessionOps.FillContentControlRichText(session.Handle, + Str(args, "anchorId"), Str(args, "markdown"), BuildContentControlOptionsJson(args)), + "set_checked" => DocxSessionOps.SetContentControlChecked(session.Handle, + Str(args, "anchorId"), RequiredBool(args, "checked"), BuildContentControlOptionsJson(args)), + "set_date" => DocxSessionOps.SetContentControlDate(session.Handle, + Str(args, "anchorId"), Str(args, "value"), OptStr(args, "displayText"), + BuildContentControlOptionsJson(args)), + "select_item" => DocxSessionOps.SelectContentControlItem(session.Handle, + Str(args, "anchorId"), Str(args, "value"), BuildContentControlOptionsJson(args)), + "fill_picture" => DocxSessionOps.FillContentControlPicture(session.Handle, + Str(args, "anchorId"), Str(args, "imageBase64"), BuildContentControlOptionsJson(args)), + "add_repeating_item" => DocxSessionOps.AddRepeatingSectionItem(session.Handle, + Str(args, "sectionAnchorId"), OptStr(args, "afterItemAnchorId"), + BuildContentControlOptionsJson(args)), + "remove_repeating_item" => DocxSessionOps.RemoveRepeatingSectionItem(session.Handle, + Str(args, "itemAnchorId")), + _ => throw new McpToolException($"unknown docxodus_content_controls action: {action}"), + }; + + private static string BuildContentControlOptionsJson(JsonElement args) + { + var policy = OptStr(args, "bindingPolicy"); + return policy is null ? "{}" : JsonSerializer.Serialize(new { bindingPolicy = policy }); + } + + private static bool RequiredBool(JsonElement args, string name) + { + if (args.TryGetProperty(name, out var value) + && value.ValueKind is JsonValueKind.True or JsonValueKind.False) + return value.GetBoolean(); + throw new McpToolException($"missing boolean \"{name}\""); + } + private static string AddComment(DocSession session, JsonElement args) { var anchorId = OptStr(args, "anchorId"); @@ -879,6 +932,8 @@ private static IReadOnlyList BuildMutationBatchSteps( "docxodus_comment" => RunCommentAction(session, action, mutationArgs), "docxodus_links" => RunLinksAction(session, action, mutationArgs), "docxodus_images" => RunImagesAction(session, action, mutationArgs), + "docxodus_content_controls" => RunContentControlsAction( + session, action, mutationArgs), "docxodus_track_changes" => RunTrackChangesAction(session, action, mutationArgs), _ => throw new McpToolException($"docxodus_mutations does not accept \"{stepTool}\" as a step"), }, @@ -912,6 +967,9 @@ private static IReadOnlyList BuildMutationBatchSteps( or "add_bookmark" or "move_bookmark" or "rename_bookmark" or "remove_bookmark", "docxodus_images" => action is "insert" or "replace" or "set_dimensions" or "set_metadata" or "set_floating_layout" or "remove", + "docxodus_content_controls" => action is "fill_text" or "fill_rich_text" + or "set_checked" or "set_date" or "select_item" or "fill_picture" + or "add_repeating_item" or "remove_repeating_item", "docxodus_track_changes" => action is "accept" or "reject" or "accept_all" or "reject_all", _ => false, @@ -1208,6 +1266,41 @@ private static void ValidateMutationBatchArguments(string tool, string action, J RequireStrings(args, "imageId"); break; + case ("docxodus_content_controls", "fill_text"): + RequireStrings(args, "anchorId", "text"); + ValidateOptionalEnum(args, "bindingPolicy", "preserve", "detach_target"); + break; + case ("docxodus_content_controls", "fill_rich_text"): + RequireStrings(args, "anchorId", "markdown"); + ValidateOptionalEnum(args, "bindingPolicy", "preserve", "detach_target"); + break; + case ("docxodus_content_controls", "set_checked"): + RequireStrings(args, "anchorId"); + _ = RequiredBool(args, "checked"); + ValidateOptionalEnum(args, "bindingPolicy", "preserve", "detach_target"); + break; + case ("docxodus_content_controls", "set_date"): + RequireStrings(args, "anchorId", "value"); + ValidateOptionalString(args, "displayText"); + ValidateOptionalEnum(args, "bindingPolicy", "preserve", "detach_target"); + break; + case ("docxodus_content_controls", "select_item"): + RequireStrings(args, "anchorId", "value"); + ValidateOptionalEnum(args, "bindingPolicy", "preserve", "detach_target"); + break; + case ("docxodus_content_controls", "fill_picture"): + RequireStrings(args, "anchorId", "imageBase64"); + ValidateOptionalEnum(args, "bindingPolicy", "preserve", "detach_target"); + break; + case ("docxodus_content_controls", "add_repeating_item"): + RequireStrings(args, "sectionAnchorId"); + ValidateOptionalString(args, "afterItemAnchorId"); + ValidateOptionalEnum(args, "bindingPolicy", "preserve", "detach_target"); + break; + case ("docxodus_content_controls", "remove_repeating_item"): + RequireStrings(args, "itemAnchorId"); + break; + case ("docxodus_track_changes", "accept"): case ("docxodus_track_changes", "reject"): RequireStrings(args, "revisionId"); @@ -1441,6 +1534,7 @@ private static string BuildTableBorderSpecJson(JsonElement args) { "anchorId", "cellAnchorId", "sourceAnchorId", "fromAnchorId", "firstAnchorId", "headingAnchorId", "bodyAnchorId", "commentAnchorId", "newAnchorId", + "sectionAnchorId", "itemAnchorId", }) { if (args.ValueKind == JsonValueKind.Object diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 8da54f24..5deafbec 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -10,7 +10,7 @@ namespace Docxodus.McpServer; internal sealed record ToolDefinition(string Name, string Description, string InputSchemaJson); /// -/// The tool surface this server advertises: three lifecycle tools (open/save/close) plus fourteen +/// The tool surface this server advertises: three lifecycle tools (open/save/close) plus fifteen /// read or grouped-intent tools. Grouped tools accept an action discriminator and /// action-specific arguments. See docs/architecture/docx_agent_server.md for the full contract, the /// mapping of every action onto the underlying Docxodus API, and the documented capability gaps. @@ -452,6 +452,31 @@ internal static class ToolCatalog "required": ["action"] } """), + new ToolDefinition( + "docxodus_content_controls", + "Inspect and fill native Word content controls (structured-document tags) while preserving their wrappers and metadata. Bound controls fail closed unless bindingPolicy is detach_target, which removes only the selected control's own binding. Whole-control fills are refused in render_inline tracked-change mode.", + """ + { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "action": { "type": "string", "enum": ["list", "fill_text", "fill_rich_text", "set_checked", "set_date", "select_item", "fill_picture", "add_repeating_item", "remove_repeating_item"] }, + "scope": { "type": "string", "enum": ["body", "headers", "footers", "footnotes", "endnotes", "comments", "all"] }, + "anchorId": { "type": "string", "description": "Target sdt anchor returned by list." }, + "text": { "type": "string", "description": "fill_text payload." }, + "markdown": { "type": "string", "description": "fill_rich_text payload." }, + "checked": { "type": "boolean", "description": "set_checked value." }, + "value": { "type": "string", "description": "set_date ISO-8601 value or select_item value/display text." }, + "displayText": { "type": "string", "description": "Optional set_date displayed text." }, + "imageBase64": { "type": "string", "description": "fill_picture raw image bytes as base64." }, + "sectionAnchorId": { "type": "string", "description": "add_repeating_item section control." }, + "afterItemAnchorId": { "type": "string", "description": "Optional direct item after which the clone is inserted." }, + "itemAnchorId": { "type": "string", "description": "remove_repeating_item direct item." }, + "bindingPolicy": { "type": "string", "enum": ["preserve", "detach_target"], "description": "Default preserve. detach_target removes only the selected target's own w:dataBinding; a bound ancestor still fails closed." } + }, + "required": ["sessionId", "action"] + } + """), new ToolDefinition( "docxodus_track_changes", "List, selectively accept/reject (by revisionId), or atomically bulk-resolve live tracked changes including structural cell, content-control, and numbering families — or switch how the session records its OWN subsequent edits (set_mode).", @@ -476,7 +501,7 @@ internal static class ToolCatalog """), new ToolDefinition( "docxodus_mutations", - "Apply or safely preview a batch of mutating edit/format/create/table/list/comment/link/image/track-changes actions. Atomic mode commits as one unit; preview executes the identical batch path against an isolated complete package clone and never mutates the live session or its undo/redo history.", + "Apply or safely preview a batch of mutating edit/format/create/table/list/comment/link/image/content-control/track-changes actions. Atomic mode commits as one unit; preview executes the identical batch path against an isolated complete package clone and never mutates the live session or its undo/redo history.", """ { "type": "object", @@ -493,7 +518,7 @@ internal static class ToolCatalog "items": { "type": "object", "properties": { - "tool": { "type": "string", "enum": ["docxodus_edit", "docxodus_format", "docxodus_create", "docxodus_table", "docxodus_list", "docxodus_comment", "docxodus_links", "docxodus_images", "docxodus_track_changes"] }, + "tool": { "type": "string", "enum": ["docxodus_edit", "docxodus_format", "docxodus_create", "docxodus_table", "docxodus_list", "docxodus_comment", "docxodus_links", "docxodus_images", "docxodus_content_controls", "docxodus_track_changes"] }, "args": { "type": "object", "description": "The same arguments that tool's action takes, minus sessionId (inherited from the batch)." } }, "required": ["tool", "args"] diff --git a/tools/python-host/Dispatcher.cs b/tools/python-host/Dispatcher.cs index d4fb55e4..4a056784 100644 --- a/tools/python-host/Dispatcher.cs +++ b/tools/python-host/Dispatcher.cs @@ -161,6 +161,32 @@ public static string Dispatch(string op, JsonElement args) "set_image_floating_layout" => DocxSessionOps.SetImageFloatingLayout( Handle(args), Str(args, "imageId"), JsonObject(args, "layout")), "remove_image" => DocxSessionOps.RemoveImage(Handle(args), Str(args, "imageId")), + "list_content_controls" => DocxSessionOps.ListContentControls( + Handle(args), (ProjectionScopes)IntOptional(args, "scopes", (int)ProjectionScopes.All)), + "fill_content_control_text" => DocxSessionOps.FillContentControlText( + Handle(args), Str(args, "anchorId"), Str(args, "text"), + JsonObjectOrEmpty(args, "options")), + "fill_content_control_rich_text" => DocxSessionOps.FillContentControlRichText( + Handle(args), Str(args, "anchorId"), Str(args, "markdown"), + JsonObjectOrEmpty(args, "options")), + "set_content_control_checked" => DocxSessionOps.SetContentControlChecked( + Handle(args), Str(args, "anchorId"), + OptBool(args, "checked") ?? throw new FormatException("args missing boolean \"checked\""), + JsonObjectOrEmpty(args, "options")), + "set_content_control_date" => DocxSessionOps.SetContentControlDate( + Handle(args), Str(args, "anchorId"), Str(args, "value"), + OptStr(args, "displayText"), JsonObjectOrEmpty(args, "options")), + "select_content_control_item" => DocxSessionOps.SelectContentControlItem( + Handle(args), Str(args, "anchorId"), Str(args, "value"), + JsonObjectOrEmpty(args, "options")), + "fill_content_control_picture" => DocxSessionOps.FillContentControlPicture( + Handle(args), Str(args, "anchorId"), Str(args, "imageBase64"), + JsonObjectOrEmpty(args, "options")), + "add_repeating_section_item" => DocxSessionOps.AddRepeatingSectionItem( + Handle(args), Str(args, "sectionAnchorId"), OptStr(args, "afterItemAnchorId"), + JsonObjectOrEmpty(args, "options")), + "remove_repeating_section_item" => DocxSessionOps.RemoveRepeatingSectionItem( + Handle(args), Str(args, "itemAnchorId")), "list_bookmarks" => DocxSessionOps.ListBookmarks( Handle(args), (ProjectionScopes)IntOptional(args, "scopes", (int)ProjectionScopes.All)), "add_bookmark" => DocxSessionOps.AddBookmark( diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs index 044b2988..58718b1e 100644 --- a/wasm/DocxodusWasm/DocxSessionBridge.cs +++ b/wasm/DocxodusWasm/DocxSessionBridge.cs @@ -595,6 +595,51 @@ public static string SetImageFloatingLayout(int h, string imageId, string layout public static string RemoveImage(int h, string imageId) => DocxSessionOps.RemoveImage(h, imageId); + [JSExport] + public static string ListContentControls(int h, int scopes) => + DocxSessionOps.ListContentControls(h, (ProjectionScopes)scopes); + + [JSExport] + public static string FillContentControlText(int h, string anchorId, string text, + string optionsJson) => + DocxSessionOps.FillContentControlText(h, anchorId, text, optionsJson); + + [JSExport] + public static string FillContentControlRichText(int h, string anchorId, string markdown, + string optionsJson) => + DocxSessionOps.FillContentControlRichText(h, anchorId, markdown, optionsJson); + + [JSExport] + public static string SetContentControlChecked(int h, string anchorId, bool isChecked, + string optionsJson) => + DocxSessionOps.SetContentControlChecked(h, anchorId, isChecked, optionsJson); + + [JSExport] + public static string SetContentControlDate(int h, string anchorId, string value, + string displayText, string optionsJson) => + DocxSessionOps.SetContentControlDate(h, anchorId, value, + string.IsNullOrEmpty(displayText) ? null : displayText, optionsJson); + + [JSExport] + public static string SelectContentControlItem(int h, string anchorId, string value, + string optionsJson) => + DocxSessionOps.SelectContentControlItem(h, anchorId, value, optionsJson); + + [JSExport] + public static string FillContentControlPicture(int h, string anchorId, + string imageBase64, string optionsJson) => + DocxSessionOps.FillContentControlPicture(h, anchorId, imageBase64, optionsJson); + + [JSExport] + public static string AddRepeatingSectionItem(int h, string sectionAnchorId, + string afterItemAnchorId, string optionsJson) => + DocxSessionOps.AddRepeatingSectionItem(h, sectionAnchorId, + string.IsNullOrEmpty(afterItemAnchorId) ? null : afterItemAnchorId, optionsJson); + + [JSExport] + public static string RemoveRepeatingSectionItem(int h, string itemAnchorId) => + DocxSessionOps.RemoveRepeatingSectionItem(h, itemAnchorId); + [JSExport] public static string ListBookmarks(int h, int scopes) => DocxSessionOps.ListBookmarks(h, (ProjectionScopes)scopes); From e91867f8a32452f23a625900de6499e509d4066a Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 04:36:39 -0500 Subject: [PATCH 2/5] Harden content control safety and transport parity --- .../DocxSessionContentControlTests.cs | 398 +++++++++++++++++- Docxodus.Tests/McpServerDispatcherTests.cs | 119 ++++++ Docxodus/DocxSession.ContentControls.cs | 156 ++++++- Docxodus/DocxSession.LinksBookmarks.cs | 11 +- Docxodus/Internal/ContentControlIdentity.cs | 101 +++-- docs/architecture/native_content_controls.md | 38 +- npm/src/session.ts | 2 +- npm/src/types.ts | 2 +- tools/mcp-server/Dispatcher.cs | 6 +- tools/mcp-server/ToolCatalog.cs | 4 +- wasm/DocxodusWasm/DocxSessionBridge.cs | 5 +- 11 files changed, 756 insertions(+), 86 deletions(-) diff --git a/Docxodus.Tests/DocxSessionContentControlTests.cs b/Docxodus.Tests/DocxSessionContentControlTests.cs index 099a8f5e..7e66b822 100644 --- a/Docxodus.Tests/DocxSessionContentControlTests.cs +++ b/Docxodus.Tests/DocxSessionContentControlTests.cs @@ -21,6 +21,7 @@ namespace Docxodus.Tests; public sealed class DocxSessionContentControlTests { private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; private static readonly XNamespace W14 = "http://schemas.microsoft.com/office/word/2010/wordml"; private static readonly XNamespace W15 = "http://schemas.microsoft.com/office/word/2012/wordml"; @@ -39,6 +40,8 @@ public void CC001_Registry_IsOuterBeforeInner_AndReportsNativeMetadataPlacementA Assert.Equal(ContentControlPlacement.Block, outer.Placement); Assert.Equal("outer-tag", outer.Tag); Assert.Equal("Outer alias", outer.Alias); + Assert.False(outer.CanMutate); + Assert.Contains("nested controls", outer.UnsupportedReason, StringComparison.Ordinal); Assert.Equal(outer.AnchorId, inner.ParentAnchorId); Assert.Equal(1, inner.Depth); Assert.Equal(ContentControlPlacement.Inline, inner.Placement); @@ -254,11 +257,21 @@ public void CC008_OpsJson_IsStrictAndRoundTripsRegistryAndMutations() Assert.Equal("invalid_content_control_value", invalidOptions.RootElement .GetProperty("error").GetProperty("code").GetString()); + var dateAnchor = controls.Single(control => control.TryGetProperty("nativeId", out var id) + && id.GetString() == "103").GetProperty("anchorId").GetString()!; + using var emptyDisplayDate = JsonDocument.Parse( + Docxodus.Internal.DocxSessionOps.SetContentControlDate( + handle, dateAnchor, "2031-05-06T00:00:00Z", "", "{}")); + Assert.True(emptyDisplayDate.RootElement.GetProperty("success").GetBoolean()); + using var relisted = JsonDocument.Parse( + Docxodus.Internal.DocxSessionOps.ListContentControls(handle)); + Assert.Equal(string.Empty, relisted.RootElement.EnumerateArray().Single(control => + control.TryGetProperty("nativeId", out var id) && id.GetString() == "103") + .GetProperty("text").GetString()); + using var invalidDate = JsonDocument.Parse( Docxodus.Internal.DocxSessionOps.SetContentControlDate( - handle, controls.Single(control => control.TryGetProperty("nativeId", out var id) - && id.GetString() == "103") - .GetProperty("anchorId").GetString()!, "not-a-date", null, "{}")); + handle, dateAnchor, "not-a-date", null, "{}")); Assert.Equal("invalid_content_control_value", invalidDate.RootElement .GetProperty("error").GetProperty("code").GetString()); } @@ -295,10 +308,389 @@ public void CC009_PictureFill_ReusesNativeImageValidationAndRelationshipSeams() .Where(IsMaterialValidationError)); } + [Fact] + public void CC010_Office2013Binding_IsEnumeratedAndFailsClosedUntilExplicitlyDetached() + { + var fixture = Transform(BuildFixture(), document => + { + var properties = ControlByNativeId(document, "101").Element(W + "sdtPr")!; + properties.Add(new XElement(W15 + "dataBinding", + new XAttribute(W + "storeItemID", "{11111111-1111-1111-1111-111111111111}"), + new XAttribute(W + "xpath", "/root/value"), + new XAttribute(W + "prefixMappings", "xmlns:x='urn:test'"))); + }); + var customBefore = CustomXmlBytes(fixture); + using var session = new DocxSession(fixture); + var bound = session.ListContentControls().Single(control => control.NativeId == "101"); + Assert.True(bound.IsBound); + Assert.True(bound.CanDetachTargetBinding); + Assert.Equal("/root/value", bound.Binding!.XPath); + + var refused = session.FillContentControlText(bound.AnchorId, "refused"); + Assert.Equal(EditErrorCode.ContentControlBound, refused.Error!.Code); + Assert.Equal(0, session.UndoCount); + Assert.True(session.FillContentControlText(bound.AnchorId, "detached", + new ContentControlFillOptions + { + BindingPolicy = ContentControlBindingPolicy.DetachTarget, + }).Success); + + var saved = session.Save(); + Assert.Equal(customBefore, CustomXmlBytes(saved)); + using var document = WordprocessingDocument.Open(new MemoryStream(saved), false); + Assert.Null(ControlByNativeId(document, "101").Element(W + "sdtPr")? + .Element(W15 + "dataBinding")); + } + + [Fact] + public void CC011_CheckboxMissingChecked_UndoRestoresExactMissingPropertyShape() + { + var fixture = Transform(BuildFixture(), document => + ControlByNativeId(document, "102").Descendants(W14 + "checked").Single().Remove()); + using var session = new DocxSession(fixture); + var checkbox = session.ListContentControls().Single(control => control.NativeId == "102"); + Assert.True(session.SetContentControlChecked(checkbox.AnchorId, true).Success); + Assert.True(session.Undo()); + + using var document = WordprocessingDocument.Open(new MemoryStream(session.Save()), false); + Assert.Empty(ControlByNativeId(document, "102").Descendants(W14 + "checked")); + } + + [Fact] + public void CC012_RowAndCellTextControls_ReportUnsupportedPlacementBeforeHistory() + { + var fixture = Transform(BuildFixture(), document => + { + var body = document.MainDocumentPart!.GetXDocument().Root!.Element(W + "body")!; + var rowControl = new XElement(W + "sdt", + new XElement(W + "sdtPr", + new XElement(W + "id", new XAttribute(W + "val", "201")), + new XElement(W + "text")), + new XElement(W + "sdtContent", + new XElement(W + "tr", + new XElement(W + "tc", + new XElement(W + "p", + new XElement(W + "r", new XElement(W + "t", "row"))))))); + var cellControl = new XElement(W + "sdt", + new XElement(W + "sdtPr", + new XElement(W + "id", new XAttribute(W + "val", "202")), + new XElement(W + "text")), + new XElement(W + "sdtContent", + new XElement(W + "tc", + new XElement(W + "p", + new XElement(W + "r", new XElement(W + "t", "cell")))))); + body.AddFirst(new XElement(W + "tbl", rowControl, + new XElement(W + "tr", cellControl))); + }); + using var session = new DocxSession(fixture); + var controls = session.ListContentControls(); + var row = controls.Single(control => control.NativeId == "201"); + var cell = controls.Single(control => control.NativeId == "202"); + Assert.Equal(ContentControlPlacement.Row, row.Placement); + Assert.Equal(ContentControlPlacement.Cell, cell.Placement); + Assert.False(row.CanMutate); + Assert.False(cell.CanMutate); + Assert.Contains("inline and block", row.UnsupportedReason); + Assert.Equal(EditErrorCode.ContentControlPlacementUnsupported, + session.FillContentControlText(row.AnchorId, "x").Error!.Code); + Assert.Equal(EditErrorCode.ContentControlPlacementUnsupported, + session.FillContentControlText(cell.AnchorId, "x").Error!.Code); + Assert.Equal(0, session.UndoCount); + } + + [Fact] + public void CC013_PictureFill_RefusesNestedControlWithoutBypassingChildLock() + { + var fixture = Transform(BuildPictureFixture(), document => + { + var outer = ControlByNativeId(document, "113"); + var run = outer.Descendants(W + "r").Single(value => value.Descendants(W + "drawing").Any()); + run.ReplaceWith(new XElement(W + "sdt", + new XElement(W + "sdtPr", + new XElement(W + "id", new XAttribute(W + "val", "114")), + new XElement(W + "lock", new XAttribute(W + "val", "contentLocked")), + new XElement(W + "picture")), + new XElement(W + "sdtContent", new XElement(run)))); + }); + using var session = new DocxSession(fixture); + var outer = session.ListContentControls().Single(control => control.NativeId == "113"); + var child = session.ListContentControls().Single(control => control.NativeId == "114"); + Assert.False(outer.CanMutate); + Assert.Contains("nested controls", outer.UnsupportedReason, StringComparison.Ordinal); + var before = Assert.Single(session.ListImages()).IntrinsicWidthPixels; + Assert.Equal(EditErrorCode.ContentControlNestedFillUnsupported, + session.FillContentControlPicture(outer.AnchorId, Png(7, 9)).Error!.Code); + Assert.Equal(EditErrorCode.ContentControlLocked, + session.FillContentControlPicture(child.AnchorId, Png(7, 9)).Error!.Code); + Assert.Equal(0, session.UndoCount); + Assert.Equal(before, Assert.Single(session.ListImages()).IntrinsicWidthPixels); + } + + [Fact] + public void CC014_RepeatingClone_AssignsDistinctDocumentPropertyIdsToEveryDrawing() + { + var fixture = Transform(BuildPictureFixture(), document => + { + var drawingRun = ControlByNativeId(document, "113").Descendants(W + "r") + .Single(value => value.Descendants(W + "drawing").Any()); + var first = new XElement(drawingRun); + var second = new XElement(drawingRun); + first.Descendants().Single(value => value.Name.LocalName == "docPr") + .SetAttributeValue("id", "501"); + second.Descendants().Single(value => value.Name.LocalName == "docPr") + .SetAttributeValue("id", "502"); + ControlByNativeId(document, "109").Element(W + "sdtContent")! + .ReplaceNodes(new XElement(W + "p", first, second)); + }); + using var session = new DocxSession(fixture); + var section = session.ListContentControls().Single(control => control.NativeId == "108"); + Assert.True(session.AddRepeatingSectionItem(section.AnchorId).Success); + var saved = session.Save(); + using var document = WordprocessingDocument.Open(new MemoryStream(saved), false); + XNamespace wp = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; + var ids = document.MainDocumentPart!.GetXDocument().Descendants(wp + "docPr") + .Select(value => (string)value.Attribute("id")!).ToList(); + Assert.Equal(ids.Count, ids.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void CC015_RepeatingClone_RejectsCustomXmlMoveAndParagraphIdentities() + { + var cases = new Action[] + { + item => item.Element(W + "sdtContent")!.AddFirst( + new XElement(W + "customXml", new XElement(W + "p"))), + item => item.Element(W + "sdtContent")!.AddFirst( + new XElement(W + "customXmlMoveFromRangeStart", + new XAttribute(W + "id", "7"))), + item => item.Descendants(W + "p").First().SetAttributeValue(W14 + "paraId", "12345678"), + item => item.Descendants(W + "p").First().SetAttributeValue(W14 + "textId", "87654321"), + }; + foreach (var arrange in cases) + { + var fixture = Transform(BuildFixture(), document => + arrange(ControlByNativeId(document, "109"))); + using var session = new DocxSession(fixture); + var section = session.ListContentControls().Single(control => control.NativeId == "108"); + Assert.Equal(EditErrorCode.RepeatingSectionConstraint, + session.AddRepeatingSectionItem(section.AnchorId).Error!.Code); + Assert.Equal(0, session.UndoCount); + } + } + + [Fact] + public void CC016_DuplicateNativeIdsAcrossStories_AreStableDiagnosticsAndNotMutable() + { + var fixture = Transform(BuildFixture(), document => + { + var header = document.MainDocumentPart!.AddNewPart(); + header.PutXDocument(new XDocument(new XElement(W + "hdr", + BlockSdt("101", new XElement(W + "text"), "header duplicate")))); + }); + using var session = new DocxSession(fixture); + var duplicates = session.ListContentControls().Where(control => control.NativeId == "101").ToList(); + Assert.Equal(2, duplicates.Count); + Assert.All(duplicates, control => + { + Assert.True(control.HasDuplicateNativeId); + Assert.False(control.CanMutate); + Assert.Equal(EditErrorCode.ContentControlMalformed, + session.FillContentControlText(control.AnchorId, "x").Error!.Code); + }); + var anchors = duplicates.Select(control => control.AnchorId).OrderBy(value => value).ToArray(); + using var reopened = new DocxSession(session.Save()); + Assert.Equal(anchors, reopened.ListContentControls() + .Where(control => control.NativeId == "101") + .Select(control => control.AnchorId).OrderBy(value => value).ToArray()); + } + + [Fact] + public void CC017_WholeFill_ProtectsBookmarksIncludingTargetsRemovedByTheReplacement() + { + static void AddBookmark(XElement control, string name) + { + var content = control.Element(W + "sdtContent")!; + content.AddFirst(new XElement(W + "bookmarkStart", + new XAttribute(W + "id", "31"), new XAttribute(W + "name", name))); + content.Add(new XElement(W + "bookmarkEnd", new XAttribute(W + "id", "31"))); + } + + var externallyReferenced = Transform(BuildFixture(), document => + { + AddBookmark(ControlByNativeId(document, "101"), "InnerTarget"); + document.MainDocumentPart!.GetXDocument().Root!.Element(W + "body")!.Add( + new XElement(W + "p", new XElement(W + "hyperlink", + new XAttribute(W + "anchor", "InnerTarget"), + new XElement(W + "r", new XElement(W + "t", "jump"))))); + }); + using (var session = new DocxSession(externallyReferenced)) + { + var target = session.ListContentControls().Single(control => control.NativeId == "101"); + var refused = session.FillContentControlText(target.AnchorId, "replacement"); + Assert.Equal(EditErrorCode.BookmarkInUse, refused.Error!.Code); + Assert.Equal(0, session.UndoCount); + Assert.Equal("inner", session.GetContentControl(target.AnchorId)!.Text); + } + + var replacementTarget = Transform(BuildFixture(), document => + { + var control = ControlByNativeId(document, "101"); + control.Element(W + "sdtPr")!.Element(W + "text")! + .ReplaceWith(new XElement(W + "richText")); + AddBookmark(control, "RemovedTarget"); + }); + using (var session = new DocxSession(replacementTarget)) + { + var target = session.ListContentControls().Single(control => control.NativeId == "101"); + var refused = session.FillContentControlRichText(target.AnchorId, + "[dangling](#RemovedTarget)"); + Assert.Equal(EditErrorCode.MissingBookmarkTarget, refused.Error!.Code); + Assert.Equal(0, session.UndoCount); + Assert.Equal("inner", session.GetContentControl(target.AnchorId)!.Text); + } + } + + [Fact] + public void CC018_WholeFill_PromotesNewLinksSweepsOldRelationshipsAndImages_ThroughUndoRedo() + { + const string oldUri = "https://old.example.test/value"; + const string newUri = "https://new.example.test/value"; + var fixture = Transform(BuildPictureFixture(), document => + { + var main = document.MainDocumentPart!; + var control = ControlByNativeId(document, "113"); + control.Element(W + "sdtPr")!.Element(W + "picture")! + .ReplaceWith(new XElement(W + "richText")); + var textRun = control.Descendants(W + "r").First(run => run.Descendants(W + "t").Any()); + var relationship = main.AddHyperlinkRelationship(new Uri(oldUri), true); + textRun.ReplaceWith(new XElement(W + "hyperlink", + new XAttribute(R + "id", relationship.Id), new XElement(textRun))); + }); + + using var session = new DocxSession(fixture); + var target = session.ListContentControls().Single(control => control.NativeId == "113"); + Assert.True(session.FillContentControlRichText(target.AnchorId, + $"[new link]({newUri})").Success); + Assert.Empty(session.ListImages()); + Assert.Equal(newUri, Assert.Single(session.ListHyperlinks()).Target); + + Assert.True(session.Undo()); + Assert.Single(session.ListImages()); + Assert.Equal(oldUri, Assert.Single(session.ListHyperlinks()).Target); + + Assert.True(session.Redo()); + Assert.Empty(session.ListImages()); + Assert.Equal(newUri, Assert.Single(session.ListHyperlinks()).Target); + using var saved = WordprocessingDocument.Open(new MemoryStream(session.Save()), false); + Assert.Empty(saved.MainDocumentPart!.ImageParts); + var liveRelationship = Assert.Single(saved.MainDocumentPart.HyperlinkRelationships); + Assert.Equal(newUri, liveRelationship.Uri.ToString()); + Assert.Equal(liveRelationship.Id, saved.MainDocumentPart.GetXDocument().Descendants(W + "hyperlink") + .Single().Attribute(R + "id")?.Value); + } + + [Fact] + public void CC019_RepeatingRemoval_ProtectsBookmarksCleansRelationshipsAndFailsClosedWhenTracked() + { + static XElement AddSecondItem(WordprocessingDocument document) + { + var first = ControlByNativeId(document, "109"); + var second = new XElement(first); + second.Element(W + "sdtPr")!.Element(W + "id")! + .SetAttributeValue(W + "val", "209"); + first.AddAfterSelf(second); + return second; + } + + var bookmarked = Transform(BuildFixture(), document => + { + var second = AddSecondItem(document); + var paragraph = second.Descendants(W + "p").Single(); + paragraph.AddFirst(new XElement(W + "bookmarkStart", + new XAttribute(W + "id", "41"), new XAttribute(W + "name", "RepeatedTarget"))); + paragraph.Add(new XElement(W + "bookmarkEnd", new XAttribute(W + "id", "41"))); + document.MainDocumentPart!.GetXDocument().Root!.Element(W + "body")!.Add( + new XElement(W + "p", new XElement(W + "hyperlink", + new XAttribute(W + "anchor", "RepeatedTarget"), + new XElement(W + "r", new XElement(W + "t", "jump"))))); + }); + using (var session = new DocxSession(bookmarked)) + { + var item = session.ListContentControls().Single(control => control.NativeId == "209"); + var refused = session.RemoveRepeatingSectionItem(item.AnchorId); + Assert.Equal(EditErrorCode.BookmarkInUse, refused.Error!.Code); + Assert.Equal(0, session.UndoCount); + Assert.Equal(2, session.ListContentControls().Count(control => + control.Type == ContentControlType.RepeatingSectionItem)); + } + + const string oldUri = "https://removed.example.test/value"; + var relationshipFixture = Transform(BuildPictureFixture(), document => + { + var main = document.MainDocumentPart!; + var second = AddSecondItem(document); + var drawingRun = ControlByNativeId(document, "113").Descendants(W + "r") + .Single(run => run.Descendants(W + "drawing").Any()); + drawingRun.Remove(); + ControlByNativeId(document, "113").Remove(); + var relationship = main.AddHyperlinkRelationship(new Uri(oldUri), true); + second.Element(W + "sdtContent")!.ReplaceNodes(new XElement(W + "p", + drawingRun, + new XElement(W + "hyperlink", new XAttribute(R + "id", relationship.Id), + new XElement(W + "r", new XElement(W + "t", "removed link"))))); + }); + + using (var tracked = new DocxSession(relationshipFixture)) + { + tracked.SetTrackedChanges(TrackedChangeMode.RenderInline); + var item = tracked.ListContentControls().Single(control => control.NativeId == "209"); + Assert.Equal(EditErrorCode.TrackedOperationUnsupported, + tracked.RemoveRepeatingSectionItem(item.AnchorId).Error!.Code); + Assert.Equal(0, tracked.UndoCount); + Assert.Single(tracked.ListImages()); + } + + using (var session = new DocxSession(relationshipFixture)) + { + var item = session.ListContentControls().Single(control => control.NativeId == "209"); + Assert.True(session.RemoveRepeatingSectionItem(item.AnchorId).Success); + Assert.Empty(session.ListImages()); + Assert.Empty(session.ListHyperlinks()); + Assert.True(session.Undo()); + Assert.Single(session.ListImages()); + Assert.Equal(oldUri, Assert.Single(session.ListHyperlinks()).Target); + Assert.True(session.Redo()); + Assert.Empty(session.ListImages()); + Assert.Empty(session.ListHyperlinks()); + } + } + private static string[] ParagraphAnchors(DocxSession session) => session.Project().AnchorIndex.Values .Where(value => value.Anchor.Kind is "p" or "h" or "li") .Select(value => value.Anchor.Id).Distinct().ToArray(); + private static XElement ControlByNativeId(WordprocessingDocument document, string nativeId) => + document.MainDocumentPart!.GetXDocument().Descendants(W + "sdt").Concat( + document.MainDocumentPart.HeaderParts.SelectMany(header => + header.GetXDocument().Descendants(W + "sdt"))) + .Single(value => (string?)value.Element(W + "sdtPr")?.Element(W + "id")? + .Attribute(W + "val") == nativeId); + + private static byte[] Transform(byte[] bytes, Action transform) + { + using var stream = new MemoryStream(); + stream.Write(bytes); + stream.Position = 0; + using (var document = WordprocessingDocument.Open(stream, true)) + { + transform(document); + document.MainDocumentPart!.PutXDocument(); + foreach (var header in document.MainDocumentPart.HeaderParts) + header.PutXDocument(); + } + return stream.ToArray(); + } + internal static byte[] BuildFixture() { var bytes = DocxSessionTests.BuildDS001_SimpleTwoParagraphs(); diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs index a6dccbe3..03fcf406 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -2269,6 +2269,15 @@ public void MCP146_ContentControls_ListFillDetachAndBatchPreview_AreFirstClass() var detached = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( $$"""{"sessionId":{{sessionArg}},"action":"fill_text","anchorId":{{JsonSerializer.Serialize(boundAnchor)}},"text":"yes","bindingPolicy":"detach_target"}"""))); Assert.True(detached.GetProperty("success").GetBoolean()); + Assert.Throws(() => Dispatcher.Call( + _store, "docxodus_content_controls", J(JsonSerializer.Serialize(new + { + sessionId, + action = "fill_text", + anchorId = plainAnchor, + text = "ignored", + bindingPolicy = false, + })))); var previewArgs = JsonSerializer.Serialize(new { @@ -2292,4 +2301,114 @@ public void MCP146_ContentControls_ListFillDetachAndBatchPreview_AreFirstClass() .GetProperty("bindingPolicy").GetProperty("enum").EnumerateArray() .Select(value => value.GetString())); } + + [Fact] + public void MCP147_ContentControlPreview_RefusalsNeverConsumePreexistingUndoHistory() + { + File.WriteAllBytes(_tempPath, DocxSessionContentControlTests.BuildFixture()); + var sessionId = OpenSession(); + var sessionArg = JsonSerializer.Serialize(sessionId); + var listed = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"list"}"""))) + .GetProperty("contentControls").EnumerateArray().ToArray(); + string Anchor(string nativeId) => listed.Single(control => + control.TryGetProperty("nativeId", out var id) && id.GetString() == nativeId) + .GetProperty("anchorId").GetString()!; + var plainAnchor = Anchor("101"); + var boundAnchor = Anchor("106"); + + Assert.True(Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"fill_text","anchorId":{{JsonSerializer.Serialize(plainAnchor)}},"text":"kept edit"}"""))) + .GetProperty("success").GetBoolean()); + var guarded = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + JsonSerializer.Serialize(new + { + sessionId, + action = "fill_text", + anchorId = plainAnchor, + text = "stale", + preconditions = new { expectedVersion = 0 }, + })))); + Assert.Equal("precondition_failed", + guarded.GetProperty("error").GetProperty("code").GetString()); + + string Preview(params object[] steps) => Dispatcher.Call(_store, "docxodus_mutations", J( + JsonSerializer.Serialize(new { sessionId, mode = "preview", steps }))); + var blocks = Parse(Dispatcher.Call(_store, "docxodus_get_content", J( + $$"""{"sessionId":{{sessionArg}},"format":"blocks"}"""))).GetProperty("blocks"); + var bodyBlock = blocks.EnumerateObject().First(property => property.Value.ValueKind + == JsonValueKind.Object && property.Value.GetProperty("scope").GetString() == "body" + && property.Value.GetProperty("kind").GetString() is "p" or "h" or "li" or "tbl").Name; + var successfulNoOp = Parse(Preview(new + { + tool = "docxodus_edit", + args = new + { + action = "move_block", + sourceAnchorId = bodyBlock, + targetAnchorId = bodyBlock, + position = "before", + }, + })); + Assert.Equal("ok", successfulNoOp.GetProperty("status").GetString()); + + var failedOnly = Parse(Preview(new + { + tool = "docxodus_content_controls", + args = new { action = "fill_text", anchorId = boundAnchor, text = "refused" }, + })); + Assert.Equal("failed", failedOnly.GetProperty("status").GetString()); + + var readOnly = Parse(Preview(new + { + tool = "docxodus_content_controls", + args = new { action = "list" }, + })); + Assert.Equal("invalid_batch_step", + readOnly.GetProperty("failure").GetProperty("error").GetProperty("code").GetString()); + + var mixed = Parse(Preview( + new + { + tool = "docxodus_content_controls", + args = new { action = "fill_text", anchorId = plainAnchor, text = "preview" }, + }, + new + { + tool = "docxodus_content_controls", + args = new { action = "fill_text", anchorId = boundAnchor, text = "refused" }, + })); + Assert.Equal("failed", mixed.GetProperty("status").GetString()); + Assert.Equal(0, mixed.GetProperty("editsApplied").GetInt32()); + + var invalidArguments = Parse(Dispatcher.Call(_store, "docxodus_mutations", J( + JsonSerializer.Serialize(new + { + sessionId, + mode = "atomic", + steps = new object[] + { + new { tool = "docxodus_content_controls", + args = new { action = "fill_text", anchorId = plainAnchor, text = "not applied" } }, + new { tool = "docxodus_content_controls", + args = new { action = "set_checked", anchorId = plainAnchor } }, + }, + })))); + Assert.Equal("invalid_batch_step", invalidArguments.GetProperty("failure") + .GetProperty("error").GetProperty("code").GetString()); + var after = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"list"}"""))) + .GetProperty("contentControls").EnumerateArray().Single(control => + control.TryGetProperty("nativeId", out var id) && id.GetString() == "101"); + Assert.Equal("kept edit", after.GetProperty("text").GetString()); + + Assert.True(Parse(Dispatcher.Call(_store, "docxodus_edit", J( + $$"""{"sessionId":{{sessionArg}},"action":"undo"}"""))) + .GetProperty("success").GetBoolean()); + var undone = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + $$"""{"sessionId":{{sessionArg}},"action":"list"}"""))) + .GetProperty("contentControls").EnumerateArray().Single(control => + control.TryGetProperty("nativeId", out var id) && id.GetString() == "101"); + Assert.Equal("inner", undone.GetProperty("text").GetString()); + } } diff --git a/Docxodus/DocxSession.ContentControls.cs b/Docxodus/DocxSession.ContentControls.cs index 0695d0ee..6898c9a6 100644 --- a/Docxodus/DocxSession.ContentControls.cs +++ b/Docxodus/DocxSession.ContentControls.cs @@ -33,8 +33,9 @@ public enum ContentControlBindingPolicy /// Never alter a binding. Bound controls fail closed. Preserve = 0, - /// Remove only the selected control's own w:dataBinding before filling it. - /// A binding on any ancestor still fails closed. + /// Remove only the selected control's own native data-binding element + /// (w:dataBinding or w15:dataBinding) before filling it. A binding on any ancestor + /// still fails closed. DetachTarget = 1, } @@ -123,11 +124,6 @@ public EditResult SetContentControlChecked(string anchorId, bool isChecked, return EditResult.Fail(EditErrorCode.ContentControlMalformed, "checkbox content control has no w14:checkbox properties", anchorId); var checkedElement = checkbox.Element(ContentControlW14 + "checked"); - if (checkedElement is null) - { - checkedElement = new XElement(ContentControlW14 + "checked"); - checkbox.AddFirst(checkedElement); - } var stateElement = checkbox.Element(isChecked ? ContentControlW14 + "checkedState" @@ -135,9 +131,16 @@ public EditResult SetContentControlChecked(string anchorId, bool isChecked, var fallback = isChecked ? 0x2612 : 0x2610; var glyph = TryParseHexScalar((string?)stateElement?.Attribute(ContentControlW14 + "val"), out var scalar) ? char.ConvertFromUtf32(scalar) : char.ConvertFromUtf32(fallback); + if (ValidateWholeContentReplacement(candidate, replacement: null, anchorId) is { } replacementError) + return replacementError; return MutateContentControl(candidate, options, () => { + if (checkedElement is null) + { + checkedElement = new XElement(ContentControlW14 + "checked"); + checkbox.AddFirst(checkedElement); + } checkedElement.SetAttributeValue(ContentControlW14 + "val", isChecked ? "1" : "0"); ReplaceControlWithPlainText(candidate.Element, glyph); }); @@ -155,6 +158,8 @@ public EditResult SetContentControlDate(string anchorId, DateTimeOffset value, return EditResult.Fail(EditErrorCode.ContentControlMalformed, "date content control has no w:date properties", anchorId); var shown = displayText ?? value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + if (ValidateWholeContentReplacement(candidate, replacement: null, anchorId) is { } replacementError) + return replacementError; return MutateContentControl(candidate, options, () => { date.SetAttributeValue(W.fullDate, value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", @@ -183,6 +188,8 @@ public EditResult SelectContentControlItem(string anchorId, string value, : $"content control has multiple list items matching '{value}'", anchorId); var display = (string?)matches[0].Attribute(W.displayText) ?? (string?)matches[0].Attribute(ContentControlW + "value") ?? string.Empty; + if (ValidateWholeContentReplacement(candidate, replacement: null, anchorId) is { } replacementError) + return replacementError; return MutateContentControl(candidate, options, () => ReplaceControlWithPlainText(candidate.Element, display)); } @@ -192,6 +199,8 @@ public EditResult FillContentControlPicture(string anchorId, byte[] imageBytes, { if (ResolveContentControlForMutation(anchorId, ContentControlType.Picture, options, out var candidate, out var error) is false) return error!; + if (ContainsNestedContentControl(candidate!.Element)) + return NestedFillError(anchorId); var binary = ValidateImageBytes(imageBytes, anchorId); if (binary.Error is not null) return binary.Error; var images = EnumerateImageCandidates(ProjectionScopes.All).Where(image => @@ -251,7 +260,7 @@ public EditResult AddRepeatingSectionItem(string sectionAnchorId, } if (FindUnsafeRepeatingCloneCarrier(template) is { } unsafeCarrier) return EditResult.Fail(EditErrorCode.RepeatingSectionConstraint, - $"repeating item contains clone-sensitive markup ({unsafeCarrier.Name.LocalName})", + $"repeating item contains clone-sensitive markup ({unsafeCarrier})", sectionAnchorId); _history.RecordPreOp(TakeSnapshot()); @@ -263,8 +272,7 @@ public EditResult AddRepeatingSectionItem(string sectionAnchorId, element.Attribute(PtOpenXml.Unid)?.Remove(); AssignFreshContentControlIds(clone); UnidHelper.AssignToSelfAndDescendants(clone); - foreach (var docPr in clone.Descendants(WP.docPr)) - docPr.SetAttributeValue("id", NextDocumentPropertyId().ToString(CultureInfo.InvariantCulture)); + AssignFreshDocumentPropertyIds(clone); template.AddAfterSelf(clone); ContentControlIdentity.AssignStableUnids(section.Owner.Part.GetXDocument().Root!); InvalidateProjectionCache(); @@ -301,6 +309,8 @@ public EditResult RemoveRepeatingSectionItem(string itemAnchorId) return parentLock; if (ValidateBindingPolicy(parentCandidate, options: null) is { } bindingError) return bindingError; + if (ValidateBookmarkRemoval(new[] { item.Element }, itemAnchorId) is { } bookmarkError) + return bookmarkError; _history.RecordPreOp(TakeSnapshot()); try @@ -329,8 +339,12 @@ private EditResult FillTextualContentControl(string anchorId, string payload, bo if (ContainsNestedContentControl(candidate!.Element)) return NestedFillError(anchorId); if (!rich) + { + if (ValidateWholeContentReplacement(candidate, replacement: null, anchorId) is { } replacementError) + return replacementError; return MutateContentControl(candidate, options, () => ReplaceControlWithPlainText(candidate.Element, payload)); + } var parsed = MarkdownPayloadParser.Parse(payload); if (!parsed.Success) @@ -343,6 +357,9 @@ private EditResult FillTextualContentControl(string anchorId, string payload, bo if (candidate.Info.Placement is not (ContentControlPlacement.Inline or ContentControlPlacement.Block)) return EditResult.Fail(EditErrorCode.ContentControlPlacementUnsupported, "rich-text fill supports only inline and block content controls", anchorId); + var replacement = parsed.Blocks.SelectMany(block => block.RunElements).ToList(); + if (ValidateWholeContentReplacement(candidate, replacement, anchorId) is { } richReplacementError) + return richReplacementError; return MutateContentControl(candidate, options, () => { @@ -361,7 +378,6 @@ private EditResult FillTextualContentControl(string anchorId, string payload, bo content.ReplaceNodes(blocks); } candidate.Element.Element(W.sdtPr)?.Element(W.showingPlcHdr)?.Remove(); - PromoteHyperlinkRelationships(candidate.Element); }); } @@ -422,6 +438,14 @@ private bool ResolveContentControlForMutation(string anchorId, candidate.Info.UnsupportedReason ?? "unsupported content-control placement", anchorId); return false; } + if (!IsMutationPlacementSupported(candidate.Info.Type, candidate.Info.Placement)) + { + error = EditResult.Fail(EditErrorCode.ContentControlPlacementUnsupported, + candidate.Info.UnsupportedReason + ?? $"{candidate.Info.Type} mutation supports only inline and block content controls", + anchorId); + return false; + } if (ValidateEffectiveLocks(candidate, removingWrapper) is { } lockError) { error = lockError; @@ -455,7 +479,7 @@ private bool ResolveContentControlForMutation(string anchorId, ContentControlFillOptions? options) { var boundControls = candidate.Element.AncestorsAndSelf(W.sdt).Where(control => - control.Element(W.sdtPr)?.Element(W.dataBinding) is not null).ToList(); + FindDataBinding(control.Element(W.sdtPr)) is not null).ToList(); if (boundControls.Count == 0) return null; var targetBound = boundControls.Any(control => ReferenceEquals(control, candidate.Element)); var hasBoundAncestor = boundControls.Any(control => !ReferenceEquals(control, candidate.Element)); @@ -466,7 +490,7 @@ private bool ResolveContentControlForMutation(string anchorId, if (!targetBound) return null; if (options?.BindingPolicy == ContentControlBindingPolicy.DetachTarget) return null; return EditResult.Fail(EditErrorCode.ContentControlBound, - "target is data-bound; retry with bindingPolicy=detach_target to remove only its w:dataBinding", + "target is data-bound; retry with bindingPolicy=detach_target to remove only its native data-binding element", candidate.Info.AnchorId); } @@ -478,6 +502,8 @@ private EditResult MutateContentControl(ContentControlCandidate candidate, { DetachTargetBindingIfRequested(candidate.Element, options); mutation(); + PromoteHyperlinkRelationships(candidate.Element); + SweepOrphanedStoryRelationships(candidate.Owner.Part); UnidHelper.AssignToSelfAndDescendants(candidate.Element); ContentControlIdentity.AssignStableUnids(candidate.Owner.Part.GetXDocument().Root!); InvalidateProjectionCache(); @@ -496,7 +522,8 @@ private static void DetachTargetBindingIfRequested(XElement control, ContentControlFillOptions? options) { if (options?.BindingPolicy == ContentControlBindingPolicy.DetachTarget) - control.Element(W.sdtPr)?.Element(W.dataBinding)?.Remove(); + foreach (var binding in FindDataBindings(control.Element(W.sdtPr)).ToList()) + binding.Remove(); } private static void ReplaceControlWithPlainText(XElement control, string text) @@ -527,6 +554,25 @@ private static void ReplaceControlWithPlainText(XElement control, string text) control.Element(W.sdtPr)?.Element(W.showingPlcHdr)?.Remove(); } + /// Validate every consequence of replacing the target's complete payload before + /// taking an undo snapshot. Existing bookmark ranges must be safe to remove, and hyperlinks + /// in detached Markdown must still resolve after those ranges are gone. + private EditResult? ValidateWholeContentReplacement( + ContentControlCandidate candidate, + IEnumerable? replacement, + string anchorId) + { + var content = candidate.Element.Element(W.sdtContent); + if (content is null) + return EditResult.Fail(EditErrorCode.ContentControlMalformed, + "content control has no w:sdtContent", anchorId); + if (ValidateBookmarkRemoval(new[] { content }, anchorId) is { } bookmarkError) + return bookmarkError; + return replacement is null + ? null + : ValidatePendingHyperlinks(replacement, anchorId, content); + } + private static bool ContainsNestedContentControl(XElement control) => control.Element(W.sdtContent)?.Descendants(W.sdt).Any() == true; @@ -538,12 +584,16 @@ private static EditResult NestedFillError(string anchorId) => private IReadOnlyList BuildContentControlRegistry(ProjectionScopes scopes) { var result = new List(); - foreach (var owner in OwnedPartRelationships.StoryParts(_doc!)) + var owners = OwnedPartRelationships.StoryParts(_doc!); + var roots = owners.Select(owner => owner.Part.GetXDocument().Root) + .Where(root => root is not null).Cast().ToList(); + var identitiesByRoot = ContentControlIdentity.AssignStableUnids(roots, out _); + foreach (var owner in owners) { if (!ScopeIncluded(owner.Scope, scopes)) continue; var root = owner.Part.GetXDocument().Root; if (root is null) continue; - var identities = ContentControlIdentity.AssignStableUnids(root); + var identities = identitiesByRoot[root]; var byElement = identities.ToDictionary(identity => identity.Element, identity => identity); var anchorByElement = identities.ToDictionary(identity => identity.Element, @@ -554,21 +604,27 @@ private IReadOnlyList BuildContentControlRegistry(Proje var props = element.Element(W.sdtPr); var type = ClassifyContentControl(props); var placement = DetectContentControlPlacement(element); - var binding = props?.Element(W.dataBinding); + var binding = FindDataBinding(props); var parent = element.Ancestors(W.sdt).FirstOrDefault(); var lockToken = (string?)props?.Element(ContentControlW + "lock")?.Attribute(W.val); string? unsupported = null; if (!identity.HasValidNativeId) unsupported = "missing or invalid native w:sdtPr/w:id"; - else if (identity.IsDuplicateNativeId) unsupported = "duplicate native w:sdtPr/w:id in owning story"; + else if (identity.IsDuplicateNativeId) unsupported = "duplicate native w:sdtPr/w:id in package"; else if (placement == ContentControlPlacement.Unknown) unsupported = "unsupported or malformed OOXML placement"; else if (type == ContentControlType.Unsupported) unsupported = "unsupported content-control family"; bool targetBound = binding is not null; bool ancestorBound = element.Ancestors(W.sdt).Any(ancestor => - ancestor.Element(W.sdtPr)?.Element(W.dataBinding) is not null); + FindDataBinding(ancestor.Element(W.sdtPr)) is not null); bool locked = element.AncestorsAndSelf(W.sdt).Any(control => (string?)control.Element(W.sdtPr)?.Element(ContentControlW + "lock")?.Attribute(W.val) is "contentLocked" or "sdtContentLocked"); + bool placementSupported = IsMutationPlacementSupported(type, placement); + if (unsupported is null && !placementSupported) + unsupported = $"{type} mutation supports only inline and block content controls"; + if (unsupported is null && IsWholeControlFillType(type) + && ContainsNestedContentControl(element)) + unsupported = "whole-control fill is unsupported when the target contains nested controls"; bool defaultMutable = unsupported is null && !locked && !targetBound && !ancestorBound; var items = props?.Elements().FirstOrDefault(value => @@ -636,9 +692,10 @@ private static ContentControlType ClassifyContentControl(XElement? props) if (props.Element(W.text) is not null) return ContentControlType.PlainText; if (props.Element(ContentControlW + "richText") is not null) return ContentControlType.RichText; var knownMetadata = new HashSet { W.id, W.tag, W.alias, W.dataBinding, + ContentControlW15 + "dataBinding", W.showingPlcHdr, ContentControlW + "lock", ContentControlW + "placeholder", - ContentControlW + "temporary", ContentControlW + "appearance", - ContentControlW + "color" }; + ContentControlW + "temporary", ContentControlW15 + "appearance", + ContentControlW15 + "color", W.rPr }; return props.Elements().All(element => knownMetadata.Contains(element.Name)) ? ContentControlType.RichText : ContentControlType.Unsupported; @@ -708,19 +765,72 @@ private void AssignFreshContentControlIds(XElement root) } } - private static XElement? FindUnsafeRepeatingCloneCarrier(XElement item) + private void AssignFreshDocumentPropertyIds(XElement root) + { + var used = OwnedPartRelationships.StoryParts(_doc!) + .SelectMany(owner => owner.Part.GetXDocument().Descendants(WP.docPr)) + .Select(element => uint.TryParse((string?)element.Attribute("id"), + NumberStyles.None, CultureInfo.InvariantCulture, out var id) ? id : 0) + .Where(id => id != 0).ToHashSet(); + uint next = 1; + foreach (var docPr in root.Descendants(WP.docPr)) + { + while (next != 0 && used.Contains(next)) next++; + if (next == 0) + throw new InvalidOperationException("no globally available wp:docPr id remains"); + docPr.SetAttributeValue("id", next.ToString(CultureInfo.InvariantCulture)); + used.Add(next++); + } + } + + private static string? FindUnsafeRepeatingCloneCarrier(XElement item) { var unsafeNames = new HashSet { W.bookmarkStart, W.bookmarkEnd, W.commentRangeStart, W.commentRangeEnd, W.commentReference, W.footnoteReference, W.endnoteReference, ContentControlW + "permStart", ContentControlW + "permEnd", + ContentControlW + "customXml", ContentControlW + "customXmlInsRangeStart", ContentControlW + "customXmlInsRangeEnd", ContentControlW + "customXmlDelRangeStart", ContentControlW + "customXmlDelRangeEnd", + ContentControlW + "customXmlMoveFromRangeStart", ContentControlW + "customXmlMoveFromRangeEnd", + ContentControlW + "customXmlMoveToRangeStart", ContentControlW + "customXmlMoveToRangeEnd", + ContentControlW + "moveFromRangeStart", ContentControlW + "moveFromRangeEnd", + ContentControlW + "moveToRangeStart", ContentControlW + "moveToRangeEnd", + ContentControlW + "moveFrom", ContentControlW + "moveTo", }; - return item.Descendants().FirstOrDefault(element => unsafeNames.Contains(element.Name)); + var unsafeElement = item.Descendants().FirstOrDefault(element => unsafeNames.Contains(element.Name)); + if (unsafeElement is not null) return unsafeElement.Name.LocalName; + var unsafeIdentity = item.DescendantsAndSelf().Attributes().FirstOrDefault(attribute => + attribute.Name == ContentControlW14 + "paraId" + || attribute.Name == ContentControlW14 + "textId"); + return unsafeIdentity?.Name.LocalName; } + private static IEnumerable FindDataBindings(XElement? properties) => + properties?.Elements().Where(element => + element.Name == W.dataBinding || element.Name == ContentControlW15 + "dataBinding") + ?? Enumerable.Empty(); + + private static XElement? FindDataBinding(XElement? properties) => + FindDataBindings(properties).FirstOrDefault(); + + private static bool IsMutationPlacementSupported(ContentControlType type, + ContentControlPlacement placement) => type switch + { + ContentControlType.PlainText or ContentControlType.RichText + or ContentControlType.Checkbox or ContentControlType.Date + or ContentControlType.DropDownList or ContentControlType.ComboBox => + placement is ContentControlPlacement.Inline or ContentControlPlacement.Block, + _ => placement != ContentControlPlacement.Unknown, + }; + + private static bool IsWholeControlFillType(ContentControlType type) => type is + ContentControlType.PlainText or ContentControlType.RichText + or ContentControlType.Checkbox or ContentControlType.Date + or ContentControlType.DropDownList or ContentControlType.ComboBox + or ContentControlType.Picture; + private static bool TryParseHexScalar(string? value, out int scalar) { scalar = 0; diff --git a/Docxodus/DocxSession.LinksBookmarks.cs b/Docxodus/DocxSession.LinksBookmarks.cs index 061dbfa4..0d22eea3 100644 --- a/Docxodus/DocxSession.LinksBookmarks.cs +++ b/Docxodus/DocxSession.LinksBookmarks.cs @@ -378,7 +378,10 @@ public EditResult RemoveBookmark(string name) /// Validate Markdown parser's detached href markers before a mutation snapshots or /// changes XML. This gives [text](#bookmark) the same structured target rules as the /// first-class API. - private EditResult? ValidatePendingHyperlinks(IEnumerable elements, string? anchorId) + private EditResult? ValidatePendingHyperlinks( + IEnumerable elements, + string? anchorId, + XElement? replacementRoot = null) { foreach (var link in elements.SelectMany(e => e.DescendantsAndSelf(W.hyperlink))) { @@ -388,6 +391,12 @@ public EditResult RemoveBookmark(string name) ? HyperlinkTarget.Internal(href.Substring(1)) : HyperlinkTarget.External(href); if (ValidateHyperlinkTarget(target, anchorId) is { } error) return error; + if (replacementRoot is not null && target.Kind == HyperlinkKind.Internal + && BookmarkStarts(target.Target).Any(start => ReferenceEquals(start, replacementRoot) + || start.Ancestors().Any(ancestor => ReferenceEquals(ancestor, replacementRoot)))) + return EditResult.Fail(EditErrorCode.MissingBookmarkTarget, + $"replacement would remove the bookmark targeted by an internal hyperlink: {target.Target}", + anchorId); } return null; } diff --git a/Docxodus/Internal/ContentControlIdentity.cs b/Docxodus/Internal/ContentControlIdentity.cs index e6419e9f..def6bb30 100644 --- a/Docxodus/Internal/ContentControlIdentity.cs +++ b/Docxodus/Internal/ContentControlIdentity.cs @@ -44,51 +44,78 @@ internal static IReadOnlyList AssignStableUnids(XElement storyRoot) => internal static IReadOnlyList AssignStableUnids(XElement storyRoot, out bool changed) { - ArgumentNullException.ThrowIfNull(storyRoot); - changed = false; - var controls = storyRoot.DescendantsAndSelf(W.sdt).ToList(); - if (controls.Count == 0) return Array.Empty(); + var byRoot = AssignStableUnids(new[] { storyRoot }, out changed); + return byRoot[storyRoot]; + } - var parsed = controls.Select((element, ordinal) => - { - var raw = (string?)element.Element(W.sdtPr)?.Element(W.id)?.Attribute(W.val); - var valid = TryCanonicalizeNativeId(raw, out var canonical); - return (element, ordinal, raw, valid, canonical); - }).ToList(); + /// + /// Assign identities across every story in one package. Native w:id uniqueness is a + /// document-wide invariant, so callers that decide mutability must use this overload rather + /// than validating each part in isolation. Diagnostic Unids for duplicates remain scoped to + /// their owning story: cross-story duplicates can therefore be marked non-writable without + /// changing otherwise stable sdt:{scope}:... anchors, while duplicates inside one story + /// retain distinct local ordinals and cannot collide in that story's anchor index. + /// + internal static IReadOnlyDictionary> AssignStableUnids( + IReadOnlyList storyRoots, out bool changed) + { + ArgumentNullException.ThrowIfNull(storyRoots); + changed = false; + foreach (var root in storyRoots) ArgumentNullException.ThrowIfNull(root); - var counts = parsed.Where(value => value.valid) + var parsedByRoot = storyRoots.ToDictionary(root => root, root => + root.DescendantsAndSelf(W.sdt).Select((element, ordinal) => + { + var raw = (string?)element.Element(W.sdtPr)?.Element(W.id)?.Attribute(W.val); + var valid = TryCanonicalizeNativeId(raw, out var canonical); + return (element, ordinal, raw, valid, canonical); + }).ToList()); + var globalCounts = parsedByRoot.Values.SelectMany(values => values) + .Where(value => value.valid) .GroupBy(value => value.canonical!, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); - var duplicateOrdinals = new Dictionary(StringComparer.Ordinal); - var result = new List(parsed.Count); - - foreach (var value in parsed) + var result = new Dictionary>(); + int documentOrdinal = 0; + foreach (var root in storyRoots) { - int duplicateOrdinal = 0; - bool duplicate = value.valid && counts[value.canonical!] > 1; - if (duplicate) + var parsed = parsedByRoot[root]; + var localCounts = parsed.Where(value => value.valid) + .GroupBy(value => value.canonical!, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var localOrdinals = new Dictionary(StringComparer.Ordinal); + var entries = new List(parsed.Count); + foreach (var value in parsed) { - duplicateOrdinals.TryGetValue(value.canonical!, out duplicateOrdinal); - duplicateOrdinals[value.canonical!] = duplicateOrdinal + 1; - } + int duplicateOrdinal = 0; + bool localDuplicate = value.valid && localCounts[value.canonical!] > 1; + bool packageDuplicate = value.valid && globalCounts[value.canonical!] > 1; + if (localDuplicate) + { + localOrdinals.TryGetValue(value.canonical!, out duplicateOrdinal); + localOrdinals[value.canonical!] = duplicateOrdinal + 1; + } - // Unique, valid native ids are location- and content-independent. The fallback - // discriminator is intentionally only for non-writable malformed documents. - var seed = value.valid - ? duplicate - ? $"duplicate\0{value.canonical}\0{duplicateOrdinal}" - : $"native\0{value.canonical}" - : $"malformed\0{value.ordinal}\0{value.raw ?? ""}"; - var unid = HashToUnid(seed); - if (!string.Equals((string?)value.element.Attribute(PtOpenXml.Unid), unid, - StringComparison.Ordinal)) - { - value.element.SetAttributeValue(PtOpenXml.Unid, unid); - changed = true; + // Unique, valid native ids are location- and content-independent. Duplicate + // ordinals are local to a story because scope is already part of the public + // anchor; this keeps a package-wide duplicate diagnostic idempotent with the + // legacy single-story assignment performed by projection helpers. + var seed = value.valid + ? localDuplicate + ? $"duplicate\0{value.canonical}\0{duplicateOrdinal}" + : $"native\0{value.canonical}" + : $"malformed\0{value.ordinal}\0{value.raw ?? ""}"; + var unid = HashToUnid(seed); + if (!string.Equals((string?)value.element.Attribute(PtOpenXml.Unid), unid, + StringComparison.Ordinal)) + { + value.element.SetAttributeValue(PtOpenXml.Unid, unid); + changed = true; + } + entries.Add(new Entry(value.element, unid, + value.valid ? value.canonical : value.raw, + value.valid, packageDuplicate, documentOrdinal++, duplicateOrdinal)); } - result.Add(new Entry(value.element, unid, - value.valid ? value.canonical : value.raw, - value.valid, duplicate, value.ordinal, duplicateOrdinal)); + result[root] = entries; } return result; } diff --git a/docs/architecture/native_content_controls.md b/docs/architecture/native_content_controls.md index c1ad2232..a3f085ec 100644 --- a/docs/architecture/native_content_controls.md +++ b/docs/architecture/native_content_controls.md @@ -11,10 +11,10 @@ owning part/scope, nesting parent/depth, current text/list values, and mutation The public anchor is `sdt:{scope}:{unid}`. For a unique, valid signed 32-bit `w:sdtPr/w:id`, `unid` is a deterministic hash of that native id; the scope identifies the owning story. It therefore survives value edits and a normal clean save/reopen, -even though `PtOpenXml:Unid` bookkeeping is stripped. Missing, invalid, or duplicate -native ids remain enumerable under deterministic diagnostic anchors but are not -mutable. Repeating-item clones receive fresh native ids before their anchors are made -public. +even though `PtOpenXml:Unid` bookkeeping is stripped. Missing, invalid, or +package-wide duplicate native ids remain enumerable under deterministic diagnostic +anchors but are not mutable. Repeating-item clones receive fresh native ids before +their anchors are made public. `sdt` is an AnchorIndex kind in both the WML projector and the immutable IR emitter. The IR captures projector-order anchor facts while its private package is open, so @@ -39,11 +39,22 @@ operation. Text fills retain representative run/paragraph properties and clear o the showing-placeholder marker. Picture fills replace the image relationship without rebuilding the wrapper. Repeating clones freshen every nested content-control id and drawing `docPr` id, and reject clone-sensitive bookmark, comment, permission, custom -XML range, and note-reference markup. The final item cannot be removed. +XML container/range, move, note-reference, and `w14:paraId`/`w14:textId` markup. The +final item cannot be removed. -Every successful operation is one undo/redo step. Whole-control fills are explicitly -rejected in `render_inline` tracked-change mode until issue #455 defines revision -semantics; surgical text/format operations inside a control remain available. +Whole-content replacement and repeating-item removal use the same bookmark-removal +gate as other structural edits: crossing or externally referenced ranges fail before +history changes. Rich-text links are validated against the document that will remain +after replacement, so a payload cannot target a bookmark it simultaneously removes. +After a successful replacement/removal, owner-local hyperlink relationships are +promoted or reference-counted away and unreferenced image parts are swept. Undo/redo +restores that XML and package relationship topology together. + +Every successful operation is one undo/redo step. Whole-control fills support inline +and block controls; row/cell controls remain enumerable but report `CanMutate=false` +and reject typed fills. Whole-control fills are also rejected in `render_inline` +tracked-change mode until issue #455 defines revision semantics; surgical text/format +operations inside a control remain available. ## Locks, bindings, and nesting @@ -51,10 +62,11 @@ Content locks are effective through ancestors. A locked target or ancestor fails without changing history. A whole-content replacement that would discard a nested control is also refused; callers address the nested child directly. -Bindings fail closed by default. `bindingPolicy: "detach_target"` is the only opt-in: -it removes the selected control's own `w:dataBinding` before the mutation. It never -removes an ancestor binding and never edits or regenerates a Custom XML data part. -A target inside a bound ancestor is always refused. +Bindings fail closed by default. Both `w:dataBinding` and the Office 2013 +`w15:dataBinding` form are recognized. `bindingPolicy: "detach_target"` is the only +opt-in: it removes the selected control's own native binding element before the +mutation. It never removes an ancestor binding and never edits or regenerates a +Custom XML data part. A target inside a bound ancestor is always refused. ## Transports @@ -64,6 +76,8 @@ is `bindingPolicy`, with `preserve` (default) or `detach_target`. The MCP groupe is `docxodus_content_controls`; its mutating actions participate in `docxodus_mutations` apply/preview rollback, while `list` is read-only and rejected as a batch step. Picture bytes cross JSON transports only as base64. +An omitted date `displayText` selects the invariant default; an explicitly empty +string remains empty through the JSON, WASM, and TypeScript layers. Failures are structured `EditErrorCode` values, including not found, malformed, unsupported family/placement, wrong type, locked, bound, invalid value, unsafe nested diff --git a/npm/src/session.ts b/npm/src/session.ts index a8534b23..13f5ec10 100644 --- a/npm/src/session.ts +++ b/npm/src/session.ts @@ -1064,7 +1064,7 @@ export class DocxSession { options: ContentControlFillOptions = {}): EditResult { const timestamp = value instanceof Date ? value.toISOString() : value; return JSON.parse(this.wasm.SetContentControlDate( - this.handle, anchorId, timestamp, displayText ?? "", JSON.stringify(options))) as EditResult; + this.handle, anchorId, timestamp, displayText ?? null, JSON.stringify(options))) as EditResult; } selectContentControlItem(anchorId: string, value: string, diff --git a/npm/src/types.ts b/npm/src/types.ts index cafa7b40..4b42efca 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1210,7 +1210,7 @@ export interface DocxodusWasmExports { FillContentControlText: (handle: number, anchorId: string, text: string, optionsJson: string) => string; FillContentControlRichText: (handle: number, anchorId: string, markdown: string, optionsJson: string) => string; SetContentControlChecked: (handle: number, anchorId: string, isChecked: boolean, optionsJson: string) => string; - SetContentControlDate: (handle: number, anchorId: string, value: string, displayText: string, optionsJson: string) => string; + SetContentControlDate: (handle: number, anchorId: string, value: string, displayText: string | null, optionsJson: string) => string; SelectContentControlItem: (handle: number, anchorId: string, value: string, optionsJson: string) => string; FillContentControlPicture: (handle: number, anchorId: string, imageBase64: string, optionsJson: string) => string; AddRepeatingSectionItem: (handle: number, sectionAnchorId: string, afterItemAnchorId: string, optionsJson: string) => string; diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs index 4e87ff26..ec7ba900 100644 --- a/tools/mcp-server/Dispatcher.cs +++ b/tools/mcp-server/Dispatcher.cs @@ -650,14 +650,14 @@ private static string RunContentControlsAction( "set_checked" => DocxSessionOps.SetContentControlChecked(session.Handle, Str(args, "anchorId"), RequiredBool(args, "checked"), BuildContentControlOptionsJson(args)), "set_date" => DocxSessionOps.SetContentControlDate(session.Handle, - Str(args, "anchorId"), Str(args, "value"), OptStr(args, "displayText"), + Str(args, "anchorId"), Str(args, "value"), OptionalStringValue(args, "displayText"), BuildContentControlOptionsJson(args)), "select_item" => DocxSessionOps.SelectContentControlItem(session.Handle, Str(args, "anchorId"), Str(args, "value"), BuildContentControlOptionsJson(args)), "fill_picture" => DocxSessionOps.FillContentControlPicture(session.Handle, Str(args, "anchorId"), Str(args, "imageBase64"), BuildContentControlOptionsJson(args)), "add_repeating_item" => DocxSessionOps.AddRepeatingSectionItem(session.Handle, - Str(args, "sectionAnchorId"), OptStr(args, "afterItemAnchorId"), + Str(args, "sectionAnchorId"), OptionalStringValue(args, "afterItemAnchorId"), BuildContentControlOptionsJson(args)), "remove_repeating_item" => DocxSessionOps.RemoveRepeatingSectionItem(session.Handle, Str(args, "itemAnchorId")), @@ -666,7 +666,7 @@ private static string RunContentControlsAction( private static string BuildContentControlOptionsJson(JsonElement args) { - var policy = OptStr(args, "bindingPolicy"); + var policy = OptionalStringValue(args, "bindingPolicy"); return policy is null ? "{}" : JsonSerializer.Serialize(new { bindingPolicy = policy }); } diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 5deafbec..4d4138c6 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -454,7 +454,7 @@ internal static class ToolCatalog """), new ToolDefinition( "docxodus_content_controls", - "Inspect and fill native Word content controls (structured-document tags) while preserving their wrappers and metadata. Bound controls fail closed unless bindingPolicy is detach_target, which removes only the selected control's own binding. Whole-control fills are refused in render_inline tracked-change mode.", + "Inspect and fill native Word content controls (structured-document tags) while preserving their wrappers and metadata. Bound controls fail closed unless bindingPolicy is detach_target, which removes only the selected control's own binding. Whole-control fills are refused for row/cell placements, nested targets, and render_inline tracked-change mode.", """ { "type": "object", @@ -472,7 +472,7 @@ internal static class ToolCatalog "sectionAnchorId": { "type": "string", "description": "add_repeating_item section control." }, "afterItemAnchorId": { "type": "string", "description": "Optional direct item after which the clone is inserted." }, "itemAnchorId": { "type": "string", "description": "remove_repeating_item direct item." }, - "bindingPolicy": { "type": "string", "enum": ["preserve", "detach_target"], "description": "Default preserve. detach_target removes only the selected target's own w:dataBinding; a bound ancestor still fails closed." } + "bindingPolicy": { "type": "string", "enum": ["preserve", "detach_target"], "description": "Default preserve. detach_target removes only the selected target's own native w:dataBinding or w15:dataBinding element; a bound ancestor still fails closed." } }, "required": ["sessionId", "action"] } diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs index 58718b1e..59a19d60 100644 --- a/wasm/DocxodusWasm/DocxSessionBridge.cs +++ b/wasm/DocxodusWasm/DocxSessionBridge.cs @@ -616,9 +616,8 @@ public static string SetContentControlChecked(int h, string anchorId, bool isChe [JSExport] public static string SetContentControlDate(int h, string anchorId, string value, - string displayText, string optionsJson) => - DocxSessionOps.SetContentControlDate(h, anchorId, value, - string.IsNullOrEmpty(displayText) ? null : displayText, optionsJson); + string? displayText, string optionsJson) => + DocxSessionOps.SetContentControlDate(h, anchorId, value, displayText, optionsJson); [JSExport] public static string SelectContentControlItem(int h, string anchorId, string value, From 539f355e1a6b13bb7746eea2d18adad9da5c1b95 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 05:57:05 -0500 Subject: [PATCH 3/5] Harden content control invariants and receipts --- .../DocxSessionContentControlTests.cs | 337 ++++++++++++++++++ ...DocxSessionTrackedStructuredDeleteTests.cs | 18 +- Docxodus.Tests/McpServerDispatcherTests.cs | 90 +++++ Docxodus/DocxSession.ContentControls.cs | 175 ++++++--- Docxodus/DocxSession.cs | 4 +- Docxodus/Internal/ContentControlIdentity.cs | 12 +- docs/architecture/docx_mutation_api.md | 14 +- docs/architecture/markdown_projection.md | 2 +- docs/architecture/native_content_controls.md | 49 ++- python/src/docx_scalpel/types.py | 2 +- python/tests/test_content_control_types.py | 42 +++ tools/mcp-server/ToolCatalog.cs | 3 +- 12 files changed, 682 insertions(+), 66 deletions(-) create mode 100644 python/tests/test_content_control_types.py diff --git a/Docxodus.Tests/DocxSessionContentControlTests.cs b/Docxodus.Tests/DocxSessionContentControlTests.cs index 7e66b822..c52047bd 100644 --- a/Docxodus.Tests/DocxSessionContentControlTests.cs +++ b/Docxodus.Tests/DocxSessionContentControlTests.cs @@ -665,6 +665,343 @@ static XElement AddSecondItem(WordprocessingDocument document) } } + [Fact] + public void CC020_ListSelection_PersistsNativeLastValue_AndComboAcceptsCustomText() + { + using var session = new DocxSession(BuildFixture()); + var controls = session.ListContentControls(); + var dropDown = controls.Single(control => control.NativeId == "104"); + var combo = controls.Single(control => control.NativeId == "105"); + + Assert.True(session.SelectContentControlItem(dropDown.AnchorId, "b").Success); + Assert.True(session.SelectContentControlItem(combo.AnchorId, "custom value").Success); + using (var document = WordprocessingDocument.Open(new MemoryStream(session.Save()), false)) + { + var dropDownProperties = ControlByNativeId(document, "104").Element(W + "sdtPr")! + .Element(W + "dropDownList")!; + var comboProperties = ControlByNativeId(document, "105").Element(W + "sdtPr")! + .Element(W + "comboBox")!; + Assert.Equal("b", (string?)dropDownProperties.Attribute(W + "lastValue")); + Assert.Equal("custom value", (string?)comboProperties.Attribute(W + "lastValue")); + Assert.Equal("Beta", ControlByNativeId(document, "104").Element(W + "sdtContent")!.Value); + Assert.Equal("custom value", ControlByNativeId(document, "105").Element(W + "sdtContent")!.Value); + } + + using var matched = new DocxSession(BuildFixture()); + var matchedCombo = matched.ListContentControls().Single(control => control.NativeId == "105"); + Assert.True(matched.SelectContentControlItem(matchedCombo.AnchorId, "Alpha").Success); + using var matchedDocument = WordprocessingDocument.Open(new MemoryStream(matched.Save()), false); + Assert.Equal("a", (string?)ControlByNativeId(matchedDocument, "105").Element(W + "sdtPr")! + .Element(W + "comboBox")!.Attribute(W + "lastValue")); + } + + [Fact] + public void CC021_ExactSdtCardinalityAndFamilyExclusivity_FailBeforeHistory() + { + var malformedFixtures = new (Action Arrange, string Diagnostic)[] + { + (control => control.Element(W + "sdtPr")!.AddAfterSelf( + new XElement(control.Element(W + "sdtPr")!)), "exactly one w:sdtPr"), + (control => control.Element(W + "sdtContent")!.AddAfterSelf( + new XElement(control.Element(W + "sdtContent")!)), "exactly one w:sdtContent"), + (control => control.Element(W + "sdtPr")!.Element(W + "id")!.AddAfterSelf( + new XElement(W + "id", new XAttribute(W + "val", "901"))), "exactly one w:id"), + (control => control.Element(W + "sdtPr")!.Add( + new XElement(W + "date")), "mutually exclusive"), + }; + + foreach (var (arrange, diagnostic) in malformedFixtures) + { + var fixture = Transform(BuildFixture(), document => + arrange(ControlByNativeId(document, "101"))); + using var malformedSession = new DocxSession(fixture); + var target = malformedSession.ListContentControls().Single(control => control.Text == "inner"); + Assert.False(target.CanMutate); + Assert.Contains(diagnostic, target.UnsupportedReason, StringComparison.Ordinal); + var result = malformedSession.FillContentControlText(target.AnchorId, "must not apply"); + Assert.Equal(EditErrorCode.ContentControlMalformed, result.Error?.Code); + Assert.Equal(0, malformedSession.UndoCount); + Assert.Equal("inner", malformedSession.GetContentControl(target.AnchorId)?.Text); + } + + var unsafeClone = Transform(BuildFixture(), document => + { + var properties = ControlByNativeId(document, "109").Element(W + "sdtPr")!; + properties.Element(W + "id")!.AddAfterSelf( + new XElement(W + "id", new XAttribute(W + "val", "902"))); + }); + using var cloneSession = new DocxSession(unsafeClone); + var section = cloneSession.ListContentControls().Single(control => control.NativeId == "108"); + Assert.False(section.CanMutate); + Assert.Contains("malformed content control", section.UnsupportedReason, StringComparison.Ordinal); + var clone = cloneSession.AddRepeatingSectionItem(section.AnchorId); + Assert.Equal(EditErrorCode.RepeatingSectionConstraint, clone.Error?.Code); + Assert.Equal(0, cloneSession.UndoCount); + } + + [Fact] + public void CC022_RepeatingCanMutate_ExplainsSoleOrphanLockedAndUnsafeCases() + { + using (var soleSession = new DocxSession(BuildFixture())) + { + var controls = soleSession.ListContentControls(); + Assert.True(controls.Single(control => control.NativeId == "108").CanMutate); + var sole = controls.Single(control => control.NativeId == "109"); + Assert.False(sole.CanMutate); + Assert.Contains("retain at least one", sole.UnsupportedReason, StringComparison.Ordinal); + Assert.Equal(EditErrorCode.RepeatingSectionConstraint, + soleSession.RemoveRepeatingSectionItem(sole.AnchorId).Error?.Code); + } + + using (var multiSession = new DocxSession(BuildFixture())) + { + var section = multiSession.ListContentControls().Single(control => control.NativeId == "108"); + Assert.True(multiSession.AddRepeatingSectionItem(section.AnchorId).Success); + Assert.All(multiSession.ListContentControls().Where(control => + control.Type == ContentControlType.RepeatingSectionItem), control => + Assert.True(control.CanMutate, control.UnsupportedReason)); + } + + var lockedFixture = Transform(BuildFixture(), document => + { + var first = ControlByNativeId(document, "109"); + var locked = new XElement(first); + locked.Element(W + "sdtPr")!.Element(W + "id")! + .SetAttributeValue(W + "val", "209"); + locked.Element(W + "sdtPr")!.Add( + new XElement(W + "lock", new XAttribute(W + "val", "sdtLocked"))); + first.AddAfterSelf(locked); + }); + using (var lockedSession = new DocxSession(lockedFixture)) + { + var locked = lockedSession.ListContentControls().Single(control => control.NativeId == "209"); + Assert.False(locked.CanMutate); + Assert.Contains("wrapper is locked", locked.UnsupportedReason, StringComparison.Ordinal); + Assert.Equal(EditErrorCode.ContentControlLocked, + lockedSession.RemoveRepeatingSectionItem(locked.AnchorId).Error?.Code); + } + + var orphanFixture = Transform(BuildFixture(), document => + { + var item = ControlByNativeId(document, "109"); + item.Remove(); + document.MainDocumentPart!.GetXDocument().Root!.Element(W + "body")!.Add(item); + }); + using (var orphanSession = new DocxSession(orphanFixture)) + { + var orphan = orphanSession.ListContentControls().Single(control => control.NativeId == "109"); + Assert.False(orphan.CanMutate); + Assert.Contains("not a direct child", orphan.UnsupportedReason, StringComparison.Ordinal); + } + + var nonDirectFixture = Transform(BuildFixture(), document => + { + var item = ControlByNativeId(document, "109"); + item.ReplaceWith(new XElement(W + "customXml", + new XAttribute(W + "element", "wrapper"), new XElement(item))); + }); + using (var nonDirectSession = new DocxSession(nonDirectFixture)) + { + var nonDirect = nonDirectSession.ListContentControls() + .Single(control => control.NativeId == "109"); + Assert.False(nonDirect.CanMutate); + Assert.Contains("not a direct child", nonDirect.UnsupportedReason, StringComparison.Ordinal); + } + + var unsafeFixture = Transform(BuildFixture(), document => + { + var content = ControlByNativeId(document, "109").Element(W + "sdtContent")!; + content.AddFirst(new XElement(W + "bookmarkStart", + new XAttribute(W + "id", "71"), new XAttribute(W + "name", "clone-sensitive"))); + }); + using var unsafeSession = new DocxSession(unsafeFixture); + var unsafeSection = unsafeSession.ListContentControls() + .Single(control => control.NativeId == "108"); + Assert.False(unsafeSection.CanMutate); + Assert.Contains("clone-sensitive markup", unsafeSection.UnsupportedReason, StringComparison.Ordinal); + } + + [Fact] + public void CC023_CheckboxStateFont_IsAppliedToTheProducedGlyphRun() + { + var fixture = Transform(BuildFixture(), document => + ControlByNativeId(document, "102").Descendants(W14 + "checkedState").Single() + .SetAttributeValue(W14 + "font", "Wingdings")); + using var session = new DocxSession(fixture); + var checkbox = session.ListContentControls().Single(control => control.NativeId == "102"); + Assert.True(session.SetContentControlChecked(checkbox.AnchorId, true).Success); + + using var document = WordprocessingDocument.Open(new MemoryStream(session.Save()), false); + var fonts = ControlByNativeId(document, "102").Descendants(W + "rFonts").Single(); + Assert.Equal("Wingdings", (string?)fonts.Attribute(W + "ascii")); + Assert.Equal("Wingdings", (string?)fonts.Attribute(W + "hAnsi")); + Assert.Equal("Wingdings", (string?)fonts.Attribute(W + "eastAsia")); + Assert.Equal("Wingdings", (string?)fonts.Attribute(W + "cs")); + } + + [Fact] + public void CC024_PlaceholderAndBoundAncestorEdges_FailClosedOrPreserveMetadata() + { + var placeholderFixture = Transform(BuildFixture(), document => + { + var properties = ControlByNativeId(document, "101").Element(W + "sdtPr")!; + properties.Element(W + "id")!.AddAfterSelf( + new XElement(W + "placeholder", new XElement(W + "docPart", + new XAttribute(W + "val", "DefaultPlaceholder_22675703"))), + new XElement(W + "showingPlcHdr")); + }); + using (var placeholderSession = new DocxSession(placeholderFixture)) + { + var target = placeholderSession.ListContentControls() + .Single(control => control.NativeId == "101"); + Assert.True(target.IsShowingPlaceholder); + Assert.True(placeholderSession.FillContentControlText(target.AnchorId, "real value").Success); + Assert.False(placeholderSession.GetContentControl(target.AnchorId)!.IsShowingPlaceholder); + using (var saved = WordprocessingDocument.Open( + new MemoryStream(placeholderSession.Save()), false)) + Assert.NotNull(ControlByNativeId(saved, "101").Element(W + "sdtPr")! + .Element(W + "placeholder")); + Assert.True(placeholderSession.Undo()); + Assert.True(placeholderSession.GetContentControl(target.AnchorId)!.IsShowingPlaceholder); + } + + var ancestorBoundFixture = Transform(BuildFixture(), document => + { + XElement Binding(string path) => new(W + "dataBinding", + new XAttribute(W + "storeItemID", "{11111111-1111-1111-1111-111111111111}"), + new XAttribute(W + "xpath", path), + new XAttribute(W + "prefixMappings", "xmlns:x='urn:test'")); + ControlByNativeId(document, "100").Element(W + "sdtPr")!.Add(Binding("/root/outer")); + ControlByNativeId(document, "101").Element(W + "sdtPr")!.Add(Binding("/root/inner")); + }); + using var boundSession = new DocxSession(ancestorBoundFixture); + var boundTarget = boundSession.ListContentControls().Single(control => control.NativeId == "101"); + var refused = boundSession.FillContentControlText(boundTarget.AnchorId, "must not detach", + new ContentControlFillOptions { BindingPolicy = ContentControlBindingPolicy.DetachTarget }); + Assert.Equal(EditErrorCode.ContentControlBound, refused.Error?.Code); + Assert.Equal(0, boundSession.UndoCount); + using var boundDocument = WordprocessingDocument.Open( + new MemoryStream(boundSession.Save()), false); + Assert.NotNull(ControlByNativeId(boundDocument, "100").Element(W + "sdtPr")! + .Element(W + "dataBinding")); + Assert.NotNull(ControlByNativeId(boundDocument, "101").Element(W + "sdtPr")! + .Element(W + "dataBinding")); + } + + [Fact] + public void CC025_PictureFill_RejectsZeroMultipleAndLinkedCandidatesBeforeHistory() + { + var fixtures = new (byte[] Bytes, EditErrorCode Error)[] + { + (Transform(BuildPictureFixture(), document => + ControlByNativeId(document, "113").Descendants() + .Where(element => element.Name.LocalName == "drawing").Remove()), + EditErrorCode.ContentControlMalformed), + (Transform(BuildPictureFixture(), document => + { + var run = ControlByNativeId(document, "113").Descendants(W + "r") + .First(element => element.Descendants().Any(value => value.Name.LocalName == "drawing")); + run.AddAfterSelf(new XElement(run)); + }), EditErrorCode.ContentControlMalformed), + (Transform(BuildPictureFixture(), document => + { + var blip = ControlByNativeId(document, "113").Descendants() + .Single(element => element.Name.LocalName == "blip"); + var relationshipId = (string?)blip.Attribute(R + "embed"); + blip.Attribute(R + "embed")!.Remove(); + blip.SetAttributeValue(R + "link", relationshipId); + }), EditErrorCode.LinkedImageReadOnly), + }; + + foreach (var (bytes, expected) in fixtures) + { + using var pictureSession = new DocxSession(bytes); + var picture = pictureSession.ListContentControls() + .Single(control => control.NativeId == "113"); + var result = pictureSession.FillContentControlPicture(picture.AnchorId, Png(4, 5)); + Assert.Equal(expected, result.Error?.Code); + Assert.Equal(0, pictureSession.UndoCount); + } + } + + [Fact] + public void CC026_FillAddRemoveReceipts_UseStableSdtIdentityEnvelopes() + { + using var session = new DocxSession(BuildFixture()); + var controls = session.ListContentControls(); + var plain = controls.Single(control => control.NativeId == "101"); + var section = controls.Single(control => control.NativeId == "108"); + + var fill = session.FillContentControlText(plain.AnchorId, "receipt value"); + Assert.True(fill.Success, fill.Error?.Message); + Assert.Empty(fill.Created); + Assert.Empty(fill.Removed); + Assert.Equal(plain.AnchorId, Assert.Single(fill.Modified).Id); + + var add = session.AddRepeatingSectionItem(section.AnchorId); + Assert.True(add.Success, add.Error?.Message); + var created = Assert.Single(add.Created); + Assert.Equal("sdt", created.Kind); + Assert.Equal(section.AnchorId, Assert.Single(add.Modified).Id); + Assert.NotNull(session.GetContentControl(created.Id)); + + var remove = session.RemoveRepeatingSectionItem(created.Id); + Assert.True(remove.Success, remove.Error?.Message); + Assert.Equal(created.Id, Assert.Single(remove.Removed).Id); + Assert.Equal(section.AnchorId, Assert.Single(remove.Modified).Id); + Assert.Null(session.GetContentControl(created.Id)); + } + + [Fact] + public void CC027_WordAuthoredFixture_RoundTripsNativeMetadataAndState() + { + var fixturePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, + "../../../../TestFiles/HC030-Content-Controls.docx")); + using var session = new DocxSession(File.ReadAllBytes(fixturePath)); + var controls = session.ListContentControls(); + Assert.Equal(5, controls.Count); + Assert.Equal(new[] + { + ContentControlType.RichText, + ContentControlType.PlainText, + ContentControlType.Picture, + ContentControlType.Checkbox, + ContentControlType.ComboBox, + }, controls.Select(control => control.Type)); + Assert.All(controls, control => Assert.True(control.CanMutate, control.UnsupportedReason)); + + var plain = controls.Single(control => control.Type == ContentControlType.PlainText); + var checkbox = controls.Single(control => control.Type == ContentControlType.Checkbox); + var combo = controls.Single(control => control.Type == ContentControlType.ComboBox); + var picture = controls.Single(control => control.Type == ContentControlType.Picture); + Assert.True(session.FillContentControlText(plain.AnchorId, "authored fixture value").Success); + Assert.True(session.SetContentControlChecked(checkbox.AnchorId, true).Success); + Assert.True(session.SelectContentControlItem(combo.AnchorId, "custom authored value").Success); + Assert.True(session.FillContentControlPicture(picture.AnchorId, Png(7, 8)).Success); + + var saved = session.Save(); + using var reopened = new DocxSession(saved); + Assert.Equal("authored fixture value", reopened.GetContentControl(plain.AnchorId)?.Text); + Assert.Equal("☒", reopened.GetContentControl(checkbox.AnchorId)?.Text); + Assert.Equal("custom authored value", reopened.GetContentControl(combo.AnchorId)?.Text); + using var document = WordprocessingDocument.Open(new MemoryStream(saved), false); + var plainProperties = ControlByNativeId(document, plain.NativeId!).Element(W + "sdtPr")!; + Assert.NotNull(plainProperties.Element(W + "placeholder")); + var comboProperties = ControlByNativeId(document, combo.NativeId!).Element(W + "sdtPr")! + .Element(W + "comboBox")!; + Assert.Equal("custom authored value", (string?)comboProperties.Attribute(W + "lastValue")); + var checkboxFonts = ControlByNativeId(document, checkbox.NativeId!) + .Descendants(W + "rFonts").Single(); + Assert.Equal("MS Gothic", (string?)checkboxFonts.Attribute(W + "ascii")); + Assert.Equal("MS Gothic", (string?)checkboxFonts.Attribute(W + "hAnsi")); + Assert.Single(document.MainDocumentPart!.ImageParts); + var validationErrors = new OpenXmlValidator(FileFormatVersions.Office2013).Validate(document) + .Where(IsMaterialValidationError).ToList(); + Assert.True(validationErrors.Count == 0, string.Join(Environment.NewLine, + validationErrors.Select(validation => + $"{validation.Description} Node: {validation.Node?.OuterXml}"))); + } + private static string[] ParagraphAnchors(DocxSession session) => session.Project().AnchorIndex.Values .Where(value => value.Anchor.Kind is "p" or "h" or "li") .Select(value => value.Anchor.Id).Distinct().ToArray(); diff --git a/Docxodus.Tests/DocxSessionTrackedStructuredDeleteTests.cs b/Docxodus.Tests/DocxSessionTrackedStructuredDeleteTests.cs index 995a5549..46ce2d0e 100644 --- a/Docxodus.Tests/DocxSessionTrackedStructuredDeleteTests.cs +++ b/Docxodus.Tests/DocxSessionTrackedStructuredDeleteTests.cs @@ -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()); + AssertAnchorAccounting(result, new[] { from, controlAnchor, controlled }, Array.Empty()); var tracked = session.Save(); var body = Body(tracked); @@ -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()); var tracked = session.Save(); @@ -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(); @@ -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)); @@ -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) => diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs index 03fcf406..2deb2c28 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -2297,6 +2297,7 @@ public void MCP146_ContentControls_ListFillDetachAndBatchPreview_AreFirstClass() var tool = Assert.Single(ToolCatalog.Tools, definition => definition.Name == "docxodus_content_controls"); using var schema = JsonDocument.Parse(tool.InputSchemaJson); + Assert.True(schema.RootElement.GetProperty("properties").TryGetProperty("preconditions", out _)); Assert.Contains("detach_target", schema.RootElement.GetProperty("properties") .GetProperty("bindingPolicy").GetProperty("enum").EnumerateArray() .Select(value => value.GetString())); @@ -2411,4 +2412,93 @@ string Preview(params object[] steps) => Dispatcher.Call(_store, "docxodus_mutat control.TryGetProperty("nativeId", out var id) && id.GetString() == "101"); Assert.Equal("inner", undone.GetProperty("text").GetString()); } + + [Fact] + public void MCP148_ContentControlReceiptsAndBestEffortBatch_PreserveSdtIdentities() + { + File.WriteAllBytes(_tempPath, DocxSessionContentControlTests.BuildFixture()); + var sessionId = OpenSession(); + var listed = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + JsonSerializer.Serialize(new { sessionId, action = "list" })))) + .GetProperty("contentControls").EnumerateArray().ToArray(); + string Anchor(string nativeId) => listed.Single(control => + control.TryGetProperty("nativeId", out var id) && id.GetString() == nativeId) + .GetProperty("anchorId").GetString()!; + var plainAnchor = Anchor("101"); + var boundAnchor = Anchor("106"); + var sectionAnchor = Anchor("108"); + + var fill = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + JsonSerializer.Serialize(new + { + sessionId, + action = "fill_text", + anchorId = plainAnchor, + text = "receipt", + })))); + Assert.Equal(plainAnchor, Assert.Single(fill.GetProperty("modified").EnumerateArray()) + .GetProperty("id").GetString()); + Assert.Empty(fill.GetProperty("created").EnumerateArray()); + Assert.Empty(fill.GetProperty("removed").EnumerateArray()); + + var added = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + JsonSerializer.Serialize(new + { + sessionId, + action = "add_repeating_item", + sectionAnchorId = sectionAnchor, + })))); + var createdAnchor = Assert.Single(added.GetProperty("created").EnumerateArray()) + .GetProperty("id").GetString()!; + Assert.StartsWith("sdt:body:", createdAnchor, StringComparison.Ordinal); + Assert.Equal(sectionAnchor, Assert.Single(added.GetProperty("modified").EnumerateArray()) + .GetProperty("id").GetString()); + + var removed = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + JsonSerializer.Serialize(new + { + sessionId, + action = "remove_repeating_item", + itemAnchorId = createdAnchor, + })))); + Assert.Equal(createdAnchor, Assert.Single(removed.GetProperty("removed").EnumerateArray()) + .GetProperty("id").GetString()); + Assert.Equal(sectionAnchor, Assert.Single(removed.GetProperty("modified").EnumerateArray()) + .GetProperty("id").GetString()); + + var batch = Parse(Dispatcher.Call(_store, "docxodus_mutations", J( + JsonSerializer.Serialize(new + { + sessionId, + mode = "best_effort", + steps = new object[] + { + new { tool = "docxodus_content_controls", args = new + { action = "fill_text", anchorId = plainAnchor, text = "best effort" } }, + new { tool = "docxodus_content_controls", args = new + { action = "fill_text", anchorId = boundAnchor, text = "refused" } }, + new { tool = "docxodus_content_controls", args = new + { action = "add_repeating_item", sectionAnchorId = sectionAnchor } }, + }, + })))); + Assert.Equal("partial", batch.GetProperty("status").GetString()); + Assert.Equal(2, batch.GetProperty("editsApplied").GetInt32()); + var steps = batch.GetProperty("steps").EnumerateArray().ToArray(); + Assert.True(steps[0].GetProperty("success").GetBoolean()); + Assert.False(steps[1].GetProperty("success").GetBoolean()); + Assert.True(steps[2].GetProperty("success").GetBoolean()); + Assert.Equal(plainAnchor, Assert.Single(steps[0].GetProperty("results")[0] + .GetProperty("modified").EnumerateArray()).GetProperty("id").GetString()); + Assert.Equal(sectionAnchor, Assert.Single(steps[2].GetProperty("results")[0] + .GetProperty("modified").EnumerateArray()).GetProperty("id").GetString()); + + var after = Parse(Dispatcher.Call(_store, "docxodus_content_controls", J( + JsonSerializer.Serialize(new { sessionId, action = "list" })))) + .GetProperty("contentControls").EnumerateArray().ToArray(); + Assert.Equal("best effort", after.Single(control => + control.TryGetProperty("nativeId", out var id) && id.GetString() == "101") + .GetProperty("text").GetString()); + Assert.Equal(2, after.Count(control => + control.GetProperty("type").GetString() == "repeating_section_item")); + } } diff --git a/Docxodus/DocxSession.ContentControls.cs b/Docxodus/DocxSession.ContentControls.cs index 6898c9a6..6c0b97d8 100644 --- a/Docxodus/DocxSession.ContentControls.cs +++ b/Docxodus/DocxSession.ContentControls.cs @@ -82,11 +82,34 @@ public sealed partial class DocxSession private static readonly XNamespace ContentControlW15 = "http://schemas.microsoft.com/office/word/2012/wordml"; + private static readonly IReadOnlyDictionary + ContentControlFamilies = new Dictionary + { + [ContentControlW14 + "checkbox"] = ContentControlType.Checkbox, + [ContentControlW15 + "repeatingSection"] = ContentControlType.RepeatingSection, + [ContentControlW15 + "repeatingSectionItem"] = ContentControlType.RepeatingSectionItem, + [W.picture] = ContentControlType.Picture, + [W.date] = ContentControlType.Date, + [W.dropDownList] = ContentControlType.DropDownList, + [W.comboBox] = ContentControlType.ComboBox, + [W.text] = ContentControlType.PlainText, + [ContentControlW + "richText"] = ContentControlType.RichText, + }; + + private static readonly HashSet ContentControlMetadata = new() + { + W.id, W.tag, W.alias, W.dataBinding, ContentControlW15 + "dataBinding", + W.showingPlcHdr, ContentControlW + "lock", ContentControlW + "placeholder", + ContentControlW + "temporary", ContentControlW15 + "appearance", + ContentControlW15 + "color", W.rPr, + }; + private sealed record ContentControlCandidate( OwnedPartRelationships.Owner Owner, XElement Element, ContentControlIdentity.Entry Identity, - ContentControlInfo Info); + ContentControlInfo Info, + string? MalformedReason); public IReadOnlyList ListContentControls( ProjectionScopes scopes = ProjectionScopes.All) @@ -131,6 +154,7 @@ public EditResult SetContentControlChecked(string anchorId, bool isChecked, var fallback = isChecked ? 0x2612 : 0x2610; var glyph = TryParseHexScalar((string?)stateElement?.Attribute(ContentControlW14 + "val"), out var scalar) ? char.ConvertFromUtf32(scalar) : char.ConvertFromUtf32(fallback); + var stateFont = (string?)stateElement?.Attribute(ContentControlW14 + "font"); if (ValidateWholeContentReplacement(candidate, replacement: null, anchorId) is { } replacementError) return replacementError; @@ -142,7 +166,7 @@ public EditResult SetContentControlChecked(string anchorId, bool isChecked, checkbox.AddFirst(checkedElement); } checkedElement.SetAttributeValue(ContentControlW14 + "val", isChecked ? "1" : "0"); - ReplaceControlWithPlainText(candidate.Element, glyph); + ReplaceControlWithPlainText(candidate.Element, glyph, stateFont); }); } @@ -178,20 +202,29 @@ public EditResult SelectContentControlItem(string anchorId, string value, return NestedFillError(anchorId); var props = candidate.Element.Element(W.sdtPr)!; var list = props.Element(W.dropDownList) ?? props.Element(W.comboBox)!; + var isComboBox = list.Name == W.comboBox; var matches = list.Elements(W.listItem).Where(item => string.Equals((string?)item.Attribute(ContentControlW + "value"), value, StringComparison.Ordinal) || string.Equals((string?)item.Attribute(W.displayText), value, StringComparison.Ordinal)).ToList(); - if (matches.Count != 1) + if (matches.Count > 1 || matches.Count == 0 && !isComboBox) return EditResult.Fail(EditErrorCode.InvalidContentControlValue, matches.Count == 0 ? $"content control has no list item matching '{value}'" : $"content control has multiple list items matching '{value}'", anchorId); - var display = (string?)matches[0].Attribute(W.displayText) - ?? (string?)matches[0].Attribute(ContentControlW + "value") ?? string.Empty; + var selectedValue = matches.Count == 1 + ? (string?)matches[0].Attribute(ContentControlW + "value") + ?? (string?)matches[0].Attribute(W.displayText) ?? string.Empty + : value; + var display = matches.Count == 1 + ? (string?)matches[0].Attribute(W.displayText) ?? selectedValue + : value; if (ValidateWholeContentReplacement(candidate, replacement: null, anchorId) is { } replacementError) return replacementError; - return MutateContentControl(candidate, options, - () => ReplaceControlWithPlainText(candidate.Element, display)); + return MutateContentControl(candidate, options, () => + { + list.SetAttributeValue(W.lastValue, selectedValue); + ReplaceControlWithPlainText(candidate.Element, display); + }); } public EditResult FillContentControlPicture(string anchorId, byte[] imageBytes, @@ -414,10 +447,11 @@ private bool ResolveContentControlForMutation(string anchorId, $"content control not found: {anchorId}", anchorId); return false; } - if (!candidate.Identity.HasMutableIdentity) + if (candidate.MalformedReason is not null || !candidate.Identity.HasMutableIdentity) { error = EditResult.Fail(EditErrorCode.ContentControlMalformed, - candidate.Info.UnsupportedReason ?? "content control has no unique valid native w:id", anchorId); + candidate.MalformedReason ?? candidate.Info.UnsupportedReason + ?? "content control has no unique valid native w:id", anchorId); return false; } if (candidate.Info.Type == ContentControlType.Unsupported) @@ -526,7 +560,8 @@ private static void DetachTargetBindingIfRequested(XElement control, binding.Remove(); } - private static void ReplaceControlWithPlainText(XElement control, string text) + private static void ReplaceControlWithPlainText(XElement control, string text, + string? stateFont = null) { var content = control.Element(W.sdtContent) ?? throw new InvalidOperationException("content control has no w:sdtContent"); @@ -536,6 +571,25 @@ private static void ReplaceControlWithPlainText(XElement control, string text) var run = new XElement(W.r, oldRunProperties is null ? null : new XElement(oldRunProperties), new XElement(W.t, new XAttribute(XNamespace.Xml + "space", "preserve"), text)); + if (!string.IsNullOrWhiteSpace(stateFont)) + { + var runProperties = run.Element(W.rPr); + if (runProperties is null) + { + runProperties = new XElement(W.rPr); + run.AddFirst(runProperties); + } + var fonts = runProperties.Element(W.rFonts); + if (fonts is null) + { + fonts = new XElement(W.rFonts); + runProperties.AddFirst(fonts); + } + fonts.SetAttributeValue(W.ascii, stateFont); + fonts.SetAttributeValue(W.hAnsi, stateFont); + fonts.SetAttributeValue(W.eastAsia, stateFont); + fonts.SetAttributeValue(W.cs, stateFont); + } if (placement == ContentControlPlacement.Inline) { content.ReplaceNodes(run); @@ -601,6 +655,7 @@ private IReadOnlyList BuildContentControlRegistry(Proje foreach (var identity in identities) { var element = identity.Element; + var malformed = ValidateContentControlStructure(element); var props = element.Element(W.sdtPr); var type = ClassifyContentControl(props); var placement = DetectContentControlPlacement(element); @@ -608,7 +663,8 @@ private IReadOnlyList BuildContentControlRegistry(Proje var parent = element.Ancestors(W.sdt).FirstOrDefault(); var lockToken = (string?)props?.Element(ContentControlW + "lock")?.Attribute(W.val); string? unsupported = null; - if (!identity.HasValidNativeId) unsupported = "missing or invalid native w:sdtPr/w:id"; + if (malformed is not null) unsupported = malformed; + else if (!identity.HasValidNativeId) unsupported = "missing or invalid native w:sdtPr/w:id"; else if (identity.IsDuplicateNativeId) unsupported = "duplicate native w:sdtPr/w:id in package"; else if (placement == ContentControlPlacement.Unknown) unsupported = "unsupported or malformed OOXML placement"; else if (type == ContentControlType.Unsupported) unsupported = "unsupported content-control family"; @@ -619,13 +675,18 @@ private IReadOnlyList BuildContentControlRegistry(Proje bool locked = element.AncestorsAndSelf(W.sdt).Any(control => (string?)control.Element(W.sdtPr)?.Element(ContentControlW + "lock")?.Attribute(W.val) is "contentLocked" or "sdtContentLocked"); + bool wrapperLocked = type == ContentControlType.RepeatingSectionItem + && lockToken is "sdtLocked" or "sdtContentLocked"; bool placementSupported = IsMutationPlacementSupported(type, placement); if (unsupported is null && !placementSupported) unsupported = $"{type} mutation supports only inline and block content controls"; if (unsupported is null && IsWholeControlFillType(type) && ContainsNestedContentControl(element)) unsupported = "whole-control fill is unsupported when the target contains nested controls"; - bool defaultMutable = unsupported is null && !locked && !targetBound && !ancestorBound; + if (unsupported is null) + unsupported = RepeatingMutationConstraint(element, type); + bool defaultMutable = unsupported is null && !locked && !wrapperLocked + && !targetBound && !ancestorBound; var items = props?.Elements().FirstOrDefault(value => value.Name == W.dropDownList || value.Name == W.comboBox) @@ -654,15 +715,18 @@ private IReadOnlyList BuildContentControlRegistry(Proje HasValidNativeId = identity.HasValidNativeId, HasDuplicateNativeId = identity.IsDuplicateNativeId, CanMutate = defaultMutable, - CanDetachTargetBinding = unsupported is null && targetBound && !ancestorBound && !locked, + CanDetachTargetBinding = unsupported is null && targetBound && !ancestorBound + && !locked && !wrapperLocked, UnsupportedReason = unsupported ?? (locked ? "content locked by target or ancestor" + : wrapperLocked ? "content-control wrapper is locked" : ancestorBound ? "inside a data-bound ancestor" : targetBound ? "target is data-bound; explicit detach_target is required" : null), Text = string.Concat(element.Element(W.sdtContent)?.Descendants(W.t) .Select(text => (string)text) ?? Enumerable.Empty()), ItemValues = items, }; - result.Add(new ContentControlCandidate(owner, element, byElement[element], info)); + result.Add(new ContentControlCandidate(owner, element, byElement[element], info, + malformed)); } } return result; @@ -682,25 +746,59 @@ var value when value.StartsWith("ftr", StringComparison.Ordinal) => scopes.HasFl private static ContentControlType ClassifyContentControl(XElement? props) { if (props is null) return ContentControlType.Unsupported; - if (props.Element(ContentControlW14 + "checkbox") is not null) return ContentControlType.Checkbox; - if (props.Element(ContentControlW15 + "repeatingSection") is not null) return ContentControlType.RepeatingSection; - if (props.Element(ContentControlW15 + "repeatingSectionItem") is not null) return ContentControlType.RepeatingSectionItem; - if (props.Element(W.picture) is not null) return ContentControlType.Picture; - if (props.Element(W.date) is not null) return ContentControlType.Date; - if (props.Element(W.dropDownList) is not null) return ContentControlType.DropDownList; - if (props.Element(W.comboBox) is not null) return ContentControlType.ComboBox; - if (props.Element(W.text) is not null) return ContentControlType.PlainText; - if (props.Element(ContentControlW + "richText") is not null) return ContentControlType.RichText; - var knownMetadata = new HashSet { W.id, W.tag, W.alias, W.dataBinding, - ContentControlW15 + "dataBinding", - W.showingPlcHdr, ContentControlW + "lock", ContentControlW + "placeholder", - ContentControlW + "temporary", ContentControlW15 + "appearance", - ContentControlW15 + "color", W.rPr }; - return props.Elements().All(element => knownMetadata.Contains(element.Name)) - ? ContentControlType.RichText + var family = props.Elements().Where(element => + !ContentControlMetadata.Contains(element.Name)).ToList(); + if (family.Count == 0) return ContentControlType.RichText; + return family.Count == 1 && ContentControlFamilies.TryGetValue(family[0].Name, out var type) + ? type : ContentControlType.Unsupported; } + private static string? ValidateContentControlStructure(XElement control) + { + var properties = control.Elements(W.sdtPr).ToList(); + if (properties.Count != 1) + return $"content control must contain exactly one w:sdtPr; found {properties.Count}"; + var contents = control.Elements(W.sdtContent).ToList(); + if (contents.Count != 1) + return $"content control must contain exactly one w:sdtContent; found {contents.Count}"; + var ids = properties[0].Elements(W.id).ToList(); + if (ids.Count != 1) + return $"w:sdtPr must contain exactly one w:id; found {ids.Count}"; + if (!ContentControlIdentity.TryCanonicalizeNativeId( + (string?)ids[0].Attribute(W.val), out _)) + return "w:sdtPr/w:id must have a signed 32-bit integer w:val"; + var family = properties[0].Elements().Where(element => + !ContentControlMetadata.Contains(element.Name)).ToList(); + if (family.Count > 1) + return "w:sdtPr must contain at most one mutually exclusive content-control family marker"; + return null; + } + + private static string? RepeatingMutationConstraint(XElement control, ContentControlType type) + { + if (type == ContentControlType.RepeatingSection) + { + var content = control.Element(W.sdtContent); + var items = content?.Elements(W.sdt).Where(IsRepeatingSectionItem).ToList() + ?? new List(); + if (items.Count == 0 || content!.Elements().Any(element => element.Name != W.sdt + || !IsRepeatingSectionItem(element))) + return "repeating section must contain only one or more direct repeating-section-item controls"; + if (FindUnsafeRepeatingCloneCarrier(items[^1]) is { } unsafeCarrier) + return $"default repeating-item template contains clone-sensitive markup ({unsafeCarrier})"; + return null; + } + if (type != ContentControlType.RepeatingSectionItem) return null; + var outer = control.Parent?.Parent; + if (control.Parent?.Name != W.sdtContent || outer?.Name != W.sdt + || !IsRepeatingSection(outer)) + return "repeating-section item is not a direct child of a repeating section"; + if (control.Parent.Elements(W.sdt).Count(IsRepeatingSectionItem) <= 1) + return "a repeating section must retain at least one item"; + return null; + } + private static ContentControlPlacement DetectContentControlPlacement(XElement control) { var content = control.Element(W.sdtContent); @@ -748,18 +846,8 @@ private void AssignFreshContentControlIds(XElement root) while (used.Contains(next) && next < int.MaxValue) next++; if (used.Contains(next)) throw new InvalidOperationException("no unused content-control id remains"); used.Add(next); - var props = control.Element(W.sdtPr); - if (props is null) - { - props = new XElement(W.sdtPr); - control.AddFirst(props); - } - var id = props.Element(W.id); - if (id is null) - { - id = new XElement(W.id); - props.Add(id); - } + var props = control.Elements(W.sdtPr).Single(); + var id = props.Elements(W.id).Single(); id.SetAttributeValue(W.val, next.ToString(CultureInfo.InvariantCulture)); next++; } @@ -785,6 +873,9 @@ private void AssignFreshDocumentPropertyIds(XElement root) private static string? FindUnsafeRepeatingCloneCarrier(XElement item) { + foreach (var control in item.DescendantsAndSelf(W.sdt)) + if (ValidateContentControlStructure(control) is { } malformed) + return $"malformed content control: {malformed}"; var unsafeNames = new HashSet { W.bookmarkStart, W.bookmarkEnd, W.commentRangeStart, W.commentRangeEnd, diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 9cafae97..b3d3508c 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -6462,8 +6462,8 @@ private EditResult DeleteSiblingRangeCore( // removing it. Anchors stay live in the document tree so callers can // re-address the same blocks before changes are accepted. Ordinary // paragraphs/tables retain DeleteBlock's single top-level-anchor - // contract. Structured wrappers have no anchor of their own, so every - // descendant anchor they keep live is reported as Modified. A remaining + // contract. Structured wrappers and every descendant anchor they keep + // live are reported as Modified. A remaining // structural fall-through is a real removal and is reported as such. var modified = new List(); var trackedRemoved = new List(); diff --git a/Docxodus/Internal/ContentControlIdentity.cs b/Docxodus/Internal/ContentControlIdentity.cs index def6bb30..5f7a324c 100644 --- a/Docxodus/Internal/ContentControlIdentity.cs +++ b/Docxodus/Internal/ContentControlIdentity.cs @@ -66,8 +66,16 @@ internal static IReadOnlyDictionary> AssignStable var parsedByRoot = storyRoots.ToDictionary(root => root, root => root.DescendantsAndSelf(W.sdt).Select((element, ordinal) => { - var raw = (string?)element.Element(W.sdtPr)?.Element(W.id)?.Attribute(W.val); - var valid = TryCanonicalizeNativeId(raw, out var canonical); + var properties = element.Elements(W.sdtPr).ToList(); + var ids = properties.Count == 1 + ? properties[0].Elements(W.id).ToList() + : new List(); + var raw = ids.Count == 0 + ? null + : string.Join("|", ids.Select(id => (string?)id.Attribute(W.val) ?? "")); + string? canonical = null; + var valid = properties.Count == 1 && ids.Count == 1 + && TryCanonicalizeNativeId(raw, out canonical); return (element, ordinal, raw, valid, canonical); }).ToList()); var globalCounts = parsedByRoot.Values.SelectMany(values => values) diff --git a/docs/architecture/docx_mutation_api.md b/docs/architecture/docx_mutation_api.md index 44305588..df148bb5 100644 --- a/docs/architecture/docx_mutation_api.md +++ b/docs/architecture/docx_mutation_api.md @@ -157,7 +157,7 @@ For the full public surface — exact method signatures, settings, value types An anchor id looks like `{#h:body:7b9f61007f9341c8aa5878ee63ffc874}`. The parts: -- `kind` — what kind of OOXML element this is (`p`, `h`, `li`, `tbl`, `tr`, `tc`, `cmt`, `fn`, `en`, `img`, `drw`, `unk`). +- `kind` — what kind of OOXML element this is (`p`, `h`, `li`, `tbl`, `tr`, `tc`, `sdt`, `cmt`, `fn`, `en`, `img`, `drw`, `unk`). - `scope` — which package part it lives in (`body`, `hdr1`/`hdr2`/…, `ftr1`/…, `fn`, `en`, `cmt`). - `unid` — a 32-char hex stable identifier (Docxodus's `PtOpenXml.Unid`). @@ -218,6 +218,9 @@ Each mutation reports which anchors it created, removed, or modified. This table | `UpdateComment(cmt, md)` | the new body paragraph anchors (scope `cmt`) | the old body paragraph anchors | `cmt` | `cmt` | | `SetCommentResolved(cmt, resolved)` | — | — | `cmt` | `cmt` | | `RemoveComment(cmt)` | — | `cmt` + descendant paragraph anchors (the `DeleteBlock(cmt)` shape) | — | nearest stable ancestor | +| content-control fill (`FillContentControlText`, rich text, checkbox, date, list, or picture) | — | — | selected `sdt` | selected `sdt` | +| `AddRepeatingSectionItem(section)` | fresh item `sdt` | — | section `sdt` | section `sdt` | +| `RemoveRepeatingSectionItem(item)` | — | item `sdt` | section `sdt` | section `sdt` | | `Raw.InsertXml(a, pos, xml)` | every block in the new XML | — | — | enclosing parent | | `Raw.ReplaceXml(a, xml)` | unids present in the new XML but not the old (typical for caller-authored XML) | unids present in the old element but not the new (when `a` itself is gone) | unids present in both (typical for the `GetXml → mutate → ReplaceXml` round trip, which preserves Unids) | enclosing parent | | `Undo()` / `Redo()` | (diff vs current) | (diff vs current) | (diff vs current) | `null` — caller re-projects | @@ -325,7 +328,7 @@ What am I editing? │ ├── Inserting/deleting table rows or columns, merging cells, │ embedding a chart, inserting a math equation, -│ adding a content control? +│ creating a new arbitrary content control? │ → Drop to session.Raw.* (v2 ops planned for the common cases) │ └── Anything that needs an undo guard? @@ -448,8 +451,8 @@ preserved until the revision is resolved. Anchor accounting describes what actually happened. Ordinary paragraph/table top-level anchors remain the compact `Modified` contract. A structured wrapper -has no anchor of its own, so every anchored descendant retained under that -wrapper appears in `Modified`, without duplicates. A remaining structural +has its own `sdt` anchor, so that wrapper and every anchored descendant retained +under it appear in `Modified`, without duplicates. A remaining structural fall-through that must be hard-removed appears in `Removed`; it is never silently omitted from both lists. @@ -1916,7 +1919,8 @@ is not a second inheritance engine. A returned paragraph style `Id` is accepted Each `InlineSpan` reports the containing mutation-ready `AnchorId`, stable run `RunUnid`, flat-text `Span`, text, `Direct` run properties, and `Effective` run properties. `AnchorId` + `Span` can be passed directly to `ApplyFormat`. These are run/format spans only; hyperlink, bookmark, revision, -content-control, and other inline memberships are separate follow-ons (#451/#452/#455). +content-control membership is reported separately through `ContentControlAnchorIds`; +bookmark, revision, and other inline memberships remain separate follow-ons. ### `BlockMetadata` diff --git a/docs/architecture/markdown_projection.md b/docs/architecture/markdown_projection.md index 2921c11c..43899383 100644 --- a/docs/architecture/markdown_projection.md +++ b/docs/architecture/markdown_projection.md @@ -80,7 +80,7 @@ Anchors appear at the start of the line they refer to (block-level) or as inline | `w:footnoteReference` | `[^fn-xxxx]` GFM footnote ref | Definitions collected at end | | `w:endnoteReference` | `[^en-xxxx]` | Same | | `w:drawing` / `w:pict` (image) | `![alt](docxodus://img/…){#img:…}` | URL is a scheme the caller resolves; metadata accessible via anchor | -| `w:sdt` (content control) | Rendered content, anchor on outer SDT | The SDT itself is an anchor target so callers can address "this content control" | +| `w:sdt` (content control) | Anchor on the outer SDT; the current Markdown oracle omits inline/block SDT-delivered content | The SDT remains addressable without changing historical Markdown bytes; HTML and `ListBlocks` flatten/render its content | | `w:ins` / `w:del` (tracked changes) | Configurable: accept, show as `{+ins+}`/`{-del-}`, or omit | Mirrors `WmlToHtmlConverter.RenderTrackedChanges` | | `w:sectPr` | `---` thematic break preceded by `{#sec:scope:unid}` | Section breaks are addressable as `sec` kind — useful for "find the next section break" tooling. Today no mutation op accepts a `sec` anchor (only block-level `p`/`h`/`li`/`tbl` kinds are mutable); treat `sec` as a passive read-side marker. | diff --git a/docs/architecture/native_content_controls.md b/docs/architecture/native_content_controls.md index a3f085ec..a35507e6 100644 --- a/docs/architecture/native_content_controls.md +++ b/docs/architecture/native_content_controls.md @@ -16,10 +16,17 @@ package-wide duplicate native ids remain enumerable under deterministic diagnost anchors but are not mutable. Repeating-item clones receive fresh native ids before their anchors are made public. +Mutation also requires an exact SDT envelope: one `w:sdtPr`, one `w:sdtContent`, +one `w:id`, and no more than one mutually exclusive family marker. Malformed controls +stay enumerable under diagnostic anchors and fail before an undo snapshot. A repeating +template is cloneable only when every nested SDT satisfies the same invariant. + `sdt` is an AnchorIndex kind in both the WML projector and the immutable IR emitter. The IR captures projector-order anchor facts while its private package is open, so -index parity also holds with `RetainSources=false`. The wrapper remains transparent to -markdown, HTML, and `ListBlocks`; those surfaces continue to render/list its content. +index parity also holds with `RetainSources=false`. The current Markdown oracle indexes +the outer SDT but omits content delivered through an inline or block SDT, so adding the +anchor does not change historical Markdown bytes. HTML and `ListBlocks` remain wrapper- +transparent and flatten/render the contained blocks. `ListInlineSpans` additionally returns outer-to-inner `contentControlAnchorIds` for each run. @@ -42,6 +49,11 @@ drawing `docPr` id, and reject clone-sensitive bookmark, comment, permission, cu XML container/range, move, note-reference, and `w14:paraId`/`w14:textId` markup. The final item cannot be removed. +Dropdown selection writes the selected item's native `w:lastValue` as well as its +displayed text. Combo boxes do the same for a listed item and also accept custom text; +custom text becomes both the displayed payload and `w:lastValue`. Checkbox fills honor +the selected `w14:checkedState`/`w14:uncheckedState` font on the produced glyph run. + Whole-content replacement and repeating-item removal use the same bookmark-removal gate as other structural edits: crossing or externally referenced ranges fail before history changes. Rich-text links are validated against the document that will remain @@ -50,11 +62,25 @@ After a successful replacement/removal, owner-local hyperlink relationships are promoted or reference-counted away and unreferenced image parts are swept. Undo/redo restores that XML and package relationship topology together. -Every successful operation is one undo/redo step. Whole-control fills support inline -and block controls; row/cell controls remain enumerable but report `CanMutate=false` -and reject typed fills. Whole-control fills are also rejected in `render_inline` -tracked-change mode until issue #455 defines revision semantics; surgical text/format -operations inside a control remain available. +Every successful operation is one undo/redo step. Text, rich-text, checkbox, date, +dropdown, and combo-box fills support inline and block controls only. Row/cell controls +remain enumerable; picture and repeating-section operations use their own structural +shape checks instead of the text-placement rule. Whole-control fills are rejected in +`render_inline` tracked-change mode because they do not yet have a faithful replacement +revision encoding; surgical text/format operations inside a control remain available. + +## Anchor and receipt lifecycle + +Typed fills preserve the selected wrapper identity and return that `sdt` anchor in +`Modified`. `AddRepeatingSectionItem` returns the fresh item anchor in `Created` and the +section anchor in `Modified`. `RemoveRepeatingSectionItem` returns the item anchor in +`Removed` and the section anchor in `Modified`. These identities remain usable through +undo/redo according to whether the corresponding wrapper is live. + +Generic tracked `DeleteRange`/`DeleteSection` keeps a selected SDT wrapper live until +revision resolution. Its receipt therefore reports the wrapper `sdt` anchor and every +retained descendant anchor in `Modified`; a structural fall-through that is actually +removed appears in `Removed`. ## Locks, bindings, and nesting @@ -62,6 +88,12 @@ Content locks are effective through ancestors. A locked target or ancestor fails without changing history. A whole-content replacement that would discard a nested control is also refused; callers address the nested child directly. +For repeating sections, `CanMutate` describes the default operation honestly: a +section must have a safe final clone template, and an item is removable only when it +is a direct child, at least one sibling item will remain, and its wrapper is not +locked. Orphaned/non-direct items and clone-sensitive templates remain enumerable with +the corresponding diagnostic. + Bindings fail closed by default. Both `w:dataBinding` and the Office 2013 `w15:dataBinding` form are recognized. `bindingPolicy: "detach_target"` is the only opt-in: it removes the selected control's own native binding element before the @@ -73,7 +105,8 @@ Custom XML data part. A target inside a bound ancestor is always refused. The shared JSON facade, WASM bridge, TypeScript package, Python host/client, and MCP server expose the same typed operations. Options JSON is strict: the only fill option is `bindingPolicy`, with `preserve` (default) or `detach_target`. The MCP grouped tool -is `docxodus_content_controls`; its mutating actions participate in +is `docxodus_content_controls`; it advertises the same optional optimistic +`preconditions` guard as the other mutating tools, and its mutating actions participate in `docxodus_mutations` apply/preview rollback, while `list` is read-only and rejected as a batch step. Picture bytes cross JSON transports only as base64. An omitted date `displayText` selects the invariant default; an explicitly empty diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py index c375fb8a..66368a5a 100644 --- a/python/src/docx_scalpel/types.py +++ b/python/src/docx_scalpel/types.py @@ -1088,7 +1088,7 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "ContentControlInfo": is_showing_placeholder=bool(d.get("isShowingPlaceholder", False)), is_bound=bool(d.get("isBound", False)), binding=(ContentControlBindingInfo._from_wire(d["binding"]) - if d.get("binding") else None), + if "binding" in d and d["binding"] is not None else None), owning_part_uri=d["owningPartUri"], scope=d["scope"], parent_anchor_id=d.get("parentAnchorId"), depth=int(d.get("depth", 0)), has_valid_native_id=bool(d.get("hasValidNativeId", False)), diff --git a/python/tests/test_content_control_types.py b/python/tests/test_content_control_types.py new file mode 100644 index 00000000..32bad496 --- /dev/null +++ b/python/tests/test_content_control_types.py @@ -0,0 +1,42 @@ +"""Wire-decoding regressions for native content controls.""" + +from __future__ import annotations + +from docx_scalpel import ContentControlBindingInfo, ContentControlInfo + + +def _wire() -> dict[str, object]: + return { + "anchorId": "sdt:body:abc", + "type": "plain_text", + "placement": "inline", + "nativeId": "17", + "isBound": True, + "owningPartUri": "/word/document.xml", + "scope": "body", + "depth": 0, + "hasValidNativeId": True, + "hasDuplicateNativeId": False, + "canMutate": False, + "canDetachTargetBinding": True, + } + + +def test_content_control_empty_binding_object_is_decoded_by_key_presence() -> None: + wire = _wire() + wire["binding"] = {} + + decoded = ContentControlInfo._from_wire(wire) + + assert decoded.binding == ContentControlBindingInfo(None, None, None) + assert decoded.is_bound is True + + +def test_content_control_null_or_absent_binding_decodes_as_none() -> None: + absent = ContentControlInfo._from_wire(_wire()) + wire = _wire() + wire["binding"] = None + explicit_null = ContentControlInfo._from_wire(wire) + + assert absent.binding is None + assert explicit_null.binding is None diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 4d4138c6..9fae7be9 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -454,12 +454,13 @@ internal static class ToolCatalog """), new ToolDefinition( "docxodus_content_controls", - "Inspect and fill native Word content controls (structured-document tags) while preserving their wrappers and metadata. Bound controls fail closed unless bindingPolicy is detach_target, which removes only the selected control's own binding. Whole-control fills are refused for row/cell placements, nested targets, and render_inline tracked-change mode.", + "Inspect and fill native Word content controls (structured-document tags) while preserving their wrappers and metadata. Bound controls fail closed unless bindingPolicy is detach_target, which removes only the selected control's own binding. Text, checkbox, date, and list whole-content replacements are refused for row/cell placements; nested targets and render_inline tracked-change mode fail closed for every whole-control fill.", """ { "type": "object", "properties": { "sessionId": { "type": "string" }, + "preconditions": { "type": "object", "description": "Optional optimistic mutation guards; omitted preserves legacy behavior." }, "action": { "type": "string", "enum": ["list", "fill_text", "fill_rich_text", "set_checked", "set_date", "select_item", "fill_picture", "add_repeating_item", "remove_repeating_item"] }, "scope": { "type": "string", "enum": ["body", "headers", "footers", "footnotes", "endnotes", "comments", "all"] }, "anchorId": { "type": "string", "description": "Target sdt anchor returned by list." }, From e1e90df25ff761f463808c61c05afb1ecd38a196 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 06:25:32 -0500 Subject: [PATCH 4/5] Harden native content control safety --- .../DocxSessionContentControlTests.cs | 214 +++++++++++++++++- Docxodus/DocxSession.ContentControls.cs | 120 +++++++--- Docxodus/Internal/RevisionOps.cs | 5 +- docs/architecture/native_content_controls.md | 23 +- 4 files changed, 324 insertions(+), 38 deletions(-) diff --git a/Docxodus.Tests/DocxSessionContentControlTests.cs b/Docxodus.Tests/DocxSessionContentControlTests.cs index c52047bd..e5050403 100644 --- a/Docxodus.Tests/DocxSessionContentControlTests.cs +++ b/Docxodus.Tests/DocxSessionContentControlTests.cs @@ -454,7 +454,7 @@ public void CC014_RepeatingClone_AssignsDistinctDocumentPropertyIdsToEveryDrawin } [Fact] - public void CC015_RepeatingClone_RejectsCustomXmlMoveAndParagraphIdentities() + public void CC015_RepeatingClone_RejectsCloneSensitiveMarkup() { var cases = new Action[] { @@ -465,6 +465,27 @@ public void CC015_RepeatingClone_RejectsCustomXmlMoveAndParagraphIdentities() new XAttribute(W + "id", "7"))), item => item.Descendants(W + "p").First().SetAttributeValue(W14 + "paraId", "12345678"), item => item.Descendants(W + "p").First().SetAttributeValue(W14 + "textId", "87654321"), + item => + { + var run = item.Descendants(W + "r").First(); + run.ReplaceWith(new XElement(W + "ins", + new XAttribute(W + "id", "31"), + new XAttribute(W + "author", "reviewer"), run)); + }, + item => + { + var run = item.Descendants(W + "r").First(); + run.Descendants(W + "t").Single().Name = W + "delText"; + run.ReplaceWith(new XElement(W + "del", + new XAttribute(W + "id", "32"), + new XAttribute(W + "author", "reviewer"), run)); + }, + item => item.Descendants(W + "r").First().AddFirst( + new XElement(W + "rPr", + new XElement(W + "rPrChange", + new XAttribute(W + "id", "33"), + new XAttribute(W + "author", "reviewer"), + new XElement(W + "rPr")))), }; foreach (var arrange in cases) { @@ -472,9 +493,19 @@ public void CC015_RepeatingClone_RejectsCustomXmlMoveAndParagraphIdentities() arrange(ControlByNativeId(document, "109"))); using var session = new DocxSession(fixture); var section = session.ListContentControls().Single(control => control.NativeId == "108"); - Assert.Equal(EditErrorCode.RepeatingSectionConstraint, - session.AddRepeatingSectionItem(section.AnchorId).Error!.Code); + Assert.False(section.CanMutate); + var bytesBefore = session.Save(); + var versionBefore = session.Version; + var registryBefore = JsonSerializer.Serialize(session.ListContentControls()); + var revisionsBefore = JsonSerializer.Serialize(session.ListRevisions()); + var result = session.AddRepeatingSectionItem(section.AnchorId); + Assert.Equal(EditErrorCode.RepeatingSectionConstraint, result.Error!.Code); Assert.Equal(0, session.UndoCount); + Assert.Equal(0, session.RedoCount); + Assert.Equal(versionBefore, session.Version); + Assert.Equal(bytesBefore, session.Save()); + Assert.Equal(registryBefore, JsonSerializer.Serialize(session.ListContentControls())); + Assert.Equal(revisionsBefore, JsonSerializer.Serialize(session.ListRevisions())); } } @@ -708,6 +739,15 @@ public void CC021_ExactSdtCardinalityAndFamilyExclusivity_FailBeforeHistory() new XElement(W + "id", new XAttribute(W + "val", "901"))), "exactly one w:id"), (control => control.Element(W + "sdtPr")!.Add( new XElement(W + "date")), "mutually exclusive"), + (control => control.Element(W + "sdtPr")!.AddFirst( + new XElement(W + "lock", new XAttribute(W + "val", "unlocked")), + new XElement(W + "lock", new XAttribute(W + "val", "contentLocked"))), + "at most one w:lock"), + (control => control.Element(W + "sdtPr")!.AddFirst( + new XElement(W + "lock", new XAttribute(W + "val", "not-a-lock"))), + "supported w:val"), + (control => control.Element(W + "sdtPr")!.AddFirst( + new XElement(W + "lock")), "supported w:val"), }; foreach (var (arrange, diagnostic) in malformedFixtures) @@ -718,9 +758,17 @@ public void CC021_ExactSdtCardinalityAndFamilyExclusivity_FailBeforeHistory() var target = malformedSession.ListContentControls().Single(control => control.Text == "inner"); Assert.False(target.CanMutate); Assert.Contains(diagnostic, target.UnsupportedReason, StringComparison.Ordinal); + var bytesBefore = malformedSession.Save(); + var versionBefore = malformedSession.Version; + var registryBefore = JsonSerializer.Serialize(malformedSession.ListContentControls()); var result = malformedSession.FillContentControlText(target.AnchorId, "must not apply"); Assert.Equal(EditErrorCode.ContentControlMalformed, result.Error?.Code); Assert.Equal(0, malformedSession.UndoCount); + Assert.Equal(0, malformedSession.RedoCount); + Assert.Equal(versionBefore, malformedSession.Version); + Assert.Equal(bytesBefore, malformedSession.Save()); + Assert.Equal(registryBefore, + JsonSerializer.Serialize(malformedSession.ListContentControls())); Assert.Equal("inner", malformedSession.GetContentControl(target.AnchorId)?.Text); } @@ -891,6 +939,14 @@ public void CC024_PlaceholderAndBoundAncestorEdges_FailClosedOrPreserveMetadata( [Fact] public void CC025_PictureFill_RejectsZeroMultipleAndLinkedCandidatesBeforeHistory() { + using (var validSession = new DocxSession(BuildPictureFixture())) + { + var valid = validSession.ListContentControls() + .Single(control => control.NativeId == "113"); + Assert.True(valid.CanMutate, valid.UnsupportedReason); + Assert.Null(valid.UnsupportedReason); + } + var fixtures = new (byte[] Bytes, EditErrorCode Error)[] { (Transform(BuildPictureFixture(), document => @@ -918,9 +974,19 @@ public void CC025_PictureFill_RejectsZeroMultipleAndLinkedCandidatesBeforeHistor using var pictureSession = new DocxSession(bytes); var picture = pictureSession.ListContentControls() .Single(control => control.NativeId == "113"); + Assert.False(picture.CanMutate); + Assert.NotNull(picture.UnsupportedReason); + var bytesBefore = pictureSession.Save(); + var versionBefore = pictureSession.Version; + var registryBefore = JsonSerializer.Serialize(pictureSession.ListContentControls()); var result = pictureSession.FillContentControlPicture(picture.AnchorId, Png(4, 5)); Assert.Equal(expected, result.Error?.Code); + Assert.Equal(versionBefore, pictureSession.Version); Assert.Equal(0, pictureSession.UndoCount); + Assert.Equal(0, pictureSession.RedoCount); + Assert.Equal(bytesBefore, pictureSession.Save()); + Assert.Equal(registryBefore, + JsonSerializer.Serialize(pictureSession.ListContentControls())); } } @@ -1002,6 +1068,148 @@ public void CC027_WordAuthoredFixture_RoundTripsNativeMetadataAndState() $"{validation.Description} Node: {validation.Node?.OuterXml}"))); } + [Fact] + public void CC028_EmptyAndNestedOnlyRowCellControls_UseOwningContentModel() + { + static XElement EmptyControl(string id) => + new(W + "sdt", + new XElement(W + "sdtPr", + new XElement(W + "id", new XAttribute(W + "val", id)), + new XElement(W + "text")), + new XElement(W + "sdtContent")); + + static XElement ContainerControl(string id, XElement child) => + new(W + "sdt", + new XElement(W + "sdtPr", + new XElement(W + "id", new XAttribute(W + "val", id)), + new XElement(W + "richText")), + new XElement(W + "sdtContent", child)); + + var fixture = Transform(BuildFixture(), document => + { + var body = document.MainDocumentPart!.GetXDocument().Root!.Element(W + "body")!; + body.AddFirst(new XElement(W + "tbl", + new XElement(W + "tblPr"), + new XElement(W + "tblGrid", new XElement(W + "gridCol")), + EmptyControl("301"), + ContainerControl("302", EmptyControl("303")), + new XElement(W + "tr", + EmptyControl("304"), + ContainerControl("305", EmptyControl("306"))))); + }); + using var session = new DocxSession(fixture); + var controls = session.ListContentControls(); + Assert.All(new[] { "301", "302", "303" }, id => + Assert.Equal(ContentControlPlacement.Row, + controls.Single(control => control.NativeId == id).Placement)); + Assert.All(new[] { "304", "305", "306" }, id => + Assert.Equal(ContentControlPlacement.Cell, + controls.Single(control => control.NativeId == id).Placement)); + + foreach (var id in new[] { "301", "303", "304", "306" }) + { + var target = controls.Single(control => control.NativeId == id); + Assert.False(target.CanMutate); + var bytesBefore = session.Save(); + var versionBefore = session.Version; + var registryBefore = JsonSerializer.Serialize(session.ListContentControls()); + var result = session.FillContentControlText(target.AnchorId, "must not apply"); + Assert.Equal(EditErrorCode.ContentControlPlacementUnsupported, result.Error?.Code); + Assert.Equal(versionBefore, session.Version); + Assert.Equal(0, session.UndoCount); + Assert.Equal(0, session.RedoCount); + Assert.Equal(bytesBefore, session.Save()); + Assert.Equal(registryBefore, JsonSerializer.Serialize(session.ListContentControls())); + } + + using var saved = WordprocessingDocument.Open(new MemoryStream(session.Save()), false); + var validationErrors = new OpenXmlValidator(FileFormatVersions.Office2013).Validate(saved) + .Where(IsMaterialValidationError).ToList(); + Assert.True(validationErrors.Count == 0, string.Join(Environment.NewLine, + validationErrors.Select(validation => + $"{validation.Description} Node: {validation.Node?.OuterXml}"))); + } + + [Fact] + public void CC029_MalformedAncestorLock_FailsChildMutationWithoutStateChange() + { + var fixture = Transform(BuildFixture(), document => + { + var properties = ControlByNativeId(document, "100").Element(W + "sdtPr")!; + properties.AddFirst( + new XElement(W + "lock", new XAttribute(W + "val", "unlocked")), + new XElement(W + "lock", new XAttribute(W + "val", "contentLocked"))); + }); + using var session = new DocxSession(fixture); + var child = session.ListContentControls().Single(control => control.NativeId == "101"); + Assert.False(child.CanMutate); + Assert.Contains("ancestor content control is malformed", child.UnsupportedReason, + StringComparison.Ordinal); + Assert.Contains("at most one w:lock", child.UnsupportedReason, StringComparison.Ordinal); + var bytesBefore = session.Save(); + var versionBefore = session.Version; + var registryBefore = JsonSerializer.Serialize(session.ListContentControls()); + + var result = session.FillContentControlText(child.AnchorId, "must not apply"); + + Assert.Equal(EditErrorCode.ContentControlMalformed, result.Error?.Code); + Assert.Equal(versionBefore, session.Version); + Assert.Equal(0, session.UndoCount); + Assert.Equal(0, session.RedoCount); + Assert.Equal(bytesBefore, session.Save()); + Assert.Equal(registryBefore, JsonSerializer.Serialize(session.ListContentControls())); + } + + [Fact] + public void CC030_EmptyInlineRichText_UsesSchemaSafePayloadAndPreservesUndoReceipt() + { + var fixture = Transform(BuildFixture(), document => + { + var properties = ControlByNativeId(document, "101").Element(W + "sdtPr")!; + properties.Element(W + "text")!.ReplaceWith(new XElement(W + "richText")); + properties.Element(W + "id")!.AddAfterSelf( + new XElement(W + "placeholder", new XElement(W + "docPart", + new XAttribute(W + "val", "DefaultPlaceholder_22675703"))), + new XElement(W + "showingPlcHdr")); + }); + using var session = new DocxSession(fixture); + var target = session.ListContentControls().Single(control => control.NativeId == "101"); + Assert.Equal(ContentControlType.RichText, target.Type); + Assert.Equal(ContentControlPlacement.Inline, target.Placement); + Assert.True(target.IsShowingPlaceholder); + + var result = session.FillContentControlRichText(target.AnchorId, string.Empty); + + Assert.True(result.Success, result.Error?.Message); + Assert.Empty(result.Created); + Assert.Empty(result.Removed); + Assert.Equal(target.AnchorId, Assert.Single(result.Modified).Id); + var filled = session.GetContentControl(target.AnchorId)!; + Assert.Equal(string.Empty, filled.Text); + Assert.False(filled.IsShowingPlaceholder); + using (var saved = WordprocessingDocument.Open(new MemoryStream(session.Save()), false)) + { + var control = ControlByNativeId(saved, "101"); + Assert.NotNull(control.Element(W + "sdtPr")!.Element(W + "placeholder")); + Assert.Null(control.Element(W + "sdtPr")!.Element(W + "showingPlcHdr")); + Assert.Equal(W + "r", Assert.Single(control.Element(W + "sdtContent")!.Elements()).Name); + var validationErrors = new OpenXmlValidator(FileFormatVersions.Office2013).Validate(saved) + .Where(IsMaterialValidationError).ToList(); + Assert.True(validationErrors.Count == 0, string.Join(Environment.NewLine, + validationErrors.Select(validation => + $"{validation.Description} Node: {validation.Node?.OuterXml}"))); + } + + Assert.True(session.Undo()); + var undone = session.GetContentControl(target.AnchorId)!; + Assert.Equal("inner", undone.Text); + Assert.True(undone.IsShowingPlaceholder); + Assert.True(session.Redo()); + var redone = session.GetContentControl(target.AnchorId)!; + Assert.Equal(string.Empty, redone.Text); + Assert.False(redone.IsShowingPlaceholder); + } + private static string[] ParagraphAnchors(DocxSession session) => session.Project().AnchorIndex.Values .Where(value => value.Anchor.Kind is "p" or "h" or "li") .Select(value => value.Anchor.Id).Distinct().ToArray(); diff --git a/Docxodus/DocxSession.ContentControls.cs b/Docxodus/DocxSession.ContentControls.cs index 6c0b97d8..e6151f84 100644 --- a/Docxodus/DocxSession.ContentControls.cs +++ b/Docxodus/DocxSession.ContentControls.cs @@ -109,7 +109,13 @@ private sealed record ContentControlCandidate( XElement Element, ContentControlIdentity.Entry Identity, ContentControlInfo Info, - string? MalformedReason); + string? MalformedReason, + string? MalformedAncestorReason); + + private sealed record PictureContentControlTarget( + ImageCandidate? Image, + EditErrorCode? ErrorCode, + string? Diagnostic); public IReadOnlyList ListContentControls( ProjectionScopes scopes = ProjectionScopes.All) @@ -236,26 +242,18 @@ public EditResult FillContentControlPicture(string anchorId, byte[] imageBytes, return NestedFillError(anchorId); var binary = ValidateImageBytes(imageBytes, anchorId); if (binary.Error is not null) return binary.Error; - var images = EnumerateImageCandidates(ProjectionScopes.All).Where(image => - ReferenceEquals(image.Outer, candidate!.Element) - || image.Outer.Ancestors().Any(ancestor => ReferenceEquals(ancestor, candidate!.Element))) - .ToList(); - if (images.Count != 1) - return EditResult.Fail(EditErrorCode.ContentControlMalformed, - $"picture content control must contain exactly one mutable image; found {images.Count}", anchorId); - var image = images[0]; - if (image.Info.IsLinked) - return EditResult.Fail(EditErrorCode.LinkedImageReadOnly, - "a linked picture content control is read-only", anchorId); - if (!image.Info.CanMutate || image.Blip is null) - return EditResult.Fail(EditErrorCode.UnsupportedImageMarkup, - image.Info.UnsupportedReason ?? "picture content control uses unsupported image markup", anchorId); + var target = ResolvePictureContentControlTarget(candidate!.Element, + EnumerateImageCandidates(ProjectionScopes.All)); + if (target.ErrorCode is { } errorCode) + return EditResult.Fail(errorCode, target.Diagnostic!, anchorId); + var image = target.Image!; + var blip = image.Blip!; return MutateContentControl(candidate!, options, () => { var relationship = OwnedPartRelationships.FindOrAddImagePart(_doc!, candidate!.Owner.Part, imageBytes, binary.ContentType!, binary.Format); - image.Blip.SetAttributeValue(ImageR + "embed", relationship.RelationshipId); + blip.SetAttributeValue(ImageR + "embed", relationship.RelationshipId); candidate.Element.Element(W.sdtPr)?.Element(W.showingPlcHdr)?.Remove(); OwnedPartRelationships.SweepOrphanedImages(candidate.Owner.Part, ImageR + "embed", ImageR + "link"); }); @@ -383,7 +381,11 @@ private EditResult FillTextualContentControl(string anchorId, string payload, bo if (!parsed.Success) return EditResult.Fail(parsed.Error!.Code, parsed.Error.Message, anchorId); if (parsed.Blocks.Count == 0) - parsed = MarkdownPayloadParser.Parse(""); + parsed = ParseResult.Ok(new[] + { + new ParsedBlock(ParserBlockKind.Paragraph, 0, + new[] { new XElement(W.r) }), + }); if (candidate.Info.Placement == ContentControlPlacement.Inline && parsed.Blocks.Count != 1) return EditResult.Fail(EditErrorCode.ContentControlPlacementUnsupported, "an inline rich-text control accepts exactly one markdown block", anchorId); @@ -399,15 +401,12 @@ private EditResult FillTextualContentControl(string anchorId, string payload, bo var content = candidate.Element.Element(W.sdtContent)!; if (candidate.Info.Placement == ContentControlPlacement.Inline) { - var block = parsed.Blocks.Count == 0 - ? new ParsedBlock(ParserBlockKind.Paragraph, 0, Array.Empty()) - : parsed.Blocks[0]; + var block = parsed.Blocks[0]; content.ReplaceNodes(block.RunElements.Select(element => new XElement(element))); } else { var blocks = parsed.Blocks.Select(BuildParagraphFromParsedBlock).ToList(); - if (blocks.Count == 0) blocks.Add(new XElement(W.p)); content.ReplaceNodes(blocks); } candidate.Element.Element(W.sdtPr)?.Element(W.showingPlcHdr)?.Remove(); @@ -447,10 +446,12 @@ private bool ResolveContentControlForMutation(string anchorId, $"content control not found: {anchorId}", anchorId); return false; } - if (candidate.MalformedReason is not null || !candidate.Identity.HasMutableIdentity) + if (candidate.MalformedReason is not null || candidate.MalformedAncestorReason is not null + || !candidate.Identity.HasMutableIdentity) { error = EditResult.Fail(EditErrorCode.ContentControlMalformed, - candidate.MalformedReason ?? candidate.Info.UnsupportedReason + candidate.MalformedReason ?? candidate.MalformedAncestorReason + ?? candidate.Info.UnsupportedReason ?? "content control has no unique valid native w:id", anchorId); return false; } @@ -638,6 +639,7 @@ private static EditResult NestedFillError(string anchorId) => private IReadOnlyList BuildContentControlRegistry(ProjectionScopes scopes) { var result = new List(); + IReadOnlyList? imageCandidates = null; var owners = OwnedPartRelationships.StoryParts(_doc!); var roots = owners.Select(owner => owner.Part.GetXDocument().Root) .Where(root => root is not null).Cast().ToList(); @@ -656,6 +658,9 @@ private IReadOnlyList BuildContentControlRegistry(Proje { var element = identity.Element; var malformed = ValidateContentControlStructure(element); + var malformedAncestor = element.Ancestors(W.sdt) + .Select(ValidateContentControlStructure) + .FirstOrDefault(reason => reason is not null); var props = element.Element(W.sdtPr); var type = ClassifyContentControl(props); var placement = DetectContentControlPlacement(element); @@ -664,6 +669,8 @@ private IReadOnlyList BuildContentControlRegistry(Proje var lockToken = (string?)props?.Element(ContentControlW + "lock")?.Attribute(W.val); string? unsupported = null; if (malformed is not null) unsupported = malformed; + else if (malformedAncestor is not null) + unsupported = $"ancestor content control is malformed: {malformedAncestor}"; else if (!identity.HasValidNativeId) unsupported = "missing or invalid native w:sdtPr/w:id"; else if (identity.IsDuplicateNativeId) unsupported = "duplicate native w:sdtPr/w:id in package"; else if (placement == ContentControlPlacement.Unknown) unsupported = "unsupported or malformed OOXML placement"; @@ -685,6 +692,13 @@ private IReadOnlyList BuildContentControlRegistry(Proje unsupported = "whole-control fill is unsupported when the target contains nested controls"; if (unsupported is null) unsupported = RepeatingMutationConstraint(element, type); + if (unsupported is null && type == ContentControlType.Picture) + { + imageCandidates ??= EnumerateImageCandidates(ProjectionScopes.All); + var pictureTarget = ResolvePictureContentControlTarget(element, imageCandidates); + if (pictureTarget.ErrorCode is not null) + unsupported = pictureTarget.Diagnostic; + } bool defaultMutable = unsupported is null && !locked && !wrapperLocked && !targetBound && !ancestorBound; @@ -726,12 +740,34 @@ private IReadOnlyList BuildContentControlRegistry(Proje ItemValues = items, }; result.Add(new ContentControlCandidate(owner, element, byElement[element], info, - malformed)); + malformed, malformedAncestor)); } } return result; } + /// Apply the picture topology contract once for both discovery and mutation. + /// A picture SDT is mutable only when it owns exactly one canonical embedded image. + private static PictureContentControlTarget ResolvePictureContentControlTarget( + XElement control, IReadOnlyList imageCandidates) + { + var images = imageCandidates.Where(image => + ReferenceEquals(image.Outer, control) + || image.Outer.Ancestors().Any(ancestor => ReferenceEquals(ancestor, control))) + .ToList(); + if (images.Count != 1) + return new PictureContentControlTarget(null, EditErrorCode.ContentControlMalformed, + $"picture content control must contain exactly one mutable image; found {images.Count}"); + var image = images[0]; + if (image.Info.IsLinked) + return new PictureContentControlTarget(null, EditErrorCode.LinkedImageReadOnly, + "a linked picture content control is read-only"); + if (!image.Info.CanMutate || image.Blip is null) + return new PictureContentControlTarget(null, EditErrorCode.UnsupportedImageMarkup, + image.Info.UnsupportedReason ?? "picture content control uses unsupported image markup"); + return new PictureContentControlTarget(image, null, null); + } + private static bool ScopeIncluded(string scope, ProjectionScopes scopes) => scope switch { "body" => scopes.HasFlag(ProjectionScopes.Body), @@ -768,6 +804,12 @@ private static ContentControlType ClassifyContentControl(XElement? props) if (!ContentControlIdentity.TryCanonicalizeNativeId( (string?)ids[0].Attribute(W.val), out _)) return "w:sdtPr/w:id must have a signed 32-bit integer w:val"; + var locks = properties[0].Elements(ContentControlW + "lock").ToList(); + if (locks.Count > 1) + return $"w:sdtPr must contain at most one w:lock; found {locks.Count}"; + if (locks.Count == 1 && (string?)locks[0].Attribute(W.val) + is not ("unlocked" or "sdtLocked" or "contentLocked" or "sdtContentLocked")) + return "w:sdtPr/w:lock must have a supported w:val"; var family = properties[0].Elements().Where(element => !ContentControlMetadata.Contains(element.Name)).ToList(); if (family.Count > 1) @@ -805,10 +847,11 @@ private static ContentControlPlacement DetectContentControlPlacement(XElement co if (content is null) return ContentControlPlacement.Unknown; var children = content.Elements().ToList(); if (children.Count == 0) - { - if (control.Ancestors(W.p).Any()) return ContentControlPlacement.Inline; - return ContentControlPlacement.Block; - } + return DetectContentControlPlacementFromContext(control); + // A nested SDT is valid in every placement grammar, so an sdt-only payload is + // intrinsically ambiguous from children alone. Its parent context is authoritative. + if (children.All(element => element.Name == W.sdt)) + return DetectContentControlPlacementFromContext(control); bool allInline = children.All(element => element.Name == W.r || element.Name == W.hyperlink || element.Name == W.fldSimple || element.Name == W.sdt || element.Name == W.smartTag || element.Name == W.bookmarkStart || element.Name == W.bookmarkEnd @@ -824,6 +867,25 @@ private static ContentControlPlacement DetectContentControlPlacement(XElement co return ContentControlPlacement.Unknown; } + /// An empty or nested-SDT-only sdtContent has no unambiguous child grammar from + /// which to infer its typed SDT context. Use the nearest OOXML content-model boundary + /// instead, walking transparently through nested SDTs and revision/custom-XML carriers. + private static ContentControlPlacement DetectContentControlPlacementFromContext(XElement control) + { + foreach (var ancestor in control.Ancestors()) + { + if (ancestor.Name == W.p) return ContentControlPlacement.Inline; + if (ancestor.Name == W.tc || ancestor.Name == W.body || ancestor.Name == W.hdr + || ancestor.Name == W.ftr || ancestor.Name == W.footnote + || ancestor.Name == W.endnote || ancestor.Name == W.comment + || ancestor.Name == W.txbxContent) + return ContentControlPlacement.Block; + if (ancestor.Name == W.tr) return ContentControlPlacement.Cell; + if (ancestor.Name == W.tbl) return ContentControlPlacement.Row; + } + return ContentControlPlacement.Unknown; + } + private static bool IsRepeatingSection(XElement control) => control.Element(W.sdtPr)?.Element(ContentControlW15 + "repeatingSection") is not null; @@ -876,6 +938,8 @@ private void AssignFreshDocumentPropertyIds(XElement root) foreach (var control in item.DescendantsAndSelf(W.sdt)) if (ValidateContentControlStructure(control) is { } malformed) return $"malformed content control: {malformed}"; + var revision = item.Descendants().FirstOrDefault(RevisionOps.IsRecognizedRevisionMarker); + if (revision is not null) return $"tracked revision {revision.Name.LocalName}"; var unsafeNames = new HashSet { W.bookmarkStart, W.bookmarkEnd, W.commentRangeStart, W.commentRangeEnd, diff --git a/Docxodus/Internal/RevisionOps.cs b/Docxodus/Internal/RevisionOps.cs index b6ef212d..8bc5c160 100644 --- a/Docxodus/Internal/RevisionOps.cs +++ b/Docxodus/Internal/RevisionOps.cs @@ -984,7 +984,10 @@ private static void AddUnsupportedGroups(XElement root, int partIndex, ListTrue for every live tracked-change carrier the native revision registry + /// inventories, including malformed/orphan payload markers. Structural clone operations + /// use this shared vocabulary so copied markup cannot manufacture duplicate live ids. + internal static bool IsRecognizedRevisionMarker(XElement element) { var name = element.Name; return RevWrapperNames.Contains(name) diff --git a/docs/architecture/native_content_controls.md b/docs/architecture/native_content_controls.md index a35507e6..783cbb67 100644 --- a/docs/architecture/native_content_controls.md +++ b/docs/architecture/native_content_controls.md @@ -17,9 +17,11 @@ anchors but are not mutable. Repeating-item clones receive fresh native ids befo their anchors are made public. Mutation also requires an exact SDT envelope: one `w:sdtPr`, one `w:sdtContent`, -one `w:id`, and no more than one mutually exclusive family marker. Malformed controls -stay enumerable under diagnostic anchors and fail before an undo snapshot. A repeating -template is cloneable only when every nested SDT satisfies the same invariant. +one `w:id`, no more than one mutually exclusive family marker, and at most one +`w:lock` carrying a native lock value. The same malformed-envelope gate applies through +ancestors, so a valid child cannot bypass a malformed outer lock. Malformed controls stay +enumerable under diagnostic anchors and fail before an undo snapshot. A repeating template +is cloneable only when every nested SDT satisfies the same invariant. `sdt` is an AnchorIndex kind in both the WML projector and the immutable IR emitter. The IR captures projector-order anchor facts while its private package is open, so @@ -43,11 +45,18 @@ The typed surface is deliberately operation-specific: Fills preserve `w:sdt`, `w:sdtPr`, `w:sdtEndPr`, and metadata not owned by the operation. Text fills retain representative run/paragraph properties and clear only -the showing-placeholder marker. Picture fills replace the image relationship without -rebuilding the wrapper. Repeating clones freshen every nested content-control id and +the showing-placeholder marker. Empty rich-text input normalizes to one schema-safe +empty paragraph or run payload, preserving the wrapper and placeholder definition while +clearing its showing-state marker. Picture fills replace the image relationship without +rebuilding the wrapper. Discovery and fill share the same picture topology gate: exactly +one canonical, embedded, mutable image must belong to the control, so `CanMutate` cannot +advertise zero-image, multi-image, linked, or unsupported picture controls as writable. +Repeating clones freshen every nested content-control id and drawing `docPr` id, and reject clone-sensitive bookmark, comment, permission, custom XML container/range, move, note-reference, and `w14:paraId`/`w14:textId` markup. The -final item cannot be removed. +clone gate also rejects every live tracked-revision carrier recognized by the revision +registry; duplicating such markup would duplicate its native revision ids and make later +resolution ambiguous. The final item cannot be removed. Dropdown selection writes the selected item's native `w:lastValue` as well as its displayed text. Combo boxes do the same for a listed item and also accept custom text; @@ -68,6 +77,8 @@ remain enumerable; picture and repeating-section operations use their own struct shape checks instead of the text-placement rule. Whole-control fills are rejected in `render_inline` tracked-change mode because they do not yet have a faithful replacement revision encoding; surgical text/format operations inside a control remain available. +Empty and nested-SDT-only payloads derive row/cell/block/inline placement from their nearest +owning content-model boundary rather than defaulting to block placement. ## Anchor and receipt lifecycle From 335265e93cec94c81f111d9c3d0d804b4efb3242 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 21:47:02 -0500 Subject: [PATCH 5/5] fix(content-controls): freshen paraId on clone, align canMutate with mutation, order rPr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on the native content-control surface (#452). Repeating-section clones now freshen `w14:paraId` instead of refusing any item that carries one. Word 2013+ stamps a paraId on essentially every `w:p`, so the reject made the feature inert on real templates: HC031 carries 158 of them, while the only fixture exercising the clone path carries zero — which is why no test caught it. The clone mints package-unique values from a new `CommentOps.ParaIdAllocator`, extracted out of `NextParaId` so a detached subtree can mint several ids without aliasing, and kept as the single owner of paraId minting. `w14:textId` is copied verbatim: it is a hash of the paragraph's text rather than an identity, and Word emits the same value for two paragraphs with the same content — which is exactly what a clone is. `ListContentControls` now consults the session's tracked-change mode and the bookmark-removal gate, so `canMutate`/`unsupportedReason` agree with what a mutation actually does. Under `RenderInline` every control reported `canMutate: true` while every fill failed with `tracked_operation_unsupported`; an agent planning off the registry — and the MCP `list` action feeds exactly this — built a batch guaranteed to fail. Both gates are now shared predicates that discovery and `ResolveContentControlForMutation` read, closing two more instances of the divergence class the PR's three Harden commits each closed one of. `SetContentControlChecked` inserted `w:rFonts` at position 0 of a cloned `w:rPr`, which is schema-invalid whenever the glyph run already carries an earlier `CT_RPr` member (`w:ins`, `w:del`, `w:rStyle`, the move markers). Routed through the existing `WordprocessingMLUtil.InsertRPrChildInOrder`. Coverage: CC031-CC035 (paraId freshening with OpenXmlValidator, tracked-mode registry agreement across all nine ops, bookmark gate visible in discovery, rFonts schema slot, header/footer `ScopeIncluded`), an end-to-end `python/tests/test_content_controls.py` over all nine stdio-host route names, and `npm/tests/docx-session-content-controls.spec.ts` for the WASM bridge. Docs: the `[Unreleased]` CHANGELOG entry, the missing `### docxodus_content_controls` section plus the corrected tool count and batch-step list in `docx_agent_server.md`, and two corrected claims in `native_content_controls.md` — the SDT envelope is stricter than `CT_SdtPr` (both `w:sdtPr` and `w:id` are `minOccurs="0"`), and `w:lock` is honoured by content-control operations rather than by the generic anchor-addressed surface. --- CHANGELOG.md | 16 ++ .../DocxSessionContentControlTests.cs | 196 +++++++++++++++- Docxodus/DocxSession.ContentControls.cs | 92 ++++++-- Docxodus/Internal/CommentOps.cs | 33 ++- docs/architecture/docx_agent_server.md | 42 +++- docs/architecture/native_content_controls.md | 32 ++- .../docx-session-content-controls.spec.ts | 221 ++++++++++++++++++ python/tests/test_content_controls.py | 217 +++++++++++++++++ tools/mcp-server/ToolCatalog.cs | 2 +- 9 files changed, 825 insertions(+), 26 deletions(-) create mode 100644 npm/tests/docx-session-content-controls.spec.ts create mode 100644 python/tests/test_content_controls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f85ad01f..a763c214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added +- **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 diff --git a/Docxodus.Tests/DocxSessionContentControlTests.cs b/Docxodus.Tests/DocxSessionContentControlTests.cs index e5050403..bf4ade21 100644 --- a/Docxodus.Tests/DocxSessionContentControlTests.cs +++ b/Docxodus.Tests/DocxSessionContentControlTests.cs @@ -463,8 +463,6 @@ public void CC015_RepeatingClone_RejectsCloneSensitiveMarkup() item => item.Element(W + "sdtContent")!.AddFirst( new XElement(W + "customXmlMoveFromRangeStart", new XAttribute(W + "id", "7"))), - item => item.Descendants(W + "p").First().SetAttributeValue(W14 + "paraId", "12345678"), - item => item.Descendants(W + "p").First().SetAttributeValue(W14 + "textId", "87654321"), item => { var run = item.Descendants(W + "r").First(); @@ -1210,6 +1208,200 @@ public void CC030_EmptyInlineRichText_UsesSchemaSafePayloadAndPreservesUndoRecei Assert.False(redone.IsShowingPlaceholder); } + [Fact] + public void CC031_RepeatingClone_FreshensWordParagraphIdentityInsteadOfRefusingIt() + { + // Word 2013+ stamps w14:paraId on essentially every w:p, so a template that carries one + // is the normal case rather than an exotic one; the clone must mint fresh identities. + var fixture = Transform(BuildFixture(), document => + { + ControlByNativeId(document, "109").Descendants(W + "p").Single() + .SetAttributeValue(W14 + "paraId", "0000002A"); + ControlByNativeId(document, "109").Descendants(W + "p").Single() + .SetAttributeValue(W14 + "textId", "77777777"); + ControlByNativeId(document, "100").Descendants(W + "p").First() + .SetAttributeValue(W14 + "paraId", "0000002B"); + }); + + using var session = new DocxSession(fixture); + var section = session.ListContentControls().Single(control => control.NativeId == "108"); + Assert.True(section.CanMutate, section.UnsupportedReason); + var first = session.AddRepeatingSectionItem(section.AnchorId); + Assert.True(first.Success, first.Error?.Message); + Assert.True(session.AddRepeatingSectionItem(section.AnchorId).Success); + Assert.Equal(3, session.ListContentControls() + .Count(control => control.Type == ContentControlType.RepeatingSectionItem)); + + var saved = session.Save(); + using var document = WordprocessingDocument.Open(new MemoryStream(saved), false); + var paraIds = document.MainDocumentPart!.GetXDocument().Descendants(W + "p") + .Select(paragraph => (string?)paragraph.Attribute(W14 + "paraId")) + .Where(value => value is not null).ToList(); + // Three cloned items plus the untouched outer paragraph, every identity distinct. + Assert.Equal(4, paraIds.Count); + Assert.Equal(paraIds.Count, paraIds.Distinct(StringComparer.Ordinal).Count()); + Assert.DoesNotContain("00000000", paraIds); + + // w14:textId is a hash of the paragraph text, not an identity: clones of identical text + // legitimately share it, exactly as Word emits it. + var textIds = document.MainDocumentPart.GetXDocument().Descendants(W + "sdt") + .Where(control => control.Element(W + "sdtPr")?.Element(W15 + "repeatingSectionItem") is not null) + .Select(item => (string?)item.Descendants(W + "p").Single().Attribute(W14 + "textId")) + .ToList(); + Assert.Equal(new[] { "77777777", "77777777", "77777777" }, textIds); + + var validationErrors = new OpenXmlValidator(FileFormatVersions.Office2013).Validate(document) + .Where(IsMaterialValidationError).ToList(); + Assert.True(validationErrors.Count == 0, string.Join(Environment.NewLine, + validationErrors.Select(validation => + $"{validation.Description} Node: {validation.Node?.OuterXml}"))); + } + + [Fact] + public void CC032_TrackedMode_RegistryAgreesWithWhatEveryMutationActuallyDoes() + { + using var session = new DocxSession(BuildPictureFixture()); + Assert.Contains(session.ListContentControls(), control => control.CanMutate); + var identifiers = session.ListContentControls() + .Where(control => control.NativeId is not null) + .GroupBy(control => control.NativeId!, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First().AnchorId, + StringComparer.Ordinal); + + session.SetTrackedChanges(TrackedChangeMode.RenderInline); + var tracked = session.ListContentControls(); + Assert.All(tracked, control => + { + Assert.False(control.CanMutate); + Assert.False(control.CanDetachTargetBinding); + Assert.Contains("tracked revisions", control.UnsupportedReason!, StringComparison.Ordinal); + }); + + var attempts = new Func[] + { + () => session.FillContentControlText(identifiers["101"], "x"), + () => session.FillContentControlRichText(identifiers["100"], "x"), + () => session.SetContentControlChecked(identifiers["102"], true), + () => session.SetContentControlDate(identifiers["103"], DateTimeOffset.UnixEpoch), + () => session.SelectContentControlItem(identifiers["104"], "a"), + () => session.SelectContentControlItem(identifiers["105"], "a"), + () => session.FillContentControlPicture(identifiers["113"], Png(4, 5)), + () => session.AddRepeatingSectionItem(identifiers["108"]), + () => session.RemoveRepeatingSectionItem(identifiers["109"]), + }; + foreach (var attempt in attempts) + Assert.Equal(EditErrorCode.TrackedOperationUnsupported, attempt().Error!.Code); + Assert.Equal(0, session.UndoCount); + + // Leaving tracked mode restores exactly the pre-tracked registry verdicts. + session.SetTrackedChanges(TrackedChangeMode.Accept); + Assert.Contains(session.ListContentControls(), control => control.CanMutate); + } + + [Fact] + public void CC033_BookmarkGate_IsVisibleInDiscoveryNotOnlyAtMutationTime() + { + var fixture = Transform(BuildFixture(), document => + { + var content = ControlByNativeId(document, "101").Element(W + "sdtContent")!; + content.AddFirst(new XElement(W + "bookmarkStart", + new XAttribute(W + "id", "31"), new XAttribute(W + "name", "InnerTarget"))); + content.Add(new XElement(W + "bookmarkEnd", new XAttribute(W + "id", "31"))); + document.MainDocumentPart!.GetXDocument().Root!.Element(W + "body")!.Add( + new XElement(W + "p", new XElement(W + "hyperlink", + new XAttribute(W + "anchor", "InnerTarget"), + new XElement(W + "r", new XElement(W + "t", "jump"))))); + }); + using var session = new DocxSession(fixture); + var target = session.ListContentControls().Single(control => control.NativeId == "101"); + Assert.False(target.CanMutate); + Assert.Contains("InnerTarget", target.UnsupportedReason!, StringComparison.Ordinal); + Assert.Equal(EditErrorCode.BookmarkInUse, + session.FillContentControlText(target.AnchorId, "replacement").Error!.Code); + + // The same control is mutable again once nothing points at the bookmark. + var released = Transform(fixture, document => + document.MainDocumentPart!.GetXDocument().Descendants(W + "hyperlink").Single().Remove()); + using var releasedSession = new DocxSession(released); + var releasedTarget = releasedSession.ListContentControls() + .Single(control => control.NativeId == "101"); + Assert.True(releasedTarget.CanMutate, releasedTarget.UnsupportedReason); + Assert.True(releasedSession.FillContentControlText(releasedTarget.AnchorId, "replacement").Success); + } + + [Fact] + public void CC034_CheckboxStateFont_InsertsRFontsAtItsSchemaSlotNotAtPositionZero() + { + // CT_RPr is a strict sequence: w:rStyle (30) ranks before w:rFonts (40). A glyph run that + // already carries an earlier member must not be given w:rFonts at position 0. + var fixture = Transform(BuildFixture(), document => + { + var control = ControlByNativeId(document, "102"); + control.Descendants(W14 + "checkedState").Single() + .SetAttributeValue(W14 + "font", "Wingdings"); + control.Descendants(W + "r").Single().AddFirst(new XElement(W + "rPr", + new XElement(W + "rStyle", new XAttribute(W + "val", "Strong")))); + }); + using var session = new DocxSession(fixture); + var checkbox = session.ListContentControls().Single(control => control.NativeId == "102"); + Assert.True(session.SetContentControlChecked(checkbox.AnchorId, true).Success); + + var saved = session.Save(); + using var document = WordprocessingDocument.Open(new MemoryStream(saved), false); + var runProperties = ControlByNativeId(document, "102").Descendants(W + "rPr").Single(); + Assert.Equal(new[] { "rStyle", "rFonts" }, + runProperties.Elements().Select(element => element.Name.LocalName).ToArray()); + Assert.Equal("Wingdings", (string?)runProperties.Element(W + "rFonts")!.Attribute(W + "ascii")); + var validationErrors = new OpenXmlValidator(FileFormatVersions.Office2013).Validate(document) + .Where(IsMaterialValidationError).ToList(); + Assert.True(validationErrors.Count == 0, string.Join(Environment.NewLine, + validationErrors.Select(validation => + $"{validation.Description} Node: {validation.Node?.OuterXml}"))); + } + + [Fact] + public void CC035_HeaderAndFooterControls_HonorProjectionScopes_AndFillInPlace() + { + var fixture = Transform(BuildFixture(), document => + { + var main = document.MainDocumentPart!; + main.AddNewPart().PutXDocument(new XDocument(new XElement(W + "hdr", + BlockSdt("301", new XElement(W + "text"), "header value", tag: "header-tag")))); + main.AddNewPart().PutXDocument(new XDocument(new XElement(W + "ftr", + BlockSdt("302", new XElement(W + "text"), "footer value", tag: "footer-tag")))); + }); + using var session = new DocxSession(fixture); + + string[] Ids(ProjectionScopes scopes) => session.ListContentControls(scopes) + .Select(control => control.NativeId).Where(value => value is not null).ToArray()!; + Assert.DoesNotContain("301", Ids(ProjectionScopes.Body)); + Assert.DoesNotContain("302", Ids(ProjectionScopes.Body)); + Assert.Equal(new[] { "301" }, Ids(ProjectionScopes.Headers)); + Assert.Equal(new[] { "302" }, Ids(ProjectionScopes.Footers)); + Assert.Contains("301", Ids(ProjectionScopes.All)); + Assert.Contains("302", Ids(ProjectionScopes.All)); + + var header = session.ListContentControls().Single(control => control.NativeId == "301"); + var footer = session.ListContentControls().Single(control => control.NativeId == "302"); + Assert.Equal("hdr1", header.Scope); + Assert.Equal("ftr1", footer.Scope); + Assert.EndsWith("header1.xml", header.OwningPartUri, StringComparison.Ordinal); + Assert.EndsWith("footer1.xml", footer.OwningPartUri, StringComparison.Ordinal); + Assert.True(header.CanMutate, header.UnsupportedReason); + Assert.True(footer.CanMutate, footer.UnsupportedReason); + + Assert.True(session.FillContentControlText(header.AnchorId, "running header value").Success); + Assert.True(session.FillContentControlText(footer.AnchorId, "running footer value").Success); + using var reopened = new DocxSession(session.Save()); + Assert.Equal("running header value", reopened.ListContentControls() + .Single(control => control.NativeId == "301").Text); + Assert.Equal("running footer value", reopened.ListContentControls() + .Single(control => control.NativeId == "302").Text); + // The body story is untouched by a running-content fill. + Assert.Equal("inner", reopened.ListContentControls() + .Single(control => control.NativeId == "101").Text); + } + private static string[] ParagraphAnchors(DocxSession session) => session.Project().AnchorIndex.Values .Where(value => value.Anchor.Kind is "p" or "h" or "li") .Select(value => value.Anchor.Id).Distinct().ToArray(); diff --git a/Docxodus/DocxSession.ContentControls.cs b/Docxodus/DocxSession.ContentControls.cs index e6151f84..e21a14b5 100644 --- a/Docxodus/DocxSession.ContentControls.cs +++ b/Docxodus/DocxSession.ContentControls.cs @@ -304,6 +304,7 @@ public EditResult AddRepeatingSectionItem(string sectionAnchorId, AssignFreshContentControlIds(clone); UnidHelper.AssignToSelfAndDescendants(clone); AssignFreshDocumentPropertyIds(clone); + AssignFreshParagraphIds(clone); template.AddAfterSelf(clone); ContentControlIdentity.AssignStableUnids(section.Owner.Part.GetXDocument().Root!); InvalidateProjectionCache(); @@ -431,11 +432,9 @@ private bool ResolveContentControlForMutation(string anchorId, error = EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); return false; } - if (_trackedChanges == TrackedChangeMode.RenderInline) + if (TrackedContentControlBlocker() is { } trackedReason) { - error = EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, - "whole content-control fills cannot be represented faithfully as tracked revisions; use surgical text operations inside the control or switch modes", - anchorId); + error = EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, trackedReason, anchorId); return false; } candidate = BuildContentControlRegistry(ProjectionScopes.All).FirstOrDefault(value => @@ -494,6 +493,38 @@ private bool ResolveContentControlForMutation(string anchorId, return true; } + /// + /// The session-mode gate every content-control mutation shares: a whole-control fill and a + /// repeating-item add/remove both rewrite a payload wholesale, which has no faithful tracked + /// representation. Discovery and mutation both read it, so the registry cannot advertise + /// canMutate for an operation that is guaranteed to be refused. + /// + private string? TrackedContentControlBlocker() => + _trackedChanges == TrackedChangeMode.RenderInline + ? "content-control mutations cannot be represented faithfully as tracked revisions; use surgical text operations inside the control or switch modes" + : null; + + /// + /// The bookmark consequences of an operation that discards the target's complete payload — + /// the same gate applies to a whole-control + /// fill and applies to the item it removes. + /// Evaluated once for discovery so a control whose fill is certain to fail is not reported + /// mutable. Picture fill is excluded because it rewrites only the blip relationship and + /// therefore takes no bookmark gate at mutation time. + /// + private string? WholeContentBookmarkBlocker(XElement element, ContentControlType type) + { + var removalRoot = type switch + { + ContentControlType.RepeatingSectionItem => element, + _ when IsWholeContentReplacementType(type) => element.Element(W.sdtContent), + _ => null, + }; + return removalRoot is null + ? null + : ValidateBookmarkRemoval(new[] { removalRoot }, string.Empty)?.Error?.Message; + } + private EditResult? ValidateEffectiveLocks(ContentControlCandidate candidate, bool removingWrapper) { foreach (var control in candidate.Element.AncestorsAndSelf(W.sdt)) @@ -583,8 +614,11 @@ private static void ReplaceControlWithPlainText(XElement control, string text, var fonts = runProperties.Element(W.rFonts); if (fonts is null) { + // CT_RPr is a strict sequence and the cloned rPr can already carry earlier + // members (w:ins, w:del, w:rStyle, the move markers). Insert at the schema slot + // rather than at position 0. fonts = new XElement(W.rFonts); - runProperties.AddFirst(fonts); + WordprocessingMLUtil.InsertRPrChildInOrder(runProperties, fonts); } fonts.SetAttributeValue(W.ascii, stateFont); fonts.SetAttributeValue(W.hAnsi, stateFont); @@ -640,6 +674,7 @@ private IReadOnlyList BuildContentControlRegistry(Proje { var result = new List(); IReadOnlyList? imageCandidates = null; + var trackedBlocker = TrackedContentControlBlocker(); var owners = OwnedPartRelationships.StoryParts(_doc!); var roots = owners.Select(owner => owner.Part.GetXDocument().Root) .Where(root => root is not null).Cast().ToList(); @@ -667,8 +702,12 @@ private IReadOnlyList BuildContentControlRegistry(Proje var binding = FindDataBinding(props); var parent = element.Ancestors(W.sdt).FirstOrDefault(); var lockToken = (string?)props?.Element(ContentControlW + "lock")?.Attribute(W.val); + // Discovery evaluates the mutation-time gates in the order + // ResolveContentControlForMutation applies them, so the first reason an agent + // reads here is the reason the mutation would actually return. string? unsupported = null; - if (malformed is not null) unsupported = malformed; + if (trackedBlocker is not null) unsupported = trackedBlocker; + else if (malformed is not null) unsupported = malformed; else if (malformedAncestor is not null) unsupported = $"ancestor content control is malformed: {malformedAncestor}"; else if (!identity.HasValidNativeId) unsupported = "missing or invalid native w:sdtPr/w:id"; @@ -699,6 +738,8 @@ private IReadOnlyList BuildContentControlRegistry(Proje if (pictureTarget.ErrorCode is not null) unsupported = pictureTarget.Diagnostic; } + if (unsupported is null) + unsupported = WholeContentBookmarkBlocker(element, type); bool defaultMutable = unsupported is null && !locked && !wrapperLocked && !targetBound && !ancestorBound; @@ -915,6 +956,27 @@ private void AssignFreshContentControlIds(XElement root) } } + /// + /// Freshen the identity half of Word's paragraph identity pair on a clone. Word 2013+ writes + /// w14:paraId on essentially every w:p, so refusing to clone a paragraph that + /// carries one would make repeating sections inert on real templates; a paraId is + /// package-unique, so the clone gets fresh values from the shared allocator instead. + /// + /// + /// w14:textId is deliberately left verbatim: it is a hash of the paragraph's text + /// rather than an identity, and Word itself emits the same value for two paragraphs with the + /// same content — which is exactly what a clone is. The clone gate still refuses items + /// carrying markup whose identity is semantic (bookmarks, comment and note + /// references, permissions, custom-XML and tracked-revision ranges). + /// + private void AssignFreshParagraphIds(XElement root) + { + var carriers = root.DescendantsAndSelf().Attributes(W14.paraId).ToList(); + if (carriers.Count == 0) return; + var allocator = new CommentOps.ParaIdAllocator(_doc!.MainDocumentPart!); + foreach (var attribute in carriers) attribute.SetValue(allocator.Next()); + } + private void AssignFreshDocumentPropertyIds(XElement root) { var used = OwnedPartRelationships.StoryParts(_doc!) @@ -955,11 +1017,7 @@ private void AssignFreshDocumentPropertyIds(XElement root) ContentControlW + "moveFrom", ContentControlW + "moveTo", }; var unsafeElement = item.Descendants().FirstOrDefault(element => unsafeNames.Contains(element.Name)); - if (unsafeElement is not null) return unsafeElement.Name.LocalName; - var unsafeIdentity = item.DescendantsAndSelf().Attributes().FirstOrDefault(attribute => - attribute.Name == ContentControlW14 + "paraId" - || attribute.Name == ContentControlW14 + "textId"); - return unsafeIdentity?.Name.LocalName; + return unsafeElement?.Name.LocalName; } private static IEnumerable FindDataBindings(XElement? properties) => @@ -980,11 +1038,17 @@ or ContentControlType.Checkbox or ContentControlType.Date _ => placement != ContentControlPlacement.Unknown, }; - private static bool IsWholeControlFillType(ContentControlType type) => type is + /// The families whose fill discards and rebuilds the whole w:sdtContent + /// payload, and therefore takes the bookmark-removal gate. + private static bool IsWholeContentReplacementType(ContentControlType type) => type is ContentControlType.PlainText or ContentControlType.RichText or ContentControlType.Checkbox or ContentControlType.Date - or ContentControlType.DropDownList or ContentControlType.ComboBox - or ContentControlType.Picture; + or ContentControlType.DropDownList or ContentControlType.ComboBox; + + /// Every family filled as a unit, adding picture — whose fill retargets only the + /// blip relationship and so leaves existing payload markup in place. + private static bool IsWholeControlFillType(ContentControlType type) => + IsWholeContentReplacementType(type) || type == ContentControlType.Picture; private static bool TryParseHexScalar(string? value, out int scalar) { diff --git a/Docxodus/Internal/CommentOps.cs b/Docxodus/Internal/CommentOps.cs index 5ae44eca..e5a30c2a 100644 --- a/Docxodus/Internal/CommentOps.cs +++ b/Docxodus/Internal/CommentOps.cs @@ -298,7 +298,36 @@ private static void EnsureIgnorablePrefix(XElement root, string prefix, XNamespa internal static bool ParseDone(string? value) => value is "1" or "true" or "on"; - private static string NextParaId(MainDocumentPart main) + private static string NextParaId(MainDocumentPart main) => new ParaIdAllocator(main).Next(); + + /// + /// The single owner of package-unique w14:paraId minting. Word keys comment + /// threading, coauthoring and revision identity off a paraId, so a value must be unique + /// across every part that can carry one — including the two paraId-keyed comment metadata + /// parts, whose entries outlive the paragraph they name. + /// + /// + /// A caller minting several ids for a subtree that is still detached from the + /// package (a clone not yet inserted) must share one allocator instance: each minted value + /// is retained here, so a later call cannot alias an earlier one. Calling + /// repeatedly would return the same value until each is written + /// back into a live part. + /// + internal sealed class ParaIdAllocator + { + private readonly List _used; + + internal ParaIdAllocator(MainDocumentPart main) => _used = CollectParaIds(main); + + internal string Next() + { + var value = NextEightHex(_used); + _used.Add(value); + return value; + } + } + + private static List CollectParaIds(MainDocumentPart main) { var values = new List(); foreach (var part in ReferenceHostParts(main).Append(main.WordprocessingCommentsPart)) @@ -326,7 +355,7 @@ private static string NextParaId(MainDocumentPart main) .Select(e => (string?)e.Attribute(W16Cid + "paraId")) .Where(v => !string.IsNullOrEmpty(v)).Select(v => v!)); - return NextEightHex(values); + return values; } private static string NextDurableId(XElement idsRoot) => diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md index f11f8de0..7122f5cf 100644 --- a/docs/architecture/docx_agent_server.md +++ b/docs/architecture/docx_agent_server.md @@ -214,7 +214,7 @@ problem that has no good answer at this layer. ## Tool reference -Three lifecycle tools, four read/preview tools, and eleven grouped-intent tools. Every grouped tool takes `sessionId` plus an +Three lifecycle tools, four read/preview tools, and twelve grouped-intent tools. Every grouped tool takes `sessionId` plus an `action` string; see `tools/mcp-server/ToolCatalog.cs` for the exact JSON Schema advertised over `tools/list` (this section is the narrative version). @@ -409,6 +409,44 @@ false capability claim. Image mutations are also rejected under `render_inline` because OOXML cannot represent them faithfully as this API's tracked revisions. The full core and cross-language contract is in `docs/architecture/native_images.md`. +### `docxodus_content_controls` — native Word content controls (issue #452) + +`list` accepts `scope: body|headers|footers|footnotes|endnotes|comments|all` and returns every +structured-document tag in outer-before-inner story order: the stable `sdt:` anchor, family +(`plain_text`, `rich_text`, `checkbox`, `date`, `drop_down_list`, `combo_box`, `picture`, +`repeating_section`, `repeating_section_item`, `unsupported`), placement +(`inline`/`block`/`row`/`cell`/`unknown`), owning part and scope, parent anchor and depth, the +native `w:sdtPr` metadata (`nativeId`, `tag`, `alias`, `lock`, `showingPlcHdr`), any +data-binding, current text, list item values, and an explicit `canMutate`/`unsupportedReason` +decision. + +That decision is the whole point of `list`: it evaluates the *same* gates a mutation would, in +the same order, so a plan built from the registry does not turn into a batch of guaranteed +failures. It accounts for malformed or duplicate native ids, malformed ancestors, unsupported +families and placements, nested targets, repeating-section topology, picture topology, bookmark +ranges the fill would orphan or that an internal hyperlink still targets, locks (own or +inherited), data bindings (own or inherited), **and the session's tracked-change mode** — under +`render_inline` every control reports `canMutate: false` with the tracked reason, because a +whole-control fill has no faithful tracked representation. + +`fill_text`, `fill_rich_text`, `set_checked`, `set_date`, and `select_item` replace the target's +complete `w:sdtContent` payload while preserving the wrapper and its `w:sdtPr` metadata (the +placeholder definition survives; only `w:showingPlcHdr` is cleared). They are refused for +row/cell placements, for targets containing nested controls, and when the replacement would +orphan a bookmark range or dangle an internal hyperlink. `fill_picture` retargets the blip +relationship of the single canonical embedded image a picture control owns — it does not rebuild +the payload. `add_repeating_item`/`remove_repeating_item` clone or drop one direct +`w15:repeatingSectionItem`; the clone gets fresh `w:sdtPr/w:id`, `wp:docPr` and `w14:paraId` +identities, and a section whose item carries markup whose identity is semantic (bookmarks, +comment or note references, permissions, custom-XML or tracked-revision ranges) is refused +rather than duplicated. + +`bindingPolicy` defaults to `preserve`: a data-bound control fails closed with +`content_control_bound`. `detach_target` removes only the selected control's own +`w:dataBinding`/`w15:dataBinding` element; a binding on any ancestor still fails closed, and the +custom-XML part itself is never touched. The full core and cross-language contract is in +`docs/architecture/native_content_controls.md`. + ### `docxodus_track_changes` — list/accept/reject tracked changes, switch recording mode `set_mode` (issue #304) switches how the session records its *own subsequent* edits — @@ -454,7 +492,7 @@ rebuilding the live registry after each entry; the complete operation is atomic `steps: [{ tool, args }]` where `tool` is one of `docxodus_edit`/`docxodus_format`/ `docxodus_create`/`docxodus_table`/`docxodus_list`/`docxodus_comment`/`docxodus_links`/ -`docxodus_images` (their `undo`/`redo` and +`docxodus_images`/`docxodus_content_controls` (their `undo`/`redo` and read-only actions — e.g. `get_membership`, comment `list` — are rejected as steps; a batch is a sequence of *mutations*). diff --git a/docs/architecture/native_content_controls.md b/docs/architecture/native_content_controls.md index 783cbb67..861236ca 100644 --- a/docs/architecture/native_content_controls.md +++ b/docs/architecture/native_content_controls.md @@ -18,7 +18,11 @@ their anchors are made public. Mutation also requires an exact SDT envelope: one `w:sdtPr`, one `w:sdtContent`, one `w:id`, no more than one mutually exclusive family marker, and at most one -`w:lock` carrying a native lock value. The same malformed-envelope gate applies through +`w:lock` carrying a native lock value. This envelope is deliberately **stricter than +`CT_SdtPr`**, where both `w:sdtPr` and `w:id` are `minOccurs="0"`: a spec-valid control +from a non-Word generator that omits either is enumerable but not mutable. The gate fails +closed — it never edits markup it cannot address — at the cost of refusing some +schema-legal input Word itself never emits. The same malformed-envelope gate applies through ancestors, so a valid child cannot bypass a malformed outer lock. Malformed controls stay enumerable under diagnostic anchors and fail before an undo snapshot. A repeating template is cloneable only when every nested SDT satisfies the same invariant. @@ -51,9 +55,14 @@ clearing its showing-state marker. Picture fills replace the image relationship rebuilding the wrapper. Discovery and fill share the same picture topology gate: exactly one canonical, embedded, mutable image must belong to the control, so `CanMutate` cannot advertise zero-image, multi-image, linked, or unsupported picture controls as writable. -Repeating clones freshen every nested content-control id and -drawing `docPr` id, and reject clone-sensitive bookmark, comment, permission, custom -XML container/range, move, note-reference, and `w14:paraId`/`w14:textId` markup. The +Repeating clones freshen every nested content-control id, +drawing `docPr` id, and `w14:paraId` — Word 2013+ stamps a paraId on essentially every +paragraph, so refusing to clone one would make the feature inert on real templates; +paraId is package-unique, so the clone mints fresh values from the same allocator native +comment authoring uses. `w14:textId` is deliberately copied verbatim: it is a hash of the +paragraph's text rather than an identity, and Word emits the same value for two paragraphs +with the same content. Clones still reject markup whose identity *is* semantic — +bookmark, comment, permission, custom XML container/range, move, and note-reference. The clone gate also rejects every live tracked-revision carrier recognized by the revision registry; duplicating such markup would duplicate its native revision ids and make later resolution ambiguous. The final item cannot be removed. @@ -95,10 +104,23 @@ removed appears in `Removed`. ## Locks, bindings, and nesting -Content locks are effective through ancestors. A locked target or ancestor fails +Content locks are effective through ancestors **for the content-control operations on +this page**. `w:lock` is not yet consulted by the generic anchor-addressed surface: an +op such as `ReplaceTextAtSpan` or `DeleteRange` still edits through a `contentLocked` +control. Honouring `w:lock` document-wide is a separate change to the generic +mutation path and is outside issue #452. A locked target or ancestor fails without changing history. A whole-content replacement that would discard a nested control is also refused; callers address the nested child directly. +`CanMutate` is the single honest answer to "would a mutation succeed?", and discovery +evaluates the same gates the mutation does, in the same order. That includes the +session-level ones: under `render_inline` tracked-change mode every control reports +`CanMutate: false` with the tracked reason, and a whole-content replacement whose +bookmark ranges the fill would orphan — or that an internal hyperlink still targets — +is reported unmutable before it is attempted. A registry that advertised a mutation the +session is guaranteed to refuse is worse than no registry: an agent planning off it +builds a batch that cannot apply. + For repeating sections, `CanMutate` describes the default operation honestly: a section must have a safe final clone template, and an item is removable only when it is a direct child, at least one sibling item will remain, and its wrapper is not diff --git a/npm/tests/docx-session-content-controls.spec.ts b/npm/tests/docx-session-content-controls.spec.ts new file mode 100644 index 00000000..c1c47e28 --- /dev/null +++ b/npm/tests/docx-session-content-controls.spec.ts @@ -0,0 +1,221 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const TEST_FILES_DIR = path.join(__dirname, '../../TestFiles'); + +function readTestFile(relativePath: string): Uint8Array { + return new Uint8Array(fs.readFileSync(path.join(TEST_FILES_DIR, relativePath))); +} + +async function waitForDocxodus(page: Page) { + await page.waitForFunction(() => (window as any).DocxodusReady === true, { timeout: 30000 }); +} + +// A PNG signature + IHDR is all the bridge's format/dimension sniffing needs. +function png(width: number, height: number): number[] { + const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]; + for (const value of [width, height]) { + bytes.push((value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff); + } + return bytes; +} + +// Issue #452 — native content controls across the WASM bridge. HC030 is Word-authored and +// carries five controls: rich text, plain text, picture, checkbox, combo box. +test.describe('DocxSession content controls (WASM bridge)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/test-harness.html'); + await waitForDocxodus(page); + }); + + test('ListContentControls decodes the Word-authored registry and honors scopes', async ({ page }) => { + const bytes = readTestFile('HC030-Content-Controls.docx'); + + const result = await page.evaluate(async (bytesArray: number[]) => { + const bridge = (window as any).Docxodus.DocxSessionBridge; + const handle = bridge.OpenSession(new Uint8Array(bytesArray), ''); + try { + const all = JSON.parse(bridge.ListContentControls(handle, 0x3f)); + const body = JSON.parse(bridge.ListContentControls(handle, 0x01)); + const headers = JSON.parse(bridge.ListContentControls(handle, 0x02)); + return { + types: all.map((c: any) => c.type), + placements: all.map((c: any) => c.placement), + anchors: all.map((c: any) => c.anchorId), + scopes: all.map((c: any) => c.scope), + canMutate: all.map((c: any) => c.canMutate), + unsupported: all.map((c: any) => c.unsupportedReason ?? null), + bodyCount: body.length, + headerCount: headers.length, + comboItems: all.find((c: any) => c.type === 'combo_box')?.itemValues, + }; + } finally { + bridge.CloseSession(handle); + } + }, Array.from(bytes)); + + expect(result.types).toEqual(['rich_text', 'plain_text', 'picture', 'checkbox', 'combo_box']); + expect(result.placements).toEqual(['block', 'inline', 'block', 'block', 'block']); + expect(result.anchors.every((a: string) => a.startsWith('sdt:body:'))).toBe(true); + expect(result.scopes).toEqual(['body', 'body', 'body', 'body', 'body']); + expect(result.canMutate).toEqual([true, true, true, true, true]); + expect(result.unsupported).toEqual([null, null, null, null, null]); + expect(result.bodyCount).toBe(5); + expect(result.headerCount).toBe(0); + expect(result.comboItems).toEqual(['One', 'Two', 'Three']); + }); + + test('every fill route mutates through the bridge and survives save/reopen', async ({ page }) => { + const bytes = readTestFile('HC030-Content-Controls.docx'); + + const result = await page.evaluate( + async ({ bytesArray, image }: { bytesArray: number[]; image: number[] }) => { + const bridge = (window as any).Docxodus.DocxSessionBridge; + const handle = bridge.OpenSession(new Uint8Array(bytesArray), ''); + try { + const controls = JSON.parse(bridge.ListContentControls(handle, 0x3f)); + const byType = (type: string) => + controls.find((c: any) => c.type === type).anchorId as string; + const plain = byType('plain_text'); + const rich = byType('rich_text'); + const checkbox = byType('checkbox'); + const combo = byType('combo_box'); + const picture = byType('picture'); + const options = '{}'; + + const text = JSON.parse( + bridge.FillContentControlText(handle, plain, 'bridge plain value', options), + ); + const markdown = JSON.parse( + bridge.FillContentControlRichText(handle, rich, 'bridge **rich** value', options), + ); + const checked = JSON.parse( + bridge.SetContentControlChecked(handle, checkbox, true, options), + ); + const selected = JSON.parse( + bridge.SelectContentControlItem(handle, combo, 'Three', options), + ); + const filledPicture = JSON.parse( + bridge.FillContentControlPicture( + handle, + picture, + btoa(String.fromCharCode(...image)), + options, + ), + ); + + const saved = bridge.Save(handle); + const reopenedHandle = bridge.OpenSession(saved, ''); + let reopened: any[] = []; + try { + reopened = JSON.parse(bridge.ListContentControls(reopenedHandle, 0x3f)); + } finally { + bridge.CloseSession(reopenedHandle); + } + + return { + successes: [text, markdown, checked, selected, filledPicture].map((r) => r.success), + errors: [text, markdown, checked, selected, filledPicture].map( + (r) => r.error?.code ?? null, + ), + modifiedIsTarget: text.modified?.[0]?.id === plain, + // The anchor derives from the native w:sdtPr/w:id, so it survives a clean save. + reopenedText: Object.fromEntries(reopened.map((c: any) => [c.anchorId, c.text])), + plain, + rich, + checkbox, + combo, + }; + } finally { + bridge.CloseSession(handle); + } + }, + { bytesArray: Array.from(bytes), image: png(4, 5) }, + ); + + expect(result.errors).toEqual([null, null, null, null, null]); + expect(result.successes).toEqual([true, true, true, true, true]); + expect(result.modifiedIsTarget).toBe(true); + expect(result.reopenedText[result.plain]).toBe('bridge plain value'); + expect(result.reopenedText[result.rich]).toBe('bridge rich value'); + expect(result.reopenedText[result.checkbox]).toBe('☒'); + expect(result.reopenedText[result.combo]).toBe('Three'); + }); + + test('the date and repeating-section routes are wired and typed', async ({ page }) => { + const bytes = readTestFile('HC030-Content-Controls.docx'); + + // HC030 has no date or repeating-section control, so these routes are proved reachable + // by the engine's typed rejection of a well-formed call against a wrong-typed target. + const result = await page.evaluate(async (bytesArray: number[]) => { + const bridge = (window as any).Docxodus.DocxSessionBridge; + const handle = bridge.OpenSession(new Uint8Array(bytesArray), ''); + try { + const controls = JSON.parse(bridge.ListContentControls(handle, 0x3f)); + const plain = controls.find((c: any) => c.type === 'plain_text').anchorId as string; + + return { + wrongTypeDate: JSON.parse( + bridge.SetContentControlDate(handle, plain, '2026-08-14T00:00:00Z', null, '{}'), + ).error?.code, + badDateValue: JSON.parse( + bridge.SetContentControlDate(handle, plain, 'not-a-timestamp', 'August 2026', '{}'), + ).error?.code, + addItem: JSON.parse(bridge.AddRepeatingSectionItem(handle, plain, '', '{}')).error?.code, + removeItem: JSON.parse(bridge.RemoveRepeatingSectionItem(handle, plain)).error?.code, + unknownAnchor: JSON.parse( + bridge.RemoveRepeatingSectionItem(handle, 'sdt:body:deadbeef'), + ).error?.code, + badOptions: JSON.parse( + bridge.FillContentControlText(handle, plain, 'x', '{"bindingPolicy":"nonsense"}'), + ).error?.code, + }; + } finally { + bridge.CloseSession(handle); + } + }, Array.from(bytes)); + + expect(result.wrongTypeDate).toBe('content_control_wrong_type'); + expect(result.badDateValue).toBe('invalid_content_control_value'); + expect(result.addItem).toBe('content_control_wrong_type'); + expect(result.removeItem).toBe('content_control_wrong_type'); + expect(result.unknownAnchor).toBe('content_control_not_found'); + expect(result.badOptions).toBe('invalid_content_control_value'); + }); + + test('render_inline tracked mode is reported by the registry, not only at mutation time', async ({ + page, + }) => { + const bytes = readTestFile('HC030-Content-Controls.docx'); + + const result = await page.evaluate(async (bytesArray: number[]) => { + const bridge = (window as any).Docxodus.DocxSessionBridge; + const handle = bridge.OpenSession(new Uint8Array(bytesArray), ''); + try { + const before = JSON.parse(bridge.ListContentControls(handle, 0x3f)); + bridge.SetTrackedChanges(handle, 1); // TrackedChangeMode.RenderInline + const tracked = JSON.parse(bridge.ListContentControls(handle, 0x3f)); + const plain = tracked.find((c: any) => c.type === 'plain_text').anchorId as string; + const attempted = JSON.parse(bridge.FillContentControlText(handle, plain, 'x', '{}')); + return { + beforeMutable: before.map((c: any) => c.canMutate), + trackedMutable: tracked.map((c: any) => c.canMutate), + trackedReasons: tracked.map((c: any) => c.unsupportedReason ?? ''), + attemptedCode: attempted.error?.code, + }; + } finally { + bridge.CloseSession(handle); + } + }, Array.from(bytes)); + + expect(result.beforeMutable).toEqual([true, true, true, true, true]); + // Discovery must agree with what a fill actually does — an agent plans off this registry. + expect(result.trackedMutable).toEqual([false, false, false, false, false]); + expect(result.trackedReasons.every((r: string) => r.includes('tracked revisions'))).toBe(true); + expect(result.attemptedCode).toBe('tracked_operation_unsupported'); + }); +}); diff --git a/python/tests/test_content_controls.py b/python/tests/test_content_controls.py new file mode 100644 index 00000000..c655e6fe --- /dev/null +++ b/python/tests/test_content_controls.py @@ -0,0 +1,217 @@ +"""Native content controls end-to-end through the stdio host (issue #452). + +`test_content_control_types.py` covers wire *decoding* only. This module drives every +one of the nine content-control routes across the real `docxodus-pyhost` subprocess, so +a typo in a route name or an argument key fails here instead of shipping: an unknown op +raises out of the host rather than returning an `EditResult`, and a misspelled argument +key raises a `FormatException` rather than reaching `DocxSessionOps`. + +The fixture is HC030 — Word-authored, five controls (rich text, plain text, picture, +checkbox, combo box). It carries no date or repeating-section control, so those three +routes are proved reachable by asserting the *engine's* typed rejection of a +well-formed call against a wrong-typed target. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +import pytest + +from docx_scalpel import ( + ContentControlBindingPolicy, + ContentControlFillOptions, + ContentControlPlacement, + ContentControlType, + DocxSession, + ProjectionScopes, + open_session, +) +from docx_scalpel.enums import EditErrorCode + + +def _png(width: int, height: int) -> bytes: + """A minimal PNG signature + IHDR — enough for the host's format/dimension sniffing.""" + return ( + bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + + bytes([0, 0, 0, 13]) + + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + ) + + +@pytest.fixture(scope="session") +def content_control_bytes(test_files_dir: Path) -> bytes: + return (test_files_dir / "HC030-Content-Controls.docx").read_bytes() + + +@pytest.fixture +def session(content_control_bytes: bytes) -> Iterator[DocxSession]: + s = open_session(content_control_bytes) + try: + yield s + finally: + s.close() + + +def _only(session: DocxSession, kind: ContentControlType) -> str: + matches = [c for c in session.list_content_controls() if c.type is kind] + assert len(matches) == 1, f"expected exactly one {kind}, got {len(matches)}" + return matches[0].anchor_id + + +def test_list_content_controls_decodes_the_word_authored_registry(session: DocxSession) -> None: + controls = session.list_content_controls() + + assert [c.type for c in controls] == [ + ContentControlType.RICH_TEXT, + ContentControlType.PLAIN_TEXT, + ContentControlType.PICTURE, + ContentControlType.CHECKBOX, + ContentControlType.COMBO_BOX, + ] + assert all(c.anchor_id.startswith("sdt:body:") for c in controls) + assert all(c.scope == "body" for c in controls) + assert all(c.owning_part_uri.endswith("document.xml") for c in controls) + assert [c.placement for c in controls] == [ + ContentControlPlacement.BLOCK, + ContentControlPlacement.INLINE, + ContentControlPlacement.BLOCK, + ContentControlPlacement.BLOCK, + ContentControlPlacement.BLOCK, + ] + assert all(c.has_valid_native_id and not c.has_duplicate_native_id for c in controls) + assert all(c.can_mutate for c in controls), [c.unsupported_reason for c in controls] + assert all(not c.is_bound and c.binding is None for c in controls) + assert [c.item_values for c in controls if c.type is ContentControlType.COMBO_BOX] == [ + ("One", "Two", "Three") + ] + + +def test_list_content_controls_honors_the_scopes_argument(session: DocxSession) -> None: + assert len(session.list_content_controls(ProjectionScopes.BODY)) == 5 + assert session.list_content_controls(ProjectionScopes.HEADERS) == () + assert session.list_content_controls(ProjectionScopes.FOOTERS) == () + + +def test_fill_text_and_rich_text_round_trip_through_the_host(session: DocxSession) -> None: + plain = _only(session, ContentControlType.PLAIN_TEXT) + rich = _only(session, ContentControlType.RICH_TEXT) + + text = session.fill_content_control_text(plain, "wired plain value") + assert text.success, text.error + assert [a.id for a in text.modified] == [plain] + + markdown = session.fill_content_control_rich_text(rich, "wired **rich** value") + assert markdown.success, markdown.error + + by_anchor = {c.anchor_id: c for c in session.list_content_controls()} + assert by_anchor[plain].text == "wired plain value" + assert by_anchor[rich].text == "wired rich value" + + +def test_set_checked_and_select_item_persist_native_state(session: DocxSession) -> None: + checkbox = _only(session, ContentControlType.CHECKBOX) + combo = _only(session, ContentControlType.COMBO_BOX) + + checked = session.set_content_control_checked(checkbox, True) + assert checked.success, checked.error + + selected = session.select_content_control_item(combo, "wired combo value") + assert selected.success, selected.error + + by_anchor = {c.anchor_id: c for c in session.list_content_controls()} + assert by_anchor[checkbox].text == "☒" + assert by_anchor[combo].text == "wired combo value" + + +def test_fill_picture_accepts_base64_bytes(session: DocxSession) -> None: + picture = _only(session, ContentControlType.PICTURE) + + result = session.fill_content_control_picture(picture, _png(4, 5)) + + assert result.success, result.error + assert [a.id for a in result.modified] == [picture] + + +def test_fill_picture_rejects_non_image_bytes_without_touching_the_session( + session: DocxSession, +) -> None: + picture = _only(session, ContentControlType.PICTURE) + before = session.list_content_controls() + + result = session.fill_content_control_picture(picture, b"not an image at all") + + assert not result.success + assert result.error is not None + assert session.list_content_controls() == before + + +def test_set_date_route_is_wired_and_validates_its_value_argument(session: DocxSession) -> None: + plain = _only(session, ContentControlType.PLAIN_TEXT) + + # A well-formed call reaches the engine, which rejects the wrong family. + wrong_type = session.set_content_control_date(plain, "2026-08-14T00:00:00Z") + assert wrong_type.error is not None + assert wrong_type.error.code is EditErrorCode.CONTENT_CONTROL_WRONG_TYPE + + # displayText is optional and the value is parsed host-side, not passed through raw. + bad_value = session.set_content_control_date(plain, "not-a-timestamp", "August 2026") + assert bad_value.error is not None + assert bad_value.error.code is EditErrorCode.INVALID_CONTENT_CONTROL_VALUE + + +def test_repeating_section_routes_are_wired_and_typed(session: DocxSession) -> None: + plain = _only(session, ContentControlType.PLAIN_TEXT) + + add = session.add_repeating_section_item(plain) + assert add.error is not None + assert add.error.code is EditErrorCode.CONTENT_CONTROL_WRONG_TYPE + + add_after = session.add_repeating_section_item(plain, after_item_anchor_id=plain) + assert add_after.error is not None + assert add_after.error.code is EditErrorCode.CONTENT_CONTROL_WRONG_TYPE + + remove = session.remove_repeating_section_item(plain) + assert remove.error is not None + assert remove.error.code is EditErrorCode.CONTENT_CONTROL_WRONG_TYPE + + missing = session.remove_repeating_section_item("sdt:body:deadbeef") + assert missing.error is not None + assert missing.error.code is EditErrorCode.CONTENT_CONTROL_NOT_FOUND + + +def test_binding_policy_option_crosses_the_wire_on_every_fill(session: DocxSession) -> None: + plain = _only(session, ContentControlType.PLAIN_TEXT) + detach = ContentControlFillOptions(ContentControlBindingPolicy.DETACH_TARGET) + + # HC030 has no bindings, so detach_target is a no-op the fill must still accept. + result = session.fill_content_control_text(plain, "detach-policy value", detach) + + assert result.success, result.error + assert {c.anchor_id: c.text for c in session.list_content_controls()}[plain] == ( + "detach-policy value" + ) + + +def test_content_control_fills_are_undoable_and_survive_save_reopen( + session: DocxSession, content_control_bytes: bytes +) -> None: + plain = _only(session, ContentControlType.PLAIN_TEXT) + original = {c.anchor_id: c.text for c in session.list_content_controls()}[plain] + + assert session.fill_content_control_text(plain, "persisted value").success + saved = session.save() + assert session.undo() + assert {c.anchor_id: c.text for c in session.list_content_controls()}[plain] == original + + reopened = open_session(saved) + try: + # The anchor is derived from the native w:sdtPr/w:id, so it survives a clean save. + assert {c.anchor_id: c.text for c in reopened.list_content_controls()}[plain] == ( + "persisted value" + ) + finally: + reopened.close() diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 9fae7be9..fed68ffc 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -10,7 +10,7 @@ namespace Docxodus.McpServer; internal sealed record ToolDefinition(string Name, string Description, string InputSchemaJson); /// -/// The tool surface this server advertises: three lifecycle tools (open/save/close) plus fifteen +/// The tool surface this server advertises: three lifecycle tools (open/save/close) plus sixteen /// read or grouped-intent tools. Grouped tools accept an action discriminator and /// action-specific arguments. See docs/architecture/docx_agent_server.md for the full contract, the /// mapping of every action onto the underlying Docxodus API, and the documented capability gaps.