Skip to content
Merged
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.

## [Unreleased] - .NET 8 / Open XML SDK 3.x Migration

### Added
- **Incremental annotation overlay API (Issue #106)** - Decouple HTML conversion from annotation projection to avoid full WASM re-conversion
- `ProjectAnnotationsOntoHtml()` - Project a full annotation set onto already-converted HTML
- `AddAnnotationToHtml()` - Add a single annotation to existing HTML without re-converting the document
- `RemoveAnnotationFromHtml()` - Remove a single annotation by ID, unwrapping spans back to plain text
- `GenerateVisibilityCss()` - Generate CSS to hide/show annotations by label ID for instant toggling
- `GenerateAnnotationCssString()` - Generate annotation CSS separately for independent management
- All methods available in .NET, WASM (JSExport), and npm TypeScript wrapper
- CSS-based label filtering enables responsive toggle without any re-rendering

### Fixed
- **Move markup Word compatibility (Issue #96)** - Documents with move operations no longer cause Word "unreadable content" warnings
- Added `SimplifyMoveMarkup` setting to convert native move markup (`w:moveFrom`/`w:moveTo`) to simple `w:del`/`w:ins`
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,17 @@ See `docs/architecture/comment_rendering.md` for detailed comment rendering docu
- Structural annotations (sections, paragraphs, tables) with relationships
- See `docs/architecture/opencontracts_export.md` for detailed documentation

**ExternalAnnotationProjector.cs** - Incremental annotation overlay API (Issue #106). Decouples annotation projection from DOCX conversion for dramatically better performance when annotations change:
- `ProjectAnnotationsOntoHtml(html, set, settings)` - Project a full annotation set onto pre-converted HTML (~56ms vs ~892ms for full re-conversion, 15.9x faster)
- `AddAnnotationToHtml(html, annotation, label, settings)` - Add a single annotation (~0.3ms, 2972x faster than full re-conversion)
- `RemoveAnnotationFromHtml(html, annotationId, cssPrefix)` - Remove a single annotation by ID (~18ms)
- `GenerateVisibilityCss(hiddenLabelIds, cssPrefix)` - Generate CSS to hide/show annotations by label (instant toggling)
- `GenerateAnnotationCssString(labels, settings)` - Generate annotation CSS independently
- Works by building a text map of the HTML, finding annotation text via string search, and wrapping matches with styled `<span>` elements
- `GetTextNodes` skips already-projected annotation wrappers to prevent offset drift from label text
- Available in .NET, WASM (JSExport), and npm TypeScript wrapper
- See `docs/architecture/incremental_annotation_overlay.md` for detailed documentation

### Target Frameworks

Library targets: `net8.0`
Expand Down
54 changes: 27 additions & 27 deletions Docxodus.Tests/Docxodus.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<!-- Disable TreatWarningsAsErrors for test project - legacy xUnit patterns -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);xUnit1012;xUnit2020</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DocumentFormat.OpenXml" Version="3.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Docxodus\Docxodus.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>

<!-- Disable TreatWarningsAsErrors for test project - legacy xUnit patterns -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);xUnit1012;xUnit2020</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="DocumentFormat.OpenXml" Version="3.4.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Docxodus\Docxodus.csproj" />
</ItemGroup>

</Project>
193 changes: 193 additions & 0 deletions Docxodus.Tests/ExternalAnnotationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,199 @@ public void EA060_Integration_RealDocument_CreatesAndValidatesSet()
}

#endregion

#region Incremental Annotation Overlay Tests (Issue #106)

[Fact]
public void EA020_ProjectAnnotationsOntoHtml_AddsAnnotationSpans()
{
// Arrange
var doc = CreateSimpleTestDocument("Hello, world! This is a test document.");
var set = ExternalAnnotationManager.CreateAnnotationSet(doc, "test");

set.TextLabels["GREETING"] = new AnnotationLabel
{
Id = "GREETING",
Text = "Greeting",
Color = "#FFEB3B"
};

var annotation = ExternalAnnotationManager.CreateAnnotation(
"ann-001", "GREETING", set.Content, 0, 5);
Assert.NotNull(annotation);
set.LabelledText.Add(annotation);

// Convert HTML once (without annotations)
var baseHtml = WmlToHtmlConverter.ConvertToHtml(doc, new WmlToHtmlConverterSettings
{
PageTitle = "Test"
}).ToString();

// Act - project annotations onto cached HTML
var annotatedHtml = ExternalAnnotationProjector.ProjectAnnotationsOntoHtml(
baseHtml, set);

// Assert
Assert.Contains("data-annotation-id=\"ann-001\"", annotatedHtml);
Assert.Contains("ext-annot-highlight", annotatedHtml);
Assert.Contains("--annot-color: #FFEB3B", annotatedHtml);
}

[Fact]
public void EA021_AddAnnotationToHtml_AddsSingleAnnotation()
{
// Arrange
var doc = CreateSimpleTestDocument("Hello, world! This is a test document.");
var baseHtml = WmlToHtmlConverter.ConvertToHtml(doc, new WmlToHtmlConverterSettings
{
PageTitle = "Test"
}).ToString();

var set = ExternalAnnotationManager.CreateAnnotationSet(doc, "test");
var annotation = ExternalAnnotationManager.CreateAnnotation(
"ann-single", "CLAUSE", set.Content, 0, 5);
Assert.NotNull(annotation);

var label = new AnnotationLabel
{
Id = "CLAUSE",
Text = "Clause",
Color = "#FF5722"
};

// Act
var result = ExternalAnnotationProjector.AddAnnotationToHtml(
baseHtml, annotation, label);

// Assert
Assert.Contains("data-annotation-id=\"ann-single\"", result);
Assert.Contains("--annot-color: #FF5722", result);
}

[Fact]
public void EA022_RemoveAnnotationFromHtml_RemovesAnnotationSpans()
{
// Arrange - first project an annotation
var doc = CreateSimpleTestDocument("Hello, world!");
var set = ExternalAnnotationManager.CreateAnnotationSet(doc, "test");

set.TextLabels["GREETING"] = new AnnotationLabel
{
Id = "GREETING",
Text = "Greeting",
Color = "#FFEB3B"
};

var annotation = ExternalAnnotationManager.CreateAnnotation(
"ann-remove", "GREETING", set.Content, 0, 5);
Assert.NotNull(annotation);
set.LabelledText.Add(annotation);

var baseHtml = WmlToHtmlConverter.ConvertToHtml(doc, new WmlToHtmlConverterSettings
{
PageTitle = "Test"
}).ToString();

var annotatedHtml = ExternalAnnotationProjector.ProjectAnnotationsOntoHtml(
baseHtml, set);
Assert.Contains("data-annotation-id=\"ann-remove\"", annotatedHtml);

// Act
var result = ExternalAnnotationProjector.RemoveAnnotationFromHtml(
annotatedHtml, "ann-remove");

// Assert - annotation spans should be removed
Assert.DoesNotContain("data-annotation-id=\"ann-remove\"", result);
// But the text should still be there
Assert.Contains("Hello", result);
}

[Fact]
public void EA023_GenerateVisibilityCss_HidesSpecifiedLabels()
{
// Act
var css = ExternalAnnotationProjector.GenerateVisibilityCss(
new[] { "DRAFT", "INTERNAL" });

// Assert
Assert.Contains("data-label-id=\"DRAFT\"", css);
Assert.Contains("data-label-id=\"INTERNAL\"", css);
Assert.Contains("background-color: transparent", css);
Assert.Contains("display: none", css);
}

[Fact]
public void EA024_GenerateAnnotationCssString_GeneratesValidCss()
{
// Arrange
var labels = new Dictionary<string, AnnotationLabel>
{
["CLAUSE"] = new AnnotationLabel
{
Id = "CLAUSE",
Text = "Clause",
Color = "#FF5722"
},
["TERM"] = new AnnotationLabel
{
Id = "TERM",
Text = "Term",
Color = "#2196F3"
}
};

// Act
var css = ExternalAnnotationProjector.GenerateAnnotationCssString(labels);

// Assert
Assert.Contains("ext-annot-highlight", css);
Assert.Contains("ext-annot-label-CLAUSE", css);
Assert.Contains("#FF5722", css);
Assert.Contains("ext-annot-label-TERM", css);
Assert.Contains("#2196F3", css);
}

[Fact]
public void EA025_ProjectAnnotationsOntoHtml_ThenRemove_PreservesText()
{
// Arrange - use two separate paragraphs to avoid text splitting issues
var doc = CreateTestDocument(body =>
{
body.AppendChild(new Paragraph(new Run(new Text("Alpha paragraph"))));
body.AppendChild(new Paragraph(new Run(new Text("Beta paragraph"))));
});
var set = ExternalAnnotationManager.CreateAnnotationSet(doc, "test");

set.TextLabels["LABEL_A"] = new AnnotationLabel { Id = "LABEL_A", Text = "A", Color = "#FF0000" };
set.TextLabels["LABEL_B"] = new AnnotationLabel { Id = "LABEL_B", Text = "B", Color = "#00FF00" };

// Use text search to create annotations (more reliable than offset-based)
var ann1 = ExternalAnnotationManager.CreateAnnotationFromSearch(
"ann-a", "LABEL_A", set.Content, "Alpha", 1);
var ann2 = ExternalAnnotationManager.CreateAnnotationFromSearch(
"ann-b", "LABEL_B", set.Content, "Beta", 1);
Assert.NotNull(ann1);
Assert.NotNull(ann2);
set.LabelledText.Add(ann1);
set.LabelledText.Add(ann2);

var baseHtml = WmlToHtmlConverter.ConvertToHtml(doc, new WmlToHtmlConverterSettings
{
PageTitle = "Test"
}).ToString();

// Act - project both, then remove one
var annotatedHtml = ExternalAnnotationProjector.ProjectAnnotationsOntoHtml(baseHtml, set);
var afterRemove = ExternalAnnotationProjector.RemoveAnnotationFromHtml(annotatedHtml, "ann-a");

// Assert - ann-a removed, ann-b still present, all text preserved
Assert.DoesNotContain("data-annotation-id=\"ann-a\"", afterRemove);
Assert.Contains("data-annotation-id=\"ann-b\"", afterRemove);
Assert.Contains("Alpha", afterRemove);
Assert.Contains("Beta", afterRemove);
}

#endregion
}
}

Expand Down
102 changes: 51 additions & 51 deletions Docxodus/Docxodus.csproj
Original file line number Diff line number Diff line change
@@ -1,51 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>
<!-- Enable packaging for NuGet -->
<IsPackable>true</IsPackable>
<!-- NuGet Package Metadata -->
<PackageId>Docxodus</PackageId>
<Version>1.0.0</Version>
<Authors>OpenXmlPowerTools Authors, JSv4</Authors>
<Description>A powerful library for manipulating Open XML documents (DOCX, XLSX, PPTX). Fork of OpenXmlPowerTools upgraded to .NET 8.0.</Description>
<PackageProjectUrl>https://github.com/JSv4/Docxodus</PackageProjectUrl>
<RepositoryUrl>https://github.com/JSv4/Docxodus</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>openxml;docx;word;xlsx;excel;pptx;powerpoint;document;compare;merge</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<!-- Disable TreatWarningsAsErrors for legacy code - too many existing warnings -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS8073;CA2200;CS8632</NoWarn>
<!-- ReadyToRun: Pre-compile to native code during publish to eliminate JIT warmup -->
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
<!-- Define WASM_BUILD constant when building for WASM -->
<PropertyGroup Condition="'$(WASM_BUILD)' == 'true'">
<DefineConstants>$(DefineConstants);WASM_BUILD</DefineConstants>
</PropertyGroup>
<!-- Core dependencies (always included) -->
<ItemGroup>
<PackageReference Include="DocumentFormat.OpenXml" Version="3.4.1" />
</ItemGroup>
<!-- SkiaSharp dependencies (only for non-WASM builds) -->
<ItemGroup Condition="'$(WASM_BUILD)' != 'true'">
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
</ItemGroup>
<ItemGroup>
<None Include="..\README.md" Pack="true" PackagePath="" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>

<!-- Enable packaging for NuGet -->
<IsPackable>true</IsPackable>

<!-- NuGet Package Metadata -->
<PackageId>Docxodus</PackageId>
<Version>1.0.0</Version>
<Authors>OpenXmlPowerTools Authors, JSv4</Authors>
<Description>A powerful library for manipulating Open XML documents (DOCX, XLSX, PPTX). Fork of OpenXmlPowerTools upgraded to .NET 8.0.</Description>
<PackageProjectUrl>https://github.com/JSv4/Docxodus</PackageProjectUrl>
<RepositoryUrl>https://github.com/JSv4/Docxodus</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>openxml;docx;word;xlsx;excel;pptx;powerpoint;document;compare;merge</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>

<!-- Disable TreatWarningsAsErrors for legacy code - too many existing warnings -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS8073;CA2200;CS8632</NoWarn>

<!-- ReadyToRun: Pre-compile to native code during publish to eliminate JIT warmup -->
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>

<!-- Define WASM_BUILD constant when building for WASM -->
<PropertyGroup Condition="'$(WASM_BUILD)' == 'true'">
<DefineConstants>$(DefineConstants);WASM_BUILD</DefineConstants>
</PropertyGroup>

<!-- Core dependencies (always included) -->
<ItemGroup>
<PackageReference Include="DocumentFormat.OpenXml" Version="3.4.1" />
</ItemGroup>

<!-- SkiaSharp dependencies (only for non-WASM builds) -->
<ItemGroup Condition="'$(WASM_BUILD)' != 'true'">
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
</ItemGroup>

<ItemGroup>
<None Include="..\README.md" Pack="true" PackagePath="" />
</ItemGroup>

</Project>
Loading
Loading