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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 55 additions & 9 deletions Docxodus.Tests/DocxSessionTextFormatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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")))),
Expand All @@ -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)
{
Expand All @@ -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)
{
Expand All @@ -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)),
};
Expand All @@ -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();
Expand Down
15 changes: 11 additions & 4 deletions Docxodus/DocxSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6112,8 +6112,8 @@
/// 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 <c>end</c> run,
/// or before its <c>begin</c>. 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 <c>begin</c>. Interior insertion accepts text runs with optional leading tabs
/// directly in the paragraph, outside fields, and must not split a UTF-16 surrogate pair.
/// </summary>
private EditResult InsertTextAtBoundary(
AnchorTarget target, XElement element, Internal.RunTextMap.Map map, int offset, string replace)
Expand All @@ -6135,11 +6135,11 @@
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;
Expand Down Expand Up @@ -6274,6 +6274,13 @@
&& 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)
{
Expand Down Expand Up @@ -9781,7 +9788,7 @@
// structured wrappers; validate the whole cell subtree rather than only direct paragraphs.
if (ValidateBookmarkRemoval(new[] { cell! }, cellAnchorId) is { } bookmarkError)
return bookmarkError;
var hyperlinkOwner = Internal.OwnedPartRelationships.FindOwner(_doc!, cell);

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (macos-latest)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (ubuntu-latest)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (windows-latest)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.10)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.13)

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-npm

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.

Check warning on line 9791 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'element' in 'Owner? OwnedPartRelationships.FindOwner(WordprocessingDocument document, XElement element)'.
var oldHyperlinkIds = cell.Descendants(W.hyperlink)
.Select(h => (string?)h.Attribute(R.id)).Where(id => !string.IsNullOrEmpty(id)).Cast<string>().ToList();

Expand Down Expand Up @@ -9901,7 +9908,7 @@
{
// Inline code references a "Code" character style by id; ensure it actually
// exists so the run renders monospace instead of pointing at a phantom style.
if (op.Code is true) Internal.StyleFactory.EnsureCodeCharacterStyle(_doc);

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (macos-latest)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (ubuntu-latest)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (windows-latest)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.10)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.13)

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-npm

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

Check warning on line 9911 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'doc' in 'void StyleFactory.EnsureCodeCharacterStyle(WordprocessingDocument doc)'.

SplitRunsAtOffset(element, actualSpan.Start);
SplitRunsAtOffset(element, actualSpan.Start + actualSpan.Length);
Expand Down Expand Up @@ -9973,7 +9980,7 @@
if (target.Anchor.Kind is not ("p" or "h" or "li"))
return EditResult.Fail(EditErrorCode.AnchorWrongKind, "SetParagraphStyle requires a paragraph anchor", anchorId);

var element = target.Resolve(_doc);

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (macos-latest)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (ubuntu-latest)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-matrix (windows-latest)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.10)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / python-tests (3.13)

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-npm

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.

Check warning on line 9983 in Docxodus/DocxSession.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'document' in 'XElement? AnchorTarget.Resolve(WordprocessingDocument document)'.
if (element is null) return EditResult.Fail(EditErrorCode.AnchorNotFound, "element null", anchorId);
if (RefuseNestedTrackedParagraphPropertyChange(element, anchorId) is { } pending) return pending;

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/docx_mutation_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 16 additions & 8 deletions npm/tests/atomic-batch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -68,26 +71,30 @@ 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(
{ ...match, span: { start, length: replacement.length } }, 'failed text',
{ 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)
Expand All @@ -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);
Expand Down
Loading