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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ 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 native image inspection and editing across every session surface (#453).**
`DocxSession` now enumerates image occurrences across body, headers, footers, footnotes,
endnotes, and comments. It can insert, replace, resize, describe, reposition, or remove the
canonical DrawingML subset. PNG/JPEG/GIF/BMP/TIFF bytes are validated by signature and dimensions;
WebP, external links, legacy VML, multi-picture/non-canonical DrawingML, and unsupported
floating layouts remain truthfully enumerable but read-only. Image relationships are owned by
the actual story part, identical media is reused across owners, orphan cleanup understands both
DrawingML and VML references, and undo/redo restores bytes, content type, exact media URI,
owner-local relationship ids, and external targets. Runtime capabilities, points-versus-EMU
units, 96-DPI default sizing, size caps, and base64-only JSON transports are exposed through
.NET, JSON ops, WASM/npm, stdio/Python, and MCP (`docxodus_images`).
- **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
Expand Down
651 changes: 651 additions & 0 deletions Docxodus.Tests/DocxSessionImageTests.cs

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions Docxodus.Tests/McpServerDispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,7 @@ public void MCP100_ToolCatalog_HasExpectedDistinctNamedToolsWithValidSchemas()
"docxodus_edit",
"docxodus_format",
"docxodus_get_content",
"docxodus_images",
"docxodus_links",
"docxodus_list",
"docxodus_mutations",
Expand Down Expand Up @@ -1966,4 +1967,122 @@ public void MCP141_NativeLinkAndBookmarkCrud_RoundTripsIdsAndTypedFailures()
$$"""{"sessionId":{{sessionArg}},"action":"remove_bookmark","name":"ClauseTwo"}""")))
.GetProperty("success").GetBoolean());
}

[Fact]
public void MCP144_NativeImageCapabilitiesAndCrud_UseExplicitBase64Boundary()
{
var capabilities = Parse(Dispatcher.Call(_store, "docxodus_images",
J("""{"action":"capabilities"}"""))).GetProperty("capabilities");
Assert.Equal(96, capabilities.GetProperty("defaultDpi").GetDouble());
Assert.False(capabilities.GetProperty("supportsNetworkFetch").GetBoolean());
Assert.DoesNotContain(capabilities.GetProperty("horizontalReferences").EnumerateArray(),
value => value.GetString() == "unknown");
var imageTool = Assert.Single(ToolCatalog.Tools, tool => tool.Name == "docxodus_images");
using (var schema = JsonDocument.Parse(imageTool.InputSchemaJson))
Assert.Contains("comments", schema.RootElement.GetProperty("properties")
.GetProperty("scope").GetProperty("enum").EnumerateArray()
.Select(value => value.GetString()));

var sessionId = OpenSession();
var sessionArg = JsonSerializer.Serialize(sessionId);
var anchor = FirstBodyAnchorId(sessionId, _store);
var png = new byte[24];
new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0, 0, 0, 13, (byte)'I', (byte)'H', (byte)'D', (byte)'R' }.CopyTo(png, 0);
png[19] = 2;
png[23] = 3;
var imageBase64 = JsonSerializer.Serialize(Convert.ToBase64String(png));

var inserted = Parse(Dispatcher.Call(_store, "docxodus_images", J(
$$$"""{"sessionId":{{{sessionArg}}},"action":"insert","anchorId":"{{{anchor}}}","characterOffset":0,"imageBase64":{{{imageBase64}}},"options":{"altText":"diagram","widthPoints":72}}""")));
Assert.True(inserted.GetProperty("success").GetBoolean());
var imageId = inserted.GetProperty("imageId").GetString()!;

var images = Parse(Dispatcher.Call(_store, "docxodus_images", J(
$$"""{"sessionId":{{sessionArg}},"action":"list","scope":"body"}""")));
var image = Assert.Single(images.GetProperty("images").EnumerateArray());
Assert.Equal(imageId, image.GetProperty("id").GetString());
Assert.Equal("png", image.GetProperty("format").GetString());

Assert.True(Parse(Dispatcher.Call(_store, "docxodus_images", J(
$$$"""{"sessionId":{{{sessionArg}}},"action":"set_dimensions","imageId":{{{JsonSerializer.Serialize(imageId)}}},"dimensions":{"widthPoints":36}}""")))
.GetProperty("success").GetBoolean());
Assert.True(Parse(Dispatcher.Call(_store, "docxodus_images", J(
$$"""{"sessionId":{{sessionArg}},"action":"set_metadata","imageId":{{JsonSerializer.Serialize(imageId)}},"altText":"updated","title":null}""")))
.GetProperty("success").GetBoolean());
Assert.True(Parse(Dispatcher.Call(_store, "docxodus_images", J(
$$"""{"sessionId":{{sessionArg}},"action":"remove","imageId":{{JsonSerializer.Serialize(imageId)}}}""")))
.GetProperty("success").GetBoolean());
Assert.Empty(Parse(Dispatcher.Call(_store, "docxodus_images", J(
$$"""{"sessionId":{{sessionArg}},"action":"list"}""")))
.GetProperty("images").EnumerateArray());

var urlRejected = Parse(Dispatcher.Call(_store, "docxodus_images", J(
$$"""{"sessionId":{{sessionArg}},"action":"insert","anchorId":"{{anchor}}","characterOffset":0,"imageBase64":"https://example.test/image.png"}""")));
Assert.False(urlRejected.GetProperty("success").GetBoolean());
Assert.Equal("invalid_image_data",
urlRejected.GetProperty("error").GetProperty("code").GetString());
var wrongOptions = "{\"sessionId\":" + sessionArg
+ ",\"action\":\"insert\",\"anchorId\":" + JsonSerializer.Serialize(anchor)
+ ",\"characterOffset\":0,\"imageBase64\":" + imageBase64
+ ",\"options\":false}";
Assert.Throws<McpToolException>(() => Dispatcher.Call(
_store, "docxodus_images", J(wrongOptions)));
}

[Fact]
public void MCP145_NativeImageBatchPreviewRollsBackParts_AndRejectsReadOnlyActions()
{
var sessionId = OpenSession();
var anchor = FirstBodyAnchorId(sessionId, _store);
var png = new byte[24];
new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0, 0, 0, 13, (byte)'I', (byte)'H', (byte)'D', (byte)'R' }.CopyTo(png, 0);
png[19] = 2;
png[23] = 3;
var imageBase64 = Convert.ToBase64String(png);
var previewArgs = JsonSerializer.Serialize(new
{
sessionId,
mode = "preview",
steps = new[]
{
new
{
tool = "docxodus_images",
args = new
{
action = "insert", anchorId = anchor, characterOffset = 0,
imageBase64, options = new { altText = "preview only" },
},
},
},
});
var preview = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(previewArgs)));
Assert.Equal("ok", preview.GetProperty("status").GetString());
Assert.Equal(1, preview.GetProperty("editsApplied").GetInt32());

var listed = Parse(Dispatcher.Call(_store, "docxodus_images", J(JsonSerializer.Serialize(new
{
sessionId,
action = "list",
}))));
Assert.Empty(listed.GetProperty("images").EnumerateArray());
var savedPath = Path.Combine(_root, "image-preview-rollback.docx");
Save(sessionId, savedPath);
using (var stream = new MemoryStream(File.ReadAllBytes(savedPath)))
using (var document = DocumentFormat.OpenXml.Packaging.WordprocessingDocument.Open(stream, false))
Assert.Empty(document.MainDocumentPart!.ImageParts);

var readOnlyArgs = JsonSerializer.Serialize(new
{
sessionId,
mode = "preview",
steps = new[] { new { tool = "docxodus_images", args = new { action = "list" } } },
});
var invalid = Parse(Dispatcher.Call(_store, "docxodus_mutations", J(readOnlyArgs)));
Assert.False(invalid.GetProperty("success").GetBoolean());
Assert.Equal("invalid_batch_step",
invalid.GetProperty("failure").GetProperty("error").GetProperty("code").GetString());
}
}
101 changes: 101 additions & 0 deletions Docxodus/DocxSession.ImageHistory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// 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.IO;
using System.Linq;
using System.Xml.Linq;
using DocumentFormat.OpenXml.Packaging;
using Docxodus.Internal;

namespace Docxodus;

public sealed partial class DocxSession
{
private static void SweepOrphanedStoryRelationships(OpenXmlPart owner)
{
OwnedPartRelationships.SweepOrphanedHyperlinks(owner, R.id);
OwnedPartRelationships.SweepOrphanedImages(owner, R.embed, R.link);
}

private void SweepOrphanedStoryImageRelationships()
{
foreach (var owner in OwnedPartRelationships.StoryParts(_doc!))
OwnedPartRelationships.SweepOrphanedImages(owner.Part, R.embed, R.link);
}

/// <summary>Restore image media and owner-local relationship topology after the owning XML
/// stories have been restored, including the exact OPC target URI. Reopen the SDK graph once
/// the low-level repair is complete so every subsequent typed read sees the restored parts.</summary>
private void RestoreImageRelationships(DocumentSnapshot snapshot)
{
if (ImageTopologyMatches(snapshot)) return;

var owners = OwnedPartRelationships.StoryParts(_doc!)
.ToDictionary(owner => owner.PartUri, owner => owner.Part, StringComparer.Ordinal);
// Most restored XML lives in the SDK XDocument cache until Save. Flush it before the
// controlled package reopen or those just-restored trees would be lost.
foreach (var part in EnumerateProjectedPartsForSnapshot())
part.PutXDocument(new XDocument(part.GetXDocument()));
OwnedPartRelationships.RestoreExactImageTopology(_doc!, owners, snapshot.ImageParts,
snapshot.ImageRelationships, snapshot.LinkedImageRelationships);
DisposeRenderShell();
_doc!.Dispose();
_stream!.Position = 0;
_doc = WordprocessingDocument.Open(_stream, isEditable: true);
}

/// <summary>A text/format/layout-only undo already has the snapshot's binary topology. Avoid
/// deleting/recreating media and reopening the SDK graph in that overwhelmingly common case.</summary>
private bool ImageTopologyMatches(DocumentSnapshot snapshot)
{
var liveRelationships = new HashSet<(string OwnerPartUri, string RelId, string TargetPartUri)>();
var liveLinked = new HashSet<(string OwnerPartUri, string RelId, string TargetUri)>();
var liveParts = new Dictionary<string, ImagePart>(StringComparer.Ordinal);
foreach (var owner in OwnedPartRelationships.StoryParts(_doc!))
{
foreach (var relationship in OwnedPartRelationships.ImageRelationships(owner.Part))
{
var targetUri = relationship.Target.Uri.ToString();
liveRelationships.Add((owner.PartUri, relationship.RelationshipId, targetUri));
liveParts[targetUri] = relationship.Target;
}
foreach (var relationship in OwnedPartRelationships.ExternalImageRelationships(owner.Part))
liveLinked.Add((owner.PartUri, relationship.Id, relationship.Uri.ToString()));
}

if (!liveRelationships.SetEquals(snapshot.ImageRelationships)
|| !liveLinked.SetEquals(snapshot.LinkedImageRelationships)
|| liveParts.Count != snapshot.ImageParts.Count)
return false;

foreach (var expected in snapshot.ImageParts)
{
if (!liveParts.TryGetValue(expected.PartUri, out var live)
|| !string.Equals(live.ContentType, expected.ContentType, StringComparison.Ordinal)
|| !PartBytesEqual(live, expected.Bytes))
return false;
}
return true;
}

private static bool PartBytesEqual(OpenXmlPart part, byte[] expected)
{
using var input = part.GetStream(FileMode.Open, FileAccess.Read);
if (input.CanSeek && input.Length != expected.Length) return false;
var buffer = new byte[Math.Min(81920, Math.Max(1, expected.Length))];
int offset = 0;
while (offset < expected.Length)
{
int read = input.Read(buffer, 0, Math.Min(buffer.Length, expected.Length - offset));
if (read == 0) return false;
for (int i = 0; i < read; i++)
if (buffer[i] != expected[offset + i]) return false;
offset += read;
}
return input.ReadByte() == -1;
}
}
Loading