From e767c709e44d277e95b83a8f39d577cd8f91798f Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 14 Aug 2026 03:21:31 -0500 Subject: [PATCH] feat: add native image editing --- CHANGELOG.md | 11 + Docxodus.Tests/DocxSessionImageTests.cs | 651 ++++++++ Docxodus.Tests/McpServerDispatcherTests.cs | 119 ++ Docxodus/DocxSession.ImageHistory.cs | 101 ++ Docxodus/DocxSession.Images.cs | 1338 +++++++++++++++++ Docxodus/DocxSession.cs | 78 +- Docxodus/ImageHeaderParser.cs | 137 +- Docxodus/Internal/DocxSessionJson.cs | 288 ++++ Docxodus/Internal/DocxSessionOps.cs | 95 ++ .../Internal/OwnedPartRelationships.Images.cs | 220 +++ Docxodus/Internal/OwnedPartRelationships.cs | 4 +- docs/architecture/docx_agent_server.md | 37 +- docs/architecture/native_images.md | 105 ++ npm/README.md | 17 + npm/src/index.ts | 15 + npm/src/session.ts | 52 + npm/src/types.ts | 87 ++ python/README.md | 1 + python/src/docx_scalpel/__init__.py | 30 + python/src/docx_scalpel/session.py | 46 + python/src/docx_scalpel/types.py | 328 ++++ python/tests/test_images.py | 79 + tools/mcp-server/Dispatcher.cs | 96 ++ tools/mcp-server/ToolCatalog.cs | 33 +- tools/python-host/Dispatcher.cs | 24 + wasm/DocxodusWasm/DocxSessionBridge.cs | 32 + 26 files changed, 3930 insertions(+), 94 deletions(-) create mode 100644 Docxodus.Tests/DocxSessionImageTests.cs create mode 100644 Docxodus/DocxSession.ImageHistory.cs create mode 100644 Docxodus/DocxSession.Images.cs create mode 100644 Docxodus/Internal/OwnedPartRelationships.Images.cs create mode 100644 docs/architecture/native_images.md create mode 100644 python/tests/test_images.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 44e5657c..f85ad01f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Docxodus.Tests/DocxSessionImageTests.cs b/Docxodus.Tests/DocxSessionImageTests.cs new file mode 100644 index 00000000..7e9341fe --- /dev/null +++ b/Docxodus.Tests/DocxSessionImageTests.cs @@ -0,0 +1,651 @@ +// 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.Text.Json; +using System.Xml.Linq; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Validation; +using Docxodus; +using Xunit; + +namespace Docxodus.Tests; + +public class DocxSessionImageTests +{ + private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + private static readonly XNamespace WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; + private static readonly XNamespace A = "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static readonly XNamespace R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static readonly XNamespace V = "urn:schemas-microsoft-com:vml"; + private static readonly XNamespace MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + private static readonly XNamespace WP14 = "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"; + + private static string[] Paragraphs(DocxSession session, string scope = "body") => + session.Project().AnchorIndex.Values.Where(target => target.Anchor.Scope == scope + && target.Anchor.Kind is "p" or "h" or "li") + .Select(target => target.Anchor.Id).Distinct().ToArray(); + + [Fact] + public void IM001_InsertInspectMutateRemove_RoundTripsSchemaValidDrawing() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = Paragraphs(session)[0]; + var insert = session.InsertImage(anchor, 5, Png(2, 3), new ImageInsertOptions + { + AltText = "diagram", Title = "title", WidthPoints = 72, + }); + Assert.True(insert.Success, insert.Error?.Message); + var image = Assert.Single(session.ListImages()); + Assert.Equal(insert.ImageId, image.Id); + Assert.Equal(ImageBinaryFormat.Png, image.Format); + Assert.Equal(2, image.IntrinsicWidthPixels); + Assert.Equal(3, image.IntrinsicHeightPixels); + Assert.Equal(72, image.RenderedWidthPoints!.Value, 6); + Assert.Equal(108, image.RenderedHeightPoints!.Value, 6); + Assert.Equal(new CharSpan(5, 0), image.Span); + Assert.True(image.ContentTypeMatchesBytes); + + Assert.True(session.SetImageMetadata(image.Id, "updated", null).Success); + Assert.True(session.SetImageDimensions(image.Id, 36, null).Success); + image = Assert.Single(session.ListImages()); + Assert.Equal("updated", image.AltText); + Assert.Equal(36, image.RenderedWidthPoints!.Value, 6); + Assert.Equal(54, image.RenderedHeightPoints!.Value, 6); + + var saved = session.Save(true); + using (var stream = new MemoryStream(saved)) + using (var document = WordprocessingDocument.Open(stream, false)) + Assert.Empty(new OpenXmlValidator().Validate(document).Where(IsRealValidationError)); + + Assert.True(session.RemoveImage(image.Id).Success); + Assert.Empty(session.ListImages()); + Assert.Empty(ImageRelationships(session.Save(true)).SelectMany(pair => pair.Relationships)); + } + + [Theory] + [MemberData(nameof(SupportedFormats))] + public void IM002_SupportedMagicFormats_AreAcceptedAndReported(byte[] bytes, + ImageBinaryFormat expected) + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var result = session.InsertImage(Paragraphs(session)[0], 0, bytes); + Assert.True(result.Success, result.Error?.Message); + Assert.Equal(expected, Assert.Single(session.ListImages()).Format); + } + + public static IEnumerable SupportedFormats() + { + yield return new object[] { Png(2, 3), ImageBinaryFormat.Png }; + yield return new object[] { Jpeg(3, 2), ImageBinaryFormat.Jpeg }; + yield return new object[] { Gif(4, 5), ImageBinaryFormat.Gif }; + yield return new object[] { Bmp(6, 7), ImageBinaryFormat.Bmp }; + yield return new object[] { Tiff(8, 9), ImageBinaryFormat.Tiff }; + } + + [Fact] + public void IM003_DedupAcrossOwners_AndUndoRestoreExactTopology() + { + 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 bytes = Png(11, 13); + var bodyInsert = session.InsertImage(body[0], 0, bytes); + var headerInsert = session.InsertImage(header, 0, bytes); + Assert.True(bodyInsert.Success && headerInsert.Success); + var before = ImageRelationships(session.Save(true)); + Assert.Equal(2, before.Count); + Assert.Single(before.SelectMany(owner => owner.Relationships).Select(rel => rel.TargetUri).Distinct()); + Assert.Equal(2, DocumentPropertyIds(session.Save(true)).Distinct().Count()); + var exact = before.SelectMany(owner => owner.Relationships + .Select(rel => (owner.OwnerUri, rel.RelId, rel.TargetUri))).OrderBy(value => value).ToArray(); + + Assert.True(session.RemoveImage(bodyInsert.ImageId!).Success); + Assert.Single(session.ListImages()); + Assert.True(session.Undo()); + var restored = ImageRelationships(session.Save(true)).SelectMany(owner => owner.Relationships + .Select(rel => (owner.OwnerUri, rel.RelId, rel.TargetUri))).OrderBy(value => value).ToArray(); + Assert.Equal(exact, restored); + Assert.True(session.Redo()); + Assert.Single(session.ListImages()); + } + + [Fact] + public void IM004_ReplaceUndoRedo_RestoresBytesContentTypeRelIdAndTargetUri() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var insert = session.InsertImage(Paragraphs(session)[0], 0, Png(2, 3)); + var original = Assert.Single(session.ListImages()); + Assert.True(session.ReplaceImage(original.Id, Jpeg(7, 5)).Success); + var replaced = Assert.Single(session.ListImages()); + Assert.Equal(ImageBinaryFormat.Jpeg, replaced.Format); + Assert.True(session.Undo()); + var undone = Assert.Single(session.ListImages()); + Assert.Equal(ImageBinaryFormat.Png, undone.Format); + Assert.Equal(original.RelationshipId, undone.RelationshipId); + Assert.Equal(original.TargetPartUri, undone.TargetPartUri); + Assert.True(session.Redo()); + var redone = Assert.Single(session.ListImages()); + Assert.Equal(replaced.RelationshipId, redone.RelationshipId); + Assert.Equal(replaced.TargetPartUri, redone.TargetPartUri); + } + + [Fact] + public void IM005_NoOpsAndRejectedInputs_DoNotCreateHistory() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var png = Png(2, 3); + var insert = session.InsertImage(Paragraphs(session)[0], 0, png); + var image = Assert.Single(session.ListImages()); + int undo = session.UndoCount; + Assert.True(session.ReplaceImage(image.Id, png).Success); + Assert.True(session.SetImageDimensions(image.Id, image.RenderedWidthPoints, image.RenderedHeightPoints, false).Success); + Assert.True(session.SetImageMetadata(image.Id, image.AltText, image.Title).Success); + Assert.Equal(undo, session.UndoCount); + + Assert.Equal(EditErrorCode.InvalidImageData, session.ReplaceImage(image.Id, Array.Empty()).Error!.Code); + Assert.Equal(EditErrorCode.UnsupportedImageFormat, session.ReplaceImage(image.Id, + new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }).Error!.Code); + Assert.Equal(EditErrorCode.InvalidImageDimensions, + session.SetImageDimensions(image.Id, double.PositiveInfinity, null).Error!.Code); + Assert.Equal(undo, session.UndoCount); + } + + [Fact] + public void IM006_FloatingSubsetRoundTrips_AndUnsupportedTokensAreReadOnly() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var layout = new FloatingImageLayout + { + HorizontalOffsetEmu = 12345, VerticalOffsetEmu = -23456, + WrapMode = ImageWrapMode.Square, WrapSide = ImageWrapSide.Right, + DistanceLeftEmu = 100, BehindDocument = true, AllowOverlap = false, + }; + Assert.True(session.InsertImage(Paragraphs(session)[0], 0, Png(4, 5), + new ImageInsertOptions { Placement = ImagePlacement.Floating, FloatingLayout = layout }).Success); + var image = Assert.Single(session.ListImages()); + Assert.True(image.FloatingLayoutSupported, + image.UnsupportedReason + ": " + image.FloatingLayout?.RawHorizontalPosition); + Assert.Equal(layout, image.FloatingLayout); + + var mutated = MutatePackage(session.Save(true), document => + { + var anchor = document.MainDocumentPart!.GetXDocument().Descendants(WP + "anchor").Single(); + anchor.SetAttributeValue("behindDoc", "banana"); + anchor.Element(WP + "wrapSquare")!.ReplaceWith(new XElement(WP + "wrapTight", + new XAttribute("wrapText", "right"))); + document.MainDocumentPart.PutXDocument(); + }); + using var probe = new DocxSession(mutated); + var unsupported = Assert.Single(probe.ListImages()); + Assert.False(unsupported.CanMutate); + Assert.False(unsupported.FloatingLayoutSupported); + Assert.Equal(ImageWrapMode.Tight, unsupported.FloatingLayout!.WrapMode); + Assert.Equal("banana", unsupported.FloatingLayout.RawFlagTokens!["behindDoc"]); + } + + [Fact] + public void IM006A_FloatingPositionAndWrapExtrasAreEnumeratedButReadOnly() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + Assert.True(seed.InsertImage(Paragraphs(seed)[0], 0, Png(4, 5), + new ImageInsertOptions { Placement = ImagePlacement.Floating }).Success); + var mutated = MutatePackage(seed.Save(true), document => + { + var main = document.MainDocumentPart!; + var anchor = main.GetXDocument().Descendants(WP + "anchor").Single(); + anchor.Element(WP + "positionH")!.Add( + new XElement(WP14 + "pctPosHOffset", "50000")); + anchor.Element(WP + "wrapSquare")!.SetAttributeValue("distL", "123"); + main.PutXDocument(); + }); + + using var session = new DocxSession(mutated); + var image = Assert.Single(session.ListImages()); + Assert.False(image.CanMutate); + Assert.False(image.FloatingLayoutSupported); + Assert.Contains("pctPosHOffset", image.FloatingLayout!.RawHorizontalPosition); + Assert.Contains("distL=\"123\"", image.FloatingLayout.RawWrapMode); + Assert.Equal(EditErrorCode.UnsupportedImageMarkup, + session.SetImageFloatingLayout(image.Id, new FloatingImageLayout()).Error!.Code); + } + + [Fact] + public void IM007_ContentTypeMagicMismatchIsReportedBroken() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + Assert.True(seed.InsertImage(Paragraphs(seed)[0], 0, Png(2, 3)).Success); + var bytes = MutatePackage(seed.Save(true), document => + { + var image = document.MainDocumentPart!.ImageParts.Single(); + using var input = new MemoryStream(Gif(4, 5), writable: false); + image.FeedData(input); + }); + using var session = new DocxSession(bytes); + var image = Assert.Single(session.ListImages()); + Assert.Equal(ImageBinaryFormat.Gif, image.Format); + Assert.False(image.ContentTypeMatchesBytes); + Assert.True(image.IsBroken); + } + + [Fact] + public void IM008_RawReplaceRemovingDrawing_CleansRelationshipAndUndoRestoresIt() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var anchor = Paragraphs(session)[0]; + Assert.True(session.InsertImage(anchor, 0, Png(2, 3)).Success); + var before = Assert.Single(session.ListImages()); + var paragraph = XElement.Parse(session.Raw.GetXml(anchor)); + paragraph.Descendants(W + "drawing").Remove(); + Assert.True(session.Raw.ReplaceXml(anchor, paragraph.ToString()).Success); + Assert.Empty(session.ListImages()); + Assert.Empty(ImageRelationships(session.Save(true)).SelectMany(owner => owner.Relationships)); + Assert.True(session.Undo()); + Assert.Equal(before.TargetPartUri, Assert.Single(session.ListImages()).TargetPartUri); + } + + [Fact] + public void IM009_JsonBoundaryRejectsMalformedValuesAndUsesBase64() + { + var bytes = DocxSessionTests.BuildDS001_SimpleTwoParagraphs(); + using var probe = new DocxSession(bytes); + var anchor = Paragraphs(probe)[0]; + int handle = Docxodus.Internal.DocxSessionOps.OpenSession(bytes, null); + try + { + using var malformed = JsonDocument.Parse(Docxodus.Internal.DocxSessionOps.InsertImage( + handle, anchor, 0, Convert.ToBase64String(Png(2, 3)), "{\"widthPoints\":\"wide\"}")); + Assert.False(malformed.RootElement.GetProperty("success").GetBoolean()); + using var malformedLayout = JsonDocument.Parse(Docxodus.Internal.DocxSessionOps.InsertImage( + handle, anchor, 0, Convert.ToBase64String(Png(2, 3)), "{\"floatingLayout\":false}")); + Assert.False(malformedLayout.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("invalid_image_layout", + malformedLayout.RootElement.GetProperty("error").GetProperty("code").GetString()); + using var badBase64 = JsonDocument.Parse(Docxodus.Internal.DocxSessionOps.InsertImage( + handle, anchor, 0, "***", "{}")); + Assert.Equal("invalid_image_data", badBase64.RootElement.GetProperty("error").GetProperty("code").GetString()); + using var images = JsonDocument.Parse(Docxodus.Internal.DocxSessionOps.ListImages(handle)); + Assert.Empty(images.RootElement.EnumerateArray()); + } + finally { Docxodus.Internal.DocxSessionOps.CloseSession(handle); } + } + + [Fact] + public void IM010_ExternalLinkIsReadOnly_AndUndoRestoresExactRelationship() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + Assert.True(seed.InsertImage(Paragraphs(seed)[0], 0, Png(2, 3)).Success); + const string relationshipId = "rIdExternalImage37"; + const string target = "https://example.test/image.png"; + var linkedBytes = MutatePackage(seed.Save(true), document => + { + var main = document.MainDocumentPart!; + var blip = main.GetXDocument().Descendants(A + "blip").Single(); + blip.Attribute(R + "embed")!.Remove(); + blip.SetAttributeValue(R + "link", relationshipId); + main.AddExternalRelationship( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + new Uri(target), relationshipId); + main.PutXDocument(); + }); + + using var session = new DocxSession(linkedBytes); + var image = Assert.Single(session.ListImages()); + Assert.True(image.IsLinked); + Assert.False(image.CanMutate); + Assert.Equal(relationshipId, image.LinkedRelationshipId); + Assert.Equal(target, image.LinkedTarget); + Assert.Equal(EditErrorCode.LinkedImageReadOnly, + session.ReplaceImage(image.Id, Png(4, 5)).Error!.Code); + + Assert.True(session.ReplaceText(Paragraphs(session)[1], "changed").Success); + Assert.True(session.Undo()); + image = Assert.Single(session.ListImages()); + Assert.Equal(relationshipId, image.LinkedRelationshipId); + Assert.Equal(target, image.LinkedTarget); + } + + [Fact] + public void IM011_LegacyVmlOccurrenceKeepsSharedRelationshipWhenModernImageIsRemoved() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + Assert.True(seed.InsertImage(Paragraphs(seed)[0], 0, Png(2, 3)).Success); + var mixed = MutatePackage(seed.Save(true), document => + { + var main = document.MainDocumentPart!; + var root = main.GetXDocument(); + var relationshipId = (string)root.Descendants(A + "blip").Single().Attribute(R + "embed")!; + root.Descendants(W + "p").First().Add(new XElement(W + "r", + new XElement(W + "pict", + new XElement(V + "shape", new XAttribute("alt", "legacy"), + new XElement(V + "imagedata", new XAttribute(R + "id", relationshipId)))))); + main.PutXDocument(); + }); + + using var session = new DocxSession(mixed); + var images = session.ListImages(); + Assert.Equal(2, images.Count); + var modern = Assert.Single(images.Where(value => value.MarkupKind == ImageMarkupKind.ModernDrawing)); + var legacy = Assert.Single(images.Where(value => value.MarkupKind == ImageMarkupKind.LegacyVml)); + Assert.False(legacy.CanMutate); + Assert.Equal(modern.RelationshipId, legacy.RelationshipId); + Assert.True(session.RemoveImage(modern.Id).Success); + legacy = Assert.Single(session.ListImages()); + Assert.Equal(ImageMarkupKind.LegacyVml, legacy.MarkupKind); + Assert.False(legacy.IsBroken); + Assert.Single(ImageRelationships(session.Save(true)).SelectMany(owner => owner.Relationships)); + } + + [Fact] + public void IM012_MultiPictureDrawingEnumeratesStableReadOnlySubOccurrences() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + Assert.True(seed.InsertImage(Paragraphs(seed)[0], 0, Png(2, 3)).Success); + var multi = MutatePackage(seed.Save(true), document => + { + var main = document.MainDocumentPart!; + var root = main.GetXDocument(); + var blip = root.Descendants(A + "blip").Single(); + blip.AddAfterSelf(new XElement(blip)); + main.PutXDocument(); + }); + + using var session = new DocxSession(multi); + var images = session.ListImages(); + Assert.Equal(2, images.Count); + Assert.All(images, image => + { + Assert.Equal(ImageMarkupKind.UnsupportedDrawing, image.MarkupKind); + Assert.False(image.CanMutate); + Assert.Contains(":sub", image.Id); + }); + Assert.NotEqual(images[0].Id, images[1].Id); + Assert.Equal(images.Select(image => image.Id), session.ListImages().Select(image => image.Id)); + } + + [Fact] + public void IM013_CommentStoryInsertSaveReopenAndUndoPreserveExactTopology() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = Paragraphs(seed); + var comment = seed.AddComment(body[0], null, "Alice", "comment image"); + Assert.True(comment.Success, comment.Error?.Message); + var commentParagraph = comment.Created.Single(anchor => anchor.Kind == "p" && anchor.Scope == "cmt").Id; + var inserted = seed.InsertImage(commentParagraph, 7, Png(7, 9)); + Assert.True(inserted.Success, inserted.Error?.Message); + var commentImage = Assert.Single(seed.ListImages(ProjectionScopes.Comments)); + Assert.Equal("cmt", commentImage.Scope); + Assert.Equal("/word/comments.xml", commentImage.OwningPartUri); + + var saved = seed.Save(true); + var exact = ImageRelationships(saved).SelectMany(owner => owner.Relationships + .Select(relationship => (owner.OwnerUri, relationship.RelId, relationship.TargetUri))) + .OrderBy(value => value).ToArray(); + using var reopened = new DocxSession(saved); + commentImage = Assert.Single(reopened.ListImages(ProjectionScopes.Comments)); + Assert.Equal(inserted.ImageId, commentImage.Id); + Assert.True(reopened.RemoveImage(commentImage.Id).Success); + Assert.Empty(reopened.ListImages(ProjectionScopes.Comments)); + Assert.True(reopened.Undo()); + Assert.Equal(commentImage.Id, Assert.Single(reopened.ListImages(ProjectionScopes.Comments)).Id); + var restored = ImageRelationships(reopened.Save(true)).SelectMany(owner => owner.Relationships + .Select(relationship => (owner.OwnerUri, relationship.RelId, relationship.TargetUri))) + .OrderBy(value => value).ToArray(); + Assert.Equal(exact, restored); + } + + [Fact] + public void IM014_UnrelatedUndoRedoKeepsSdkGraphAndMediaLayerUntouched() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var paragraphs = Paragraphs(session); + Assert.True(session.InsertImage(paragraphs[0], 0, Png(19, 23)).Success); + var expectedTopology = FlatImageRelationships(session.Save(true)); + var expectedPayloads = ImagePartPayloads(session.Save(true)); + + Assert.True(session.ReplaceText(paragraphs[1], "unrelated text edit").Success); + var documentField = typeof(DocxSession).GetField("_doc", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + var graphBeforeUndo = documentField.GetValue(session); + Assert.True(session.Undo()); + Assert.Same(graphBeforeUndo, documentField.GetValue(session)); + Assert.Equal(expectedTopology, FlatImageRelationships(session.Save(true))); + Assert.Equal(expectedPayloads, ImagePartPayloads(session.Save(true))); + + var graphBeforeRedo = documentField.GetValue(session); + Assert.True(session.Redo()); + Assert.Same(graphBeforeRedo, documentField.GetValue(session)); + Assert.Equal(expectedTopology, FlatImageRelationships(session.Save(true))); + Assert.Equal(expectedPayloads, ImagePartPayloads(session.Save(true))); + } + + [Fact] + public void IM015_FooterAndTableCellOccurrencesOwnCorrectPartsAndRoundTrip() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var body = Paragraphs(session); + Assert.True(session.SetFooterText(body[0], HeaderFooterKind.Default, "footer").Success); + var footer = Assert.Single(Paragraphs(session, "ftr1")); + var table = session.InsertTable(body[0], Position.After, 1, 1, + new TableInsertOptions { CellContents = new[] { "cell" } }); + Assert.True(table.Success, table.Error?.Message); + // #450 returns canonical structural identities via TableAnchors; images remain + // paragraph-addressed, so select the newly materialized cell paragraph. + var cellParagraph = Assert.Single(Paragraphs(session).Except(body)); + var bytes = Png(29, 31); + var footerInsert = session.InsertImage(footer, 0, bytes, + new ImageInsertOptions { AltText = "footer image" }); + var cellInsert = session.InsertImage(cellParagraph, 4, bytes, + new ImageInsertOptions { AltText = "cell image" }); + Assert.True(footerInsert.Success, footerInsert.Error?.Message); + Assert.True(cellInsert.Success, cellInsert.Error?.Message); + + var images = session.ListImages(); + var footerImage = Assert.Single(images.Where(image => image.Id == footerInsert.ImageId)); + var cellImage = Assert.Single(images.Where(image => image.Id == cellInsert.ImageId)); + Assert.Equal("ftr1", footerImage.Scope); + Assert.StartsWith("/word/footer", footerImage.OwningPartUri); + Assert.Equal(new CharSpan(0, 0), footerImage.Span); + Assert.Equal("body", cellImage.Scope); + Assert.Equal("/word/document.xml", cellImage.OwningPartUri); + Assert.Equal(cellParagraph, cellImage.AnchorId); + Assert.Equal(new CharSpan(4, 0), cellImage.Span); + + var saved = session.Save(true); + Assert.Single(ImagePartPayloads(saved)); + Assert.Equal(2, FlatImageRelationships(saved).Length); + Assert.Single(FlatImageRelationships(saved).Select(value => value.TargetUri).Distinct()); + using var reopened = new DocxSession(saved); + var reopenedImages = reopened.ListImages(); + Assert.Contains(reopenedImages, image => image.Id == footerInsert.ImageId + && image.AltText == "footer image"); + Assert.Contains(reopenedImages, image => image.Id == cellInsert.ImageId + && image.AltText == "cell image" && image.AnchorId == cellParagraph); + } + + [Fact] + public void IM016_AlternateContentDrawingAndVmlFallbackAreBothReadOnly() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + Assert.True(seed.InsertImage(Paragraphs(seed)[0], 0, Png(2, 3)).Success); + var compatible = MutatePackage(seed.Save(true), document => + { + var main = document.MainDocumentPart!; + var root = main.GetXDocument(); + var drawing = root.Descendants(W + "drawing").Single(); + var relationshipId = (string)drawing.Descendants(A + "blip").Single() + .Attribute(R + "embed")!; + drawing.ReplaceWith(new XElement(MC + "AlternateContent", + new XElement(MC + "Choice", new XAttribute("Requires", "wp14"), + new XElement(drawing)), + new XElement(MC + "Fallback", + new XElement(W + "pict", + new XElement(V + "shape", + new XElement(V + "imagedata", + new XAttribute(R + "id", relationshipId))))))); + main.PutXDocument(); + }); + + using var session = new DocxSession(compatible); + var images = session.ListImages(); + Assert.Equal(2, images.Count); + var modern = Assert.Single(images.Where(image => image.MarkupKind == ImageMarkupKind.ModernDrawing)); + var legacy = Assert.Single(images.Where(image => image.MarkupKind == ImageMarkupKind.LegacyVml)); + Assert.False(modern.CanMutate); + Assert.False(legacy.CanMutate); + Assert.Contains("AlternateContent", modern.UnsupportedReason); + Assert.Equal(modern.RelationshipId, legacy.RelationshipId); + Assert.Equal(EditErrorCode.UnsupportedImageMarkup, + session.SetImageMetadata(modern.Id, "changed", null).Error!.Code); + } + + [Fact] + public void IM017_UpdateCommentSweepsImagesRemovedWithOldBody() + { + using var session = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + var added = session.AddComment(Paragraphs(session)[0], null, "Alice", "old body"); + Assert.True(added.Success, added.Error?.Message); + var commentAnchor = Assert.Single(added.Created.Where(anchor => anchor.Kind == "cmt")); + var paragraphAnchor = Assert.Single(added.Created.Where(anchor => anchor.Kind == "p")); + Assert.True(session.InsertImage(paragraphAnchor.Id, 0, Png(3, 4)).Success); + Assert.Single(session.ListImages(ProjectionScopes.Comments)); + + var updated = session.UpdateComment(commentAnchor.Id, "replacement body"); + Assert.True(updated.Success, updated.Error?.Message); + Assert.Empty(session.ListImages(ProjectionScopes.Comments)); + Assert.DoesNotContain(FlatImageRelationships(session.Save(true)), + relationship => relationship.OwnerUri == "/word/comments.xml"); + } + + [Fact] + public void IM018_SaveSweepsPreExistingOrphanImageRelationships() + { + using var seed = new DocxSession(DocxSessionTests.BuildDS001_SimpleTwoParagraphs()); + Assert.True(seed.InsertImage(Paragraphs(seed)[0], 0, Png(2, 3)).Success); + var orphaned = MutatePackage(seed.Save(true), document => + { + var main = document.MainDocumentPart!; + main.GetXDocument().Descendants(W + "drawing").Remove(); + main.PutXDocument(); + }); + Assert.Single(FlatImageRelationships(orphaned)); + + using var session = new DocxSession(orphaned); + Assert.Empty(session.ListImages()); + Assert.Empty(FlatImageRelationships(session.Save(true))); + Assert.Empty(FlatImageRelationships(session.Save(false))); + } + + private static bool IsRealValidationError(ValidationErrorInfo error) => + !(error.Description ?? string.Empty).Contains("powertools.codeplex.com", StringComparison.Ordinal); + + private static List<(string OwnerUri, List<(string RelId, string TargetUri)> Relationships)> + ImageRelationships(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + using var document = WordprocessingDocument.Open(stream, false); + var owners = StoryOwners(document); + return owners.Select(owner => (owner.Uri.ToString(), owner.Parts + .Where(pair => pair.OpenXmlPart is ImagePart) + .Select(pair => (pair.RelationshipId, pair.OpenXmlPart.Uri.ToString())).ToList())).ToList(); + } + + private static (string OwnerUri, string RelId, string TargetUri)[] FlatImageRelationships(byte[] bytes) => + ImageRelationships(bytes).SelectMany(owner => owner.Relationships + .Select(relationship => (owner.OwnerUri, relationship.RelId, relationship.TargetUri))) + .OrderBy(value => value).ToArray(); + + private static uint[] DocumentPropertyIds(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + using var document = WordprocessingDocument.Open(stream, false); + return StoryOwners(document).SelectMany(owner => owner.GetXDocument().Descendants(WP + "docPr")) + .Select(element => (uint)element.Attribute("id")!).ToArray(); + } + + private static List<(string PartUri, string ContentType, string Bytes)> ImagePartPayloads(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + using var document = WordprocessingDocument.Open(stream, false); + return StoryOwners(document).SelectMany(owner => owner.Parts) + .Where(pair => pair.OpenXmlPart is ImagePart) + .Select(pair => (ImagePart)pair.OpenXmlPart) + .GroupBy(part => part.Uri.ToString(), StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(part => part.Uri.ToString(), StringComparer.Ordinal) + .Select(part => + { + using var input = part.GetStream(FileMode.Open, FileAccess.Read); + using var output = new MemoryStream(); + input.CopyTo(output); + return (part.Uri.ToString(), part.ContentType, Convert.ToBase64String(output.ToArray())); + }).ToList(); + } + + private static List StoryOwners(WordprocessingDocument document) + { + var main = document.MainDocumentPart!; + var owners = new List { main }; + owners.AddRange(main.HeaderParts); + owners.AddRange(main.FooterParts); + if (main.FootnotesPart is not null) owners.Add(main.FootnotesPart); + if (main.EndnotesPart is not null) owners.Add(main.EndnotesPart); + if (main.WordprocessingCommentsPart is not null) owners.Add(main.WordprocessingCommentsPart); + return owners; + } + + private static byte[] MutatePackage(byte[] bytes, Action mutate) + { + var stream = new MemoryStream(); stream.Write(bytes); stream.Position = 0; + using (var document = WordprocessingDocument.Open(stream, true)) mutate(document); + return stream.ToArray(); + } + + private static byte[] Png(int width, int height) + { + var bytes = new byte[24]; + new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0, 0, 0, 13, (byte)'I', (byte)'H', (byte)'D', (byte)'R' }.CopyTo(bytes, 0); + WriteBig(bytes, 16, width); WriteBig(bytes, 20, height); return bytes; + } + + private static byte[] Jpeg(int width, int height) => new byte[] + { + 0xFF, 0xD8, 0xFF, 0xC0, 0, 17, 8, + (byte)(height >> 8), (byte)height, (byte)(width >> 8), (byte)width, + 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xFF, 0xD9, + }; + + private static byte[] Gif(int width, int height) => new byte[] + { (byte)'G', (byte)'I', (byte)'F', (byte)'8', (byte)'9', (byte)'a', + (byte)width, (byte)(width >> 8), (byte)height, (byte)(height >> 8) }; + + private static byte[] Bmp(int width, int height) + { + var bytes = new byte[54]; bytes[0] = (byte)'B'; bytes[1] = (byte)'M'; + bytes[14] = 40; WriteLittle(bytes, 18, width); WriteLittle(bytes, 22, height); return bytes; + } + + private static byte[] Tiff(int width, int height) + { + var bytes = new byte[38]; bytes[0] = (byte)'I'; bytes[1] = (byte)'I'; bytes[2] = 42; + bytes[4] = 8; bytes[8] = 2; + WriteTiffEntry(bytes, 10, 256, width); WriteTiffEntry(bytes, 22, 257, height); return bytes; + } + + private static void WriteTiffEntry(byte[] bytes, int offset, int tag, int value) + { bytes[offset] = (byte)tag; bytes[offset + 1] = (byte)(tag >> 8); bytes[offset + 2] = 4; + bytes[offset + 4] = 1; WriteLittle(bytes, offset + 8, value); } + private static void WriteBig(byte[] bytes, int offset, int value) + { bytes[offset] = (byte)(value >> 24); bytes[offset + 1] = (byte)(value >> 16); + bytes[offset + 2] = (byte)(value >> 8); bytes[offset + 3] = (byte)value; } + private static void WriteLittle(byte[] bytes, int offset, int value) + { bytes[offset] = (byte)value; bytes[offset + 1] = (byte)(value >> 8); + bytes[offset + 2] = (byte)(value >> 16); bytes[offset + 3] = (byte)(value >> 24); } +} diff --git a/Docxodus.Tests/McpServerDispatcherTests.cs b/Docxodus.Tests/McpServerDispatcherTests.cs index f5a446c8..699c7671 100644 --- a/Docxodus.Tests/McpServerDispatcherTests.cs +++ b/Docxodus.Tests/McpServerDispatcherTests.cs @@ -1322,6 +1322,7 @@ public void MCP100_ToolCatalog_HasExpectedDistinctNamedToolsWithValidSchemas() "docxodus_edit", "docxodus_format", "docxodus_get_content", + "docxodus_images", "docxodus_links", "docxodus_list", "docxodus_mutations", @@ -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(() => 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()); + } } diff --git a/Docxodus/DocxSession.ImageHistory.cs b/Docxodus/DocxSession.ImageHistory.cs new file mode 100644 index 00000000..9e02932a --- /dev/null +++ b/Docxodus/DocxSession.ImageHistory.cs @@ -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); + } + + /// 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. + 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); + } + + /// 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. + 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(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; + } +} diff --git a/Docxodus/DocxSession.Images.cs b/Docxodus/DocxSession.Images.cs new file mode 100644 index 00000000..64a22146 --- /dev/null +++ b/Docxodus/DocxSession.Images.cs @@ -0,0 +1,1338 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using DocumentFormat.OpenXml.Packaging; +using Docxodus.Internal; + +namespace Docxodus; + +public enum ImageBinaryFormat { Unknown, Png, Jpeg, Gif, Bmp, Tiff, Webp } +public enum ImageMarkupKind { ModernDrawing, LegacyVml, UnsupportedDrawing } +public enum ImagePlacement { Inline, Floating } +public enum ImageWrapMode { None, Square, Tight, Through, TopAndBottom, Unknown } +public enum ImageWrapSide { BothSides, Left, Right, Largest, Unknown } +public enum ImageHorizontalReference { Page, Margin, Column, Character, Unknown } +public enum ImageVerticalReference { Page, Margin, Paragraph, Line, Unknown } +public enum ImageHorizontalAlignment { Left, Center, Right, Inside, Outside, Unknown } +public enum ImageVerticalAlignment { Top, Center, Bottom, Inside, Outside, Unknown } + +/// Supported floating DrawingML layout. Offsets and wrap distances are exact EMUs; +/// position axes use either an offset or an alignment, never both. +public sealed record FloatingImageLayout +{ + public ImageHorizontalReference HorizontalRelativeFrom { get; init; } = ImageHorizontalReference.Column; + public long? HorizontalOffsetEmu { get; init; } = 0; + public ImageHorizontalAlignment? HorizontalAlignment { get; init; } + public ImageVerticalReference VerticalRelativeFrom { get; init; } = ImageVerticalReference.Paragraph; + public long? VerticalOffsetEmu { get; init; } = 0; + public ImageVerticalAlignment? VerticalAlignment { get; init; } + public ImageWrapMode WrapMode { get; init; } = ImageWrapMode.Square; + public ImageWrapSide WrapSide { get; init; } = ImageWrapSide.BothSides; + public long DistanceTopEmu { get; init; } + public long DistanceBottomEmu { get; init; } + public long DistanceLeftEmu { get; init; } + public long DistanceRightEmu { get; init; } + public uint RelativeHeight { get; init; } = 251658240; + public bool BehindDocument { get; init; } + public bool Locked { get; init; } + public bool LayoutInCell { get; init; } = true; + public bool AllowOverlap { get; init; } = true; + /// Raw OOXML tokens are populated only when a report-only layout contains a token + /// outside the mutable subset. They preserve inspection truth without making it writable. + public string? RawHorizontalReference { get; init; } + public string? RawVerticalReference { get; init; } + public string? RawHorizontalPosition { get; init; } + public string? RawVerticalPosition { get; init; } + public string? RawWrapMode { get; init; } + public string? RawWrapSide { get; init; } + public string? RawRelativeSizeHorizontal { get; init; } + public string? RawRelativeSizeVertical { get; init; } + public IReadOnlyDictionary? RawFlagTokens { get; init; } +} + +/// Options for binary image insertion. Rendered dimensions are points. At 96 DPI the +/// default is exactly 0.75 point per intrinsic pixel. +public sealed record ImageInsertOptions +{ + public ImagePlacement Placement { get; init; } = ImagePlacement.Inline; + public double? WidthPoints { get; init; } + public double? HeightPoints { get; init; } + public bool PreserveAspect { get; init; } = true; + public string? AltText { get; init; } + public string? Title { get; init; } + public FloatingImageLayout? FloatingLayout { get; init; } +} + +public sealed record ImageFormatCapability( + ImageBinaryFormat Format, string ContentType, bool CanInspect, + bool CanInsert, bool CanReplace, string? Limitation); + +/// Versioned runtime facts for the native image surface. These are operational +/// capabilities, not decoder/network/file-I/O claims. +public sealed record ImageCapabilities( + int SchemaVersion, string Runtime, IReadOnlyList Formats, + IReadOnlyList Operations, IReadOnlyList MutableWrapModes, + IReadOnlyList HorizontalReferences, + IReadOnlyList VerticalReferences, + long MaxInputBytes, double MaxRenderedPoints, double DefaultDpi, + bool UsesHeaderParsingOnly, bool AcceptsBinaryBytes, + bool SupportsNetworkFetch, bool SupportsFileIo); + +/// One native Word image occurrence. Rendered dimensions are points; floating offsets +/// and distances are exact EMUs. Legacy VML and unsupported DrawingML remain enumerable but +/// is false. +public sealed record ImageOccurrence( + string Id, ImageMarkupKind MarkupKind, ImagePlacement? Placement, + bool CanMutate, string? UnsupportedReason, + string OwningPartUri, string Scope, string AnchorId, CharSpan Span, + string? RelationshipId, string? TargetPartUri, + string? LinkedRelationshipId, string? LinkedTarget, + bool IsEmbedded, bool IsLinked, bool IsBroken, + string? MediaFileName, string? ContentType, ImageBinaryFormat Format, + bool? ContentTypeMatchesBytes, + int? IntrinsicWidthPixels, int? IntrinsicHeightPixels, + double? RenderedWidthPoints, double? RenderedHeightPoints, + string? AltText, string? Title, + FloatingImageLayout? FloatingLayout, bool FloatingLayoutSupported); + +public sealed partial class DocxSession +{ + internal const long MaxImageInputBytes = 64L * 1024 * 1024; + internal const double MaxImageRenderedPoints = 100000; + internal const double ImageDefaultDpi = 96.0; + private const long EmusPerPoint = 12700; + private const string PictureGraphicDataUri = + "http://schemas.openxmlformats.org/drawingml/2006/picture"; + private static readonly XNamespace ImageR = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static readonly XNamespace ImageV = "urn:schemas-microsoft-com:vml"; + private static readonly XNamespace ImageO = "urn:schemas-microsoft-com:office:office"; + + private sealed record ImageCandidate( + OwnedPartRelationships.Owner Owner, XElement Outer, XElement? Container, + XElement? Blip, ImageOccurrence Info); + + public static ImageCapabilities GetImageCapabilities() + { +#if WASM_BUILD + const string runtime = "browser-wasm"; +#else + const string runtime = "dotnet"; +#endif + return new ImageCapabilities( + 1, + runtime, + new[] + { + new ImageFormatCapability(ImageBinaryFormat.Png, "image/png", true, true, true, null), + new ImageFormatCapability(ImageBinaryFormat.Jpeg, "image/jpeg", true, true, true, null), + new ImageFormatCapability(ImageBinaryFormat.Gif, "image/gif", true, true, true, null), + new ImageFormatCapability(ImageBinaryFormat.Bmp, "image/bmp", true, true, true, null), + new ImageFormatCapability(ImageBinaryFormat.Tiff, "image/tiff", true, true, true, null), + new ImageFormatCapability(ImageBinaryFormat.Webp, "image/webp", true, false, false, + "Open XML SDK 3.5.1 exposes no Word ImagePartType for WebP; existing parts are read-only"), + new ImageFormatCapability(ImageBinaryFormat.Unknown, "application/octet-stream", false, false, false, + "unrecognized bytes are rejected"), + }, + new[] { "list", "insert", "replace", "set_dimensions", "set_metadata", "set_floating_layout", "remove" }, + new[] { ImageWrapMode.None, ImageWrapMode.Square }, + new[] { ImageHorizontalReference.Page, ImageHorizontalReference.Margin, + ImageHorizontalReference.Column, ImageHorizontalReference.Character }, + new[] { ImageVerticalReference.Page, ImageVerticalReference.Margin, + ImageVerticalReference.Paragraph, ImageVerticalReference.Line }, + MaxImageInputBytes, MaxImageRenderedPoints, ImageDefaultDpi, + UsesHeaderParsingOnly: true, AcceptsBinaryBytes: true, + SupportsNetworkFetch: false, SupportsFileIo: false); + } + + public IReadOnlyList ListImages(ProjectionScopes scopes = ProjectionScopes.All) + { + ThrowIfDisposed(); + return EnumerateImageCandidates(scopes).Select(candidate => candidate.Info).ToList(); + } + + public EditResult InsertImage(string anchorId, int characterOffset, byte[] imageBytes, + ImageInsertOptions? options = null) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + options ??= new ImageInsertOptions(); + if (ValidateImageMutationMode(anchorId) is { } modeError) return modeError; + var binary = ValidateImageBytes(imageBytes, anchorId); + if (binary.Error is not null) return binary.Error; + if (ValidateInsertOptions(options, binary.Width, binary.Height, anchorId, + out var widthEmu, out var heightEmu, out var layout) is { } optionError) return optionError; + 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, + "InsertImage requires a paragraph/heading/list-item anchor", anchorId); + var textLength = ParagraphText(paragraph).Length; + if (characterOffset < 0 || characterOffset > textLength) + return EditResult.Fail(EditErrorCode.OffsetOutOfRange, + $"offset {characterOffset} outside paragraph of length {textLength}", anchorId); + if (ValidateImageInsertionBoundary(paragraph, characterOffset) is { } boundaryError) + return EditResult.Fail(EditErrorCode.UnsupportedInlineBoundary, boundaryError, 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 + { + var relationship = OwnedPartRelationships.FindOrAddImagePart( + _doc!, owner.Value.Part, imageBytes, binary.ContentType!, binary.Format); + var docPrId = NextDocumentPropertyId(); + var drawing = BuildImageDrawing(relationship.RelationshipId, docPrId, + widthEmu, heightEmu, options.AltText, options.Title, + options.Placement, layout); + var run = new XElement(W.r, + new XElement(W.rPr, new XElement(W.noProof)), + drawing); + InsertInlineElementAtOffset(paragraph, characterOffset, run); + UnidHelper.AssignToSelfAndDescendants(run); + InvalidateProjectionCache(); + var imageId = ImagePublicId(owner.Value, drawing); + return new EditResult { Success = true, ImageId = imageId, + Modified = new[] { anchor.Anchor } }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message, anchorId); + } + } + + public EditResult ReplaceImage(string imageId, byte[] imageBytes) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + if (ValidateImageMutationMode() is { } modeError) return modeError; + var binary = ValidateImageBytes(imageBytes, null); + if (binary.Error is not null) return binary.Error; + if (ResolveMutableImage(imageId, out var candidate) is { } imageError) return imageError; + var currentPart = OwnedPartRelationships.ResolveImagePart( + candidate.Owner.Part, candidate.Info.RelationshipId); + if (currentPart is not null && currentPart.ContentType == binary.ContentType + && OwnedPartRelationships.ReadPartBytes(currentPart).SequenceEqual(imageBytes)) + return ImageMutationSuccess(candidate, imageId); + _history.RecordPreOp(TakeSnapshot()); + try + { + var relationship = OwnedPartRelationships.FindOrAddImagePart( + _doc!, candidate.Owner.Part, imageBytes, binary.ContentType!, binary.Format); + candidate.Blip!.SetAttributeValue(ImageR + "embed", relationship.RelationshipId); + OwnedPartRelationships.SweepOrphanedImages(candidate.Owner.Part, + ImageR + "embed", ImageR + "link"); + InvalidateProjectionCache(); + return ImageMutationSuccess(candidate, imageId); + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult SetImageDimensions(string imageId, double? widthPoints, + double? heightPoints, bool preserveAspect = true) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + if (ValidateImageMutationMode() is { } modeError) return modeError; + if (ResolveMutableImage(imageId, out var candidate) is { } imageError) return imageError; + if (ResolveRenderedDimensions(widthPoints, heightPoints, preserveAspect, + candidate.Info.RenderedWidthPoints, candidate.Info.RenderedHeightPoints, + out var widthEmu, out var heightEmu) is { } dimensionError) return dimensionError; + var extent = candidate.Container!.Element(WP.extent)!; + var transformExtent = candidate.Container.Descendants(A.xfrm).First().Element(A.ext)!; + if ((string?)extent.Attribute("cx") == widthEmu.ToString(CultureInfo.InvariantCulture) + && (string?)extent.Attribute("cy") == heightEmu.ToString(CultureInfo.InvariantCulture) + && (string?)transformExtent.Attribute("cx") == widthEmu.ToString(CultureInfo.InvariantCulture) + && (string?)transformExtent.Attribute("cy") == heightEmu.ToString(CultureInfo.InvariantCulture)) + return ImageMutationSuccess(candidate, imageId); + + _history.RecordPreOp(TakeSnapshot()); + try + { + SetDrawingExtents(candidate.Container!, widthEmu, heightEmu); + InvalidateProjectionCache(); + return ImageMutationSuccess(candidate, imageId); + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult SetImageMetadata(string imageId, string? altText, string? title) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + if (ValidateImageMutationMode() is { } modeError) return modeError; + if (ResolveMutableImage(imageId, out var candidate) is { } imageError) return imageError; + if (!ValidXmlAttributeText(altText) || !ValidXmlAttributeText(title)) + return EditResult.Fail(EditErrorCode.InvalidImageData, + "image metadata contains characters XML attributes cannot represent"); + var currentDocPr = candidate.Container!.Element(WP.docPr)!; + var currentCNvPr = candidate.Container.Descendants(Pic.cNvPr).FirstOrDefault(); + if ((string?)currentDocPr.Attribute("descr") == altText + && (string?)currentDocPr.Attribute("title") == title + && (currentCNvPr is null || ((string?)currentCNvPr.Attribute("descr") == altText + && (string?)currentCNvPr.Attribute("title") == title))) + return ImageMutationSuccess(candidate, imageId); + + _history.RecordPreOp(TakeSnapshot()); + try + { + var docPr = candidate.Container!.Element(WP.docPr)!; + docPr.SetAttributeValue("descr", altText); + docPr.SetAttributeValue("title", title); + var cNvPr = candidate.Container.Descendants(Pic.cNvPr).FirstOrDefault(); + if (cNvPr is not null) + { + cNvPr.SetAttributeValue("descr", altText); + cNvPr.SetAttributeValue("title", title); + } + InvalidateProjectionCache(); + return ImageMutationSuccess(candidate, imageId); + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult SetImageFloatingLayout(string imageId, FloatingImageLayout layout) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + if (ValidateImageMutationMode() is { } modeError) return modeError; + if (layout is null) return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "floating layout is required"); + if (ValidateFloatingLayout(layout) is { } layoutError) return layoutError; + if (ResolveMutableImage(imageId, out var candidate) is { } imageError) return imageError; + if (candidate.Info.Placement != ImagePlacement.Floating) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "floating layout can only be set on a floating image"); + if (!candidate.Info.FloatingLayoutSupported) + return EditResult.Fail(EditErrorCode.UnsupportedImageMarkup, + candidate.Info.UnsupportedReason ?? "floating layout is read-only"); + if (candidate.Info.FloatingLayout == layout) + return ImageMutationSuccess(candidate, imageId); + + _history.RecordPreOp(TakeSnapshot()); + try + { + ApplyFloatingLayout(candidate.Container!, layout); + InvalidateProjectionCache(); + return ImageMutationSuccess(candidate, imageId); + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + public EditResult RemoveImage(string imageId) + { + if (_disposed) return EditResult.Fail(EditErrorCode.SessionDisposed, "session disposed"); + if (ValidateImageMutationMode() is { } modeError) return modeError; + if (ResolveMutableImage(imageId, out var candidate) is { } imageError) return imageError; + var paragraph = candidate.Outer.Ancestors(W.p).First(); + var anchor = AnchorForElement(paragraph); + + _history.RecordPreOp(TakeSnapshot()); + try + { + var run = candidate.Outer.Ancestors(W.r).FirstOrDefault(); + candidate.Outer.Remove(); + if (run is not null && !run.Elements().Any(element => element.Name != W.rPr)) run.Remove(); + OwnedPartRelationships.SweepOrphanedImages(candidate.Owner.Part, + ImageR + "embed", ImageR + "link"); + InvalidateProjectionCache(); + return new EditResult { Success = true, ImageId = imageId, + Modified = anchor is null ? Array.Empty() : new[] { anchor.Value } }; + } + catch (Exception ex) + { + LastInternalError = ex; + RollbackFailedOp(); + return EditResult.Fail(EditErrorCode.InternalError, ex.Message); + } + } + + private IReadOnlyList EnumerateImageCandidates(ProjectionScopes scopes) + { + _ = 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 drawing in root.Descendants(W.drawing)) + result.AddRange(BuildDrawingCandidates(owner, drawing)); + foreach (var pict in root.Descendants(W.pict)) + result.AddRange(BuildLegacyCandidates(owner, pict)); + } + return result; + } + + private IEnumerable BuildDrawingCandidates( + OwnedPartRelationships.Owner owner, XElement drawing) + { + var paragraph = drawing.Ancestors(W.p).FirstOrDefault(); + var anchor = paragraph is null ? null : AnchorForElement(paragraph); + if (paragraph is null || anchor is null) yield break; + var containers = drawing.Elements().Where(element => + element.Name == WP.inline || element.Name == WP.anchor).ToList(); + var container = containers.Count == 1 ? containers[0] : null; + var blips = drawing.Descendants(A.blip).ToList(); + var occurrences = blips.Count == 0 + ? new XElement?[] { null } + : blips.Cast().ToArray(); + ImagePlacement? placement = container?.Name == WP.inline ? ImagePlacement.Inline + : container?.Name == WP.anchor ? ImagePlacement.Floating : null; + var extent = container?.Element(WP.extent); + var (renderedWidth, renderedHeight) = ReadRenderedPoints(extent); + var docPr = container?.Element(WP.docPr); + FloatingImageLayout? floatingLayout = null; + bool floatingSupported = placement != ImagePlacement.Floating; + string? layoutUnsupported = null; + if (placement == ImagePlacement.Floating && container is not null) + { + floatingSupported = TryReadFloatingLayout(container, out floatingLayout, out layoutUnsupported); + } + var boundaryReason = ExistingImageBoundaryReason(drawing, paragraph); + bool hasMutableStructure = container?.Element(WP.docPr) is not null + && container.Element(WP.extent) is not null + && container.Descendants(A.xfrm).FirstOrDefault()?.Element(A.ext) is not null; + var offset = ImageOffset(paragraph, drawing); + for (int index = 0; index < occurrences.Length; index++) + { + var blip = occurrences[index]; + var graphicData = blip?.Ancestors(A.graphicData).FirstOrDefault(); + bool picturePayload = container is not null && blips.Count == 1 + && graphicData is not null + && ReferenceEquals(graphicData, container.Element(A.graphic)?.Element(A.graphicData)) + && (string?)graphicData.Attribute("uri") == PictureGraphicDataUri + && graphicData.Descendants(Pic._pic).Count() == 1; + var occurrenceProperties = blip?.Ancestors(Pic._pic).FirstOrDefault()? + .Descendants(Pic.cNvPr).FirstOrDefault(); + var alt = (string?)occurrenceProperties?.Attribute("descr") + ?? (picturePayload ? (string?)docPr?.Attribute("descr") : null); + var title = (string?)occurrenceProperties?.Attribute("title") + ?? (picturePayload ? (string?)docPr?.Attribute("title") : null); + var embedId = (string?)blip?.Attribute(ImageR + "embed"); + var linkId = (string?)blip?.Attribute(ImageR + "link"); + var imagePart = OwnedPartRelationships.ResolveImagePart(owner.Part, embedId); + var linked = string.IsNullOrEmpty(linkId) ? null + : owner.Part.ExternalRelationships.FirstOrDefault(relationship => relationship.Id == linkId); + var media = ReadMediaInfo(imagePart); + bool broken = (!string.IsNullOrEmpty(embedId) && imagePart is null) + || (!string.IsNullOrEmpty(linkId) && linked is null) + || (string.IsNullOrEmpty(embedId) && string.IsNullOrEmpty(linkId)) + || media.IsMalformed || media.ContentTypeMatchesBytes == false; + string? unsupported = picturePayload ? null + : blip is null + ? "drawing contains no identifiable image blip" + : "drawing contains a non-canonical or multi-picture payload"; + if (picturePayload && !hasMutableStructure) + unsupported = "drawing lacks required picture properties or extents"; + if (layoutUnsupported is not null) unsupported = layoutUnsupported; + if (!string.IsNullOrEmpty(linkId)) unsupported = "external linked images are read-only"; + if (boundaryReason is not null) unsupported = boundaryReason; + bool canMutate = picturePayload && hasMutableStructure && blip is not null + && string.IsNullOrEmpty(linkId) && floatingSupported && boundaryReason is null; + var info = new ImageOccurrence( + ImagePublicId(owner, drawing, occurrences.Length == 1 ? null : index), + picturePayload ? ImageMarkupKind.ModernDrawing : ImageMarkupKind.UnsupportedDrawing, + placement, canMutate, unsupported, + owner.PartUri, owner.Scope, anchor.Value.Id, new CharSpan(offset, 0), + embedId ?? linkId, imagePart?.Uri.ToString(), linkId, linked?.Uri.ToString(), + !string.IsNullOrEmpty(embedId), !string.IsNullOrEmpty(linkId), broken, + imagePart is null ? null : Path.GetFileName(imagePart.Uri.OriginalString), + imagePart?.ContentType, media.Format, media.ContentTypeMatchesBytes, + media.Width, media.Height, renderedWidth, renderedHeight, alt, title, + floatingLayout, floatingSupported); + yield return new ImageCandidate(owner, drawing, container, blip, info); + } + } + + private IEnumerable BuildLegacyCandidates( + OwnedPartRelationships.Owner owner, XElement pict) + { + var imageDataOccurrences = pict.Descendants(ImageV + "imagedata").ToList(); + if (imageDataOccurrences.Count == 0) yield break; + var paragraph = pict.Ancestors(W.p).FirstOrDefault(); + var anchor = paragraph is null ? null : AnchorForElement(paragraph); + if (paragraph is null || anchor is null) yield break; + for (int index = 0; index < imageDataOccurrences.Count; index++) + { + var imageData = imageDataOccurrences[index]; + var relationshipId = (string?)imageData.Attribute(ImageR + "id"); + var imagePart = OwnedPartRelationships.ResolveImagePart(owner.Part, relationshipId); + var external = string.IsNullOrEmpty(relationshipId) ? null + : owner.Part.ExternalRelationships.FirstOrDefault(relationship => relationship.Id == relationshipId); + var media = ReadMediaInfo(imagePart); + var shape = imageData.Ancestors(ImageV + "shape").FirstOrDefault(); + var info = new ImageOccurrence( + ImagePublicId(owner, pict, imageDataOccurrences.Count == 1 ? null : index), + ImageMarkupKind.LegacyVml, null, false, + "legacy VML image markup is enumerable but read-only", + owner.PartUri, owner.Scope, anchor.Value.Id, new CharSpan(ImageOffset(paragraph, pict), 0), + relationshipId, imagePart?.Uri.ToString(), external is null ? null : relationshipId, + external?.Uri.ToString(), imagePart is not null, external is not null, + !string.IsNullOrEmpty(relationshipId) && (imagePart is null || media.IsMalformed + || media.ContentTypeMatchesBytes == false) && external is null, + imagePart is null ? null : Path.GetFileName(imagePart.Uri.OriginalString), + imagePart?.ContentType, media.Format, media.ContentTypeMatchesBytes, + media.Width, media.Height, null, null, (string?)shape?.Attribute("alt"), + (string?)imageData.Attribute(ImageO + "title"), null, false); + yield return new ImageCandidate(owner, pict, null, null, info); + } + } + + private sealed record MediaInfo(ImageBinaryFormat Format, int? Width, int? Height, + bool? ContentTypeMatchesBytes, bool IsMalformed); + + private static MediaInfo ReadMediaInfo(ImagePart? part) + { + if (part is null) return new(ImageBinaryFormat.Unknown, null, null, null, false); + var declaredFormat = FormatFromContentType(part.ContentType); + try + { + var bytes = OwnedPartRelationships.ReadPartBytes(part); + var format = FormatFromMagicToken(ImageHeaderParser.DetectFormat(bytes)); + var dimensions = ImageHeaderParser.GetDimensions(bytes); + return new(format, dimensions?.Width, dimensions?.Height, + format != ImageBinaryFormat.Unknown && format == declaredFormat, + format == ImageBinaryFormat.Unknown || dimensions is null); + } + catch + { + return new(ImageBinaryFormat.Unknown, null, null, false, true); + } + } + + private static ImageBinaryFormat FormatFromMagicToken(string? token) => token switch + { + "png" => ImageBinaryFormat.Png, + "jpeg" => ImageBinaryFormat.Jpeg, + "gif" => ImageBinaryFormat.Gif, + "bmp" => ImageBinaryFormat.Bmp, + "tiff" => ImageBinaryFormat.Tiff, + "webp" => ImageBinaryFormat.Webp, + _ => ImageBinaryFormat.Unknown, + }; + + private static ImageBinaryFormat FormatFromContentType(string? contentType) => + contentType?.ToLowerInvariant() switch + { + "image/png" => ImageBinaryFormat.Png, + "image/jpeg" or "image/jpg" => ImageBinaryFormat.Jpeg, + "image/gif" => ImageBinaryFormat.Gif, + "image/bmp" or "image/x-ms-bmp" => ImageBinaryFormat.Bmp, + "image/tiff" => ImageBinaryFormat.Tiff, + "image/webp" => ImageBinaryFormat.Webp, + _ => ImageBinaryFormat.Unknown, + }; + + private static (double? Width, double? Height) ReadRenderedPoints(XElement? extent) + { + if (!long.TryParse((string?)extent?.Attribute("cx"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var width) + || !long.TryParse((string?)extent?.Attribute("cy"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var height) + || width <= 0 || height <= 0) return (null, null); + return (width / (double)EmusPerPoint, height / (double)EmusPerPoint); + } + + private static string ImagePublicId(OwnedPartRelationships.Owner owner, XElement outer, + int? subOccurrence = null) => + $"img:{owner.Scope}:{UnidHelper.ReadOrDeriveUnid(outer)}" + + (subOccurrence is null ? string.Empty : $":sub{subOccurrence.Value}"); + + private EditResult? ResolveMutableImage(string imageId, out ImageCandidate candidate) + { + candidate = EnumerateImageCandidates(ProjectionScopes.All) + .FirstOrDefault(item => string.Equals(item.Info.Id, imageId, StringComparison.Ordinal))!; + if (candidate is null) + return EditResult.Fail(EditErrorCode.ImageNotFound, $"image not found: {imageId}"); + if (candidate.Info.IsLinked) + return EditResult.Fail(EditErrorCode.LinkedImageReadOnly, + "external linked images are read-only"); + if (!candidate.Info.CanMutate) + return EditResult.Fail(EditErrorCode.UnsupportedImageMarkup, + candidate.Info.UnsupportedReason ?? "image markup is read-only"); + return null; + } + + private EditResult? ValidateImageMutationMode(string? anchorId = null) + { + if (_trackedChanges == TrackedChangeMode.RenderInline) + return EditResult.Fail(EditErrorCode.TrackedOperationUnsupported, + "image mutations cannot be represented faithfully as tracked revisions", anchorId); + return null; + } + + private sealed record ValidatedImageData( + ImageBinaryFormat Format, string? ContentType, int Width, int Height, EditResult? Error); + + private static ValidatedImageData ValidateImageBytes(byte[]? bytes, string? anchorId) + { + if (bytes is null || bytes.Length == 0) + return new(ImageBinaryFormat.Unknown, null, 0, 0, + EditResult.Fail(EditErrorCode.InvalidImageData, "image bytes are empty", anchorId)); + if (bytes.LongLength > MaxImageInputBytes) + return new(ImageBinaryFormat.Unknown, null, 0, 0, + EditResult.Fail(EditErrorCode.ImageTooLarge, + $"image exceeds the {MaxImageInputBytes}-byte runtime limit", anchorId)); + var token = ImageHeaderParser.DetectFormat(bytes); + var format = FormatFromMagicToken(token); + if (format == ImageBinaryFormat.Webp) + return new(format, "image/webp", 0, 0, + EditResult.Fail(EditErrorCode.UnsupportedImageFormat, + "WebP mutation is unsupported because Open XML SDK 3.5.1 exposes no Word ImagePartType for it", anchorId)); + if (format == ImageBinaryFormat.Unknown) + return new(format, null, 0, 0, + EditResult.Fail(EditErrorCode.UnsupportedImageFormat, + "image bytes are not PNG, JPEG, GIF, BMP, or TIFF", anchorId)); + var dimensions = ImageHeaderParser.GetDimensions(bytes); + if (dimensions is null) + return new(format, null, 0, 0, + EditResult.Fail(EditErrorCode.InvalidImageData, + "image header is malformed or has unreadable dimensions", anchorId)); + var contentType = format switch + { + ImageBinaryFormat.Png => "image/png", + ImageBinaryFormat.Jpeg => "image/jpeg", + ImageBinaryFormat.Gif => "image/gif", + ImageBinaryFormat.Bmp => "image/bmp", + ImageBinaryFormat.Tiff => "image/tiff", + _ => null, + }; + return new(format, contentType, dimensions.Value.Width, dimensions.Value.Height, null); + } + + private static EditResult? ValidateInsertOptions(ImageInsertOptions options, + int intrinsicWidth, int intrinsicHeight, string? anchorId, + out long widthEmu, out long heightEmu, out FloatingImageLayout? layout) + { + widthEmu = 0; + heightEmu = 0; + layout = null; + if (!ValidXmlAttributeText(options.AltText) || !ValidXmlAttributeText(options.Title)) + return EditResult.Fail(EditErrorCode.InvalidImageData, + "image metadata contains characters XML attributes cannot represent", anchorId); + if (!Enum.IsDefined(options.Placement)) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + $"unknown image placement: {options.Placement}", anchorId); + if (options.Placement == ImagePlacement.Inline && options.FloatingLayout is not null) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "floatingLayout is only valid when placement is floating", anchorId); + if (options.Placement == ImagePlacement.Floating) + { + layout = options.FloatingLayout ?? new FloatingImageLayout(); + if (ValidateFloatingLayout(layout, anchorId) is { } layoutError) return layoutError; + } + double defaultWidth = intrinsicWidth * 72.0 / ImageDefaultDpi; + double defaultHeight = intrinsicHeight * 72.0 / ImageDefaultDpi; + return ResolveRenderedDimensions(options.WidthPoints, options.HeightPoints, + options.PreserveAspect, defaultWidth, defaultHeight, + out widthEmu, out heightEmu, allowDefaults: true, anchorId); + } + + private static EditResult? ResolveRenderedDimensions(double? widthPoints, double? heightPoints, + bool preserveAspect, double? currentWidthPoints, double? currentHeightPoints, + out long widthEmu, out long heightEmu, bool allowDefaults = false, string? anchorId = null) + { + widthEmu = 0; + heightEmu = 0; + if (currentWidthPoints is null || currentHeightPoints is null + || !ValidRenderedPoints(currentWidthPoints.Value) + || !ValidRenderedPoints(currentHeightPoints.Value)) + return EditResult.Fail(EditErrorCode.InvalidImageDimensions, + "current image dimensions are missing or invalid", anchorId); + if (widthPoints is not null && !ValidRenderedPoints(widthPoints.Value) + || heightPoints is not null && !ValidRenderedPoints(heightPoints.Value)) + return EditResult.Fail(EditErrorCode.InvalidImageDimensions, + $"rendered dimensions must be finite, positive, and no greater than {MaxImageRenderedPoints} points", anchorId); + if (widthPoints is null && heightPoints is null) + { + if (!allowDefaults) + return EditResult.Fail(EditErrorCode.InvalidImageDimensions, + "at least one rendered dimension is required", anchorId); + widthPoints = currentWidthPoints; + heightPoints = currentHeightPoints; + } + else if (preserveAspect) + { + double scale = widthPoints is not null && heightPoints is not null + ? Math.Min(widthPoints.Value / currentWidthPoints.Value, + heightPoints.Value / currentHeightPoints.Value) + : widthPoints is not null + ? widthPoints.Value / currentWidthPoints.Value + : heightPoints!.Value / currentHeightPoints.Value; + widthPoints = currentWidthPoints.Value * scale; + heightPoints = currentHeightPoints.Value * scale; + } + else if (widthPoints is null || heightPoints is null) + { + return EditResult.Fail(EditErrorCode.InvalidImageDimensions, + "widthPoints and heightPoints are both required when preserveAspect is false", anchorId); + } + if (!ValidRenderedPoints(widthPoints!.Value) || !ValidRenderedPoints(heightPoints!.Value)) + return EditResult.Fail(EditErrorCode.InvalidImageDimensions, + "preserve-aspect calculation produced an invalid rendered dimension", anchorId); + try + { + widthEmu = checked((long)Math.Round(widthPoints.Value * EmusPerPoint, + MidpointRounding.AwayFromZero)); + heightEmu = checked((long)Math.Round(heightPoints.Value * EmusPerPoint, + MidpointRounding.AwayFromZero)); + } + catch (OverflowException) + { + return EditResult.Fail(EditErrorCode.InvalidImageDimensions, + "rendered dimension overflows DrawingML EMUs", anchorId); + } + return widthEmu <= 0 || heightEmu <= 0 + ? EditResult.Fail(EditErrorCode.InvalidImageDimensions, + "rendered dimensions round to zero EMUs", anchorId) + : null; + } + + private static bool ValidRenderedPoints(double points) => + double.IsFinite(points) && points > 0 && points <= MaxImageRenderedPoints; + + private static bool ValidXmlAttributeText(string? value) + { + if (value is null) return true; + try { XmlConvert.VerifyXmlChars(value); return true; } + catch (XmlException) { return false; } + } + + private static EditResult? ValidateFloatingLayout(FloatingImageLayout layout, + string? anchorId = null) + { + if (!Enum.IsDefined(layout.HorizontalRelativeFrom) + || !Enum.IsDefined(layout.VerticalRelativeFrom) + || !Enum.IsDefined(layout.WrapMode) + || !Enum.IsDefined(layout.WrapSide) + || layout.HorizontalAlignment is { } horizontal && !Enum.IsDefined(horizontal) + || layout.VerticalAlignment is { } vertical && !Enum.IsDefined(vertical)) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "floating layout contains an unknown enum value", anchorId); + if (layout.HorizontalRelativeFrom == ImageHorizontalReference.Unknown + || layout.VerticalRelativeFrom == ImageVerticalReference.Unknown + || layout.HorizontalAlignment == ImageHorizontalAlignment.Unknown + || layout.VerticalAlignment == ImageVerticalAlignment.Unknown + || layout.WrapMode == ImageWrapMode.Unknown + || layout.WrapSide == ImageWrapSide.Unknown + || layout.RawHorizontalReference is not null || layout.RawVerticalReference is not null + || layout.RawHorizontalPosition is not null || layout.RawVerticalPosition is not null + || layout.RawWrapMode is not null || layout.RawWrapSide is not null + || layout.RawRelativeSizeHorizontal is not null || layout.RawRelativeSizeVertical is not null + || layout.RawFlagTokens is not null) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "report-only raw floating layout tokens cannot be written", anchorId); + if (layout.WrapMode is not (ImageWrapMode.None or ImageWrapMode.Square)) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "only none and square floating wrap modes are mutable", anchorId); + if ((layout.HorizontalOffsetEmu is null) == (layout.HorizontalAlignment is null) + || (layout.VerticalOffsetEmu is null) == (layout.VerticalAlignment is null)) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "each floating position axis requires exactly one offset or alignment", anchorId); + long max = checked((long)(MaxImageRenderedPoints * EmusPerPoint)); + if (layout.HorizontalOffsetEmu is { } x && Math.Abs((decimal)x) > max + || layout.VerticalOffsetEmu is { } y && Math.Abs((decimal)y) > max) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "floating position offset exceeds the runtime EMU limit", anchorId); + if (layout.DistanceTopEmu < 0 || layout.DistanceBottomEmu < 0 + || layout.DistanceLeftEmu < 0 || layout.DistanceRightEmu < 0 + || layout.DistanceTopEmu > max || layout.DistanceBottomEmu > max + || layout.DistanceLeftEmu > max || layout.DistanceRightEmu > max) + return EditResult.Fail(EditErrorCode.InvalidImageLayout, + "wrap distances must be non-negative and within the runtime EMU limit", anchorId); + return null; + } + + private static string? ValidateImageInsertionBoundary(XElement paragraph, int offset) + { + if (paragraph.Descendants().Any(element => element.Name == W.ins || element.Name == W.del + || element.Name == W.moveFrom || element.Name == W.moveTo)) + return "images cannot be inserted into tracked-revision markup"; + if (paragraph.Descendants().Any(element => element.Name == W.fldChar || element.Name == W.instrText)) + return "images cannot be inserted into a paragraph containing a complex field"; + int consumed = 0; + foreach (var child in paragraph.Elements().Where(IsInlineChild)) + { + int length = string.Concat(child.DescendantsAndSelf(W.t).Select(text => (string)text)).Length; + if (consumed < offset && offset < consumed + length && child.Name != W.r) + return "image insertion boundary is inside an unsupported inline container"; + consumed += length; + } + return null; + } + + private static string? ExistingImageBoundaryReason(XElement image, XElement paragraph) + { + if (image.Ancestors().TakeWhile(element => element != paragraph).Any(element => + element.Name == W.ins || element.Name == W.del || element.Name == W.moveFrom + || element.Name == W.moveTo || element.Name == W.hyperlink || element.Name == W.sdt + || element.Name == W.fldSimple || element.Name == W.smartTag)) + return "image is inside an unsupported inline/revision container"; + if (image.Ancestors().TakeWhile(element => element != paragraph) + .Any(element => element.Name == MC.AlternateContent)) + return "image is inside markup-compatibility AlternateContent and cannot be changed without synchronizing its fallback"; + if (paragraph.Descendants().Any(element => element.Name == W.fldChar || element.Name == W.instrText)) + return "image is in a paragraph containing a complex field"; + return null; + } + + private static int ImageOffset(XElement paragraph, XElement image) + { + int offset = 0; + foreach (var run in InlineRuns(paragraph)) + { + if (ReferenceEquals(run, image) || image.AncestorsAndSelf().Contains(run) + || XNode.DocumentOrderComparer.Compare(run, image) >= 0) break; + offset += RunText(run).Length; + } + return offset; + } + + private static void InsertInlineElementAtOffset(XElement paragraph, int offset, XElement element) + { + SplitRunsAtOffset(paragraph, offset); + SplitInlineContainersAtOffset(paragraph, offset); + var map = RunTextMap.Build(paragraph); + var right = map.Segments.FirstOrDefault(segment => segment.StartOffsetInBlock >= offset).Run; + if (right is not null) + { + var boundary = right.AncestorsAndSelf().First(node => ReferenceEquals(node.Parent, paragraph)); + boundary.AddBeforeSelf(element); + } + else paragraph.Add(element); + } + + private uint NextDocumentPropertyId() + { + var used = OwnedPartRelationships.StoryParts(_doc!) + .SelectMany(owner => owner.Part.GetXDocument().Descendants(WP.docPr)) + .Select(element => uint.TryParse((string?)element.Attribute("id"), + NumberStyles.None, CultureInfo.InvariantCulture, out var id) ? id : 0) + .Where(id => id != 0).ToHashSet(); + for (uint id = 1; id < uint.MaxValue; id++) if (!used.Contains(id)) return id; + throw new InvalidOperationException("no globally available wp:docPr id remains"); + } + + private static XElement BuildImageDrawing(string relationshipId, uint docPrId, + long widthEmu, long heightEmu, string? altText, string? title, + ImagePlacement placement, FloatingImageLayout? layout) + { + var docPr = new XElement(WP.docPr, + new XAttribute("id", docPrId), + new XAttribute("name", $"Picture {docPrId}")); + if (altText is not null) docPr.Add(new XAttribute("descr", altText)); + if (title is not null) docPr.Add(new XAttribute("title", title)); + var cNvPr = new XElement(Pic.cNvPr, + new XAttribute("id", docPrId), + new XAttribute("name", $"Picture {docPrId}")); + if (altText is not null) cNvPr.Add(new XAttribute("descr", altText)); + if (title is not null) cNvPr.Add(new XAttribute("title", title)); + var graphic = new XElement(A.graphic, + new XElement(A.graphicData, + new XAttribute("uri", PictureGraphicDataUri), + new XElement(Pic._pic, + new XElement(Pic.nvPicPr, + cNvPr, + new XElement(Pic.cNvPicPr, + new XElement(A.picLocks, new XAttribute("noChangeAspect", 1)))), + new XElement(Pic.blipFill, + new XElement(A.blip, new XAttribute(ImageR + "embed", relationshipId)), + new XElement(A.stretch, new XElement(A.fillRect))), + new XElement(Pic.spPr, + new XElement(A.xfrm, + new XElement(A.off, new XAttribute("x", 0), new XAttribute("y", 0)), + new XElement(A.ext, new XAttribute("cx", widthEmu), new XAttribute("cy", heightEmu))), + new XElement(A.prstGeom, new XAttribute("prst", "rect"), new XElement(A.avLst)))))); + XElement container; + if (placement == ImagePlacement.Inline) + { + container = new XElement(WP.inline, + new XAttribute("distT", 0), new XAttribute("distB", 0), + new XAttribute("distL", 0), new XAttribute("distR", 0), + new XElement(WP.extent, new XAttribute("cx", widthEmu), new XAttribute("cy", heightEmu)), + new XElement(WP.effectExtent, new XAttribute("l", 0), new XAttribute("t", 0), + new XAttribute("r", 0), new XAttribute("b", 0)), + docPr, + new XElement(WP.cNvGraphicFramePr, + new XElement(A.graphicFrameLocks, new XAttribute("noChangeAspect", 1))), + graphic); + } + else + { + layout ??= new FloatingImageLayout(); + container = new XElement(WP.anchor, + new XElement(WP.simplePos, new XAttribute("x", 0), new XAttribute("y", 0)), + BuildHorizontalPosition(layout), BuildVerticalPosition(layout), + new XElement(WP.extent, new XAttribute("cx", widthEmu), new XAttribute("cy", heightEmu)), + new XElement(WP.effectExtent, new XAttribute("l", 0), new XAttribute("t", 0), + new XAttribute("r", 0), new XAttribute("b", 0)), + BuildWrap(layout), docPr, + new XElement(WP.cNvGraphicFramePr, + new XElement(A.graphicFrameLocks, new XAttribute("noChangeAspect", 1))), + graphic); + ApplyFloatingAttributes(container, layout); + } + return new XElement(W.drawing, container); + } + + private static void SetDrawingExtents(XElement container, long widthEmu, long heightEmu) + { + var extent = container.Element(WP.extent) + ?? throw new InvalidDataException("drawing has no wp:extent"); + extent.SetAttributeValue("cx", widthEmu); + extent.SetAttributeValue("cy", heightEmu); + var transformExtent = container.Descendants(A.xfrm).FirstOrDefault()?.Element(A.ext) + ?? throw new InvalidDataException("picture has no a:xfrm/a:ext"); + transformExtent.SetAttributeValue("cx", widthEmu); + transformExtent.SetAttributeValue("cy", heightEmu); + } + + private static void ApplyFloatingLayout(XElement anchor, FloatingImageLayout layout) + { + var positionH = anchor.Element(WP.positionH) + ?? throw new InvalidDataException("floating image has no horizontal position"); + var positionV = anchor.Element(WP.positionV) + ?? throw new InvalidDataException("floating image has no vertical position"); + positionH.ReplaceWith(BuildHorizontalPosition(layout)); + positionV.ReplaceWith(BuildVerticalPosition(layout)); + var wrap = anchor.Elements().FirstOrDefault(element => element.Name == WP.wrapNone + || element.Name == WP.wrapSquare || element.Name == WP.wrapTight + || element.Name == WP.wrapThrough || element.Name == WP.wrapTopAndBottom) + ?? throw new InvalidDataException("floating image has no wrap element"); + wrap.ReplaceWith(BuildWrap(layout)); + ApplyFloatingAttributes(anchor, layout); + } + + private static void ApplyFloatingAttributes(XElement anchor, FloatingImageLayout layout) + { + anchor.SetAttributeValue("distT", layout.DistanceTopEmu); + anchor.SetAttributeValue("distB", layout.DistanceBottomEmu); + anchor.SetAttributeValue("distL", layout.DistanceLeftEmu); + anchor.SetAttributeValue("distR", layout.DistanceRightEmu); + anchor.SetAttributeValue("simplePos", 0); + anchor.SetAttributeValue("relativeHeight", layout.RelativeHeight); + anchor.SetAttributeValue("behindDoc", BoolToken(layout.BehindDocument)); + anchor.SetAttributeValue("locked", BoolToken(layout.Locked)); + anchor.SetAttributeValue("layoutInCell", BoolToken(layout.LayoutInCell)); + anchor.SetAttributeValue("allowOverlap", BoolToken(layout.AllowOverlap)); + } + + private static XElement BuildHorizontalPosition(FloatingImageLayout layout) + { + var position = new XElement(WP.positionH, + new XAttribute("relativeFrom", HorizontalReferenceToken(layout.HorizontalRelativeFrom))); + if (layout.HorizontalAlignment is { } alignment) + position.Add(new XElement(WP.align, HorizontalAlignmentToken(alignment))); + else position.Add(new XElement(WP.posOffset, layout.HorizontalOffsetEmu!.Value)); + return position; + } + + private static XElement BuildVerticalPosition(FloatingImageLayout layout) + { + var position = new XElement(WP.positionV, + new XAttribute("relativeFrom", VerticalReferenceToken(layout.VerticalRelativeFrom))); + if (layout.VerticalAlignment is { } alignment) + position.Add(new XElement(WP.align, VerticalAlignmentToken(alignment))); + else position.Add(new XElement(WP.posOffset, layout.VerticalOffsetEmu!.Value)); + return position; + } + + private static XElement BuildWrap(FloatingImageLayout layout) => + layout.WrapMode == ImageWrapMode.None + ? new XElement(WP.wrapNone) + : new XElement(WP.wrapSquare, new XAttribute("wrapText", WrapSideToken(layout.WrapSide))); + + private static bool TryReadFloatingLayout(XElement anchor, + out FloatingImageLayout? layout, out string? unsupportedReason) + { + layout = null; + unsupportedReason = null; + bool mutable = true; + var rawTokens = new Dictionary(StringComparer.Ordinal); + string? rawRelativeSizeHorizontal = null; + string? rawRelativeSizeVertical = null; + if (!TryBoolAttribute(anchor, "simplePos", false, out var usesSimplePosition)) + { + rawTokens["simplePos"] = (string)anchor.Attribute("simplePos")!; + mutable = false; + unsupportedReason = "floating layout has a malformed simplePos flag"; + } + else if (usesSimplePosition) + { + rawTokens["simplePos"] = (string?)anchor.Attribute("simplePos") ?? "1"; + mutable = false; + unsupportedReason = "floating layout uses the read-only simplePos coordinate system"; + } + var sizeRelH = anchor.Element(WP14.sizeRelH); + var sizeRelV = anchor.Element(WP14.sizeRelV); + if (sizeRelH is not null) + { + rawRelativeSizeHorizontal = sizeRelH.ToString(SaveOptions.DisableFormatting); + mutable = false; + unsupportedReason ??= "relative percentage sizing is read-only"; + } + if (sizeRelV is not null) + { + rawRelativeSizeVertical = sizeRelV.ToString(SaveOptions.DisableFormatting); + mutable = false; + unsupportedReason ??= "relative percentage sizing is read-only"; + } + var positionH = anchor.Element(WP.positionH); + var positionV = anchor.Element(WP.positionV); + var wrapElements = anchor.Elements().Where(element => element.Name == WP.wrapNone + || element.Name == WP.wrapSquare || element.Name == WP.wrapTight + || element.Name == WP.wrapThrough || element.Name == WP.wrapTopAndBottom).ToList(); + if (positionH is null || positionV is null || wrapElements.Count != 1) + { + unsupportedReason = "floating image lacks one canonical position/wrap layout"; + return false; + } + var horizontalReferenceToken = (string?)positionH.Attribute("relativeFrom"); + var verticalReferenceToken = (string?)positionV.Attribute("relativeFrom"); + string? rawHorizontalReference = null; + string? rawVerticalReference = null; + if (!TryParseHorizontalReference(horizontalReferenceToken, out var horizontalRef)) + { + horizontalRef = ImageHorizontalReference.Unknown; + rawHorizontalReference = horizontalReferenceToken; + mutable = false; + unsupportedReason = "floating image uses an unsupported horizontal position reference"; + } + if (!TryParseVerticalReference(verticalReferenceToken, out var verticalRef)) + { + verticalRef = ImageVerticalReference.Unknown; + rawVerticalReference = verticalReferenceToken; + mutable = false; + unsupportedReason ??= "floating image uses an unsupported vertical position reference"; + } + string? rawHorizontalPosition = null; + string? rawVerticalPosition = null; + if (!TryReadHorizontalPosition(positionH, out var horizontalOffset, out var horizontalAlignment)) + { + horizontalOffset = null; + horizontalAlignment = null; + rawHorizontalPosition = positionH.ToString(SaveOptions.DisableFormatting); + mutable = false; + unsupportedReason ??= "floating horizontal position is not one supported offset or alignment"; + } + if (!TryReadVerticalPosition(positionV, out var verticalOffset, out var verticalAlignment)) + { + verticalOffset = null; + verticalAlignment = null; + rawVerticalPosition = positionV.ToString(SaveOptions.DisableFormatting); + mutable = false; + unsupportedReason ??= "floating vertical position is not one supported offset or alignment"; + } + var wrap = wrapElements[0]; + ImageWrapMode wrapMode; + ImageWrapSide wrapSide = ImageWrapSide.BothSides; + string? rawWrapMode = null; + string? rawWrapSide = null; + if (wrap.Name == WP.wrapNone) + { + wrapMode = ImageWrapMode.None; + if (!HasOnlyAttributes(wrap) || wrap.Nodes().Any()) + { + rawWrapMode = wrap.ToString(SaveOptions.DisableFormatting); + mutable = false; + unsupportedReason ??= "floating wrap contains unmodeled attributes or children"; + } + } + else + { + wrapMode = wrap.Name == WP.wrapSquare ? ImageWrapMode.Square + : wrap.Name == WP.wrapTight ? ImageWrapMode.Tight + : wrap.Name == WP.wrapThrough ? ImageWrapMode.Through + : wrap.Name == WP.wrapTopAndBottom ? ImageWrapMode.TopAndBottom + : ImageWrapMode.Unknown; + var wrapSideToken = (string?)wrap.Attribute("wrapText") ?? "bothSides"; + if (!TryParseWrapSide(wrapSideToken, out wrapSide)) + { + wrapSide = ImageWrapSide.Unknown; + rawWrapSide = wrapSideToken; + mutable = false; + unsupportedReason ??= "floating image uses an unsupported wrap side"; + } + if (wrapMode is not ImageWrapMode.Square) + { + rawWrapMode = wrap.ToString(SaveOptions.DisableFormatting); + mutable = false; + unsupportedReason ??= $"floating wrap form {wrap.Name.LocalName} is read-only"; + } + else if (!HasOnlyAttributes(wrap, "wrapText") || wrap.Nodes().Any()) + { + rawWrapMode = wrap.ToString(SaveOptions.DisableFormatting); + mutable = false; + unsupportedReason ??= "floating wrap contains unmodeled attributes or children"; + } + } + if (!TryLongAttribute(anchor, "distT", 0, out var distT)) + { + rawTokens["distT"] = (string)anchor.Attribute("distT")!; mutable = false; distT = 0; + } + if (!TryLongAttribute(anchor, "distB", 0, out var distB)) + { rawTokens["distB"] = (string)anchor.Attribute("distB")!; mutable = false; distB = 0; } + if (!TryLongAttribute(anchor, "distL", 0, out var distL)) + { rawTokens["distL"] = (string)anchor.Attribute("distL")!; mutable = false; distL = 0; } + if (!TryLongAttribute(anchor, "distR", 0, out var distR)) + { rawTokens["distR"] = (string)anchor.Attribute("distR")!; mutable = false; distR = 0; } + if (!TryUIntAttribute(anchor, "relativeHeight", 0, out var relativeHeight)) + { rawTokens["relativeHeight"] = (string)anchor.Attribute("relativeHeight")!; mutable = false; relativeHeight = 0; } + if (!TryBoolAttribute(anchor, "behindDoc", false, out var behindDocument)) + { rawTokens["behindDoc"] = (string)anchor.Attribute("behindDoc")!; mutable = false; behindDocument = false; } + if (!TryBoolAttribute(anchor, "locked", false, out var locked)) + { rawTokens["locked"] = (string)anchor.Attribute("locked")!; mutable = false; locked = false; } + if (!TryBoolAttribute(anchor, "layoutInCell", true, out var layoutInCell)) + { rawTokens["layoutInCell"] = (string)anchor.Attribute("layoutInCell")!; mutable = false; layoutInCell = true; } + if (!TryBoolAttribute(anchor, "allowOverlap", true, out var allowOverlap)) + { rawTokens["allowOverlap"] = (string)anchor.Attribute("allowOverlap")!; mutable = false; allowOverlap = true; } + if (rawTokens.Count != 0) + unsupportedReason ??= "floating layout contains malformed numeric or boolean attributes"; + layout = new FloatingImageLayout + { + HorizontalRelativeFrom = horizontalRef, + HorizontalOffsetEmu = horizontalOffset, + HorizontalAlignment = horizontalAlignment, + VerticalRelativeFrom = verticalRef, + VerticalOffsetEmu = verticalOffset, + VerticalAlignment = verticalAlignment, + WrapMode = wrapMode, + WrapSide = wrapSide, + DistanceTopEmu = distT, + DistanceBottomEmu = distB, + DistanceLeftEmu = distL, + DistanceRightEmu = distR, + RelativeHeight = relativeHeight, + BehindDocument = behindDocument, + Locked = locked, + LayoutInCell = layoutInCell, + AllowOverlap = allowOverlap, + RawHorizontalReference = rawHorizontalReference, + RawVerticalReference = rawVerticalReference, + RawHorizontalPosition = rawHorizontalPosition, + RawVerticalPosition = rawVerticalPosition, + RawWrapMode = rawWrapMode, + RawWrapSide = rawWrapSide, + RawRelativeSizeHorizontal = rawRelativeSizeHorizontal, + RawRelativeSizeVertical = rawRelativeSizeVertical, + RawFlagTokens = rawTokens.Count == 0 ? null : rawTokens, + }; + if (mutable && ValidateFloatingLayout(layout) is { } error) + { + unsupportedReason = error.Error?.Message; + mutable = false; + } + return mutable; + } + + private static bool TryReadHorizontalPosition(XElement position, + out long? offset, out ImageHorizontalAlignment? alignment) + { + offset = null; + alignment = null; + if (position.Attribute("relativeFrom") is null + || !HasOnlyAttributes(position, "relativeFrom") + || position.Elements().Count() != 1) return false; + var offsetElements = position.Elements(WP.posOffset).ToList(); + var alignElements = position.Elements(WP.align).ToList(); + if (offsetElements.Count + alignElements.Count != 1) return false; + var offsetElement = offsetElements.SingleOrDefault(); + var alignElement = alignElements.SingleOrDefault(); + var valueElement = offsetElement ?? alignElement!; + if (!HasOnlyAttributes(valueElement) + || valueElement.Nodes().Any(node => node is not XText)) return false; + if (offsetElement is not null) + return long.TryParse(offsetElement.Value, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var value) && Assign(value, out offset); + return TryParseHorizontalAlignment(alignElement!.Value, out alignment); + } + + private static bool TryReadVerticalPosition(XElement position, + out long? offset, out ImageVerticalAlignment? alignment) + { + offset = null; + alignment = null; + if (position.Attribute("relativeFrom") is null + || !HasOnlyAttributes(position, "relativeFrom") + || position.Elements().Count() != 1) return false; + var offsetElements = position.Elements(WP.posOffset).ToList(); + var alignElements = position.Elements(WP.align).ToList(); + if (offsetElements.Count + alignElements.Count != 1) return false; + var offsetElement = offsetElements.SingleOrDefault(); + var alignElement = alignElements.SingleOrDefault(); + var valueElement = offsetElement ?? alignElement!; + if (!HasOnlyAttributes(valueElement) + || valueElement.Nodes().Any(node => node is not XText)) return false; + if (offsetElement is not null) + return long.TryParse(offsetElement.Value, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var value) && Assign(value, out offset); + return TryParseVerticalAlignment(alignElement!.Value, out alignment); + } + + private static bool HasOnlyAttributes(XElement element, params XName[] allowed) => + element.Attributes().Where(attribute => !attribute.IsNamespaceDeclaration + && attribute.Name != PtOpenXml.Unid) + .All(attribute => allowed.Contains(attribute.Name)); + + private static bool Assign(long value, out long? target) { target = value; return true; } + + private static bool TryLongAttribute(XElement element, XName name, long fallback, out long value) + { + var token = (string?)element.Attribute(name); + if (token is null) { value = fallback; return true; } + return long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + + private static bool TryUIntAttribute(XElement element, XName name, uint fallback, out uint value) + { + var token = (string?)element.Attribute(name); + if (token is null) { value = fallback; return true; } + return uint.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } + + private static bool TryBoolAttribute(XElement element, XName name, bool fallback, out bool value) + { + var token = (string?)element.Attribute(name); + if (token is null) { value = fallback; return true; } + switch (token) + { + case "1": case "true": case "on": value = true; return true; + case "0": case "false": case "off": value = false; return true; + default: value = fallback; return false; + } + } + + private static int BoolToken(bool value) => value ? 1 : 0; + + private static string HorizontalReferenceToken(ImageHorizontalReference value) => value switch + { + ImageHorizontalReference.Page => "page", + ImageHorizontalReference.Margin => "margin", + ImageHorizontalReference.Column => "column", + ImageHorizontalReference.Character => "character", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + + private static string VerticalReferenceToken(ImageVerticalReference value) => value switch + { + ImageVerticalReference.Page => "page", + ImageVerticalReference.Margin => "margin", + ImageVerticalReference.Paragraph => "paragraph", + ImageVerticalReference.Line => "line", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + + private static string HorizontalAlignmentToken(ImageHorizontalAlignment value) => value switch + { + ImageHorizontalAlignment.Left => "left", + ImageHorizontalAlignment.Center => "center", + ImageHorizontalAlignment.Right => "right", + ImageHorizontalAlignment.Inside => "inside", + ImageHorizontalAlignment.Outside => "outside", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + + private static string VerticalAlignmentToken(ImageVerticalAlignment value) => value switch + { + ImageVerticalAlignment.Top => "top", + ImageVerticalAlignment.Center => "center", + ImageVerticalAlignment.Bottom => "bottom", + ImageVerticalAlignment.Inside => "inside", + ImageVerticalAlignment.Outside => "outside", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + + private static string WrapSideToken(ImageWrapSide value) => value switch + { + ImageWrapSide.BothSides => "bothSides", + ImageWrapSide.Left => "left", + ImageWrapSide.Right => "right", + ImageWrapSide.Largest => "largest", + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + + private static bool TryParseHorizontalReference(string? token, out ImageHorizontalReference value) => + EnumTry(token, out value, ("page", ImageHorizontalReference.Page), + ("margin", ImageHorizontalReference.Margin), ("column", ImageHorizontalReference.Column), + ("character", ImageHorizontalReference.Character)); + + private static bool TryParseVerticalReference(string? token, out ImageVerticalReference value) => + EnumTry(token, out value, ("page", ImageVerticalReference.Page), + ("margin", ImageVerticalReference.Margin), ("paragraph", ImageVerticalReference.Paragraph), + ("line", ImageVerticalReference.Line)); + + private static bool TryParseHorizontalAlignment(string? token, out ImageHorizontalAlignment? value) + { + if (EnumTry(token, out ImageHorizontalAlignment parsed, + ("left", ImageHorizontalAlignment.Left), ("center", ImageHorizontalAlignment.Center), + ("right", ImageHorizontalAlignment.Right), ("inside", ImageHorizontalAlignment.Inside), + ("outside", ImageHorizontalAlignment.Outside))) { value = parsed; return true; } + value = null; + return false; + } + + private static bool TryParseVerticalAlignment(string? token, out ImageVerticalAlignment? value) + { + if (EnumTry(token, out ImageVerticalAlignment parsed, + ("top", ImageVerticalAlignment.Top), ("center", ImageVerticalAlignment.Center), + ("bottom", ImageVerticalAlignment.Bottom), ("inside", ImageVerticalAlignment.Inside), + ("outside", ImageVerticalAlignment.Outside))) { value = parsed; return true; } + value = null; + return false; + } + + private static bool TryParseWrapSide(string? token, out ImageWrapSide value) => + EnumTry(token, out value, ("bothSides", ImageWrapSide.BothSides), + ("left", ImageWrapSide.Left), ("right", ImageWrapSide.Right), + ("largest", ImageWrapSide.Largest)); + + private static bool EnumTry(string? token, out T value, params (string Token, T Value)[] values) + where T : struct + { + foreach (var candidate in values) + { + if (!string.Equals(token, candidate.Token, StringComparison.Ordinal)) continue; + value = candidate.Value; + return true; + } + value = default; + return false; + } + + private EditResult ImageMutationSuccess(ImageCandidate candidate, string imageId) + { + var paragraph = candidate.Outer.Ancestors(W.p).FirstOrDefault(); + var anchor = paragraph is null ? null : AnchorForElement(paragraph); + return new EditResult { Success = true, ImageId = imageId, + Modified = anchor is null ? Array.Empty() : new[] { anchor.Value } }; + } +} diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 1bc044f4..a068b9fa 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -1621,6 +1621,15 @@ public enum EditErrorCode UnsupportedInlineBoundary, TrackedOperationUnsupported, + ImageNotFound, + InvalidImageData, + UnsupportedImageFormat, + ImageTooLarge, + InvalidImageDimensions, + UnsupportedImageMarkup, + LinkedImageReadOnly, + InvalidImageLayout, + /// 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. @@ -1663,6 +1672,9 @@ public sealed class EditResult public string? HyperlinkId { get; init; } public string? BookmarkName { get; init; } + /// The affected native image occurrence identity, when applicable. + public string? ImageId { get; init; } + internal static EditResult Fail(EditErrorCode code, string message, string? anchorId = null) => new() { Success = false, Error = new EditError(code, message, anchorId) }; } @@ -2672,7 +2684,7 @@ private EditResult ResolveRevision(string revisionId, bool accept) try { var removedElements = Internal.RevisionOps.Apply(group, accept); - Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owningPart, R.id); + SweepOrphanedStoryRelationships(owningPart); var removed = new List(); var seenRemoved = new HashSet(StringComparer.Ordinal); @@ -5676,6 +5688,11 @@ public byte[] Save(bool persistAnchorIds) { ThrowIfDisposed(); + // A save is the package-wide normalization boundary. Some higher-level transforms can + // remove image markup without passing through the native image operations, so sweep every + // story owner here as a final defense against dangling media relationships. + SweepOrphanedStoryImageRelationships(); + if (persistAnchorIds) { // Flush every projected part's cached XDocument to its stream first. @@ -5897,8 +5914,11 @@ public EditResult ReplaceText(string anchorId, string markdownPayload) } PromoteHyperlinkRelationships(element); if (hyperlinkOwner is { } owner) + { foreach (var relationshipId in oldHyperlinkIds) Internal.OwnedPartRelationships.DeleteReferenceRelationshipIfOrphaned(owner.Part, relationshipId, R.id); + Internal.OwnedPartRelationships.SweepOrphanedImages(owner.Part, R.embed, R.link); + } InvalidateProjectionCache(); return new EditResult @@ -6005,7 +6025,7 @@ public EditResult DeleteBlock(string anchorId) } element.Remove(); if (hyperlinkOwner is { } owner) - Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); + SweepOrphanedStoryRelationships(owner.Part); InvalidateProjectionCache(); return new EditResult { @@ -6219,7 +6239,7 @@ private EditResult DeleteSiblingRangeCore( } } if (hyperlinkOwner is { } trackedOwner) - Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(trackedOwner.Part, R.id); + SweepOrphanedStoryRelationships(trackedOwner.Part); InvalidateProjectionCache(); return new EditResult { @@ -6239,7 +6259,7 @@ private EditResult DeleteSiblingRangeCore( el.Remove(); } if (hyperlinkOwner is { } owner) - Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); + SweepOrphanedStoryRelationships(owner.Part); InvalidateProjectionCache(); return new EditResult { @@ -7222,6 +7242,7 @@ internal EditResult RawReplaceXmlInternal(string anchorId, string xml) var element = target.Resolve(_doc!); if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", anchorId); + var relationshipOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, element); int baselineErrors = _settings.ValidateRawOps ? CountRealValidationErrors() : 0; _history.RecordPreOp(TakeSnapshot()); @@ -7237,6 +7258,9 @@ internal EditResult RawReplaceXmlInternal(string anchorId, string xml) return EditResult.Fail(EditErrorCode.ValidationFailed, "OpenXmlValidator found new errors", anchorId); } + if (relationshipOwner is { } owner) + SweepOrphanedStoryRelationships(owner.Part); + InvalidateProjectionCache(); var freshIndex = AnchorIndex(); var newUnids = CollectUnids(parsedXml).ToHashSet(); @@ -7364,8 +7388,11 @@ public EditResult ReplaceCellContent(string cellAnchorId, string markdownPayload PromoteHyperlinkRelationships(p); } if (hyperlinkOwner is { } owner) + { foreach (var relationshipId in oldHyperlinkIds) Internal.OwnedPartRelationships.DeleteReferenceRelationshipIfOrphaned(owner.Part, relationshipId, R.id); + Internal.OwnedPartRelationships.SweepOrphanedImages(owner.Part, R.embed, R.link); + } // A table cell must contain at least one paragraph per OOXML schema. if (!cell.Elements(W.p).Any()) cell.Add(new XElement(W.p)); @@ -8063,6 +8090,7 @@ private EditResult SetHeaderFooterText(bool isHeader, string anchorId, HeaderFoo foreach (var p in paras) PromoteHyperlinkRelationships(p); foreach (var relationshipId in oldHyperlinkIds) Internal.OwnedPartRelationships.DeleteReferenceRelationshipIfOrphaned(part, relationshipId, R.id); + Internal.OwnedPartRelationships.SweepOrphanedImages(part, R.embed, R.link); // Visibility flags so Word actually shows the First/Even stories. if (kind == HeaderFooterKind.First && sectPr.Element(W.titlePg) is null) @@ -9199,6 +9227,7 @@ public EditResult UpdateComment(string commentAnchorId, string markdownPayload) paras[paras.Count - 1].SetAttributeValue(W14.paraId, preservedParaId); foreach (var p in paras) UnidHelper.AssignToSelfAndDescendants(p); main.WordprocessingCommentsPart.PutXDocument(); + SweepOrphanedStoryRelationships(main.WordprocessingCommentsPart); InvalidateProjectionCache(); @@ -9777,7 +9806,7 @@ public EditResult DeleteTableRow(string cellAnchorId) } if (hyperlinkOwner is { } owner) - Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); + SweepOrphanedStoryRelationships(owner.Part); InvalidateProjectionCache(); var mapping = CompleteTableMapping(before, tbl); @@ -9859,7 +9888,7 @@ public EditResult DeleteTableColumn(string cellAnchorId) } if (hyperlinkOwner is { } owner) - Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); + SweepOrphanedStoryRelationships(owner.Part); InvalidateProjectionCache(); var mapping = CompleteTableMapping(before, tbl); @@ -10013,7 +10042,7 @@ public EditResult MergeCells(string cellAnchorId, int rowSpan, int colSpan, } if (hyperlinkOwner is { } owner) - Internal.OwnedPartRelationships.SweepOrphanedHyperlinks(owner.Part, R.id); + SweepOrphanedStoryRelationships(owner.Part); InvalidateProjectionCache(); var mapping = CompleteTableMapping(before, tbl); @@ -11277,7 +11306,10 @@ internal sealed record DocumentSnapshot( 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 PartUri, string RelId, string Uri, bool IsExternal)> HyperlinkRelationships) + System.Collections.Generic.IReadOnlyList<(string PartUri, string RelId, string Uri, bool IsExternal)> HyperlinkRelationships, + System.Collections.Generic.IReadOnlyList<(string PartUri, string ContentType, byte[] Bytes)> ImageParts, + System.Collections.Generic.IReadOnlyList<(string OwnerPartUri, string RelId, string TargetPartUri)> ImageRelationships, + System.Collections.Generic.IReadOnlyList<(string OwnerPartUri, string RelId, string TargetUri)> LinkedImageRelationships) { /// /// Optional exact package checkpoint used by transaction boundaries. Unlike the selective @@ -11298,7 +11330,8 @@ internal sealed record DocumentSnapshot( /// internal long ApproximateBytes => _approximateBytes ??= PackageBytes?.LongLength - ?? Parts.Sum(p => Internal.XmlMemoryEstimator.Estimate(p.Xml)); + ?? (Parts.Sum(p => Internal.XmlMemoryEstimator.Estimate(p.Xml)) + + ImageParts.Sum(p => (long)p.Bytes.Length)); private long? _approximateBytes; } @@ -11314,6 +11347,9 @@ internal DocumentSnapshot TakeSnapshot() 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 imageParts = new System.Collections.Generic.List<(string, string, byte[])>(); + var imageRelationships = new System.Collections.Generic.List<(string, string, string)>(); + var linkedImageRelationships = new System.Collections.Generic.List<(string, string, string)>(); var main = _doc!.MainDocumentPart; if (main is not null) { @@ -11333,11 +11369,24 @@ internal DocumentSnapshot TakeSnapshot() main.WordprocessingCommentsIdsPart.Uri.ToString())); } 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)); + foreach (var relationship in Internal.OwnedPartRelationships.ImageRelationships(owner.Part)) + { + imageRelationships.Add((owner.PartUri, relationship.RelationshipId, + relationship.Target.Uri.ToString())); + if (imageParts.All(part => part.Item1 != relationship.Target.Uri.ToString())) + imageParts.Add((relationship.Target.Uri.ToString(), relationship.Target.ContentType, + Internal.OwnedPartRelationships.ReadPartBytes(relationship.Target))); + } + foreach (var relationship in Internal.OwnedPartRelationships.ExternalImageRelationships(owner.Part)) + linkedImageRelationships.Add((owner.PartUri, relationship.Id, relationship.Uri.ToString())); + } return new DocumentSnapshot(_version, parts, hfParts, noteParts, commentParts, - commentThreadingParts, hyperlinkRelationships); + commentThreadingParts, hyperlinkRelationships, imageParts, imageRelationships, + linkedImageRelationships); } /// @@ -11355,7 +11404,10 @@ internal DocumentSnapshot TakePackageSnapshot() 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 PartUri, string RelId, string Uri, bool IsExternal)>()) + Array.Empty<(string PartUri, string RelId, string Uri, bool IsExternal)>(), + Array.Empty<(string PartUri, string ContentType, byte[] Bytes)>(), + Array.Empty<(string OwnerPartUri, string RelId, string TargetPartUri)>(), + Array.Empty<(string OwnerPartUri, string RelId, string TargetUri)>()) { PackageBytes = bytes, RevisionCounter = _revisionCounter, @@ -11541,6 +11593,10 @@ internal void RestoreSnapshot(DocumentSnapshot snapshot) } } + // Binary media restoration can require recreating an exact OPC part URI. It is last + // because it reopens the SDK package graph after low-level part/relationship repair. + RestoreImageRelationships(snapshot); + _version = snapshot.Version; InvalidateProjectionCache(); } diff --git a/Docxodus/ImageHeaderParser.cs b/Docxodus/ImageHeaderParser.cs index 477d5f25..0745c0cb 100644 --- a/Docxodus/ImageHeaderParser.cs +++ b/Docxodus/ImageHeaderParser.cs @@ -21,7 +21,7 @@ public static class ImageHeaderParser /// Tuple of (Width, Height) or null if parsing fails public static (int Width, int Height)? GetDimensions(byte[] bytes) { - if (bytes == null || bytes.Length < 10) + if (bytes == null) return null; // PNG: 89 50 4E 47 0D 0A 1A 0A @@ -33,19 +33,19 @@ public static (int Width, int Height)? GetDimensions(byte[] bytes) } // JPEG: FF D8 FF - if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) { return GetJpegDimensions(bytes); } // GIF: 47 49 46 38 (GIF8) - if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38) + if (HasGifSignature(bytes)) { return GetGifDimensions(bytes); } // BMP: 42 4D (BM) - if (bytes[0] == 0x42 && bytes[1] == 0x4D && bytes.Length >= 26) + if (bytes.Length >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D) { return GetBmpDimensions(bytes); } @@ -77,16 +77,18 @@ public static (int Width, int Height)? GetDimensions(byte[] bytes) if (bytes == null || bytes.Length < 4) return null; - if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + if (bytes.Length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 + && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D + && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A) return "png"; - if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) return "jpeg"; - if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38) + if (HasGifSignature(bytes)) return "gif"; - if (bytes[0] == 0x42 && bytes[1] == 0x4D) + if (bytes.Length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D) return "bmp"; if (bytes.Length > 11 && @@ -94,8 +96,8 @@ public static (int Width, int Height)? GetDimensions(byte[] bytes) bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) return "webp"; - if ((bytes[0] == 0x49 && bytes[1] == 0x49 && bytes[2] == 0x2A && bytes[3] == 0x00) || - (bytes[0] == 0x4D && bytes[1] == 0x4D && bytes[2] == 0x00 && bytes[3] == 0x2A)) + if (bytes.Length >= 4 && ((bytes[0] == 0x49 && bytes[1] == 0x49 && bytes[2] == 0x2A && bytes[3] == 0x00) || + (bytes[0] == 0x4D && bytes[1] == 0x4D && bytes[2] == 0x00 && bytes[3] == 0x2A))) return "tiff"; return null; @@ -106,11 +108,14 @@ private static (int, int)? GetPngDimensions(byte[] bytes) // IHDR chunk: offset 8 (chunk length) + 4 (type) = 12 // Dimensions at bytes 16-23 (big-endian) if (bytes.Length < 24) return null; + if (bytes[8] != 0 || bytes[9] != 0 || bytes[10] != 0 || bytes[11] != 13 + || bytes[12] != (byte)'I' || bytes[13] != (byte)'H' + || bytes[14] != (byte)'D' || bytes[15] != (byte)'R') return null; int width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; int height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; - if (width <= 0 || height <= 0 || width > 65535 || height > 65535) + if (width <= 0 || height <= 0) return null; return (width, height); @@ -118,56 +123,29 @@ private static (int, int)? GetPngDimensions(byte[] bytes) private static (int, int)? GetJpegDimensions(byte[] bytes) { - // Scan for SOF0 (0xFFC0), SOF1 (0xFFC1), SOF2 (0xFFC2), or SOF3 (0xFFC3) markers int i = 2; - while (i < bytes.Length - 9) + while (i < bytes.Length) { - if (bytes[i] != 0xFF) - { - i++; - continue; - } - - byte marker = bytes[i + 1]; - - // SOF0, SOF1, SOF2, SOF3 markers contain dimensions - if (marker >= 0xC0 && marker <= 0xC3) - { - int height = (bytes[i + 5] << 8) | bytes[i + 6]; - int width = (bytes[i + 7] << 8) | bytes[i + 8]; - - if (width > 0 && height > 0) - return (width, height); - } - - // Skip marker - if (marker == 0xD8 || marker == 0xD9 || marker == 0x01) - { - // Standalone markers - i += 2; - } - else if (marker >= 0xD0 && marker <= 0xD7) + while (i < bytes.Length && bytes[i] != 0xFF) i++; + while (i < bytes.Length && bytes[i] == 0xFF) i++; + if (i >= bytes.Length) return null; + byte marker = bytes[i++]; + if (marker == 0x00) continue; + if (marker == 0xD9 || marker == 0xDA) return null; + if (marker == 0xD8 || marker == 0x01 || marker is >= 0xD0 and <= 0xD7) continue; + if (i + 2 > bytes.Length) return null; + int length = (bytes[i] << 8) | bytes[i + 1]; + if (length < 2 || length > bytes.Length - i) return null; + bool isStartOfFrame = marker is >= 0xC0 and <= 0xCF + && marker is not (0xC4 or 0xC8 or 0xCC); + if (isStartOfFrame) { - // RST markers (no length) - i += 2; - } - else if (marker == 0x00) - { - // Stuffed byte, skip - i += 1; - } - else if (i + 3 < bytes.Length) - { - // Read segment length and skip - int length = (bytes[i + 2] << 8) | bytes[i + 3]; - if (length < 2) - break; // Invalid length - i += 2 + length; - } - else - { - break; + if (length < 8 || i + 7 >= bytes.Length) return null; + int height = (bytes[i + 3] << 8) | bytes[i + 4]; + int width = (bytes[i + 5] << 8) | bytes[i + 6]; + return width > 0 && height > 0 ? (width, height) : null; } + i += length; } return null; } @@ -186,16 +164,25 @@ private static (int, int)? GetGifDimensions(byte[] bytes) return (width, height); } + private static bool HasGifSignature(byte[] bytes) => bytes.Length >= 6 + && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F' + && bytes[3] == (byte)'8' && (bytes[4] == (byte)'7' || bytes[4] == (byte)'9') + && bytes[5] == (byte)'a'; + private static (int, int)? GetBmpDimensions(byte[] bytes) { // DIB header starts at offset 14 // Dimensions at bytes 18-25 (little-endian, signed for height) if (bytes.Length < 26) return null; + uint dibSize = (uint)(bytes[14] | (bytes[15] << 8) | (bytes[16] << 16) | (bytes[17] << 24)); + if (dibSize < 40 || dibSize > bytes.Length - 14) return null; + int width = bytes[18] | (bytes[19] << 8) | (bytes[20] << 16) | (bytes[21] << 24); int height = bytes[22] | (bytes[23] << 8) | (bytes[24] << 16) | (bytes[25] << 24); // Height can be negative (top-down bitmap) + if (height == int.MinValue) return null; height = Math.Abs(height); if (width <= 0 || height <= 0) @@ -273,12 +260,11 @@ private static (int, int)? GetTiffDimensions(byte[] bytes) bool isLittleEndian = bytes[0] == 0x49; // 'I' = little-endian, 'M' = big-endian // Read IFD offset (bytes 4-7) - int ifdOffset = isLittleEndian - ? bytes[4] | (bytes[5] << 8) | (bytes[6] << 16) | (bytes[7] << 24) - : (bytes[4] << 24) | (bytes[5] << 16) | (bytes[6] << 8) | bytes[7]; + uint ifdOffsetValue = ReadUInt32(bytes, 4, isLittleEndian); - if (ifdOffset < 0 || ifdOffset + 2 >= bytes.Length) + if (ifdOffsetValue > int.MaxValue || ifdOffsetValue > (uint)(bytes.Length - 2)) return null; + int ifdOffset = (int)ifdOffsetValue; // Read number of directory entries int numEntries = isLittleEndian @@ -288,7 +274,9 @@ private static (int, int)? GetTiffDimensions(byte[] bytes) int width = 0, height = 0; // Each entry is 12 bytes - for (int i = 0; i < numEntries && ifdOffset + 2 + (i + 1) * 12 <= bytes.Length; i++) + long entriesEnd = (long)ifdOffset + 2L + (long)numEntries * 12L; + if (entriesEnd > bytes.Length) return null; + for (int i = 0; i < numEntries; i++) { int entryOffset = ifdOffset + 2 + i * 12; @@ -302,22 +290,19 @@ private static (int, int)? GetTiffDimensions(byte[] bytes) int type = isLittleEndian ? bytes[entryOffset + 2] | (bytes[entryOffset + 3] << 8) : (bytes[entryOffset + 2] << 8) | bytes[entryOffset + 3]; + uint count = ReadUInt32(bytes, entryOffset + 4, isLittleEndian); + if (count != 1 || type is not (3 or 4)) continue; - int value; + uint rawValue; if (type == 3) // SHORT (2 bytes) { - value = isLittleEndian + rawValue = (uint)(isLittleEndian ? bytes[entryOffset + 8] | (bytes[entryOffset + 9] << 8) - : (bytes[entryOffset + 8] << 8) | bytes[entryOffset + 9]; - } - else // LONG (4 bytes) - { - value = isLittleEndian - ? bytes[entryOffset + 8] | (bytes[entryOffset + 9] << 8) | - (bytes[entryOffset + 10] << 16) | (bytes[entryOffset + 11] << 24) - : (bytes[entryOffset + 8] << 24) | (bytes[entryOffset + 9] << 16) | - (bytes[entryOffset + 10] << 8) | bytes[entryOffset + 11]; + : (bytes[entryOffset + 8] << 8) | bytes[entryOffset + 9]); } + else rawValue = ReadUInt32(bytes, entryOffset + 8, isLittleEndian); + if (rawValue == 0 || rawValue > int.MaxValue) return null; + int value = (int)rawValue; if (tag == 256) width = value; else height = value; @@ -332,5 +317,11 @@ private static (int, int)? GetTiffDimensions(byte[] bytes) return null; } + + private static uint ReadUInt32(byte[] bytes, int offset, bool littleEndian) => littleEndian + ? (uint)(bytes[offset] | (bytes[offset + 1] << 8) + | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) + : (uint)((bytes[offset] << 24) | (bytes[offset + 1] << 16) + | (bytes[offset + 2] << 8) | bytes[offset + 3]); } } diff --git a/Docxodus/Internal/DocxSessionJson.cs b/Docxodus/Internal/DocxSessionJson.cs index 00ccd6f4..813392ed 100644 --- a/Docxodus/Internal/DocxSessionJson.cs +++ b/Docxodus/Internal/DocxSessionJson.cs @@ -609,6 +609,149 @@ public static bool TryGetBool(JsonElement root, string name, bool fallback) => public static double? TryGetDoubleNullable(JsonElement root, string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : (double?)null; + public static ImageInsertOptions ParseImageInsertOptions(string json) + { + if (string.IsNullOrEmpty(json)) return new ImageInsertOptions(); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + RequireObject(root, "image options"); + return new ImageInsertOptions + { + Placement = ParseImagePlacement(StrictString(root, "placement", "inline")), + WidthPoints = StrictDoubleNullable(root, "widthPoints"), + HeightPoints = StrictDoubleNullable(root, "heightPoints"), + PreserveAspect = StrictBool(root, "preserveAspect", true), + AltText = StrictString(root, "altText", null), + Title = StrictString(root, "title", null), + FloatingLayout = ParseOptionalFloatingImageLayout(root), + }; + } + + private static FloatingImageLayout? ParseOptionalFloatingImageLayout(JsonElement root) + { + if (!root.TryGetProperty("floatingLayout", out var layout)) return null; + if (layout.ValueKind != JsonValueKind.Object) + throw new System.ArgumentException("floatingLayout must be a JSON object"); + return ParseFloatingImageLayout(layout); + } + + public static FloatingImageLayout ParseFloatingImageLayout(string json) + { + using var document = JsonDocument.Parse(json); + return ParseFloatingImageLayout(document.RootElement); + } + + public static FloatingImageLayout ParseFloatingImageLayout(JsonElement root) + { + RequireObject(root, "floating layout"); + long? horizontalOffset = StrictInt64Nullable(root, "horizontalOffsetEmu"); + long? verticalOffset = StrictInt64Nullable(root, "verticalOffsetEmu"); + var horizontalAlignment = ParseHorizontalAlignment(StrictString(root, "horizontalAlignment", null)); + var verticalAlignment = ParseVerticalAlignment(StrictString(root, "verticalAlignment", null)); + if (horizontalAlignment is not null && !root.TryGetProperty("horizontalOffsetEmu", out _)) + horizontalOffset = null; + if (verticalAlignment is not null && !root.TryGetProperty("verticalOffsetEmu", out _)) + verticalOffset = null; + return new FloatingImageLayout + { + HorizontalRelativeFrom = ParseHorizontalReference( + StrictString(root, "horizontalRelativeFrom", "column")), + HorizontalOffsetEmu = horizontalOffset ?? (horizontalAlignment is null ? 0 : null), + HorizontalAlignment = horizontalAlignment, + VerticalRelativeFrom = ParseVerticalReference( + StrictString(root, "verticalRelativeFrom", "paragraph")), + VerticalOffsetEmu = verticalOffset ?? (verticalAlignment is null ? 0 : null), + VerticalAlignment = verticalAlignment, + WrapMode = ParseWrapMode(StrictString(root, "wrapMode", "square")), + WrapSide = ParseWrapSide(StrictString(root, "wrapSide", "both_sides")), + DistanceTopEmu = StrictInt64(root, "distanceTopEmu", 0), + DistanceBottomEmu = StrictInt64(root, "distanceBottomEmu", 0), + DistanceLeftEmu = StrictInt64(root, "distanceLeftEmu", 0), + DistanceRightEmu = StrictInt64(root, "distanceRightEmu", 0), + RelativeHeight = StrictUInt32(root, "relativeHeight", 251658240), + BehindDocument = StrictBool(root, "behindDocument", false), + Locked = StrictBool(root, "locked", false), + LayoutInCell = StrictBool(root, "layoutInCell", true), + AllowOverlap = StrictBool(root, "allowOverlap", true), + }; + } + + public static (double? Width, double? Height, bool PreserveAspect) ParseImageDimensions(string json) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + RequireObject(root, "image dimensions"); + return (StrictDoubleNullable(root, "widthPoints"), + StrictDoubleNullable(root, "heightPoints"), StrictBool(root, "preserveAspect", true)); + } + + private static void RequireObject(JsonElement root, string description) + { if (root.ValueKind != JsonValueKind.Object) throw new System.ArgumentException($"{description} must be a JSON object"); } + private static string? StrictString(JsonElement root, string name, string? fallback) + { + if (!root.TryGetProperty(name, out var value)) return fallback; + if (value.ValueKind == JsonValueKind.Null) return null; + if (value.ValueKind != JsonValueKind.String) throw new System.ArgumentException($"{name} must be a string or null"); + return value.GetString(); + } + private static double? StrictDoubleNullable(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetDouble(out var parsed)) + throw new System.ArgumentException($"{name} must be a number or null"); + return parsed; + } + private static bool StrictBool(JsonElement root, string name, bool fallback) + { + if (!root.TryGetProperty(name, out var value)) return fallback; + if (value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new System.ArgumentException($"{name} must be a boolean"); + return value.GetBoolean(); + } + private static long StrictInt64(JsonElement root, string name, long fallback) => + StrictInt64Nullable(root, name) ?? fallback; + private static long? StrictInt64Nullable(JsonElement root, string name) + { + if (!root.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt64(out var parsed)) + throw new System.ArgumentException($"{name} must be a 64-bit integer or null"); + return parsed; + } + private static uint StrictUInt32(JsonElement root, string name, uint fallback) + { + if (!root.TryGetProperty(name, out var value)) return fallback; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetUInt32(out var parsed)) + throw new System.ArgumentException($"{name} must be an integer from 0 through {uint.MaxValue}"); + return parsed; + } + + private static ImagePlacement ParseImagePlacement(string? token) => token switch + { "inline" => ImagePlacement.Inline, "floating" => ImagePlacement.Floating, _ => (ImagePlacement)(-1) }; + private static ImageHorizontalReference ParseHorizontalReference(string? token) => token switch + { "page" => ImageHorizontalReference.Page, "margin" => ImageHorizontalReference.Margin, + "column" => ImageHorizontalReference.Column, "character" => ImageHorizontalReference.Character, + _ => ImageHorizontalReference.Unknown }; + private static ImageVerticalReference ParseVerticalReference(string? token) => token switch + { "page" => ImageVerticalReference.Page, "margin" => ImageVerticalReference.Margin, + "paragraph" => ImageVerticalReference.Paragraph, "line" => ImageVerticalReference.Line, + _ => ImageVerticalReference.Unknown }; + private static ImageHorizontalAlignment? ParseHorizontalAlignment(string? token) => token switch + { null => null, "left" => ImageHorizontalAlignment.Left, "center" => ImageHorizontalAlignment.Center, + "right" => ImageHorizontalAlignment.Right, "inside" => ImageHorizontalAlignment.Inside, + "outside" => ImageHorizontalAlignment.Outside, _ => ImageHorizontalAlignment.Unknown }; + private static ImageVerticalAlignment? ParseVerticalAlignment(string? token) => token switch + { null => null, "top" => ImageVerticalAlignment.Top, "center" => ImageVerticalAlignment.Center, + "bottom" => ImageVerticalAlignment.Bottom, "inside" => ImageVerticalAlignment.Inside, + "outside" => ImageVerticalAlignment.Outside, _ => ImageVerticalAlignment.Unknown }; + private static ImageWrapMode ParseWrapMode(string? token) => token switch + { "none" => ImageWrapMode.None, "square" => ImageWrapMode.Square, "tight" => ImageWrapMode.Tight, + "through" => ImageWrapMode.Through, "top_and_bottom" => ImageWrapMode.TopAndBottom, + _ => ImageWrapMode.Unknown }; + private static ImageWrapSide ParseWrapSide(string? token) => token switch + { "both_sides" => ImageWrapSide.BothSides, "left" => ImageWrapSide.Left, + "right" => ImageWrapSide.Right, "largest" => ImageWrapSide.Largest, + _ => ImageWrapSide.Unknown }; + // ─── Serializers ──────────────────────────────────────────────────── public static string Serialize(EditResult r) @@ -665,6 +808,8 @@ public static string Serialize(EditResult r) sb.Append(",\"hyperlinkId\":").Append(JsonString(r.HyperlinkId)); if (r.BookmarkName is not null) sb.Append(",\"bookmarkName\":").Append(JsonString(r.BookmarkName)); + if (r.ImageId is not null) + sb.Append(",\"imageId\":").Append(JsonString(r.ImageId)); if (r.Patch is not null) { sb.Append(",\"patch\":{") @@ -932,6 +1077,149 @@ public static string SerializeHyperlinks(IReadOnlyList links) return sb.Append(']').ToString(); } + public static string SerializeImages(IReadOnlyList images) + { + var sb = new StringBuilder(images.Count * 700 + 2).Append('['); + for (int i = 0; i < images.Count; i++) + { + if (i > 0) sb.Append(','); + var image = images[i]; + sb.Append("{\"id\":").Append(JsonString(image.Id)) + .Append(",\"markupKind\":").Append(JsonString(ToSnake(image.MarkupKind.ToString()))); + AppendEnum(sb, "placement", image.Placement); + sb.Append(",\"canMutate\":").Append(image.CanMutate ? "true" : "false"); + AppendString(sb, "unsupportedReason", image.UnsupportedReason); + sb.Append(",\"owningPartUri\":").Append(JsonString(image.OwningPartUri)) + .Append(",\"scope\":").Append(JsonString(image.Scope)) + .Append(",\"anchorId\":").Append(JsonString(image.AnchorId)) + .Append(",\"span\":{\"start\":").Append(image.Span.Start) + .Append(",\"length\":").Append(image.Span.Length).Append('}'); + AppendString(sb, "relationshipId", image.RelationshipId); + AppendString(sb, "targetPartUri", image.TargetPartUri); + AppendString(sb, "linkedRelationshipId", image.LinkedRelationshipId); + AppendString(sb, "linkedTarget", image.LinkedTarget); + sb.Append(",\"isEmbedded\":").Append(image.IsEmbedded ? "true" : "false") + .Append(",\"isLinked\":").Append(image.IsLinked ? "true" : "false") + .Append(",\"isBroken\":").Append(image.IsBroken ? "true" : "false"); + AppendString(sb, "mediaFileName", image.MediaFileName); + AppendString(sb, "contentType", image.ContentType); + sb.Append(",\"format\":").Append(JsonString(ToSnake(image.Format.ToString()))); + AppendNullableBool(sb, "contentTypeMatchesBytes", image.ContentTypeMatchesBytes); + AppendNullableNumber(sb, "intrinsicWidthPixels", image.IntrinsicWidthPixels); + AppendNullableNumber(sb, "intrinsicHeightPixels", image.IntrinsicHeightPixels); + AppendNullableDouble(sb, "renderedWidthPoints", image.RenderedWidthPoints); + AppendNullableDouble(sb, "renderedHeightPoints", image.RenderedHeightPoints); + AppendString(sb, "altText", image.AltText); + AppendString(sb, "title", image.Title); + if (image.FloatingLayout is not null) + { + sb.Append(",\"floatingLayout\":"); + AppendFloatingLayout(sb, image.FloatingLayout); + } + sb.Append(",\"floatingLayoutSupported\":") + .Append(image.FloatingLayoutSupported ? "true" : "false").Append('}'); + } + return sb.Append(']').ToString(); + } + + public static string SerializeImageCapabilities(ImageCapabilities capabilities) + { + var sb = new StringBuilder(1200).Append("{\"schemaVersion\":") + .Append(capabilities.SchemaVersion).Append(",\"runtime\":") + .Append(JsonString(capabilities.Runtime)).Append(",\"formats\":["); + for (int i = 0; i < capabilities.Formats.Count; i++) + { + if (i > 0) sb.Append(','); + var format = capabilities.Formats[i]; + sb.Append("{\"format\":").Append(JsonString(ToSnake(format.Format.ToString()))) + .Append(",\"contentType\":").Append(JsonString(format.ContentType)) + .Append(",\"canInspect\":").Append(format.CanInspect ? "true" : "false") + .Append(",\"canInsert\":").Append(format.CanInsert ? "true" : "false") + .Append(",\"canReplace\":").Append(format.CanReplace ? "true" : "false"); + AppendString(sb, "limitation", format.Limitation); + sb.Append('}'); + } + sb.Append("],\"operations\":"); AppendStringArray(sb, capabilities.Operations); + sb.Append(",\"mutableWrapModes\":"); + AppendEnumArray(sb, capabilities.MutableWrapModes); + sb.Append(",\"horizontalReferences\":"); + AppendEnumArray(sb, capabilities.HorizontalReferences.Where(value => value != ImageHorizontalReference.Unknown).ToArray()); + sb.Append(",\"verticalReferences\":"); + AppendEnumArray(sb, capabilities.VerticalReferences.Where(value => value != ImageVerticalReference.Unknown).ToArray()); + sb.Append(",\"maxInputBytes\":").Append(capabilities.MaxInputBytes) + .Append(",\"maxRenderedPoints\":").Append(capabilities.MaxRenderedPoints.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Append(",\"defaultDpi\":").Append(capabilities.DefaultDpi.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Append(",\"usesHeaderParsingOnly\":").Append(capabilities.UsesHeaderParsingOnly ? "true" : "false") + .Append(",\"acceptsBinaryBytes\":").Append(capabilities.AcceptsBinaryBytes ? "true" : "false") + .Append(",\"supportsNetworkFetch\":").Append(capabilities.SupportsNetworkFetch ? "true" : "false") + .Append(",\"supportsFileIo\":").Append(capabilities.SupportsFileIo ? "true" : "false") + .Append('}'); + return sb.ToString(); + } + + private static void AppendFloatingLayout(StringBuilder sb, FloatingImageLayout layout) + { + sb.Append("{\"horizontalRelativeFrom\":").Append(JsonString(ToSnake(layout.HorizontalRelativeFrom.ToString()))); + AppendNullableNumber(sb, "horizontalOffsetEmu", layout.HorizontalOffsetEmu); + AppendEnum(sb, "horizontalAlignment", layout.HorizontalAlignment); + sb.Append(",\"verticalRelativeFrom\":").Append(JsonString(ToSnake(layout.VerticalRelativeFrom.ToString()))); + AppendNullableNumber(sb, "verticalOffsetEmu", layout.VerticalOffsetEmu); + AppendEnum(sb, "verticalAlignment", layout.VerticalAlignment); + sb.Append(",\"wrapMode\":").Append(JsonString(ToSnake(layout.WrapMode.ToString()))) + .Append(",\"wrapSide\":").Append(JsonString(ToSnake(layout.WrapSide.ToString()))) + .Append(",\"distanceTopEmu\":").Append(layout.DistanceTopEmu) + .Append(",\"distanceBottomEmu\":").Append(layout.DistanceBottomEmu) + .Append(",\"distanceLeftEmu\":").Append(layout.DistanceLeftEmu) + .Append(",\"distanceRightEmu\":").Append(layout.DistanceRightEmu) + .Append(",\"relativeHeight\":").Append(layout.RelativeHeight) + .Append(",\"behindDocument\":").Append(layout.BehindDocument ? "true" : "false") + .Append(",\"locked\":").Append(layout.Locked ? "true" : "false") + .Append(",\"layoutInCell\":").Append(layout.LayoutInCell ? "true" : "false") + .Append(",\"allowOverlap\":").Append(layout.AllowOverlap ? "true" : "false"); + AppendString(sb, "rawHorizontalReference", layout.RawHorizontalReference); + AppendString(sb, "rawVerticalReference", layout.RawVerticalReference); + AppendString(sb, "rawHorizontalPosition", layout.RawHorizontalPosition); + AppendString(sb, "rawVerticalPosition", layout.RawVerticalPosition); + AppendString(sb, "rawWrapMode", layout.RawWrapMode); + AppendString(sb, "rawWrapSide", layout.RawWrapSide); + AppendString(sb, "rawRelativeSizeHorizontal", layout.RawRelativeSizeHorizontal); + AppendString(sb, "rawRelativeSizeVertical", layout.RawRelativeSizeVertical); + if (layout.RawFlagTokens is not null) + { + sb.Append(",\"rawFlagTokens\":{"); + int i = 0; + foreach (var pair in layout.RawFlagTokens) + { + if (i++ > 0) sb.Append(','); + sb.Append(JsonString(pair.Key)).Append(':').Append(JsonString(pair.Value)); + } + sb.Append('}'); + } + sb.Append('}'); + } + + private static string ToSnake(string value) + { + var sb = new StringBuilder(value.Length + 4); + for (int i = 0; i < value.Length; i++) + { + if (i > 0 && char.IsUpper(value[i])) sb.Append('_'); + sb.Append(char.ToLowerInvariant(value[i])); + } + return sb.ToString(); + } + + private static void AppendString(StringBuilder sb, string name, string? value) + { if (value is not null) sb.Append(',').Append(JsonString(name)).Append(':').Append(JsonString(value)); } + private static void AppendNullableNumber(StringBuilder sb, string name, T? value) where T : struct + { if (value is not null) sb.Append(',').Append(JsonString(name)).Append(':').Append(value.Value); } + private static void AppendNullableDouble(StringBuilder sb, string name, double? value) + { if (value is not null) sb.Append(',').Append(JsonString(name)).Append(':').Append(value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); } + private static void AppendEnum(StringBuilder sb, string name, T? value) where T : struct, System.Enum + { if (value is not null) sb.Append(',').Append(JsonString(name)).Append(':').Append(JsonString(ToSnake(value.Value.ToString()))); } + private static void AppendEnumArray(StringBuilder sb, IReadOnlyList values) where T : struct, System.Enum + { sb.Append('['); for (int i = 0; i < values.Count; i++) { if (i > 0) sb.Append(','); sb.Append(JsonString(ToSnake(values[i].ToString()))); } sb.Append(']'); } + public static string SerializeBookmarks(IReadOnlyList bookmarks) { var sb = new StringBuilder(bookmarks.Count * 320 + 2).Append('['); diff --git a/Docxodus/Internal/DocxSessionOps.cs b/Docxodus/Internal/DocxSessionOps.cs index 041f469a..34b6bdb3 100644 --- a/Docxodus/Internal/DocxSessionOps.cs +++ b/Docxodus/Internal/DocxSessionOps.cs @@ -578,6 +578,101 @@ 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)); + // ─── Native images (issue #453) ──────────────────────────────────── + + public static string GetImageCapabilities() => + DocxSessionJson.SerializeImageCapabilities(DocxSession.GetImageCapabilities()); + + public static string ListImages(int handle, ProjectionScopes scopes = ProjectionScopes.All) => + DocxSessionJson.SerializeImages(SessionRegistry.Get(handle).ListImages(scopes)); + + public static string InsertImage(int handle, string anchorId, int characterOffset, + string imageBase64, string optionsJson) + { + if (!TryDecodeImageBase64(imageBase64, anchorId, out var bytes, out var error)) return error!; + try + { + return DocxSessionJson.Serialize(SessionRegistry.Get(handle).InsertImage( + anchorId, characterOffset, bytes!, DocxSessionJson.ParseImageInsertOptions(optionsJson))); + } + catch (System.Exception ex) when (ex is System.Text.Json.JsonException or System.ArgumentException + or System.OverflowException) + { + return DocxSessionJson.Serialize(EditResult.Fail(EditErrorCode.InvalidImageLayout, + $"invalid image options JSON: {ex.Message}", anchorId)); + } + } + + public static string ReplaceImage(int handle, string imageId, string imageBase64) + { + if (!TryDecodeImageBase64(imageBase64, null, out var bytes, out var error)) return error!; + return DocxSessionJson.Serialize(SessionRegistry.Get(handle).ReplaceImage(imageId, bytes!)); + } + + public static string SetImageDimensions(int handle, string imageId, string dimensionsJson) + { + try + { + var dimensions = DocxSessionJson.ParseImageDimensions(dimensionsJson); + return DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetImageDimensions(imageId, + dimensions.Width, dimensions.Height, dimensions.PreserveAspect)); + } + catch (System.Exception ex) when (ex is System.Text.Json.JsonException or System.ArgumentException + or System.OverflowException) + { + return DocxSessionJson.Serialize(EditResult.Fail(EditErrorCode.InvalidImageDimensions, + $"invalid image dimensions JSON: {ex.Message}")); + } + } + + public static string SetImageMetadata(int handle, string imageId, string? altText, string? title) => + DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetImageMetadata(imageId, altText, title)); + + public static string SetImageFloatingLayout(int handle, string imageId, string layoutJson) + { + try + { + return DocxSessionJson.Serialize(SessionRegistry.Get(handle).SetImageFloatingLayout( + imageId, DocxSessionJson.ParseFloatingImageLayout(layoutJson))); + } + catch (System.Exception ex) when (ex is System.Text.Json.JsonException or System.ArgumentException + or System.OverflowException) + { + return DocxSessionJson.Serialize(EditResult.Fail(EditErrorCode.InvalidImageLayout, + $"invalid floating layout JSON: {ex.Message}")); + } + } + + public static string RemoveImage(int handle, string imageId) => + DocxSessionJson.Serialize(SessionRegistry.Get(handle).RemoveImage(imageId)); + + private static bool TryDecodeImageBase64(string? base64, string? anchorId, + out byte[]? bytes, out string? error) + { + bytes = null; + error = null; + if (string.IsNullOrEmpty(base64)) + { + error = DocxSessionJson.Serialize(EditResult.Fail(EditErrorCode.InvalidImageData, + "image base64 is empty", anchorId)); + return false; + } + long maxEncodedLength = ((DocxSession.MaxImageInputBytes + 2) / 3) * 4; + if (base64.Length > maxEncodedLength) + { + error = DocxSessionJson.Serialize(EditResult.Fail(EditErrorCode.ImageTooLarge, + $"encoded image exceeds the {DocxSession.MaxImageInputBytes}-byte runtime limit", anchorId)); + return false; + } + try { bytes = System.Convert.FromBase64String(base64); return true; } + catch (System.FormatException) + { + error = DocxSessionJson.Serialize(EditResult.Fail(EditErrorCode.InvalidImageData, + "imageBase64 is not valid base64", anchorId)); + return false; + } + } + // ─── Tier C: formatting ───────────────────────────────────────────── public static string ApplyFormat(int handle, string anchorId, CharSpan? span, FormatOp op, diff --git a/Docxodus/Internal/OwnedPartRelationships.Images.cs b/Docxodus/Internal/OwnedPartRelationships.Images.cs new file mode 100644 index 00000000..44da4a1a --- /dev/null +++ b/Docxodus/Internal/OwnedPartRelationships.Images.cs @@ -0,0 +1,220 @@ +// 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.Security.Cryptography; +using System.Xml.Linq; +using System.IO.Packaging; +using DocumentFormat.OpenXml.Experimental; +using DocumentFormat.OpenXml.Packaging; + +namespace Docxodus.Internal; + +/// Image-specific operations layered on the generic owning-part relationship seam. +internal static partial class OwnedPartRelationships +{ + private static readonly XNamespace OfficeRelationships = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + internal const string ImageRelationshipType = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; + + internal readonly record struct OwnedImageRelationship( + OpenXmlPart Owner, string RelationshipId, ImagePart Target); + + internal static IEnumerable ImageRelationships(OpenXmlPart owner) => + owner.Parts.Where(pair => pair.OpenXmlPart is ImagePart) + .Select(pair => new OwnedImageRelationship(owner, pair.RelationshipId, + (ImagePart)pair.OpenXmlPart)); + + internal static IEnumerable ExternalImageRelationships(OpenXmlPart owner) => + owner.ExternalRelationships.Where(relationship => + relationship.RelationshipType == ImageRelationshipType); + + internal static ImagePart? ResolveImagePart(OpenXmlPart owner, string? relationshipId) + { + if (string.IsNullOrEmpty(relationshipId)) return null; + return owner.Parts.FirstOrDefault(pair => pair.RelationshipId == relationshipId) + .OpenXmlPart as ImagePart; + } + + internal static byte[] ReadPartBytes(OpenXmlPart part) + { + using var input = part.GetStream(FileMode.Open, FileAccess.Read); + using var output = new MemoryStream(); + input.CopyTo(output); + return output.ToArray(); + } + + internal static string ImageContentHash(string contentType, byte[] bytes) + { + var contentTypeBytes = System.Text.Encoding.UTF8.GetBytes(contentType); + var payload = new byte[contentTypeBytes.Length + 1 + bytes.Length]; + Buffer.BlockCopy(contentTypeBytes, 0, payload, 0, contentTypeBytes.Length); + Buffer.BlockCopy(bytes, 0, payload, contentTypeBytes.Length + 1, bytes.Length); + return Convert.ToHexString(SHA256.HashData(payload)); + } + + /// Find an identical image part anywhere in the editable stories, attaching it to + /// when necessary; otherwise create and feed a new owner-valid part. + /// The returned relationship id is always owned by . + internal static (ImagePart Part, string RelationshipId, bool Reused) FindOrAddImagePart( + WordprocessingDocument document, OpenXmlPart owner, byte[] bytes, + string contentType, ImageBinaryFormat format) + { + var wantedHash = ImageContentHash(contentType, bytes); + foreach (var relationship in ImageRelationships(owner)) + { + if (relationship.Target.ContentType == contentType + && ImageContentHash(contentType, ReadPartBytes(relationship.Target)) == wantedHash) + return (relationship.Target, relationship.RelationshipId, true); + } + + ImagePart? packageMatch = null; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var candidateOwner in StoryParts(document)) + { + foreach (var relationship in ImageRelationships(candidateOwner.Part)) + { + if (!seen.Add(relationship.Target.Uri.ToString()) + || relationship.Target.ContentType != contentType) continue; + if (ImageContentHash(contentType, ReadPartBytes(relationship.Target)) == wantedHash) + { + packageMatch = relationship.Target; + break; + } + } + if (packageMatch is not null) break; + } + + if (packageMatch is not null) + { + var attached = owner.AddPart(packageMatch); + return (attached, owner.GetIdOfPart(attached), true); + } + + var partType = format switch + { + ImageBinaryFormat.Png => ImagePartType.Png, + ImageBinaryFormat.Jpeg => ImagePartType.Jpeg, + ImageBinaryFormat.Gif => ImagePartType.Gif, + ImageBinaryFormat.Bmp => ImagePartType.Bmp, + ImageBinaryFormat.Tiff => ImagePartType.Tiff, + _ => throw new NotSupportedException($"unsupported image format: {format}"), + }; + ImagePart created = owner switch + { + MainDocumentPart part => part.AddImagePart(partType), + HeaderPart part => part.AddImagePart(partType), + FooterPart part => part.AddImagePart(partType), + FootnotesPart part => part.AddImagePart(partType), + EndnotesPart part => part.AddImagePart(partType), + WordprocessingCommentsPart part => part.AddImagePart(partType), + _ => throw new NotSupportedException($"part cannot own a Word image: {owner.Uri}"), + }; + using (var input = new MemoryStream(bytes, writable: false)) created.FeedData(input); + return (created, owner.GetIdOfPart(created), false); + } + + internal static ImagePart CreateImagePart(OpenXmlPart owner, string contentType, + byte[] bytes, string relationshipId) + { + ImagePart created = owner switch + { + MainDocumentPart part => part.AddImagePart(contentType, relationshipId), + HeaderPart part => part.AddImagePart(contentType, relationshipId), + FooterPart part => part.AddImagePart(contentType, relationshipId), + FootnotesPart part => part.AddImagePart(contentType, relationshipId), + EndnotesPart part => part.AddImagePart(contentType, relationshipId), + WordprocessingCommentsPart part => part.AddImagePart(contentType, relationshipId), + _ => throw new NotSupportedException($"part cannot own a Word image: {owner.Uri}"), + }; + using var input = new MemoryStream(bytes, writable: false); + created.FeedData(input); + return created; + } + + internal static void AttachImagePart(OpenXmlPart owner, ImagePart target, + string relationshipId) => owner.AddPart(target, relationshipId); + + /// Remove unreferenced embedded-image part relationships and external linked-image + /// relationships from one owner. Shared package parts remain alive while any other owner still + /// relates to them; the SDK removes the media part only after its last relationship is gone. + internal static int SweepOrphanedImages(OpenXmlPart owner, XName embedAttribute, XName linkAttribute) + { + int removed = 0; + foreach (var relationship in ImageRelationships(owner).ToList()) + { + if (IsReferenced(owner, relationship.RelationshipId, embedAttribute) + || IsReferenced(owner, relationship.RelationshipId, OfficeRelationships + "id")) continue; + owner.DeletePart(relationship.RelationshipId); + removed++; + } + foreach (var relationship in owner.ExternalRelationships + .Where(r => r.RelationshipType == ImageRelationshipType).ToList()) + { + if (IsReferenced(owner, relationship.Id, linkAttribute) + || IsReferenced(owner, relationship.Id, OfficeRelationships + "id")) continue; + owner.DeleteExternalRelationship(relationship.Id); + removed++; + } + return removed; + } + + /// Rebuild the snapshot's image layer at exact OPC part URIs. The high-level SDK + /// controls relationship ids but allocates a fresh media filename (image2, image3, ...), so + /// undo/redo topology restoration must use the package abstraction for this one operation. + /// The caller reopens the SDK graph immediately afterward. + internal static void RestoreExactImageTopology( + WordprocessingDocument document, + IReadOnlyDictionary owners, + IReadOnlyList<(string PartUri, string ContentType, byte[] Bytes)> imageParts, + IReadOnlyList<(string OwnerPartUri, string RelId, string TargetPartUri)> imageRelationships, + IReadOnlyList<(string OwnerPartUri, string RelId, string TargetUri)> linkedRelationships) + { + foreach (var owner in owners.Values) + { + foreach (var relationship in ImageRelationships(owner).ToList()) + owner.DeletePart(relationship.RelationshipId); + foreach (var relationship in ExternalImageRelationships(owner).ToList()) + owner.DeleteExternalRelationship(relationship.Id); + } + + var package = document.GetPackage(); + foreach (var snapshot in imageParts) + { + var uri = new Uri(snapshot.PartUri, UriKind.RelativeOrAbsolute); + if (package.PartExists(uri) && package.GetPart(uri).ContentType != snapshot.ContentType) + package.DeletePart(uri); + var part = package.PartExists(uri) + ? package.GetPart(uri) + : package.CreatePart(uri, snapshot.ContentType, CompressionOption.Normal); + using var output = part.GetStream(FileMode.Create, FileAccess.Write); + output.Write(snapshot.Bytes, 0, snapshot.Bytes.Length); + } + + foreach (var relationship in imageRelationships) + { + if (!owners.TryGetValue(relationship.OwnerPartUri, out var owner)) continue; + var ownerPackagePart = package.GetPart(owner.Uri); + var targetUri = new Uri(relationship.TargetPartUri, UriKind.RelativeOrAbsolute); + var relativeTarget = PackUriHelper.GetRelativeUri(owner.Uri, targetUri); + ownerPackagePart.CreateRelationship(relativeTarget, TargetMode.Internal, + ImageRelationshipType, relationship.RelId); + } + foreach (var relationship in linkedRelationships) + { + if (!owners.TryGetValue(relationship.OwnerPartUri, out var owner)) continue; + var ownerPackagePart = package.GetPart(owner.Uri); + ownerPackagePart.CreateRelationship( + new Uri(relationship.TargetUri, UriKind.RelativeOrAbsolute), TargetMode.External, + ImageRelationshipType, relationship.RelId); + } + package.Flush(); + } +} diff --git a/Docxodus/Internal/OwnedPartRelationships.cs b/Docxodus/Internal/OwnedPartRelationships.cs index 75f462c0..d94791af 100644 --- a/Docxodus/Internal/OwnedPartRelationships.cs +++ b/Docxodus/Internal/OwnedPartRelationships.cs @@ -16,7 +16,7 @@ namespace Docxodus.Internal; /// 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 static partial class OwnedPartRelationships { internal readonly record struct Owner(OpenXmlPart Part, string Scope) { @@ -36,6 +36,8 @@ internal static IReadOnlyList StoryParts(WordprocessingDocument document) 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")); + if (main.WordprocessingCommentsPart is not null) + result.Add(new Owner(main.WordprocessingCommentsPart, "cmt")); return result; } diff --git a/docs/architecture/docx_agent_server.md b/docs/architecture/docx_agent_server.md index 3d67e4b5..914efd01 100644 --- a/docs/architecture/docx_agent_server.md +++ b/docs/architecture/docx_agent_server.md @@ -85,7 +85,7 @@ session registry assumes single-threaded access. — the requested `protocolVersion` is echoed when present (every implemented method is shape-stable across published revisions; the UI extension negotiates via `capabilities.extensions`) - `notifications/initialized` → no response (notification) -- `tools/list` → `{ tools: [ { name, description, inputSchema, _meta? }, ... ] }` — the 16 tools below +- `tools/list` → `{ tools: [ { name, description, inputSchema, _meta? }, ... ] }` — the 17 tools below - `tools/call` params `{ name, arguments }` → `{ content: [ { type: "text", text: } ], isError }` (plus `structuredContent`/`_meta` on the two preview-related tools — see "Inline preview" below) - `resources/list` / `resources/read` / `resources/templates/list` — serve the `ui://` viewer @@ -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_links / docxodus_track_changes / + ↓ docxodus_list / docxodus_comment / docxodus_annotate / docxodus_links / docxodus_images / docxodus_track_changes / ↓ docxodus_mutations calls) docxodus_save(session_id, path?) ↓ @@ -218,7 +218,7 @@ problem that has no good answer at this layer. ## Tool reference -Three lifecycle tools, thirteen grouped-intent tools. Every grouped tool takes `sessionId` plus an +Three lifecycle tools, four read/preview tools, and eleven grouped-intent tools. Every grouped tool takes `sessionId` plus an `action` string; see `tools/mcp-server/ToolCatalog.cs` for the exact JSON Schema advertised over `tools/list` (this section is the narrative version). @@ -388,6 +388,31 @@ cross-part ranges, active inbound links, unsupported inline boundaries, and trac 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_images` — native Word images + +`capabilities` needs no session and reports the runtime's exact formats, operations, writable +wrap/reference vocabulary, limits, units, and lack of network/file I/O. `list` accepts +`scope: body|headers|footers|footnotes|endnotes|comments|all` and returns one occurrence per DrawingML +`a:blip` or VML `v:imagedata`, including owner part, anchor/span, relationship topology, detected +binary format and dimensions, rendered size, metadata, floating layout, and an explicit +`canMutate`/`unsupportedReason` decision. + +`insert` takes a paragraph `anchorId`, `characterOffset`, `imageBase64`, and optional placement, +size, metadata, and floating-layout options. `replace`, `set_dimensions`, `set_metadata`, +`set_floating_layout`, and `remove` consume the `imageId` returned by insert/list. This JSON tool +accepts bytes only as base64: it never interprets a URL or local path, and malformed base64 is a +typed `invalid_image_data` result. Rendered width/height are points; floating offsets and wrap +distances are exact EMUs. Omitted insert size uses intrinsic pixels at 96 DPI (0.75 point/pixel). + +The writable subset is deliberately strict: embedded canonical DrawingML pictures, inline or +floating with `none`/`square` wrap. PNG, JPEG, GIF, BMP, and TIFF are writable. External linked +images, legacy VML, WebP, multi-picture/non-canonical DrawingML, unsupported wrap geometry, and +malformed or content-type-mismatched media stay enumerable but read-only. The Open XML SDK +version used here has no Word `ImagePartType` for WebP, so advertising WebP insertion would be a +false capability claim. Image mutations are also rejected under `render_inline` tracked mode +because OOXML cannot represent them faithfully as this API's tracked revisions. The full core and +cross-language contract is in `docs/architecture/native_images.md`. + ### `docxodus_track_changes` — list/accept/reject tracked changes, switch recording mode `set_mode` (issue #304) switches how the session records its *own subsequent* edits — @@ -435,7 +460,8 @@ 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`/`docxodus_links` (their `undo`/`redo` and +`docxodus_create`/`docxodus_table`/`docxodus_list`/`docxodus_comment`/`docxodus_links`/ +`docxodus_images` (their `undo`/`redo` and read-only actions — e.g. `get_membership`, comment `list` — are rejected as steps; a batch is a sequence of *mutations*). @@ -489,6 +515,9 @@ monotonic document version; `format: "check_preconditions"` evaluates guards without mutating. Preview evaluates these guards and predicts versions entirely on the shadow, so a dry-run does not make an otherwise-current live plan stale. +Image `insert`/`replace`/`set_dimensions`/`set_metadata`/`set_floating_layout`/`remove` actions +are batchable; image `capabilities` and `list` are rejected as read-only steps. + ### `docxodus_table` — tables `insert`, `insert_row`, `insert_column`, `delete_row`, `delete_column`, `replace_cell_content`, diff --git a/docs/architecture/native_images.md b/docs/architecture/native_images.md new file mode 100644 index 00000000..b7f48f1e --- /dev/null +++ b/docs/architecture/native_images.md @@ -0,0 +1,105 @@ +# Native image session API + +Issue #453 adds an occurrence-oriented image API to `DocxSession`. It edits OOXML picture +markup and package relationships directly; it is not an image decoder, URL downloader, or +filesystem facade. Call `DocxSession.GetImageCapabilities()` (or the equivalent JSON/client +method) when behavior must be selected at runtime. + +## Public contract + +`ListImages(scopes)` walks the body, every header/footer, footnotes, endnotes, and comments through the +shared owning-part seam. It returns one `ImageOccurrence` for every DrawingML `a:blip` and legacy +VML `v:imagedata` it can identify. An occurrence reports: + +- a stable story-scoped id, anchor, and zero-length character span at the picture boundary; +- the owning part and its owner-local embedded or external relationship id/target; +- markup/placement kind, intrinsic pixel dimensions, rendered point dimensions, media filename, + declared content type, signature-detected format, and content-type/signature agreement; +- alt text/title and typed floating layout facts; and +- `canMutate` plus `unsupportedReason`, so inspection never implies write support. + +One non-canonical drawing can contain several blips. Each is returned separately with `:subN` +on the common drawing id. These rows are read-only. A drawing with no identifiable blip is also +reported as unsupported instead of disappearing. IDs are structural occurrence IDs, not media +part IDs: two occurrences may deliberately share one media part. + +The mutation surface is `InsertImage`, `ReplaceImage`, `SetImageDimensions`, +`SetImageMetadata`, `SetImageFloatingLayout`, and `RemoveImage`. Insert targets a paragraph +anchor and exact character boundary. Successful insert returns the new `ImageId`; list-returned +ids feed every later operation unchanged. Mutations are ordinary single undo steps. Valid no-ops +do not create history, and validation failures occur before snapshot creation. + +## Writable subset and formats + +Canonical embedded `w:drawing/wp:inline|wp:anchor/a:graphic/a:graphicData/pic:pic` pictures are +writable when the placement and surrounding inline boundary are safe. Legacy VML, external +linked images, multi-picture/non-canonical DrawingML, malformed structures, unsupported floating +geometry, and content-type/signature mismatches remain enumerable but read-only. Image mutations +under `TrackedChangeMode.RenderInline` return `tracked_operation_unsupported`; the supported +tracked-change vocabulary cannot faithfully represent a native picture edit. + +DrawingML inside `mc:AlternateContent` is also read-only. Both its modern occurrence and any VML +fallback are enumerated, but changing only one compatibility branch would make consumers render +different images. + +PNG, JPEG, GIF, BMP, and TIFF are insertable/replaceable. Input is capped at 64 MiB and rendered +width/height at 100,000 points. The parser validates format signatures and reads dimensions from +headers only; it does not fully decode pixels. Empty, truncated, malformed, unknown, or +content-type-mismatched input is rejected with a typed image error. + +Existing WebP parts can be signature-inspected but are read-only. Open XML SDK 3.5.1 does not +expose a Word `ImagePartType` for WebP, so writable WebP would require inventing a package +capability the selected SDK surface does not provide. `GetImageCapabilities()` makes this +limitation explicit rather than inferring it from a failed insert. + +## Units and floating layout + +Rendered dimensions are points. When neither insert dimension is supplied, intrinsic pixels are +mapped at 96 DPI: one pixel is 0.75 point. Supplying one dimension with `PreserveAspect=true` +derives the other from intrinsic or current rendered aspect ratio. Both DrawingML extent copies +(`wp:extent` and `a:xfrm/a:ext`) are updated together. + +Floating offsets, wrap distances, and relative positions are exact English Metric Units (EMUs), +not points. The writable layout subset supports `none` and `square` wrap, typed page/margin/ +column/character horizontal references, page/margin/paragraph/line vertical references, +offset-or-alignment positioning, relative height, behind-document/lock/layout-in-cell/overlap +flags, and wrap side. Tight/through/top-and-bottom wrap, relative sizing, `simplePos`, duplicate or +mixed align/offset positions, malformed booleans/numerics, and unknown reference/alignment tokens +are reported with raw OOXML tokens and make the occurrence read-only. Position or wrap elements +with any unmodeled attributes or children are likewise preserved for inspection and rejected for +mutation rather than being silently replaced by the smaller modeled shape. + +## Package topology, cleanup, and history + +An image relationship belongs to the story part containing its markup. Inserts first reuse +identical content within that owner, then attach an identical package media part already used by +another story owner, and create a media part only when necessary. Equality includes both content +type and bytes. Drawing property ids are allocated document-wide, including headers and footers. + +After image removal and generic destructive operations, owner-local image relationships are swept +only when no DrawingML `r:embed`/`r:link` or VML `r:id` still references them. Shared media remains +until its final owning relationship is gone. Raw XML replacement performs cleanup only after the +replacement has validated successfully. `Save` repeats this safe sweep over every story owner as +the centralized package-normalization boundary, covering transforms that do not use an image API; +normalization does not create an undo entry. + +Undo snapshots include image bytes/content types, exact media part URIs, every owner-local +embedded relationship id/target, and external `r:link` ids/targets. Restore rebuilds that layer at +the OPC package level and reopens the SDK graph, preserving topology across save/reopen, +undo/redo, shared owners, format replacement, and external links. Snapshot memory accounting +includes the captured media bytes. + +## Transport surfaces + +- .NET accepts `byte[]` and typed records directly. +- WASM/npm accepts `Uint8Array`; npm performs chunked base64 encoding at the JS/WASM boundary. +- Python accepts `bytes`; the stdio client encodes them as base64. +- JSON ops and MCP use an explicit `imageBase64` string. They do not fetch URLs or interpret file + paths. MCP exposes the grouped `docxodus_images` tool. + +MCP image mutators can also be used as `docxodus_mutations` steps; `capabilities` and `list` are +read-only and rejected there. Preview mode applies the same image operation and then restores its +snapshot, including the media-part and relationship layer. + +The JSON shape is manually serialized/parsing-safe for trimming and uses snake-case enum tokens. +All clients expose the same versioned capabilities record and typed occurrence/layout models. diff --git a/npm/README.md b/npm/README.md index 8f6efd3c..9a9cdfb3 100644 --- a/npm/README.md +++ b/npm/README.md @@ -137,6 +137,23 @@ External links own their relationship in the actual body/header/footer/footnote/ internal links are relationship-free bookmark targets. Bookmark rename retargets inbound links atomically, and unsafe removal or cross-part ranges return typed `EditResult` errors. +Native images are occurrence-addressed too, with bytes kept explicit at the API boundary: + +```ts +const capabilities = session.getImageCapabilities(); +const inserted = session.insertImage(paragraph, 0, pngBytes, { + widthPoints: 144, + altText: 'Revenue by quarter', +}); +const image = session.listImages().find(value => value.id === inserted.imageId)!; +session.setImageDimensions(image.id, { widthPoints: 108 }); +``` + +PNG/JPEG/GIF/BMP/TIFF are writable. Existing WebP, external links, legacy VML, and unsupported +DrawingML remain enumerable with `canMutate: false`. Dimensions use points; floating offsets use +exact EMUs, and omitted insert dimensions use 96 DPI. The browser API accepts `Uint8Array` and +does not fetch image URLs or read paths. + --- ## Everything else diff --git a/npm/src/index.ts b/npm/src/index.ts index 03b110c0..491b872d 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -81,6 +81,21 @@ export type { FormatOp, HyperlinkInfo, HyperlinkKind, + ImageBinaryFormat, + ImageCapabilities, + ImageDimensions, + ImageFormatCapability, + ImageHorizontalAlignment, + ImageHorizontalReference, + ImageInsertOptions, + ImageMarkupKind, + ImageOccurrence, + ImagePlacement, + ImageVerticalAlignment, + ImageVerticalReference, + ImageWrapMode, + ImageWrapSide, + FloatingImageLayout, LineSpacingRule, ListMembership, FormattingInspection, diff --git a/npm/src/session.ts b/npm/src/session.ts index fdd33302..addd6e60 100644 --- a/npm/src/session.ts +++ b/npm/src/session.ts @@ -28,6 +28,11 @@ import type { HeaderFooterKind, HyperlinkInfo, HyperlinkKind, + ImageCapabilities, + ImageDimensions, + ImageInsertOptions, + ImageOccurrence, + FloatingImageLayout, InlineSpan, NumberFormat, PageNumberField, @@ -991,6 +996,45 @@ export class DocxSession { return JSON.parse(this.wasm.RemoveHyperlink(this.handle, hyperlinkId)) as EditResult; } + /** Versioned operational facts for native image inspection/mutation in this runtime. */ + getImageCapabilities(): ImageCapabilities { + return JSON.parse(this.wasm.GetImageCapabilities()) as ImageCapabilities; + } + + listImages(scopes: ProjectionScopes = ProjectionScopes.All): ImageOccurrence[] { + return JSON.parse(this.wasm.ListImages(this.handle, scopes)) as ImageOccurrence[]; + } + + insertImage(anchorId: string, characterOffset: number, bytes: Uint8Array, + options: ImageInsertOptions = {}): EditResult { + return JSON.parse(this.wasm.InsertImage(this.handle, anchorId, characterOffset, + imageBytesToBase64(bytes), JSON.stringify(options))) as EditResult; + } + + replaceImage(imageId: string, bytes: Uint8Array): EditResult { + return JSON.parse(this.wasm.ReplaceImage( + this.handle, imageId, imageBytesToBase64(bytes))) as EditResult; + } + + setImageDimensions(imageId: string, dimensions: ImageDimensions): EditResult { + return JSON.parse(this.wasm.SetImageDimensions( + this.handle, imageId, JSON.stringify(dimensions))) as EditResult; + } + + setImageMetadata(imageId: string, altText: string | null, title: string | null): EditResult { + return JSON.parse(this.wasm.SetImageMetadata( + this.handle, imageId, altText, title)) as EditResult; + } + + setImageFloatingLayout(imageId: string, layout: FloatingImageLayout): EditResult { + return JSON.parse(this.wasm.SetImageFloatingLayout( + this.handle, imageId, JSON.stringify(layout))) as EditResult; + } + + removeImage(imageId: string): EditResult { + return JSON.parse(this.wasm.RemoveImage(this.handle, imageId)) as EditResult; + } + listBookmarks(scopes: ProjectionScopes = ProjectionScopes.All): BookmarkInfo[] { return JSON.parse(this.wasm.ListBookmarks(this.handle, scopes)) as BookmarkInfo[]; } @@ -1701,6 +1745,14 @@ export class DocxSession { } } +function imageBytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return globalThis.btoa(binary); +} + /** * Opens a new {@link DocxSession} over the supplied DOCX bytes. * The returned session holds its document in WASM memory until you call diff --git a/npm/src/types.ts b/npm/src/types.ts index 8595d51c..bf046727 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1198,6 +1198,14 @@ export interface DocxodusWasmExports { 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; + GetImageCapabilities: () => string; + ListImages: (handle: number, scopes: number) => string; + InsertImage: (handle: number, anchor: string, characterOffset: number, imageBase64: string, optionsJson: string) => string; + ReplaceImage: (handle: number, imageId: string, imageBase64: string) => string; + SetImageDimensions: (handle: number, imageId: string, dimensionsJson: string) => string; + SetImageMetadata: (handle: number, imageId: string, altText: string | null, title: string | null) => string; + SetImageFloatingLayout: (handle: number, imageId: string, layoutJson: string) => string; + RemoveImage: (handle: number, imageId: string) => string; 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; @@ -1485,6 +1493,7 @@ export interface EditResult { annotationId?: string; hyperlinkId?: string; bookmarkName?: string; + imageId?: string; } export type HyperlinkKind = "external" | "internal"; @@ -1503,6 +1512,84 @@ export interface HyperlinkInfo { isBroken: boolean; } +export type ImageBinaryFormat = "unknown" | "png" | "jpeg" | "gif" | "bmp" | "tiff" | "webp"; +export type ImageMarkupKind = "modern_drawing" | "legacy_vml" | "unsupported_drawing"; +export type ImagePlacement = "inline" | "floating"; +export type ImageWrapMode = "none" | "square" | "tight" | "through" | "top_and_bottom" | "unknown"; +export type ImageWrapSide = "both_sides" | "left" | "right" | "largest" | "unknown"; +export type ImageHorizontalReference = "page" | "margin" | "column" | "character" | "unknown"; +export type ImageVerticalReference = "page" | "margin" | "paragraph" | "line" | "unknown"; +export type ImageHorizontalAlignment = "left" | "center" | "right" | "inside" | "outside" | "unknown"; +export type ImageVerticalAlignment = "top" | "center" | "bottom" | "inside" | "outside" | "unknown"; + +export interface FloatingImageLayout { + horizontalRelativeFrom?: ImageHorizontalReference; + horizontalOffsetEmu?: number | null; + horizontalAlignment?: ImageHorizontalAlignment | null; + verticalRelativeFrom?: ImageVerticalReference; + verticalOffsetEmu?: number | null; + verticalAlignment?: ImageVerticalAlignment | null; + wrapMode?: ImageWrapMode; + wrapSide?: ImageWrapSide; + distanceTopEmu?: number; + distanceBottomEmu?: number; + distanceLeftEmu?: number; + distanceRightEmu?: number; + relativeHeight?: number; + behindDocument?: boolean; + locked?: boolean; + layoutInCell?: boolean; + allowOverlap?: boolean; + rawHorizontalReference?: string; + rawVerticalReference?: string; + rawHorizontalPosition?: string; + rawVerticalPosition?: string; + rawWrapMode?: string; + rawWrapSide?: string; + rawRelativeSizeHorizontal?: string; + rawRelativeSizeVertical?: string; + rawFlagTokens?: Record; +} + +export interface ImageInsertOptions { + placement?: ImagePlacement; + widthPoints?: number; + heightPoints?: number; + preserveAspect?: boolean; + altText?: string | null; + title?: string | null; + floatingLayout?: FloatingImageLayout; +} + +export interface ImageDimensions { + widthPoints?: number; + heightPoints?: number; + preserveAspect?: boolean; +} + +export interface ImageOccurrence { + id: string; markupKind: ImageMarkupKind; placement?: ImagePlacement; canMutate: boolean; + unsupportedReason?: string; owningPartUri: string; scope: string; anchorId: string; span: CharSpan; + relationshipId?: string; targetPartUri?: string; linkedRelationshipId?: string; linkedTarget?: string; + isEmbedded: boolean; isLinked: boolean; isBroken: boolean; mediaFileName?: string; contentType?: string; + format: ImageBinaryFormat; contentTypeMatchesBytes?: boolean; intrinsicWidthPixels?: number; + intrinsicHeightPixels?: number; renderedWidthPoints?: number; renderedHeightPoints?: number; + altText?: string; title?: string; floatingLayout?: FloatingImageLayout; floatingLayoutSupported: boolean; +} + +export interface ImageFormatCapability { + format: ImageBinaryFormat; contentType: string; canInspect: boolean; canInsert: boolean; + canReplace: boolean; limitation?: string; +} + +export interface ImageCapabilities { + schemaVersion: number; runtime: string; formats: ImageFormatCapability[]; operations: string[]; + mutableWrapModes: ImageWrapMode[]; horizontalReferences: ImageHorizontalReference[]; + verticalReferences: ImageVerticalReference[]; maxInputBytes: number; maxRenderedPoints: number; + defaultDpi: number; usesHeaderParsingOnly: boolean; acceptsBinaryBytes: boolean; + supportsNetworkFetch: boolean; supportsFileIo: boolean; +} + export interface DocumentRange { startAnchorId: string; startOffset: number; diff --git a/python/README.md b/python/README.md index 7894c33b..de596571 100644 --- a/python/README.md +++ b/python/README.md @@ -149,6 +149,7 @@ The `DocxSession` class exposes every op in `Docxodus.Internal.DocxSessionOps` a | **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` | +| **Native images** | `get_image_capabilities`, `list_images`, `insert_image`, `replace_image`, `set_image_dimensions`, `set_image_metadata`, `set_image_floating_layout`, `remove_image` | | **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 de4a8135..0dfbbf70 100644 --- a/python/src/docx_scalpel/__init__.py +++ b/python/src/docx_scalpel/__init__.py @@ -112,6 +112,21 @@ HeaderFooterRef, HtmlOptions, HyperlinkInfo, + FloatingImageLayout, + ImageBinaryFormat, + ImageCapabilities, + ImageDimensions, + ImageFormatCapability, + ImageHorizontalAlignment, + ImageHorizontalReference, + ImageInsertOptions, + ImageMarkupKind, + ImageOccurrence, + ImagePlacement, + ImageVerticalAlignment, + ImageVerticalReference, + ImageWrapMode, + ImageWrapSide, InlineSpan, ListMembership, MarkdownPatch, @@ -198,6 +213,21 @@ "CharSpan", "DocumentRange", "HyperlinkInfo", + "FloatingImageLayout", + "ImageBinaryFormat", + "ImageCapabilities", + "ImageDimensions", + "ImageFormatCapability", + "ImageHorizontalAlignment", + "ImageHorizontalReference", + "ImageInsertOptions", + "ImageMarkupKind", + "ImageOccurrence", + "ImagePlacement", + "ImageVerticalAlignment", + "ImageVerticalReference", + "ImageWrapMode", + "ImageWrapSide", "BookmarkRangeSegment", "BookmarkInfo", "CommentListEntry", diff --git a/python/src/docx_scalpel/session.py b/python/src/docx_scalpel/session.py index 78c97a63..31421ab1 100644 --- a/python/src/docx_scalpel/session.py +++ b/python/src/docx_scalpel/session.py @@ -72,6 +72,11 @@ FormattingInspection, HtmlOptions, HyperlinkInfo, + FloatingImageLayout, + ImageCapabilities, + ImageDimensions, + ImageInsertOptions, + ImageOccurrence, InlineSpan, ListMembership, MarkdownProjection, @@ -882,6 +887,47 @@ def remove_hyperlink(self, hyperlink_id: str) -> EditResult: return EditResult._from_wire( self._call("remove_hyperlink", {"hyperlinkId": hyperlink_id})) + def get_image_capabilities(self) -> ImageCapabilities: + """Return versioned runtime facts; this does not claim decoding, file, or network support.""" + return ImageCapabilities._from_wire(self._call("get_image_capabilities", {})) + + def list_images(self, scopes: ProjectionScopes = ProjectionScopes.ALL) -> tuple[ImageOccurrence, ...]: + result = self._call("list_images", {"scopes": int(scopes)}) + return tuple(ImageOccurrence._from_wire(item) for item in result) + + def insert_image(self, anchor_id: str, character_offset: int, image_bytes: bytes, + options: ImageInsertOptions | None = None) -> EditResult: + return EditResult._from_wire(self._call("insert_image", { + "anchorId": anchor_id, "characterOffset": character_offset, + "imageBase64": base64.b64encode(image_bytes).decode("ascii"), + "options": (options or ImageInsertOptions()).to_wire(), + })) + + def replace_image(self, image_id: str, image_bytes: bytes) -> EditResult: + return EditResult._from_wire(self._call("replace_image", { + "imageId": image_id, "imageBase64": base64.b64encode(image_bytes).decode("ascii"), + })) + + def set_image_dimensions(self, image_id: str, dimensions: ImageDimensions) -> EditResult: + return EditResult._from_wire(self._call("set_image_dimensions", { + "imageId": image_id, "dimensions": dimensions.to_wire(), + })) + + def set_image_metadata(self, image_id: str, alt_text: str | None, + title: str | None) -> EditResult: + return EditResult._from_wire(self._call("set_image_metadata", { + "imageId": image_id, "altText": alt_text, "title": title, + })) + + def set_image_floating_layout(self, image_id: str, + layout: FloatingImageLayout) -> EditResult: + return EditResult._from_wire(self._call("set_image_floating_layout", { + "imageId": image_id, "layout": layout.to_wire(), + })) + + def remove_image(self, image_id: str) -> EditResult: + return EditResult._from_wire(self._call("remove_image", {"imageId": image_id})) + def list_bookmarks(self, scopes: ProjectionScopes = ProjectionScopes.ALL) -> tuple[BookmarkInfo, ...]: result = self._call("list_bookmarks", {"scopes": int(scopes)}) return tuple(BookmarkInfo._from_wire(item) for item in result) diff --git a/python/src/docx_scalpel/types.py b/python/src/docx_scalpel/types.py index ec4d5b19..34a886a3 100644 --- a/python/src/docx_scalpel/types.py +++ b/python/src/docx_scalpel/types.py @@ -100,6 +100,21 @@ "AnnotationUpdate", "DocumentRange", "HyperlinkInfo", + "ImageBinaryFormat", + "ImageMarkupKind", + "ImagePlacement", + "ImageWrapMode", + "ImageWrapSide", + "ImageHorizontalReference", + "ImageVerticalReference", + "ImageHorizontalAlignment", + "ImageVerticalAlignment", + "FloatingImageLayout", + "ImageInsertOptions", + "ImageDimensions", + "ImageFormatCapability", + "ImageOccurrence", + "ImageCapabilities", "BookmarkRangeSegment", "BookmarkInfo", "EditSummary", @@ -678,6 +693,317 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "HyperlinkInfo": bool(d.get("isBroken", False))) +class ImageBinaryFormat(str, Enum): + UNKNOWN = "unknown" + PNG = "png" + JPEG = "jpeg" + GIF = "gif" + BMP = "bmp" + TIFF = "tiff" + WEBP = "webp" + + +class ImageMarkupKind(str, Enum): + MODERN_DRAWING = "modern_drawing" + LEGACY_VML = "legacy_vml" + UNSUPPORTED_DRAWING = "unsupported_drawing" + + +class ImagePlacement(str, Enum): + INLINE = "inline" + FLOATING = "floating" + + +class ImageWrapMode(str, Enum): + NONE = "none" + SQUARE = "square" + TIGHT = "tight" + THROUGH = "through" + TOP_AND_BOTTOM = "top_and_bottom" + UNKNOWN = "unknown" + + +class ImageWrapSide(str, Enum): + BOTH_SIDES = "both_sides" + LEFT = "left" + RIGHT = "right" + LARGEST = "largest" + UNKNOWN = "unknown" + + +class ImageHorizontalReference(str, Enum): + PAGE = "page" + MARGIN = "margin" + COLUMN = "column" + CHARACTER = "character" + UNKNOWN = "unknown" + + +class ImageVerticalReference(str, Enum): + PAGE = "page" + MARGIN = "margin" + PARAGRAPH = "paragraph" + LINE = "line" + UNKNOWN = "unknown" + + +class ImageHorizontalAlignment(str, Enum): + LEFT = "left" + CENTER = "center" + RIGHT = "right" + INSIDE = "inside" + OUTSIDE = "outside" + UNKNOWN = "unknown" + + +class ImageVerticalAlignment(str, Enum): + TOP = "top" + CENTER = "center" + BOTTOM = "bottom" + INSIDE = "inside" + OUTSIDE = "outside" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, slots=True) +class FloatingImageLayout: + horizontal_relative_from: ImageHorizontalReference = ImageHorizontalReference.COLUMN + horizontal_offset_emu: int | None = 0 + horizontal_alignment: ImageHorizontalAlignment | None = None + vertical_relative_from: ImageVerticalReference = ImageVerticalReference.PARAGRAPH + vertical_offset_emu: int | None = 0 + vertical_alignment: ImageVerticalAlignment | None = None + wrap_mode: ImageWrapMode = ImageWrapMode.SQUARE + wrap_side: ImageWrapSide = ImageWrapSide.BOTH_SIDES + distance_top_emu: int = 0 + distance_bottom_emu: int = 0 + distance_left_emu: int = 0 + distance_right_emu: int = 0 + relative_height: int = 251658240 + behind_document: bool = False + locked: bool = False + layout_in_cell: bool = True + allow_overlap: bool = True + raw_horizontal_reference: str | None = None + raw_vertical_reference: str | None = None + raw_horizontal_position: str | None = None + raw_vertical_position: str | None = None + raw_wrap_mode: str | None = None + raw_wrap_side: str | None = None + raw_relative_size_horizontal: str | None = None + raw_relative_size_vertical: str | None = None + raw_flag_tokens: Mapping[str, str] | None = None + + def to_wire(self) -> dict[str, Any]: + return {"horizontalRelativeFrom": self.horizontal_relative_from.value, + "horizontalOffsetEmu": self.horizontal_offset_emu, + "horizontalAlignment": (self.horizontal_alignment.value + if self.horizontal_alignment is not None else None), + "verticalRelativeFrom": self.vertical_relative_from.value, + "verticalOffsetEmu": self.vertical_offset_emu, + "verticalAlignment": (self.vertical_alignment.value + if self.vertical_alignment is not None else None), + "wrapMode": self.wrap_mode.value, "wrapSide": self.wrap_side.value, + "distanceTopEmu": self.distance_top_emu, "distanceBottomEmu": self.distance_bottom_emu, + "distanceLeftEmu": self.distance_left_emu, "distanceRightEmu": self.distance_right_emu, + "relativeHeight": self.relative_height, "behindDocument": self.behind_document, + "locked": self.locked, "layoutInCell": self.layout_in_cell, + "allowOverlap": self.allow_overlap} + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "FloatingImageLayout": + horizontal_alignment = d.get("horizontalAlignment") + vertical_alignment = d.get("verticalAlignment") + return cls( + horizontal_relative_from=ImageHorizontalReference( + d.get("horizontalRelativeFrom", "unknown")), + horizontal_offset_emu=d.get("horizontalOffsetEmu"), + horizontal_alignment=(ImageHorizontalAlignment(horizontal_alignment) + if horizontal_alignment is not None else None), + vertical_relative_from=ImageVerticalReference( + d.get("verticalRelativeFrom", "unknown")), + vertical_offset_emu=d.get("verticalOffsetEmu"), + vertical_alignment=(ImageVerticalAlignment(vertical_alignment) + if vertical_alignment is not None else None), + wrap_mode=ImageWrapMode(d.get("wrapMode", "unknown")), + wrap_side=ImageWrapSide(d.get("wrapSide", "unknown")), + distance_top_emu=int(d.get("distanceTopEmu", 0)), + distance_bottom_emu=int(d.get("distanceBottomEmu", 0)), + distance_left_emu=int(d.get("distanceLeftEmu", 0)), + distance_right_emu=int(d.get("distanceRightEmu", 0)), + relative_height=int(d.get("relativeHeight", 0)), + behind_document=bool(d.get("behindDocument", False)), + locked=bool(d.get("locked", False)), + layout_in_cell=bool(d.get("layoutInCell", True)), + allow_overlap=bool(d.get("allowOverlap", True)), + raw_horizontal_reference=d.get("rawHorizontalReference"), + raw_vertical_reference=d.get("rawVerticalReference"), + raw_horizontal_position=d.get("rawHorizontalPosition"), + raw_vertical_position=d.get("rawVerticalPosition"), + raw_wrap_mode=d.get("rawWrapMode"), + raw_wrap_side=d.get("rawWrapSide"), + raw_relative_size_horizontal=d.get("rawRelativeSizeHorizontal"), + raw_relative_size_vertical=d.get("rawRelativeSizeVertical"), + raw_flag_tokens=d.get("rawFlagTokens"), + ) + + +@dataclass(frozen=True, slots=True) +class ImageInsertOptions: + placement: ImagePlacement = ImagePlacement.INLINE + width_points: float | None = None + height_points: float | None = None + preserve_aspect: bool = True + alt_text: str | None = None + title: str | None = None + floating_layout: FloatingImageLayout | None = None + + def to_wire(self) -> dict[str, Any]: + result: dict[str, Any] = {"placement": self.placement.value, "preserveAspect": self.preserve_aspect} + if self.width_points is not None: result["widthPoints"] = self.width_points + if self.height_points is not None: result["heightPoints"] = self.height_points + if self.alt_text is not None: result["altText"] = self.alt_text + if self.title is not None: result["title"] = self.title + if self.floating_layout is not None: result["floatingLayout"] = self.floating_layout.to_wire() + return result + + +@dataclass(frozen=True, slots=True) +class ImageDimensions: + width_points: float | None = None + height_points: float | None = None + preserve_aspect: bool = True + + def to_wire(self) -> dict[str, Any]: + result: dict[str, Any] = {"preserveAspect": self.preserve_aspect} + if self.width_points is not None: result["widthPoints"] = self.width_points + if self.height_points is not None: result["heightPoints"] = self.height_points + return result + + +@dataclass(frozen=True, slots=True) +class ImageOccurrence: + id: str + markup_kind: ImageMarkupKind + placement: ImagePlacement | None + can_mutate: bool + unsupported_reason: str | None + owning_part_uri: str + scope: str + anchor_id: str + span: CharSpan + relationship_id: str | None + target_part_uri: str | None + linked_relationship_id: str | None + linked_target: str | None + is_embedded: bool + is_linked: bool + is_broken: bool + media_file_name: str | None + content_type: str | None + format: ImageBinaryFormat + content_type_matches_bytes: bool | None + intrinsic_width_pixels: int | None + intrinsic_height_pixels: int | None + rendered_width_points: float | None + rendered_height_points: float | None + alt_text: str | None + title: str | None + floating_layout: FloatingImageLayout | None + floating_layout_supported: bool + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "ImageOccurrence": + return cls( + id=d["id"], + markup_kind=ImageMarkupKind(d["markupKind"]), + placement=(ImagePlacement(d["placement"]) if d.get("placement") else None), + can_mutate=bool(d["canMutate"]), + unsupported_reason=d.get("unsupportedReason"), + owning_part_uri=d["owningPartUri"], + scope=d["scope"], + anchor_id=d["anchorId"], + span=CharSpan._from_wire(d["span"]), + relationship_id=d.get("relationshipId"), + target_part_uri=d.get("targetPartUri"), + linked_relationship_id=d.get("linkedRelationshipId"), + linked_target=d.get("linkedTarget"), + is_embedded=bool(d.get("isEmbedded", False)), + is_linked=bool(d.get("isLinked", False)), + is_broken=bool(d.get("isBroken", False)), + media_file_name=d.get("mediaFileName"), + content_type=d.get("contentType"), + format=ImageBinaryFormat(d.get("format", "unknown")), + content_type_matches_bytes=d.get("contentTypeMatchesBytes"), + intrinsic_width_pixels=d.get("intrinsicWidthPixels"), + intrinsic_height_pixels=d.get("intrinsicHeightPixels"), + rendered_width_points=d.get("renderedWidthPoints"), + rendered_height_points=d.get("renderedHeightPoints"), + alt_text=d.get("altText"), + title=d.get("title"), + floating_layout=(FloatingImageLayout._from_wire(d["floatingLayout"]) + if "floatingLayout" in d else None), + floating_layout_supported=bool(d.get("floatingLayoutSupported", False)), + ) + + +@dataclass(frozen=True, slots=True) +class ImageFormatCapability: + format: ImageBinaryFormat + content_type: str + can_inspect: bool + can_insert: bool + can_replace: bool + limitation: str | None = None + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "ImageFormatCapability": + return cls(ImageBinaryFormat(d["format"]), d["contentType"], + bool(d["canInspect"]), bool(d["canInsert"]), + bool(d["canReplace"]), d.get("limitation")) + + +@dataclass(frozen=True, slots=True) +class ImageCapabilities: + schema_version: int + runtime: str + formats: tuple[ImageFormatCapability, ...] + operations: tuple[str, ...] + mutable_wrap_modes: tuple[ImageWrapMode, ...] + horizontal_references: tuple[ImageHorizontalReference, ...] + vertical_references: tuple[ImageVerticalReference, ...] + max_input_bytes: int + max_rendered_points: float + default_dpi: float + uses_header_parsing_only: bool + accepts_binary_bytes: bool + supports_network_fetch: bool + supports_file_io: bool + + @classmethod + def _from_wire(cls, d: Mapping[str, Any]) -> "ImageCapabilities": + return cls( + schema_version=int(d["schemaVersion"]), + runtime=d["runtime"], + formats=tuple(ImageFormatCapability._from_wire(value) + for value in d.get("formats", ())), + operations=tuple(d.get("operations", ())), + mutable_wrap_modes=tuple(ImageWrapMode(value) + for value in d.get("mutableWrapModes", ())), + horizontal_references=tuple(ImageHorizontalReference(value) + for value in d.get("horizontalReferences", ())), + vertical_references=tuple(ImageVerticalReference(value) + for value in d.get("verticalReferences", ())), + max_input_bytes=int(d["maxInputBytes"]), + max_rendered_points=float(d["maxRenderedPoints"]), + default_dpi=float(d["defaultDpi"]), + uses_header_parsing_only=bool(d["usesHeaderParsingOnly"]), + accepts_binary_bytes=bool(d["acceptsBinaryBytes"]), + supports_network_fetch=bool(d["supportsNetworkFetch"]), + supports_file_io=bool(d["supportsFileIo"]), + ) + + @dataclass(frozen=True, slots=True) class BookmarkRangeSegment: owning_part_uri: str @@ -1409,6 +1735,7 @@ class EditResult: table_anchors: TableAnchorMapping | None = None hyperlink_id: str | None = None bookmark_name: str | None = None + image_id: str | None = None @classmethod def _from_wire(cls, d: Mapping[str, Any]) -> "EditResult": @@ -1426,6 +1753,7 @@ def _from_wire(cls, d: Mapping[str, Any]) -> "EditResult": if d.get("tableAnchors") else None, hyperlink_id=d.get("hyperlinkId"), bookmark_name=d.get("bookmarkName"), + image_id=d.get("imageId"), ) diff --git a/python/tests/test_images.py b/python/tests/test_images.py new file mode 100644 index 00000000..87178f8d --- /dev/null +++ b/python/tests/test_images.py @@ -0,0 +1,79 @@ +"""Native image CRUD and typed capability projection through the stdio host.""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +from docx_scalpel import ( + DocxSession, + ImageBinaryFormat, + ImageDimensions, + ImageInsertOptions, + ImageMarkupKind, + ProjectionScopes, + open_session, +) + + +@pytest.fixture +def session(tour_plan_bytes: bytes) -> Iterator[DocxSession]: + value = open_session(tour_plan_bytes) + try: + yield value + finally: + value.close() + + +def _first_body_paragraph(session: DocxSession) -> str: + return next( + anchor.id + for anchor in session.project().anchor_index.values() + if anchor.scope == "body" and anchor.kind in ("p", "h", "li") + ) + + +def _png_header(width: int, height: int) -> bytes: + return ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + ) + + +def test_capabilities_and_image_crud_are_typed(session: DocxSession) -> None: + capabilities = session.get_image_capabilities() + assert capabilities.default_dpi == 96 + assert capabilities.supports_network_fetch is False + assert ImageBinaryFormat.PNG in {entry.format for entry in capabilities.formats} + + made = session.insert_image( + _first_body_paragraph(session), + 0, + _png_header(2, 3), + ImageInsertOptions(width_points=72, alt_text="diagram"), + ) + assert made.success, made.error + image = session.list_images(ProjectionScopes.BODY)[0] + assert image.id == made.image_id + assert image.markup_kind is ImageMarkupKind.MODERN_DRAWING + assert image.format is ImageBinaryFormat.PNG + assert image.intrinsic_width_pixels == 2 + assert image.intrinsic_height_pixels == 3 + assert image.rendered_width_points == 72 + assert image.rendered_height_points == 108 + + resized = session.set_image_dimensions(image.id, ImageDimensions(width_points=36)) + assert resized.success, resized.error + assert session.list_images()[0].rendered_height_points == 54 + assert session.set_image_metadata(image.id, "updated", None).success + + reopened = open_session(session.save()) + try: + persisted = reopened.list_images()[0] + assert persisted.alt_text == "updated" + assert reopened.remove_image(persisted.id).success + assert reopened.list_images() == () + finally: + reopened.close() diff --git a/tools/mcp-server/Dispatcher.cs b/tools/mcp-server/Dispatcher.cs index c769014b..396e19d8 100644 --- a/tools/mcp-server/Dispatcher.cs +++ b/tools/mcp-server/Dispatcher.cs @@ -41,6 +41,7 @@ internal static class Dispatcher "docxodus_list" => ListTool(store, args), "docxodus_comment" => Comment(store, args), "docxodus_links" => Links(store, args), + "docxodus_images" => Images(store, args), "docxodus_annotate" => Annotate(store, args), "docxodus_track_changes" => TrackChanges(store, args), "docxodus_mutations" => Mutations(store, args), @@ -575,12 +576,55 @@ private static string BookmarkRangeAction(DocSession session, JsonElement args, "footers" => ProjectionScopes.Footers, "footnotes" => ProjectionScopes.Footnotes, "endnotes" => ProjectionScopes.Endnotes, + "comments" => ProjectionScopes.Comments, _ => throw new McpToolException($"unknown link scope: {scope}"), }; private static bool IsMutatingLinksAction(string action) => action is not ("list_hyperlinks" or "list_bookmarks"); + // ─── Native images (issue #453) ─────────────────────────────────── + + private static string Images(SessionStore store, JsonElement args) + { + var action = Str(args, "action"); + if (action == "capabilities") + return $"{{\"capabilities\":{DocxSessionOps.GetImageCapabilities()}}}"; + var session = Session(store, args); + return RunImagesAction(session, action, args); + } + + private static string RunImagesAction(DocSession session, string action, JsonElement args) => + action switch + { + "list" => $"{{\"images\":{DocxSessionOps.ListImages(session.Handle, + ParseLinkScopes(OptStr(args, "scope")))}}}", + "insert" => DocxSessionOps.InsertImage(session.Handle, Str(args, "anchorId"), + Int(args, "characterOffset"), Str(args, "imageBase64"), RawObjectOrEmpty(args, "options")), + "replace" => DocxSessionOps.ReplaceImage(session.Handle, + Str(args, "imageId"), Str(args, "imageBase64")), + "set_dimensions" => DocxSessionOps.SetImageDimensions(session.Handle, + Str(args, "imageId"), RawObject(args, "dimensions")), + "set_metadata" => SetImageMetadata(session, args), + "set_floating_layout" => DocxSessionOps.SetImageFloatingLayout(session.Handle, + Str(args, "imageId"), RawObject(args, "layout")), + "remove" => DocxSessionOps.RemoveImage(session.Handle, Str(args, "imageId")), + _ => throw new McpToolException($"unknown docxodus_images action: {action}"), + }; + + private static bool IsMutatingImagesAction(string action) => + action is not ("capabilities" or "list"); + + private static string SetImageMetadata(DocSession session, JsonElement args) + { + if (!args.TryGetProperty("altText", out var alt) || alt.ValueKind is not (JsonValueKind.String or JsonValueKind.Null) + || !args.TryGetProperty("title", out var title) || title.ValueKind is not (JsonValueKind.String or JsonValueKind.Null)) + throw new McpToolException("docxodus_images set_metadata requires altText and title as string or null"); + return DocxSessionOps.SetImageMetadata(session.Handle, Str(args, "imageId"), + alt.ValueKind == JsonValueKind.Null ? null : alt.GetString(), + title.ValueKind == JsonValueKind.Null ? null : title.GetString()); + } + private static string AddComment(DocSession session, JsonElement args) { var anchorId = OptStr(args, "anchorId"); @@ -835,6 +879,7 @@ private static IReadOnlyList BuildMutationBatchSteps( "docxodus_list" => RunListAction(session, action, mutationArgs), "docxodus_comment" => RunCommentAction(session, action, mutationArgs), "docxodus_links" => RunLinksAction(session, action, mutationArgs), + "docxodus_images" => RunImagesAction(session, action, mutationArgs), _ => throw new McpToolException($"docxodus_mutations does not accept \"{stepTool}\" as a step"), }, () => ValidateMutationBatchStep(session, stepTool, action, stepArgs))); @@ -865,6 +910,8 @@ private static IReadOnlyList BuildMutationBatchSteps( "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", + "docxodus_images" => action is "insert" or "replace" or "set_dimensions" + or "set_metadata" or "set_floating_layout" or "remove", _ => false, }; return known ? null : new EditError( @@ -1133,9 +1180,41 @@ private static void ValidateMutationBatchArguments(string tool, string action, J case ("docxodus_links", "remove_bookmark"): RequireStrings(args, "name"); break; + + case ("docxodus_images", "insert"): + RequireStrings(args, "anchorId", "imageBase64"); + RequireNumbers(args, "characterOffset"); + _ = RawObjectOrEmpty(args, "options"); + break; + case ("docxodus_images", "replace"): + RequireStrings(args, "imageId", "imageBase64"); + break; + case ("docxodus_images", "set_dimensions"): + RequireStrings(args, "imageId"); + _ = RawObject(args, "dimensions"); + break; + case ("docxodus_images", "set_metadata"): + RequireStrings(args, "imageId"); + ValidateNullableString(args, "altText"); + ValidateNullableString(args, "title"); + break; + case ("docxodus_images", "set_floating_layout"): + RequireStrings(args, "imageId"); + _ = RawObject(args, "layout"); + break; + case ("docxodus_images", "remove"): + RequireStrings(args, "imageId"); + break; } } + private static void ValidateNullableString(JsonElement args, string name) + { + if (!args.TryGetProperty(name, out var value) + || value.ValueKind is not (JsonValueKind.String or JsonValueKind.Null)) + throw new McpToolException($"argument \"{name}\" must be a string or null"); + } + private static void ValidateCommentAddArguments(JsonElement args) { var anchorId = OptionalStringValue(args, "anchorId"); @@ -1302,6 +1381,23 @@ private static string RawArray(JsonElement args, string name) return v.GetRawText(); } + private static string RawObject(JsonElement args, string name) + { + if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value) + || value.ValueKind != JsonValueKind.Object) + throw new McpToolException($"missing required object argument \"{name}\""); + return value.GetRawText(); + } + + private static string RawObjectOrEmpty(JsonElement args, string name) + { + if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value)) + return "{}"; + if (value.ValueKind != JsonValueKind.Object) + throw new McpToolException($"optional argument \"{name}\" must be an object when present"); + return value.GetRawText(); + } + private static string BuildTableBorderSpecJson(JsonElement args) { var spec = new Dictionary diff --git a/tools/mcp-server/ToolCatalog.cs b/tools/mcp-server/ToolCatalog.cs index 1c4975f9..3b5c992b 100644 --- a/tools/mcp-server/ToolCatalog.cs +++ b/tools/mcp-server/ToolCatalog.cs @@ -10,9 +10,9 @@ namespace Docxodus.McpServer; internal sealed record ToolDefinition(string Name, string Description, string InputSchemaJson); /// -/// The tool surface this server advertises: three lifecycle tools (open/save/close) plus thirteen -/// grouped-intent tools, each accepting an action discriminator and action-specific -/// arguments. See docs/architecture/docx_agent_server.md for the full contract, the +/// The tool surface this server advertises: three lifecycle tools (open/save/close) plus fourteen +/// read or grouped-intent tools. Grouped tools accept an action discriminator and +/// action-specific arguments. See docs/architecture/docx_agent_server.md for the full contract, the /// mapping of every action onto the underlying Docxodus API, and the documented capability gaps. /// internal static class ToolCatalog @@ -429,6 +429,29 @@ internal static class ToolCatalog "required": ["sessionId", "action"] } """), + new ToolDefinition( + "docxodus_images", + "Inspect and mutate native Word images. Binary payloads cross this JSON boundary only as base64; the server never fetches URLs or reads image paths. PNG, JPEG, GIF, BMP, and TIFF are writable; WebP, legacy VML, external links, and unsupported DrawingML remain inspection-only. Rendered dimensions are points; floating offsets/distances are exact EMUs at a documented 96-DPI default.", + """ + { + "type": "object", + "properties": { + "sessionId": { "type": "string", "description": "Required except for capabilities." }, + "action": { "type": "string", "enum": ["capabilities", "list", "insert", "replace", "set_dimensions", "set_metadata", "set_floating_layout", "remove"] }, + "scope": { "type": "string", "enum": ["body", "headers", "footers", "footnotes", "endnotes", "comments", "all"] }, + "anchorId": { "type": "string", "description": "insert: paragraph anchor." }, + "characterOffset": { "type": "integer", "minimum": 0 }, + "imageId": { "type": "string", "description": "replace/set/remove: id from list or insert." }, + "imageBase64": { "type": "string", "description": "insert/replace only; raw image bytes encoded as base64." }, + "options": { "type": "object", "description": "insert options: placement inline|floating, widthPoints, heightPoints, preserveAspect, altText, title, and optional floatingLayout." }, + "dimensions": { "type": "object", "description": "set_dimensions: widthPoints and/or heightPoints plus preserveAspect (default true)." }, + "altText": { "type": ["string", "null"], "description": "set_metadata full value; null removes it." }, + "title": { "type": ["string", "null"], "description": "set_metadata full value; null removes it." }, + "layout": { "type": "object", "description": "set_floating_layout: none/square wrap; typed references/alignments; exact EMU positions/distances and flags." } + }, + "required": ["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).", @@ -450,7 +473,7 @@ internal static class ToolCatalog """), new ToolDefinition( "docxodus_mutations", - "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.", + "Apply or safely preview a batch of mutating edit/format/create/table/list/comment/link/image 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", @@ -467,7 +490,7 @@ internal static class ToolCatalog "items": { "type": "object", "properties": { - "tool": { "type": "string", "enum": ["docxodus_edit", "docxodus_format", "docxodus_create", "docxodus_table", "docxodus_list", "docxodus_comment", "docxodus_links"] }, + "tool": { "type": "string", "enum": ["docxodus_edit", "docxodus_format", "docxodus_create", "docxodus_table", "docxodus_list", "docxodus_comment", "docxodus_links", "docxodus_images"] }, "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 e1c7d79e..3bb8cae4 100644 --- a/tools/python-host/Dispatcher.cs +++ b/tools/python-host/Dispatcher.cs @@ -146,6 +146,21 @@ public static string Dispatch(string op, JsonElement args) "update_hyperlink" => DocxSessionOps.UpdateHyperlink( Handle(args), Str(args, "hyperlinkId"), Str(args, "kind"), Str(args, "target")), "remove_hyperlink" => DocxSessionOps.RemoveHyperlink(Handle(args), Str(args, "hyperlinkId")), + "get_image_capabilities" => DocxSessionOps.GetImageCapabilities(), + "list_images" => DocxSessionOps.ListImages( + Handle(args), (ProjectionScopes)IntOptional(args, "scopes", (int)ProjectionScopes.All)), + "insert_image" => DocxSessionOps.InsertImage( + Handle(args), Str(args, "anchorId"), Int(args, "characterOffset"), + Str(args, "imageBase64"), JsonObjectOrEmpty(args, "options")), + "replace_image" => DocxSessionOps.ReplaceImage( + Handle(args), Str(args, "imageId"), Str(args, "imageBase64")), + "set_image_dimensions" => DocxSessionOps.SetImageDimensions( + Handle(args), Str(args, "imageId"), JsonObjectOrEmpty(args, "dimensions")), + "set_image_metadata" => DocxSessionOps.SetImageMetadata( + Handle(args), Str(args, "imageId"), OptStr(args, "altText"), OptStr(args, "title")), + "set_image_floating_layout" => DocxSessionOps.SetImageFloatingLayout( + Handle(args), Str(args, "imageId"), JsonObject(args, "layout")), + "remove_image" => DocxSessionOps.RemoveImage(Handle(args), Str(args, "imageId")), "list_bookmarks" => DocxSessionOps.ListBookmarks( Handle(args), (ProjectionScopes)IntOptional(args, "scopes", (int)ProjectionScopes.All)), "add_bookmark" => DocxSessionOps.AddBookmark( @@ -764,4 +779,13 @@ private static JsonElement JsonObjectElement(JsonElement args, string name) throw new FormatException($"args missing object \"{name}\""); return v; } + + private static string JsonObjectOrEmpty(JsonElement args, string name) + { + if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value)) + return "{}"; + if (value.ValueKind != JsonValueKind.Object) + throw new FormatException($"optional argument \"{name}\" must be an object when present"); + return value.GetRawText(); + } } diff --git a/wasm/DocxodusWasm/DocxSessionBridge.cs b/wasm/DocxodusWasm/DocxSessionBridge.cs index d005a64a..f2094e40 100644 --- a/wasm/DocxodusWasm/DocxSessionBridge.cs +++ b/wasm/DocxodusWasm/DocxSessionBridge.cs @@ -563,6 +563,38 @@ public static string UpdateHyperlink(int h, string hyperlinkId, string kind, str public static string RemoveHyperlink(int h, string hyperlinkId) => DocxSessionOps.RemoveHyperlink(h, hyperlinkId); + [JSExport] + public static string GetImageCapabilities() => DocxSessionOps.GetImageCapabilities(); + + [JSExport] + public static string ListImages(int h, int scopes) => + DocxSessionOps.ListImages(h, (ProjectionScopes)scopes); + + [JSExport] + public static string InsertImage(int h, string anchor, int characterOffset, + string imageBase64, string optionsJson) => + DocxSessionOps.InsertImage(h, anchor, characterOffset, imageBase64, optionsJson); + + [JSExport] + public static string ReplaceImage(int h, string imageId, string imageBase64) => + DocxSessionOps.ReplaceImage(h, imageId, imageBase64); + + [JSExport] + public static string SetImageDimensions(int h, string imageId, string dimensionsJson) => + DocxSessionOps.SetImageDimensions(h, imageId, dimensionsJson); + + [JSExport] + public static string SetImageMetadata(int h, string imageId, string altText, string title) => + DocxSessionOps.SetImageMetadata(h, imageId, altText, title); + + [JSExport] + public static string SetImageFloatingLayout(int h, string imageId, string layoutJson) => + DocxSessionOps.SetImageFloatingLayout(h, imageId, layoutJson); + + [JSExport] + public static string RemoveImage(int h, string imageId) => + DocxSessionOps.RemoveImage(h, imageId); + [JSExport] public static string ListBookmarks(int h, int scopes) => DocxSessionOps.ListBookmarks(h, (ProjectionScopes)scopes);