Tag Replacement : In Single Row : Row not getting delete if Tag Value is empty

Hello Team ,

We are replacing tag in word template , we have require if tag present in single line and value is empty , we need to remove line , to avoid blank line in completed doc .

using below code to replace tag

private void ReplaceTagsInWordDocument(Document doc, Dictionary<string, object> tags)
{
    FindReplaceOptions options = new FindReplaceOptions
    {
        ReplacingCallback = new TagReplacer(tags),
        Direction = FindReplaceDirection.Forward
    };

    doc.Range.Replace(new Regex(@"\{\{(.+?)\}\}"), "", options);
}

Blank Line Defect.zip (143.3 KB)

Thanks,
Avinash

@Chilke

Summary
When a placeholder like {{TagName}} occupies an entire paragraph and you replace it with an empty string, Aspose.Words removes only the text of the placeholder. The paragraph itself remains, which results in a blank line in the generated document.

Why it happens
Document.Range.Replace (or the FindReplace API) substitutes the matched characters but does not automatically delete the containing Paragraph node. If the replacement string is "", the run that held the tag becomes empty, but the paragraph node is still present, so the document shows an empty line.

What to do next

  1. Delete the whole paragraph when the tag value is empty
    Implement the IReplacingCallback so that, when a tag’s value is null or an empty string, the callback removes the ancestor Paragraph (or the appropriate container such as a table cell).
using Aspose.Words;
using Aspose.Words.Replacing;
using System.Collections.Generic;
using System.Text.RegularExpressions;

public class TagReplacer : IReplacingCallback
{
private readonly Dictionary<string, object> _tags;
public TagReplacer(Dictionary<string, object> tags) => _tags = tags;

ReplaceAction IReplacingCallback.Replacing(ReplacingArgs e)
{
// Capture the tag name from {{TagName}}
string tagName = e.Match.Groups[1].Value;

if (_tags.TryGetValue(tagName, out var value) &&
value != null && !string.IsNullOrWhiteSpace(value.ToString()))
{
// Normal replacement – insert the value
e.Replacement = value.ToString();
return ReplaceAction.Replace;
}

// Value is empty – remove the whole paragraph that contains the match
Paragraph para = (Paragraph)e.MatchNode.GetAncestor(NodeType.Paragraph);
para?.Remove();
return ReplaceAction.Skip; // No further processing for this match
}
}

Use the callback as you already do:

FindReplaceOptions options = new FindReplaceOptions
{
ReplacingCallback = new TagReplacer(tags),
Direction = FindReplaceDirection.Forward
};
doc.Range.Replace(new Regex(@"\{\{(.+?)\}\}"), "", options);
  1. Alternative quick‑fix – include the line break in the regex
    If the tag is guaranteed to be the only content of a paragraph (not inside tables, headers/footers, etc.), you can match the trailing line‑break and delete it in one step:
var pattern = new Regex(@"\{\{(.+?)\}\}\s*\r?\n?");
doc.Range.Replace(pattern, "", options);

Note: This will not work for tags that appear inside a table cell or a paragraph that also contains other text.

  1. Verify the Aspose.Words version
    Some older versions had bugs where empty‑string replacements left orphaned runs. Print the version and, if needed, upgrade to the latest stable release:
Console.WriteLine("Aspose.Words version: " + Aspose.Words.Version);
  1. Test your template
  • Run the updated code on a copy of the document that contains a tag on a line by itself.
  • Confirm that the line disappears when the tag’s value is null or an empty string.
  • Also test cases where the tag is inside a table cell or a paragraph with other text to ensure only the intended line is removed.
  1. If the problem persists
  • Double‑check the implementation of your TagReplacer for any additional logic that might interfere.
  • Ensure the tag really resides in a Paragraph node; if it is inside a table cell, you may need to remove the Cell or its containing Row instead.
  • Provide the exact Aspose.Words version you are using when asking for further help, as version‑specific behavior can matter.

These steps should eliminate the unwanted blank line when a tag’s value is empty.

Disclaimer: The answer is based on typical Aspose.Words behavior and common solutions found in the documentation. The supplied evidence did not contain a direct reference to this specific scenario, so please test the suggested code in your environment and adjust as needed.

:warning: I could not fully verify this explanation against the retrieved sources. Please double-check the behaviour in your environment and confirm the exact SDK/version.

SDK version unclear — refer to the official release notes before applying this guidance.

Hello @alexey.noskov , Can you please provide guide on this ?

Thanks,
Avinash

@Chilke You can check whether after replacement the target paragraph is empty and if so remove it. Please see the following test code and implementation of IReplacingCallback:

// Some items are commented out to demonstrate the concept works as expected.
Dictionary<string, string> tags = new Dictionary<string, string>()
{
    {"sepworker-notifyby","sepworker-notifyby"},
    {"worker-full-legal-name","worker-full-legal-name"},
    {"worker-employeeID","worker-employeeID"},
    {"worker-job-title","worker-job-title"},
    {"worker-supervisory-org","worker-supervisory-org"},
    {"worker-division","worker-division"},
    {"sepworker-last-day","sepworker-last-day"},
    {"TEXT-Availability","TEXT-Availability"},
    {"sev-PaymentAmount","sev-PaymentAmount"},
    {"sev-SevWeeks","sev-SevWeeks"},
    //{"Text-SepBonus-P1","Text-SepBonus-P1"},
    //{"sev-lumpSum","sev-lumpSum"},
    //{"Text-SepBonus-P2","Text-SepBonus-P2"},
    //{"Text-Outplacement","Text-Outplacement"},
    {"N","N"},
    {"Text-PaidPTO","Text-PaidPTO"},
    {"sev-AgreePeriod","sev-AgreePeriod"},
    {"sepworker-NoteJobTitle","sepworker-NoteJobTitle"},
    {"sepworker-NoteName","sepworker-NoteName"},
    {"sepworker-NoteAddress","sepworker-NoteAddress"},
    {"TEXT-RIFGroupOver40-ExhibitA","TEXT-RIFGroupOver40-ExhibitA"},
    {"TEXT-RIFGroupOver40-AddLang","TEXT-RIFGroupOver40-AddLang"},
    {"sepworker-Notifyplus45","sepworker-Notifyplus45"}
};

Document doc = new Document(@"C:\Temp\in.docx");

FindReplaceOptions options = new FindReplaceOptions
{
    ReplacingCallback = new TagReplacer(tags),
    Direction = FindReplaceDirection.Backward // <----- NOTE: replace direction is changed to backward.
};

doc.Range.Replace(new Regex(@"\{\{(.+?)\}\}"), "", options);

doc.Save(@"C:\Temp\out.docx");
private class TagReplacer : IReplacingCallback
{
    public TagReplacer(Dictionary<string, string> tags)
    {
        mTags = tags;
    }

    public ReplaceAction Replacing(ReplacingArgs args)
    {
        string key = args.Match.Groups[1].Value;
        string value = mTags.ContainsKey(key) ? mTags[key] : "";

        Document doc = (Document)args.MatchNode.Document;
        List<Run> matchedRuns = GetMatchedRuns(args);

        DocumentBuilder builder = new DocumentBuilder(doc);
        builder.MoveTo(matchedRuns.First());
        builder.Write(value);

        // Delete matched runs
        foreach (Run run in matchedRuns)
            run.Remove();

        // Check whether current paragraph is empty, if so remove it.
        Paragraph currentPara = builder.CurrentParagraph;
        if (string.IsNullOrEmpty(currentPara.ToString(SaveFormat.Text).Trim()) && 
            currentPara.GetChildNodes(NodeType.Shape, true).Count == 0)
            currentPara.Remove();

        // Signal to the replace engine to do nothing because we have already done all what we wanted.
        return ReplaceAction.Skip;
    }

    protected static List<Run> GetMatchedRuns(ReplacingArgs args)
    {
        // This is a Run node that contains either the beginning or the complete match.
        Node currentNode = args.MatchNode;

        // The first (and may be the only) run can contain text before the match, 
        // in this case it is necessary to split the run.
        if (args.MatchOffset > 0)
            currentNode = SplitRun((Run)currentNode, args.MatchOffset);

        // This array is used to store all nodes of the match for further deleting.
        List<Run> runs = new List<Run>();

        // Find all runs that contain parts of the match string.
        int remainingLength = args.Match.Value.Length;
        while (
            remainingLength > 0 &&
            currentNode != null &&
            currentNode.GetText().Length <= remainingLength)
        {
            runs.Add((Run)currentNode);
            remainingLength -= currentNode.GetText().Length;

            // Select the next Run node.
            // Have to loop because there could be other nodes such as BookmarkStart etc.
            do
            {
                currentNode = currentNode.NextSibling;
            } while (currentNode != null && currentNode.NodeType != NodeType.Run);
        }

        // Split the last run that contains the match if there is any text left.
        if (currentNode != null && remainingLength > 0)
        {
            SplitRun((Run)currentNode, remainingLength);
            runs.Add((Run)currentNode);
        }

        return runs;
    }

    private static Run SplitRun(Run run, int position)
    {
        Run afterRun = (Run)run.Clone(true);
        run.ParentNode.InsertAfter(afterRun, run);
        afterRun.Text = run.Text.Substring(position);
        run.Text = run.Text.Substring((0), (0) + (position));
        return afterRun;
    }

    private Dictionary<string, string> mTags;
}