diff --git a/CHANGELOG.md b/CHANGELOG.md index 04fa1b48..44e5657c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,19 @@ All notable changes to this project will be documented in this file. evaluation, counting, and the whole multi-match rewrite share one mutation gate and one undo snapshot, so duplicate text cannot turn a stale plan into a partial replacement. +- **First-class hyperlinks and bookmarks across every editing surface (#448/#451/#469/#470).** + `DocxSession` can enumerate and mutate external or bookmark-target hyperlinks and paired, + multi-paragraph bookmarks with exact character spans. External relationships are owned and + reused by the containing body/header/footer/footnote/endnote part; internal links use + `w:anchor` without a package relationship. Rename retargets inbound links atomically, removal + refuses live targets, malformed/cross-part ranges return structured errors, destructive edits + cannot orphan markers, and undo/redo restores relationship topology. The same contract is + exposed through JSON ops, WASM/npm, stdio/Python, and MCP (`docxodus_links`); Markdown links now + use the same owner-aware promotion and orphan cleanup. Coverage includes Open XML validation, + save/reopen identity, exact run-format boundaries, repeated story-scoped bookmark ids, tracked + limitations, and relationship cleanup. This supersedes the earlier tracked-move clone policy: + a tracked block move containing bookmark markers now fails before snapshot instead of creating + two simultaneously-live copies of a globally unique bookmark name. - **Complete inspect-before-edit formatting surface (#448).** `DocxSession` now exposes an explicit style catalog (`ListStyles`), direct-versus-effective paragraph/run formatting (`GetFormatting`), and enumerable mutation-compatible run spans (`ListInlineSpans`). Effective diff --git a/Docxodus.Tests/DocxSessionLinkBookmarkTests.cs b/Docxodus.Tests/DocxSessionLinkBookmarkTests.cs new file mode 100644 index 00000000..4a1c6aee --- /dev/null +++ b/Docxodus.Tests/DocxSessionLinkBookmarkTests.cs @@ -0,0 +1,590 @@ +// 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.Json; +using System.Xml.Linq; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Validation; +using Docxodus; +using Xunit; + +namespace Docxodus.Tests; + +public class DocxSessionLinkBookmarkTests +{ + 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 string[] Paragraphs(DocxSession session, string scope = "body") => + session.Project().AnchorIndex.Values + .Where(a => a.Anchor.Scope == scope && a.Anchor.Kind is "p" or "h" or "li") + .Select(a => a.Anchor.Id).Distinct().ToArray(); + + [Fact] + public void LB001_ExternalCrud_ReusesOwnerRelationship_AndCleansOnlyAfterLastReference() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchors = Paragraphs(session); + + var first = session.AddHyperlink(anchors[0], new CharSpan(0, 5), + HyperlinkTarget.External("https://example.test/shared")); + var second = session.AddHyperlink(anchors[1], new CharSpan(0, 6), + HyperlinkTarget.External("https://example.test/shared")); + + Assert.True(first.Success, first.Error?.Message); + Assert.True(second.Success, second.Error?.Message); + var links = session.ListHyperlinks(); + Assert.Equal(2, links.Count); + Assert.Single(links.Select(l => l.RelationshipId).Distinct()); + Assert.Single(HyperlinkRelationships(session.Save(true), m => m)); + + Assert.True(session.RemoveHyperlink(first.HyperlinkId!).Success); + Assert.Single(HyperlinkRelationships(session.Save(true), m => m)); + Assert.True(session.RemoveHyperlink(second.HyperlinkId!).Success); + Assert.Empty(HyperlinkRelationships(session.Save(true), m => m)); + } + + [Fact] + public void LB002_InternalLink_IsRelationshipFree_RenameRetargetsCrossPart_AndRemoveCannotDangle() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = Paragraphs(session); + Assert.True(session.AddBookmark("TargetOne", DocumentRange.In(body[0], new CharSpan(0, 5))).Success); + Assert.True(session.SetHeaderText(body[0], HeaderFooterKind.Default, "jump").Success); + var header = Assert.Single(Paragraphs(session, "hdr1")); + + var add = session.AddHyperlink(header, new CharSpan(0, 4), HyperlinkTarget.Internal("TargetOne")); + Assert.True(add.Success, add.Error?.Message); + var link = Assert.Single(session.ListHyperlinks(ProjectionScopes.Headers)); + Assert.Equal(HyperlinkKind.Internal, link.Kind); + Assert.Null(link.RelationshipId); + Assert.Empty(HyperlinkRelationships(session.Save(true), m => m.HeaderParts.Single())); + + Assert.True(session.RenameBookmark("TargetOne", "TargetTwo").Success); + Assert.Equal("TargetTwo", Assert.Single(session.ListHyperlinks(ProjectionScopes.Headers)).Target); + var blocked = session.RemoveBookmark("TargetTwo"); + Assert.False(blocked.Success); + Assert.Equal(EditErrorCode.BookmarkInUse, blocked.Error!.Code); + + Assert.True(session.UpdateHyperlink(add.HyperlinkId!, + HyperlinkTarget.External("https://example.test/out")).Success); + Assert.True(session.RemoveBookmark("TargetTwo").Success); + Assert.Single(HyperlinkRelationships(session.Save(true), m => m.HeaderParts.Single())); + } + + [Theory] + [InlineData(0)] + [InlineData(5)] + [InlineData(16)] + public void LB003_CollapsedBookmark_StartAlwaysPrecedesEnd(int offset) + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = Paragraphs(session)[0]; + var result = session.AddBookmark("Point" + offset, + new DocumentRange(anchor, offset, anchor, offset)); + Assert.True(result.Success, result.Error?.Message); + + var saved = session.Save(); + using var doc = WordprocessingDocument.Open(new MemoryStream(saved), false); + var paragraph = doc.MainDocumentPart!.GetXDocument().Descendants(W + "p").First(); + var nodes = paragraph.DescendantsAndSelf().ToList(); + var start = nodes.Single(e => e.Name == W + "bookmarkStart"); + var end = nodes.Single(e => e.Name == W + "bookmarkEnd"); + Assert.True(XNode.DocumentOrderComparer.Compare(start, end) < 0); + } + + [Fact] + public void LB004_MultiParagraphBookmark_EnumeratesPreciseSegments_AndMoveKeepsPairId() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchors = Paragraphs(session); + Assert.True(session.AddBookmark("AcrossParas", + new DocumentRange(anchors[0], 6, anchors[1], 6)).Success); + var before = Assert.Single(session.ListBookmarks()); + Assert.True(before.IsValid); + Assert.Equal(2, before.Segments.Count); + Assert.Equal("paragraph.\nSecond", before.Text); + + Assert.True(session.MoveBookmark("AcrossParas", DocumentRange.In(anchors[1], new CharSpan(7, 9))).Success); + var after = Assert.Single(session.ListBookmarks()); + Assert.Equal(before.BookmarkId, after.BookmarkId); + Assert.Equal("paragraph", after.Text); + } + + [Fact] + public void LB005_CrossPartBookmarkMutation_IsStructuredUnsupported() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = Paragraphs(session); + Assert.True(session.SetHeaderText(body[0], HeaderFooterKind.Default, "header").Success); + var header = Assert.Single(Paragraphs(session, "hdr1")); + + var result = session.AddBookmark("CrossPart", + new DocumentRange(body[0], 0, header, 1)); + Assert.False(result.Success); + Assert.Equal(EditErrorCode.UnsupportedInlineBoundary, result.Error!.Code); + Assert.Empty(session.ListBookmarks()); + } + + [Fact] + public void LB006_MarkdownInternalLink_WritesAnchorNotRelationship_AndMissingTargetIsStructured() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchors = Paragraphs(session); + Assert.True(session.AddBookmark("Clause", DocumentRange.In(anchors[0], new CharSpan(0, 5))).Success); + + var ok = session.ReplaceText(anchors[1], "[go](#Clause)"); + Assert.True(ok.Success, ok.Error?.Message); + var link = Assert.Single(session.ListHyperlinks()); + Assert.Equal(HyperlinkKind.Internal, link.Kind); + Assert.Equal("Clause", link.Target); + Assert.Empty(HyperlinkRelationships(session.Save(true), m => m)); + + var missing = session.ReplaceText(anchors[1], "[bad](#Missing)"); + Assert.False(missing.Success); + Assert.Equal(EditErrorCode.MissingBookmarkTarget, missing.Error!.Code); + } + + [Fact] + public void LB007_UndoRestoresRelationshipTopology_AndPersistedIdsRoundTrip() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = Paragraphs(session)[0]; + var add = session.AddHyperlink(anchor, new CharSpan(0, 5), + HyperlinkTarget.External("https://example.test/a")); + Assert.True(add.Success, add.Error?.Message); + var persisted = session.Save(true); + using (var reopened = new DocxSession(persisted)) + Assert.Equal(add.HyperlinkId, Assert.Single(reopened.ListHyperlinks()).Id); + + Assert.True(session.RemoveHyperlink(add.HyperlinkId!).Success); + Assert.Empty(HyperlinkRelationships(session.Save(true), m => m)); + Assert.True(session.Undo()); + Assert.Single(HyperlinkRelationships(session.Save(true), m => m)); + Assert.Equal(add.HyperlinkId, Assert.Single(session.ListHyperlinks()).Id); + } + + [Fact] + public void LB008_HighOrphanEndId_IsNotReused_AndSavedPackageValidates() + { + var bytes = DocxSessionTests.BuildDS001_SimpleTwoParagraphs(); + bytes = MutatePackage(bytes, doc => + { + doc.MainDocumentPart!.GetXDocument().Descendants(W + "p").First() + .Add(new XElement(W + "bookmarkEnd", new XAttribute(W + "id", "99"))); + doc.MainDocumentPart.PutXDocument(); + }); + + using var session = new DocxSession(bytes); + var anchor = Paragraphs(session)[0]; + Assert.True(session.AddBookmark("Fresh", DocumentRange.In(anchor, new CharSpan(0, 1))).Success); + Assert.Equal("100", Assert.Single(session.ListBookmarks()).BookmarkId); + var saved = session.Save(); + using var reopenedStream = new MemoryStream(saved); + using var reopened = WordprocessingDocument.Open(reopenedStream, false); + var realErrors = new OpenXmlValidator().Validate(reopened) + .Where(e => !(e.Description ?? string.Empty).Contains("powertools.codeplex.com", StringComparison.Ordinal)) + .ToList(); + Assert.Empty(realErrors); + } + + [Fact] + public void LB010_DuplicateNumericIdsInDifferentParts_DoNotConfuseMoveOrRemove() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var bodyAnchors = Paragraphs(seed); + Assert.True(seed.AddBookmark("BodyMark", DocumentRange.In(bodyAnchors[0], new CharSpan(0, 1))).Success); + Assert.True(seed.SetHeaderText(bodyAnchors[0], HeaderFooterKind.Default, "header").Success); + var headerAnchor = Assert.Single(Paragraphs(seed, "hdr1")); + Assert.True(seed.AddBookmark("HeaderMark", DocumentRange.In(headerAnchor, new CharSpan(0, 1))).Success); + var bytes = seed.Save(); + + bytes = MutatePackage(bytes, doc => + { + var main = doc.MainDocumentPart!; + var bodyId = (string)main.GetXDocument() + .Descendants(W + "bookmarkStart").Single().Attribute(W + "id")!; + var header = main.HeaderParts.Single(); + foreach (var marker in header.GetXDocument().Descendants() + .Where(e => e.Name == W + "bookmarkStart" || e.Name == W + "bookmarkEnd")) + marker.SetAttributeValue(W + "id", bodyId); + header.PutXDocument(); + }); + + using var session = new DocxSession(bytes); + var anchors = Paragraphs(session); + Assert.Equal(2, session.ListBookmarks().Count); + Assert.True(session.MoveBookmark("BodyMark", DocumentRange.In(anchors[1], new CharSpan(0, 1))).Success); + Assert.True(session.RemoveBookmark("BodyMark").Success); + var survivor = Assert.Single(session.ListBookmarks()); + Assert.Equal("HeaderMark", survivor.Name); + Assert.True(survivor.IsPaired); + } + + [Fact] + public void LB009_DeleteLinkedBlock_CleansItsOrphan_ButKeepsSharedLiveRelationship() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchors = Paragraphs(session); + Assert.True(session.AddHyperlink(anchors[0], new CharSpan(0, 5), + HyperlinkTarget.External("https://example.test/shared")).Success); + Assert.True(session.AddHyperlink(anchors[1], new CharSpan(0, 6), + HyperlinkTarget.External("https://example.test/shared")).Success); + + Assert.True(session.DeleteBlock(anchors[0]).Success); + Assert.Single(HyperlinkRelationships(session.Save(true), m => m)); + Assert.True(session.DeleteBlock(anchors[1]).Success); + Assert.Empty(HyperlinkRelationships(session.Save(true), m => m)); + } + + [Fact] + public void LB011_ReplaceCellContent_RejectsDeletingTargetedBookmark() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = Paragraphs(session); + var inserted = session.InsertTable(body[0], Position.After, 1, 1, + new TableInsertOptions { CellContents = new[] { "Cell text" } }); + Assert.True(inserted.Success, inserted.Error?.Message); + // #450 reports canonical table identities through TableAnchors; locate the new + // mutation-ready paragraph independently of that structural result envelope. + var cellParagraph = Assert.Single(Paragraphs(session).Except(body)); + Assert.True(session.AddBookmark("CellTarget", + DocumentRange.In(cellParagraph, new CharSpan(0, 4))).Success); + Assert.True(session.AddHyperlink(body[1], new CharSpan(0, 6), + HyperlinkTarget.Internal("CellTarget")).Success); + + var cell = session.Project().AnchorIndex.Keys.Single(id => id.StartsWith("tc:", StringComparison.Ordinal)); + var blocked = session.ReplaceCellContent(cell, "Replacement"); + Assert.False(blocked.Success); + Assert.Equal(EditErrorCode.BookmarkInUse, blocked.Error!.Code); + Assert.Equal("CellTarget", Assert.Single(session.ListBookmarks()).Name); + } + + [Fact] + public void LB012_SetHeaderText_RejectsDeletingTargetedBookmark() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = Paragraphs(session); + Assert.True(session.SetHeaderText(body[0], HeaderFooterKind.Default, "Header target").Success); + var header = Assert.Single(Paragraphs(session, "hdr1")); + Assert.True(session.AddBookmark("HeaderTarget", + DocumentRange.In(header, new CharSpan(0, 6))).Success); + Assert.True(session.AddHyperlink(body[1], new CharSpan(0, 6), + HyperlinkTarget.Internal("HeaderTarget")).Success); + + var blocked = session.SetHeaderText(body[0], HeaderFooterKind.Default, "Replacement"); + Assert.False(blocked.Success); + Assert.Equal(EditErrorCode.BookmarkInUse, blocked.Error!.Code); + Assert.Equal("HeaderTarget", Assert.Single(session.ListBookmarks()).Name); + } + + [Fact] + public void LB013_WholeParagraphReplacement_RetainsBookmarkCoordinatesAndClampsEnd() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = Paragraphs(session)[0]; + Assert.True(session.AddBookmark("StableRange", + DocumentRange.In(anchor, new CharSpan(3, 5))).Success); + + Assert.True(session.ReplaceText(anchor, "abcdef").Success); + var bookmark = Assert.Single(session.ListBookmarks()); + Assert.Equal(new CharSpan(3, 3), Assert.Single(bookmark.Segments).Span); + Assert.Equal("def", bookmark.Text); + } + + [Fact] + public void LB014_MarkdownLinks_OwnRelationshipsInFooterFootnoteAndEndnoteParts() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = Paragraphs(session); + Assert.True(session.SetFooterText(body[0], HeaderFooterKind.Default, + "[footer](https://example.test/footer)").Success); + Assert.True(session.InsertFootnote(body[0], 1, + "[footnote](https://example.test/footnote)").Success); + Assert.True(session.InsertEndnote(body[1], 1, + "[endnote](https://example.test/endnote)").Success); + + var links = session.ListHyperlinks(); + Assert.Contains(links, link => link.Scope.StartsWith("ftr", StringComparison.Ordinal)); + Assert.Contains(links, link => link.Scope == "fn"); + Assert.Contains(links, link => link.Scope == "en"); + var saved = session.Save(true); + Assert.Empty(HyperlinkRelationships(saved, m => m)); + Assert.Single(HyperlinkRelationships(saved, m => m.FooterParts.Single())); + Assert.Single(HyperlinkRelationships(saved, m => m.FootnotesPart!)); + Assert.Single(HyperlinkRelationships(saved, m => m.EndnotesPart!)); + } + + [Fact] + public void LB015_PartialFormattedSpan_PreservesRunProperties_AndTrackedMetadataOpsRejectCleanly() + { + using (var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs())) + { + var anchor = Paragraphs(session)[0]; + Assert.True(session.ReplaceText(anchor, "**Hello** world").Success); + Assert.True(session.AddHyperlink(anchor, new CharSpan(1, 3), + HyperlinkTarget.External("https://example.test/formatted")).Success); + using var doc = WordprocessingDocument.Open(new MemoryStream(session.Save()), false); + var paragraph = doc.MainDocumentPart!.GetXDocument().Descendants(W + "p").First(); + var linkRun = paragraph.Descendants(W + "hyperlink").Single().Element(W + "r")!; + Assert.NotNull(linkRun.Element(W + "rPr")?.Element(W + "b")); + Assert.Equal("ell", linkRun.Value); + Assert.Equal("H", paragraph.Elements(W + "r").First().Value); + } + + var settings = new DocxSessionSettings { TrackedChanges = TrackedChangeMode.RenderInline }; + using var tracked = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs(), settings); + var trackedAnchor = Paragraphs(tracked)[0]; + Assert.Equal(EditErrorCode.TrackedOperationUnsupported, + tracked.AddHyperlink(trackedAnchor, new CharSpan(0, 1), + HyperlinkTarget.External("https://example.test")).Error!.Code); + Assert.Equal(EditErrorCode.TrackedOperationUnsupported, + tracked.AddBookmark("TrackedBookmark", + DocumentRange.In(trackedAnchor, new CharSpan(0, 1))).Error!.Code); + Assert.Empty(tracked.ListHyperlinks()); + Assert.Empty(tracked.ListBookmarks()); + } + + [Fact] + public void LB016_TrackedWholeReplacementWithBookmark_RejectsBeforeSnapshot_ThenAcceptModeWorks() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = Paragraphs(session)[0]; + Assert.True(session.AddBookmark("TrackedBoundary", + DocumentRange.In(anchor, new CharSpan(3, 5))).Success); + var before = Assert.Single(session.ListBookmarks()); + int undoCount = session.UndoCount; + + session.SetTrackedChanges(TrackedChangeMode.RenderInline); + var blocked = session.ReplaceText(anchor, "abcdef"); + Assert.False(blocked.Success); + Assert.Equal(EditErrorCode.TrackedOperationUnsupported, blocked.Error!.Code); + Assert.Equal(undoCount, session.UndoCount); + Assert.Empty(session.ListRevisions()); + var unchanged = Assert.Single(session.ListBookmarks()); + Assert.Equal(before.Range, unchanged.Range); + Assert.Equal(before.Text, unchanged.Text); + + session.SetTrackedChanges(TrackedChangeMode.Accept); + Assert.True(session.ReplaceText(anchor, "abcdef").Success); + Assert.Equal(new CharSpan(3, 3), Assert.Single(session.ListBookmarks()).Segments.Single().Span); + } + + [Fact] + public void LB017_DuplicateNamesAcrossStories_AreDiagnosticsAndAmbiguousTargetsFail() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = Paragraphs(seed); + Assert.True(seed.AddBookmark("Duplicate", DocumentRange.In(body[0], new CharSpan(0, 1))).Success); + Assert.True(seed.SetHeaderText(body[0], HeaderFooterKind.Default, "header").Success); + var header = Assert.Single(Paragraphs(seed, "hdr1")); + Assert.True(seed.AddBookmark("HeaderName", DocumentRange.In(header, new CharSpan(0, 1))).Success); + var bytes = MutatePackage(seed.Save(), doc => + { + doc.MainDocumentPart!.HeaderParts.Single().GetXDocument() + .Descendants(W + "bookmarkStart").Single() + .SetAttributeValue(W + "name", "Duplicate"); + doc.MainDocumentPart.HeaderParts.Single().PutXDocument(); + }); + + using var session = new DocxSession(bytes); + var diagnostics = session.ListBookmarks(); + Assert.Equal(2, diagnostics.Count); + Assert.All(diagnostics, bookmark => + { + Assert.False(bookmark.IsValid); + Assert.Contains("duplicated", bookmark.ValidationError); + }); + var blocked = session.AddHyperlink(Paragraphs(session)[1], new CharSpan(0, 1), + HyperlinkTarget.Internal("Duplicate")); + Assert.False(blocked.Success); + Assert.Equal(EditErrorCode.DuplicateBookmarkName, blocked.Error!.Code); + } + + [Fact] + public void LB018_SplitAndMerge_PreserveBoundaryPointAndSpanningBookmarkPrecisely() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var first = Paragraphs(session)[0]; + Assert.True(session.AddBookmark("SpansSplit", + DocumentRange.In(first, new CharSpan(2, 10))).Success); + Assert.True(session.AddBookmark("PointAtSplit", + new DocumentRange(first, 6, first, 6)).Success); + var originalIds = session.ListBookmarks().ToDictionary(b => b.Name, b => b.BookmarkId); + + var split = session.SplitParagraph(first, 6); + Assert.True(split.Success, split.Error?.Message); + var second = Assert.Single(split.Created).Id; + var afterSplit = session.ListBookmarks().ToDictionary(b => b.Name); + + var point = afterSplit["PointAtSplit"]; + Assert.True(point.IsValid, point.ValidationError); + Assert.Equal(originalIds[point.Name], point.BookmarkId); + Assert.Equal(new DocumentRange(second, 0, second, 0), point.Range); + var pointSegment = Assert.Single(point.Segments); + Assert.Equal(new CharSpan(0, 0), pointSegment.Span); + Assert.Equal(string.Empty, point.Text); + + var spanning = afterSplit["SpansSplit"]; + Assert.True(spanning.IsValid, spanning.ValidationError); + Assert.Equal(originalIds[spanning.Name], spanning.BookmarkId); + Assert.Equal(new DocumentRange(first, 2, second, 6), spanning.Range); + Assert.Collection(spanning.Segments, + segment => + { + Assert.Equal(first, segment.AnchorId); + Assert.Equal(new CharSpan(2, 4), segment.Span); + Assert.Equal("rst ", segment.Text); + }, + segment => + { + Assert.Equal(second, segment.AnchorId); + Assert.Equal(new CharSpan(0, 6), segment.Span); + Assert.Equal("paragr", segment.Text); + }); + Assert.Equal("rst \nparagr", spanning.Text); + AssertBookmarkPairsAndPackageValidity(session.Save(), originalIds); + + var merge = session.MergeParagraphs(first, second); + Assert.True(merge.Success, merge.Error?.Message); + var afterMerge = session.ListBookmarks().ToDictionary(b => b.Name); + point = afterMerge["PointAtSplit"]; + Assert.Equal(new DocumentRange(first, 6, first, 6), point.Range); + Assert.Equal(originalIds[point.Name], point.BookmarkId); + spanning = afterMerge["SpansSplit"]; + Assert.Equal(new DocumentRange(first, 2, first, 12), spanning.Range); + var mergedSegment = Assert.Single(spanning.Segments); + Assert.Equal(new CharSpan(2, 10), mergedSegment.Span); + Assert.Equal("rst paragr", spanning.Text); + Assert.Equal(originalIds[spanning.Name], spanning.BookmarkId); + AssertBookmarkPairsAndPackageValidity(session.Save(), originalIds); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LB019_MalformedBookmarkPair_CannotBeRenamedOrTargeted(bool duplicateEnd) + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var seedAnchors = Paragraphs(seed); + Assert.True(seed.AddBookmark("Malformed", + DocumentRange.In(seedAnchors[0], new CharSpan(0, 5))).Success); + Assert.True(seed.AddHyperlink(seedAnchors[1], new CharSpan(0, 1), + HyperlinkTarget.Internal("Malformed")).Success); + var bytes = MutatePackage(seed.Save(), document => + { + var end = document.MainDocumentPart!.GetXDocument().Descendants(W + "bookmarkEnd").Single(); + if (duplicateEnd) end.AddAfterSelf(new XElement(end)); + else end.Remove(); + document.MainDocumentPart.PutXDocument(); + }); + + using var session = new DocxSession(bytes); + var anchors = Paragraphs(session); + Assert.False(Assert.Single(session.ListBookmarks()).IsValid); + Assert.True(Assert.Single(session.ListHyperlinks()).IsBroken); + int undoCount = session.UndoCount; + + var rename = session.RenameBookmark("Malformed", "Renamed"); + Assert.False(rename.Success); + Assert.Equal(EditErrorCode.BookmarkNotFound, rename.Error!.Code); + var link = session.AddHyperlink(anchors[1], new CharSpan(2, 1), + HyperlinkTarget.Internal("Malformed")); + Assert.False(link.Success); + Assert.Equal(EditErrorCode.MissingBookmarkTarget, link.Error!.Code); + Assert.Equal(undoCount, session.UndoCount); + Assert.Equal("Malformed", Assert.Single(session.ListBookmarks()).Name); + } + + [Fact] + public void LB020_UnknownWireAndEnumHyperlinkKinds_AreStructuredErrorsWithoutMutation() + { + var bytes = DocxSessionTests.BuildDS001_SimpleTwoParagraphs(); + using var probe = new DocxSession(bytes); + var anchor = Paragraphs(probe)[0]; + var invalidEnum = probe.AddHyperlink(anchor, new CharSpan(0, 1), + new HyperlinkTarget((HyperlinkKind)99, "https://example.test")); + Assert.False(invalidEnum.Success); + Assert.Equal(EditErrorCode.InvalidHyperlinkTarget, invalidEnum.Error!.Code); + Assert.Empty(probe.ListHyperlinks()); + + int handle = Docxodus.Internal.DocxSessionOps.OpenSession(bytes, null); + try + { + using var add = JsonDocument.Parse(Docxodus.Internal.DocxSessionOps.AddHyperlink( + handle, anchor, 0, 1, "externl", "https://example.test")); + Assert.False(add.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("invalid_hyperlink_target", + add.RootElement.GetProperty("error").GetProperty("code").GetString()); + using (var emptyLinks = JsonDocument.Parse( + Docxodus.Internal.DocxSessionOps.ListHyperlinks(handle))) + Assert.Empty(emptyLinks.RootElement.EnumerateArray()); + + using var validAdd = JsonDocument.Parse(Docxodus.Internal.DocxSessionOps.AddHyperlink( + handle, anchor, 0, 1, "external", "https://example.test/original")); + var hyperlinkId = validAdd.RootElement.GetProperty("hyperlinkId").GetString()!; + using var update = JsonDocument.Parse(Docxodus.Internal.DocxSessionOps.UpdateHyperlink( + handle, hyperlinkId, "internla", "Replacement")); + Assert.False(update.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("invalid_hyperlink_target", + update.RootElement.GetProperty("error").GetProperty("code").GetString()); + using var links = JsonDocument.Parse(Docxodus.Internal.DocxSessionOps.ListHyperlinks(handle)); + Assert.Equal("https://example.test/original", + Assert.Single(links.RootElement.EnumerateArray()).GetProperty("target").GetString()); + } + finally + { + Docxodus.Internal.DocxSessionOps.CloseSession(handle); + } + } + + private static void AssertBookmarkPairsAndPackageValidity(byte[] bytes, + System.Collections.Generic.IReadOnlyDictionary expectedIds) + { + using var stream = new MemoryStream(bytes); + using var document = WordprocessingDocument.Open(stream, false); + var root = document.MainDocumentPart!.GetXDocument(); + foreach (var (name, id) in expectedIds) + { + var start = Assert.Single(root.Descendants(W + "bookmarkStart"), + marker => (string?)marker.Attribute(W + "name") == name); + var end = Assert.Single(root.Descendants(W + "bookmarkEnd"), + marker => (string?)marker.Attribute(W + "id") == id); + Assert.Equal(id, (string?)start.Attribute(W + "id")); + Assert.True(XNode.DocumentOrderComparer.Compare(start, end) < 0); + } + var realErrors = new OpenXmlValidator().Validate(document) + .Where(error => !(error.Description ?? string.Empty) + .Contains("powertools.codeplex.com", StringComparison.Ordinal)) + .ToList(); + Assert.Empty(realErrors); + } + + private static HyperlinkRelationship[] HyperlinkRelationships( + byte[] bytes, Func owner) + { + using var stream = new MemoryStream(bytes); + using var document = WordprocessingDocument.Open(stream, false); + return owner(document.MainDocumentPart!).HyperlinkRelationships.ToArray(); + } + + private static MemoryStream Expandable(byte[] bytes) + { + var stream = new MemoryStream(bytes.Length + 4096); + stream.Write(bytes); + stream.Position = 0; + return stream; + } + + private static byte[] MutatePackage(byte[] bytes, Action mutate) + { + using var stream = Expandable(bytes); + using (var document = WordprocessingDocument.Open(stream, true)) mutate(document); + return stream.ToArray(); + } +} diff --git a/Docxodus.Tests/DocxSessionMoveBlockTests.cs b/Docxodus.Tests/DocxSessionMoveBlockTests.cs index c3ca7884..e66848be 100644 --- a/Docxodus.Tests/DocxSessionMoveBlockTests.cs +++ b/Docxodus.Tests/DocxSessionMoveBlockTests.cs @@ -196,13 +196,11 @@ public void MoveBlock_RejectsCrossBlockRangeMembershipChange() Assert.Contains("cross-block comment range", result.Error.Message); } - // A tracked move clones the source paragraph. Every id-bearing marker in the clone is a - // SECOND live copy, so the ids must be made unique or the document violates the schema's - // id-uniqueness constraint while the revision is pending — the exact state a redline is - // sent out in. Mirrors IrMarkupRenderer.NormalizeBookmarks step (B): both copies keep the - // NAME (each survives its own resolution); only the ids are renumbered. + // A tracked move keeps source and destination copies live simultaneously. A bookmark has + // document-global name identity, so it cannot be duplicated faithfully across both sides; + // moving it to only one side would lose it on either accept or reject. Reject explicitly. [Fact] - public void MoveBlock_TrackedParagraph_GivesClonedBookmarksFreshIds() + public void MoveBlock_TrackedParagraph_WithBookmark_IsExplicitlyUnsupported() { using var session = new DocxSession( Document( @@ -217,23 +215,11 @@ public void MoveBlock_TrackedParagraph_GivesClonedBookmarksFreshIds() }); var anchors = ParagraphAnchors(session); - Assert.True(session.MoveBlock(anchors[0], anchors[2], Position.After).Success); + var result = session.MoveBlock(anchors[0], anchors[2], Position.After); - var saved = session.Save(); - AssertValid(saved); - using var stream = new MemoryStream(saved); - using var document = WordprocessingDocument.Open(stream, false); - var main = document.MainDocumentPart!.GetXDocument(); - var starts = main.Descendants(W.bookmarkStart).ToList(); - var ends = main.Descendants(W.bookmarkEnd).ToList(); - - Assert.Equal(2, starts.Count); - Assert.Equal(2, ends.Count); - // Both copies keep the name; the ids are distinct and each start still pairs with an end. - Assert.All(starts, s => Assert.Equal("_Ref1", (string?)s.Attribute(W.name))); - var startIds = starts.Select(s => (string?)s.Attribute(W.id)).ToList(); - Assert.Equal(2, startIds.Distinct().Count()); - Assert.Equal(startIds.OrderBy(x => x), ends.Select(e => (string?)e.Attribute(W.id)).OrderBy(x => x)); + Assert.False(result.Success); + Assert.Equal(EditErrorCode.UnsupportedInlineBoundary, result.Error!.Code); + Assert.Single(session.ListBookmarks()); } // The drag UI gates its drop indicators on this, so it has to agree with MoveBlock exactly: diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs index a3709e58..f5a446c8 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -1311,9 +1311,30 @@ public void MCP097_AtomicStepPreconditionsUseBatchStartState() // ─── Tool catalog ─────────────────────────────────────────────────── [Fact] - public void MCP100_ToolCatalog_HasSixteenDistinctNamedToolsWithValidSchemas() + public void MCP100_ToolCatalog_HasExpectedDistinctNamedToolsWithValidSchemas() { - Assert.Equal(16, ToolCatalog.Tools.Count); + string[] expectedNames = + { + "docxodus_annotate", + "docxodus_close", + "docxodus_comment", + "docxodus_create", + "docxodus_edit", + "docxodus_format", + "docxodus_get_content", + "docxodus_links", + "docxodus_list", + "docxodus_mutations", + "docxodus_open", + "docxodus_pagination", + "docxodus_preview", + "docxodus_save", + "docxodus_search", + "docxodus_table", + "docxodus_track_changes", + }; + + Assert.Equal(expectedNames.Length, ToolCatalog.Tools.Count); var names = new System.Collections.Generic.HashSet(); foreach (var tool in ToolCatalog.Tools) { @@ -1323,6 +1344,7 @@ public void MCP100_ToolCatalog_HasSixteenDistinctNamedToolsWithValidSchemas() using var schema = JsonDocument.Parse(tool.InputSchemaJson); // must be valid JSON Assert.Equal("object", schema.RootElement.GetProperty("type").GetString()); } + Assert.Equal(expectedNames, names.OrderBy(name => name, StringComparer.Ordinal)); } [Fact] @@ -1900,4 +1922,48 @@ string Render(string? anchorId = null) Save(sessionId, savedPath); Assert.Contains("trackRevisions", SavedSettingsXml(savedPath)); } + + [Fact] + public void MCP141_NativeLinkAndBookmarkCrud_RoundTripsIdsAndTypedFailures() + { + var sessionId = OpenSession(); + var sessionArg = JsonSerializer.Serialize(sessionId); + var anchor = FirstBodyAnchorId(sessionId, _store); + Assert.True(ReplaceText(_store, sessionId, anchor, "alpha beta") + .GetProperty("success").GetBoolean()); + + var bookmark = Parse(Dispatcher.Call(_store, "docxodus_links", J( + $$"""{"sessionId":{{sessionArg}},"action":"add_bookmark","name":"Clause","startAnchorId":"{{anchor}}","startOffset":0,"endAnchorId":"{{anchor}}","endOffset":5}"""))); + Assert.True(bookmark.GetProperty("success").GetBoolean()); + + var added = Parse(Dispatcher.Call(_store, "docxodus_links", J( + $$"""{"sessionId":{{sessionArg}},"action":"add_hyperlink","anchorId":"{{anchor}}","startOffset":6,"length":4,"kind":"external","target":"https://example.test/mcp"}"""))); + Assert.True(added.GetProperty("success").GetBoolean()); + var hyperlinkId = added.GetProperty("hyperlinkId").GetString()!; + + var updated = Parse(Dispatcher.Call(_store, "docxodus_links", J( + $$"""{"sessionId":{{sessionArg}},"action":"update_hyperlink","hyperlinkId":{{JsonSerializer.Serialize(hyperlinkId)}},"kind":"internal","target":"Clause"}"""))); + Assert.True(updated.GetProperty("success").GetBoolean()); + Assert.True(Parse(Dispatcher.Call(_store, "docxodus_links", J( + $$"""{"sessionId":{{sessionArg}},"action":"rename_bookmark","name":"Clause","newName":"ClauseTwo"}"""))) + .GetProperty("success").GetBoolean()); + + var links = Parse(Dispatcher.Call(_store, "docxodus_links", J( + $$"""{"sessionId":{{sessionArg}},"action":"list_hyperlinks","scope":"body"}"""))); + var listed = Assert.Single(links.GetProperty("hyperlinks").EnumerateArray()); + Assert.Equal(hyperlinkId, listed.GetProperty("id").GetString()); + Assert.Equal("ClauseTwo", listed.GetProperty("target").GetString()); + + var blocked = Parse(Dispatcher.Call(_store, "docxodus_links", J( + $$"""{"sessionId":{{sessionArg}},"action":"remove_bookmark","name":"ClauseTwo"}"""))); + Assert.False(blocked.GetProperty("success").GetBoolean()); + Assert.Equal("bookmark_in_use", blocked.GetProperty("error").GetProperty("code").GetString()); + + Assert.True(Parse(Dispatcher.Call(_store, "docxodus_links", J( + $$"""{"sessionId":{{sessionArg}},"action":"remove_hyperlink","hyperlinkId":{{JsonSerializer.Serialize(hyperlinkId)}}}"""))) + .GetProperty("success").GetBoolean()); + Assert.True(Parse(Dispatcher.Call(_store, "docxodus_links", J( + $$"""{"sessionId":{{sessionArg}},"action":"remove_bookmark","name":"ClauseTwo"}"""))) + .GetProperty("success").GetBoolean()); + } } diff --git a/Docxodus/DocxSession.LinksBookmarks.cs b/Docxodus/DocxSession.LinksBookmarks.cs new file mode 100644 index 00000000..061dbfa4 --- /dev/null +++ b/Docxodus/DocxSession.LinksBookmarks.cs @@ -0,0 +1,721 @@ +// 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.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using DocumentFormat.OpenXml.Packaging; +using Docxodus.Internal; + +namespace Docxodus; + +public sealed partial class DocxSession +{ + private static readonly XNamespace LinkR = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static readonly Regex BookmarkNamePattern = new("^[A-Za-z_][A-Za-z0-9_]{0,39}$", RegexOptions.CultureInvariant); + + /// Enumerate native hyperlinks in body, headers, footers, footnotes, and endnotes. + public IReadOnlyList ListHyperlinks(ProjectionScopes scopes = ProjectionScopes.All) + { + ThrowIfDisposed(); + _ = AnchorIndex(); + var result = new List(); + foreach (var owner in OwnedPartRelationships.StoryParts(_doc!)) + { + if (!scopes.IncludesScope(owner.Scope)) continue; + var root = owner.Part.GetXDocument().Root; + if (root is null) continue; + foreach (var link in root.Descendants(W.hyperlink)) + { + var paragraph = link.Ancestors(W.p).FirstOrDefault(); + var anchor = paragraph is null ? null : AnchorForElement(paragraph); + if (paragraph is null || anchor is null) continue; + var map = RunTextMap.Build(paragraph); + var member = map.Segments.Where(s => s.Run.Ancestors(W.hyperlink).FirstOrDefault() == link).ToList(); + int start = member.Count == 0 ? MarkerOffset(paragraph, link) : member[0].StartOffsetInBlock; + int end = member.Count == 0 ? start : member[^1].EndOffsetInBlock; + var internalTarget = (string?)link.Attribute(W.anchor); + var relationshipId = (string?)link.Attribute(LinkR + "id"); + HyperlinkKind kind; + string? target; + bool? external = null; + bool broken; + if (!string.IsNullOrEmpty(internalTarget)) + { + kind = HyperlinkKind.Internal; + target = internalTarget; + broken = ResolveBookmarkPair(internalTarget, hyperlinkTarget: true, + anchor.Value.Id, out _, out _) is not null; + } + else + { + kind = HyperlinkKind.External; + var relationship = owner.Part.HyperlinkRelationships.FirstOrDefault(r => r.Id == relationshipId); + target = relationship?.Uri.ToString(); + external = relationship?.IsExternal; + broken = relationship is null; + } + result.Add(new HyperlinkInfo( + HyperlinkPublicId(owner, link), kind, owner.PartUri, owner.Scope, + anchor.Value.Id, new CharSpan(start, Math.Max(0, end - start)), + TextForRuns(member.Select(s => s.Run)), target, relationshipId, external, broken)); + } + } + return result; + } + + /// Enumerate bookmark pairs, including pairs whose endpoints cross paragraphs in + /// one owning part. Pairing is keyed by (part, w:id); unmatched starts and ambiguous + /// ids/names are diagnostic. Orphan ends have no name/start coordinate and are not rows. + public IReadOnlyList ListBookmarks(ProjectionScopes scopes = ProjectionScopes.All) + { + ThrowIfDisposed(); + _ = AnchorIndex(); + var owners = OwnedPartRelationships.StoryParts(_doc!).ToList(); + var nodes = owners.SelectMany((owner, ownerIndex) => + (owner.Part.GetXDocument().Root?.DescendantsAndSelf() ?? Enumerable.Empty()) + .Select((element, nodeIndex) => (owner, ownerIndex, element, nodeIndex))).ToList(); + var ends = nodes.Where(n => n.element.Name == W.bookmarkEnd).ToList(); + var result = new List(); + + foreach (var startNode in nodes.Where(n => n.element.Name == W.bookmarkStart)) + { + if (!scopes.IncludesScope(startNode.owner.Scope)) continue; + var name = (string?)startNode.element.Attribute(W.name) ?? string.Empty; + var id = (string?)startNode.element.Attribute(W.id) ?? string.Empty; + // w:id is story-part scoped in real files. Never pair a body start with a header/footer + // end merely because Word reused the same decimal id in both parts. + var candidateEnds = ends.Where(e => e.owner.PartUri == startNode.owner.PartUri + && string.Equals((string?)e.element.Attribute(W.id), id, StringComparison.Ordinal) + && XNode.DocumentOrderComparer.Compare(e.element, startNode.element) > 0).ToList(); + var endNode = candidateEnds.FirstOrDefault(); + int sameIdStarts = nodes.Count(n => n.element.Name == W.bookmarkStart + && n.owner.PartUri == startNode.owner.PartUri + && string.Equals((string?)n.element.Attribute(W.id), id, StringComparison.Ordinal)); + int sameIdEnds = ends.Count(n => n.owner.PartUri == startNode.owner.PartUri + && string.Equals((string?)n.element.Attribute(W.id), id, StringComparison.Ordinal)); + int sameNameStarts = nodes.Count(n => n.element.Name == W.bookmarkStart + && string.Equals((string?)n.element.Attribute(W.name), name, StringComparison.Ordinal)); + var startParagraph = startNode.element.Ancestors(W.p).FirstOrDefault(); + var endParagraph = endNode.element?.Ancestors(W.p).FirstOrDefault(); + var startAnchor = startParagraph is null ? null : AnchorForElement(startParagraph); + var endAnchor = endParagraph is null ? null : AnchorForElement(endParagraph); + DocumentRange? range = null; + IReadOnlyList segments = Array.Empty(); + string text = string.Empty; + if (startParagraph is not null && endParagraph is not null && startAnchor is not null && endAnchor is not null) + { + int startOffset = MarkerOffset(startParagraph, startNode.element); + int endOffset = MarkerOffset(endParagraph, endNode.element!); + range = new DocumentRange(startAnchor.Value.Id, startOffset, endAnchor.Value.Id, endOffset); + segments = BuildBookmarkSegments(owners, startNode.owner, startParagraph, startOffset, + endNode.owner, endParagraph, endOffset); + text = string.Join("\n", segments.Select(s => s.Text)); + } + string? validationError = candidateEnds.Count == 0 ? "bookmarkEnd is missing" + : candidateEnds.Count > 1 ? "multiple bookmarkEnd markers follow this start" + : sameIdStarts > 1 ? "bookmark numeric id is duplicated in this story part" + : sameIdEnds > 1 ? "bookmark numeric id has multiple ends in this story part" + : sameNameStarts > 1 ? "bookmark name is duplicated" + : null; + result.Add(new BookmarkInfo(name, id, startNode.owner.PartUri, startNode.owner.Scope, + endNode.element is null ? null : endNode.owner.PartUri, + endNode.element is null ? null : endNode.owner.Scope, + range, segments, text, endNode.element is not null, + name.StartsWith(AnnotationManager.BookmarkPrefix, StringComparison.Ordinal), + validationError is null, validationError)); + } + return result; + } + + public EditResult AddHyperlink(string anchorId, CharSpan span, HyperlinkTarget target) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + if (_trackedChanges == TrackedChangeMode.RenderInline) + return EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, + "hyperlink mutations cannot be represented faithfully as tracked revisions", anchorId); + var validatedTarget = ValidateHyperlinkTarget(target, anchorId); + if (validatedTarget is not null) return validatedTarget; + var anchor = FindAnchor(anchorId); + if (anchor is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, $"anchor not found: {anchorId}", anchorId); + var paragraph = anchor.Resolve(_doc!); + if (paragraph is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element resolved null", anchorId); + if (paragraph.Name != W.p) + return EditResult.Fail(EditErrorCode.AnchorWrongKind, "AddHyperlink requires a paragraph/heading/list-item anchor", anchorId); + var map = RunTextMap.Build(paragraph); + if (span.Length <= 0) return EditResult.Fail(EditErrorCode.EmptyHyperlinkSpan, "hyperlink span must contain text", anchorId); + if (span.Start < 0 || span.Start + span.Length > map.FlatText.Length) + return EditResult.Fail(EditErrorCode.OffsetOutOfRange, + $"span [{span.Start},{span.Start + span.Length}) outside paragraph of length {map.FlatText.Length}", anchorId); + var range = RunTextMap.ResolveRange(map, span.Start, span.Length); + var unsupported = ValidateHyperlinkBoundary(paragraph, range.Select(x => x.Segment.Run)); + if (unsupported is not null) return EditResult.Fail(EditErrorCode.UnsupportedInlineBoundary, unsupported, anchorId); + var owner = OwnedPartRelationships.FindOwner(_doc!, paragraph); + if (owner is null) return EditResult.Fail(EditErrorCode.InternalError, "paragraph has no owning story part", anchorId); + + _history.RecordPreOp(TakeSnapshot()); + try + { + SplitRunsAtOffset(paragraph, span.Start + span.Length); + SplitRunsAtOffset(paragraph, span.Start); + var splitMap = RunTextMap.Build(paragraph); + var selected = splitMap.Segments + .Where(s => s.StartOffsetInBlock >= span.Start && s.EndOffsetInBlock <= span.Start + span.Length) + .Select(s => s.Run).ToList(); + if (selected.Count == 0 || selected.Any(r => r.Parent != paragraph)) + throw new InvalidOperationException("selection did not resolve to direct paragraph runs"); + var link = new XElement(W.hyperlink, + new XAttribute(PtOpenXml.Unid, UnidHelper.GenerateUnid())); + ApplyHyperlinkTarget(owner.Value.Part, link, target); + selected[0].AddBeforeSelf(link); + foreach (var run in selected) { run.Remove(); link.Add(run); } + var id = HyperlinkPublicId(owner.Value, link); + InvalidateProjectionCache(); + return new EditResult { Success = true, HyperlinkId = id, Modified = new[] { anchor.Anchor } }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message, anchorId); + } + } + + public EditResult UpdateHyperlink(string hyperlinkId, HyperlinkTarget target) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + if (_trackedChanges == TrackedChangeMode.RenderInline) + return EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, + "hyperlink mutations cannot be represented faithfully as tracked revisions"); + var validatedTarget = ValidateHyperlinkTarget(target, null); + if (validatedTarget is not null) return validatedTarget; + var found = FindHyperlinkElement(hyperlinkId); + if (found is null) return EditResult.Fail(EditErrorCode.HyperlinkNotFound, $"hyperlink not found: {hyperlinkId}"); + var (owner, link) = found.Value; + var oldRelationshipId = (string?)link.Attribute(LinkR + "id"); + var paragraph = link.Ancestors(W.p).FirstOrDefault(); + var anchor = paragraph is null ? null : AnchorForElement(paragraph); + _history.RecordPreOp(TakeSnapshot()); + try + { + ApplyHyperlinkTarget(owner.Part, link, target); + OwnedPartRelationships.DeleteReferenceRelationshipIfOrphaned(owner.Part, oldRelationshipId, LinkR + "id"); + InvalidateProjectionCache(); + return new EditResult { Success = true, HyperlinkId = hyperlinkId, + Modified = anchor is null ? Array.Empty() : new[] { anchor.Value } }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult RemoveHyperlink(string hyperlinkId) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + if (_trackedChanges == TrackedChangeMode.RenderInline) + return EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, + "hyperlink mutations cannot be represented faithfully as tracked revisions"); + var found = FindHyperlinkElement(hyperlinkId); + if (found is null) return EditResult.Fail(EditErrorCode.HyperlinkNotFound, $"hyperlink not found: {hyperlinkId}"); + var (owner, link) = found.Value; + var oldRelationshipId = (string?)link.Attribute(LinkR + "id"); + var paragraph = link.Ancestors(W.p).FirstOrDefault(); + var anchor = paragraph is null ? null : AnchorForElement(paragraph); + _history.RecordPreOp(TakeSnapshot()); + try + { + link.ReplaceWith(link.Nodes()); + OwnedPartRelationships.DeleteReferenceRelationshipIfOrphaned(owner.Part, oldRelationshipId, LinkR + "id"); + InvalidateProjectionCache(); + return new EditResult { Success = true, HyperlinkId = hyperlinkId, + Modified = anchor is null ? Array.Empty() : new[] { anchor.Value } }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult AddBookmark(string name, DocumentRange range) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + var common = ValidateBookmarkMutation(name); + if (common is not null) return common; + if (BookmarkStarts(name).Count != 0) + return EditResult.Fail(EditErrorCode.DuplicateBookmarkName, $"bookmark name already exists: {name}"); + var endpoints = ValidateDocumentRange(range); + if (endpoints.Error is not null) return endpoints.Error; + var bookmarkId = NextGlobalBookmarkId(); + _history.RecordPreOp(TakeSnapshot()); + try + { + InsertRangeMarkers(endpoints, bookmarkId, name); + InvalidateProjectionCache(); + return new EditResult { Success = true, BookmarkName = name, + Modified = EndpointAnchors(endpoints) }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult RenameBookmark(string name, string newName) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + var oldValidation = ValidateBookmarkMutation(name, requireValidName: false); + if (oldValidation is not null) return oldValidation; + var newValidation = ValidateBookmarkMutation(newName); + if (newValidation is not null) return newValidation; + if (ResolveBookmarkPair(name, hyperlinkTarget: false, null, + out var start, out _) is { } pairError) return pairError; + if (!string.Equals(name, newName, StringComparison.Ordinal) && BookmarkStarts(newName).Count != 0) + return EditResult.Fail(EditErrorCode.DuplicateBookmarkName, $"bookmark name already exists: {newName}"); + _history.RecordPreOp(TakeSnapshot()); + try + { + start.SetAttributeValue(W.name, newName); + foreach (var owner in OwnedPartRelationships.StoryParts(_doc!)) + foreach (var link in owner.Part.GetXDocument().Descendants(W.hyperlink) + .Where(h => string.Equals((string?)h.Attribute(W.anchor), name, StringComparison.Ordinal))) + link.SetAttributeValue(W.anchor, newName); + InvalidateProjectionCache(); + return new EditResult { Success = true, BookmarkName = newName }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult MoveBookmark(string name, DocumentRange range) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + var common = ValidateBookmarkMutation(name, requireValidName: false); + if (common is not null) return common; + if (ResolveBookmarkPair(name, hyperlinkTarget: false, null, + out var start, out var end) is { } pairError) return pairError; + var id = (string?)start.Attribute(W.id); + var endpoints = ValidateDocumentRange(range); + if (endpoints.Error is not null) return endpoints.Error; + _history.RecordPreOp(TakeSnapshot()); + try + { + start.Remove(); + end.Remove(); + InsertRangeMarkers(endpoints, id!, name); + InvalidateProjectionCache(); + return new EditResult { Success = true, BookmarkName = name, + Modified = EndpointAnchors(endpoints) }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult RemoveBookmark(string name) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + var common = ValidateBookmarkMutation(name, requireValidName: false); + if (common is not null) return common; + if (ResolveBookmarkPair(name, hyperlinkTarget: false, null, + out var start, out var end) is { } pairError) return pairError; + if (OwnedPartRelationships.StoryParts(_doc!).Any(o => o.Part.GetXDocument().Descendants(W.hyperlink) + .Any(h => string.Equals((string?)h.Attribute(W.anchor), name, StringComparison.Ordinal)))) + return EditResult.Fail(EditErrorCode.BookmarkInUse, + $"bookmark is targeted by one or more internal hyperlinks: {name}"); + _history.RecordPreOp(TakeSnapshot()); + try + { + start.Remove(); + end.Remove(); + InvalidateProjectionCache(); + return new EditResult { Success = true, BookmarkName = name }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + private EditResult? ValidateHyperlinkTarget(HyperlinkTarget? target, string? anchorId) + { + if (target is null || string.IsNullOrWhiteSpace(target.Target)) + return EditResult.Fail(EditErrorCode.InvalidHyperlinkTarget, "hyperlink target is empty", anchorId); + if (target.Kind is not (HyperlinkKind.Internal or HyperlinkKind.External)) + return EditResult.Fail(EditErrorCode.InvalidHyperlinkTarget, + $"unknown hyperlink target kind: {target.Kind}", anchorId); + if (target.Kind == HyperlinkKind.Internal) + { + if (ResolveBookmarkPair(target.Target, hyperlinkTarget: true, anchorId, + out _, out _) is { } pairError) return pairError; + } + else if (!Uri.TryCreate(target.Target, UriKind.RelativeOrAbsolute, out _) + || target.Target.StartsWith("#", StringComparison.Ordinal)) + return EditResult.Fail(EditErrorCode.InvalidHyperlinkTarget, + $"invalid external hyperlink target: {target.Target}", anchorId); + return null; + } + + /// 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) + { + foreach (var link in elements.SelectMany(e => e.DescendantsAndSelf(W.hyperlink))) + { + var href = (string?)link.Attribute(MarkdownPayloadParser.HrefAttr); + if (href is null) continue; + var target = href.StartsWith("#", StringComparison.Ordinal) + ? HyperlinkTarget.Internal(href.Substring(1)) + : HyperlinkTarget.External(href); + if (ValidateHyperlinkTarget(target, anchorId) is { } error) return error; + } + return null; + } + + private EditResult? ValidateBookmarkMutation(string name, bool requireValidName = true) + { + if (_trackedChanges == TrackedChangeMode.RenderInline) + return EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, + "bookmark mutations cannot be represented faithfully as tracked revisions"); + if (name.StartsWith(AnnotationManager.BookmarkPrefix, StringComparison.Ordinal)) + return EditResult.Fail(EditErrorCode.ManagedBookmark, + $"bookmark is managed by the annotation subsystem: {name}"); + if (requireValidName && !BookmarkNamePattern.IsMatch(name)) + return EditResult.Fail(EditErrorCode.InvalidBookmarkName, + "bookmark names must be 1-40 characters, start with a letter or underscore, and contain only letters, digits, or underscores"); + return null; + } + + private static string? ValidateHyperlinkBoundary(XElement paragraph, IEnumerable selectedRuns) + { + if (paragraph.Descendants().Any(e => e.Name == W.ins || e.Name == W.del + || e.Name == W.moveFrom || e.Name == W.moveTo)) + return "hyperlinks cannot be created across tracked-revision markup"; + if (paragraph.Descendants().Any(e => e.Name == W.fldChar || e.Name == W.instrText)) + return "hyperlinks cannot be created across a complex field boundary"; + foreach (var run in selectedRuns) + { + var containers = run.Ancestors().TakeWhile(a => a != paragraph).ToList(); + if (containers.Count != 0) + return containers.Any(a => a.Name == W.hyperlink) + ? "hyperlink spans cannot overlap an existing hyperlink" + : "hyperlink span crosses an unsupported inline container"; + } + return null; + } + + private void ApplyHyperlinkTarget(OpenXmlPart owner, XElement link, HyperlinkTarget target) + { + link.Attribute(W.anchor)?.Remove(); + link.Attribute(LinkR + "id")?.Remove(); + if (target.Kind == HyperlinkKind.Internal) + link.SetAttributeValue(W.anchor, target.Target); + else + { + var relationship = OwnedPartRelationships.FindOrAddExternalHyperlink( + owner, new Uri(target.Target, UriKind.RelativeOrAbsolute)); + link.SetAttributeValue(LinkR + "id", relationship.Id); + } + } + + private (OwnedPartRelationships.Owner Owner, XElement Link)? FindHyperlinkElement(string hyperlinkId) + { + _ = AnchorIndex(); + foreach (var owner in OwnedPartRelationships.StoryParts(_doc!)) + foreach (var link in owner.Part.GetXDocument().Descendants(W.hyperlink)) + if (string.Equals(HyperlinkPublicId(owner, link), hyperlinkId, StringComparison.Ordinal)) + return (owner, link); + return null; + } + + private static string HyperlinkPublicId(OwnedPartRelationships.Owner owner, XElement link) => + $"hl:{owner.Scope}:{UnidHelper.ReadOrDeriveUnid(link)}"; + + private List BookmarkStarts(string name) => OwnedPartRelationships.StoryParts(_doc!) + .SelectMany(o => o.Part.GetXDocument().Descendants(W.bookmarkStart)) + .Where(b => string.Equals((string?)b.Attribute(W.name), name, StringComparison.Ordinal)).ToList(); + + private List BookmarkEndsForStart(XElement start, string? id) + { + var owner = OwnedPartRelationships.FindOwner(_doc!, start); + if (owner is null) return new List(); + return owner.Value.Part.GetXDocument().Descendants(W.bookmarkEnd) + .Where(b => string.Equals((string?)b.Attribute(W.id), id, StringComparison.Ordinal) + && XNode.DocumentOrderComparer.Compare(b, start) > 0).ToList(); + } + + /// Resolve one globally named bookmark to one unambiguous, ordered start/end pair in + /// a single story part. A name-only start is not a valid internal-link target or mutable + /// bookmark: accepting it would preserve or create dangling Word markup. + private EditResult? ResolveBookmarkPair(string name, bool hyperlinkTarget, string? anchorId, + out XElement start, out XElement end) + { + start = null!; + end = null!; + var starts = BookmarkStarts(name); + if (starts.Count == 0) + return EditResult.Fail(hyperlinkTarget + ? EditErrorCode.MissingBookmarkTarget : EditErrorCode.BookmarkNotFound, + $"bookmark {(hyperlinkTarget ? "target does not exist" : "not found")}: {name}", anchorId); + if (starts.Count > 1) + return EditResult.Fail(EditErrorCode.DuplicateBookmarkName, + $"bookmark name is ambiguous: {name}", anchorId); + + start = starts[0]; + var owner = OwnedPartRelationships.FindOwner(_doc!, start); + var id = (string?)start.Attribute(W.id); + if (owner is not null && !string.IsNullOrEmpty(id)) + { + var storyStarts = owner.Value.Part.GetXDocument().Descendants(W.bookmarkStart) + .Where(marker => string.Equals((string?)marker.Attribute(W.id), id, + StringComparison.Ordinal)).ToList(); + var storyEnds = owner.Value.Part.GetXDocument().Descendants(W.bookmarkEnd) + .Where(marker => string.Equals((string?)marker.Attribute(W.id), id, + StringComparison.Ordinal)).ToList(); + if (storyStarts.Count == 1 && storyEnds.Count == 1 + && XNode.DocumentOrderComparer.Compare(start, storyEnds[0]) < 0) + { + end = storyEnds[0]; + return null; + } + } + + return EditResult.Fail(hyperlinkTarget + ? EditErrorCode.MissingBookmarkTarget : EditErrorCode.BookmarkNotFound, + $"bookmark is not one coherent same-story start/end pair: {name}", anchorId); + } + + /// + /// Guard generic structural deletions from leaving half a bookmark pair or a dangling + /// internal hyperlink. A complete unreferenced pair may be deleted with its containing + /// content; ranges crossing the deletion boundary are rejected before the undo snapshot. + /// + private EditResult? ValidateBookmarkRemoval(IEnumerable removalRoots, string anchorId) + { + var roots = removalRoots.Distinct().ToList(); + bool IsRemoved(XElement element) => roots.Any(root => + ReferenceEquals(root, element) || element.Ancestors().Any(a => ReferenceEquals(a, root))); + + var markers = roots.SelectMany(root => root.DescendantsAndSelf() + .Where(e => e.Name == W.bookmarkStart || e.Name == W.bookmarkEnd)) + .Distinct().ToList(); + if (markers.Count == 0) return null; + + foreach (var start in markers.Where(e => e.Name == W.bookmarkStart)) + { + var name = (string?)start.Attribute(W.name); + var id = (string?)start.Attribute(W.id); + var ends = BookmarkEndsForStart(start, id); + if (name is null || ends.Count != 1 || !IsRemoved(ends[0])) + return EditResult.Fail(EditErrorCode.UnsupportedInlineBoundary, + "structural deletion would leave a bookmark range endpoint orphaned", anchorId); + if (name.StartsWith(AnnotationManager.BookmarkPrefix, StringComparison.Ordinal)) + return EditResult.Fail(EditErrorCode.ManagedBookmark, + $"structural deletion includes an annotation-managed bookmark: {name}", anchorId); + if (OwnedPartRelationships.StoryParts(_doc!).Any(owner => + owner.Part.GetXDocument().Descendants(W.hyperlink).Any(link => + string.Equals((string?)link.Attribute(W.anchor), name, StringComparison.Ordinal) + && !IsRemoved(link)))) + return EditResult.Fail(EditErrorCode.BookmarkInUse, + $"structural deletion would remove a bookmark still targeted by an internal hyperlink: {name}", anchorId); + } + + foreach (var end in markers.Where(e => e.Name == W.bookmarkEnd)) + { + var owner = OwnedPartRelationships.FindOwner(_doc!, end); + var id = (string?)end.Attribute(W.id); + if (owner is null) return EditResult.Fail(EditErrorCode.UnsupportedInlineBoundary, + "structural deletion contains an ownerless bookmarkEnd", anchorId); + var starts = owner.Value.Part.GetXDocument().Descendants(W.bookmarkStart) + .Where(start => + { + var paired = BookmarkEndsForStart(start, id); + return string.Equals((string?)start.Attribute(W.id), id, StringComparison.Ordinal) + && paired.Count == 1 && ReferenceEquals(paired[0], end); + }) + .ToList(); + if (starts.Count != 1 || !IsRemoved(starts[0])) + return EditResult.Fail(EditErrorCode.UnsupportedInlineBoundary, + "structural deletion would leave a bookmark range endpoint orphaned", anchorId); + } + return null; + } + + private string NextGlobalBookmarkId() + { + int max = -1; + foreach (var owner in OwnedPartRelationships.StoryParts(_doc!)) + foreach (var marker in owner.Part.GetXDocument().Descendants() + .Where(e => e.Name == W.bookmarkStart || e.Name == W.bookmarkEnd)) + if (int.TryParse((string?)marker.Attribute(W.id), out var id)) max = Math.Max(max, id); + return checked(max + 1).ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + private sealed record ValidatedRange( + XElement StartParagraph, Anchor StartAnchor, int StartOffset, + XElement EndParagraph, Anchor EndAnchor, int EndOffset, + EditResult? Error = null); + + private ValidatedRange ValidateDocumentRange(DocumentRange range) + { + var startTarget = FindAnchor(range.StartAnchorId); + if (startTarget is null) return RangeError(EditErrorCode.AnchorNotFound, "start anchor not found", range.StartAnchorId); + var endTarget = FindAnchor(range.EndAnchorId); + if (endTarget is null) return RangeError(EditErrorCode.AnchorNotFound, "end anchor not found", range.EndAnchorId); + var start = startTarget.Resolve(_doc!); + var end = endTarget.Resolve(_doc!); + if (start is null || end is null) return RangeError(EditErrorCode.AnchorNotFound, "range endpoint resolved null", range.StartAnchorId); + if (start.Name != W.p || end.Name != W.p) + return RangeError(EditErrorCode.AnchorWrongKind, "bookmark endpoints must be paragraphs/headings/list items", range.StartAnchorId); + int startLength = RunTextMap.Build(start).FlatText.Length; + int endLength = RunTextMap.Build(end).FlatText.Length; + if (range.StartOffset < 0 || range.StartOffset > startLength || range.EndOffset < 0 || range.EndOffset > endLength) + return RangeError(EditErrorCode.OffsetOutOfRange, "bookmark endpoint offset is outside its paragraph", range.StartAnchorId); + if (ReferenceEquals(start, end) && range.EndOffset < range.StartOffset) + return RangeError(EditErrorCode.OffsetOutOfRange, "bookmark end precedes its start", range.StartAnchorId); + var startOwner = OwnedPartRelationships.FindOwner(_doc!, start); + var endOwner = OwnedPartRelationships.FindOwner(_doc!, end); + if (startOwner is null || endOwner is null + || !string.Equals(startOwner.Value.PartUri, endOwner.Value.PartUri, StringComparison.Ordinal)) + return RangeError(EditErrorCode.UnsupportedInlineBoundary, + "bookmark pairs cannot cross XML package parts; choose endpoints in the same body/header/footer/note story", + range.StartAnchorId); + if (HasUnsafeBookmarkBoundary(start, range.StartOffset) || HasUnsafeBookmarkBoundary(end, range.EndOffset)) + return RangeError(EditErrorCode.UnsupportedInlineBoundary, + "bookmark endpoint falls inside a field, revision, or unsupported inline container", range.StartAnchorId); + var ordered = OwnedPartRelationships.StoryParts(_doc!).SelectMany(o => + o.Part.GetXDocument().Descendants(W.p)).ToList(); + if (ordered.IndexOf(start) > ordered.IndexOf(end)) + return RangeError(EditErrorCode.OffsetOutOfRange, "bookmark end precedes its start in document order", range.StartAnchorId); + return new ValidatedRange(start, startTarget.Anchor, range.StartOffset, end, endTarget.Anchor, range.EndOffset); + } + + private static ValidatedRange RangeError(EditErrorCode code, string message, string anchorId) => + new(new XElement(W.p), new Anchor("", "", "", ""), 0, + new XElement(W.p), new Anchor("", "", "", ""), 0, EditResult.Fail(code, message, anchorId)); + + private static bool HasUnsafeBookmarkBoundary(XElement paragraph, int offset) + { + if (paragraph.Descendants().Any(e => e.Name == W.ins || e.Name == W.del + || e.Name == W.moveFrom || e.Name == W.moveTo || e.Name == W.fldChar || e.Name == W.instrText)) + return true; + int consumed = 0; + foreach (var child in paragraph.Elements().Where(IsInlineChild)) + { + int length = InlineChildTextLength(child); + if (child.Name != W.r && child.Name != W.hyperlink + && consumed < offset && offset < consumed + length) + return true; + consumed += length; + } + return false; + } + + private static void InsertRangeMarkers(ValidatedRange range, string bookmarkId, string name) + { + var start = new XElement(W.bookmarkStart, + new XAttribute(W.id, bookmarkId), new XAttribute(W.name, name)); + var end = new XElement(W.bookmarkEnd, new XAttribute(W.id, bookmarkId)); + if (ReferenceEquals(range.StartParagraph, range.EndParagraph) + && range.StartOffset == range.EndOffset) + { + InsertCollapsedBookmarkAtOffset(range.StartParagraph, range.StartOffset, start, end); + return; + } + // End first keeps its pre-split offset stable when both endpoints share a paragraph. + InsertMarkerAtOffset(range.EndParagraph, range.EndOffset, + end); + InsertMarkerAtOffset(range.StartParagraph, range.StartOffset, + start); + } + + private static void InsertCollapsedBookmarkAtOffset( + XElement paragraph, int offset, XElement start, XElement end) + { + InsertMarkersAtOffset(paragraph, offset, new[] { start, end }); + } + + private static void InsertMarkerAtOffset(XElement paragraph, int offset, XElement marker) + => InsertMarkersAtOffset(paragraph, offset, new[] { marker }); + + private static void InsertMarkersAtOffset( + XElement paragraph, int offset, IReadOnlyList markers) + { + SplitRunsAtOffset(paragraph, offset); + SplitInlineContainersAtOffset(paragraph, offset); + var map = RunTextMap.Build(paragraph); + var right = map.Segments.FirstOrDefault(s => s.StartOffsetInBlock >= offset).Run; + if (right is not null) + { + var boundary = right.AncestorsAndSelf().First(e => ReferenceEquals(e.Parent, paragraph)); + boundary.AddBeforeSelf(markers); + return; + } + paragraph.Add(markers); + } + + private static IReadOnlyList EndpointAnchors(ValidatedRange range) => + range.StartAnchor.Id == range.EndAnchor.Id + ? new[] { range.StartAnchor } + : new[] { range.StartAnchor, range.EndAnchor }; + + private IReadOnlyList BuildBookmarkSegments( + IReadOnlyList owners, + OwnedPartRelationships.Owner startOwner, XElement startParagraph, int startOffset, + OwnedPartRelationships.Owner endOwner, XElement endParagraph, int endOffset) + { + var paragraphs = owners.SelectMany(o => o.Part.GetXDocument().Descendants(W.p).Select(p => (o, p))).ToList(); + int first = paragraphs.FindIndex(x => ReferenceEquals(x.p, startParagraph)); + int last = paragraphs.FindIndex(x => ReferenceEquals(x.p, endParagraph)); + if (first < 0 || last < first) return Array.Empty(); + var result = new List(); + for (int i = first; i <= last; i++) + { + var (owner, paragraph) = paragraphs[i]; + var anchor = AnchorForElement(paragraph); + if (anchor is null) continue; + var text = RunTextMap.Build(paragraph).FlatText; + int from = i == first ? startOffset : 0; + int to = i == last ? endOffset : text.Length; + from = Math.Clamp(from, 0, text.Length); + to = Math.Clamp(to, from, text.Length); + result.Add(new BookmarkRangeSegment(owner.PartUri, owner.Scope, anchor.Value.Id, + new CharSpan(from, to - from), text.Substring(from, to - from))); + } + return result; + } + + private static int MarkerOffset(XElement paragraph, XElement marker) + { + int offset = 0; + foreach (var run in InlineRuns(paragraph)) + { + if (XNode.DocumentOrderComparer.Compare(run, marker) >= 0) break; + offset += RunText(run).Length; + } + return offset; + } + + private static string TextForRuns(IEnumerable runs) => + string.Concat(runs.Select(RunText)); +} diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index bff97922..1bc044f4 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -66,6 +66,49 @@ public enum ContextBoundary public readonly record struct CharSpan(int Start, int Length); +/// The kind of target carried by a native Word hyperlink. +public enum HyperlinkKind { External, Internal } + +/// A hyperlink destination. External targets are URI strings; internal targets are +/// bookmark names (without a leading #). +public sealed record HyperlinkTarget(HyperlinkKind Kind, string Target) +{ + public static HyperlinkTarget External(string uri) => new(HyperlinkKind.External, uri); + public static HyperlinkTarget Internal(string bookmarkName) => new(HyperlinkKind.Internal, bookmarkName); +} + +/// An unambiguous two-ended document range. Offsets are character boundaries and the +/// end is exclusive. Endpoints may be in different paragraphs but writable bookmark ranges must +/// remain in the same owning XML story part. +public sealed record DocumentRange( + string StartAnchorId, int StartOffset, string EndAnchorId, int EndOffset) +{ + public static DocumentRange In(string anchorId, CharSpan span) => + new(anchorId, span.Start, anchorId, checked(span.Start + span.Length)); +} + +/// One paragraph-local slice of a bookmark's range. +public sealed record BookmarkRangeSegment( + string OwningPartUri, string Scope, string AnchorId, CharSpan Span, string Text); + +/// A first-class native Word hyperlink. follows the +/// session anchor identity contract: stable in-session and across Save(true) (or a session +/// configured with PersistAnchorIds=true), but not promised across the default stripped save. +public sealed record HyperlinkInfo( + string Id, HyperlinkKind Kind, string OwningPartUri, string Scope, + string AnchorId, CharSpan Span, string Text, string? Target, + string? RelationshipId, bool? RelationshipIsExternal, bool IsBroken); + +/// A native Word bookmark pair. identifies both endpoints even +/// when Word places them in different paragraphs; provides the +/// paragraph-local text slices. Malformed or unmatched start markers are reported through +/// and . +public sealed record BookmarkInfo( + string Name, string BookmarkId, string StartPartUri, string StartScope, + string? EndPartUri, string? EndScope, DocumentRange? Range, + IReadOnlyList Segments, string Text, + bool IsPaired, bool IsManaged, bool IsValid, string? ValidationError); + public sealed record FormatOp { public bool? Bold { get; init; } @@ -1566,6 +1609,18 @@ public enum EditErrorCode AnnotationNotFound, EmptyAnnotationSpan, + HyperlinkNotFound, + BookmarkNotFound, + DuplicateBookmarkName, + InvalidBookmarkName, + InvalidHyperlinkTarget, + MissingBookmarkTarget, + BookmarkInUse, + ManagedBookmark, + EmptyHyperlinkSpan, + UnsupportedInlineBoundary, + TrackedOperationUnsupported, + /// 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. @@ -1604,6 +1659,10 @@ public sealed class EditResult /// public string? AnnotationId { get; init; } + /// The affected native hyperlink/bookmark identity, when applicable. + public string? HyperlinkId { get; init; } + public string? BookmarkName { get; init; } + internal static EditResult Fail(EditErrorCode code, string message, string? anchorId = null) => new() { Success = false, Error = new EditError(code, message, anchorId) }; } @@ -1697,7 +1756,7 @@ public sealed class DocxSessionSettings // ─── Session ─────────────────────────────────────────────────────────────── -public sealed class DocxSession : IDisposable +public sealed partial class DocxSession : IDisposable { private readonly DocxSessionSettings _settings; private readonly Internal.UndoRing _history; @@ -2602,7 +2661,8 @@ private EditResult ResolveRevision(string revisionId, bool accept) if (group is null) return EditResult.Fail(EditErrorCode.RevisionNotFound, $"revision not found: {revisionId}"); - var partUri = parts[group.PartIndex].Part.Uri.ToString(); + var owningPart = parts[group.PartIndex].Part; + var partUri = owningPart.Uri.ToString(); // Capture the block anchors the resolution touches BEFORE applying — elements // detach during Apply and can no longer be resolved to a part afterwards. @@ -2612,6 +2672,7 @@ private EditResult ResolveRevision(string revisionId, bool accept) try { var removedElements = Internal.RevisionOps.Apply(group, accept); + Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owningPart, R.id); var removed = new List(); var seenRemoved = new HashSet(StringComparer.Ordinal); @@ -4117,7 +4178,7 @@ public IReadOnlyDictionary> FindByLabel( } /// - /// Resolves any bookmark in the main document part (Docxodus-managed or user-authored) + /// Resolves any bookmark in body/header/footer/footnote/endnote parts (Docxodus-managed or user-authored) /// to the block-level anchors covering its range, in document order. Empty when the /// bookmark name is unknown or its end marker is missing. Use this for raw bookmark /// names that didn't come from . @@ -4158,21 +4219,20 @@ public IReadOnlyList ListAnnotations() } /// - /// Walks the main document part once: locates the bookmark by name, then collects + /// Walks the bookmark's owning story part once: locates the bookmark by name, then collects /// every block-level anchor whose subtree overlaps the bookmark range, deduplicated /// and sorted in document order. Pre-order positions are recomputed per call rather /// than cached — callers in agentic loops should resolve once and reuse the result. /// private IReadOnlyList ResolveBookmarkAnchors(string bookmarkName) { - var main = _doc!.MainDocumentPart; - if (main is null) return Array.Empty(); - var root = main.GetXDocument().Root; - if (root is null) return Array.Empty(); - - var start = root.Descendants(W.bookmarkStart) - .FirstOrDefault(b => (string?)b.Attribute(W.name) == bookmarkName); - if (start is null) return Array.Empty(); + var matches = Internal.OwnedPartRelationships.StoryParts(_doc!) + .SelectMany(o => o.Part.GetXDocument().Descendants(W.bookmarkStart).Select(start => (Owner: o, Start: start))) + .Where(x => (string?)x.Start.Attribute(W.name) == bookmarkName).ToList(); + if (matches.Count != 1) return Array.Empty(); + var owner = matches[0].Owner; + var start = matches[0].Start; + var root = owner.Part.GetXDocument().Root!; var bookmarkId = (string?)start.Attribute(W.id); if (bookmarkId is null) return Array.Empty(); var end = root.Descendants(W.bookmarkEnd) @@ -4184,7 +4244,8 @@ private IReadOnlyList ResolveBookmarkAnchors(string bookmarkName) // candidate block without re-running the converter's KindFor classifier here. var index = Project().AnchorIndex; var byUnid = new Dictionary(StringComparer.Ordinal); - foreach (var t in index.Values) byUnid[t.Unid] = t; + foreach (var t in index.Values) + if (t.PartUri == owner.PartUri) byUnid[t.Unid] = t; // Pre-order positions support two operations: (a) deciding whether a block's // subtree overlaps the bookmark range, (b) sorting the collected hits back into @@ -4205,11 +4266,7 @@ private IReadOnlyList ResolveBookmarkAnchors(string bookmarkName) var unid = (string?)el.Attribute(PtOpenXml.Unid); if (unid is null) continue; if (!byUnid.TryGetValue(unid, out var target)) continue; - // The bookmark we found lives in the body part, so only body-scope anchors - // can possibly intersect it. The guard cheaply rejects same-Unid collisions - // with header/footer/footnote anchors (rare, but possible if the projector's - // index ever surfaces them). - if (!string.Equals(target.Anchor.Scope, "body", StringComparison.Ordinal)) continue; + if (!string.Equals(target.PartUri, owner.PartUri, StringComparison.Ordinal)) continue; var elStart = pos[el]; var lastDesc = el.DescendantsAndSelf().Last(); @@ -5803,7 +5860,11 @@ public EditResult ReplaceText(string anchorId, string markdownPayload) var element = target.Resolve(_doc!); if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element resolved null", anchorId); - + if (_trackedChanges == TrackedChangeMode.RenderInline + && element.Descendants().Any(e => e.Name == W.bookmarkStart || e.Name == W.bookmarkEnd)) + return EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, + "tracked whole-paragraph replacement containing bookmark markers is unsupported; use a surgical span replacement or switch recording mode", + anchorId); // Strip a leading auto-number prefix from the payload before parsing. The // projector emits "## Fourth The total number…" — auto-number from numPr // plus a space separator plus the run text — so an agent that echoes the @@ -5816,6 +5877,12 @@ public EditResult ReplaceText(string anchorId, string markdownPayload) var parsed = Internal.MarkdownPayloadParser.Parse(markdownPayload); if (!parsed.Success) return EditResult.Fail(parsed.Error!.Code, parsed.Error.Message, anchorId); + if (ValidatePendingHyperlinks(parsed.Blocks.SelectMany(b => b.RunElements), anchorId) is { } linkError) + return linkError; + + var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, element); + var oldHyperlinkIds = element.Descendants(W.hyperlink) + .Select(h => (string?)h.Attribute(R.id)).Where(id => !string.IsNullOrEmpty(id)).Cast().ToList(); _history.RecordPreOp(TakeSnapshot()); try @@ -5829,6 +5896,9 @@ public EditResult ReplaceText(string anchorId, string markdownPayload) ApplyReplaceTextAccept(element, parsed.Blocks); } PromoteHyperlinkRelationships(element); + if (hyperlinkOwner is { } owner) + foreach (var relationshipId in oldHyperlinkIds) + Internal.OwnedPartRelationships.DeleteReferenceRelationshipIfOrphaned(owner.Part, relationshipId, R.id); InvalidateProjectionCache(); return new EditResult @@ -5859,6 +5929,7 @@ public EditResult DeleteBlock(string anchorId) var element = target.Resolve(_doc!); if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element resolved null", anchorId); + var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, element); // Word reserves the TYPED footnote/endnote definitions (separator, continuationSeparator, // continuationNotice) for page-rendering scaffolding; they carry no user content and @@ -5869,6 +5940,11 @@ public EditResult DeleteBlock(string anchorId) $"cannot delete a Word-reserved {target.Anchor.Kind} of type='{(string?)element.Attribute(W.type)}'", anchorId); + bool structurallyDeletes = _trackedChanges != TrackedChangeMode.RenderInline + || target.Anchor.Kind is not ("p" or "h" or "li"); + if (structurallyDeletes && ValidateBookmarkRemoval(new[] { element }, anchorId) is { } bookmarkError) + return bookmarkError; + _history.RecordPreOp(TakeSnapshot()); try { @@ -5928,6 +6004,8 @@ public EditResult DeleteBlock(string anchorId) } } element.Remove(); + if (hyperlinkOwner is { } owner) + Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); InvalidateProjectionCache(); return new EditResult { @@ -6086,6 +6164,13 @@ private EditResult DeleteSiblingRangeCore( anchorForPatchScope.Anchor.Id); } + var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, fromElement); + var structuralRoots = _trackedChanges == TrackedChangeMode.RenderInline + ? toRemove.Where(el => el.Name != W.p && el.Name != W.tbl).ToList() + : toRemove; + if (ValidateBookmarkRemoval(structuralRoots, anchorForPatchScope.Anchor.Id) is { } bookmarkError) + return bookmarkError; + _history.RecordPreOp(TakeSnapshot()); try { @@ -6133,6 +6218,8 @@ private EditResult DeleteSiblingRangeCore( el.Remove(); } } + if (hyperlinkOwner is { } trackedOwner) + Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(trackedOwner.Part, R.id); InvalidateProjectionCache(); return new EditResult { @@ -6151,6 +6238,8 @@ private EditResult DeleteSiblingRangeCore( CollectAnchors(el, includeDescendants: true, index, removed, removedIds); el.Remove(); } + if (hyperlinkOwner is { } owner) + Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); InvalidateProjectionCache(); return new EditResult { @@ -6303,6 +6392,16 @@ public EditResult MoveBlock(string sourceAnchorId, string targetAnchorId, Positi (pos == Position.After && ReferenceEquals(target.NextNode, source))) return new EditResult { Success = true }; + // A native tracked move keeps source and destination copies live simultaneously. + // Duplicating bookmark names violates global bookmark identity; moving the markers to + // only one side would lose them on either accept or reject. Reject explicitly instead + // of emitting an ambiguous pending document. + if (_trackedChanges == TrackedChangeMode.RenderInline + && source.DescendantsAndSelf().Any(e => e.Name == W.bookmarkStart || e.Name == W.bookmarkEnd)) + return EditResult.Fail(EditErrorCode.UnsupportedInlineBoundary, + "tracked block moves containing bookmark markers are unsupported because both revision sides are live", + sourceAnchorId); + if (MoveSourceRejection(source) is { } sourceRejection) return EditResult.Fail(EditErrorCode.InvalidPosition, sourceRejection, sourceAnchorId); if (BlockMoveSafetyError(BuildBlockMoveContext(parent), source, target, pos) is { } safetyError) @@ -6329,7 +6428,6 @@ public EditResult MoveBlock(string sourceAnchorId, string targetAnchorId, Positi var destination = new XElement(source); foreach (var el in destination.DescendantsAndSelf()) el.Attributes(PtOpenXml.Unid).Remove(); - RenumberClonedBookmarks(destination); // Both copies are live while the revision is pending, so the shared comment ids have to // be split. The move SOURCE takes the clones (see CloneCommentsForMoveSource), leaving @@ -6632,69 +6730,6 @@ int Reordered(int i) marker.Ancestors().FirstOrDefault(e => ReferenceEquals(e.Parent, parent) && (e.Name == W.p || e.Name == W.tbl)); - /// - /// Give a tracked-move destination clone's bookmarks fresh, document-unique ids, preserving - /// each start↔end pairing and both copies' NAME. - /// - /// - /// A tracked move keeps the source and the destination live at the same time, so every - /// id-bearing marker in the clone is a second copy. w:bookmarkStart/@w:id is - /// uniqueness-constrained, so leaving the clone's ids alone makes the document schema-invalid - /// for as long as the revision is pending — the state a redline is sent out in. - /// - /// The NAME is deliberately duplicated: this mirrors - /// IrMarkupRenderer.NormalizeBookmarks step (B), whose rule for a whole-block-revised - /// paragraph is that the del copy and the ins copy each carry the name into their own - /// resolution. Accepting keeps the destination's bookmark, rejecting keeps the source's, so - /// every REF/PAGEREF/HYPERLINK \l still resolves either way. - /// - /// - private void RenumberClonedBookmarks(XElement destination) - { - var starts = destination.DescendantsAndSelf(W.bookmarkStart).ToList(); - var ends = destination.DescendantsAndSelf(W.bookmarkEnd).ToList(); - if (starts.Count == 0 && ends.Count == 0) - return; - - int next = GlobalMaxBookmarkId() + 1; - foreach (var start in starts) - { - var oldId = (string?)start.Attribute(W.id); - var fresh = (next++).ToString(); - start.SetAttributeValue(W.id, fresh); - // Re-pair: the matching end is the clone's own end with the same original id. - foreach (var end in ends.Where(e => (string?)e.Attribute(W.id) == oldId).Take(1)) - end.SetAttributeValue(W.id, fresh); - } - } - - /// Highest w:bookmarkStart/w:bookmarkEnd id across every story in the - /// package, so a fresh id collides with nothing. Mirrors - /// IrMarkupRenderer.GlobalMaxBookmarkId. - private int GlobalMaxBookmarkId() - { - int max = 0; - var main = _doc!.MainDocumentPart; - if (main is null) - return max; - - void Scan(XElement? root) - { - if (root is null) return; - foreach (var m in root.DescendantsAndSelf() - .Where(e => e.Name == W.bookmarkStart || e.Name == W.bookmarkEnd)) - if (int.TryParse((string?)m.Attribute(W.id), out var v) && v > max) - max = v; - } - - Scan(main.GetXDocument().Root); - foreach (var header in main.HeaderParts) Scan(header.GetXDocument().Root); - foreach (var footer in main.FooterParts) Scan(footer.GetXDocument().Root); - if (main.FootnotesPart is not null) Scan(main.FootnotesPart.GetXDocument().Root); - if (main.EndnotesPart is not null) Scan(main.EndnotesPart.GetXDocument().Root); - return max; - } - /// /// Wrap every not-already-revised run of in a /// revision envelope and mark the paragraph mark to match. @@ -6812,6 +6847,8 @@ public EditResult InsertParagraph(string anchorId, Position pos, string markdown var parsed = Internal.MarkdownPayloadParser.Parse(markdownPayload); if (!parsed.Success) return EditResult.Fail(parsed.Error!.Code, parsed.Error.Message, anchorId); + if (ValidatePendingHyperlinks(parsed.Blocks.SelectMany(b => b.RunElements), anchorId) is { } linkError) + return linkError; if (parsed.Blocks.Count == 0) return EditResult.Fail(EditErrorCode.MalformedMarkdown, "empty payload", anchorId); @@ -7303,6 +7340,16 @@ public EditResult ReplaceCellContent(string cellAnchorId, string markdownPayload var parsed = Internal.MarkdownPayloadParser.Parse(markdownPayload); if (!parsed.Success) return EditResult.Fail(parsed.Error!.Code, parsed.Error.Message, cellAnchorId); + if (ValidatePendingHyperlinks(parsed.Blocks.SelectMany(b => b.RunElements), cellAnchorId) is { } linkError) + return linkError; + + // Replacing cell content removes every block in the cell, including nested tables and + // structured wrappers; validate the whole cell subtree rather than only direct paragraphs. + if (ValidateBookmarkRemoval(new[] { cell! }, cellAnchorId) is { } bookmarkError) + return bookmarkError; + var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, cell); + var oldHyperlinkIds = cell.Descendants(W.hyperlink) + .Select(h => (string?)h.Attribute(R.id)).Where(id => !string.IsNullOrEmpty(id)).Cast().ToList(); _history.RecordPreOp(TakeSnapshot()); try @@ -7316,6 +7363,9 @@ public EditResult ReplaceCellContent(string cellAnchorId, string markdownPayload cell.Add(p); PromoteHyperlinkRelationships(p); } + if (hyperlinkOwner is { } owner) + foreach (var relationshipId in oldHyperlinkIds) + Internal.OwnedPartRelationships.DeleteReferenceRelationshipIfOrphaned(owner.Part, relationshipId, R.id); // A table cell must contain at least one paragraph per OOXML schema. if (!cell.Elements(W.p).Any()) cell.Add(new XElement(W.p)); @@ -7937,8 +7987,29 @@ private EditResult SetHeaderFooterText(bool isHeader, string anchorId, HeaderFoo paras.Add(BuildParagraphFromParsedBlock(block)); } if (paras.Count == 0) paras.Add(new XElement(W.p)); + if (ValidatePendingHyperlinks(paras, anchorId) is { } linkError) + return linkError; ApplyHeaderFooterStyle(paras, isHeader); + // Resolve an existing same-kind story before snapshotting so replacing its root cannot + // silently remove one end of a cross-boundary bookmark or a still-targeted bookmark. + var currentSectPr = Internal.BlockMetadataOps.FindGoverningSectPr(element); + if (currentSectPr is not null) + { + var currentRefName = isHeader ? W.headerReference : W.footerReference; + var currentType = HeaderFooterTypeValue(kind); + var currentRef = currentSectPr.Elements(currentRefName) + .FirstOrDefault(r => (string?)r.Attribute(W.type) == currentType); + OpenXmlPart? currentPart = null; + if ((string?)currentRef?.Attribute(R.id) is { } currentRid) + foreach (var pp in main.Parts) + if (pp.RelationshipId == currentRid) { currentPart = pp.OpenXmlPart; break; } + bool currentTypeMatches = isHeader ? currentPart is HeaderPart : currentPart is FooterPart; + if (currentTypeMatches && currentPart?.GetXDocument().Root is { } oldRoot + && ValidateBookmarkRemoval(new[] { oldRoot }, anchorId) is { } bookmarkError) + return bookmarkError; + } + _history.RecordPreOp(TakeSnapshot()); try { @@ -7964,9 +8035,12 @@ private EditResult SetHeaderFooterText(bool isHeader, string anchorId, HeaderFoo bool typeMatches = isHeader ? reuse is HeaderPart : reuse is FooterPart; OpenXmlPart part; + var oldHyperlinkIds = new List(); if (reuse is not null && typeMatches) { part = reuse; + oldHyperlinkIds.AddRange(part.GetXDocument().Descendants(W.hyperlink) + .Select(h => (string?)h.Attribute(R.id)).Where(id => !string.IsNullOrEmpty(id)).Cast()); } else { @@ -7986,6 +8060,9 @@ private EditResult SetHeaderFooterText(bool isHeader, string anchorId, HeaderFoo new XAttribute(XNamespace.Xmlns + "r", R.r), paras); part.PutXDocument(new XDocument(newRoot)); + foreach (var p in paras) PromoteHyperlinkRelationships(p); + foreach (var relationshipId in oldHyperlinkIds) + Internal.OwnedPartRelationships.DeleteReferenceRelationshipIfOrphaned(part, relationshipId, R.id); // Visibility flags so Word actually shows the First/Even stories. if (kind == HeaderFooterKind.First && sectPr.Element(W.titlePg) is null) @@ -8344,6 +8421,8 @@ private EditResult InsertNote(bool isFootnote, string anchorId, int characterOff paras.Add(BuildParagraphFromParsedBlock(block)); } if (paras.Count == 0) paras.Add(new XElement(W.p)); + if (ValidatePendingHyperlinks(paras, anchorId) is { } linkError) + return linkError; _history.RecordPreOp(TakeSnapshot()); try @@ -8374,6 +8453,7 @@ private EditResult InsertNote(bool isFootnote, string anchorId, int characterOff paras); root.Add(note); UnidHelper.AssignToSelfAndDescendants(note); + foreach (var p in paras) PromoteHyperlinkRelationships(p); part.PutXDocument(); InvalidateProjectionCache(); @@ -9185,6 +9265,17 @@ public EditResult InsertTable(string anchorId, Position pos, int rows, int cols, var opts = options ?? new TableInsertOptions(); var contents = opts.CellContents; + if (contents is not null) + { + foreach (var markdown in contents.Where(s => !string.IsNullOrEmpty(s))) + { + var parsedCell = Internal.MarkdownPayloadParser.Parse(markdown!); + if (parsedCell.Success + && ValidatePendingHyperlinks(parsedCell.Blocks.SelectMany(b => b.RunElements), anchorId) is { } linkError) + return linkError; + } + } + // Explicit per-column widths: one per column, all positive. A mismatched count is a // caller error — reject rather than silently equalize (no silent caps). var colWidths = opts.ColumnWidths; @@ -9666,6 +9757,10 @@ public EditResult DeleteTableRow(string cellAnchorId) { if (ResolveCell(cellAnchorId, out _, out _, out var tr, out var tbl, out var target) is { } err) return err; + var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, tbl!); + var removalRoot = tbl!.Elements(W.tr).Count() <= 1 ? tbl : tr!; + if (ValidateBookmarkRemoval(new[] { removalRoot }, cellAnchorId) is { } bookmarkError) + return bookmarkError; var before = CaptureTableMetadata(tbl!); _history.RecordPreOp(TakeSnapshot()); @@ -9681,6 +9776,9 @@ public EditResult DeleteTableRow(string cellAnchorId) tr.Remove(); } + if (hyperlinkOwner is { } owner) + Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); + InvalidateProjectionCache(); var mapping = CompleteTableMapping(before, tbl); return new EditResult @@ -9707,8 +9805,17 @@ public EditResult DeleteTableColumn(string cellAnchorId) { if (ResolveCell(cellAnchorId, out _, out var tc, out var tr, out var tbl, out var target) is { } err) return err; + var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, tbl!); int doomed = RowGrid(tr!).First(g => g.Tc == tc).Start; + int existingColumns = GridColumnCount(tbl!); + var removalRoots = existingColumns <= 1 + ? new List { tbl! } + : tbl!.Elements(W.tr).Select(row => CellCovering(RowGrid(row), doomed)) + .Where(cell => cell.HasValue && cell.Value.Span == 1) + .Select(cell => cell!.Value.Tc).ToList(); + if (ValidateBookmarkRemoval(removalRoots, cellAnchorId) is { } bookmarkError) + return bookmarkError; var before = CaptureTableMetadata(tbl!); _history.RecordPreOp(TakeSnapshot()); @@ -9716,7 +9823,7 @@ public EditResult DeleteTableColumn(string cellAnchorId) { EnsureGridColumnsForMutation(tbl!); var grid = tbl!.Element(W.tblGrid); - int colCount = GridColumnCount(tbl); + int colCount = existingColumns; int lostWidth = GridColWidths(tbl) is { } widths && doomed < widths.Count ? widths[doomed] : 0; if (colCount <= 1) tbl.Remove(); @@ -9751,6 +9858,9 @@ public EditResult DeleteTableColumn(string cellAnchorId) if (cols is not null && doomed < cols.Count) cols[doomed].Remove(); } + if (hyperlinkOwner is { } owner) + Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); + InvalidateProjectionCache(); var mapping = CompleteTableMapping(before, tbl); return new EditResult @@ -9797,7 +9907,7 @@ private static List CellBlocks(XElement tc) => private static XElement? EmptyCellBody(XElement tc) { var blocks = CellBlocks(tc); - if (blocks is [var only] && IsEmptyBlock(only)) return null; + if (blocks.Count == 1 && IsEmptyBlock(blocks[0])) return null; foreach (var b in blocks) b.Remove(); var p = new XElement(W.p); UnidHelper.AssignToSelfAndDescendants(p); @@ -9869,6 +9979,13 @@ public EditResult MergeCells(string cellAnchorId, int rowSpan, int colSpan, cellAnchorId); var before = CaptureTableMetadata(tbl); + var discardedBlocks = opts.Content == TableMergeContent.Append + ? absorbed.SelectMany(CellBlocks).Where(IsEmptyBlock).ToList() + : absorbed.SelectMany(CellBlocks).ToList(); + if (ValidateBookmarkRemoval(discardedBlocks, cellAnchorId) is { } bookmarkError) + return bookmarkError; + + var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, tbl); _history.RecordPreOp(TakeSnapshot()); try { @@ -9895,6 +10012,9 @@ public EditResult MergeCells(string cellAnchorId, int rowSpan, int colSpan, if (i > 0) _ = EmptyCellBody(keep); } + if (hyperlinkOwner is { } owner) + Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); + InvalidateProjectionCache(); var mapping = CompleteTableMapping(before, tbl); return new EditResult @@ -11156,7 +11276,8 @@ internal sealed record DocumentSnapshot( System.Collections.Generic.IReadOnlyList<(string RelId, bool IsHeader, string PartUri)> HeaderFooterParts, System.Collections.Generic.IReadOnlyList<(string RelId, bool IsFootnote, string PartUri)> NoteParts, System.Collections.Generic.IReadOnlyList<(string RelId, string PartUri)> CommentParts, - System.Collections.Generic.IReadOnlyList<(string RelId, bool IsCommentsEx, string PartUri)> CommentThreadingParts) + System.Collections.Generic.IReadOnlyList<(string RelId, bool IsCommentsEx, string PartUri)> CommentThreadingParts, + System.Collections.Generic.IReadOnlyList<(string PartUri, string RelId, string Uri, bool IsExternal)> HyperlinkRelationships) { /// /// Optional exact package checkpoint used by transaction boundaries. Unlike the selective @@ -11192,6 +11313,7 @@ internal DocumentSnapshot TakeSnapshot() var noteParts = new System.Collections.Generic.List<(string, bool, string)>(); var commentParts = new System.Collections.Generic.List<(string, string)>(); var commentThreadingParts = new System.Collections.Generic.List<(string, bool, string)>(); + var hyperlinkRelationships = new System.Collections.Generic.List<(string, string, string, bool)>(); var main = _doc!.MainDocumentPart; if (main is not null) { @@ -11210,7 +11332,12 @@ internal DocumentSnapshot TakeSnapshot() commentThreadingParts.Add((main.GetIdOfPart(main.WordprocessingCommentsIdsPart), false, main.WordprocessingCommentsIdsPart.Uri.ToString())); } - return new DocumentSnapshot(_version, parts, hfParts, noteParts, commentParts, commentThreadingParts); + foreach (var owner in Internal.OwnedPartRelationships.StoryParts(_doc!)) + foreach (var relationship in owner.Part.HyperlinkRelationships) + hyperlinkRelationships.Add((owner.PartUri, relationship.Id, + relationship.Uri.ToString(), relationship.IsExternal)); + return new DocumentSnapshot(_version, parts, hfParts, noteParts, commentParts, + commentThreadingParts, hyperlinkRelationships); } /// @@ -11227,7 +11354,8 @@ internal DocumentSnapshot TakePackageSnapshot() Array.Empty<(string RelId, bool IsHeader, string PartUri)>(), Array.Empty<(string RelId, bool IsFootnote, string PartUri)>(), Array.Empty<(string RelId, string PartUri)>(), - Array.Empty<(string RelId, bool IsCommentsEx, string PartUri)>()) + Array.Empty<(string RelId, bool IsCommentsEx, string PartUri)>(), + Array.Empty<(string PartUri, string RelId, string Uri, bool IsExternal)>()) { PackageBytes = bytes, RevisionCounter = _revisionCounter, @@ -11382,6 +11510,8 @@ internal void RestoreSnapshot(DocumentSnapshot snapshot) ReconcileCommentThreadingParts(main, snapshot, byUri); } + RestoreHyperlinkRelationships(snapshot); + // The annotations CustomXmlPart is reconciled the same way (its own factory) — see // EnumerateProjectedPartsForSnapshot for why AddCustomXmlPart(CustomXml) is unsafe for // non-annotation custom-xml parts (wrong content type, no CustomXmlPropertiesPart partner). @@ -11429,6 +11559,36 @@ private void RestorePackage(byte[] packageBytes) InvalidateProjectionCache(); } + /// Restore reference-relationship topology as well as XML. Without this, undoing a + /// hyperlink create/delete restores r:id attributes but leaves the corresponding + /// package relationship in the wrong state. + private void RestoreHyperlinkRelationships(DocumentSnapshot snapshot) + { + var expectedByPart = snapshot.HyperlinkRelationships + .GroupBy(r => r.PartUri, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToDictionary(r => r.RelId, StringComparer.Ordinal), StringComparer.Ordinal); + foreach (var owner in Internal.OwnedPartRelationships.StoryParts(_doc!)) + { + expectedByPart.TryGetValue(owner.PartUri, out var expected); + expected ??= new System.Collections.Generic.Dictionary(StringComparer.Ordinal); + var live = owner.Part.HyperlinkRelationships.ToDictionary(r => r.Id, StringComparer.Ordinal); + foreach (var relationship in live.Values) + if (!expected.ContainsKey(relationship.Id)) + owner.Part.DeleteReferenceRelationship(relationship.Id); + foreach (var relationship in expected.Values) + { + if (live.TryGetValue(relationship.RelId, out var existing) + && existing.Uri.ToString() == relationship.Uri + && existing.IsExternal == relationship.IsExternal) continue; + if (live.ContainsKey(relationship.RelId)) + owner.Part.DeleteReferenceRelationship(relationship.RelId); + owner.Part.AddHyperlinkRelationship( + new Uri(relationship.Uri, UriKind.RelativeOrAbsolute), + relationship.IsExternal, relationship.RelId); + } + } + } + /// /// Reconcile the live document's header/footer parts against : /// delete parts created since the snapshot (relationship id present live, absent in snapshot) @@ -11657,6 +11817,38 @@ private static (List pre, List post) ExtractWrappingMarkers( return (pre, post); } + private sealed record PreservedMarkerPosition(XElement Element, int Offset, int Order); + + /// + /// Capture zero-width markers before a whole-paragraph replacement. Bookmark endpoints retain + /// their old character coordinate when the replacement is long enough and clamp to its end + /// otherwise. This is deterministic and, unlike the old leading-marker fallback, cannot invert + /// or collapse a range merely because its end marker originally sat between two runs. + /// + private static List CapturePreservedMarkerPositions(XElement paragraph) + { + var candidates = paragraph.Elements() + .Where(e => PreservedMarkerNames.Contains(e.Name) || IsNoteRefOnlyRun(e)) + .Concat(paragraph.Descendants() + .Where(e => e.Name == W.bookmarkStart || e.Name == W.bookmarkEnd)) + .Distinct() + .OrderBy(e => e, Comparer.Create(XNode.DocumentOrderComparer.Compare)) + .ToList(); + var result = candidates.Select((element, order) => + new PreservedMarkerPosition(element, MarkerOffset(paragraph, element), order)).ToList(); + foreach (var marker in candidates) marker.Remove(); + return result; + } + + private static void RestorePreservedMarkerPositions( + XElement paragraph, IReadOnlyList markers) + { + int length = ParagraphText(paragraph).Length; + foreach (var group in markers.GroupBy(m => Math.Clamp(m.Offset, 0, length)).OrderBy(g => g.Key)) + InsertMarkersAtOffset(paragraph, group.Key, + group.OrderBy(m => m.Order).Select(m => m.Element).ToList()); + } + /// /// If carries a resolvable w:numPr auto-number /// (e.g. "1.", "Fourth"), strip a matching leading prefix from @@ -11682,14 +11874,13 @@ private string StripResolvedAutoNumberPrefix(XElement paragraph, string payload) private static void ApplyReplaceTextAccept(XElement paragraph, IReadOnlyList blocks) { var pPr = paragraph.Element(W.pPr); - var (preMarkers, postMarkers) = ExtractWrappingMarkers(paragraph); + var markers = CapturePreservedMarkerPositions(paragraph); paragraph.RemoveNodes(); if (pPr is not null) paragraph.Add(pPr); - foreach (var m in preMarkers) paragraph.Add(m); if (blocks.Count > 0) foreach (var run in blocks[0].RunElements) paragraph.Add(new XElement(run)); - foreach (var m in postMarkers) paragraph.Add(m); + RestorePreservedMarkerPositions(paragraph, markers); } private void ApplyReplaceTextTracked(XElement paragraph, IReadOnlyList blocks) @@ -11878,28 +12069,27 @@ private void MarkTrackedStructuredContentChild(XElement child) private void PromoteHyperlinkRelationships(XElement paragraph) { - var main = _doc!.MainDocumentPart!; - // Reuse an existing relationship when the same URL has already been registered. - // Without dedup, every ReplaceText with a link adds a fresh rId; an agent loop - // that edits the same paragraph N times grows the .rels file unboundedly. - var existing = main.HyperlinkRelationships - .GroupBy(rl => rl.Uri.ToString()) - .ToDictionary(g => g.Key, g => g.First().Id); + var owner = Internal.OwnedPartRelationships.FindOwner(_doc!, paragraph) + ?? throw new InvalidOperationException("hyperlink paragraph has no owning package part"); foreach (var link in paragraph.Descendants(W.hyperlink).ToList()) { var hrefAttr = link.Attribute(Internal.MarkdownPayloadParser.HrefAttr); if (hrefAttr is null) continue; var url = hrefAttr.Value; - string relId; - if (existing.TryGetValue(url, out var foundId)) relId = foundId; + if (url.StartsWith("#", StringComparison.Ordinal)) + { + // Internal jumps are relationship-free OOXML. Writing r:id to an external + // relationship whose URI is literally "#name" is invalid Word semantics (#469). + link.SetAttributeValue(W.anchor, url.Substring(1)); + link.Attribute(R.id)?.Remove(); + } else { - var rel = main.AddHyperlinkRelationship( - new Uri(url, UriKind.RelativeOrAbsolute), true); - relId = rel.Id; - existing[url] = relId; + var relationship = Internal.OwnedPartRelationships.FindOrAddExternalHyperlink( + owner.Part, new Uri(url, UriKind.RelativeOrAbsolute)); + link.SetAttributeValue(R.id, relationship.Id); + link.Attribute(W.anchor)?.Remove(); } - link.SetAttributeValue(R.id, relId); hrefAttr.Remove(); } } @@ -12424,18 +12614,18 @@ private static void SplitHyperlinkAt(XElement hyperlink, int localOffset) SplitRunsAtOffset(hyperlink, localOffset); int consumed = 0; - var movedRuns = new List(); - foreach (var run in hyperlink.Elements(W.r).ToList()) + var movedChildren = new List(); + foreach (var child in hyperlink.Elements().ToList()) { - int len = RunText(run).Length; - if (consumed >= localOffset) movedRuns.Add(run); + int len = IsInlineChild(child) ? InlineChildTextLength(child) : 0; + if (consumed >= localOffset) movedChildren.Add(child); consumed += len; } - if (movedRuns.Count == 0) return; + if (movedChildren.Count == 0) return; var newLink = new XElement(W.hyperlink); foreach (var a in hyperlink.Attributes()) newLink.SetAttributeValue(a.Name, a.Value); - foreach (var run in movedRuns) { run.Remove(); newLink.Add(run); } + foreach (var child in movedChildren) { child.Remove(); newLink.Add(child); } hyperlink.AddAfterSelf(newLink); } diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs index 0460f662..00ccd6f4 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -661,6 +661,10 @@ public static string Serialize(EditResult r) } if (r.AnnotationId is not null) sb.Append(",\"annotationId\":").Append(JsonString(r.AnnotationId)); + if (r.HyperlinkId is not null) + sb.Append(",\"hyperlinkId\":").Append(JsonString(r.HyperlinkId)); + if (r.BookmarkName is not null) + sb.Append(",\"bookmarkName\":").Append(JsonString(r.BookmarkName)); if (r.Patch is not null) { sb.Append(",\"patch\":{") @@ -904,6 +908,73 @@ private static string PageMapAvailabilityString(PageMapAvailability availability private static string Invariant(double value) => value.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + public static string SerializeHyperlinks(IReadOnlyList links) + { + var sb = new StringBuilder(links.Count * 220 + 2).Append('['); + for (int i = 0; i < links.Count; i++) + { + if (i > 0) sb.Append(','); + var link = links[i]; + sb.Append("{\"id\":").Append(JsonString(link.Id)) + .Append(",\"kind\":").Append(JsonString(link.Kind == HyperlinkKind.Internal ? "internal" : "external")) + .Append(",\"owningPartUri\":").Append(JsonString(link.OwningPartUri)) + .Append(",\"scope\":").Append(JsonString(link.Scope)) + .Append(",\"anchorId\":").Append(JsonString(link.AnchorId)) + .Append(",\"span\":{\"start\":").Append(link.Span.Start) + .Append(",\"length\":").Append(link.Span.Length).Append('}') + .Append(",\"text\":").Append(JsonString(link.Text)); + if (link.Target is not null) sb.Append(",\"target\":").Append(JsonString(link.Target)); + if (link.RelationshipId is not null) sb.Append(",\"relationshipId\":").Append(JsonString(link.RelationshipId)); + if (link.RelationshipIsExternal is not null) + sb.Append(",\"relationshipIsExternal\":").Append(link.RelationshipIsExternal.Value ? "true" : "false"); + sb.Append(",\"isBroken\":").Append(link.IsBroken ? "true" : "false").Append('}'); + } + return sb.Append(']').ToString(); + } + + public static string SerializeBookmarks(IReadOnlyList bookmarks) + { + var sb = new StringBuilder(bookmarks.Count * 320 + 2).Append('['); + for (int i = 0; i < bookmarks.Count; i++) + { + if (i > 0) sb.Append(','); + var bookmark = bookmarks[i]; + sb.Append("{\"name\":").Append(JsonString(bookmark.Name)) + .Append(",\"bookmarkId\":").Append(JsonString(bookmark.BookmarkId)) + .Append(",\"startPartUri\":").Append(JsonString(bookmark.StartPartUri)) + .Append(",\"startScope\":").Append(JsonString(bookmark.StartScope)); + if (bookmark.EndPartUri is not null) sb.Append(",\"endPartUri\":").Append(JsonString(bookmark.EndPartUri)); + if (bookmark.EndScope is not null) sb.Append(",\"endScope\":").Append(JsonString(bookmark.EndScope)); + if (bookmark.Range is { } range) + { + sb.Append(",\"range\":{\"startAnchorId\":").Append(JsonString(range.StartAnchorId)) + .Append(",\"startOffset\":").Append(range.StartOffset) + .Append(",\"endAnchorId\":").Append(JsonString(range.EndAnchorId)) + .Append(",\"endOffset\":").Append(range.EndOffset).Append('}'); + } + sb.Append(",\"segments\":["); + for (int s = 0; s < bookmark.Segments.Count; s++) + { + if (s > 0) sb.Append(','); + var segment = bookmark.Segments[s]; + sb.Append("{\"owningPartUri\":").Append(JsonString(segment.OwningPartUri)) + .Append(",\"scope\":").Append(JsonString(segment.Scope)) + .Append(",\"anchorId\":").Append(JsonString(segment.AnchorId)) + .Append(",\"span\":{\"start\":").Append(segment.Span.Start) + .Append(",\"length\":").Append(segment.Span.Length).Append('}') + .Append(",\"text\":").Append(JsonString(segment.Text)).Append('}'); + } + sb.Append(']').Append(",\"text\":").Append(JsonString(bookmark.Text)) + .Append(",\"isPaired\":").Append(bookmark.IsPaired ? "true" : "false") + .Append(",\"isManaged\":").Append(bookmark.IsManaged ? "true" : "false") + .Append(",\"isValid\":").Append(bookmark.IsValid ? "true" : "false"); + if (bookmark.ValidationError is not null) + sb.Append(",\"validationError\":").Append(JsonString(bookmark.ValidationError)); + sb.Append('}'); + } + return sb.Append(']').ToString(); + } + public static string SerializeEditResults(IReadOnlyList results) { var sb = new StringBuilder(256); diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs index b8f2df96..041f469a 100644 --- a/Docxodus/Internal/DocxSessionOps.cs +++ b/Docxodus/Internal/DocxSessionOps.cs @@ -512,6 +512,72 @@ public static string RemoveComment(int handle, string commentAnchorId, public static string ListComments(int handle) => DocxSessionJson.SerializeCommentList(SessionRegistry.Get(handle).ListComments()); + // ─── Hyperlinks / bookmarks (issue #451) ─────────────────────────── + + public static string ListHyperlinks(int handle, ProjectionScopes scopes = ProjectionScopes.All) => + DocxSessionJson.SerializeHyperlinks(SessionRegistry.Get(handle).ListHyperlinks(scopes)); + + public static string AddHyperlink(int handle, string anchorId, int start, int length, + string kind, string target) + { + var session = SessionRegistry.Get(handle); + if (!TryParseHyperlinkTarget(kind, target, out var parsed)) + return InvalidHyperlinkKind(kind, anchorId); + return DocxSessionJson.Serialize(session.AddHyperlink(anchorId, + new CharSpan(start, length), parsed)); + } + + public static string UpdateHyperlink(int handle, string hyperlinkId, string kind, string target) + { + var session = SessionRegistry.Get(handle); + if (!TryParseHyperlinkTarget(kind, target, out var parsed)) + return InvalidHyperlinkKind(kind); + return DocxSessionJson.Serialize(session.UpdateHyperlink(hyperlinkId, parsed)); + } + + public static string RemoveHyperlink(int handle, string hyperlinkId) => + DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveHyperlink(hyperlinkId)); + + public static string ListBookmarks(int handle, ProjectionScopes scopes = ProjectionScopes.All) => + DocxSessionJson.SerializeBookmarks(SessionRegistry.Get(handle).ListBookmarks(scopes)); + + public static string AddBookmark(int handle, string name, string startAnchorId, int startOffset, + string endAnchorId, int endOffset) => + DocxSessionJson.Serialize(SessionRegistry.Get(handle).AddBookmark(name, + new DocumentRange(startAnchorId, startOffset, endAnchorId, endOffset))); + + public static string RenameBookmark(int handle, string name, string newName) => + DocxSessionJson.Serialize(SessionRegistry.Get(handle).RenameBookmark(name, newName)); + + public static string MoveBookmark(int handle, string name, string startAnchorId, int startOffset, + string endAnchorId, int endOffset) => + DocxSessionJson.Serialize(SessionRegistry.Get(handle).MoveBookmark(name, + new DocumentRange(startAnchorId, startOffset, endAnchorId, endOffset))); + + public static string RemoveBookmark(int handle, string name) => + DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveBookmark(name)); + + private static bool TryParseHyperlinkTarget(string kind, string target, + out HyperlinkTarget parsed) + { + if (string.Equals(kind, "internal", StringComparison.OrdinalIgnoreCase)) + { + parsed = HyperlinkTarget.Internal(target); + return true; + } + if (string.Equals(kind, "external", StringComparison.OrdinalIgnoreCase)) + { + parsed = HyperlinkTarget.External(target); + return true; + } + parsed = null!; + return false; + } + + private static string InvalidHyperlinkKind(string kind, string? anchorId = null) => + DocxSessionJson.Serialize(EditResult.Fail(EditErrorCode.InvalidHyperlinkTarget, + $"unknown hyperlink target kind '{kind}'; expected 'internal' or 'external'", anchorId)); + // ─── Tier C: formatting ───────────────────────────────────────────── public static string ApplyFormat(int handle, string anchorId, CharSpan? span, FormatOp op, diff --git a/Docxodus/Internal/OwnedPartRelationships.cs b/Docxodus/Internal/OwnedPartRelationships.cs new file mode 100644 index 00000000..75f462c0 --- /dev/null +++ b/Docxodus/Internal/OwnedPartRelationships.cs @@ -0,0 +1,109 @@ +// 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.Linq; +using System.Xml.Linq; +using DocumentFormat.OpenXml.Packaging; + +namespace Docxodus.Internal; + +/// +/// Relationship operations whose owner is the package part containing the referring XML. +/// Hyperlinks use this today; image authoring can reuse the same owner lookup/reference-counted +/// cleanup without learning anything about hyperlink markup. +/// +internal static class OwnedPartRelationships +{ + internal readonly record struct Owner(OpenXmlPart Part, string Scope) + { + public string PartUri => Part.Uri.ToString(); + } + + internal static IReadOnlyList StoryParts(WordprocessingDocument document) + { + var result = new List(); + var main = document.MainDocumentPart; + if (main is null) return result; + + result.Add(new Owner(main, "body")); + int n = 0; + foreach (var part in main.HeaderParts) result.Add(new Owner(part, "hdr" + ++n)); + n = 0; + foreach (var part in main.FooterParts) result.Add(new Owner(part, "ftr" + ++n)); + if (main.FootnotesPart is not null) result.Add(new Owner(main.FootnotesPart, "fn")); + if (main.EndnotesPart is not null) result.Add(new Owner(main.EndnotesPart, "en")); + return result; + } + + internal static Owner? FindOwner(WordprocessingDocument document, XElement element) + { + var root = element.AncestorsAndSelf().Last(); + foreach (var owner in StoryParts(document)) + if (ReferenceEquals(owner.Part.GetXDocument().Root, root)) return owner; + return null; + } + + internal static IEnumerable ReferencedIds(XElement root, params XName[] attributes) => + root.DescendantsAndSelf() + .SelectMany(e => attributes.Select(a => (string?)e.Attribute(a))) + .Where(id => !string.IsNullOrEmpty(id))! + .Cast(); + + internal static bool IsReferenced(OpenXmlPart owner, string relationshipId, params XName[] attributes) + { + var root = owner.GetXDocument().Root; + return root is not null && ReferencedIds(root, attributes) + .Any(id => string.Equals(id, relationshipId, StringComparison.Ordinal)); + } + + internal static bool DeleteReferenceRelationshipIfOrphaned( + OpenXmlPart owner, string? relationshipId, params XName[] referenceAttributes) + { + if (string.IsNullOrEmpty(relationshipId) + || IsReferenced(owner, relationshipId, referenceAttributes)) return false; + try + { + owner.DeleteReferenceRelationship(relationshipId); + return true; + } + catch (KeyNotFoundException) { return false; } + catch (ArgumentOutOfRangeException) { return false; } + } + + /// + /// Deletes a child-part relationship only after the owning part's XML has no remaining + /// references to its id. This is deliberately generic: drawing/image operations can pass + /// r:embed/r:link; hyperlink code passes r:id to the reference variant. + /// + internal static bool DeletePartRelationshipIfOrphaned( + OpenXmlPart owner, OpenXmlPart target, params XName[] referenceAttributes) + { + var id = owner.GetIdOfPart(target); + if (IsReferenced(owner, id, referenceAttributes)) return false; + owner.DeletePart(target); + return true; + } + + internal static HyperlinkRelationship FindOrAddExternalHyperlink(OpenXmlPart owner, Uri uri) + { + var existing = owner.HyperlinkRelationships.FirstOrDefault(r => + r.IsExternal && Uri.Compare(r.Uri, uri, UriComponents.SerializationInfoString, + UriFormat.UriEscaped, StringComparison.Ordinal) == 0); + return existing ?? owner.AddHyperlinkRelationship(uri, true); + } + + /// Remove every hyperlink relationship no longer referenced by XML in this owner. + /// Call only after a destructive mutation of that owner; live shared relationships survive + /// because cleanup is reference-counted against the complete part tree. + internal static int SweepOrphanedHyperlinks(OpenXmlPart owner, XName relationshipAttribute) + { + int removed = 0; + foreach (var relationship in owner.HyperlinkRelationships.ToList()) + if (DeleteReferenceRelationshipIfOrphaned(owner, relationship.Id, relationshipAttribute)) removed++; + return removed; + } +} diff --git a/README.md b/README.md index ed152f6e..9aebed92 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ document. That gives an agent something a raw text dump can't: a way to *point*. - Read the markdown, decide "rewrite the indemnification clause", write back to that anchor. - Anchors are shared across the whole stack — the same id addresses a projection block, a rendered DOM node (`data-anchor`), a diff revision, and an edit target. -- Resolve intent to anchors by text, regex, kind, bookmark, or annotation id — no re-walking the +- Resolve intent to anchors by text, regex, kind, bookmark, or annotation id; enumerate and safely + mutate native hyperlinks and multi-paragraph bookmarks — no re-walking the document. - Also exports to [OpenContracts](docs/architecture/opencontracts_export.md) format with PAWLS page layout and token positions, for NLP and document-analysis pipelines. diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md index 1c2bc007..3d67e4b5 100644 --- a/docs/architecture/docx_agent_server.md +++ b/docs/architecture/docx_agent_server.md @@ -15,7 +15,7 @@ to `tools/python-host/` (a stdio host for a *library-shaped* API, one request pe this server groups the same underlying `DocxSession` surface into a smaller number of **intent-shaped tools**, because that is the granularity an LLM tool-calling loop wants — a model picks from a short, memorable list of verbs (`docxodus_edit`, `docxodus_format`, -`docxodus_table`, …) with an `action` discriminator, rather than one MCP tool per one of +`docxodus_table`, `docxodus_links`, …) with an `action` discriminator, rather than one MCP tool per one of `DocxSession`'s ~40 public methods. Every tool call ultimately routes through `Docxodus.Internal.DocxSessionOps` (and, for tracked @@ -28,7 +28,7 @@ facade the WASM bridge and the Python stdio host use. No new editing logic lives Document-editing MCP servers built around "open a file into a stateful in-memory session, address every subsequent edit by a stable anchor id, group many operations under a handful of grouped-intent tools (read / preview / pagination / search / edit / format / create / list / -comment / annotate / track-changes / batch-mutate / table), save on request" are a known-good shape for this problem — it matches how +comment / annotate / links / track-changes / batch-mutate / table), save on request" are a known-good shape for this problem — it matches how this class of tool is used in practice: an agent reads a projection once, holds anchor ids in its context, and issues a sequence of small, anchor-addressed mutations before saving. This server adopts that shape but is a clean-room implementation against Docxodus's own `DocxSession` engine @@ -103,7 +103,7 @@ unknown methods, which a well-behaved client should never produce. ``` docxodus_open(path) → session_id ↓ (any number of docxodus_edit / docxodus_format / docxodus_create / docxodus_table / - ↓ docxodus_list / docxodus_comment / docxodus_annotate / docxodus_track_changes / + ↓ docxodus_list / docxodus_comment / docxodus_annotate / docxodus_links / docxodus_track_changes / ↓ docxodus_mutations calls) docxodus_save(session_id, path?) ↓ @@ -374,6 +374,20 @@ entries it owned and clears child links that would otherwise dangle. Documented Deliberately distinct from `docxodus_comment`: the overlay semantically tags regions for external tools (e.g. OpenContracts) and never appears in Word's Reviewing UI. +### `docxodus_links` — native hyperlinks and bookmarks + +`list_hyperlinks`/`add_hyperlink`/`update_hyperlink`/`remove_hyperlink` and +`list_bookmarks`/`add_bookmark`/`move_bookmark`/`rename_bookmark`/`remove_bookmark` map directly to +the first-class session API. Hyperlink targets use `targetKind: "external"|"internal"`; bookmark +ranges use `startAnchorId`/`startOffset` and `endAnchorId`/`endOffset`. List actions accept the +same numeric `ProjectionScopes` flag mask as the core API. + +The result is structured enough for an agent to round-trip unchanged: hyperlink ids feed update +or remove, while bookmark names feed move/rename/remove. Missing targets, duplicate/invalid names, +cross-part ranges, active inbound links, unsupported inline boundaries, and tracked-mode metadata +edits are ordinary typed `EditResult` failures. External relationships are owned by the actual +body/header/footer/note part; internal targets are relationship-free `w:anchor` links. + ### `docxodus_track_changes` — list/accept/reject tracked changes, switch recording mode `set_mode` (issue #304) switches how the session records its *own subsequent* edits — @@ -421,7 +435,7 @@ per-revision listing does not enumerate (see Known gaps). ### `docxodus_mutations` — atomic batches, explicit partial apply, or isolated preview `steps: [{ tool, args }]` where `tool` is one of `docxodus_edit`/`docxodus_format`/ -`docxodus_create`/`docxodus_table`/`docxodus_list`/`docxodus_comment` (their `undo`/`redo` and +`docxodus_create`/`docxodus_table`/`docxodus_list`/`docxodus_comment`/`docxodus_links` (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/docx_mutation_api.md b/docs/architecture/docx_mutation_api.md index 40139511..60b64395 100644 --- a/docs/architecture/docx_mutation_api.md +++ b/docs/architecture/docx_mutation_api.md @@ -344,11 +344,11 @@ is succeeds as a no-op **and records no undo snapshot**. `accept ≡ the requested order` and `reject ≡ the original order` hold for both shapes. -**Two live copies, so ids must be split.** A tracked move duplicates the block, and the clone's -id-bearing markers would otherwise collide: +**Two live copies, so identities must stay unambiguous.** A tracked move duplicates the block: -- bookmarks — the destination clone takes fresh document-unique ids; **both copies keep the - NAME**, so each survives its own resolution and cross-references resolve either way; +- bookmarks — rejected with `UnsupportedInlineBoundary` when the source contains bookmark + markers. Both revision sides are live, so duplicating the name violates the first-class global + name contract while keeping the markers on only one side loses them on accept or reject; - comments — the move SOURCE takes a fresh comment id and a cloned definition (fresh `w14:paraId`, entries in both threading parts, cloned replies re-pointed at cloned parents), leaving the destination on the original comment and its thread; @@ -362,6 +362,7 @@ id-bearing markers would otherwise collide: cross-block comment, bookmark, permission or native-move range; a move whose span crosses a section-break paragraph; a source already inside a native move range; and — in tracked mode only — a source that already contains revision markup a move would have to re-wrap. +Tracked moves containing bookmark markers are also refused with `UnsupportedInlineBoundary`. `AnchorWrongKind` for a non-block kind or a non-top-level block (a table cell paragraph: move the whole table instead). @@ -464,6 +465,66 @@ same undo, same `EditResult` accounting, the same native `w:sdt` envelope and recursive payload markup, the same pre-mutation `w:customXml` refusal, and the same reported structural fall-through. +## Native hyperlinks and bookmarks + +Hyperlinks and bookmarks are addressable document objects, not projection-only formatting: + +```csharp +IReadOnlyList ListHyperlinks(ProjectionScopes scopes = ProjectionScopes.All); +EditResult AddHyperlink(string anchorId, CharSpan span, HyperlinkTarget target); +EditResult UpdateHyperlink(string hyperlinkId, HyperlinkTarget target); +EditResult RemoveHyperlink(string hyperlinkId); + +IReadOnlyList ListBookmarks(ProjectionScopes scopes = ProjectionScopes.All); +EditResult AddBookmark(string name, DocumentRange range); +EditResult RenameBookmark(string name, string newName); +EditResult MoveBookmark(string name, DocumentRange range); +EditResult RemoveBookmark(string name); +``` + +`HyperlinkTarget.External(uri)` creates or reuses a hyperlink relationship on the XML part that +owns the link. A header link is related from its `HeaderPart`, a footnote link from its +`FootnotesPart`, and so on; the main document never acts as a relationship proxy for another +story. `HyperlinkTarget.Internal(bookmarkName)` writes only `w:anchor` and requires exactly one +coherent, ordered start/end pair with that globally unique name in one story part; a lone or +ambiguous marker is `MissingBookmarkTarget`, not a targetable bookmark. Wire callers must pass +target kind `internal` or `external`; unknown strings are `InvalidHyperlinkTarget` rather than +silently becoming external. Orphaned external relationships are removed only after their last markup +reference disappears. The owner-aware relationship helper is generic over the referencing +attribute so part-backed content such as images can reuse the same ownership/orphan rules. + +`HyperlinkInfo` reports the owner part/scope, enclosing anchor, exact half-open `CharSpan`, visible +text, target, relationship metadata, and broken-target state. A hyperlink id follows the anchor +identity contract: stable for the live session and across `Save(true)` / `PersistAnchorIds`, but +not promised across a default save that strips Docxodus Unids. + +Bookmark names follow Word's UI-safe form: 1–40 characters, starting with a letter or underscore, +then letters, digits, or underscores. Names are globally unique; numeric `w:id` pairing is scoped +to the owning story part because real Word files reuse numeric ids across parts. A +`DocumentRange` may cross paragraphs but both endpoints must belong to the same body, individual +header/footer, footnote, or endnote part. `BookmarkInfo.Range` carries the two endpoint anchors and +offsets; `Segments` supplies exact per-paragraph spans and text. Unmatched starts and ambiguous +same-story numeric ids or duplicate names remain visible as invalid diagnostics. Orphan end markers +have no name/start coordinate and are not returned as rows, but still participate in fresh numeric +id allocation. `_Docxodus_Ann_*` bookmarks are owned by the annotation subsystem and reject generic +bookmark mutation. + +Rename first requires one coherent same-story pair, then changes the start marker and every inbound +`w:hyperlink/@w:anchor` across all stories in one undo step. Remove refuses `BookmarkInUse`; move +validates the destination before detaching the old +pair and retains its numeric id. Structural edits likewise reject a pair crossing the deletion +boundary, a targeted pair, or a managed pair before snapshotting. Whole-paragraph replacement keeps +endpoint character coordinates and clamps them to the new end; surgical replacement, split, merge, +and direct block moves preserve marker order. First-class hyperlink/bookmark metadata mutations are +explicitly unavailable in `RenderInline` mode (`TrackedOperationUnsupported`), because Word has no +faithful native revision shape for them. For the same reason, tracked whole-paragraph replacement +rejects a paragraph containing bookmark markers before snapshotting; tracked surgical span +replacement remains supported because it keeps zero-width markers in place. + +All methods route through `DocxSessionOps` and are surfaced by WASM/npm, stdio/`docx-scalpel`, and +MCP's `docxodus_links`. Markdown `[text](uri)` and `[text](#bookmark)` use the same target validation, +part ownership, relationship reuse, and cleanup rules. + ## Finding anchors via tagged annotations The session addresses content by anchor id, but real workflows don't start with anchor ids — they start with intent ("edit the indemnification provision," "tighten the termination clause"). The clean way to bridge intent to anchors is to **annotate the regions ahead of time**, then resolve the annotation to its anchor(s) at edit time. @@ -491,12 +552,18 @@ What `FindByAnnotation` / `FindByLabel` / `FindByBookmark` return in v1: - **All block-level anchors whose subtree overlaps the bookmark range, in document order, deduplicated.** That includes the immediate paragraph plus any enclosing table / row / cell, so an agent sees "this annotation lives in a table" without re-walking the tree. Filter by `Anchor.Kind in {"p","h","li"}` when you want only the text-bearing blocks suitable for `ReplaceText`. - **Empty list when the id/label/bookmark is unknown** or the bookmark's end marker is missing. No exceptions for not-found. -- **Body scope only.** Bookmarks in headers/footers/footnotes aren't part of the v1 surface — `AnnotationManager` only writes to the main document part today. If header/footer annotation support lands, the helpers will return those anchors too. +- **All story scopes for generic bookmarks.** `FindByBookmark` resolves body, header, footer, + footnote, and endnote bookmarks. `AnnotationManager` itself still authors managed annotation + bookmarks in the main document part. -Two caveats that are explicitly out of scope for v1 (tracked in [#132](https://github.com/JSv4/Docxodus/issues/132)): +Two addressing details matter: -- **Bookmarks that span partial paragraphs return the enclosing block's anchor**, not a character span. A character-range surgical edit needs `ApplyFormat(anchor, CharSpan, op)` after computing the offset within the bookmark range yourself. -- **Mutations don't auto-update bookmarks.** A `ReplaceText` / `SplitParagraph` / `MergeParagraphs` call can invalidate the bookmark covering the affected region. Bookmark preservation across mutations is a separate follow-up. +- **`FindByBookmark` returns enclosing block anchors**, for compatibility with annotation-driven + workflows. Use `ListBookmarks` when exact endpoint ranges and per-paragraph character spans are + required. +- **Marker-preserving edits are deterministic.** Whole replacements retain/clamp endpoint offsets; + surgical replacements, paragraph split/merge, and direct block moves preserve marker order. + Structural edits that would orphan a marker fail rather than silently corrupting the range. The agent's prompt should also be aware: it can call `session.ListAnnotations()` once at session start to enumerate available labels (e.g., "you can target: INDEMNIFICATION, TERMINATION, GOVERNING_LAW") and present those as tools rather than asking the LLM to discover them from text. @@ -1627,7 +1694,10 @@ Errors are grouped by what the agent should do in response, not by where in the | Re-read the current version/target metadata in `error.precondition`, rebase or abandon the stale edit, then retry with fresh guards | `PreconditionFailed` | | Re-project and re-derive the anchor from current text | `AnchorNotFound` | | Re-list revisions (`ListRevisions`) and reissue with a current id | `RevisionNotFound` | -| Re-read the anchor's kind via `GetAnchorInfo`, reissue with the right op or coordinates | `AnchorWrongKind`, `TableAnchorMigrationRequired`, `AnchorsNotAdjacent`, `InvalidPosition`, `OffsetOutOfRange`, `EmptyCommentSpan` | +| Re-list native objects and reissue with a current id/name | `HyperlinkNotFound`, `BookmarkNotFound` | +| Re-read the anchor's kind via `GetAnchorInfo`, reissue with the right op or coordinates | `AnchorWrongKind`, `TableAnchorMigrationRequired`, `AnchorsNotAdjacent`, `InvalidPosition`, `OffsetOutOfRange`, `EmptyCommentSpan`, `EmptyHyperlinkSpan` | +| Fix the target/name or resolve the existing reference first | `DuplicateBookmarkName`, `InvalidBookmarkName`, `InvalidHyperlinkTarget`, `MissingBookmarkTarget`, `BookmarkInUse`, `ManagedBookmark` | +| Choose a safe run/range boundary or switch subsequent edits out of tracked mode | `UnsupportedInlineBoundary`, `TrackedOperationUnsupported` | | Fix the markdown payload (the message names what's wrong) | `MalformedMarkdown`, `UnsupportedMarkdownSyntax`, `AnchorTokenInPayload` | | Call the v1 op the message names, or fall back to `Raw.InsertXml` | `TableInsertNotSupported`, `FootnoteRefNotSupported`, `CommentMarkerNotSupported`, `ImageInsertNotSupported` | | Re-query `ListStyles()` for a current style id, or `GetListMembership()` for the valid numbering level | `UnknownStyle`, `InvalidListLevel` | diff --git a/docs/architecture/editor_block_drag_handles.md b/docs/architecture/editor_block_drag_handles.md index cd5a824e..47708baf 100644 --- a/docs/architecture/editor_block_drag_handles.md +++ b/docs/architecture/editor_block_drag_handles.md @@ -473,13 +473,14 @@ id-bearing marker the clone copies is a second live copy: | Marker | Policy | Owner | |---|---|---| -| `w:bookmarkStart`/`w:bookmarkEnd` | Destination clone gets fresh document-unique ids; **both copies keep the NAME**, so each survives its own resolution and every `REF`/`PAGEREF`/`HYPERLINK \l` still resolves | `DocxSession.RenumberClonedBookmarks`, mirroring `IrMarkupRenderer.NormalizeBookmarks` (B) | +| `w:bookmarkStart`/`w:bookmarkEnd` | The tracked move is rejected before snapshot. Fresh numeric ids cannot make two simultaneously-live copies of a globally unique bookmark NAME unambiguous, while placing the pair on only one side loses it on accept or reject. Direct-mode moves still relocate the one existing element safely. | `DocxSession.MoveBlock` | | `w:commentRangeStart`/`End`/`Reference` | The **move source** takes a fresh comment id + a cloned definition (fresh `w14:paraId`, threading entries in both metadata parts, cloned replies re-pointed at cloned parents), leaving the destination on the original comment and its thread | `CommentOps.CloneCommentsForMoveSource`, mirroring `IrMarkupRenderer.NormalizeComments` (B) | | `w:footnoteReference`/`w:endnoteReference` | **Deliberately duplicated.** A note cited at both the old and the new position is a faithful depiction of a pending move, it is not uniqueness-constrained, and exactly one citation survives either resolution | — | -Without the first two the pending redline is schema-invalid (`Sem_UniqueAttributeValue`) -and the comment shows twice in Word's Reviewing pane. Both resolve to a valid single-copy -document on accept and on reject. One residue is accepted: the resolved-away copy's comment +Without the comment split the pending redline is schema-invalid (`Sem_UniqueAttributeValue`) +and the comment shows twice in Word's Reviewing pane. The bookmark case is refused because +numeric-id splitting cannot also preserve unique name identity. Comments resolve to a valid +single-copy document on accept and on reject. One residue is accepted: the resolved-away copy's comment *definition* stays in `comments.xml` unreferenced — `RevisionProcessor` prunes no orphaned definition, for any resolved comment, and Word ignores an unanchored one. diff --git a/npm/README.md b/npm/README.md index 4df6ccd5..8f6efd3c 100644 --- a/npm/README.md +++ b/npm/README.md @@ -118,6 +118,25 @@ Pass `'best_effort'` explicitly only when partial successes should be retained. ![Markdown projection beside the rendered document](https://raw.githubusercontent.com/JSv4/Docxodus/main/docs/images/projection.png) +Native links and bookmarks use the same stable anchors and exact character spans: + +```ts +const session = openDocxSession(docxBytes, { persistAnchorIds: true }); +const paragraph = Object.keys(session.project().anchorIndex) + .find(id => id.startsWith('p:body:'))!; +session.addBookmark('Definitions', { + startAnchorId: paragraph, startOffset: 0, + endAnchorId: paragraph, endOffset: 11, +}); +session.addHyperlink(paragraph, { start: 0, length: 11 }, 'internal', 'Definitions'); +const links = session.listHyperlinks(); // ids feed updateHyperlink/removeHyperlink +const bookmarks = session.listBookmarks(); // exact endpoint range + per-paragraph segments +``` + +External links own their relationship in the actual body/header/footer/footnote/endnote part; +internal links are relationship-free bookmark targets. Bookmark rename retargets inbound links +atomically, and unsafe removal or cross-part ranges return typed `EditResult` errors. + --- ## Everything else diff --git a/npm/src/index.ts b/npm/src/index.ts index e737ae06..03b110c0 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -66,9 +66,12 @@ export type { AnchorRef, AnchorTargetRef, BlockMetadata, + BookmarkInfo, + BookmarkRangeSegment, CharSpan, CommentListEntry, DocumentAnnotation, + DocumentRange, DocxSessionProjection, DocxSessionSettings, EditError, @@ -76,6 +79,8 @@ export type { EditResult, FindOptions, FormatOp, + HyperlinkInfo, + HyperlinkKind, LineSpacingRule, ListMembership, FormattingInspection, diff --git a/npm/src/session.ts b/npm/src/session.ts index ffb20c13..fdd33302 100644 --- a/npm/src/session.ts +++ b/npm/src/session.ts @@ -7,12 +7,14 @@ import type { AnchorTargetRef, AnnotationUpdate, BlockMetadata, + BookmarkInfo, BulkEditResult, CharSpan, CommentListEntry, CrossBlockMatch, DiffEntry, DocumentAnnotation, + DocumentRange, DocxodusWasmExports, DocxSessionProjection, DocxSessionSettings, @@ -24,6 +26,8 @@ import type { FormatOp, FormattingInspection, HeaderFooterKind, + HyperlinkInfo, + HyperlinkKind, InlineSpan, NumberFormat, PageNumberField, @@ -61,7 +65,7 @@ import type { TextMatch, } from "./types.js"; import type { PageMap } from "./pagination.js"; -import { ContextBoundary, DiffFormat, PlaceholderKinds, ProjectionDepth, TrackedChangeMode } from "./types.js"; +import { ContextBoundary, DiffFormat, PlaceholderKinds, ProjectionDepth, ProjectionScopes, TrackedChangeMode } from "./types.js"; function mutationBatchChangeSet( before: readonly T[], @@ -969,6 +973,46 @@ export class DocxSession { return JSON.parse(this.wasm.ListComments(this.handle)) as CommentListEntry[]; } + listHyperlinks(scopes: ProjectionScopes = ProjectionScopes.All): HyperlinkInfo[] { + return JSON.parse(this.wasm.ListHyperlinks(this.handle, scopes)) as HyperlinkInfo[]; + } + + addHyperlink(anchorId: string, span: CharSpan, kind: HyperlinkKind, target: string): EditResult { + return JSON.parse(this.wasm.AddHyperlink( + this.handle, anchorId, span.start, span.length, kind, target, + )) as EditResult; + } + + updateHyperlink(hyperlinkId: string, kind: HyperlinkKind, target: string): EditResult { + return JSON.parse(this.wasm.UpdateHyperlink(this.handle, hyperlinkId, kind, target)) as EditResult; + } + + removeHyperlink(hyperlinkId: string): EditResult { + return JSON.parse(this.wasm.RemoveHyperlink(this.handle, hyperlinkId)) as EditResult; + } + + listBookmarks(scopes: ProjectionScopes = ProjectionScopes.All): BookmarkInfo[] { + return JSON.parse(this.wasm.ListBookmarks(this.handle, scopes)) as BookmarkInfo[]; + } + + addBookmark(name: string, range: DocumentRange): EditResult { + return JSON.parse(this.wasm.AddBookmark(this.handle, name, + range.startAnchorId, range.startOffset, range.endAnchorId, range.endOffset)) as EditResult; + } + + renameBookmark(name: string, newName: string): EditResult { + return JSON.parse(this.wasm.RenameBookmark(this.handle, name, newName)) as EditResult; + } + + moveBookmark(name: string, range: DocumentRange): EditResult { + return JSON.parse(this.wasm.MoveBookmark(this.handle, name, + range.startAnchorId, range.startOffset, range.endAnchorId, range.endOffset)) as EditResult; + } + + removeBookmark(name: string): EditResult { + return JSON.parse(this.wasm.RemoveBookmark(this.handle, name)) as EditResult; + } + // ─── Tracked revisions (issue #318) ────────────────────────────────── /** Markup-native tracked-revision listing, in document order across body, headers, diff --git a/npm/src/types.ts b/npm/src/types.ts index 58d694ce..8595d51c 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1194,6 +1194,15 @@ export interface DocxodusWasmExports { SetCommentResolved: (handle: number, commentAnchor: string, resolved: boolean) => string; RemoveComment: (handle: number, commentAnchor: string) => string; ListComments: (handle: number) => string; + ListHyperlinks: (handle: number, scopes: number) => string; + AddHyperlink: (handle: number, anchor: string, start: number, length: number, kind: string, target: string) => string; + UpdateHyperlink: (handle: number, hyperlinkId: string, kind: string, target: string) => string; + RemoveHyperlink: (handle: number, hyperlinkId: 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; + MoveBookmark: (handle: number, name: string, startAnchor: string, startOffset: number, endAnchor: string, endOffset: number) => string; + RemoveBookmark: (handle: number, name: string) => string; ListRevisions: (handle: number) => string; AcceptRevision: (handle: number, revisionId: string) => string; RejectRevision: (handle: number, revisionId: string) => string; @@ -1326,6 +1335,17 @@ export type EditErrorCode = | "revision_not_found" | "precondition_failed" | "invalid_batch_step" + | "hyperlink_not_found" + | "bookmark_not_found" + | "duplicate_bookmark_name" + | "invalid_bookmark_name" + | "invalid_hyperlink_target" + | "missing_bookmark_target" + | "bookmark_in_use" + | "managed_bookmark" + | "empty_hyperlink_span" + | "unsupported_inline_boundary" + | "tracked_operation_unsupported" | "internal_error"; export interface AnchorRef { @@ -1463,6 +1483,55 @@ export interface EditResult { /** Set by the annotation ops (addAnnotation/removeAnnotation/updateAnnotation/ * moveAnnotation) with the affected annotation id; absent for every other op. */ annotationId?: string; + hyperlinkId?: string; + bookmarkName?: string; +} + +export type HyperlinkKind = "external" | "internal"; + +export interface HyperlinkInfo { + id: string; + kind: HyperlinkKind; + owningPartUri: string; + scope: string; + anchorId: string; + span: CharSpan; + text: string; + target?: string; + relationshipId?: string; + relationshipIsExternal?: boolean; + isBroken: boolean; +} + +export interface DocumentRange { + startAnchorId: string; + startOffset: number; + endAnchorId: string; + endOffset: number; +} + +export interface BookmarkRangeSegment { + owningPartUri: string; + scope: string; + anchorId: string; + span: CharSpan; + text: string; +} + +export interface BookmarkInfo { + name: string; + bookmarkId: string; + startPartUri: string; + startScope: string; + endPartUri?: string; + endScope?: string; + range?: DocumentRange; + segments: BookmarkRangeSegment[]; + text: string; + isPaired: boolean; + isManaged: boolean; + isValid: boolean; + validationError?: string; } /** diff --git a/python/README.md b/python/README.md index c25673cc..7894c33b 100644 --- a/python/README.md +++ b/python/README.md @@ -148,6 +148,7 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a | **Projection** | `project`, `project_anchor` | | **Discovery** | `grep`, `grep_cross_block`, `find_placeholders`, `find_by_text`, `find_all_by_text`, `find_by_regex`, `find_by_kind`, `find_by_annotation`, `find_by_label`, `find_by_bookmark`, `list_annotations`, `exists`, `get_anchor_info`, `get_anchor_infos`, `get_edit_summary`, `remaining_placeholders`, `get_diff` | | **Inspection** | `list_styles`, `get_formatting`, `list_inline_spans`, `get_block_metadata`, `get_block_metadatas`, `get_list_membership`, `get_section_info` | +| **Native links/bookmarks** | `list_hyperlinks`, `add_hyperlink`, `update_hyperlink`, `remove_hyperlink`, `list_bookmarks`, `add_bookmark`, `move_bookmark`, `rename_bookmark`, `remove_bookmark` | | **A: text mutations** | `replace_text`, `replace_text_range`, `replace_text_at_span`, `replace_inner`, `replace_match`, `delete_block`, `move_block`, `delete_range`, `delete_section` | | **B: structural** | `insert_paragraph`, `split_paragraph`, `merge_paragraphs` | | **B: headers/footers/page numbers** | `set_header_text`, `set_footer_text`, `ensure_header_footer_visible`, `insert_page_number_field`, `set_page_numbering`, `clear_page_numbering` | diff --git a/python/src/docx_scalpel/__init__.py b/python/src/docx_scalpel/__init__.py index eb276058..de4a8135 100644 --- a/python/src/docx_scalpel/__init__.py +++ b/python/src/docx_scalpel/__init__.py @@ -43,6 +43,7 @@ EditErrorCode, EmptyParagraphMode, HeaderFooterKind, + HyperlinkKind, LineSpacingRule, ListFormat, MutationBatchMode, @@ -83,12 +84,15 @@ AnchorTarget, AnnotationUpdate, BlockMetadata, + BookmarkInfo, + BookmarkRangeSegment, BlockSlice, BulkEditResult, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, + DocumentRange, DocxDiffConflict, DocxDiffConflictCompetitor, DocxDiffConsolidatedRevision, @@ -107,6 +111,7 @@ FormattingInspection, HeaderFooterRef, HtmlOptions, + HyperlinkInfo, InlineSpan, ListMembership, MarkdownPatch, @@ -191,6 +196,10 @@ "BlockSlice", "BulkEditResult", "CharSpan", + "DocumentRange", + "HyperlinkInfo", + "BookmarkRangeSegment", + "BookmarkInfo", "CommentListEntry", "CrossBlockMatch", "AnnotationUpdate", @@ -273,6 +282,7 @@ "EditErrorCode", "EmptyParagraphMode", "HeaderFooterKind", + "HyperlinkKind", "LineSpacingRule", "MutationBatchMode", "ListFormat", diff --git a/python/src/docx_scalpel/enums.py b/python/src/docx_scalpel/enums.py index e75018a6..73431eef 100644 --- a/python/src/docx_scalpel/enums.py +++ b/python/src/docx_scalpel/enums.py @@ -21,6 +21,7 @@ "PlaceholderKind", "PlaceholderKinds", "ProjectionScopes", + "HyperlinkKind", "ProjectionDepth", "ContextBoundary", "DiffFormat", @@ -178,6 +179,17 @@ class EditErrorCode(str, Enum): EMPTY_COMMENT_SPAN = "empty_comment_span" REVISION_NOT_FOUND = "revision_not_found" INVALID_BATCH_STEP = "invalid_batch_step" + HYPERLINK_NOT_FOUND = "hyperlink_not_found" + BOOKMARK_NOT_FOUND = "bookmark_not_found" + DUPLICATE_BOOKMARK_NAME = "duplicate_bookmark_name" + INVALID_BOOKMARK_NAME = "invalid_bookmark_name" + INVALID_HYPERLINK_TARGET = "invalid_hyperlink_target" + MISSING_BOOKMARK_TARGET = "missing_bookmark_target" + BOOKMARK_IN_USE = "bookmark_in_use" + MANAGED_BOOKMARK = "managed_bookmark" + EMPTY_HYPERLINK_SPAN = "empty_hyperlink_span" + UNSUPPORTED_INLINE_BOUNDARY = "unsupported_inline_boundary" + TRACKED_OPERATION_UNSUPPORTED = "tracked_operation_unsupported" INTERNAL_ERROR = "internal_error" @classmethod @@ -224,6 +236,13 @@ class ProjectionScopes(IntFlag): ALL = BODY | HEADERS | FOOTERS | FOOTNOTES | ENDNOTES | COMMENTS +class HyperlinkKind(str, Enum): + """Native Word hyperlink target representation.""" + + EXTERNAL = "external" + INTERNAL = "internal" + + class ProjectionDepth(IntEnum): """How much of the document a ``project_anchor`` call returns.""" diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py index 6450b09b..78c97a63 100644 --- a/python/src/docx_scalpel/session.py +++ b/python/src/docx_scalpel/session.py @@ -32,6 +32,7 @@ ContextBoundary, DiffFormat, HeaderFooterKind, + HyperlinkKind, ListFormat, MutationBatchMode, PageNumberField, @@ -48,11 +49,13 @@ AnchorTarget, AnnotationUpdate, BlockMetadata, + BookmarkInfo, BulkEditResult, CharSpan, CommentListEntry, CrossBlockMatch, DocumentAnnotation, + DocumentRange, DocxDiffConflict, DocxDiffConsolidatedRevision, DocxDiffConsolidateSettings, @@ -68,6 +71,7 @@ FormatOp, FormattingInspection, HtmlOptions, + HyperlinkInfo, InlineSpan, ListMembership, MarkdownProjection, @@ -857,6 +861,44 @@ def find_by_bookmark( result = self._call("find_by_bookmark", args) return tuple(AnchorTarget._from_wire(a) for a in result) + def list_hyperlinks(self, scopes: ProjectionScopes = ProjectionScopes.ALL) -> tuple[HyperlinkInfo, ...]: + result = self._call("list_hyperlinks", {"scopes": int(scopes)}) + return tuple(HyperlinkInfo._from_wire(item) for item in result) + + def add_hyperlink(self, anchor_id: str, span: CharSpan, kind: HyperlinkKind, + target: str) -> EditResult: + return EditResult._from_wire(self._call("add_hyperlink", { + "anchorId": anchor_id, "start": span.start, "length": span.length, + "kind": kind.value, "target": target, + })) + + def update_hyperlink(self, hyperlink_id: str, kind: HyperlinkKind, + target: str) -> EditResult: + return EditResult._from_wire(self._call("update_hyperlink", { + "hyperlinkId": hyperlink_id, "kind": kind.value, "target": target, + })) + + def remove_hyperlink(self, hyperlink_id: str) -> EditResult: + return EditResult._from_wire( + self._call("remove_hyperlink", {"hyperlinkId": hyperlink_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) + + def add_bookmark(self, name: str, range: DocumentRange) -> EditResult: + return EditResult._from_wire(self._call("add_bookmark", {"name": name, **range.to_wire()})) + + def rename_bookmark(self, name: str, new_name: str) -> EditResult: + return EditResult._from_wire( + self._call("rename_bookmark", {"name": name, "newName": new_name})) + + def move_bookmark(self, name: str, range: DocumentRange) -> EditResult: + return EditResult._from_wire(self._call("move_bookmark", {"name": name, **range.to_wire()})) + + def remove_bookmark(self, name: str) -> EditResult: + return EditResult._from_wire(self._call("remove_bookmark", {"name": name})) + def list_annotations(self) -> tuple[DocumentAnnotation, ...]: result = self._call("list_annotations", {}) return tuple(DocumentAnnotation._from_wire(a) for a in result) diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py index 50226ec6..ec4d5b19 100644 --- a/python/src/docx_scalpel/types.py +++ b/python/src/docx_scalpel/types.py @@ -29,6 +29,7 @@ EditErrorCode, EmptyParagraphMode, HeaderFooterKind, + HyperlinkKind, LineSpacingRule, MutationBatchMode, ParagraphAlignment, @@ -97,6 +98,10 @@ "WmlToMarkdownConverterSettings", "DocumentAnnotation", "AnnotationUpdate", + "DocumentRange", + "HyperlinkInfo", + "BookmarkRangeSegment", + "BookmarkInfo", "EditSummary", "ReplaceOptions", "DocxDiffSettings", @@ -629,6 +634,91 @@ def to_wire(self) -> dict[str, int]: return {"start": self.start, "length": self.length} +@dataclass(frozen=True, slots=True) +class DocumentRange: + """Two-ended, end-exclusive bookmark range; endpoints must share one story part.""" + + start_anchor_id: str + start_offset: int + end_anchor_id: str + end_offset: int + + def to_wire(self) -> dict[str, Any]: + return { + "startAnchorId": self.start_anchor_id, + "startOffset": self.start_offset, + "endAnchorId": self.end_anchor_id, + "endOffset": self.end_offset, + } + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "DocumentRange": + return cls(d["startAnchorId"], int(d["startOffset"]), d["endAnchorId"], int(d["endOffset"])) + + +@dataclass(frozen=True, slots=True) +class HyperlinkInfo: + id: str + kind: HyperlinkKind + owning_part_uri: str + scope: str + anchor_id: str + span: CharSpan + text: str + target: str | None = None + relationship_id: str | None = None + relationship_is_external: bool | None = None + is_broken: bool = False + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "HyperlinkInfo": + return cls(d["id"], HyperlinkKind(d["kind"]), d["owningPartUri"], d["scope"], + d["anchorId"], CharSpan._from_wire(d["span"]), d.get("text", ""), + d.get("target"), d.get("relationshipId"), d.get("relationshipIsExternal"), + bool(d.get("isBroken", False))) + + +@dataclass(frozen=True, slots=True) +class BookmarkRangeSegment: + owning_part_uri: str + scope: str + anchor_id: str + span: CharSpan + text: str + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "BookmarkRangeSegment": + return cls(d["owningPartUri"], d["scope"], d["anchorId"], + CharSpan._from_wire(d["span"]), d.get("text", "")) + + +@dataclass(frozen=True, slots=True) +class BookmarkInfo: + name: str + bookmark_id: str + start_part_uri: str + start_scope: str + end_part_uri: str | None + end_scope: str | None + range: DocumentRange | None + segments: tuple[BookmarkRangeSegment, ...] + text: str + is_paired: bool + is_managed: bool + is_valid: bool + validation_error: str | None = None + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "BookmarkInfo": + return cls(d["name"], d["bookmarkId"], d["startPartUri"], d["startScope"], + d.get("endPartUri"), d.get("endScope"), + DocumentRange._from_wire(d["range"]) if d.get("range") else None, + tuple(BookmarkRangeSegment._from_wire(s) for s in d.get("segments", ())), + d.get("text", ""), bool(d.get("isPaired", False)), + bool(d.get("isManaged", False)), bool(d.get("isValid", False)), + d.get("validationError")) + + @dataclass(frozen=True, slots=True) class FormatOp: """Set of formatting changes to apply. @@ -1317,6 +1407,8 @@ class EditResult: error: EditError | None = None annotation_id: str | None = None table_anchors: TableAnchorMapping | None = None + hyperlink_id: str | None = None + bookmark_name: str | None = None @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "EditResult": @@ -1332,6 +1424,8 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "EditResult": annotation_id=d.get("annotationId"), table_anchors=TableAnchorMapping._from_wire(d["tableAnchors"]) if d.get("tableAnchors") else None, + hyperlink_id=d.get("hyperlinkId"), + bookmark_name=d.get("bookmarkName"), ) diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs index ea25ed06..c769014b 100644 --- a/tools/mcp-server/Dispatcher.cs +++ b/tools/mcp-server/Dispatcher.cs @@ -40,6 +40,7 @@ internal static class Dispatcher "docxodus_create" => Create(store, args), "docxodus_list" => ListTool(store, args), "docxodus_comment" => Comment(store, args), + "docxodus_links" => Links(store, args), "docxodus_annotate" => Annotate(store, args), "docxodus_track_changes" => TrackChanges(store, args), "docxodus_mutations" => Mutations(store, args), @@ -533,6 +534,53 @@ private static string RunCommentAction(DocSession session, string action, JsonEl private static bool IsMutatingCommentAction(string action) => action != "list"; + // ─── Native hyperlinks / bookmarks (issue #451) ─────────────────── + + private static string Links(SessionStore store, JsonElement args) + { + var session = Session(store, args); + return RunLinksAction(session, Str(args, "action"), args); + } + + private static string RunLinksAction(DocSession session, string action, JsonElement args) => action switch + { + "list_hyperlinks" => $"{{\"hyperlinks\":{DocxSessionOps.ListHyperlinks(session.Handle, ParseLinkScopes(OptStr(args, "scope")))}}}", + "add_hyperlink" => DocxSessionOps.AddHyperlink(session.Handle, Str(args, "anchorId"), + Int(args, "startOffset"), Int(args, "length"), Str(args, "kind"), Str(args, "target")), + "update_hyperlink" => DocxSessionOps.UpdateHyperlink(session.Handle, + Str(args, "hyperlinkId"), Str(args, "kind"), Str(args, "target")), + "remove_hyperlink" => DocxSessionOps.RemoveHyperlink(session.Handle, Str(args, "hyperlinkId")), + "list_bookmarks" => $"{{\"bookmarks\":{DocxSessionOps.ListBookmarks(session.Handle, ParseLinkScopes(OptStr(args, "scope")))}}}", + "add_bookmark" => BookmarkRangeAction(session, args, move: false), + "move_bookmark" => BookmarkRangeAction(session, args, move: true), + "rename_bookmark" => DocxSessionOps.RenameBookmark(session.Handle, Str(args, "name"), Str(args, "newName")), + "remove_bookmark" => DocxSessionOps.RemoveBookmark(session.Handle, Str(args, "name")), + _ => throw new McpToolException($"unknown docxodus_links action: {action}"), + }; + + private static string BookmarkRangeAction(DocSession session, JsonElement args, bool move) => + move + ? DocxSessionOps.MoveBookmark(session.Handle, Str(args, "name"), + Str(args, "startAnchorId"), Int(args, "startOffset"), + Str(args, "endAnchorId"), Int(args, "endOffset")) + : DocxSessionOps.AddBookmark(session.Handle, Str(args, "name"), + Str(args, "startAnchorId"), Int(args, "startOffset"), + Str(args, "endAnchorId"), Int(args, "endOffset")); + + private static ProjectionScopes ParseLinkScopes(string? scope) => scope switch + { + null or "all" => ProjectionScopes.All, + "body" => ProjectionScopes.Body, + "headers" => ProjectionScopes.Headers, + "footers" => ProjectionScopes.Footers, + "footnotes" => ProjectionScopes.Footnotes, + "endnotes" => ProjectionScopes.Endnotes, + _ => throw new McpToolException($"unknown link scope: {scope}"), + }; + + private static bool IsMutatingLinksAction(string action) => + action is not ("list_hyperlinks" or "list_bookmarks"); + private static string AddComment(DocSession session, JsonElement args) { var anchorId = OptStr(args, "anchorId"); @@ -786,6 +834,7 @@ private static IReadOnlyList BuildMutationBatchSteps( "docxodus_table" => RunTableAction(session, action, mutationArgs), "docxodus_list" => RunListAction(session, action, mutationArgs), "docxodus_comment" => RunCommentAction(session, action, mutationArgs), + "docxodus_links" => RunLinksAction(session, action, mutationArgs), _ => throw new McpToolException($"docxodus_mutations does not accept \"{stepTool}\" as a step"), }, () => ValidateMutationBatchStep(session, stepTool, action, stepArgs))); @@ -814,6 +863,8 @@ private static IReadOnlyList BuildMutationBatchSteps( "docxodus_list" => action is "apply_format" or "apply_format_range" or "set_level" or "set_start" or "clear_start" or "remove", "docxodus_comment" => action is "add" or "reply" or "resolve" or "update" or "remove", + "docxodus_links" => action is "add_hyperlink" or "update_hyperlink" or "remove_hyperlink" + or "add_bookmark" or "move_bookmark" or "rename_bookmark" or "remove_bookmark", _ => false, }; return known ? null : new EditError( @@ -1058,6 +1109,30 @@ private static void ValidateMutationBatchArguments(string tool, string action, J case ("docxodus_comment", "remove"): RequireStrings(args, "commentAnchorId"); break; + + case ("docxodus_links", "add_hyperlink"): + RequireStrings(args, "anchorId", "kind", "target"); + RequireNumbers(args, "startOffset", "length"); + ValidateRequiredEnum(args, "kind", "external", "internal"); + break; + case ("docxodus_links", "update_hyperlink"): + RequireStrings(args, "hyperlinkId", "kind", "target"); + ValidateRequiredEnum(args, "kind", "external", "internal"); + break; + case ("docxodus_links", "remove_hyperlink"): + RequireStrings(args, "hyperlinkId"); + break; + case ("docxodus_links", "add_bookmark"): + case ("docxodus_links", "move_bookmark"): + RequireStrings(args, "name", "startAnchorId", "endAnchorId"); + RequireNumbers(args, "startOffset", "endOffset"); + break; + case ("docxodus_links", "rename_bookmark"): + RequireStrings(args, "name", "newName"); + break; + case ("docxodus_links", "remove_bookmark"): + RequireStrings(args, "name"); + break; } } diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 9ed002a9..1c4975f9 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -404,6 +404,31 @@ internal static class ToolCatalog "required": ["sessionId", "action"] } """), + new ToolDefinition( + "docxodus_links", + "Enumerate and safely mutate native Word hyperlinks and bookmarks across body, headers, footers, footnotes, and endnotes. Internal hyperlinks target an existing bookmark with relationship-free w:anchor markup; external links use the containing story part's relationship. Bookmark ranges may cross paragraphs in one story part but not package parts. Tracked render-inline mode rejects these metadata mutations explicitly.", + """ + { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "action": { "type": "string", "enum": ["list_hyperlinks", "add_hyperlink", "update_hyperlink", "remove_hyperlink", "list_bookmarks", "add_bookmark", "rename_bookmark", "move_bookmark", "remove_bookmark"] }, + "scope": { "type": "string", "enum": ["body", "headers", "footers", "footnotes", "endnotes", "all"], "description": "Listing only; default all." }, + "anchorId": { "type": "string", "description": "add_hyperlink: containing paragraph anchor." }, + "startOffset": { "type": "integer", "description": "add_hyperlink/add_bookmark/move_bookmark: zero-based character boundary." }, + "length": { "type": "integer", "minimum": 1, "description": "add_hyperlink: selected text length." }, + "kind": { "type": "string", "enum": ["external", "internal"], "description": "add/update_hyperlink target representation." }, + "target": { "type": "string", "description": "External URI or existing bookmark name (without '#')." }, + "hyperlinkId": { "type": "string", "description": "update/remove_hyperlink: id returned by list/add." }, + "name": { "type": "string", "description": "Bookmark name for add/rename/move/remove." }, + "newName": { "type": "string", "description": "rename_bookmark destination name; inbound internal links are retargeted atomically." }, + "startAnchorId": { "type": "string", "description": "add/move_bookmark range start paragraph." }, + "endAnchorId": { "type": "string", "description": "add/move_bookmark range end paragraph in the same story part." }, + "endOffset": { "type": "integer", "description": "add/move_bookmark exclusive end boundary." } + }, + "required": ["sessionId", "action"] + } + """), new ToolDefinition( "docxodus_track_changes", "List, selectively accept/reject (by revisionId), or bulk-resolve tracked changes (w:ins/w:del/w:moveFrom/w:moveTo/w:*PrChange) already present in the document — or switch how the session records its OWN subsequent edits (set_mode).", @@ -425,7 +450,7 @@ internal static class ToolCatalog """), new ToolDefinition( "docxodus_mutations", - "Apply or safely preview a batch of docxodus_edit/docxodus_format/docxodus_create/docxodus_table/docxodus_list/docxodus_comment actions. 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 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", @@ -442,7 +467,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"] }, + "tool": { "type": "string", "enum": ["docxodus_edit", "docxodus_format", "docxodus_create", "docxodus_table", "docxodus_list", "docxodus_comment", "docxodus_links"] }, "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 66d8cbea..e1c7d79e 100644 --- a/tools/python-host/Dispatcher.cs +++ b/tools/python-host/Dispatcher.cs @@ -138,6 +138,26 @@ public static string Dispatch(string op, JsonElement args) Handle(args), Str(args, "anchorId")), "list_comments" => DocxSessionOps.ListComments(Handle(args)), + "list_hyperlinks" => DocxSessionOps.ListHyperlinks( + Handle(args), (ProjectionScopes)IntOptional(args, "scopes", (int)ProjectionScopes.All)), + "add_hyperlink" => DocxSessionOps.AddHyperlink( + Handle(args), Str(args, "anchorId"), Int(args, "start"), Int(args, "length"), + Str(args, "kind"), Str(args, "target")), + "update_hyperlink" => DocxSessionOps.UpdateHyperlink( + Handle(args), Str(args, "hyperlinkId"), Str(args, "kind"), Str(args, "target")), + "remove_hyperlink" => DocxSessionOps.RemoveHyperlink(Handle(args), Str(args, "hyperlinkId")), + "list_bookmarks" => DocxSessionOps.ListBookmarks( + Handle(args), (ProjectionScopes)IntOptional(args, "scopes", (int)ProjectionScopes.All)), + "add_bookmark" => DocxSessionOps.AddBookmark( + Handle(args), Str(args, "name"), Str(args, "startAnchorId"), Int(args, "startOffset"), + Str(args, "endAnchorId"), Int(args, "endOffset")), + "rename_bookmark" => DocxSessionOps.RenameBookmark( + Handle(args), Str(args, "name"), Str(args, "newName")), + "move_bookmark" => DocxSessionOps.MoveBookmark( + Handle(args), Str(args, "name"), Str(args, "startAnchorId"), Int(args, "startOffset"), + Str(args, "endAnchorId"), Int(args, "endOffset")), + "remove_bookmark" => DocxSessionOps.RemoveBookmark(Handle(args), Str(args, "name")), + "list_revisions" => DocxSessionOps.ListRevisions(Handle(args)), "accept_revision" => DocxSessionOps.AcceptRevision(Handle(args), Str(args, "revisionId")), "reject_revision" => DocxSessionOps.RejectRevision(Handle(args), Str(args, "revisionId")), diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs index a4d99731..d005a64a 100644 --- a/wasm/DocxodusWasm/DocxSessionBridge.cs +++ b/wasm/DocxodusWasm/DocxSessionBridge.cs @@ -546,6 +546,45 @@ public static string RemoveComment(int h, string commentAnchor) => [JSExport] public static string ListComments(int h) => DocxSessionOps.ListComments(h); + [JSExport] + public static string ListHyperlinks(int h, int scopes) => + DocxSessionOps.ListHyperlinks(h, (ProjectionScopes)scopes); + + [JSExport] + public static string AddHyperlink(int h, string anchor, int start, int length, + string kind, string target) => + DocxSessionOps.AddHyperlink(h, anchor, start, length, kind, target); + + [JSExport] + public static string UpdateHyperlink(int h, string hyperlinkId, string kind, string target) => + DocxSessionOps.UpdateHyperlink(h, hyperlinkId, kind, target); + + [JSExport] + public static string RemoveHyperlink(int h, string hyperlinkId) => + DocxSessionOps.RemoveHyperlink(h, hyperlinkId); + + [JSExport] + public static string ListBookmarks(int h, int scopes) => + DocxSessionOps.ListBookmarks(h, (ProjectionScopes)scopes); + + [JSExport] + public static string AddBookmark(int h, string name, string startAnchor, int startOffset, + string endAnchor, int endOffset) => + DocxSessionOps.AddBookmark(h, name, startAnchor, startOffset, endAnchor, endOffset); + + [JSExport] + public static string RenameBookmark(int h, string name, string newName) => + DocxSessionOps.RenameBookmark(h, name, newName); + + [JSExport] + public static string MoveBookmark(int h, string name, string startAnchor, int startOffset, + string endAnchor, int endOffset) => + DocxSessionOps.MoveBookmark(h, name, startAnchor, startOffset, endAnchor, endOffset); + + [JSExport] + public static string RemoveBookmark(int h, string name) => + DocxSessionOps.RemoveBookmark(h, name); + /// Markup-native tracked-revision listing (issue #318), document order: /// [{"id","type","author","date"?,"text","anchorId"?}]. Ids are stable while /// the markup exists and address AcceptRevision/RejectRevision; type is