From 732f9fed4fe9c95a62a21c43bd5d19c64c8a5416 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Tue, 15 Sep 2026 23:38:22 -0500 Subject: [PATCH] fix(session): support formatted insertion in leading-tab runs --- CHANGELOG.md | 7 +++ Docxodus.Tests/DocxSessionTextFormatTests.cs | 64 +++++++++++++++++--- Docxodus/DocxSession.cs | 15 +++-- docs/architecture/docx_mutation_api.md | 2 +- npm/tests/atomic-batch.spec.ts | 24 +++++--- 5 files changed, 90 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e049a3fc..813296a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- Atomic formatted insertion accepts a caret inside text preceded by leading tabs in the + same run, including NVCA footnotes (issue #802). Tabs and surrounding formatting stay + in place, with failure rollback and one-step undo/redo on the existing lightweight path. + Tabs after text and other mixed run content remain refused for interior insertion. + ## [12.6.1] - 2026-09-15 ### Fixed diff --git a/Docxodus.Tests/DocxSessionTextFormatTests.cs b/Docxodus.Tests/DocxSessionTextFormatTests.cs index 9135c377..4cbc2996 100644 --- a/Docxodus.Tests/DocxSessionTextFormatTests.cs +++ b/Docxodus.Tests/DocxSessionTextFormatTests.cs @@ -48,13 +48,18 @@ public void TF788_ReplacementAndFormat_AreOneUndoUnit( } [Theory] - [InlineData(TrackedChangeMode.Accept, 9)] - [InlineData(TrackedChangeMode.RenderInline, 9)] - [InlineData(TrackedChangeMode.Accept, 0)] - [InlineData(TrackedChangeMode.RenderInline, 0)] - public void TF788_FormatFailure_RestoresTextStylesGeneratorsAndRedo(TrackedChangeMode tracking, int length) + [InlineData(TrackedChangeMode.Accept, 9, false)] + [InlineData(TrackedChangeMode.RenderInline, 9, false)] + [InlineData(TrackedChangeMode.Accept, 0, false)] + [InlineData(TrackedChangeMode.RenderInline, 0, false)] + [InlineData(TrackedChangeMode.Accept, 0, true)] + [InlineData(TrackedChangeMode.RenderInline, 0, true)] + public void TF788_FormatFailure_RestoresTextStylesGeneratorsAndRedo( + TrackedChangeMode tracking, int length, bool leadingTab) { - using var session = Open(tracking); + using var session = leadingTab + ? Open(tracking, new XElement(W.r, new XElement(W.tab), new XElement(W.t, "First paragraph."))) + : Open(tracking); var anchor = session.FindByText("First paragraph.")!.Anchor.Id; Assert.True(session.ReplaceTextAtSpan(anchor, 0, 5, "Redo").Success); var redoHash = session.GetPackageContentHash(); @@ -130,12 +135,16 @@ public void TF788_DeliveryCapture_PreservesConsecutiveEditVersions() } [Theory] - [InlineData(TrackedChangeMode.Accept)] - [InlineData(TrackedChangeMode.RenderInline)] - public void TF799_InteriorInsertion_PreservesNeighborFormatting(TrackedChangeMode tracking) + [InlineData(TrackedChangeMode.Accept, 0)] + [InlineData(TrackedChangeMode.RenderInline, 0)] + [InlineData(TrackedChangeMode.Accept, 1)] + [InlineData(TrackedChangeMode.RenderInline, 1)] + [InlineData(TrackedChangeMode.Accept, 2)] + public void TF799_InteriorInsertion_PreservesNeighborFormatting(TrackedChangeMode tracking, int leadingTabs) { // The caret is between two text nodes in one italic run, after a UTF-16 surrogate pair. var source = new XElement(W.r, new XElement(W.rPr, new XElement(W.i)), + Enumerable.Range(0, leadingTabs).Select(_ => new XElement(W.tab)), new XElement(W.t, "doc😀u"), new XElement(W.t, "ment")); using var session = Open(tracking, new XElement(W.p, ComplexField(new XElement(W.r, new XElement(W.t, "Title")))), @@ -152,6 +161,9 @@ public void TF799_InteriorInsertion_PreservesNeighborFormatting(TrackedChangeMod Assert.Equal(new[] { ("doc😀u", false, true), (" inserted ", true, false), ("ment", false, true) }, session.GetFormatting(anchor)!.Runs.Select(r => (r.Text, r.Effective.Bold is true, r.Effective.Italic is true))); var xml = XElement.Parse(session.Raw.GetXml(anchor)); + Assert.Equal(leadingTabs, xml.Descendants(W.tab).Count()); + Assert.Equal(Enumerable.Repeat(W.tab, leadingTabs).Append(W.t), + xml.Elements(W.r).First().Elements().Where(e => e.Name != W.rPr).Select(e => e.Name)); Assert.All(xml.Descendants(W.t), t => Assert.Equal("preserve", (string?)t.Attribute(XNamespace.Xml + "space"))); if (tracking == TrackedChangeMode.RenderInline) { @@ -173,6 +185,11 @@ public void TF799_InteriorInsertion_PreservesNeighborFormatting(TrackedChangeMod [InlineData("simple field")] [InlineData("insertion")] [InlineData("mixed run")] + [InlineData("trailing tab")] + [InlineData("leading break")] + [InlineData("tab/text field")] + [InlineData("tab/text hyperlink")] + [InlineData("tab/text surrogate pair")] [InlineData("surrogate pair")] public void TF799_UnsafeInteriorInsertion_IsUnchanged(string context) { @@ -193,6 +210,12 @@ public void TF799_UnsafeInteriorInsertion_IsUnchanged(string context) "simple field" => new[] { new XElement(W.fldSimple, new XAttribute(W.instr, " DOCPROPERTY Title "), run) }, "insertion" => new[] { new XElement(W.ins, new XAttribute(W.id, "1"), new XAttribute(W.author, "Other"), run) }, "mixed run" => new[] { new XElement(W.r, new XElement(W.t, "docu"), new XElement(W.tab), new XElement(W.t, "ment")) }, + "trailing tab" => new[] { new XElement(W.r, new XElement(W.t, "document"), new XElement(W.tab)) }, + "leading break" => new[] { new XElement(W.r, new XElement(W.br), new XElement(W.t, "document")) }, + "tab/text field" => ComplexField(new XElement(W.r, new XElement(W.tab), new XElement(W.t, "document"))), + "tab/text hyperlink" => new[] { new XElement(W.hyperlink, new XAttribute(W.anchor, "top"), + new XElement(W.r, new XElement(W.tab), new XElement(W.t, "document"))) }, + "tab/text surrogate pair" => new[] { new XElement(W.r, new XElement(W.tab), new XElement(W.t, "doc😀ument")) }, "surrogate pair" => new[] { new XElement(W.r, new XElement(W.t, "doc😀ument")) }, _ => throw new ArgumentOutOfRangeException(nameof(context)), }; @@ -209,6 +232,29 @@ public void TF799_UnsafeInteriorInsertion_IsUnchanged(string context) Assert.Equal(0, session.UndoCount); } + [Fact] + public void TF802_NvcaFootnote_FormattedInsertionPreservesTabAndReference() + { + using var session = new DocxSession(File.ReadAllBytes("../../../../TestFiles/NVCA-Model-COI.docx"), + new DocxSessionSettings { PersistAnchorIds = true, EmitMarkdownPatch = false }); + var anchor = session.Project().AnchorIndex.Values.Single(a => a.Anchor.Kind == "p" && a.Anchor.Scope == "fn" + && a.TextPreview.StartsWith("Consider adding other exceptions", StringComparison.Ordinal)).Anchor.Id; + var before = string.Concat(session.GetFormatting(anchor)!.Runs.Select(r => r.Text)); + var original = XElement.Parse(session.Raw.GetXml(anchor)); + var reference = Assert.Single(original.Descendants(W.footnoteRef)).Parent; + Assert.Single(original.Descendants(W.tab)); + + var edit = session.ReplaceTextAtSpanWithFormat(anchor, 24, 0, " inserted ", new FormatOp { Bold = true }); + + Assert.True(edit.Success, edit.Error?.Message); + var after = session.GetFormatting(anchor); + Assert.NotNull(after); + Assert.Equal(before.Insert(24, " inserted "), string.Concat(after.Runs.Select(r => r.Text))); + var xml = XElement.Parse(session.Raw.GetXml(anchor)); + Assert.True(XNode.DeepEquals(reference, Assert.Single(xml.Descendants(W.footnoteRef)).Parent)); + Assert.Equal("Consider adding other ex", Assert.Single(xml.Descendants(W.tab)).ElementsAfterSelf(W.t).Single().Value); + } + private static DocxSession Open(TrackedChangeMode tracking, params XElement[] content) { using var stream = new MemoryStream(); diff --git a/Docxodus/DocxSession.cs b/Docxodus/DocxSession.cs index 4767dc3f..e303df00 100644 --- a/Docxodus/DocxSession.cs +++ b/Docxodus/DocxSession.cs @@ -6112,8 +6112,8 @@ public EditResult ReplaceTextAtSpan(string anchorId, int spanStart, int spanLeng /// where Word's next field update would discard them. The new run copies the formatting of /// the run it follows (or precedes, at offset 0), minus any revision marker, and lands /// OUTSIDE any field whose chrome surrounds the boundary — after the field's end run, - /// or before its begin. Interior insertion is limited to plain text runs directly in - /// the paragraph, outside fields, and must not split a UTF-16 surrogate pair. + /// or before its begin. Interior insertion accepts text runs with optional leading tabs + /// directly in the paragraph, outside fields, and must not split a UTF-16 surrogate pair. /// private EditResult InsertTextAtBoundary( AnchorTarget target, XElement element, Internal.RunTextMap.Map map, int offset, string replace) @@ -6135,11 +6135,11 @@ private EditResult InsertTextAtBoundary( if (seg.StartOffsetInBlock == offset) { before = seg.Run; break; } if (seg.StartOffsetInBlock < offset && offset < seg.EndOffsetInBlock) { - if (!IsPlainTextRun(seg.Run) || !ReferenceEquals(seg.Run.Parent, element) + if (!CanSplitTextRun(seg.Run) || !ReferenceEquals(seg.Run.Parent, element) || IsInsideComplexField(seg.Run) || char.IsSurrogatePair(map.FlatText, offset - 1)) return EditResult.Fail(EditErrorCode.OffsetOutOfRange, - "interior insertion requires an ordinary character boundary in a plain text run outside fields and inline containers", anchorId); + "interior insertion requires an ordinary character boundary in a text run with only optional leading tabs, outside fields and inline containers", anchorId); after = seg.Run; splitRun = true; break; @@ -6274,6 +6274,13 @@ private static bool IsPlainTextRun(XElement run) => && run.Elements().All(e => e.Name == W.rPr || e.Name == W.t) && run.Elements(W.t).Any(); + // SplitRunsAtOffset keeps non-text children on the prefix, ahead of its text. + // Leading tabs are safe there; a tab after text or other run content could move. + private static bool CanSplitTextRun(XElement run) => + run.Name == W.r && run.Elements(W.t).Any() + && run.Elements().Where(e => e.Name != W.rPr) + .SkipWhile(e => e.Name == W.tab).All(e => e.Name == W.t); + // Complex fields can cross paragraphs. Text boxes, notes and comments have separate stories. private static bool IsInsideComplexField(XElement run) { diff --git a/docs/architecture/docx_mutation_api.md b/docs/architecture/docx_mutation_api.md index 6a6522e1..1cc1e459 100644 --- a/docs/architecture/docx_mutation_api.md +++ b/docs/architecture/docx_mutation_api.md @@ -1983,7 +1983,7 @@ Multiple matches in the same paragraph are applied in **reverse document order** If the agent has computed five `[___]` placeholder matches in the same paragraph from `Grep` and wants to fill each with a different value, `ReplaceTextRange` would only see "five identical `[___]` needles" and replace each with the first value (or all with the same value). `ReplaceTextAtSpan` (or `ReplaceMatch`) addresses each match by its exact coordinates so the disambiguation is unambiguous. Apply spans in **reverse offset order** in this case for the same reason — earlier spans stay valid after later edits. -A **zero-length** span is a pure insertion. At an ordinary character boundary inside a plain text run directly in the paragraph, it splits the run and inserts text between the two halves, preserving their formatting. `ReplaceTextAtSpanWithFormat` applies the typing format only to that new text. Interior offsets in fields, inline containers (including existing revisions), mixed-content runs, or UTF-16 surrogate pairs are refused with `offset_out_of_range`. +A **zero-length** span is a pure insertion. At an ordinary character boundary inside a text run directly in the paragraph, it splits the run and inserts text between the two halves, preserving their formatting. The run may contain leading `w:tab` elements followed only by `w:t` text; those tabs stay before the prefix and do not count toward native text offsets. `ReplaceTextAtSpanWithFormat` applies the typing format only to the new text. Interior offsets in fields, inline containers (including existing revisions), other mixed-content runs (including tabs after text), or UTF-16 surrogate pairs are refused with `offset_out_of_range`. At a run boundary (the end of one run's text, the start of another's, offset 0, or the end of the paragraph), insertion copies the neighbouring run's formatting and can coalesce with an ordinary adjacent run. It steps outside any complex field whose chrome surrounds the boundary, so text inserted after `Page {PAGE} of {NUMPAGES}` follows the field's `end` run rather than joining the NUMPAGES result Word would discard on its next field update. diff --git a/npm/tests/atomic-batch.spec.ts b/npm/tests/atomic-batch.spec.ts index 55feaa27..76788bd4 100644 --- a/npm/tests/atomic-batch.spec.ts +++ b/npm/tests/atomic-batch.spec.ts @@ -49,13 +49,16 @@ test.describe('DocxSession atomic batches (#445)', () => { expect(result.markdown).toBe(true); }); + const bodyTarget = { scope: 'body', prefix: 'The Certificate of Incorporation', needle: 'document' }; for (const scenario of [ - { name: 'replacement', offset: 0, length: 8, replacement: 'new document' }, - { name: 'interior insertion', offset: 4, length: 0, replacement: ' inserted ' }, + { ...bodyTarget, name: 'replacement', offset: 0, length: 8, replacement: 'new document' }, + { ...bodyTarget, name: 'interior insertion', offset: 4, length: 0, replacement: ' inserted ' }, + { name: 'footnote tab/text insertion', scope: 'fn', prefix: 'Consider adding other exceptions', + needle: 'Consider', offset: 24, length: 0, replacement: ' inserted ' }, ]) { test(`NVCA ${scenario.name} with Bold is atomic without package checkpoint/hash calls`, async ({ page }) => { const nvca = fs.readFileSync(path.join(__dirname, '../../TestFiles/NVCA-Model-COI.docx')); - const result = await page.evaluate(({ bytes, offset, length, replacement }) => { + const result = await page.evaluate(({ bytes, scope, prefix, needle, offset, length, replacement }) => { const api = (window as any).Docxodus; const session = api.openTypedSession(new Uint8Array(bytes), JSON.stringify({ emitMarkdownPatch: false, persistAnchorIds: true, @@ -68,18 +71,22 @@ test.describe('DocxSession atomic batches (#445)', () => { bridge.GetPackageContentHash = (...args: any[]) => { packageCalls++; return hash(...args); }; try { const anchor = (Object.entries(session.project().anchorIndex) as [string, any][]) - .find(([, a]) => a.scope === 'body' && a.textPreview.startsWith('The Certificate of Incorporation'))![0]; + .find(([, a]) => a.kind === 'p' && a.scope === scope && a.textPreview.startsWith(prefix))![0]; const formatting = () => session.getFormatting(anchor).runs .map(({ text, span, effective }: any) => ({ text, span, effective })); const before = formatting(); const text = before.map((r: any) => r.text).join(''); - const sourceRun = before.find((r: any) => r.text.includes('document')); - if (!sourceRun) throw new Error('NVCA fixture no longer has a run containing document'); - const start = sourceRun.span.start + sourceRun.text.indexOf('document') + offset; + const sourceRun = before.find((r: any) => r.text.includes(needle)); + if (!sourceRun) throw new Error(`NVCA fixture no longer has a run containing ${needle}`); + const start = sourceRun.span.start + sourceRun.text.indexOf(needle) + offset; const match = { enclosingAnchor: { id: anchor }, span: { start, length } }; const edit = session.replaceMatch(match, replacement, { bold: true }); const version = session.getVersion(); const after = formatting(); + const word = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; + const xml = new DOMParser().parseFromString(session.raw.getXml(anchor), 'application/xml'); + const tabPrefixes = Array.from(xml.getElementsByTagNameNS(word, 'tab')).map(tab => + Array.from(tab.parentElement!.getElementsByTagNameNS(word, 't')).map(t => t.textContent).join('')); const undo = session.undo() && JSON.stringify(formatting()) === JSON.stringify(before); const redo = session.redo() && JSON.stringify(formatting()) === JSON.stringify(after); const failed = session.replaceMatch( @@ -87,7 +94,7 @@ test.describe('DocxSession atomic batches (#445)', () => { { code: true, highlight: 'invalid-highlight' }, ); return { - edit, version, undo, redo, packageCalls, + edit, version, undo, redo, packageCalls, tabPrefixes, expected: text.slice(0, start) + replacement + text.slice(start + length), actual: after.map((r: any) => r.text).join(''), bold: after.filter((r: any) => r.span.start < start + replacement.length && r.span.start + r.span.length > start) @@ -106,6 +113,7 @@ test.describe('DocxSession atomic batches (#445)', () => { expect(result.edit.patch).toBeFalsy(); expect(result.version).toBe(1); expect(result.actual).toBe(result.expected); + expect(result.tabPrefixes).toEqual(scenario.scope === 'fn' ? [scenario.prefix.slice(0, 24)] : []); expect(result.bold.map((r: any) => r.text).join('')).toBe(scenario.replacement); expect(result.bold.every((r: any) => r.bold === true)).toBe(true); expect(result.undo).toBe(true);