TX Text Control .NET for WPF

Handling Untrusted Documents

Topic

A WPF application that lets users open any file they receive will sooner or later meet a document that is damaged, mislabeled, encrypted or full of links to places nobody checked. None of this has to reach your users unfiltered. TX Text Control lets your code limit the formats of the Open dialog box, leave out images and links, report files that don't load, decide what a clicked link opens and keep file references in a folder of your choice.

The samples go into MainWindow.xaml.cs of a window with a TextControl named textControl1, as in Create Your First WPF Application. Add using System.Diagnostics;, using System.IO; and using TXTextControl; to the top of the file.

Limit What the Open Dialog Box Offers

WPF.TextControl.Load, called with a StreamType and LoadSettings, shows the Open dialog box with the formats you combine with | as its file filters. This handler of an Open button accepts DOCX, RTF and PDF, loads neither images nor hypertext links and catches files that can't be loaded:

private void OpenButton_Click(object sender, RoutedEventArgs e)
{
    LoadSettings loadSettings = new LoadSettings()
    {
        // Don't load images and links of the document
        LoadImages = false,
        LoadHypertextLinks = false
    };

    try
    {
        // The Open dialog box offers only these formats
        if (textControl1.Load(StreamType.WordprocessingML | StreamType.RichTextFormat | StreamType.AdobePDF,
            loadSettings) == TXTextControl.WPF.DialogResult.OK)
        {
            Title = System.IO.Path.GetFileName(loadSettings.LoadedFile);
        }
    }
    catch (FilterException exception)
    {
        // For example WrongFileType, NotWellFormed or InvalidPassword
        MessageBox.Show(this, "The document can't be opened: " + exception.Reason);
    }
}

The code works as follows:

React to Files That Fail to Load

Reason tells why a file failed. With documents from outside, expect reasons such as WrongFileType for a file that isn't what its extension says, NotWellFormed or BadToken for a damaged file, FileTooBig, Encrypted for an encryption scheme TX Text Control doesn't support and InvalidPassword for a PDF that needs a user password; Read the Reason of a FilterException lists them all.

Put every Load of a document from outside into a try block, and tell the user what went wrong instead of letting the exception end the application. A PDF with a user password is loaded again with the password, as Load Password-Protected PDF Documents shows; the encryption TX Text Control can import is listed in Encrypted PDF Documents. Document Conversion helps with files that should load and don't.

Keep File References in One Folder

Images and link targets can be stored in a document as absolute file paths of the computer it was created on. ImageSearchPath sets a folder that replaces all absolute file paths used in the document, so that these resources are looked for in that folder only:

LoadSettings loadSettings = new LoadSettings()
{
    // Resolve the file paths of the document in this folder only
    ImageSearchPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Resources")
};

textControl1.Load("received.rtf", StreamType.RichTextFormat, loadSettings);

Remove Hidden Content Before a Document Moves On

Beyond its visible text, a document can hold comments, tracked changes that still contain deleted text, properties such as the author and, in a PDF, attached files. Before your application forwards a received document or saves a copy for someone else, remove what isn't meant for the recipient. The blog article Sanitizing Data in Document Pipelines: A Practical Approach with TX Text Control in C# .NET first reports what a document contains, then clears metadata, form field values, comments and tracked changes, and reports again for an audit. The collections it works with are described in Comments and Tracked Changes.

Redact Text

Covering a name with a black rectangle hides it only on screen: the name stays in the document, where a search finds it and a copy brings it back. Real redaction deletes the text and puts a black box of the same size where it was. The following handler redacts the text the user has selected, one line at a time. Add using System.Collections.Generic; and using System.Text; to the file as well:

private void RedactButton_Click(object sender, RoutedEventArgs e)
{
    int start = textControl1.Selection.Start;
    int end = start + textControl1.Selection.Length;

    // One black box for each line of the selection
    List<(int Start, int Length, System.Drawing.Rectangle Bounds)> boxes =
        new List<(int, int, System.Drawing.Rectangle)>();
    int boxStart = -1, boxEnd = -1, boxLine = -1;
    System.Drawing.Rectangle boxBounds = System.Drawing.Rectangle.Empty;

    for (int position = start; position <= end; position++)
    {
        // TextChars counts from 1, text positions from 0
        TextChar textChar = position < end ? textControl1.TextChars[position + 1] : null;
        TXTextControl.Line line = position < end ? textControl1.Lines.GetItem(position) : null;

        // Close the box at the end of the selection, at a break and at a new line
        if (boxStart >= 0 && (textChar == null || char.IsControl(textChar.Char) || line.Number != boxLine))
        {
            boxes.Add((boxStart, boxEnd - boxStart + 1, boxBounds));
            boxStart = -1;
        }

        // Spaces and breaks don't start a box
        if (textChar == null || char.IsWhiteSpace(textChar.Char) || char.IsControl(textChar.Char))
        {
            continue;
        }

        // From the top of the character to the baseline of its line, in twips
        System.Drawing.Rectangle bounds = textChar.Bounds;
        bounds.Height = line.Baseline - bounds.Top;

        if (boxStart < 0)
        {
            boxStart = position;
            boxLine = line.Number;
            boxBounds = bounds;
        }
        else
        {
            boxBounds = System.Drawing.Rectangle.Union(boxBounds, bounds);
        }

        boxEnd = position;
    }

    // Replace the text from the last box to the first, so that the positions stay valid
    for (int i = boxes.Count - 1; i >= 0; i--)
    {
        textControl1.Select(boxes[i].Start, boxes[i].Length);
        textControl1.Selection.Text = "";

        byte[] svg = CreateBlackBox(boxes[i].Bounds.Size);

        using (MemoryStream stream = new MemoryStream(svg, 0, svg.Length, false, true))
        {
            // -1 inserts the image at the input position, where the text was
            textControl1.Images.Add(new TXTextControl.Image(stream: stream), -1);
        }
    }
}

private static byte[] CreateBlackBox(System.Drawing.Size size)
{
    // 20 twips are one point
    string svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" " +
        $"width=\"{size.Width / 20f}pt\" height=\"{size.Height / 20f}pt\">" +
        "<rect width=\"100%\" height=\"100%\" fill=\"black\" /></svg>";

    return Encoding.UTF8.GetBytes(svg);
}

The code works as follows:

  • Selection.Start and Selection.Length give the selected range. TextChars holds the characters of the main text, counted from 1: text position 0 is TextChars[1].
  • TextChar.Bounds is a System.Drawing.Rectangle in twips, measured from the upper left corner of the document; the full type names System.Drawing.Rectangle and TXTextControl.Line avoid a clash with the Rectangle and Line shapes of System.Windows.Shapes, which the Visual Studio template imports. LineCollection.GetItem returns the Line of a position: a new Number starts a new box, and the Baseline is the bottom of the box.
  • Selection.Text set to an empty string deletes the text of a box, and ImageCollection.Add with -1 inserts the SVG image inline at that position.
  • CreateBlackBox gives the SVG its size in points, a twentieth of the twips. The Image constructor gets the stream as the named argument stream:, because its overload for a System.Drawing.Image would otherwise need the System.Drawing.Common library, as Images, Text Frames, and Shapes explains.

In the editor, black boxes take the place of the selected names and addresses:

A contract in the editor with control characters shown, in which black boxes replace the names and addresses of the parties.

Save the result as PDF, for example with textControl1.Save("contract-redacted.pdf", StreamType.AdobePDF), before it leaves your application. The text behind the boxes isn't in the PDF, so nobody can select, copy or find it:

A nondisclosure agreement opened in Adobe Acrobat Reader, with the names and addresses of the parties replaced by black boxes.

The handler follows the technique of the blog article True Document and PDF Text Redaction in .NET C#. In its sample, the user marks several passages as SubTextPart objects named tx_redact first, and all of them are redacted together; the repository TextControl/Redaction holds its Redaction class, written for ServerTextControl. A Rule-Based PHI and PII Risk Scanner for Documents Using C# .NET helps to find what to redact: it checks the text of a document against regular expressions defined in JSON rules and reports each match with its location.

Next Steps